@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/srvimport {
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-apiWith an explicit version:
npx -p @smwb/srv@latest create-smwb-srv my-apiOptions:
--no-install— copy files only, skipnpm 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 devRuntime: 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.tsexecutes TS directly, no build; the server starts via Bun'snode:httpcompatibility. 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 buildQuick start
- Create a controller (
default exportof 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: [] };
}
}- Describe the routes — the module is provided by the
loadimport 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'),
},
],
});- 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));- Run:
# dev (Bun runs TS directly, no build)
bun --watch src/main.ts
# prod (Node)
tsc && node dist/main.jsBy 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.jsOptional: 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
responseschema, the action returns data and the framework serializes JSON - if there is no
responseschema, the action writes toresponsedirectly
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
paramsschema, path params are not allowed - without a
queryschema, query params are not allowed - without a
bodyschema, a request body is not allowed - validation errors →
400with{ 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 actionafter(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 wsMessages 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.
- 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;- Create a gateway.
send/onMessageare typed; reach the underlying socket viasocket.rawfor 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.
- Describe the WS routes (modules are
loadthunks):
// 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
},
],
});- Pass
websocketRoutestoregisterFrameworkBindings:
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 validationResponse codes:
404— path not found405— method not supported for the path400— validation error (incl. missing/too-many files, non-multipart upload)403— guard rejected the request413— upload exceeds a size limit415— upload has a disallowed type/extension429— rate limit guard rejected the request500— 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 # prettierPublishing
Release is automated in CI (.sourcecraft/ci.yaml) on every push to main:
npm run typecheck,npm run build,npm test- if
package.jsonversion is not yet on npm —npm publish --access publicand git tagv{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 exportof the class - routes —
*.routes.ts(viadefineController/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 startTesting
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 testThe example runs these on Vitest and aliases @smwb/srv to the library source
(vitest.config.ts → resolve.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