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

@smwb/srv

v0.1.13

Published

HTTP framework for Node.js 24+ with DI, lazy-loaded controllers and WebSocket gateways, guards, contexts, middleware, interceptors and Zod validation.

Readme

@smwb/srv

HTTP framework for Node.js 24+ with DI, lazy-loaded controllers and WebSocket gateways, guards, contexts, middleware, interceptors and Zod validation.

npm package

Single entry point — @smwb/srv, one build (dist/lib/). The server runs on node:http, so it works identically under Node.js (prod) and Bun (fast dev). There is no separate Bun build: Bun runs the same code via its node:http compatibility.

npm install @smwb/srv
import {
  appBindingKey,
  container,
  expandControllerRoutes,
  registerCors,
  registerFrameworkBindings,
  setupDiLogger,
  startApplication,
  WebSocketRouter,
} from '@smwb/srv';

const routes = expandControllerRoutes(/* ...defineController(...) */);

registerFrameworkBindings({
  webSocketRouter: WebSocketRouter,
  routes,
  middlewares: [registerCors()],
});

setupDiLogger();
await startApplication(container.resolve(appBindingKey));

DI primitives (container, BindingKey, Inject, Injectable) are re-exported from @smwb/srv, so an app does not install its own @smwb/di — the container is shared with the framework.

A complete working app lives in example/ (see "Example app").

Create a new app

Scaffold a starter project with one command:

npx -p @smwb/srv create-smwb-srv my-api

With an explicit version:

npx -p @smwb/srv@latest create-smwb-srv my-api

Options:

  • --no-install — copy files only, skip npm install
  • -h, --help — show usage

The generator creates a minimal app with GET /health, TypeScript config, and scripts for dev (bun --watch) and prod (tsc + node).

cd my-api
npm run dev

Runtime: one server, Bun for dev

There is a single server — on node:http (src/runtime/launcher.ts + ws for WebSocket). No Bun-specific implementation (Bun.serve) and no Request ↔ IncomingMessage bridge.

  • prod — Node.js: run the built app natively.
  • dev — Bun: bun --watch src/main.ts executes TS directly, no build; the server starts via Bun's node:http compatibility. Dev speed comes from the Bun runtime itself (TS without a build, fast start/watch), not from a separate HTTP server.

Bonus: dev and prod run the exact same server code — no "works in dev, breaks in prod" bugs.

src/runtime/            # server implementation (part of the library)
  launcher.ts           # HTTP/HTTPS server (node:http)
  server.create.ts
  server.type.ts
  websocket.router.ts   # WebSocket via ws
  start.ts              # startApplication(app)

Lazy modules (controllers, gateways, guards, contexts, schemas) are loaded via import thunks () => import('./x.js') — the path resolves at the call site, so it works in any app (no base URLs relative to the library).

Requirements

  • Node.js >= 24
  • npm

Install

npm install
npm run build

Quick start

  1. Create a controller (default export of the class):
// controllers/items.controller.ts
import { Controller, type ControllerActionInput } from '@smwb/srv';

export default class ItemsController extends Controller {
  list(_input: ControllerActionInput): { items: string[] } {
    return { items: [] };
  }
}
  1. Describe the routes — the module is provided by the load import thunk:
// controllers/items.routes.ts
import { defineController } from '@smwb/srv';
import type ItemsController from './items.controller.js';

export const itemsRoutes = defineController<InstanceType<typeof ItemsController>>({
  name: 'ItemsController',
  load: () => import('./items.controller.js'),
  routes: [
    {
      method: 'GET',
      path: '/items',
      action: 'list',
      response: () => import('./listResponse.schema.js'),
    },
  ],
});
  1. Register the routes and start the app (full example in example/src/main.ts):
import {
  appBindingKey,
  container,
  expandControllerRoutes,
  registerCors,
  registerFrameworkBindings,
  setupDiLogger,
  startApplication,
  WebSocketRouter,
} from '@smwb/srv';
import { itemsRoutes } from './controllers/items.routes.js';

