jkunz e1f06dbb5b
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 0s
Default (tags) / metadata (push) Skipped
v9.0.0
2026-10-06 18:34:49 +00:00
2026-10-06 18:34:49 +00:00
2026-10-06 18:34:49 +00:00
2026-10-06 18:34:49 +00:00

@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

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 @Route path, 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 a rateLimit() 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. A key function rejects a request by returning undefined.
  • Bounded memory. At most maxKeys clients (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 takes maxKeys × maxRequests admitted requests per window. Memory is bounded by maxKeys × maxRequests timestamps.
  • maxRequests, windowMs and maxKeys must be positive integers; anything else, such as NaN, 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:

  1. the registry's admission, then the route's (IAddRouteOptions.admission)
  2. under malformedPathParameters: 'refuse', the refusal of a malformed path parameter (requestValidationResponse answers it)
  3. the route's request validation (requestValidationResponse answers a refusal)
  4. class- and method-level @Guard and @Intercept request interceptors
  5. the handler, then the response interceptors (@Transform)
  6. the route's response validation (responseValidationResponse replaces an invalid response under enforce)
  7. 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's Origin may read the route's answers; it may be async and gets the request's context with the route's params. It runs at most once per request, and only for a request that carries an Origin. Anything but a boolean rejects the request with a TypeError. An opaque origin arrives as the string null.
  • allowHeaders: the request headers a preflight allows besides the CORS-safelisted ones (Authorization is 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-Origin with that origin (never *) and Access-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 ReadableStream body) 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.

  • report sends the response unchanged and passes each failure to onResponseValidationError, which it requires.

  • enforce passes each failure to the hook when one is set, cancels the invalid response's body, and answers 500 {"error":"Response validation failed"} (application/json) instead, or what responseValidationResponse chooses. 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-Match names the state a client read, in whichever coding it read it: a replacement sent with If-Match: "abc-gzip" holds while the handler's ETag is "abc".
  • If-None-Match names a response the client holds, so only the tag of a coding the request's Accept-Encoding still admits is taken back; the tag of another coding matches nothing. A handler's 304 to a client that named a compressed form carries that form's ETag (the one of the coding the request is answered in, where it named several) and Vary: 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 to path, sets the socket's mode and, when configured, its uid/gid, and only then places it on path. No process can connect before the permissions apply. Changing the owner needs the privilege to chown.
  • start() fails with UnixSocketPathUnavailableError when a process accepts connections on path or when path is not a socket, checked when start() 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 with reason 'listening'.
  • A socket file at path that 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 and path must fit the socket path limit (107 bytes on Linux, 103 on macOS); a basename of 11 bytes or more adds no constraint beyond path itself.
  • stop() removes the socket file, unless another listener has put its own socket at path meanwhile.

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 a key that 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() and start(). Stopping one server no longer changes which controller instance another server uses, and register() 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: false switches 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…) at GET <basePath>.
  • rateLimit() keys on the transport peer address, IPv6 peers per /64 network. 6.x keyed on the client-supplied X-Forwarded-For header; 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 @Route on a subclass overwrote the base class's base path.
  • getControllerMetadata() returns the resolved metadata a class is served with; IControllerMetadata.target is the class, typed Function. Its routes stay 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.instance is typed object instead of any.

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.


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.

S
Description
a cross platform server
Readme
7.9 MiB
Languages
TypeScript 100%