npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@extk/expressive

v0.8.0

Published

Production-ready Express 5 toolkit with auto-generated OpenAPI docs, structured error handling, and logging

Readme


Table of Contents


What is this?

@extk/expressive is an opinionated toolkit for Express 5 that wires up the things every API needs but nobody wants to set up from scratch:

  • Auto-generated OpenAPI 3.1 docs from your route definitions
  • Structured error handling with typed error classes and consistent JSON responses
  • Bring-your-own logger — any object with info/warn/error/debug works
  • Security defaults via Helmet, safe query parsing, and morgan request logging
  • Standardized responses (ApiResponse / ApiErrorResponse) across your entire API

You write routes. Expressive handles the plumbing.

Install

npm install @extk/expressive express

Requires Node.js >= 22 and Express 5.

Quick Start

import express from 'express';
import { bootstrap, ApiResponse, NotFoundError, SWG } from '@extk/expressive';

// 1. Bootstrap with a logger (bring your own)
const {
  expressiveServer,
  expressiveRouter,
  swaggerBuilder,
  notFoundMiddleware,
  getErrorHandlerMiddleware,
  silently,
} = bootstrap({
  logger: console, // any object with info/warn/error/debug
});

// 2. Configure swagger metadata
const swaggerDoc = swaggerBuilder()
  .withInfo({ title: 'My API', version: '1.0.0' })
  .withServers([{ url: 'http://localhost:3000' }])
  .build();

// 3. Define routes — they auto-register in the OpenAPI spec
const { router, addRoute } = expressiveRouter({
  oapi: { tags: ['Users'] },
});

addRoute(
  {
    method: 'get',
    path: '/users/:id',
    oapi: {
      summary: 'Get user by ID',
      responses: { 200: { description: 'User found' } },
    },
  },
  async (req, res) => {
    const user = await findUser(req.params.id);
    if (!user) throw new NotFoundError('User not found');
    res.json(new ApiResponse(user));
  },
);

[!IMPORTANT] Method call order on ServerBuilder matters — middleware is registered in the order you chain it.

// 4. Build the Express app
const app = expressiveServer()
  .withHelmet()
  .withQs()
  .withMorgan()
  .withRoutes(router)
  .withSwagger({ path: '/api-docs', config: swaggerDoc })
  .with((app) => {
    app.use(getErrorHandlerMiddleware());
    app.use(notFoundMiddleware);
  })
  .build();

app.listen(3000);

Visit http://localhost:3000/api-docs to see the auto-generated Swagger UI.

Error Handling

Throw typed errors anywhere in your handlers. The error middleware catches them and returns a consistent JSON response.

import { NotFoundError, BadRequestError, ForbiddenError } from '@extk/expressive';

// Throws -> { status: "error", message: "User not found", errorCode: "NOT_FOUND" }
throw new NotFoundError('User not found');

// Attach extra data (e.g. validation details)
throw new BadRequestError('Invalid input').setData({ field: 'email', issue: 'required' });

Built-in error classes:

| Class | Status | Code | | ------------------------ | ------ | ------------------------ | | BadRequestError | 400 | BAD_REQUEST | | SchemaValidationError | 400 | SCHEMA_VALIDATION_ERROR| | FileTooBigError | 400 | FILE_TOO_BIG | | InvalidFileTypeError | 400 | INVALID_FILE_TYPE | | InvalidCredentialsError| 401 | INVALID_CREDENTIALS | | TokenExpiredError | 401 | TOKEN_EXPIRED | | UserUnauthorizedError | 401 | USER_UNAUTHORIZED | | ForbiddenError | 403 | FORBIDDEN | | NotFoundError | 404 | NOT_FOUND | | DuplicateError | 409 | DUPLICATE_ENTRY | | TooManyRequestsError | 429 | TOO_MANY_REQUESTS | | InternalError | 500 | INTERNAL_ERROR |

You can also map external errors (e.g. Zod) via getErrorHandlerMiddleware:

app.use(getErrorHandlerMiddleware((err) => {
  if (err.name === 'ZodError') {
    return new SchemaValidationError('Validation failed').setData(err.issues);
  }
  return null; // let the default handler deal with it
}));

OpenAPI / Swagger

Routes registered with addRoute are automatically added to the OpenAPI spec. Use the SWG helper to define parameters and schemas:

addRoute(
  {
    method: 'get',
    path: '/posts',
    oapi: {
      summary: 'List posts',
      queryParameters: [
        SWG.queryParam('page', { type: 'integer' }, false, 'Page number'),
        SWG.queryParam('limit', { type: 'integer' }, false, 'Items per page'),
      ],
      responses: {
        200: { description: 'List of posts', ...SWG.jsonSchemaRef('PostList') },
      },
    },
  },
  listPostsHandler,
);

File uploads

Use SWG.singleFileSchema for a single file field, or SWG.formDataSchema for a custom multipart body:

// single file — field name defaults to 'file', required defaults to true
addRoute({
  method: 'post',
  path: '/upload',
  oapi: {
    requestBody: SWG.singleFileSchema(),
    // requestBody: SWG.singleFileSchema('avatar', true),
  },
}, handler);

// custom multipart schema with multiple fields
addRoute({
  method: 'post',
  path: '/upload/rich',
  oapi: {
    requestBody: SWG.formDataSchema({
      type: 'object',
      properties: {
        file: { type: 'string', format: 'binary' },
        title: { type: 'string' },
      },
      required: ['file'],
    }),
  },
}, handler);

Configure security schemes via the swagger builder:

swaggerBuilder()
  .withSecuritySchemes({
    BearerAuth: SWG.securitySchemes.BearerAuth(),
  })
  .withDefaultSecurity([SWG.security('BearerAuth')]);