registerFrameworkBindings({
  webSocketRouter: WebSocketRouter,
  routes: expandControllerRoutes(itemsRoutes),
  middlewares: [registerCors()],
});

setupDiLogger();
await startApplication(container.resolve(appBindingKey));
  1. Run:
# dev (Bun runs TS directly, no build)
bun --watch src/main.ts

# prod (Node)
tsc && node dist/main.js

By default the server listens on HTTP at port 3000 (PORT).

HTTPS

SERVER_PROTOCOL=https \
HTTPS_CERT=/path/to/cert.pem \
HTTPS_KEY=/path/to/key.pem \
node dist/main.js

Optional: HTTPS_CA — CA certificate.

Controllers and actions

A controller is a class with action methods. Action signature:

(input, request, response) => unknown | void | Promise<unknown | void>
  • input — the result of validation and enrichment (params, query, body, context)
  • if the route has a response schema, the action returns data and the framework serializes JSON
  • if there is no response schema, the action writes to response directly

Root prefix (root)

An optional controller-level root is prepended to all of its routes. Without root the address equals the method path; with root: 'admin' it becomes /admin + path.

defineController<InstanceType<typeof AdminController>>({
  name: 'AdminController',
  load: () => import('./admin.controller.js'),
  root: 'admin', // or '/admin', '/admin/' — extra slashes are normalized
  routes: [
    { method: 'GET', path: '/users', action: 'list' }, // → /admin/users
    { method: 'GET', path: '/', action: 'index' }, //      → /admin
  ],
});

Global route prefix (routePrefix)

An app-wide prefix prepended to every HTTP and WebSocket route — on top of any controller root. Set it on registerFrameworkBindings, or via the ROUTE_PREFIX env var (the explicit value wins). It is normalized like root (api, /api, /api/ are equivalent), and '' means no prefix.

registerFrameworkBindings({
  webSocketRouter: WebSocketRouter,
  routes,
  routePrefix: '/api', // GET /admin/users → GET /api/admin/users
});

Requests that don't fall under the prefix get a 404. The prefix is held in the DI Config (config.routePrefix) and bound under routePrefixBindingKey.

Lazy-load

Controllers, gateways, guards, contexts and Zod schemas are loaded dynamically on first access — via the import thunk () => import('./x.js'). The path resolves at the call site, so it works in any app; rollup/bundlers code-split it.

Validation (Zod)

A schema is a default export of a Zod object, wired by an import thunk:

// controllers/idParams.schema.ts
import { z } from 'zod';

export default z.object({
  id: z.string(),
});
{
  method: 'GET',
  path: '/items/:id',
  action: 'get',
  params: () => import('./idParams.schema.js'),
  query: () => import('./listQuery.schema.js'),
  body: () => import('./createBody.schema.js'),
  response: () => import('./itemResponse.schema.js'),
}

Rules:

  • without a params schema, path params are not allowed
  • without a query schema, query params are not allowed
  • without a body schema, a request body is not allowed
  • validation errors → 400 with { error: string }

Guards

A guard checks access before the action runs (default export + thunk registration):

// guards/api-key.guard.ts
import { Guard, type GuardContext } from '@smwb/srv';

export default class ApiKeyGuard extends Guard {
  override canActivate(context: GuardContext): boolean {
    return context.request.headers['x-api-key'] === process.env['API_KEY'];
  }
}

// guards/api-key.guard.binding.ts
import { bindGuard } from '@smwb/srv';
import type ApiKeyGuard from './api-key.guard.js';

export const apiKeyGuardBindingKey = bindGuard<InstanceType<typeof ApiKeyGuard>>({
  key: Symbol('ApiKeyGuard'),
  load: () => import('./api-key.guard.js'),
});

Attach at the controller or route level via guards: [apiKeyGuardBindingKey]. On rejection → 403 with { error: 'Forbidden' }.

Contexts

