@push.rocks/smartserve
A blazing-fast, cross-platform HTTP server for Node.js, Deno, and Bun with decorator-based routing, OpenAPI/Swagger integration, automatic compression, WebSocket support, static file serving, and WebDAV protocol. 🚀
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
npm install @push.rocks/smartserve
# or
pnpm add @push.rocks/smartserve
Features
| Feature | Description |
|---|---|
| ✨ Cross-Platform | Works seamlessly on Node.js, Deno, and Bun with zero config |
| 🎯 Decorator-Based Routing | Clean, expressive @Route, @Get, @Post decorators |
| 📖 OpenAPI/Swagger | Auto-generate OpenAPI 3.1 specs with built-in Swagger UI & ReDoc |
| ✅ Request Validation | Validate requests against JSON Schema with automatic coercion |
| 🗜️ Auto Compression | Brotli/gzip compression with smart content detection |
| 🛡️ Guards & Interceptors | Built-in @Guard, @Transform, @Intercept for auth & transformation |
| 🌍 CORS Policies | Per-registry and per-route CORS: preflights answered by the registry, origins decided per request (@Cors) |
| 📁 Static File Server | Streaming, ETags, Range requests, directory listing, pre-compressed files |
| 🌐 WebDAV Support | Mount as network drive with full RFC 4918 compliance |
| 🔌 WebSocket Ready | Native WebSocket support with TypedRouter for type-safe RPC |
| ⚡ Zero Overhead | Native Web Standards API (Request/Response) on Deno/Bun |
| 🔒 HTTPS/TLS | Built-in TLS support with certificate configuration |
| 🧦 Unix Sockets | Serve HTTP and WebSockets on a permission-guarded Unix domain socket |
Quick Start
import { SmartServe, Route, Get, Post, type IRequestContext } from '@push.rocks/smartserve';
@Route('/api')
class UserController {
@Get('/hello')
hello() {
return { message: 'Hello World! 👋' };
}
@Get('/users/:id')
getUser(ctx: IRequestContext) {
return { id: ctx.params.id, name: 'John Doe' };
}
@Post('/users')
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
const body = await ctx.json();
return { id: 'new-id', ...body };
}
}
const server = new SmartServe({ port: 3000 });
server.register(UserController);
await server.start();
console.log('🚀 Server running at http://localhost:3000');
Lifecycle and controller ownership
Every SmartServe instance owns its own ControllerRegistry, exposed as
server.controllerRegistry. Request dispatch and the OpenAPI document of a
server consult only that registry, so several servers in one process never
answer each other's routes. The decorators only describe a controller class: a
controller serves requests on exactly the servers it is registered with.
const server = new SmartServe({ port: 3000 });
server.register(UserController); // constructs an instance for this server
server.register(new AdminController(db)); // or registers a configured instance
// A route without a controller class; the returned function removes it again.
const removeHealthRoute = server.addRoute('/health', 'GET', () => new Response('ok'));
removeHealthRoute();
A server serves one instance per controller class; registering a second
instance of the same class throws. Controllers and routes can be registered at
any time, also while the server is running, and they stay registered across
stop() and start(). stop() releases the built-in OpenAPI routes, WebSocket
state, and runtime adapter resources; the next start() registers the OpenAPI
routes again.
Cleanup attempts every owned phase. Concurrent stop() calls share the same
work; a failed cleanup retains only unresolved resources for a later stop()
retry. Application WebSocket close callbacks keep their documented isolated
logging behavior.
Upgrading from 6.x? See Migrating from 6.x.
Table of Contents
- Decorators
- OpenAPI & Swagger
- Compression
- Static File Server
- WebDAV Support
- HTTP Timeouts
- WebSocket Support
- HTTPS/TLS
- Unix Socket Listener
- Error Handling
- Request Context
- Custom Request Handler
- Lifecycle and controller ownership
- Migrating from 6.x
Decorators
Route Decorators
import { Route, Get, Post, Put, Delete, Patch, All } from '@push.rocks/smartserve';
@Route('/api/v1') // Base path for all routes in this controller
class ApiController {
@Get('/items') // GET /api/v1/items
listItems() {
return [{ id: 1, name: 'Item 1' }];
}
@Get('/items/:id') // GET /api/v1/items/:id
getItem(ctx: IRequestContext) {
return { id: ctx.params.id };
}
@Post('/items') // POST /api/v1/items
async createItem(ctx: IRequestContext<{ name: string }>) {
const body = await ctx.json();
return { created: body.name };
}
@Put('/items/:id') // PUT /api/v1/items/:id
async updateItem(ctx: IRequestContext) {
const body = await ctx.json();
return { updated: ctx.params.id, ...body };
}
@Delete('/items/:id') // DELETE /api/v1/items/:id
deleteItem(ctx: IRequestContext) {
return { deleted: ctx.params.id };
}
@All('/webhook') // Matches ALL HTTP methods
handleWebhook(ctx: IRequestContext) {
return { method: ctx.method };
}
}
Only methods with an HTTP-method decorator (@Get, @Post, …, @All) are
routes. Modifier decorators such as @Guard, @Compress, or @ApiOperation
on any other method never make it reachable. Routes are served by controller
instances and looked up on the instance by method name, so route and modifier
decorators on a static method or a #private method throw when the class is
defined.
A GET route answers HEAD too (RFC 9110 §9.3.2): a HEAD request that no
@Head or @All route serves runs the GET route of its path in full
(admissions, validation, guards, handler, compression) and is answered with
the status and header fields the GET gets, without content. The route reads
ctx.method as HEAD; a body it answers with is released unread. A @Head
route of its own serves HEAD in its place. The generated OpenAPI document
lists no HEAD operation for a GET route.
Controller Inheritance
Each class keeps its own decorator metadata; decorating a subclass never changes its base class. A subclass controller is served with:
- Base path: its own
@Routepath, else the nearest ancestor's. - Routes: every route its ancestors declare plus its own, under its own base path. Overriding a method without decorators keeps the inherited route and serves the override; decorators on an overriding method add to the inherited declaration (guards and transforms accumulate, an HTTP-method decorator replaces method and path).
- Class-level guards, transforms, and OpenAPI metadata: those of every ancestor, base class first, followed by its own.
@Route('/users')
@Guard(isAuthenticated)
class UserController {
@Get('/:id')
getUser(ctx: IRequestContext) { /* ... */ }
}
@Route('/admin/users')
@Guard(isAdmin)
class AdminUserController extends UserController {
// GET /admin/users/:id runs isAuthenticated, then isAdmin
@Delete('/:id')
deleteUser(ctx: IRequestContext) { /* ... */ }
}
Guards (Authentication/Authorization)
Guards protect routes by returning true (allow) or false (reject with 403):
import { Route, Get, Guard, hasBearerToken, type IRequestContext } from '@push.rocks/smartserve';
// Custom guard function
const isAuthenticated = (ctx: IRequestContext) => {
return ctx.headers.has('Authorization');
};
const isAdmin = (ctx: IRequestContext) => {
return ctx.headers.get('X-Role') === 'admin';
};
@Route('/admin')
@Guard(isAuthenticated)
@Guard(isAdmin) // Multiple guards - all must pass
class AdminController {
@Get('/dashboard')
dashboard() {
return { admin: true };
}
// Method-level guard (runs after class guards)
@Get('/super-secret')
@Guard((ctx) => ctx.headers.get('X-Super') === 'yes')
superSecret() {
return { level: 'super-secret' };
}
}
// Built-in utility guards
@Route('/protected')
@Guard(hasBearerToken()) // Requires Authorization: Bearer <token>
class ProtectedController {
@Get('/data')
getData() {
return { protected: true };
}
}
rateLimit(maxRequests, windowMs, options?) admits at most maxRequests
requests per client within a sliding window of windowMs milliseconds and
rejects the rest like any failed guard (403, or your onReject response).
- Counters belong to the registration. A class registered with two
servers, or two subclasses that inherit the same rate-limited route, count
separately. A class-level
@Guard(rateLimit(...))counts the requests to all routes of a controller together, a method-level one those to its route, and arateLimit()value that several@Guards of one controller use shares one counter table. Changes to the route table keep the counters. - Client key. By default a client is its transport peer address
(
ctx.connectionInfo.remoteAddr);X-Forwarded-For, which any client can set, is ignored. An IPv4-mapped IPv6 peer (::ffff:192.0.2.1) counts as its IPv4 address, and an IPv6 peer counts with its whole /64 network, because a single client usually holds a /64 and could otherwise rotate addresses to escape the limit.rateLimitAddressKey(address)applies the same rules to any address. - Unidentified requests are rejected. A request whose peer is not an IP
address (no
connectionInfo, or the unnameable peer'unknown') is rejected instead of sharing one bucket with every other such request. Akeyfunction rejects a request by returningundefined. - Bounded memory. At most
maxKeysclients (default 10,000) are counted at once; a client is counted while it has an admitted request inside the window. When the table is full, a new client takes the place of the least recently admitted client that is under its limit, which starts over if it comes back. A client that has used up its limit is never evicted, so flooding the table with new addresses cannot reset its counter; it keeps its place until it is admitted again or has been idle for a whole window. Requests from new clients are rejected only while every counted client has used up its limit, so keeping them out takesmaxKeys×maxRequestsadmitted requests per window. Memory is bounded bymaxKeys×maxRequeststimestamps. maxRequests,windowMsandmaxKeysmust be positive integers; anything else, such asNaN, throws when the decorator is declared.
Behind a reverse proxy every request arrives from the proxy's address, so without
{ key }all users are throttled together as one client. Key on a header only when your proxy sets it and overwrites whatever the client sent:
import { rateLimit, rateLimitAddressKey } from '@push.rocks/smartserve';
@Guard(rateLimit(100, 60_000)) // 100 requests per minute per client
class ApiController { /* ... */ }
// Behind a proxy that sets X-Real-IP and discards the client's own value
@Guard(rateLimit(100, 60_000, {
key: (ctx) => {
const clientAddress = ctx.headers.get('x-real-ip');
return clientAddress === null ? undefined : rateLimitAddressKey(clientAddress);
},
}))
class ProxiedApiController { /* ... */ }
rateLimit() returns a guard factory (IGuardFactory), which @Guard accepts
beside guard functions: every controller registration creates its own guard
from it. Write a guard that keeps state across requests as a factory as well,
{ createGuard: () => { /* fresh state */ return (ctx) => { /* ... */ }; } },
so that servers and subclasses never share that state.
Admission (before validation)
Guards run after a route's request validation, so a request with invalid
input is refused 400 before any guard looks at its credentials or its rate
limit. An admission runs earlier: after the route is matched and before
its request validation. It is a function of the request's context
(TRouteAdmission) that answers the request with a Response of its own, or
returns nothing to let it go on. A ControllerRegistry takes one for all of
its routes, decorated or added, and addRoute() one for a route:
import { ControllerRegistry, type TRouteAdmission } from '@push.rocks/smartserve';
const tokenAdmission: TRouteAdmission = async (ctx) => {
const token = await resolveBearer(ctx.headers.get('authorization'));
if (!token) {
return new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } });
}
const budget = await spendBudget(token);
// the registry adds them to whichever Response answers this request
ctx.responseHeaders.set('RateLimit', `"token";r=${budget.remaining};t=${budget.resetSeconds}`);
if (!budget.allowed) {
return new Response(null, { status: 429, headers: { 'Retry-After': String(budget.resetSeconds) } });
}
ctx.state.token = token; // for the handler
};
const registry = new ControllerRegistry({ admission: tokenAdmission, responseValidation: 'enforce' });
registry.addRoute('/articles/:articleId', 'GET', getArticle, { openapi, admission: articleQuota });
executeRoute() runs a matched route in this order:
- the registry's admission, then the route's (
IAddRouteOptions.admission) - under
malformedPathParameters: 'refuse', the refusal of a malformed path parameter (requestValidationResponseanswers it) - the route's request validation (
requestValidationResponseanswers a refusal) - class- and method-level
@Guardand@Interceptrequest interceptors - the handler, then the response interceptors (
@Transform) - the route's response validation (
responseValidationResponsereplaces an invalid response underenforce) - the request's
responseHeaders, added to whichever Response answers
An admission that returns a Response ends the request: nothing after it runs,
and its Response is neither validated nor replaced. Anything but a Response
or nothing rejects the request with a TypeError. Admissions may be async
and pass data on in ctx.state.
ctx.responseHeaders (IRouteContext) is a Headers per request. The
registry adds it to the Response that answers the request, whoever produced
it: an admission, the request validation's refusal, the handler, or the
response validation's replacement. A header the answering Response sets
itself keeps its value; each Set-Cookie value it does not carry yet is
added to its own, so registries that run one request one inside the other
add a cookie once, and each Vary field it does not name yet is added to
its Vary (Vary: Accept-Language and Vary: Origin answer
Vary: Accept-Language, Origin; a * in either stands alone): the answer
varies with what its producer named and with what the request's handling
looked at. The registry answers a new Response with the status,
status text and body stream of the answering one, without reading the body,
so a Response whose headers are immutable (Response.redirect(), a fetched
one) gets them too and a shared one is never changed; an interim (1xx)
response, such as a WebSocket upgrade, and Response.error() are answered
as they are. A Content-Length header the answering Response sets is kept,
but the length a runtime infers from a string or Blob body is not, so such
a response with responseHeaders is sent chunked unless it sets
Content-Length itself.
An error thrown by an admission, an interceptor or the handler rejects
executeRoute() without a Response. SmartServe's own dispatch answers it
(onError, HttpError.toResponse(), or a 500) with the request's
responseHeaders added the same way. A dispatcher of its own adds them with
withResponseHeaders(response, context.responseHeaders): a context it
passes with responseHeaders keeps them, so a dispatcher that copies the
context, { ...context, params }, creates them on the copy before
executeRoute() to read them afterwards.
CORS
A route can be read by browsers on other origins under a CORS policy
(ICorsPolicy, the Fetch Standard's CORS protocol).
A ControllerRegistry takes one for all of its routes (cors), and a route
its own, which replaces the registry's: addRoute() with cors, a decorated
route with @Cors(policy). cors: false and @Cors(false) take a route out
of the registry's policy. A route without a policy is answered as it always
was.
import { ControllerRegistry, type ICorsPolicy, type TRouteAdmission } from '@push.rocks/smartserve';
const storefront: ICorsPolicy = {
// the shop's own listed origins; ctx.params are the matched route's
allowOrigin: (origin, ctx) => shopOrigins.allows(ctx.params.shopId, origin),
allowHeaders: ['Authorization', 'Content-Type', 'Idempotency-Key'],
exposeHeaders: ['RateLimit', 'RateLimit-Policy', 'Retry-After'],
maxAgeSeconds: 600,
};
// refuse a browser on an origin the shop does not list, from the one decision
const originAdmission: TRouteAdmission = (ctx) => {
if (ctx.cors && !ctx.cors.allowed) {
return new Response(null, { status: 403 });
}
};
const registry = new ControllerRegistry({ cors: storefront, admission: originAdmission });
registry.addRoute('/public/shops/:shopId/assortment', 'GET', listAssortment);
allowOrigin(origin, ctx)decides whether the request'sOriginmay read the route's answers; it may beasyncand gets the request's context with the route'sparams. It runs at most once per request, and only for a request that carries anOrigin. Anything but a boolean rejects the request with aTypeError. An opaque origin arrives as the stringnull.allowHeaders: the request headers a preflight allows besides the CORS-safelisted ones (Authorizationis never safelisted, so list it when browsers send it).exposeHeaders: the response headers an allowed origin may read besides the CORS-safelisted ones.maxAgeSeconds: how long a browser may keep a preflight's answer (Access-Control-Max-Age); without it the header is not sent and browsers keep the answer 5 seconds.
Header names are checked when the policy is given (a TypeError for
anything but field names); the wildcard * is not supported. A policy is
read once, when it is first given.
Preflights. An OPTIONS request with an Origin and an
Access-Control-Request-Method, to a path served by a route with a policy,
is answered by the registry before any admission, validation, guard or
handler of the route runs:
| Preflight | Answer |
|---|---|
| origin allowed by the policy of the route the requested method reaches | 204, Access-Control-Allow-Origin: <origin>, Access-Control-Allow-Methods (the requested method and every other method whose route on the path has the same policy), Access-Control-Allow-Headers (the requested headers the policy lists, when any), Access-Control-Max-Age (when set) |
| origin refused | 403, none of these headers |
| requested method not served on the path by a route with a policy | 403, none of these headers; the origin is not decided |
Both answers carry Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers and no body. A browser treats any answer
without the allow headers as a failed preflight; 403 says so explicitly, as
the Fetch Standard suggests for a refusal. The requested method is matched as
matchRoute() matches the request that follows, in upper case; it is
allowed in the case the browser sent (fetch() sends patch as given). An
OPTIONS request without Access-Control-Request-Method or without
Origin is no preflight: it is dispatched as before, to an OPTIONS route
of its own or to a 404, as is a preflight to a path whose routes have no
policy. A preflight to a path whose OPTIONS route has a policy is the
registry's to answer.
Every other request to a route with a policy is decided before the
admissions, and the decision is the context's cors
({ origin, allowed }, absent without an Origin), so an admission can
refuse a browser on an origin the policy does not allow without deciding
again. The request's responseHeaders then carry:
Vary: Origin, for every request to the route, so a cache never hands one origin's answer to another (Fetch Standard, "CORS protocol and HTTP caches");- for an allowed origin,
Access-Control-Allow-Originwith that origin (never*) andAccess-Control-Expose-Headers.
So every answer to an allowed origin carries them, whoever produced it: an
admission's refusal, the request validation's refusal, the handler's answer,
the response validation's replacement under enforce, the answer to a thrown
error, a compressed answer (Vary: …, Accept-Encoding). A refused origin
gets no CORS headers; whether the route serves it anyway is the admission's
choice.
Credentials are not part of a policy: Access-Control-Allow-Credentials
is never sent, so browsers share answers only with requests made without
cookies or HTTP authentication. Send an API key in a header such as
Authorization, listed in allowHeaders.
SmartServe answers preflights of its routes itself, decorated (@Cors) or
added (server.controllerRegistry.addRoute(path, method, handler, { cors })).
A dispatcher of its own asks matchPreflight() before matchRoute(), see
Dispatching to controllers yourself.
Transforms (Response Modification)
Transforms modify the response before sending:
import { Route, Get, Transform, wrapSuccess, addTimestamp } from '@push.rocks/smartserve';
// Custom transform
const addVersion = <T extends object>(data: T) => ({
...data,
apiVersion: '2.0',
});
@Route('/api')
@Transform(wrapSuccess) // Built-in: wraps in { success: true, data: ... }
class ApiController {
@Get('/info')
@Transform(addTimestamp) // Built-in: adds timestamp field
@Transform(addVersion) // Transforms stack
getInfo() {
return { name: 'MyAPI' };
}
// Response: { success: true, data: { name: 'MyAPI', timestamp: '...', apiVersion: '2.0' } }
}
Intercept (Full Control)
For complete control over request/response flow:
import { Route, Get, Intercept, type IRequestContext } from '@push.rocks/smartserve';
@Route('/api')
@Intercept({
// Runs BEFORE handler
request: async (ctx) => {
console.log(`📥 ${ctx.method} ${ctx.path}`);
// Return Response to short-circuit
if (ctx.headers.get('X-Block') === 'true') {
return new Response('Blocked', { status: 403 });
}
// Add data to state for handler access
ctx.state.requestTime = Date.now();
// Return void to continue with original context
},
// Runs AFTER handler
response: async (data, ctx) => {
const duration = Date.now() - (ctx.state.requestTime as number);
console.log(`📤 Response in ${duration}ms`);
return { ...data, processedIn: `${duration}ms` };
},
})
class LoggedController {
@Get('/data')
getData() {
return { items: [1, 2, 3] };
}
}
OpenAPI & Swagger
SmartServe includes first-class OpenAPI 3.1 support with automatic spec generation, Swagger UI, ReDoc, and request validation.
Documenting APIs
import {
SmartServe,
Route,
Get,
Post,
ApiOperation,
ApiParam,
ApiQuery,
ApiRequestBody,
ApiResponseBody,
ApiTag,
ApiSecurity,
type IRequestContext,
} from '@push.rocks/smartserve';
// Define JSON Schemas for validation
const UserSchema = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['id', 'name', 'email'],
} as const;
const CreateUserSchema = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
} as const;
@Route('/api/users')
@ApiTag('Users')
class UserController {
@Get('/')
@ApiOperation({
summary: 'List all users',
description: 'Returns a paginated list of users',
})
@ApiQuery('page', {
description: 'Page number',
schema: { type: 'integer', minimum: 1, default: 1 },
})
@ApiQuery('limit', {
description: 'Items per page',
schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
})
@ApiResponseBody(200, {
description: 'List of users',
schema: { type: 'array', items: UserSchema },
})
listUsers(ctx: IRequestContext) {
const page = ctx.query.page ?? '1';
const limit = ctx.query.limit ?? '20';
return { users: [], page: parseInt(page), limit: parseInt(limit) };
}
@Get('/:id')
@ApiOperation({ summary: 'Get user by ID' })
@ApiParam('id', {
description: 'User UUID',
schema: { type: 'string', format: 'uuid' },
})
@ApiResponseBody(200, { description: 'User found', schema: UserSchema })
@ApiResponseBody(404, { description: 'User not found' })
getUser(ctx: IRequestContext) {
return { id: ctx.params.id, name: 'John Doe', email: 'john@example.com' };
}
@Post('/')
@ApiOperation({ summary: 'Create a new user' })
@ApiRequestBody({
description: 'User data',
schema: CreateUserSchema,
})
@ApiResponseBody(201, { description: 'User created', schema: UserSchema })
@ApiResponseBody(400, { description: 'Validation error' })
@ApiSecurity('bearerAuth')
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
const body = await ctx.json();
return { id: 'new-uuid', name: body.name, email: body.email };
}
}
Request Validation
When you define @ApiRequestBody, @ApiParam, or @ApiQuery with schemas, SmartServe automatically validates incoming requests:
const server = new SmartServe({
port: 3000,
openapi: {
enabled: true,
info: {
title: 'My API',
version: '1.0.0',
description: 'A well-documented API',
},
validation: true, // 🔥 Default: validate requests against their schemas
},
});
server.register(UserController);
await server.start();
// Invalid request → 400 Bad Request with details
// POST /api/users with { "name": "" }
// Response: { "error": "Validation failed", "source": "body", "details": [...] }
Request Bodies: validation reads a JSON request body once, through ctx.json(), before the handler runs, and the context caches it, so the handler's ctx.json() answers the value validation judged without reading the body again. Content in a JSON media type (application/json or a +json type) that does not parse is refused with source: 'body' and the error { path: '/', message: 'Request body is not valid JSON', keyword: 'format' }, whether the body is required or optional. A request without content (no Transfer-Encoding, no Content-Length other than 0) has no body: a required one is refused (keyword: 'required'), an optional one (required: false) lets the request on, and the handler's ctx.json() then rejects with a SyntaxError, so a rejecting ctx.json() after validation always means no body was sent. Content in another media type is read as JSON too: it is validated when it parses and taken for no body when it does not. A body that cannot be read, as opposed to parsed, is thrown and answered as an error. A context of a dispatcher of its own must cache json(), as IRequestContext says, for its handler to read a body validation has read.
Automatic Type Coercion: Query and path parameters are automatically coerced to their schema types:
@Get('/items')
@ApiQuery('page', { schema: { type: 'integer', default: 1 } })
@ApiQuery('active', { schema: { type: 'boolean' } })
listItems(ctx: IRequestContext) {
// ctx.query.page is coerced to number (1)
// ctx.query.active is coerced to boolean
return { page: ctx.query.page, active: ctx.query.active };
}
Validation is on by default, whether or not the server publishes the OpenAPI
document (enabled). Set validation: false to switch it off: requests then
reach their handlers unvalidated, and query and path parameters stay strings
without schema defaults.
A refused request is answered 400 application/json by default. Set
requestValidationResponse (in openapi, or on a ControllerRegistry) to
'problem+json' to answer RFC 9457 problem details instead, or to a function
that builds the answer from the failure and the request's context:
openapi: {
info: { title: 'My API', version: '1.0.0' },
requestValidationResponse: 'problem+json',
// 400 application/problem+json
// { "type": "about:blank", "title": "Bad Request", "status": 400,
// "detail": "The request query does not match the API description.",
// "source": "query", "errors": [{ "path": "/limit", "message": "…" }] }
},
A responder receives an IRequestValidationFailure (the route's method and
pattern, the failing source and its errors) and the context, and returns
the Response; returning anything else throws a TypeError, which the server
answers 500, and the handler never runs. Whatever answers the refusal, response validation lets it through:
it is the registry's own answer to a request the route never saw, so an API
description that omits the 400 does not turn a precise refusal into a 500.
A 400 the route answers itself is validated like any other response.
Document the refusal in your OpenAPI document all the same.
Each route's schemas are compiled once, when the route is registered, not per
request. Routes from a given OpenAPI document (below) also resolve every
$ref then, so an unresolvable or self-referencing schema makes registration
throw. To validate values against a schema yourself, compile it once with
compileSchemaValidator(schema) and reuse the returned function;
validateSchema(data, schema) compiles on every call. Pass an OpenAPI document
as the second argument, compileSchemaValidator(schema, document), and the
schema's $refs (#/components/schemas/Article) resolve against the
document's components, whether the schema is part of the document or one you
wrote beside it, such as an envelope around a documented schema. With a
document, every reference is resolved at compile time.
Response Validation
SmartServe can also check what your handlers answer against the responses you
documented with @ApiResponseBody. It is off by default, and while it is off
nothing is compiled or run for it.
const server = new SmartServe({
port: 3000,
openapi: {
info: { title: 'My API', version: '1.0.0' },
responseValidation: 'enforce', // 'off' (default) | 'report' | 'enforce'
onResponseValidationError: (failure, ctx) => {
console.error(`${failure.method} ${failure.pattern} answered ${failure.status}`, failure.errors);
},
},
});
A response passes when its status is documented and, where that documented
response has a schema, its body is JSON (application/json or a +json type
such as application/problem+json) that the schema accepts. Every response a
route answers is checked, including those of guards and transforms. The body is
read from a clone, so the response sent keeps its body. Routes without
documented responses are not checked.
Validation reads the whole body before the response is sent, in report and
enforce alike, which is why it is opt-in:
-
A streamed JSON response (a
ReadableStreambody) is buffered in full before its first byte goes out; a stream that never ends never answers. -
A body the handler compressed itself (the response carries a
Content-Encoding) cannot be read as JSON and fails validation. Return the plain body and let SmartServe's compression encode it after validation. -
reportsends the response unchanged and passes each failure toonResponseValidationError, which it requires. -
enforcepasses each failure to the hook when one is set, cancels the invalid response's body, and answers500 {"error":"Response validation failed"}(application/json) instead, or whatresponseValidationResponsechooses. The details go only to the hook; they describe your server's defect, not the client's request.
Set responseValidationResponse (in openapi, or on a ControllerRegistry)
to 'problem+json' to answer RFC 9457 problem details instead, or to a
function that builds the answer from the failure and the request's context.
'json', the default, is the answer above. The option applies only under
enforce; report always sends the response unchanged.
openapi: {
info: { title: 'My API', version: '1.0.0' },
responseValidation: 'enforce',
responseValidationResponse: 'problem+json',
// 500 application/problem+json
// { "type": "about:blank", "title": "Internal Server Error", "status": 500,
// "detail": "The response does not match the API description." }
},
The problem details name nothing of the failure either. A responder
(TResponseValidationResponder) receives the IResponseValidationFailure and
the context after the hook has, and returns the Response to send; that Response
is not validated again, so it need not be documented. A responder that throws,
rejects, or returns anything but a Response is logged and the default 500 is
answered instead: the invalid response is already gone, and the client must
still get an answer.
responseValidationResponse: (failure, ctx) => Response.json(
{ type: 'about:blank', title: 'Internal Server Error', status: 500, code: 'internal_error' },
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
),
A hook that throws or rejects is logged and changes nothing: report still
sends the response, enforce still answers as it would without the hook.
A failure (IResponseValidationFailure) names the route's method and
pattern, the response status, and its errors: keyword status for an
undocumented status, contentType for a body that is not JSON where a schema is
documented, otherwise the JSON Schema keywords the body failed. Errors thrown by
a handler are not validated; they reach the server's error handling as before.
A registry used by a custom dispatcher takes the same options:
new ControllerRegistry({ responseValidation: 'report', onResponseValidationError }),
and responseValidationResponse beside responseValidation: 'enforce'.
Swagger UI & ReDoc
const server = new SmartServe({
port: 3000,
openapi: {
enabled: true,
info: {
title: 'My Awesome API',
version: '2.0.0',
description: 'API documentation with interactive testing',
contact: {
name: 'API Support',
email: 'support@example.com',
},
},
servers: [
{ url: 'http://localhost:3000', description: 'Development' },
{ url: 'https://api.example.com', description: 'Production' },
],
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
// Customize paths
specPath: '/openapi.json', // Default: /openapi.json
docsPath: '/docs', // Swagger UI, default: /docs
},
});
await server.start();
// 📖 Swagger UI: http://localhost:3000/docs
// 📄 OpenAPI: http://localhost:3000/openapi.json
SmartServe serves Swagger UI itself. To offer ReDoc as well, serve its handler on a route of your choice:
import { createReDocHandler } from '@push.rocks/smartserve';
server.addRoute('/redoc', 'GET', createReDocHandler('/openapi.json', 'My Awesome API'));
// 📕 ReDoc: http://localhost:3000/redoc
The served document lists the controllers registered with that server and
follows registrations made while the server runs; it is served with
Cache-Control: no-cache, so clients revalidate it. To produce it without
serving it, for example at build time, generate it from the server's registry:
import { OpenApiGenerator } from '@push.rocks/smartserve';
const spec = new OpenApiGenerator(server.controllerRegistry, {
info: { title: 'My API', version: '1.0.0' },
}).generate();
Routes from a Given OpenAPI Document
When the OpenAPI document is the source, for example one generated at build time from your contracts, derive each route's validation from it instead of from decorators. The document stays the single description of the API: the routes validate against it, and it is served as it is.
import { readFile } from 'node:fs/promises';
import {
ControllerRegistry,
createOpenApiDocsHandler,
createOpenApiDocumentHandler,
getOpenApiRouteMeta,
openApiPathToRoutePattern,
type IOpenApiSpec,
} from '@push.rocks/smartserve';
const document = JSON.parse(await readFile('openapi.json', 'utf8')) as IOpenApiSpec;
const registry = new ControllerRegistry({ responseValidation: 'enforce' });
registry.addRoute(
openApiPathToRoutePattern('/articles/{articleId}'), // '/articles/:articleId'
'GET',
(ctx) => articles.read(ctx.params.articleId),
{ openapi: getOpenApiRouteMeta(document, '/articles/{articleId}', 'GET') },
);
registry.addRoute('/openapi.json', 'GET', createOpenApiDocumentHandler(document));
registry.addRoute('/docs', 'GET', createOpenApiDocsHandler(document, { specUrl: '/openapi.json' }));
getOpenApiRouteMeta(document, path, method) derives a route's
IOpenApiRouteMeta from the operation: path, query and header parameters of
the Path Item and the operation (the operation's win), the JSON request body
(required only when the document says required: true), and the responses by
exact status (200), range (4XX) and default; response validation checks
them in that order. Local Reference Objects (#/components/parameters/…,
#/components/responses/…) are resolved, and $refs inside schemas, those of
parameters included, resolve against the document, which the metadata carries.
A schema whose $ref resolves to nothing, or whose references lead back to
themselves, makes addRoute() throw; it never fails a request. Query and path
parameters are coerced by the schema their $ref names. Cookie parameters, non-JSON
bodies and response headers are not validated. It throws when the document has
no such path or operation, and openApiPathToRoutePattern() throws for a
template expression that is not a whole path segment. Pass the metadata to
addRoute(path, method, handler, { openapi }) on any ControllerRegistry,
including server.controllerRegistry: the route's requests are validated while
validation is on and its responses while responseValidation is, exactly as
for decorated routes.
createOpenApiDocumentHandler(document) serializes the document once and
serves it as application/json with Cache-Control: no-cache and a strong
ETag; a matching If-None-Match is answered 304.
createOpenApiDocsHandler(document, { specUrl?, title? }) renders a
documentation page once: every operation grouped by its first tag with its
parameters, request body and responses, the document's webhooks in a section
of their own, each with its request headers, payload and the answers its
receiver gives, and the component schemas, with $refs linked to them;
descriptions are shown as plain text. The page has no
scripts and loads nothing from elsewhere, and its response carries its own
Content-Security-Policy: default-src 'none' with the inline stylesheet
admitted by its hash. (The Swagger UI and ReDoc handlers load their assets from
a CDN.)
Compression
SmartServe automatically compresses responses using Brotli or gzip based on client support:
const server = new SmartServe({
port: 3000,
compression: {
enabled: true, // Default: true
threshold: 1024, // Min bytes to compress (default: 1KB)
level: 6, // Compression level 1-11 for br, 1-9 for gzip
algorithms: ['br', 'gzip'], // Preferred order (default: Brotli, then gzip)
},
});
Compression, ETags and Conditional Requests
A compressed response carries a strong ETag of its own: "abc" becomes
"abc-br" or "abc-gzip" (getContentCodedETag()), since a strong validator
must differ between content-codings (RFC 9110 §8.8.3). A weak ETag stays as
it is.
SmartServe minted that suffix, so it takes it back in what a client sends:
before any handler runs (a route, a custom handler, WebDAV or the static
files), the entity-tags of If-Match and If-None-Match that carry one of
the configured algorithms' suffixes reach the handler in their identity form
("abc-gzip" as "abc", stripContentCodedETags()), so a handler compares
both with the ETag it sets and never needs to know about compression:
If-Matchnames the state a client read, in whichever coding it read it: a replacement sent withIf-Match: "abc-gzip"holds while the handler'sETagis"abc".If-None-Matchnames a response the client holds, so only the tag of a coding the request'sAccept-Encodingstill admits is taken back; the tag of another coding matches nothing. A handler's304to a client that named a compressed form carries that form'sETag(the one of the coding the request is answered in, where it named several) andVary: Accept-Encoding.
SmartServe answers no conditional request itself. Whether a GET is answered
304 is the handler's decision, made by the ETag it sets: the static file
server and createOpenApiDocumentHandler() evaluate If-None-Match, and a
route whose ETag does not validate its whole answer (a record's revision
beside figures that move without it) simply does not, and answers in full.
The suffixes of the configured algorithms (-br and -gzip by default) are
reserved at the end of any entity-tag, strong or weak: a route whose own tag
ends in one is read as the compressed form of a shorter one. A weak form is
taken back too (W/"abc-gzip" as W/"abc"), since an intermediary may weaken
the compressed form's strong tag it passes on, and a client then sends that.
Per-Route Compression Control
import { Route, Get, Compress, NoCompress } from '@push.rocks/smartserve';
@Route('/api')
class ApiController {
@Get('/large-data')
@Compress({ level: 9 }) // Force high compression
getLargeData() {
return { data: '...massive payload...' };
}
@Get('/already-compressed')
@NoCompress() // Skip compression (e.g., for pre-compressed content)
getCompressed() {
return someCompressedBuffer;
}
}
A route added with addRoute() takes the same settings as its compression
option:
registry.addRoute('/events', 'GET', streamEvents, { compression: { enabled: false } });
registry.addRoute('/export', 'GET', exportAll, { compression: { enabled: true, level: 9 } });
A custom request handler that dispatches to controllers passes these settings on with its response; see Dispatching to controllers yourself.
Pre-Compressed Static Files
Serve .br or .gz files automatically when available:
const server = new SmartServe({
port: 3000,
static: {
root: './dist',
precompressed: true, // Serve main.js.br instead of main.js
},
});
Static File Server
Serve static files with streaming, ETags, Range requests, and directory listing:
const server = new SmartServe({
port: 3000,
static: {
root: './public',
index: ['index.html', 'index.htm'],
dotFiles: 'deny', // 'allow' | 'deny' | 'ignore'
etag: true, // Generate ETags for caching
lastModified: true, // Add Last-Modified header
cacheControl: 'max-age=3600', // Or function: (path) => 'max-age=...'
extensions: ['.html'], // Try these extensions for extensionless URLs
precompressed: true, // Serve .br/.gz files when available
directoryListing: {
showHidden: false,
sortBy: 'name', // 'name' | 'size' | 'modified'
sortOrder: 'asc',
},
},
});
A file's ETag is weak, W/"size-mtime", and its pre-compressed variants
share it. If-None-Match is compared by the weak comparison, and
If-Modified-Since is evaluated only for a request without If-None-Match
(RFC 9110 §13.1). A Range request whose If-Range no longer holds is
answered 200 with the whole file: an If-Range entity-tag must match
strongly, which the weak ETag never does, and a date must be exactly the
Last-Modified of a file unchanged for at least a second.
Or use the shorthand:
const server = new SmartServe({
port: 3000,
static: './public', // Uses sensible defaults
});
Static response bodies are pulled according to consumer demand. Cancellation destroys the owned file stream and waits for its native close; sliced or pooled Node.js Buffers are copied so bytes outside the delivered chunk are never exposed.
The request path is percent-decoded once before it is mapped to a file. A path
that is not valid percent-encoded UTF-8 (%E0%A4%A, a lone %) is refused:
FileServer.serve() throws an HttpError 400, which SmartServe answers as its
other errors, through onError when set. A path that resolves outside the
root (/..%2Fsecret, a sibling directory whose name starts with the root's)
is answered 403, while a name that merely contains .. (a..b.txt) is
served. The check is on the resolved path, not on the real one: a symbolic
link inside the root is followed. A path that names no file, missing, below
a file (ENOTDIR) or too long for the file system (ENAMETOOLONG), makes
serve() answer null (not found); any other file system error, such as
EACCES or EIO, is a fault of the server and is thrown.
WebDAV Support
Mount the server as a network drive on macOS, Windows, or Linux:
const server = new SmartServe({
port: 8080,
webdav: {
root: '/path/to/files',
auth: (ctx) => {
// Optional: Basic authentication
const auth = ctx.headers.get('Authorization');
if (!auth) return false;
const [, credentials] = auth.split(' ');
const [user, pass] = atob(credentials).split(':');
return user === 'admin' && pass === 'secret';
},
locking: true, // Enable RFC 4918 exclusive write locks
},
});
await server.start();
// 💾 Connect: Finder → Go → Connect to Server → http://localhost:8080
Supported WebDAV Methods:
| Method | Description |
|---|---|
OPTIONS |
Capability discovery |
PROPFIND |
Directory listing and file metadata (Depth 0, 1 or 1,noroot for a collection) |
MKCOL |
Create directory |
COPY |
Copy files/directories |
MOVE |
Move/rename files/directories |
LOCK |
Acquire exclusive write lock |
UNLOCK |
Release lock |
GET / PUT / DELETE |
File operations |
The request path and the Destination of a COPY or MOVE are
percent-decoded once; one that is not valid percent-encoded UTF-8 is refused
with an HttpError 400 that WebDAVHandler.handle() throws, answered as the
static file server's is.
A Destination (RFC 4918 §10.3) is an absolute URI or an absolute path,
resolved against the request's URL. It is answered:
| Destination | Answer |
|---|---|
neither an absolute URI nor an absolute path (copy.txt, http://[) |
400 (§10.3) |
| on another host or port | 502 (§10.3, §9.8.5, §9.9.4: another server) |
| resolving outside the WebDAV root | 403 (§9.8.5, §9.9.4) |
The scheme is not compared: a proxy that terminates TLS forwards a client's
https Destination to a server it reaches over http.
Every request path is checked as the static file server checks it: one that
resolves outside the root (/..%2Fsecret) is answered 403, while a name
that merely contains .. is served. The root is never removed or replaced:
DELETE / and a PUT to / are answered 403, as is every COPY or MOVE
whose source and destination overlap (the same resource, or one inside the
other), the root as either (RFC 4918 §9.8.5, §9.9.4). A PUT to an existing
collection is answered 405 with Allow (§9.7.2), and a COPY onto an
existing collection replaces its membership (§9.8.4). A MKCOL, PUT,
COPY or MOVE whose target's parent collection does not exist, or is a
file, is answered 409 (§9.3.1, §9.7.1, §9.8.5, §9.9.4): no request creates
the collections above its target. A MOVE of a missing source answers 404
before an existing destination is touched.
A PROPFIND of a collection is answered at Depth 0 or 1. A PROPFIND
without a Depth header is a Depth: infinity one (RFC 4918 §9.1), and an
infinite PROPFIND of a collection is refused 403 with the
propfind-finite-depth precondition (§9.1.1), so no request walks a whole
tree; a file is answered at any depth. A Depth that is none of 0, 1,
infinity is refused 400. Microsoft's noroot extension ([MS-WDVSE]
§2.2.3), which the Windows WebDAV client sends, is served: PROPFIND with
Depth: 1,noroot lists a collection's members without the collection
itself (infinity,noroot is refused as infinity is), and DELETE with
Depth: infinity,noroot deletes a collection's members and keeps the
collection. noroot on any other method is refused 400. A COPY of a
collection with Depth: 0 copies the collection without its members
(§9.8.3); without Depth it copies the whole tree, and Depth: 1 is refused
400.
A PUT writes its body with backpressure, a chunk read only once the disk
took the one before, into a new file beside the target
(.smartserve-put-<uuid>), renamed over the target once the body arrived
whole. A body that fails, as one the client cuts short does, leaves an
existing file as it was and creates no new one. A file replaced keeps its
permission bits (set-user-ID and set-group-ID are dropped, as a write drops
them). Its owner and its other hard links do not carry over: that is the
trade-off of an atomic replace, the new file belongs to the server's user and
another hard link keeps the old content (a symbolic link at the target is
replaced by the file). No other request waits while the body arrives; the
file is put in place while no other request creates, removes, locks or
replaces the target, and the target's locks are checked again first, so a
LOCK taken while the body arrived, on the target, its parent or a
Depth: infinity collection above it, wins (423, the file stays as it was).
An upload's file, and the staging copy a COPY makes beside its
destination before it renames it into place, is the server's own: PROPFIND
does not list it, a COPY of its collection leaves it behind, and any
request whose path or Destination names one, or would create one (any name
starting .smartserve-put-, in any case), is answered 403. A server that
crashes mid-request leaves it behind; a PUT into a collection removes
those that went a day without a write (a staging collection with its
members), looking at most once an hour per collection. One in flight is
never removed: the server skips its own, whatever their age, and a staging
copy is marked written once made and member by member while it is made, so
another server serving the same root sees it written to as well, even where
the copy carries its source's old modification time (macOS). Only a name exactly
as the server creates it (.smartserve-put- and a lowercase UUID) is
removed: a file of yours whose
name merely starts .smartserve-put- (.SmartServe-Put-notes.md,
.smartserve-put-backup) is unreachable over WebDAV and unlisted, but never
deleted.
A DELETE of a collection removes it member by member while other requests
run. A member removed meanwhile counts as deleted. A member that cannot be
deleted stays and is named in a 207 with its status (403 where the file
system refuses, 423 for a lock taken on it while the DELETE ran, 500
otherwise), and its collections stay with it, unnamed (RFC 4918 §9.6.1);
everything else goes. A lock taken during the walk keeps its resource, and
only the locks whose tokens the DELETE submitted end. A LOCK of a
resource being removed waits for the removal and then creates it anew. When
the requested resource is the only one that stays, its status answers alone.
A COPY or MOVE onto an existing destination clears it the same way and
copies or moves nothing when part of it stays (a 207 names those members,
RFC 4918 §9.8.5, §9.9.4); the destination's own lock stays. The clear is not
undone: the members it removed before one stayed stay removed, and the 207
names only what stayed, so the destination is left part-cleared, as after a
DELETE that fails part-way. A LOCK taken on the destination between the
clear and the copy or move wins (423). A PUT, MKCOL, COPY or MOVE
that creates the destination in between (or after a check found it
unmapped) makes the request fail 409 (412 with Overwrite: F) and keeps
what it created. PUT and MKCOL create or replace their resource while
no other request creates, removes or locks it, so one that arrives while a
COPY or MOVE creates it waits and then finds it (MKCOL answers 405, a
PUT 405 for a collection). A COPY makes its copy under a staging name
beside the destination first, holding nothing, and only then clears an
existing destination and renames the copy into place: a long copy holds up
no other request, and a copy that fails leaves no staging copy behind and an
existing destination as it was.
Every request that changes a resource (PUT, MKCOL, COPY, MOVE, each
removal of a DELETE) checks the locks and makes the change while holding
the resource and every collection above it, the root included, as a LOCK
holds them. A LOCK of the parent collection, or of a Depth: infinity
collection further up, the root's too, therefore never lands between the
check and the change: it comes first and the change is refused 423, or it
waits and locks what it then finds. No member is created in or removed from
a locked collection without the lock's token. These checks and changes run
one at a time per handler; what takes long (a PUT's body, a COPY's copy,
a DELETE's walk over members) runs outside them.
A PROPFIND with Depth 1 leaves out a member removed while it is answered.
Every href in an answer percent-encodes each path segment, so names with
#, ?, %, spaces or non-ASCII characters round-trip.
The If header (RFC 4918 §10.4) is evaluated for every method before
anything changes. Each list applies to the resource its tag names (an
absolute URI or an absolute path) or, untagged, to the request URL; a list
holds when all its conditions do, the header when any list does. A lock
token holds for a resource in that lock's scope, an entity-tag ([W/"…"])
when it matches the resource's ETag (weak comparison), and Not reverses a
condition; an unmapped URL has neither. A header that does not hold is
answered 412, a malformed one 400. Lock tokens are submitted in If
only; the Lock-Token request header names the lock an UNLOCK ends
(§10.5) and submits nothing elsewhere. Every lock token the header names
counts as submitted, so If: (<token>) (Not <DAV:no-lock>) submits a token
without failing on a stale one. A token for a resource other than the request
URL belongs in a tagged list: COPY /a.txt onto a locked /b.txt sends
If: </b.txt> (<token>).
Locks are exclusive write locks (RFC 4918 §7), keyed by the file a path
names, so /notes.txt/, //notes.txt and /sub/..%2Fnotes.txt are the
locked /notes.txt. A request that changes a locked resource without the
lock's token, submitted in the If header, is answered 423
with the lock-token-submitted precondition naming the locks, before
anything changes. That covers the resource itself; every member of a
collection locked with Depth: infinity, a LOCK's default (Depth: 0 locks the
collection itself and its membership, not its members); creating a member of a
locked collection (PUT, MKCOL, COPY or MOVE) or removing one (DELETE
or MOVE); the destination of a COPY or MOVE; and every lock inside a
collection a DELETE or MOVE removes or a COPY or MOVE overwrites. A
DELETE ends the locks of what it deletes, and a MOVE leaves locks behind.
A LOCK on an unmapped URL creates an empty file, locks it and answers 201
(§9.10.4); its parent collection must exist (409), and a lock on that
collection needs its token. The file stays when the lock goes.
A lock lasts for its Timeout (an hour by default; Infinite does not
expire), counted from when it was taken or last refreshed: a LOCK by its
holder (its token in If), sent to the locked resource or to any resource
in its scope, refreshes it under the same token; the answer carries the lock
in its body and no Lock-Token header (§9.10.2). An expired
lock blocks nothing; no timer is kept per lock.
HTTP Timeouts
The Node.js adapter can enforce independent socket inactivity, header, and complete-request deadlines:
const server = new SmartServe({
port: 3000,
connectionTimeout: 15_000,
headersTimeout: 10_000,
requestTimeout: 120_000,
});
Each configured value must be an integer from 1 through 2147483647 milliseconds. Bun and Deno currently reject these options at startup instead of silently ignoring a deadline they cannot enforce.
On Node.js, the Web Request.signal passed to handlers aborts when the client
disconnects, the request or response transport fails, or the server stops.
Request-body and streamed-response work receives the same cancellation. A
normally completed response leaves the signal un-aborted; requestTimeout
continues to mean Node's complete-request deadline rather than a handler timer.
Graceful Stop
drainTimeout lets stop() finish the HTTP requests in flight:
const server = new SmartServe({ port: 3000, drainTimeout: 10_000 });
// ...
await server.stop();
stop() stops taking new connections and closes idle ones, then waits until
every request in flight has been answered or drainTimeout milliseconds have
passed. The requests that remain after that are aborted (their Request.signal
fires) and their connections closed. On Node.js a response sent while the
server stops carries Connection: close, and a request that arrives on an
open connection meanwhile is answered 503. WebSocket connections are closed
at once.
Without drainTimeout, Node.js aborts the requests in flight at once and Bun
closes their connections at once. Deno rejects drainTimeout at startup,
because Deno.serve cannot close its connections once a graceful shutdown has
begun; its stop() waits for the requests in flight without a bound.
WebSocket Support
WebSocket connections are handled natively across all runtimes:
const server = new SmartServe({
port: 3000,
websocket: {
maxPayloadBytes: 64 * 1024,
admit: (context) => {
const origin = context.request.headers.get('origin');
return origin === 'https://app.example.com';
},
onOpen: (peer) => {
console.log(`🔗 Connected: ${peer.id}`);
peer.send('Welcome!');
peer.tags.add('authenticated'); // Tag for filtering
},
onMessage: (peer, message) => {
console.log(`📨 ${message.text}`);
peer.send(`Echo: ${message.text}`);
},
onClose: (peer, code, reason) => {
console.log(`👋 Disconnected: ${peer.id}`);
},
onError: (peer, error) => {
console.error(`❌ Error: ${error.message}`);
},
},
});
admit runs before the WebSocket protocol upgrade on Node.js, Bun, and Deno.
Return false for a 403 rejection, return a Response for a custom rejection,
or return true/void to allow the connection. The request context exposes the
requested host, origin, path, headers, and URL, so applications can enforce
their transport boundary before a peer exists.
The upgrade context also carries context.connectionInfo, the transport-level
peer of the connection being upgraded, and every accepted peer repeats it as
peer.connectionInfo. Use it to limit connections per source; see
Connection Info for the proxy caveat.
Set maxPayloadBytes to an integer from 1 through 2147483647 bytes to reject oversized messages
before TypedRouter JSON parsing or onMessage dispatch. The limit is
enforced by the native runtime where supported and by SmartServe before
application dispatch on every runtime. SmartServe sends WebSocket code 1009
when the runtime exposes the oversized message; a native runtime may terminate
the message before an application close frame can be sent. Omitting the option
preserves the runtime's existing default.
Strict Request Authority
Enable strict authority validation when host identity is a security boundary:
const server = new SmartServe({
port: 3000,
authorityValidation: 'strict',
});
Strict mode requires exactly one syntactically valid Host authority before
SmartServe constructs the request URL. Duplicate, merged, padded, malformed,
userinfo-bearing, path-bearing, and out-of-range-port authorities receive
400 Bad Request. The parsed hostname and effective port must also match the
request URL authority. The default legacy mode preserves historical behavior.
The request URL carries the canonical authority on every runtime: the hostname
in lower case, IPv6 without brackets in hostname, and a fully qualified name's
single trailing dot removed, so Host: Shop.Example.com.:8443 reaches the
handler with the URL host shop.example.com:8443. A second trailing dot
(example.com..) is malformed and answered with 400.
parseRequestAuthority(rawHostValues) exposes the same parser for boundary
code that has access to raw header values. It returns
IParsedRequestAuthority (authority, hostname, port) in that canonical
form and throws InvalidRequestAuthorityError when the authority is missing,
ambiguous, or malformed. Code that matches hostnames, such as a router
selecting a virtual host, should compare against hostname rather than
normalizing a Host header itself.
WebSocket Heartbeat
SmartServe sends WebSocket control-frame pings by default on Node.js and Bun to keep idle proxy paths active and to close stale peers that stop responding with pongs.
const server = new SmartServe({
port: 3000,
websocket: {
heartbeat: {
intervalMs: 30_000, // default
timeoutMs: 15_000, // default, must be lower than intervalMs
},
onMessage: (peer, message) => {
peer.send(`Echo: ${message.text}`);
},
},
});
Set heartbeat: false to disable automatic pings. The heartbeat option also accepts an optional payload string or Uint8Array for ping frames. The Deno adapter does not currently support control-frame heartbeat; explicitly enabling heartbeat on Deno throws during startup.
Generic Raw-Frame Transport
Bind a generic owner when an integration needs normalized binary messages while TypedRouter continues to handle text RPC messages:
import {
SmartServe,
type IWebSocketTransportOwner,
} from '@push.rocks/smartserve';
import { TypedRouter } from '@api.global/typedrequest';
const typedRouter = new TypedRouter();
const priorityFrames = [{ type: 'text' as const, text: 'ready' }];
const binaryFrames = [
{ type: 'binary' as const, data: new Uint8Array([1, 2, 3]) },
];
const transportOwner: IWebSocketTransportOwner = {
onOpen: (peer) => {
peer.rawFrameScheduler?.wake();
},
onFrame: (_peer, frame) => {
if (frame.type === 'binary') {
console.log(frame.data, frame.size);
}
},
pullPriorityFrame: () => priorityFrames.shift(),
pullBinaryFrame: () => binaryFrames.shift(),
onOutboundFrameSettled: (_peer, frame, settlement) => {
console.log(frame.type, settlement.status, settlement.bufferedAmount);
},
onTypedResponseSettled: (_peer, response, settlement) => {
console.log(response.correlation?.id, settlement.status);
},
};
const server = new SmartServe({
port: 3000,
websocket: {
typedRouter,
transportOwner,
},
});
transportOwner and resolveTransportOwner(context) are mutually exclusive.
The resolver runs once before upgrade, and returning undefined rejects the
upgrade with 404. The selected owner, peer object, and per-peer scheduler stay
exact for that physical connection. Owner open, frame, error, and close
callbacks are invoked in native event order; close is delivered once. Raw-frame
listeners, owner frame callbacks, queue pulls, and settlement callbacks are
synchronous enqueue/accounting boundaries. SmartServe catches synchronous
throws and observes an accidentally returned Promise without awaiting it.
onTypedResponseSettled receives the exact TypedRouter response envelope after
its text frame reaches the runtime's canonical sent, accepted, failed, or
rejected settlement. TypedRequest 9 wire requests must carry a fresh,
non-empty requestInstanceId. SmartServe rejects malformed envelopes before
TypedRouter routing and serializes a response only when its method, correlation
ID, and request instance ID exactly match the request.
Owners paired with a TypedRouter may implement onTextFrameAdmission. For each
text arrival, SmartServe calls onFrame, parses the original normalized text,
calls onTextFrameAdmission, and then calls public raw-frame listeners. The
callback receives the original text and a frozen TWebSocketTextFrameAdmission
projection with the original byte size. Its status is routable,
malformed-json, or malformed-envelope; routable projections also contain
the method, correlation ID, request instance ID, and phase, but never expose the
mutable routed DTO. Malformed-envelope projections identify whether the
identity, request payload, or response payload failed validation; request and
response failures retain the valid flat identity fields so transport owners can
preserve their own validation precedence. An unchanged frame can reuse that
parse only when a routing slot is immediately available. Callback-mutated frames
and bounded queued text are parsed again when routed. Owners without this
callback retain the existing owner -> public raw listener -> routing parse order.
Legacy onConnectionOpen, onOpen, onError, and onClose Promise returns are
also observed but never awaited by adapter dispatch, preserving non-blocking
WebSocket lifecycle behavior.
TypedRouter text requests start in normalized text-frame arrival order and may complete independently by correlation. Raw frames therefore continue to reach the owner during long-running handlers. Closing the peer cancels tracked response wrappers, suppresses late responses, and still observes late handler rejections so they cannot become unhandled rejections.
Incoming Node.js, Bun, and Deno payloads are normalized to discriminated text
or binary frames. Binary messages are never passed to TypedRouter JSON parsing.
Use websocket.onRawFrame or server.subscribeWebSocketRawFrame(listener) for
an additive synchronous listener; the subscription method returns an
unsubscribe function. Deno sets binaryType = "arraybuffer"; an unexpected
Blob is rejected instead of creating an asynchronous normalization backlog.
Each peer.rawFrameScheduler.wake() requests one turn. A turn pulls at most
WEBSOCKET_RAW_PRIORITY_FRAME_MAX_PER_TURN priority text/ping/pong messages and
at most WEBSOCKET_RAW_BINARY_FRAME_MAX_PER_TURN binary messages. Size-valid
binary payload admitted to native sends totals at most
WEBSOCKET_RAW_BINARY_BYTES_MAX_PER_TURN. While priority quota remains,
priority work is rechecked between binary messages, with a binary slot after
four consecutive priority messages when both queues have work. Native sends
are submitted in order through an eight-frame, 256 KiB window; callbacks settle
independently by exact frame identity. One larger priority message may run alone.
At most one pulled frame waits for byte capacity. Deno's native buffered amount
also pauses admission until it drains. Stopping cancels all observed sends and
settles a waiting frame without waiting for native callbacks. The owner uses settlement and
bufferedAmount to decide whether and when to wake again. Explicitly requested
follow-up turns start in a later macrotask, using an immediate task when the
runtime provides a cancellable immediate pair and a zero-delay timer otherwise.
Repeated wakes before a turn starts,
or repeated wakes while one turn is running, coalesce into at most one pending
follow-up turn. Reaching a turn limit does not implicitly request another turn.
Binary messages larger than
WEBSOCKET_RAW_BINARY_FRAME_MAX_BYTES (32 KiB) settle as rejected and are not
split. The owner retains its queues and accounting and receives sent,
accepted, failed, or rejected settlement. Bun resumes after its native
drain callback only when an already-requested turn encountered backpressure;
Deno reports native acceptance and bufferedAmount but cannot confirm network
delivery or send explicit ping/pong control frames.
TypedRouter for Type-Safe RPC
Use @api.global/typedrequest 9.1 or later within the 9.x line for type-safe
WebSocket communication:
import { TypedHandler, TypedRouter } from '@api.global/typedrequest';
interface IProcessRequest {
method: 'process';
request: { value: string };
response: { result: string };
}
const typedRouter = new TypedRouter();
typedRouter.addTypedHandler(new TypedHandler<IProcessRequest>(
'process',
async (request) => ({ result: request.value }),
));
const server = new SmartServe({
port: 3000,
websocket: {
typedRouter, // Handles message routing automatically
onOpen: (peer) => {
peer.tags.add('subscriber');
},
},
});
// Broadcast to tagged connections
server.broadcastWebSocketByTag('subscriber', { event: 'update' });
When typedRouter is configured, SmartServe supplies the accepted peer as
server-owned tools.localData.peer for every handler. Caller-supplied
localData is discarded, so a network client cannot forge the peer or its
request context. SmartServe does not derive handler cancellation from wire data;
cancellation-aware transports such as TypedSocket own trusted incoming request
signal registration on the selected router.
Resolve a different router for each accepted connection when one listener serves isolated hosts or protocol surfaces:
const server = new SmartServe({
port: 3000,
authorityValidation: 'strict',
websocket: {
admit: (context) => allowedOrigins.has(context.headers.get('origin') ?? ''),
resolveTypedRouter: (context) => {
if (context.url.hostname === 'public.example.com') return publicRouter;
if (context.url.hostname === 'admin.example.com') return adminRouter;
return undefined;
},
},
});
The resolver runs once after admission and before upgrade. SmartServe binds the
selected router to that peer for its full lifetime; returning undefined
rejects the upgrade with 404. resolveTypedRouter is mutually exclusive with
typedRouter and onMessage. Connection registry, connection lifecycle,
tag-query, and broadcast APIs work whenever websocket is configured, including
plain hooks, static or resolved TypedRouter, and generic transport-owner modes.
The accepted peer exposes that exact immutable selection as
peer.routingSurface; it is undefined when the connection has no TypedRouter.
Transport owners can use object identity against this property when authorizing
separately routed responses.
server.hasWebSocketConnection(peer) performs an O(1) exact-identity check. It
returns true only while that same peer object is the connection currently
registered under its ID; another peer object with the same ID does not match.
HTTPS/TLS
Enable HTTPS with certificate configuration:
import * as fs from 'fs';
import {
SmartServe,
validateNodeTlsConfig,
type ITLSConfig,
} from '@push.rocks/smartserve';
const tls: ITLSConfig = {
cert: fs.readFileSync('./cert.pem'),
key: fs.readFileSync('./key.pem'),
ca: fs.readFileSync('./ca.pem'), // Optional: CA chain
minVersion: 'TLSv1.2', // Optional: minimum TLS version
passphrase: 'optional-key-passphrase',
};
validateNodeTlsConfig(tls);
const server = new SmartServe({
port: 443,
tls,
});
validateNodeTlsConfig() checks Node.js ALPN identifier limits and parses the
certificate, private key, optional CA, passphrase, minimum TLS version, and
secure-context input without constructing a server or listener. This explicit
preflight is useful when a caller must reject TLS input before creating other
lifecycle resources.
cert, key and ca are PEM, as text or bytes, on every runtime. Each
runtime honours an option or refuses it when the server starts, never
ignores it:
| Option | Node.js | Bun | Deno |
|---|---|---|---|
cert, key |
yes | yes | yes |
ca |
yes | yes | refused (append intermediates to cert) |
passphrase |
yes | yes | refused (unencrypted key only) |
minVersion |
yes | yes (TLS 1.2 is the floor) | TLSv1.2 yes (the floor), TLSv1.3 refused |
alpnProtocols |
yes | refused (Bun negotiates no ALPN protocol) | refused (Deno negotiates h2 and http/1.1 itself) |
Deno serves HTTP/2 over TLS; Node.js and Bun serve HTTP/1.1.
Unix Socket Listener
Listen on a Unix domain socket instead of a TCP port with unixSocket. HTTP
routes, static files, WebDAV and WebSocket upgrades (including TypedRouter
routing) work over it exactly as over TCP. The Node.js and Deno adapters
support it, deno compile binaries included; the Bun adapter rejects it at
start().
import { SmartServe, UnixSocketPathUnavailableError } from '@push.rocks/smartserve';
const server = new SmartServe({
unixSocket: {
path: '/run/myservice/control.sock',
mode: 0o660, // default 0o600
gid: 0, // optional uid / gid of the socket file
},
websocket: { typedRouter },
});
try {
const instance = await server.start();
console.log(instance.socketPath); // '/run/myservice/control.sock'
} catch (error) {
if (error instanceof UnixSocketPathUnavailableError) {
// error.reason: 'listening' (another process serves the path)
// or 'not_a_socket' (the path is some other file)
}
throw error;
}
SmartServe owns the socket file:
- It binds inside a private directory (mode
0700) next topath, sets the socket'smodeand, when configured, itsuid/gid, and only then places it onpath. No process can connect before the permissions apply. Changing the owner needs the privilege tochown. start()fails withUnixSocketPathUnavailableErrorwhen a process accepts connections onpathor whenpathis not a socket, checked whenstart()begins and again when the socket is published; the path is left untouched. A free path is taken with a hard link, which fails when any file got there first, so of two servers starting on one path exactly one wins and the other fails withreason'listening'.- A socket file at
paththat nobody listens on (left behind by a crashed process) is replaced by a rename right after a fresh liveness check. Any file placed on the path between that final check and the rename, a window of microseconds, is replaced as well. - The bind path is
dirname(path)plus 12 bytes (/.ssXXXXXX/s). It andpathmust fit the socket path limit (107 bytes on Linux, 103 on macOS); a basename of 11 bytes or more adds no constraint beyondpathitself. stop()removes the socket file, unless another listener has put its own socket atpathmeanwhile.
unixSocket is exclusive with port and hostname, and TLS is not available
on a Unix socket listener: access is governed by the socket file's permissions.
The running instance reports socketPath, with port 0 and hostname ''.
Request URLs use http:// and the request's Host header on every runtime,
so ctx.url and authorityValidation: 'strict' behave as on TCP. Unix peers
have no address: connectionInfo reports remoteAddr 'unknown',
remotePort 0, the socket path as localAddr and localPort 0.
A Node.js client reaches the listener with http.request({ socketPath, path }),
and the ws package with new WebSocket('ws+unix:///run/myservice/control.sock:/path').
Error Handling
Built-in HTTP error classes with factory methods:
import { HttpError, type IRequestContext } from '@push.rocks/smartserve';
@Route('/api')
class ApiController {
@Get('/users/:id')
async getUser(ctx: IRequestContext) {
const user = await findUser(ctx.params.id);
if (!user) {
throw HttpError.notFound('User not found', { id: ctx.params.id });
}
return user;
}
}
// Available factory methods:
HttpError.badRequest(message, details); // 400
HttpError.unauthorized(message, details); // 401
HttpError.forbidden(message, details); // 403
HttpError.notFound(message, details); // 404
HttpError.methodNotAllowed(message, details); // 405
HttpError.conflict(message, details); // 409
HttpError.unprocessableEntity(message, details); // 422
HttpError.tooManyRequests(message, details); // 429
HttpError.internalServerError(message, details); // 500
HttpError.notImplemented(message, details); // 501
HttpError.badGateway(message, details); // 502
HttpError.serviceUnavailable(message, details); // 503
Global Error Handler
const server = new SmartServe({
port: 3000,
onError: (error, request) => {
console.error('💥 Server error:', error);
// Return custom error response
return new Response(
JSON.stringify({ error: 'Something went wrong', requestId: crypto.randomUUID() }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
},
});
Request Context
Every handler receives a typed request context:
interface IRequestContext<TBody = unknown> {
request: Request; // Original Request (body never consumed by framework)
params: Record<string, string>; // URL path parameters (/users/:id → { id: '123' })
query: Record<string, string>; // Query string (?page=1 → { page: '1' })
headers: Headers; // Request headers
path: string; // Matched route path
method: THttpMethod; // GET, POST, PUT, DELETE, etc.
url: URL; // Full URL object
runtime: 'node' | 'deno' | 'bun';
connectionInfo?: IConnectionInfo; // Transport-level peer (see Connection Info)
state: Record<string, unknown>; // Per-request state (share data between interceptors)
// 🔥 Lazy body parsing (cached after first call)
json(): Promise<TBody>; // Parse as JSON (typed!)
text(): Promise<string>; // Parse as text
arrayBuffer(): Promise<ArrayBuffer>;
formData(): Promise<FormData>;
}
Path Parameters are percent-decoded once after the route matched the path as sent, so /users/org%3Aa%2Fb gives ctx.params.id the value org:a/b (an encoded / stays inside its parameter), and a parameter that is not valid percent-encoded UTF-8 (%E0%A4%A, a lone %) answers 400. A handler that puts a parameter into a file path or a URL checks the decoded value first: ..%2F arrives as ../.
Lazy Body Parsing: The request body is only consumed when you call json(), text(), etc. This allows raw access to ctx.request for cases like webhook signature verification:
@Post('/webhook')
async handleWebhook(ctx: IRequestContext) {
// Get raw body for signature verification
const rawBody = await ctx.request.text();
const signature = ctx.headers.get('X-Signature');
if (!verifyHmac(rawBody, signature)) {
throw HttpError.unauthorized('Invalid signature');
}
// Parse the body manually
const payload = JSON.parse(rawBody);
return { processed: true };
}
Connection Info
ctx.connectionInfo reports the transport-level peer of the connection a
request arrived on. SmartServe populates it for every route handler context, for
every WebSocket upgrade context handed to websocket.admit, and on every
WebSocket peer as peer.connectionInfo (the same object as
peer.context.connectionInfo). The field is optional on the type only because
IRequestContext and IWebSocketPeer values are also constructed outside this
package (test doubles, embedding servers) and those literals predate the field.
interface IConnectionInfo {
remoteAddr: string; // direct peer address, or 'unknown'
remotePort: number; // direct peer port, or 0
localAddr: string; // bound listener address
localPort: number; // bound listener port
encrypted: boolean; // TLS terminated by this server
tlsVersion?: string; // negotiated TLS version where the runtime reports one
}
@Route('/api')
class ApiController {
@Get('/whoami')
whoami(ctx: IRequestContext) {
return { peer: ctx.connectionInfo?.remoteAddr };
}
}
const server = new SmartServe({
port: 3000,
websocket: {
// Limit connections per source before a peer exists.
admit: (context) => acceptSource(context.connectionInfo?.remoteAddr),
onOpen: (peer) => trackSource(peer.id, peer.connectionInfo?.remoteAddr),
},
});
This is the direct peer, not the end client behind a proxy. When a reverse
proxy sits in front of SmartServe, remoteAddr is the proxy's address.
SmartServe never inspects X-Forwarded-For, Forwarded, or PROXY protocol and
has no trust-proxy configuration: deciding which proxies are trusted and
resolving a client address from their headers is the consumer's
responsibility, because only the deployment knows its trust boundary.
The values are reported exactly as the runtime reports them and are never
normalized. A dual-stack Node.js listener reports IPv4 peers as IPv4-mapped
IPv6 addresses (::ffff:1.2.3.4), so code that compares addresses must expect
that form. remoteAddr is 'unknown' with remotePort 0 when the runtime
cannot name the peer — a socket that was already destroyed, or Bun's
server.requestIP() returning null for a closed request — and for every peer of
a Unix socket listener, which reports the socket path as
localAddr and 0 as localPort. Treat 'unknown' as an unidentified source,
never as an address.
Per runtime, the value comes from the runtime's own connection API:
| Runtime | HTTP request | WebSocket upgrade |
|---|---|---|
| Node.js | request.socket.remoteAddress / remotePort |
the same socket, read on the upgrade event before admission runs |
| Bun | server.requestIP(request) |
server.requestIP(request) before server.upgrade() |
| Deno | info.remoteAddr of the Deno.serve handler |
the same handler info, before Deno.upgradeWebSocket() |
Custom Request Handler
Bypass decorator routing entirely for low-level control:
const server = new SmartServe({ port: 3000 });
server.setHandler(async (request, connectionInfo) => {
const url = new URL(request.url);
if (url.pathname === '/health') {
return new Response('OK', { status: 200 });
}
if (url.pathname.startsWith('/api')) {
// Handle API routes manually
const body = await request.json();
return new Response(JSON.stringify({ received: body }), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('Not Found', { status: 404 });
});
await server.start();
An error the custom handler throws is answered through onError when you set
one, which then owns reporting it. Without onError the client gets a bare
500 that names nothing, and SmartServe logs the error once with
console.error('Custom request handler failed:', error), its class and message
included.
Dispatching to controllers yourself
An integration that dispatches requests itself keeps its own
new ControllerRegistry(), matches requests with matchRoute(), and runs the
matched route with executeRoute(), the code path SmartServe's own dispatch
uses:
import { ControllerRegistry, HttpError, withResponseHeaders, type THttpMethod } from '@push.rocks/smartserve';
const registry = new ControllerRegistry();
registry.registerController(new UserController());
server.setHandler(async (request, connectionInfo) => {
const url = new URL(request.url);
const method = request.method.toUpperCase() as THttpMethod;
// a CORS preflight to a route with a CORS policy, answered without running the route
const preflight = registry.matchPreflight(url.pathname, method, request.headers);
if (preflight) {
return registry.executePreflight(preflight, createRequestContext(request, preflight.params, connectionInfo));
}
const match = registry.matchRoute(url.pathname, method);
if (!match) {
return new Response('Not Found', { status: 404 });
}
// Your IRouteContext implementation, carrying match.params and responseHeaders: new Headers()
const context = createRequestContext(request, match.params, connectionInfo);
try {
const response = await registry.executeRoute(match.route, context);
// SmartServe compresses the response with the route's @Compress/@NoCompress settings
return { response, compression: match.route.compression };
} catch (error) {
const answer = error instanceof HttpError
? error.toResponse()
: new Response('Internal Server Error', { status: 500 });
// the headers an admission or a handler set for the request
return withResponseHeaders(answer, context.responseHeaders);
}
});
matchRoute(path, 'HEAD') answers the GET route's match when no HEAD or
ALL route matches, so a dispatcher answers HEAD by running it as above;
SmartServe's adapters send its header fields and release its body unread.
matchRoute() and matchPreflight() take the path as sent and hand out its
parameters percent-decoded; for a parameter that is not valid percent-encoded
UTF-8 they throw an HttpError 400, which SmartServe's own dispatch answers
with error.toResponse() (through onError when set).
That 400 comes before any admission, so it is not counted against a token,
carries none of the admission's responseHeaders and is not the API's own
refusal. A registry that answers it as refused input instead sets
malformedPathParameters: 'refuse' (default 'throw'):
const registry = new ControllerRegistry({
admission: tokenAdmission,
malformedPathParameters: 'refuse',
// also answers `GET /contacts/ct%`: source 'params', keyword 'encoding'
requestValidationResponse: (failure, ctx) => problemOf(failure, ctx),
});
matchRoute() then returns the match without throwing: the malformed
parameters are absent from params and named in malformedParams, so their
value is never handed out, decoded or as sent. executeRoute() runs the
admissions and then refuses the request before its guards, validation and
handler through requestValidationResponse (the default json answer when
unset, with validation on or off), one error per parameter:
{ path: '/<name>', message: 'path parameter "<name>" is not valid percent-encoded UTF-8', keyword: 'encoding' }.
It decides from the request itself, not from the match: a parameter of the
route that the context's params lack, or whose segment of the request's URL
is malformed, is refused, so a dispatcher that copies or rebuilds params
cannot let one through. A dispatcher that reads a match without
executeRoute() checks malformedParams itself. matchPreflight() throws as
before: a preflight is answered before any admission.
SmartServe takes the same option and hands it to the registry it builds:
new SmartServe({ malformedPathParameters: 'refuse', openapi: { requestValidationResponse } }).
matchPreflight(path, method, headers) answers null for every request but a
CORS preflight to a route with a policy (CORS), and
executePreflight(match, context) answers that preflight, so a dispatcher
that never serves routes with a policy can leave both out.
executeRoute(route, context) decides the request's origin under the route's
CORS policy, runs the registry's and the route's
admission (Admission), the route's request
interceptors (OpenAPI validation, class- and method-level guards including
rateLimit(), @Intercept), the handler, and the response interceptors (@Transform) in
reverse order, and converts the result to a Response: a Response unchanged,
null or undefined as 204, a string as text/plain, anything else as JSON.
When the registry validates responses (responseValidation), it then validates
that Response against the route's documented responses, and adds the
context's responseHeaders to the Response that answers.
Calling match.route.handler directly skips all of that. Errors thrown by an
interceptor or the handler reject the returned promise, so the dispatcher
applies its own error policy.
SmartServe compresses a Response a custom handler answers with under the
server's compression configuration. The dispatcher above answers with an
IRouteResponse, { response, compression: match.route.compression }, so
SmartServe compresses the route's response as its own dispatch does: a
@NoCompress route stays uncompressed, and a @Compress({ level }) route is
compressed at its level, even when the server's compression is disabled. A
response that already carries a Content-Encoding is never compressed again.
Runtime Detection
SmartServe automatically detects and optimizes for the current runtime:
const instance = await server.start();
console.log(instance.runtime); // 'node' | 'deno' | 'bun'
console.log(instance.port); // Actual bound port
console.log(instance.hostname); // Actual bound hostname
console.log(instance.secure); // true if TLS enabled
// Server statistics
const stats = instance.stats();
console.log(stats.uptime); // Seconds since start
console.log(stats.requestsTotal); // Total requests handled
console.log(stats.requestsActive); // Currently processing
Pass port: 0 to let the operating system select an available port. The
resolved listener endpoint is returned by start() on Node.js, Deno, and Bun:
const ephemeralServer = new SmartServe({ port: 0, hostname: '127.0.0.1' });
const ephemeralInstance = await ephemeralServer.start();
console.log(ephemeralInstance.port); // A positive OS-assigned port
Migrating from 8.x
SmartServe 9 uses @api.global/typedrequest 9.1 throughout its public
WebSocket contracts. Upgrade a consumer's direct TypedRequest dependency to
^9.1.0 and use TypedSocket and TypedServer releases that support the same
router major. TypedRequest routers from 8.x have different private state and
cannot be passed to websocket.typedRouter, returned by
websocket.resolveTypedRouter, or assigned to IWebSocketPeer.routingSurface.
Construct a shared HTTP/WebSocket router with the consumer's TypedRequest 9
dependency and pass that exact instance to both transports.
TypedRouter.routeAndAddResponse() returns Promise<T | null>. It returns
null for a malformed envelope, an authority refusal, or a response-phase
envelope that has no response to send. SmartServe's WebSocket transport drops
those outcomes. An HTTP handler that calls the router owns its refusal:
const answer = await router.routeAndAddResponse(body, {
trustedLocalData: { authenticatedActor },
trustedAbortSignal: request.signal,
});
return answer ? Response.json(answer) : new Response(null, { status: 400 });
A router subclass must declare Promise<T | null> and check for null before
reading or changing the returned envelope. Supply authenticated context through
trustedLocalData; TypedRequest discards caller-supplied localData. SmartServe
continues to supply the accepted physical peer as tools.localData.peer to
WebSocket handlers, so an application authority guard can check the exact peer
and its admission context before dispatch.
Migrating from 6.x
7.0 removes the process-global controller registry. In 6.x every @Route class
and every ControllerRegistry.addRoute() route belonged to the whole process:
a route registered for one SmartServe instance also answered requests on every
other instance in the process, bypassing whatever access checks that other
server applies. Each server now owns its routes, and nothing is registered
implicitly.
| 6.x | 7.0 |
|---|---|
@Route registers the class process-wide; a controller nobody registered is constructed and served implicitly |
@Route only describes the class; server.register(UserController) serves it on that server |
ControllerRegistry.addRoute(path, method, handler) |
server.addRoute(path, method, handler); the returned function removes the route |
ControllerRegistry.registerInstance(controller) |
server.register(controller), or registry.registerController(controller), which returns an unregister function |
ControllerRegistry.matchRoute(path, method) |
server.controllerRegistry.matchRoute(path, method) |
ControllerRegistry.compileRoutes(enableValidation) |
registry.compileRoutes() |
ControllerRegistry.registerClass(), .removeRoute(), .clear() |
removed; tests create a fresh new ControllerRegistry() instead of clearing shared state |
new OpenApiGenerator(options) |
new OpenApiGenerator(registry, options) |
createOpenApiHandler(options) |
createOpenApiHandler(registry, options) |
Warning: rate limits behind a reverse proxy.
rateLimit()now keys on the transport peer address. Behind a reverse proxy that address is the proxy's, so a deployment that does not pass{ key }throttles all of its users together as one client. Pass akeythat reads a header your proxy sets, as shown under Guards.
Behavior changes to check:
- A registry serves one instance per controller class. Registering a second instance of an already registered class throws instead of shadowing the first one.
- Controllers stay registered across
stop()andstart(). Stopping one server no longer changes which controller instance another server uses, andregister()is accepted in every lifecycle state. - The OpenAPI document of a server lists only the controllers registered with that server.
- Method-level decorators apply once per class. In 6.x every construction of a controller added its method guards, transforms, and interceptors again, so a class registered with two servers transformed its responses twice.
openapi.validation: falseswitches request validation off. 6.x ignored the option and always validated.- Only methods with an HTTP-method decorator are routes. 6.x served a method
that carried only modifier decorators (
@Guard,@Compress,@Api…) atGET <basePath>. rateLimit()keys on the transport peer address, IPv6 peers per /64 network. 6.x keyed on the client-suppliedX-Forwarded-Forheader; pass{ key }to key on a header your proxy sets. A request without a usable key is rejected, and its counters belong to each controller registration.- Controller metadata is per class, and subclasses resolve it from their class
chain as described in Controller Inheritance. In
6.x
@Routeon a subclass overwrote the base class's base path. getControllerMetadata()returns the resolved metadata a class is served with;IControllerMetadata.targetis the class, typedFunction. Itsroutesstay empty until the class has been constructed once: method decorators declare their routes when the first instance is created, because decorator metadata (Symbol.metadata) is unavailable on Node.js and Bun. Registration always constructs the controller first; construct a class yourself before inspecting one you never registered.IRegisteredController.instanceis typedobjectinstead ofany.
An integration that dispatches requests itself through setHandler() keeps its
own new ControllerRegistry(), resolves requests with matchRoute(), and runs
the matched route with executeRoute(), as described in
Dispatching to controllers yourself.
Calling route.handler directly skips the route's guards, transforms, and
OpenAPI validation.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.