Using Zod schemas for OpenAPI

You can use Zod's global registry to define your schemas once and have them appear in both validation and OpenAPI docs automatically.

1. Define schemas with .meta({ id }) to register them globally:

// schema/userSchema.ts
import z from 'zod';

export const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  firstName: z.string(),
  lastName: z.string(),
  role: z.enum(['admin', 'user']),
}).meta({ id: 'createUser' });

export const patchUserSchema = createUserSchema.partial().meta({ id: 'patchUser' });

export const loginSchema = z.object({
  username: z.string().email(),
  password: z.string(),
}).meta({ id: 'login' });

2. Pass all registered schemas to the swagger builder:

import z from 'zod';

const app = expressiveServer()
  .withHelmet()
  .withQs()
  .withMorgan()
  .withSwagger({
    config: swaggerBuilder()
      .withInfo({ title: 'My API' })
      .withServers([{ url: 'http://localhost:3000/api' }])
      .withSchemas(z.toJSONSchema(z.globalRegistry).schemas) // all Zod schemas -> OpenAPI
      .withSecuritySchemes({ auth: SWG.securitySchemes.BearerAuth() })
      .withDefaultSecurity([SWG.security('auth')])
      .get(),
  })
  .build();

3. Reference them in routes with SWG.jsonSchemaRef:

addRoute({
  method: 'post',
  path: '/user',
  oapi: {
    summary: 'Create a user',
    requestBody: SWG.jsonSchemaRef('createUser'),
  },
}, async (req, res) => {
  const body = createUserSchema.parse(req.body); // validate with the same schema
  const result = await userController.createUser(body);
  res.status(201).json(new ApiResponse(result));
});

addRoute({
  method: 'patch',
  path: '/user/:id',
  oapi: {
    summary: 'Update a user',
    requestBody: SWG.jsonSchemaRef('patchUser'),
  },
}, async (req, res) => {
  const id = parseIdOrFail(req.params.id);
  const body = patchUserSchema.parse(req.body);
  const result = await userController.updateUser(id, body);
  res.json(new ApiResponse(result));
});

This way your Zod schemas serve as the single source of truth for both runtime validation and API documentation.

Middleware

All middleware factories are returned from bootstrap().

getApiErrorHandlerMiddleware(errorMapper?)

Express error handler for API routes. Catches ApiError subclasses, handles malformed JSON, and falls back to InternalError for unknown errors. Pass an optional errorMapper to map third-party errors (e.g. Zod, Multer) to typed ApiError instances.

app.use(getApiErrorHandlerMiddleware((err) => {
  if (err.name === 'ZodError') return new SchemaValidationError('Validation failed').setData(err.issues);
  return null;
}));

getApiNotFoundMiddleware()

Returns a JSON 404 response for unmatched API routes.

app.use(getApiNotFoundMiddleware());
// { status: 'error', message: 'GET /unknown not found', errorCode: 'NOT_FOUND' }

getGlobalNotFoundMiddleware(content?)

Returns a plain-text 404. Useful as the last catch-all for non-API routes. Defaults to ¯\_(ツ)_/¯.

app.use(getGlobalNotFoundMiddleware());
app.use(getGlobalNotFoundMiddleware('Not found'));

getGlobalErrorHandlerMiddleware()

Minimal error handler that logs and responds with a plain-text 500. Use this outside of API route groups where JSON responses aren't expected.

getBasicAuthMiddleware(basicAuthBase64, realm?)

Protects a route or the Swagger UI with HTTP Basic auth. Accepts a pre-encoded base64 user:password string.

expressiveServer()
  .withSwagger(
    { config: swaggerDoc },
    getBasicAuthMiddleware(process.env.SWAGGER_AUTH!, 'API Docs'),
  )

silently

silently runs a function — sync or async — and suppresses any errors it throws. Errors are forwarded to alertHandler (if configured) or logged via the container logger.

// fire-and-forget without crashing the process
silently(() => sendAnalyticsEvent(req));
silently(async () => await notifySlack('Server started'));

Logging

Expressive does not bundle a logger. Instead, bootstrap accepts any object that satisfies the Logger interface:

export type Logger = {
  info(message: string, ...args: any[]): void;
  error(message: string | Error | unknown, ...args: any[]): void;
  warn(message: string, ...args: any[]): void;
  debug(message: string, ...args: any[]): void;
};

This means you can pass console directly, or plug in any logging library (Winston, Pino, etc.):

bootstrap({ logger: console });

The @extk/logger-cloudwatch package from the same org is a drop-in fit:

import { getCloudwatchLogger, getConsoleLogger } from '@extk/logger-cloudwatch';

// development
bootstrap({ logger: getConsoleLogger() });

// production — streams structured JSON logs to AWS CloudWatch
bootstrap({
  logger: getCloudwatchLogger({
    aws: {
      region: 'us-east-1',
      logGroup: '/my-app/production',
      credentials: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
      },
    },
  }),
});

Utilities

import {
  slugify,
  parseDefaultPagination,
  parseIdOrFail,
  getEnvVar,
  isDev,
  isProd,
} from '@extk/expressive';

slugify('Hello World!');           // 'hello-world!'
parseDefaultPagination({ page: '2', limit: '25' }); // { offset: 25, limit: 25 }
parseIdOrFail('42');               // 42 (throws on invalid)
getEnvVar('DATABASE_URL');         // string (throws if missing)
isDev();                           // true when ENV !== 'prod'

API Response Format

All responses follow a consistent shape:

// Success
{ "status": "ok", "result": { /* ... */ } }

// Error
{ "status": "error", "message": "Not found", "errorCode": "NOT_FOUND", "errors": null }

License

ISC