A context enriches input before guards and the action (registered with bindContext):

import { Context, type ContextInputPatch, type RequestContext } from '@smwb/srv';

export default class UserContext extends Context {
  override enrich(context: RequestContext): ContextInputPatch {
    const userId = context.request.headers['x-user-id'];
    if (typeof userId !== 'string') return {};
    return { context: { user: { id: userId } } };
  }
}

Multiple contexts run in order; context is shallow-merged.

Middleware

The app binds the middleware class itself and passes an ordered list of keys to registerFrameworkBindings({ middlewares }). CORS is provided by the framework via registerCors():

const cors = registerCors();
container.bind(requestLogMiddlewareBindingKey).toClass(RequestLogMiddleware);

registerFrameworkBindings({
  webSocketRouter: WebSocketRouter,
  routes,
  middlewares: [cors, requestLogMiddlewareBindingKey], // order = execution order
});

A middleware receives { request, response, route } and calls next().

CORS

Configured via env:

| Variable | Default | | ---------------------- | -------------------------------------------- | | CORS_ORIGIN | * | | CORS_METHODS | GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS | | CORS_ALLOWED_HEADERS | echoed from preflight | | CORS_EXPOSED_HEADERS | — | | CORS_CREDENTIALS | — | | CORS_MAX_AGE | 86400 |

Or programmatically:

container.bind(corsOptionsBindingKey).toConstant({
  origin: ['https://app.example.com'],
  credentials: true,
});

Preflight (OPTIONS) is handled by the middleware before routing.

Interceptors

Global interceptors wrap the action:

  • before(context) — before the action
  • after(context) — after the action, may change the result

Rate limiting

Rate limiting is a guard — there is no special route property. Build one with the rateLimitGuard(options) factory and drop it into a route's or controller's guards. It uses an in-memory fixed-window counter keyed by client (no extra dependency).

import { defineController, rateLimitGuard } from '@smwb/srv';

// at most 2 requests per minute per client
const pingThrottle = rateLimitGuard({ limit: 2, windowMs: 60_000 });

defineController({
  name: 'StatusController',
  load: () => import('./status.controller.js'),
  guards: [rateLimitGuard({ limit: 100, windowMs: 60_000 })], // controller-wide default
  routes: [{ method: 'GET', path: '/ping', action: 'ping', guards: [pingThrottle] }],
});

Prefer a reusable, self-describing class? Extend RateLimitGuard and register it with bindGuard like any other guard, or instantiate it directly:

import { RateLimitGuard } from '@smwb/srv';

export default class PingThrottle extends RateLimitGuard {
  constructor() {
    super({ limit: 2, windowMs: 60_000 });
  }
}

| Option | Description | | ------------ | ------------------------------------------------------------------------------------- | | limit | Max requests per window (required) | | windowMs | Window length in ms (required) | | bucket | Counter name; routes sharing one bucket share a counter. Default: ${method} ${path} | | identify | (request) => string client key. Default: remote address | | statusCode | Status when exceeded. Default: 429 | | message | Body message when exceeded. Default: Too Many Requests |

Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset; a rejection adds Retry-After and short-circuits before the action runs. clearRateLimitState() resets the store (handy in tests).

File uploads

File upload is a body — there is no special route property. Set the route's body to uploadBody(options) to parse a multipart/form-data request. Parsed files arrive on input.files; text fields populate input.body (validated by the optional fields schema). The multipart parser is built in — no dependency.

import { uploadBody } from '@smwb/srv';

{
  method: 'POST',
  path: '/avatar',
  action: 'uploadAvatar',
  body: uploadBody({
    fields: () => import('./avatarBody.schema.js'),  // validates text fields → input.body
    required: true,
    maxFiles: 1,
    maxFileSize: 5 * 1024 * 1024,
    allowedMimeTypes: ['image/png', 'image/jpeg'],
    allowedExtensions: ['.png', '.jpg', '.jpeg'],
  }),
  response: () => import('./avatarResponse.schema.js'),
}

