Skip to content
GitHub Actions edited this page Sep 22, 2026 · 9 revisions

rAPId

A cross-runtime API framework for Deno, Bun, Node.js and Cloudflare Workers. One application object registers HTTP routes, WebSocket (RPC) commands, and cron jobs, and runs them through a single universal middleware/context cycle — assembled either Oak-style from functions (app.get(...), app.use(...), app.job(...)) or from decorated classes (@Module/@GET/@SOCKET/@JOB). Observability is built in: structured logging (@tundralibs/slogger) is always on with per-request correlation, distributed tracing (@tundralibs/tracer) and metrics (@tundralibs/metro-man) are opt-in, and the transport layer is an adapter so the same app serves from a listening server (app.start()) or a fetch handler (app.fetch(request)).

Deno Bun Node.js Cloudflare Workers

Pre-1.0. The API described here is real and verified against source; minor releases may still move it.

Installation

The fastest way in is the CLI — it scaffolds a whole runnable app (main.ts, configs/Application.yaml, both deno.json and package.json, and the project's AI guide) in one step, adding @tundralibs/rapid itself as part of the scaffold:

deno run -A jsr:@tundralibs/rapid/cli init my-api --module --norm --ui

See CLI below for every command and flag. To add rapid to an existing project instead:

Deno:

deno add @tundralibs/rapid

Bun:

bunx jsr add @tundralibs/rapid

Node.js:

npx jsr add @tundralibs/rapid

CLI

Command Does
init [name] [--module] [--norm] [--ui] [--with bootstrap|pico] [--yes] Scaffold a project (interactive unless --yes).
upgrade [--dir .] Bump every @tundralibs/* dependency to its latest release.
modules [dir] [--check] [--force] (Re)generate the modules barrel (--check fails CI when it's stale; an existing hand-written mod.ts needs --force).
health [url] [--path /healthz] Hit a running app's health path; exit 0 on 2xx.

init has no runtime prompt — both deno.json and package.json are always written (every package in this monorepo ships both), so the scaffold runs on Deno, Bun, or Node unmodified; there's no Docker or CI-workflow generation in this pass. --module adds the module system (a sample Greeter module); --ui scaffolds the three-tier UI starter (core + layout + a templated page on server.static), and --with bootstrap|pico adds a self-hosted CSS framework under public/vendor/; --yes accepts every default non-interactively. A .gitignore is written; git init is left to you.

--norm adds a norm schema (models/Users.ts + models/mod.ts) and a dialect-agnostic db.ts — the dialect itself is DATA, not code: it lives in configs/Norm.yaml (every dialect norm supports is shown there, one active at a time) next to configs/Application.yaml, loaded the same way. db.ts never changes when you switch dialects.

Every scaffold also writes the project's AI guide: one entry point, AGENTS.md, with CLAUDE.md (which imports it with @AGENTS.md) and .github/copilot-instructions.md pointing at it, plus the generated reference files it links — rapid.agent.md and rapid-pact.agent.md always, and rapid-modules.agent.md / rapid-ui.agent.md / norm.agent.md for the layers you chose. The guide is rendered for this project and this rapid version: its module layout if you chose --module, the context API, the middleware catalog and the error registry (generated from the code), doc links pinned to the installed version, rapid's actual API (the :id: route grammar, the { content } reply, validated(), harness()/client()), the org coding conventions fitted to an app, and the verified shape of each @tundralibs/* package an agent may reach for (guardian, norm, oql, pact, cacher, id, crypt, restler, utils, slogger, …). If you chose --norm, that guide also carries a real merge of norm's own AI guide (schema, hooks, querying, transactions, scoping, encryption, caching, events, errors) — not a shorter summary.

Quick start

Application.initialize() is the only way to make an app — the constructor is private, so an app is always built the same way and can never silently skip its config. It's async (config loading is), and takes either plain options or a config directory. Register a couple of routes and start the listener:

import { Application } from '@tundralibs/rapid';

const app = await Application.initialize({ name: 'hello' });

// A handler either RETURNS the response payload or sets `ctx.response`.
app.get('/', () => ({ content: { message: 'hello world' } }));
app.get('/users/:id:', (ctx) => ({ content: { id: ctx.params.id } }));

await app.start();
console.log(`listening on ${app.address}`);

Passing plain options is the programmatic shape (tests, scripts) — nothing is read from disk. In production, hand initialize a config directory instead: the set named Application (Application.yaml/.json/…) becomes the application options, and every other set (a database config, your own settings) stays readable via app.config.

Given configs/Application.yaml:

name: my-api
mode: PRODUCTION
server:
  port: 8008
  hostname: 0.0.0.0
  # ${VAR} references are interpolated from the environment / .env
  tls: # file paths (certFile/keyFile) or inline PEM (cert/key) — always both
    certFile: ${TLS_CERT_FILE}
    keyFile: ${TLS_KEY_FILE}
shutdownTimeout: 10 # seconds
import { Application } from '@tundralibs/rapid';

// String form: load ./configs, with .env interpolation on by default.
const app = await Application.initialize('./configs');
app.config.get<string>('database.host'); // any other set, as loaded
await app.start();

The object form takes finer control — a different env source and a different application file name:

import { Application } from '@tundralibs/rapid';

const app = await Application.initialize({
  path: './configs',
  env: './deploy/prod', // true | false | a directory holding .env, or a path ENDING in .env — omit for NO substitution (the string form implies true)
  applicationSet: 'Api', // read Api.yaml instead of Application.yaml
});
await app.start();

Either shape yields the same Application, so everything below applies whether you passed options or loaded from config.

name is required (it is also the logging appName, max 30 chars). Common options: mode ('DEVELOPMENT' | 'PRODUCTION', default 'PRODUCTION' — controls error disclosure and log level), server, jobs, uploads, logger, tracer, stateMode, and shutdownTimeout.

Routing

Paths are @tundralibs/radrouter-native: route parameters are colon-wrapped (/users/:id:, not express-style :id). The five verb helpers (get/post/put/patch/delete) and the generic route(method, path, ...) all take an optional chain of route-scoped middleware followed by the handler last.

Versioning

API versioning is a dimension separate from the path. Configure how the inbound version is resolved on server.versioning, then tag routes with a version:

import { Application } from '@tundralibs/rapid';

const app = await Application.initialize({
  name: 'api',
  server: {
    // mode: 'header' | 'accept' | 'path'
    versioning: { mode: 'header', identifier: 'x-api-version', default: 'v1' },
  },
});

// A request with no version header resolves to the `default` (v1).
app.route('GET', '/report', () => ({ content: { shape: 'v1' } }));
// Same path, explicit version slot — matched only for v2.
app.route('GET', '/report', { version: 'v2' }, () => ({
  content: { shape: 'v2', _new: true },
}));

mode: 'header' reads the identifier header; 'accept' matches an application/vnd.<identifier>.<version>+… media type; 'path' treats identifier as a capture regex over a leading path segment (stripped before routing). On the decorator API the same slot is set with @GET(path, { version }) (and a module-wide default via @Module({ version })).

Middleware

app.use(...) registers universal middleware — the outer onion, in order, on every transport's invocation cycle (HTTP requests, socket frames, and job firings alike). Narrow to a transport inside the middleware via ctx.type, or use the scope helpers. Route- and command-scoped middleware are passed inline before the handler — on a decorated route, through the middleware option of @GET/@SOCKET (one route) or @Module (every route in the class).

import { Application } from '@tundralibs/rapid';
import { cors, secureHeaders } from '@tundralibs/rapid';

const app = await Application.initialize({ name: 'api' });

app.use(secureHeaders(), cors());

The correlation id, the response time and the access log are the core's, not middleware: every HTTP response carries x-request-id (a validated inbound value is adopted, else one is minted) and x-response-time — rename or extend them under the headers option — and every invocation (request, socket frame, job firing) writes one access line through the app's slogger — GET /users 200 12ms on a console, with status, ms, matched, surface and the error code as structured fields for logfmt/JSON handlers — level by outcome. Tune or silence it under logger.access (enabled, skip paths, a slow threshold in seconds, opt-in client fields).

Shipped middleware factories (all exported from the root and from @tundralibs/rapid/middlewares): cors, secureHeaders, compress, etag, csrf, session, rateLimit, idempotency, and timeout; pactAuth lives on its own subpath, @tundralibs/rapid/middlewares/pact. Every option, default, unit and pitfall is in the middleware catalog. Static file serving is CONFIG, not a middleware — server.static maps URL prefixes to directories, served framework-side on route miss (routes always win; secureHeaders/cors/ logging always apply; traversal/symlink-guarded, weak-ETag 304s, byte ranges, and immutable fingerprinted URLs included). idempotency({ scope }) makes client retries safe: a request bearing an idempotency-key header executes once — a retry replays the first attempt's stored reply (idempotency-replayed: true), a concurrent duplicate is a 409, and thrown/streamed attempts are never recorded, so those retries re-execute. scope is required and keys replays per caller identity (session id, auth subject); scope: false explicitly opts into a shared key space (e.g. a webhook receiver keyed by the provider's event id). Note the stateful middlewares (session, rateLimit, idempotency) default to a per-process in-memory store — correct on one replica, invisible across replicas: hand each factory its persistence hooks (pact-style, one purpose per hook — session's getSession/saveSession/deleteSession, rateLimit's atomic increment, idempotency's set-if-absent claim) over redis/cacher the moment you scale out, and bound that store yourself (of the bundled defaults, rateLimit caps distinct keys at maxKeys and idempotency at maxRecords; session caps live sessions at maxSessions).

How the onion runs — next()'s contract, short-circuiting, post-processing, which headers survive an error, and how to write your own factory — is the first section of the middleware catalog. Scope helpers turn a transport-specific middleware into a universal one: onlyHTTP / onlySOCKET / onlyJOB run it only on that transport (a no-op elsewhere), while guardHTTP / guardSOCKET / guardJOB run it there and block other transports (fail-closed — the right choice for auth):

import { Application } from '@tundralibs/rapid';
import { guardHTTP, timeout } from '@tundralibs/rapid';

const app = await Application.initialize({ name: 'api' });
app.use(guardHTTP(timeout(5))); // seconds

Modules

For larger apps, group routes/commands/jobs on decorated classes. Decorators are metadata-only (TC39 standard) — they never wrap the method. Mount a decorated instance with app.module(instance):

import { Application } from '@tundralibs/rapid';
import { GET, Module, param } from '@tundralibs/rapid/decorators';
import type { RapidContextResponse } from '@tundralibs/rapid';

@Module('Users', { prefix: '/users' })
class Users {
  @GET('/:id:', { bind: [param('id')] })
  find(id: string): RapidContextResponse {
    return { content: { id } };
  }
}

const app = await Application.initialize({ name: 'demo' });
app.module(new Users());

@Module adds an HTTP prefix (paths only), a namespace (joined onto the flat @SOCKET/@JOB names), and a default route version. prefix and a route's path both accept a LIST, and the two multiply — @Module('Users', { prefix: ['', '/:orgCode:'] }) serves every route in the class both tenant-scoped and unscoped from one declaration. Argument binders (param, query, payload, paging, header, cookie, auth, session, connection, config) type the method signature via the decorator's bind tuple (all from @tundralibs/rapid/decorators; the root re-exports them too, except the session binder — the root's session is the middleware). config('auth.hmac.maxSkew') binds a value from the loaded config sets on any transport (the set is the file basename lowercased — Auth.yaml → auth.… — and the keys after it are case-sensitive; a missing path binds undefined); middleware reads the same sets via ctx.config.

OpenAPI comes from the same declarations. On a route, { summary, description, tags, operationId, security, response } document the operation; on the module, @Module('Users', { description, tags, security }) sets what its routes inherit. A module's name is its routes' default tag, its namespace their tag group (x-tagGroups — the namespace is the parent, the module the sub-module within it), its description the tag's. Bind the body with a schema object — payload(UserSchema) — and it is validated and documented (toOpenAPI() becomes the request body); payload(Schema.parse) validates only. The same duality on the way out: give response a schema that can parse (a guardian schema as-is) and DEVELOPMENT mode enforces it — a success reply whose content fails the declared shape is a loud RAPID_RESPONSE_INVALID (500) instead of a response the docs lie about; PRODUCTION never runs the check, and an emitter-only response stays documentation-only. Only the body documents: auth/session/cookie binders are context-derived, not part of the request contract. security: ['bearerAuth'] emits the requirement (the scheme is declared for you); [] marks a route deliberately public. operationId defaults to <Module>_<method>.

Decorations are recorded by method name in the class's TC39 decorator metadata, so they compose freely with third-party decorators — a wrapping decorator (a timer, retry, cache) may sit above or below a rapid one; the route binds whatever function ends up installed under that name. Symbol.metadata is polyfilled by rapid itself at load time (idempotent, the standard Symbol.for('Symbol.metadata') fallback), so nothing is required of you.

The richer RapidModule tier adds a lifecycle, a scoped logger, typed emit/invoke, and event wiring. Boot it once with app.modules({ modules: [...] }) (before start()/fetch()); stop() disposes it in reverse order. See examples/ for a full module-based app.

Dependency injection

Each Application owns app.container — a child of the global @tundralibs/doctor Doctor. It reads the global's registrations but holds its own instances, so two apps in one process never share module instances. An inject() inside a handler resolves against this app's container — even after an await, because the container rides the request's async context. stock() an override to scope a fake or a per-app implementation to one app alone:

// also: deno add @tundralibs/doctor
import { Application } from '@tundralibs/rapid';
import { inject, label } from '@tundralibs/doctor';

const Clock = label<{ now(): string }>('Clock');

const app = await Application.initialize({ name: 'di-demo' });
app.container.stock(Clock, { now: () => new Date().toISOString() });

// Resolves against app.container, not the process-wide Doctor.
app.get('/time', () => ({ content: { at: inject(Clock).now() } }));

Module classes registered as @Vial are dispensed from the container too — one instance per app, read-through to the global registration — and a plain RapidModule that calls inject() in a field initializer resolves against the same container when the module system boots.

Validation

A bound validator (bind: [payload(schema.parse)]) that throws turns the request into a 400 — but only if rapid can tell the throw is a validation failure, not a server bug. The rule, in order of precedence:

  1. You throw a RapidError yourself → used verbatim (full control over code / status / detail). Disclosure is by mode: in PRODUCTION every 500 collapses to Internal server error and any 5xx drops its details, but a 4xx keeps its message and details — they describe the client's own request and are public by design, so write them as client-facing text.
  2. A @tundralibs/guardian failure → automatic 400 (RAPID_VALIDATION_FAILED, with a client-safe message per failing field). Guardian is this repo's validator, recognized structurally — no wrapper, no import needed.
  3. Any other throw (zod, a hand-written parse, …) → an opaque 500 by default. Wrap the validator in validated() to opt its throws into a 400:
import { validated } from '@tundralibs/rapid';
import { z } from 'zod'; // any validator with a .parse

const Body = z.object({ email: z.string().email() });

class Users {
  // guardian: no wrapper needed — a failure is already a 400.
  // zod / custom: wrap in validated() or an unexpected throw is a 500.
  @POST('/', { bind: [payload(validated(Body))] })
  create(body: unknown): RapidContextResponse {
    return { content: body };
  }
}

The asymmetry is deliberate: guardian is first-class, everything else is explicit. If you validate server-side data with guardian and a failure should not be a client 400, catch it and throw your own error.

Streaming responses

A handler's content can be a string, a plain object (serialized as JSON), a Uint8Array — or a stream: a ReadableStream<Uint8Array> or any async iterable of chunks (strings are UTF-8 encoded). A stream body is handed to the client as-is, never buffered, so large files, server-sent events, and proxy passthrough don't hold the body in memory. ctx.serve() and server.static stream files this way (with a real content-length from the file's size), and static serving honours a single-range Range: bytes=… header — 206 with Content-Range, or 416 when the range lies outside the file — so clients can resume downloads and seek media.

import { Application } from '@tundralibs/rapid';

const app = await Application.initialize({ name: 'stream' });

// Any async iterable streams — a generator, a DB cursor, another fetch's body.
app.get('/lines', () => ({
  content: (async function* () {
    for (let i = 0; i < 3; i++) yield `line ${i}\n`;
  })(),
  headers: { 'content-type': 'text/plain' },
}));

// Server-Sent Events: `ctx.sse()` frames each event and sets text/event-stream.
app.get('/events', (ctx) =>
  ctx.sse((async function* () {
    yield { event: 'tick', data: { n: 1 } };
    yield { event: 'tick', data: { n: 2 } };
  })()));

A client disconnect cancels the stream, which returns the source iterator — so an async function*'s finally block runs and is the place to unsubscribe or release a cursor. Stream bodies are HTTP-only (a job or socket reply rejects one) and opaque to body-inspecting middleware: etag skips them (a content hash would need the whole body) and compress pipes them chunk-wise.

UI (./ui)

A route can name an HTML template; the handler keeps returning JSON-shaped data and never learns about HTML. Two deterministic signals pick the representation — Accept is never consulted: a rapid-swap request header (sent by the bundled client runtime) always gets the fragment; otherwise the route's prefer decides between JSON (the default — an API route that can also render) and the layout-wrapped page. Same route, same handler, same data — two representations.

import { Application } from '@tundralibs/rapid';
import { html, template } from '@tundralibs/rapid/ui';

const UserList = template<{ items: string[] }>((data) =>
  html`<ul>${data.items.map((u) => html`<li>${u}</li>`)}</ul>`
);
const Shell = template<{ body: unknown }>((data) =>
  html`<main>${data.body}</main>`
);

const app = await Application.initialize({
  name: 'ui',
  ui: { layout: Shell }, // + serves /__rapid/ui.js (data half is YAML-able)
});
app.get(
  '/users',
  { template: { render: UserList, prefer: 'html' } },
  () => ({ content: { items: ['ada'] } }),
);

html`…` escapes every interpolated value (raw() is the single, greppable opt-out — the framework uses it on constant markup only); templates are pure (data, view) => Html functions, so they unit-test with render(UserList.render(data, view)) and no server. The frozen view bag carries requestId/path/query/csrfToken and view.asset() (cache-busting URLs, lazily content-hashed under server.static's fingerprinted mounts — see Rapid-UI) — nothing from ctx.auth unless the ui.view projection names the fields that may cross. Pages compose from THREE tiers: an irreplaceable app core (the document — head/css/scripts; title + meta are its per-page slots), the swappable module/route layout nesting inside it (route → @Module → app default; false opts out), and the content fragment built from plain view components. The small (~400-line) runtime (GET /__rapid/ui.js, ETag-revalidated) swaps fragments via data-action / data-target / data-swap attributes (data-load for lazy regions — skeleton first, the slow-data answer) — no inline handlers (script-src 'self' suffices) — echoes the csrf cookie as x-csrf-token, follows the rapid-redirect header same-origin only, emits rapid:swapped / rapid:error DOM events, and exposes a two-function base API — window.rapid.swap(url, target) and window.rapid.refresh(target) — for app JS to drive multi-region updates from those events. The three contract headers are configurable (YAML: ui.swapHeader: hx-request + swapUnless + redirectHeader: HX-Redirect), so htmx can drive the same routes. Error pages resolve through the ui.errorTemplates registry (exact status → '4xx'/'5xx' → default → the built-in DefaultErrorPage) under the same disclosure rules as the JSON envelope. ui.live: true adds the live bridge — rapid.live.connect() turns app.publish() broadcasts into rapid:push DOM events that app JS maps to swaps — and ui.history: true the history module: opt-in push-state per interaction (data-push / rapid.history.push()), no DOM cache (back re-fetches), document.title synced from rapid-title. server.api (hosts / prefix) splits an api surface off the same routes — api.example.com/users or /api/users answer JSON only (pages included; uiOnly routes are 404 there), onlyApi() / onlyUi() scope middleware per side; ui.enabled: false makes every request that surface. htmlDocument(), withQuery(), when() / each() (value-truthiness branches and lists with an empty state — 0 && … would render the 0), ctx.isSwap, typed view projections, and testing's view() / swap: true round out the layer. See docs/Rapid-UI.md for the full contract and examples/dashboard/main.ts for a runnable page.

Endpoints

Ready-made handlers you mount where you like — nothing is auto-registered:

import { Application } from '@tundralibs/rapid';
import { docs, health, metrics, openapi } from '@tundralibs/rapid/endpoints';

const app = await Application.initialize({
  name: 'api',
  server: { metrics: true },
});

app.get('/healthz', health({ check: () => Promise.resolve() }));
app.get('/metrics', metrics()); // 503 unless server.metrics is on
app.get('/openapi.json', openapi());
docs(app, { spec: '/openapi.json', tryIt: true }); // GET /docs
  • health({ check }) — liveness; the check throws/rejects to report 503, else 200.
  • ready({ check }) — readiness: 503 draining the moment stop() begins (so the load balancer stops sending traffic during the drain window), 503 when the check throws, else 200. Point the platform's readiness probe here and its liveness probe at health().
  • metrics({ format }) — serves app.meter as Prometheus text (default) or JSON; returns 503 when server.metrics is off.
  • openapi({ info, servers, expose, securitySchemes }) — the assembled OpenAPI 3.0.3 document built from the mounted routes (cached per version; every declared version is listed as x-versions). bearerAuth is declared automatically; declare any other scheme routes name in security here, with the exact OpenAPI shapes (http / apiKey / oauth2 / openIdConnect, validated at mount).
  • docs(app, { path, viewer, spec, tryIt, render, layout, guards, expose }) — the API reference page, rendered server-side from rapid's own templates inside your core/layout (no CDN), with a credential box generated from the declared schemes and a try-it form per operation; or a pinned Scalar / Redoc / Swagger UI shell. See OpenAPI and the API reference.

Session endpoints (login, logout, refresh, me) come from the pact adapter's factory, not from here, so they share one cookie name with authenticate — see Authentication & authorization. Every handler's replies, options and probe guidance are in the endpoints guide.

Auth

rAPId owns only the auth bag: ctx.auth, written once by ctx.setAuth(), undefined when anonymous. The @tundralibs/pact adapter at @tundralibs/rapid/middlewares/pact fills it — one factory over your instance: const { authenticate, authorize, login, logout, refresh, me } = pactAuth(pact, options). authenticate sets ctx.auth to pact's auth context (Bearer / Basic / ApiKey / HMAC, a bearer cookie for UIs; signed responses and JWE payloads when configured); authorize('Module', 'PERMISSION') is typed by the instance's catalog; the four session handlers wrap pact.login / logout / refresh with one cookie name declared once (bearer.cookie), one 401 for every failure and a minimal principal projection. The options are pact's own middleware options — the same wire contract as its express/fastify/oak/hono adapters:

import { Application } from '@tundralibs/rapid';
import { pactAuth } from '@tundralibs/rapid/middlewares/pact';
import type { Pact } from '@tundralibs/pact'; // also: deno add @tundralibs/pact

declare const pact: Pact<{ READ: 1n }, 'Admin'>;

const app = await Application.initialize({ name: 'api' });
const { authenticate, authorize, login, logout, me } = pactAuth(pact, {
  bearer: { cookie: 'session' },
});

app.use(authenticate);
app.post('/login', login()); // { token, expiresAt, principal } + the cookie
app.post('/logout', logout()); // 204, cookie cleared
app.get('/me', me()); // { principal, via } or 401
app.get(
  '/admin',
  authorize('Admin', 'READ'),
  () => ({ content: { ok: true } }),
);

Any other identity system is a ten-line middleware over the same bag — see Authentication & authorization.

Cookies, sessions & CSRF

One application secret (≥ 32 chars, HMAC via @tundralibs/crypt) backs everything that signs a cookie. Source it from the environment — never commit it — and set it once:

# configs/Application.yaml
name: my-api
secret: ${APP_SECRET}

A signed cookie is tamper-evident: the client can read it but cannot alter it without invalidating the signature. Set one with { signed: true } (async — await it) and read it back with ctx.signedCookie(), which returns the bare value or undefined for a missing or forged cookie:

import { Application } from '@tundralibs/rapid';

const app = await Application.initialize({
  name: 'api',
  secret: 'replace-with-at-least-32-random-characters',
});

app.get('/prefs', async (ctx) => {
  ctx.setCookie('theme', 'dark', { signed: true, httpOnly: true }); // queued; signed at finalize
  return { content: { theme: (await ctx.signedCookie('theme')) ?? null } };
});

// A handler may also RETURN cookies — plain or signed — and a redirect on
// the reply itself: a string → 302, `{ url, permanent: true }` → 301.
app.get('/login', () => ({
  content: '',
  cookies: [{ name: 'sid', value: 'abc', options: { signed: true } }],
  redirect: '/dashboard',
}));

The reply cookies and redirect keys are HTTP-only: the HTTP context consumes them, and a job or socket reply silently ignores them (a job has no cookies, and a redirect never becomes a 3xx there). So a module method decorated for several transports can return them without branching on the transport.

session() — store-backed, per-client state across requests, keyed by a signed id cookie with a rolling idle TTL and a hard absolute cap (both in seconds, like every duration in rapid — the hooks' TTLs included); call regenerate() on login (rotates the id, keeps the data) and destroy() on logout. csrf() — a stateless signed double-submit token; state-changing requests must echo the token cookie back in x-csrf-token (or a form field) or get a 403. Both sign with the app secret, so neither takes one of its own. A signing feature used without a configured secret fails loudly with RAPID_CONFIG rather than silently emitting an unsigned cookie.

Observability

  • Logging is always on. app.log is a @tundralibs/slogger instance whose appName is your name; the framework owns the context provider so every log line carries the per-request correlation id (via @tundralibs/ambient). Default level is INFO in production, DEBUG in development.

  • The correlation id is yours to shape. A validated inbound x-request-id is adopted; otherwise one is minted by the process-wide Application.requestIdGenerator — by default a crypto-free, monotonic sequenceID (a correlation id never needed a CSPRNG, and this is ~10× cheaper than a ULID). Set it once to choose a different scheme; the setter calls the generator and rejects it at assignment unless it returns a safe non-empty string:

    // also: deno add @tundralibs/id
    import { Application } from '@tundralibs/rapid';
    import { ulid } from '@tundralibs/id';
    
    Application.requestIdGenerator = ulid; // sortable ids instead of sequential
  • Tracing is opt-in. Pass a tracer option and rAPId emits a SERVER span per request (honouring an inbound traceparent), propagates on outbound calls, and composes trace ids onto every log line. Read it via app.tracer.

  • Metrics are opt-in. Set server.metrics: true and rapid records into a @tundralibs/metro-man meter (app.meter) plus server counters (app.metrics, app.socketMetrics); serve them with the metrics() endpoint. Seven switchable families cover the invocation cycle (counts, a latency histogram on the same clock as x-response-time, in-flight), every disclosed error code, job outcomes and drift, socket upgrades and channel subscriptions, the decisions each shipped middleware takes, request bytes and uploads, and UI representations and static hits — the object form (server.metrics: { ui: false }) turns a family off, and an off family declares nothing. Register your own metrics on app.meter.registry. Cron statistics (app.jobMetrics) are tracked unconditionally.

Graceful shutdown

app.stop() drains in-flight HTTP requests before closing. shutdownTimeout (seconds, an integer from 1 to 30; default 25) is the drain window: in-flight requests get up to that long to finish, then whatever is left is force-closed — WebSockets don't drain, so they are held to the deadline and then dropped. Jobs stop and the module runtime disposes (reverse init order) around the drain. A process-exit backstop fires a little past the window (shutdownTimeout × 1.1, unref'd) only if teardown itself wedges. A second stop() during the drain (two signal handlers) joins the first.

import { Application } from '@tundralibs/rapid';

// Give in-flight requests up to 10s to finish on stop(), then force-close.
const app = await Application.initialize({
  name: 'api',
  shutdownTimeout: 10,
});

Call stop() from your platform's termination signal (a SIGTERM handler on Deno/Bun/Node) for zero-drop rolling deploys.

Testing

@tundralibs/rapid/testing re-exports the @tundralibs/compat/test lifecycle (describe/it/beforeAll/…) and adds client() — a JSON-in/out client that drives routes through app.fetch with no port — and harness(), which boots the module system with stubbed dependencies.

import { Application } from '@tundralibs/rapid';
import { client } from '@tundralibs/rapid/testing';

const app = await Application.initialize({ name: 'test', mode: 'DEVELOPMENT' });
app.get('/ping', () => ({ content: { ok: true } }));

const api = client(app);
const res = await api.get('/ping');
console.log(res.status, res.body);

harness() stocks each stub into a fresh child container per call — never the process-wide Doctor — so tests isolate by construction and cannot leak into one another; pass container to boot against an app's own app.container instead. dispose() (or await using) tears the runtime down.

// also: deno add @tundralibs/doctor
import { RapidModule } from '@tundralibs/rapid/modules';
import { harness } from '@tundralibs/rapid/testing';
import { inject, label } from '@tundralibs/doctor';

const Clock = label<{ now(): string }>('Clock');

class Stamper extends RapidModule {
  readonly name = 'Stamper';
  readonly namespace = 'stamp';
  protected readonly events = {};
  private readonly clock = inject(Clock);
  stamp(): { at: string } {
    return { at: this.clock.now() };
  }
}

const h = await harness({
  modules: [{ Stamper }],
  stub: [[Clock, { now: () => 'FROZEN' }]], // stocked into a fresh child
});
console.log(h.modules.Stamper.stamp()); // { at: 'FROZEN' }
await h.dispose();

Runtime support

Every target loads the package cleanly. What runs depends on the target's capabilities:

  • Deno / Bun / Node.js — full support: app.start() opens a listening server (TCP or Unix socket), cron jobs are scheduled, WebSocket commands and file uploads work.

  • Cloudflare Workers — no listening socket, filesystem, or scheduler. Serve requests through the fetch handler instead of start():

    export default { fetch: (request: Request) => app.fetch(request) };

    fetch() serves HTTP only — socket commands need a listener (and error if registered), jobs are not scheduled (fire them from a cron trigger with app.triggerJob(name)), and file uploads degrade gracefully: they are rejected with a typed RAPID_UPLOADS_UNAVAILABLE (501) rather than crashing.

  • The browser — the package loads, but a browser has no AsyncLocalStorage for @tundralibs/ambient's correlation, so app.fetch() refuses with a typed RAPID_CONFIG error rather than serving. It is the target rapid serves pages to, not one it runs in.

Examples & docs

A full module-based blog API (posts + nested comments over @tundralibs/norm, DI via @tundralibs/doctor, versioning, a cron digest job, a WebSocket module, and the endpoint + auth catalog) lives in examples/ — run it with deno run -A packages/rapid/examples/blog/main.ts. The examples walkthrough maps all four apps (blog, kanban, dashboard, htmx) to what each one shows and where to start reading.

Guides:

  • The context and the application object — what ctx carries on each transport, reading input, shaping output, reaching services, and every app.* member.
  • Modules — decorators, binders, the RapidModule tier, events, invoke, lifecycle, and booting with app.modules().
  • Testing — client(), harness(), view(), running on Deno, Bun and Node, and what each kind of test should cover.
  • Configuration reference — every Application.yaml key: type, default, unit, what validates it at boot, and which part of the framework reads it.
  • Middleware catalog — registration order and every shipped middleware's options, hooks and pitfalls.
  • Errors — how a throw becomes a response, what PRODUCTION discloses, and every RAPID_* code.
  • Database access & connection pooling — sharing one pool across modules and middleware (with or without Norm), and staying safe under concurrency and pool limits.
  • Authentication & authorization — the generic ctx.auth seam for bring-your-own auth, and the opt-in @tundralibs/pact adapter (five credential schemes, permission checks, response signing).
  • Endpoints — health(), ready(), metrics(), openapi(), docs(): what each answers, its options, and wiring the platform's probes and scrape target.
  • OpenAPI and the API reference — where the document comes from, declaring security schemes, the docs() page with its credential box and try-it forms, and the two ways to customize it.

Every public symbol carries JSDoc; the subpath exports are . (root), ./cli, ./context, ./decorators, ./endpoints, ./errors, ./middlewares, ./middlewares/pact, ./modules, ./testing, ./types, and ./ui.

License

MIT

Clone this wiki locally