Or extend UploadBody to bake the constraints into a named class:

import { UploadBody } from '@smwb/srv';

export default class AvatarUploadBody extends UploadBody {
  constructor() {
    super({ fields: () => import('./avatarBody.schema.js'), required: true, maxFiles: 1 });
  }
}
// route: body: new AvatarUploadBody()
import { Controller, type UploadedFile } from '@smwb/srv';

type UploadAvatarInput = { body: { title: string }; files: UploadedFile[] };

export default class UploadController extends Controller {
  uploadAvatar(input: UploadAvatarInput) {
    const file = input.files[0]!; // guaranteed by `required: true`
    return { status: 'ok', filename: file.filename, size: file.size };
  }
}

| Option | Description | Rejection | | ------------------- | ---------------------------------------------------------- | --------- | | fields | Zod schema (thunk) for the non-file form fields | 400 | | required | Require at least one file | 400 | | maxFiles | Max number of files | 400 | | field | Allowed file field name(s) | 400 | | maxFileSize | Max bytes per file | 413 | | maxTotalSize | Max combined bytes (also pre-checked via Content-Length) | 413 | | allowedMimeTypes | Whitelisted MIME types | 415 | | allowedExtensions | Whitelisted extensions (lower-case, with the dot) | 415 |

Each UploadedFile is { field, filename, mimeType, size, data: Buffer }.

WebSocket

WebSocket works on the same HTTP/HTTPS server via upgrade. A gateway is the analog of a controller.

ws is an optional peer dependency — it is loaded lazily and only when WebSocket routes exist. Apps without gateways don't need it; apps with gateways install it:

npm install ws

Messages are type-safe: the gateway action receives a TypedWebSocket<TIncoming, TOutgoing> instead of a raw socket. send only accepts TOutgoing (JSON-serialized for you), and onMessage delivers TIncoming after the inbound message is JSON-parsed and validated against the route's message schema. Invalid messages go to onError instead of the handler.

  1. Define the inbound message schema (single source of truth for runtime + types):
// gateways/chatMessage.schema.ts
import { z } from 'zod';

const chatMessageSchema = z.object({ text: z.string().min(1) });
export type ChatMessage = z.infer<typeof chatMessageSchema>;
export default chatMessageSchema;
  1. Create a gateway. send/onMessage are typed; reach the underlying socket via socket.raw for binary frames or custom events:
// gateways/chat.gateway.ts
import { WebSocketGateway, type TypedWebSocket, type WebSocketActionInput } from '@smwb/srv';
import type { ChatMessage } from './chatMessage.schema.js';
import type { PingMessage } from './pingMessage.schema.js';

type ServerMessage = { connected: true } | { echo: string } | { pong: true } | { error: string };
type Events = { ping: PingMessage }; // custom inbound message types

export default class ChatGateway extends WebSocketGateway {
  connect(
    _input: WebSocketActionInput,
    socket: TypedWebSocket<ChatMessage, ServerMessage, Events>,
  ): void {
    socket.send({ connected: true }); // ✗ socket.send({ oops: 1 }) is a type error
    socket.onMessage((message) => socket.send({ echo: message.text })); // message: ChatMessage
    socket.on('ping', () => socket.send({ pong: true })); // custom typed event
    socket.onError(() => socket.send({ error: 'invalid message' }));
    socket.onClose((code, reason) => console.log('closed', code, reason));
  }
}

Besides the catch-all message, a route can declare custom message types in events — a map of discriminator value → schema. The framework reads the type field (configurable via discriminator) of each inbound message, validates it against the matching schema, and dispatches to socket.on(type, handler). Messages without a known type fall through to onMessage.

  1. Describe the WS routes (modules are load thunks):
// gateways/chat.routes.ts
import { defineWebSocketGateway } from '@smwb/srv';
import type ChatGateway from './chat.gateway.js';
import { userContextBindingKey } from '../contexts/user.context.binding.js';

export const chatWebSocketRoutes = defineWebSocketGateway<InstanceType<typeof ChatGateway>>({
  name: 'ChatGateway',
  root: 'ws', // optional gateway root, prepended to every route path → /ws/chat
  load: () => import('./chat.gateway.js'),
  contexts: [userContextBindingKey],
  routes: [
    {
      path: '/chat',
      action: 'connect',
      message: () => import('./chatMessage.schema.js'), // validates inbound messages
    },
  ],
});
  1. Pass websocketRoutes to registerFrameworkBindings:
import { expandWebSocketRoutes } from '@smwb/srv';
import { chatWebSocketRoutes } from './gateways/chat.routes.js';

const websocketRoutes = expandWebSocketRoutes(chatWebSocketRoutes);

Action signature (the socket is a TypedWebSocket<TIncoming, TOutgoing>):

(input, socket, request) => void | Promise<void>

params, query, message (Zod), guards and contexts are supported just like for HTTP routes, as is the gateway-level root (analogous to a controller root; the global routePrefix applies on top). On upgrade rejection the response is 404, 400, or 403. Without a message schema, onMessage delivers the raw JSON-parsed value (typed by TIncoming).

Clients connect to ws://localhost:3000/ws/chat (or wss:// over HTTPS).

Cookies

DI-free utilities:

import { getCookie, parseCookies, setCookie, clearCookie } from '@smwb/srv';

const session = getCookie(request, 'session');
setCookie(response, 'session', 'value', { httpOnly: true, sameSite: 'Lax' });
clearCookie(response, 'session');

Request pipeline

middleware → match route → lazy schemas → validate input (incl. uploads)
  → context enrichment → guards (incl. rate limit) → interceptors → action → response validation

Response codes:

  • 404 — path not found
  • 405 — method not supported for the path
  • 400 — validation error (incl. missing/too-many files, non-multipart upload)
  • 403 — guard rejected the request
  • 413 — upload exceeds a size limit
  • 415 — upload has a disallowed type/extension
  • 429 — rate limit guard rejected the request
  • 500 — action error or response validation error

Environment variables

| Variable | Description | Default | | ----------------------- | --------------------------------- | ------------------ | | PORT | Server port | 3000 | | SERVER_PROTOCOL | http or https | http | | HTTPS_CERT | Path to the TLS certificate | — | | HTTPS_KEY | Path to the TLS key | — | | HTTPS_CA | Path to the CA certificate | — | | ROUTE_IDLE_TIMEOUT_MS | Lazy controller eviction | see app.const.ts | | ROUTE_PREFIX | Global prefix for all routes | — (none) | | CORS_* | CORS settings | see above | | API_KEY | Key for the example ApiKeyGuard | — |

Library scripts

npm run build        # build library → dist/lib + CLI → dist/create
npm run typecheck    # type-check
npm run test         # unit/integration tests
npm run test:coverage
npm run format       # prettier

Publishing

Release is automated in CI (.sourcecraft/ci.yaml) on every push to main:

  1. npm run typecheck, npm run build, npm test
  2. if package.json version is not yet on npm — npm publish --access public and git tag v{version}

To publish a new release, bump version in package.json and push to main. If the version is already published, CI skips publish and only runs tests.

The npm package includes the library (dist/lib), scaffold CLI (dist/create, bin create-smwb-srv) and app template (templates/). prepublishOnly runs npm run build before publish.

Project structure

src/                # library (framework)
  create/           # npx scaffold CLI (create-smwb-srv)
  controller/       # defineController, validate, expand routes
  router/           # routing, path matching
  middleware/       # middleware chain + CorsMiddleware
  interceptor/      # interceptors
  guard/            # guards (base + bindGuard)
  context/          # contexts (base + bindContext)
  schema/           # lazy Zod schema loading
  rate-limit/       # per-route/controller rate limiting
  upload/           # multipart/form-data parsing + constraints
  cookie/  cors/  server/
  websocket/        # WebSocket gateways and upgrade routing
  runtime/          # node:http server + startApplication
  library/          # public API (index.ts, shared.ts)
templates/          # app scaffold copied by create-smwb-srv
example/            # standalone app that consumes @smwb/srv as a library
tests/              # core Vitest tests (+ tests/__fixtures__)

Conventions

  • types — *.type.ts
  • constants — *.const.ts
  • controllers/gateways/guards/contexts — default export of the class
  • routes — *.routes.ts (via defineController / defineWebSocketGateway)
  • Zod schemas — *.schema.ts, default export, lazy via () => import(...)

Example app

example/ is a standalone app that installs @smwb/srv as a dependency ("@smwb/srv": "file:..") and imports everything from '@smwb/srv'. It demonstrates controllers (HTTP + response schemas), a guard, a context, global middleware and interceptor, CORS, the global route prefix (/api), the controller root prefix (/status), a rate-limited endpoint (/api/status/ping), a file upload with constraints (POST /api/uploads/avatar), and a WebSocket gateway (/api/ws/echo). All routes are served under /api.

cd example
npm install
npm run dev          # Bun: bun --watch src/main.ts (TS directly)
# or prod on Node:
npm run build && API_KEY=secret npm start

Testing

The library itself

Tests run on Vitest (vitest.config.ts) and live in tests/. They cover the core: router (incl. the global prefix), validation, guards, contexts, middleware, cors, cookies, schema loading, websocket, rate limiting, and file uploads (incl. negative cases). Fixtures live in tests/__fixtures__. Coverage (@vitest/coverage-v8) is ~99% statements / ~95% branches.

npm test
npm run test:coverage
npm run typecheck      # also type-checks the tests (tsconfig.test.json)

Vitest transforms with esbuild and does not type-check; npm run typecheck type-checks src and the test suite, and runs in CI.

An app built with @smwb/srv

Everything you write is a plain class, so most tests need no framework wiring — just instantiate and assert. Pass fakes for request/response/socket and inject collaborators (e.g. a fake Logger) directly through the constructor:

// controller — return-style action
expect(new ItemsController().get({ params: { id: '1' } })).toEqual({ id: '1' });

// guard — exercise the GuardContext
const ctx = { request: { headers: { 'x-api-key': 'secret' } } } as unknown as GuardContext;
expect(new ApiKeyGuard().canActivate(ctx)).toBe(true);

// middleware — inject a fake Logger, assert next() is called
const next = vi.fn();
await new RequestLogMiddleware({ log: vi.fn() } as unknown as Logger).handle(ctx, next);
expect(next).toHaveBeenCalled();

For an end-to-end test, bootstrap with registerFrameworkBindings, start the server, and hit it with fetch:

process.env.PORT = '3971';
registerFrameworkBindings({
  webSocketRouter: WebSocketRouter,
  routes,
  middlewares: [registerCors()],
});
const app = container.resolve(appBindingKey);
await startApplication(app);
const res = await fetch('http://localhost:3971/health');
expect(res.status).toBe(200);
app.stop();

Runnable examples for every component (controllers, guard, context, interceptor, middleware, gateway, file uploads, rate limiting) plus an end-to-end suite live in example/tests/:

cd example
npm install
npm test

The example runs these on Vitest and aliases @smwb/srv to the library source (vitest.config.tsresolve.alias) so it transforms with decorators intact; a published consumer would instead test against the installed package.

Building as a library

Single entry point @smwb/srv, one build dist/lib/ (works under both Node and Bun). The scaffold CLI is published as bin create-smwb-srv (dist/create/cli.js).

{
  ".": {
    "types": "./dist/lib/library/index.d.ts",
    "import": "./dist/lib/library/index.js"
  }
}

The library entry point is src/library/index.ts. Build with npm run build (library + CLI). After install from npm, scaffold a new app with:

npx -p @smwb/srv create-smwb-srv my-api