Menu
On this page

Setu-TS — Public API Contract

This document describes how developers use the framework. Implementation details are intentionally omitted.


Table of Contents

  1. Installation
  2. Full Stack Application
  3. Minimal Application
  4. Kernel diagnostics (@setu-ts/kernel + @setu-ts/common)
  5. createApplication() (@setu-ts/kernel)
  6. RuntimePlugin() (@setu-ts/runtime)
  7. LoggerPlugin() (@setu-ts/logger-plugin)
  8. ConfigPlugin() (@setu-ts/config-plugin)
  9. ValidationPlugin() (@setu-ts/validation-plugin)
  10. DatabasePlugin() (@setu-ts/database-plugin)
  11. AuthPlugin() (@setu-ts/auth-plugin)
  12. HttpSecurityPlugin() (@setu-ts/http-security-plugin)
  13. CachePlugin() (@setu-ts/cache-plugin)
  14. EventsPlugin() (@setu-ts/events-plugin)
  15. SsePlugin() (@setu-ts/sse-plugin)
  16. WebSocketPlugin() (@setu-ts/websocket-plugin)
  17. RealtimeBackplanePlugin() (@setu-ts/realtime-backplane-plugin)
  18. SessionPlugin() (@setu-ts/session-plugin)
  19. ReactRouterPlugin() (@setu-ts/react-router-plugin)
  20. WorkerPoolPlugin() (@setu-ts/worker-pool-plugin)
  21. Diagnostics Connector (@setu-ts/diagnostics-plugin)
  22. SecretsPlugin() (@setu-ts/secrets-plugin)
  23. AuditPlugin() (@setu-ts/audit-plugin)
  24. CQRS (@setu-ts/cqrs-plugin)
  25. Messaging (@setu-ts/messaging-plugin)
  26. Queue (@setu-ts/queue-plugin)
  27. Scheduler (@setu-ts/scheduler-plugin)
  28. Resilience (@setu-ts/resilience-plugin)
  29. Storage (@setu-ts/storage-plugin)
  30. MailPlugin() (@setu-ts/mail-plugin)
  31. Notifications (@setu-ts/notification-plugin)
  32. Feature Flags (@setu-ts/feature-flags-plugin)
  33. Multi-Tenancy Plugin (@setu-ts/multi-tenancy-plugin)
  34. Health (@setu-ts/health-plugin)
  35. Metrics (@setu-ts/metrics-plugin)
  36. Telemetry (@setu-ts/telemetry-plugin)
  37. OpenAPI (@setu-ts/openapi-plugin)
  38. CLI (@setu-ts/cli)
  39. REST API Application
  40. Microservice Application
  41. CQRS Application
  42. Plugin Creation
  43. Custom Middleware
  44. Custom Decorators
  45. Service Discovery (@setu-ts/service-discovery-plugin)
  46. Programmatic vs Decorator API
  47. Developer Ergonomics
  48. API Reference: @setu-ts/common
  49. API Reference: @setu-ts/kernel
  50. API Reference: @setu-ts/runtime
  51. API Reference: @setu-ts/exceptions
  52. API Reference: @setu-ts/di-plugin
  53. API Reference: @setu-ts/decorator-plugin
  54. Testing Package (@setu-ts/testing)
  55. SDK — Client SDK (@setu-ts/sdk)
  56. API Reference: @setu-ts/grpc-plugin
  57. API Reference: @setu-ts/cloudflare-plugin
  58. GraphQL (@setu-ts/graphql-plugin)
  59. Static Files Plugin (@setu-ts/static-plugin)
  60. View Plugin (@setu-ts/view-plugin)
  61. IdempotencyPlugin() (@setu-ts/idempotency-plugin)
  62. Localization Plugin (@setu-ts/localization-plugin)
  63. Boundary-Type Compatibility
  64. Summary

Installation

Packages are published to npm under the @setu-ts scope and are consumable from every runtime:

# npm (pnpm add / yarn add work the same way)
npm install @setu-ts/kernel @setu-ts/runtime

# bun
bun add @setu-ts/kernel @setu-ts/runtime

# Deno
deno add npm:@setu-ts/kernel npm:@setu-ts/runtime

Add plugins as needed:

npm install @setu-ts/logger-plugin @setu-ts/config-plugin \
            @setu-ts/validation-plugin @setu-ts/database-plugin \
            @setu-ts/auth-plugin @setu-ts/openapi-plugin

Or use a starter bundle:

npm install @setu-ts/rest-starter

Earlier releases were published on JSR (jsr:@setu-ts/…) and stay there; nothing new is published to JSR.


Full Stack Application

A full-featured service with caching, events, scheduling, and more:

import { createFullStackApp } from '@setu-ts/full-stack-starter';

const app = createFullStackApp({
  cache: { store: 'memory' },
  events: {},
  audit: { storage: 'memory' },
  secrets: { provider: 'env' },
  storage: { provider: 'memory' },
  mail: { provider: 'log' },
});

app.router.get('/health', (ctx) => ctx.response.json({ status: 'ok' }));

await app.start({ port: 3002 });

Minimal Application

The smallest possible application — just the kernel and runtime:

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';

const app = createApplication({
  plugins: [RuntimePlugin()],
});

app.router.get('/', (ctx) => {
  return ctx.response.json({ message: 'Hello, World!' });
});

await app.start({ port: 3000 });

No decorators. No DI. No reflection. Just a router and a runtime.


Kernel diagnostics (@setu-ts/kernel + @setu-ts/common)

An optional, read-only view of application composition and kernel execution. Enable it explicitly per application; an omitted diagnostics option creates no collector, metadata mirror, ring, or timer anywhere in the kernel, and IApplication.diagnostics is absent.

import { createApplication } from '@setu-ts/kernel';

const app = createApplication({
  plugins: [RuntimePlugin()],
  // Explicit activation. `labels` is the disclosure decision: a name leaves
  // the process ONLY when it exactly matches an allowlist entry.
  diagnostics: {
    labels: { plugins: ['catalog'], routes: ['/items'] },
  },
});
await app.start();

const snapshot = app.diagnostics!.snapshot(); // DiagnosticsSnapshot
const batch = app.diagnostics!.read(0, 128); // DiagnosticsBatch

The reader is pull-only. snapshot() and read() are the whole surface: neither registers a callback, resolves a service, invokes application code, or mutates state. Returned data is deeply frozen; a slow or stopped reader cannot backpressure requests. DTOs (DiagnosticsSnapshot, DiagnosticsNode, DiagnosticsEdge, DiagnosticsEvent, DiagnosticsBatch) and IDiagnosticsSource are exported from @setu-ts/common; KernelDiagnosticsOptions and the ApplicationOptions.diagnostics field from @setu-ts/kernel.

Capture minimization. No dynamic request path, query string, header, body, cookie, token, session value, database value, or raw error text is ever recorded. labels is an exact allowlist (≤256 entries per list, ≤160 UTF-8 bytes per entry, no control characters); un-allowlisted names are projected as opaque sequential ids (p1, c1, r1, m1), never truncated into new names. Approving a label is a disclosure decision, not a secret-detection guarantee.

One field is not allowlist-gated. A plugin node’s version is emitted whenever it passes a bounded major.minor.patch semver grammar, independently of labels.plugins — so an application that enables diagnostics with no labels at all still discloses the version of every registered plugin, including the framework’s own through RuntimePlugin. A version that fails the grammar (a git sha, a sentence, any string over 64 characters) is omitted rather than projected.

Bounds. Snapshots are capped at 1,024 nodes, 4,096 edges, and 256 KiB of compact JSON (truncated reports omission). Events are capped at 1,024 retained records and 1,024 bytes each; eviction is reported as lost sequence numbers, and drops as droppedEvents. Startup failure and final shutdown clear retained metadata while preserving the original application error — a reader then sees only the coarse failed/closed state, the failure code, and counters. A retried start() — possible only after a failure before any plugin registered — continues the event numbering, so a later read reports the discarded range as lost. Timing is monotonic from runtime registration; atMs/durationMs are null before that, never fabricated.

The runnable consumer is scripts/inspect-kernel.ts; the paired throughput/latency harness is scripts/benchmark-kernel-diagnostics.ts --mode=disabled|enabled. Network authentication and transport for an external devtool belong to M98b, which consumes this same contract.

createApplication() (@setu-ts/kernel)

The entry point to the framework.

A capability is not resolvable from app.services until start() has run.

Signature

function createApplication(options?: ApplicationOptions): Application;

interface ApplicationOptions {
  plugins?: IPlugin[];
  onError?: (error: Error, ctx: RequestContext) => void;
  onRequest?: (ctx: RequestContext) => void | Promise<void>;
  onResponse?: (ctx: RequestContext) => void | Promise<void>;
  gracefulShutdown?: boolean;
  shutdownTimeout?: number;
}

interface Application {
  register(plugin: IPlugin): Application;
  router: RouterApi;
  middleware: MiddlewareApi;
  services: ServiceRegistry;
  start(options?: StartOptions): Promise<void>;
  stop(): Promise<void>;
  inject(request: InjectRequest): Promise<InjectResponse>;
}

interface StartOptions {
  port?: number;
  hostname?: string;
}

Usage

const app = createApplication();

// Register plugins programmatically
app.register(RuntimePlugin());
app.register(LoggerPlugin({ level: 'info' }));

// Register an inline plugin
app.register({
  name: 'hello-plugin',
  version: '1.0.0',
  register(ctx) {
    ctx.router.get('/hello', (ctx) => ctx.response.json({ hello: 'world' }));
  },
});

await app.start({ port: 3000 });

Startup logging

After adapter.listen succeeds, the kernel logs one info record through CAPABILITIES.LOGGER when one is registered: Server listening with the port, and hostname only when one was supplied. A port: 0 start logs Server listening on an ephemeral port with no port field. With no logger, nothing is logged; a logger that throws never fails start(). Deno also keeps its own native listening banner, so a Deno start shows both lines.

Route Introspection

app.router.listRoutes() returns RouteInfo entries. Each entry has an optional owner: it is the name of the plugin whose register() call created the route, and is absent for a route added directly through app.router. Registering the same method and path twice throws instead of silently replacing the first handler.

Testing Without a Server

const app = createApplication({ plugins: [RuntimePlugin()] });

app.router.get('/users', (ctx) => ctx.response.json([{ id: 1 }]));

await app.start();

const response = await app.inject({ method: 'GET', url: '/users' });
console.log(response.statusCode); // 200
console.log(response.json()); // [{ id: 1 }]

For testing, prefer createTestApp() — it calls start() automatically (without binding a socket), so you can call inject() or fetch() directly. See Testing Package for the full API.

InjectRequest.ip?: string optionally supplies the socket peer as IRequest.ip (M113n). Omitting it leaves the injected request without a peer.

inject() carries the body shapes a request actually has (M95c): a Uint8Array and an ArrayBuffer pass through VERBATIM with no content-type default, since only the caller knows whether those bytes are multipart, JSON, or an image; a Blob also passes through verbatim and defaults content-type to its own type when that is non-empty — exactly what the platform does for new Request(url, { body: blob }) — contributing nothing when the blob has no type; a URLSearchParams is serialised with its own toString() and defaults content-type to application/x-www-form-urlencoded; a plain object is JSON-serialised and a bare string is carried as-is, both defaulting to application/json. An explicitly supplied content type always wins, and the default is written onto a COPY of headers — a Headers instance the caller reuses across two injected requests is never mutated, so the second request still gets its own shape’s default. Any other shape — an array, a Date, a class instance, a number — is refused with a TypeError naming the received type, not silently JSON-stringified: the previous behaviour turned a Uint8Array into {"0":97,…} and every other non-string into {} while answering 200, which made an injected multipart upload parse as an empty form.


Shared reconnect supervisor (@setu-ts/common)

Since 0.10.0, common exports ReconnectSupervisor, ReconnectSupervisorOptions, ReconnectMode and fullJitterDelay(attempt, initialMs, maxMs). Messaging’s RabbitMQ, NATS and Kafka brokers and the RabbitMQ queue adapter share this implementation. Messaging does not re-export these symbols.

ReconnectMode is 'drive' | 'observe'. The options require runtime, mode and attachFaultListener(onFault), which returns a disposer. Drive mode additionally requires reconnect and replay callbacks; reassert is optional. Observe mode can supply attachRecoveryListener(onRecovered). initialMs defaults to 500 and maxMs to 30000. fullJitterDelay returns an integer uniformly distributed from zero through the exponential window’s capped ceiling. Attempts are unbounded.

Call start() to attach listeners, fault() to mark an outage and start drive-mode retries, recovered() to clear a fault, and stop() to cancel scheduled attempts and dispose listeners. faulted reports the current fault window. A stopped supervisor is terminal; construct a new instance to start another lifecycle.

Dotenv parser (@setu-ts/common)

Since 0.11.0, common exports parseEnv(content: string): Readonly<Record<string, string>>, the strict dotenv parser ConfigPlugin loads .env files with. It moved from config-plugin, where it was internal, so that setu workspace deploy builds a member’s Kubernetes Secret from exactly the values the application would read from the same file.

Blank lines and # comment lines are ignored. An entry may start with export . A value may be unquoted (a trailing # comment is dropped), double-quoted (escape sequences decoded) or single-quoted (taken literally). An empty value is the empty string, and a duplicate key keeps its last value. A line without =, an invalid key, or an unterminated quote throws an Error naming the line number.

import { parseEnv } from '@setu-ts/common';

parseEnv('export A="x y"\nB=plain # note\nEMPTY=\n');
// { A: 'x y', B: 'plain', EMPTY: '' }

RuntimePlugin() (@setu-ts/runtime)

Provides runtime-agnostic services (UUID, timers, crypto, env, HTTP server).

Registration

import { RuntimePlugin } from '@setu-ts/runtime';

app.register(
  RuntimePlugin({
    httpAdapter: 'auto', // 'node' | 'deno' | 'bun' | 'auto'
  }),
);

Deno shutdown

Since 0.10.0, Deno adapter close() waits for the first of shutdown() and a 1000 ms grace, catching a rejected shutdown. After the kernel’s bounded drain, shutdown and close hooks can run while a request is still in flight. Register resource cleanup in lifecycle hooks.

Bounding the request body

maxBodyBytes caps the request-body read where the body is actually consumed:

app.register(RuntimePlugin({ maxBodyBytes: 10 * 1024 * 1024 }));

Use it with HttpSecurityPlugin({ requestSize: { maxBodySize } }): that middleware rejects a declared Content-Length before reading, while this option bounds chunked bodies as they are read. Set both to the same value or make maxBodyBytes larger. Omit maxBodyBytes for an unbounded read; 0 refuses every request that carries a body. A non-negative integer is required. An over-limit read rejects with a RequestBodyTooLargeError carrying a 413 status hint, so errorHandler answers 413 Payload Too Large in its configured format.

A component that wraps its own body read in a try must honour that hint rather than reporting its own status: the read can now REJECT, and a catch that assumes a parse failure reports the wrong cause and the wrong remedy. @setu-ts/graphql-plugin’s two HTTP transports answer 413 with extensions.code: 'REQUEST_BODY_TOO_LARGE', and @setu-ts/storage-plugin’s upload middleware serves the hint through respondWithError.

Reporting a failed WebSocket handshake

HttpAdapterOptions.reportError?: (record: Readonly<Record<string, unknown>>) => void (@since 0.10.0) is a last-resort sink for a server-side Node handshake failure: ws is not installed, or it throws while upgrading. RuntimePlugin supplies one resolved at call time — the registered logger’s error, else IRuntimeServices.reportError, with a throwing logger swallowed. The Node adapter reports { level: 'error', msg: 'WebSocket handshake failed', ...serializeError(cause) } at most once per distinct message per handle, then refuses the upgrade with the same 500 and no body as before. Deno, Workers and Bun ignore the option; omitted, the refusal is silent.

Accessing Runtime Services

app.router.get('/info', (ctx) => {
  const runtime = ctx.services.get<IRuntimeServices>('runtime');

  return ctx.response.json({
    platform: runtime.platform(),
    version: runtime.version(),
    hostname: runtime.hostname(),
    requestId: runtime.uuid(),
  });
});

Available Runtime Services

interface IRuntimeServices {
  platform(): 'node' | 'deno' | 'bun' | 'cloudflare-workers';
  version(): string;
  hostname(): string;

  uuid(): string;
  randomBytes(length: number): Uint8Array;
  subtle: SubtleCrypto;

  now(): number;
  hrtime(): number;
  setTimeout(fn: () => void, ms: number): TimerHandle;
  clearTimeout(handle: TimerHandle): void;
  setInterval(fn: () => void, ms: number): TimerHandle;
  clearInterval(handle: TimerHandle): void;

  env: Record<string, string | undefined>;
  exit(code?: number): never;

  fs?: IFileSystem;
  workers?: IWorkerHost;
  dns?: IDnsResolver;
  onSignal?(signal: RuntimeSignal, handler: () => void): void;
}

type RuntimeSignal = 'SIGTERM' | 'SIGINT';

IFileSystem provides readFile/writeFile/stat/readdir/mkdir/rm, plus an optional realPath(path): Promise<string> that canonicalizes a path following symlinks. realPath is implemented by the Node/Deno/Bun runtime adapters and absent on runtimes that cannot canonicalize; callers must degrade gracefully when it is not present (e.g. the React Router plugin’s static-asset handler uses it for symlink-safe containment when available, falling back to lexical containment).

workers is an optional IWorkerHost for spawning worker threads. It is implemented by the Node/Deno/Bun runtime adapters and absent on Cloudflare Workers (no threads on the edge). Callers must degrade gracefully when it is not present — the WorkerPoolPlugin fails run() with a typed WorkerPoolUnavailableError rather than throwing at startup.

interface IWorkerHost {
  spawn(specifier: string): IWorkerHandle;
  availableParallelism(): number;
  // Answerable BEFORE a worker exists, which is why it sits on the host: a
  // consumer that must warn about undetectable worker death needs to know at
  // registration time. A host that omits it is treated as reporting `false`.
  reportsExit?(): boolean;
}

interface IWorkerHandle {
  postMessage(message: unknown): void;
  onMessage(listener: (message: unknown) => void): void;
  onError(listener: (error: Error) => void): void;
  // The worker's THREAD ENDING, however it ended — a clean self-termination
  // included, which raises no error at all. `code` is `null` when the runtime
  // ends the worker without reporting one, and the listener also fires for a
  // host-requested `terminate()` on runtimes that implement it that way.
  onExit?(listener: (code: number | null) => void): void;
  terminate(): Promise<void>;
}

onExit/reportsExit are omitted, not no-ops, on a runtime that cannot report a worker exit: their absence means “this runtime cannot tell me a worker died”, never “no worker has died”. The Node host implements both over node:worker_threads’ 'exit'; the shared web-worker host does when its runtime names an event that fires, which Bun does ('close') and Deno does not — measured, Deno’s Worker emits nothing on self.close() and a later postMessage still resolves.

dns is an optional IDnsResolver for name resolution. It is implemented by the Node, Deno, and Bun runtime adapters and absent on Cloudflare Workers, whose network access is fetch — that resolves names internally and exposes no lookup surface. Callers must degrade gracefully when it is not present; the ServiceDiscoveryPlugin’s 'dns' provider throws a typed DiscoveryUnavailableError during register(), naming the alternatives.

MemberNodeDenoBunWorkers
fs✅✅✅❌
workers✅✅✅❌
dns✅✅✅❌
onSignal✅✅ (not Win32)✅❌

onSignal registers a process-termination handler so an application can run app.stop() — its shutdown hooks, discovery deregistration, and broker disconnects — before the process dies. Handlers are additive and are never removed: a process that received a termination signal is ending.

It is the one member absent for two unrelated reasons, and a caller must treat both the same way (runtime.onSignal?.(...)). Cloudflare Workers omits it because an isolate is evicted rather than signalled, so there is nothing to register for. The Deno adapter omits it on Windows, where registering for SIGTERM throws rather than no-opping — so the presence of the key means “this runtime can register a handler”, never “this platform raises signals”. A no-op was rejected deliberately: it would let a caller conclude its shutdown handler runs when it never would.

interface IDnsResolver {
  resolveSrv(hostname: string): Promise<readonly SrvRecord[]>;
  resolveHost(hostname: string): Promise<readonly string[]>;
}

interface SrvRecord {
  readonly host: string;
  readonly port: number;
  readonly priority: number;
  readonly weight: number;
}

SrvRecord.host is deliberately named that rather than reusing a runtime’s own spelling: Deno calls the field target and Node calls it name, and passing either through unchanged would type-check on both runtimes while producing undefined hostnames on one. resolveHost concatenates A and AAAA results and rejects only when both families fail, because an IPv4-only host has no AAAA record at all. The runtime package exports both resolver factories: createNodeDnsResolver(dns?) (shared by the Node and Bun adapters, over node:dns/promises) and createDenoDnsResolver(host) (over Deno.resolveDns).

The runtime package also exports the worker host factories createWebWorkerHost(globals?) (Deno/Bun, over the web Worker API) and createNodeWorkerHost(mods?) (Node, over node:worker_threads), each behind an injectable seam, plus a @setu-ts/runtime/worker subpath whose sole export is defineWorkerTask (see the WorkerPoolPlugin section).


LoggerPlugin() (@setu-ts/logger-plugin)

LoggerPluginOptions.excludePaths?: readonly PathPattern[] passes strings and regular expressions unchanged to request middleware. Strings match exactly; regular expressions test the request path.

OptionTypeConsumer
LoggerPluginOptions.excludePathsreadonly PathPattern[]Request-logger middleware path matcher

The request logger emits completion with the status predicted by IErrorResponder.statusFor? on a throw, or 500 without that method; normal returns use the response snapshot. It rethrows without a duplicate failure record. errorHandler logs once at warn below 500 and error otherwise. A custom ErrorHandlerOptions.respond hook choosing another status is not reflected in request completion logs. Request logging remains inside telemetry. With no logger, handled 5xx and kernel unhandled errors use the runtime’s default sink; a registered logger is the exclusive destination.

Provides structured logging via a capability token. The plugin depends on RuntimePlugin and registers its ILogger under CAPABILITIES.LOGGER at PLUGIN_PRIORITY.HIGH (100) so logging is available before most other plugins register.

Registration

import { LoggerPlugin } from '@setu-ts/logger-plugin';

app.register(
  LoggerPlugin({
    level: 'info', // minimum level to emit (default 'info')
    transport: 'console', // 'console' | 'pino' | 'noop' | 'custom' (default 'console')
    pretty: false, // pretty-print console output
    redact: ['password', 'token', 'authorization'], // dot-paths to redact
    requestLogging: true, // register request/response middleware
    slowRequestThreshold: 5000, // ms — warn when slower (default 5000)
    excludePaths: ['/health'], // paths excluded from request logging
  }),
);

Transports

TransportDescription
'console'Runtime-independent JSON lines (or pretty text) via console. Default.
'pino'Pino-backed, loaded via await import('pino') or injected factory. register() is async.
'noop'Discards all output. For tests or disabling logging.
'custom'Wraps LoggerPluginOptions.logger: ILogger with framework level filtering and redaction. Since 0.11.0.

LoggerPluginOptions.logger?: ILogger (since 0.11.0) is required with transport: 'custom' and refused with every other transport, including the default. Invalid combinations throw an Error named LoggerConfigurationError when constructing the plugin. The wrapper applies level (default info), error normalization, and the same redaction policy and redact paths as built-in transports before delegating metadata. Default secret patterns apply when redact is omitted; an explicit list replaces them. Children delegate to the supplied logger’s child() with redacted bindings and retain the wrapper. Request logging and trace correlation also apply. The supplied logger controls its own sink and may impose additional filtering.

Usage in Routes

import { CAPABILITIES, ILogger } from '@setu-ts/common';

app.router.get('/users/:id', async (ctx) => {
  const logger = ctx.services.get<ILogger>(CAPABILITIES.LOGGER);

  logger.info('Fetching user', { userId: ctx.params.id });

  const user = await getUser(ctx.params.id);

  logger.debug('User fetched', { userId: user.id });

  return ctx.response.json(user);
});

Trace correlation

When a CAPABILITIES.TELEMETRY provider is registered, every record the plugin’s logger emits carries the active span’s identifiers:

FieldValue
trace_idThe active span’s 32-hex trace identifier
span_idThe active span’s 16-hex span identifier

The names are snake_case, departing from this framework’s camelCase metadata convention (requestId), and the departure is deliberate: these fields exist to be read by a log backend, and the OpenTelemetry log-correlation convention that Loki, Elastic and the collector’s own processors key on is snake_case. A camelCase spelling would be internally consistent and would join up in none of the tools the correlation exists for.

Enrichment applies to every level and to child loggers, so logger.child({ requestId }) — what the request-logging middleware does — still carries them. A caller’s own trace_id in the metadata wins over the decorator’s.

Without a telemetry capability the record is byte-identical to before, and so it is when no span is active, when the telemetry service does not implement activeSpanContext, or when reading it throws. The read is guarded end to end: an observability enrichment must never turn logging into the fault.

The plugin does NOT declare CAPABILITIES.TELEMETRY in optionalDependencies. TelemetryPlugin already declares CAPABILITIES.LOGGER in its own, and the two edges together are a cycle the kernel’s plugin resolver refuses at start(). The telemetry capability is therefore resolved at CALL time, which is required rather than merely prudent: the resolver orders LoggerPlugin first, so telemetry is guaranteed absent when the logger registers.

One consequence is visible: the value registered under CAPABILITIES.LOGGER is an internal decorator, not the transport instance, so instanceof ConsoleLogger on the resolved capability no longer holds. ILogger is the contract; the concrete class was never part of it.

Child Loggers

app.middleware.add(async (ctx, next) => {
  const logger = ctx.services.get<ILogger>(CAPABILITIES.LOGGER);
  const requestLogger = logger.child({
    requestId: ctx.id,
    correlationId: ctx.request.headers.get('x-correlation-id') ?? undefined,
  });

  ctx.services.register(CAPABILITIES.LOGGER, requestLogger, { override: true });
  await next();
});

Logger Interface

interface ILogger {
  readonly level: LogLevel;
  fatal(message: string, metadata?: LogMetadata): void;
  error(message: string, metadata?: LogMetadata): void;
  warn(message: string, metadata?: LogMetadata): void;
  info(message: string, metadata?: LogMetadata): void;
  debug(message: string, metadata?: LogMetadata): void;
  trace(message: string, metadata?: LogMetadata): void;
  child(bindings: LogMetadata): ILogger;
}

type LogMetadata = Readonly<Record<string, unknown>>;
type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';

Standalone Logger Implementations

The logger implementations can be used directly without the plugin, e.g. in tests or scripts:

import { ConsoleLogger, NoopLogger, PinoLogger } from '@setu-ts/logger-plugin';

const consoleLogger = new ConsoleLogger(runtime, { level: 'debug', pretty: true });
const noopLogger = new NoopLogger();
// PinoLogger uses async construction (import('pino') is async):
const pinoLogger = await PinoLogger.create({ level: 'info', redact: ['password'] });

Request Logging Middleware

import { createRequestLoggerMiddleware } from '@setu-ts/logger-plugin';

app.middleware.add(
  createRequestLoggerMiddleware({
    slowRequestThreshold: 1000,
    // A string is an EXACT path match; a RegExp is tested against the path (M90a).
    excludePaths: ['/health', /^\/internal\//],
  }),
);

The middleware resolves CAPABILITIES.LOGGER on each request, creates a child logger bound to requestId, and logs:

  • request received at debug (method, path — the concrete path as the client sent it, not a route template). Set level: 'debug' to emit this arrival entry; its metadata is not built when the child’s exposed level excludes debug. With no readable level, the middleware calls debug() and lets the logger filter.
  • One request completed at info (method, path, status, duration in ms), including a request that threw: the status is the one errorHandler’s responder predicts through statusFor (500 without it), so a custom respond hook that chooses another status is not reflected
  • Slow request warning when duration exceeds slowRequestThreshold

It does not log the error itself; errorHandler logs it once (V9-80). The database-plugin scrub covers database errors only, so an identifier in a path segment reaches the log; keep it out with excludePaths, a redaction field redactor for path, or redact (which replaces the default secret patterns, so re-list them).


ConfigPlugin() (@setu-ts/config-plugin)

Provides strongly-typed configuration with environment validation and .env file loading. Configuration is an immutable application-startup snapshot — values are loaded once at startup and never mutated. Hot reload is deferred (the runtime contract has no file-watching abstraction).

Registration

import { ConfigPlugin } from '@setu-ts/config-plugin';
import { CAPABILITIES } from '@setu-ts/common';
import { z } from 'zod';

const AppConfigSchema = z.object({
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  REDIS_URL: z.string().url().optional(),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

app.register(
  ConfigPlugin({
    envFilePath: ['.env.local', '.env'],
    validationSchema: AppConfigSchema,
    expandVariables: true,
  }),
);

Usage

app.router.get('/config', (ctx) => {
  const config = ctx.services.get<IConfig>(CAPABILITIES.CONFIG);

  return ctx.response.json({
    port: config.get<number>('PORT'),
    env: config.get<string>('NODE_ENV'),
    hasRedis: config.has('REDIS_URL'),
    redisUrl: config.get<string>('REDIS_URL', { default: 'redis://localhost:6379' }),
  });
});

Config Interface

interface IConfig {
  get<T>(key: string): T | undefined;
  get<T>(key: string, options: { readonly default: T }): T;
  getOrThrow<T>(key: string): T;
  has(key: string): boolean;
}

ConfigPluginOptions

interface ConfigPluginOptions {
  readonly envFilePath?: string | readonly string[];
  readonly envFileOptional?: boolean;
  readonly validationSchema?: StructuralSchema<unknown>;
  readonly sections?: readonly ConfigSection<unknown>[];
  readonly expandVariables?: boolean;
  readonly instance?: IConfig;
  readonly diagnostics?: ConfigDiagnosticsOptions;
}

interface ConfigDiagnosticsOptions {
  readonly enabled: true;
  readonly keys: Readonly<Record<string, string>>;
  readonly files?: Readonly<Record<string, string>>;
}
  • envFilePath — Path or paths to .env files. Defaults to no file loading. When supplied, the runtime must provide fs (absent on edge platforms).
  • envFileOptional — When true, a path in envFilePath that does not exist is skipped instead of throwing. Defaults to false, which is the behaviour released in 0.1.0. Only ABSENCE is tolerated: a file that exists and cannot be read still throws. This is what a scaffolded project uses, because the dotenv file the CLI emits is gitignored and therefore absent on every fresh clone, in CI, and in a container built from the repository.
  • validationSchema — A Zod-compatible schema for startup validation. The schema’s parse() is called once after merging and expansion; the parsed output is stored as the configuration snapshot, preserving Zod coercions and defaults.
  • sections — Typed sections validated after validationSchema has parsed the whole snapshot. Each section reads only its declared prefix-plus-key entries, parses them once at startup, and caches the output for getConfigSection. Sections validate even when instance is supplied.
  • expandVariables — When true (default), expand ${NAME} references in values using the final merged configuration.
  • instance — An already-loaded snapshot to register verbatim. Present → nothing is read from the environment or from disk and envFilePath, envFileOptional, validationSchema, and expandVariables are ignored; declared sections still validate it through named reads. Absent → configuration loads normally. This exists so an application can resolve configuration before its plugins are constructed and then hand the plugin that same object, rather than letting it load a second snapshot a moment later that the composition never saw.
  • diagnostics — The opt-in value-free provenance policy, below.

loadConfig()

import { loadConfig } from '@setu-ts/config-plugin';
import { createRuntimeServices } from '@setu-ts/runtime';

const config = await loadConfig(createRuntimeServices(), {
  envFilePath: ['.env.local', '.env'],
});
const port = config.get<number>('PORT', { default: 3000 });

(runtime: IRuntimeServices, options?: ConfigPluginOptions) => Promise<IConfig> — the same implementation ConfigPlugin.register uses, reachable without an application. Use it when configuration must be read before any plugin exists (choosing which plugins to register, for instance); inside an application, resolve CAPABILITIES.CONFIG from the registry instead.

Merging, expansion, and validation behave identically on both paths because there is only one: the plugin delegates to this function and registers what it returns. Pass the result back as ConfigPlugin({ instance: config }) so the application holds exactly one snapshot — createFullStackAppFromConfig does precisely that.

StructuralSchema<T>

interface StructuralSchema<T> {
  parse(input: unknown): T;
}

Minimal schema interface compatible with Zod’s parse(unknown) API. Consumers supply a Zod schema without config-plugin depending on Zod.

Typed Configuration Sections

import { CAPABILITIES, type IConfig } from '@setu-ts/common';
import { ConfigPlugin, defineConfigSection, getConfigSection } from '@setu-ts/config-plugin';
import { z } from 'zod';

const database = defineConfigSection({
  prefix: 'DATABASE_',
  keys: ['URL', 'POOL_SIZE'],
  schema: z.object({
    URL: z.string().url(),
    POOL_SIZE: z.coerce.number().int().positive(),
  }),
});

app.register(ConfigPlugin({ sections: [database] }));

const config = app.services.get<IConfig>(CAPABILITIES.CONFIG);
const settings = getConfigSection(config, database);
// settings.POOL_SIZE is a validated number.

ConfigSection<T> is the typed declaration returned by defineConfigSection. prefix is prepended to every prefix-stripped entry in keys, so the example reads DATABASE_URL and DATABASE_POOL_SIZE while the schema receives { URL, POOL_SIZE }. The explicit key list is required because IConfig intentionally supports named reads but no key enumeration; this also makes an arbitrary ConfigPluginOptions.instance validatable. Missing keys are omitted and the schema decides whether they are optional. A section schema is not reusable as validationSchema: the latter receives the entire flat snapshot and always runs first.

getConfigSection(config, section) returns the exact schema output cached during startup. It throws when that configuration snapshot did not validate the given definition; it never parses a value at read time.

Configuration Precedence

Values are merged in the following order (highest precedence first):

  1. Environment variables (runtime.env)
  2. Earlier file paths (.env.local overrides .env)
  3. Later file paths

undefined entries in runtime.env are filtered out.

Variable references are expanded only after all sources have been merged, so file values may reference runtime.env, runtime values may reference files, and references may cross file boundaries. Cycles or missing references fail startup unless expansion is disabled.

Dotenv Parsing

Configured files use strict parsing. Blank lines, comments, optional export prefixes, quoted and unquoted values, common double-quoted escapes, empty values, and inline comments are supported. Malformed entries, invalid keys, and unterminated quotes fail startup with a line number but do not include rejected values in the error message.

Schema validation failures use a stable, value-free error message so validator diagnostics cannot leak configuration secrets.

Edge Runtimes

On edge platforms where runtime.fs is undefined, envFilePath must not be set. Attempting to do so throws a clear startup error.

Hot Reload

Deferred. Configuration is an immutable application-startup snapshot.

Configuration Provenance (0.8.0)

ConfigDiagnosticsOptions is the explicit opt-in: enabled is the LITERAL true (an absent option is the disabled path; any other value is refused, with fixed messages that never echo a value), keys is the exact configuration-key to display-alias allowlist (at most 128 entries, each alias unique, 1–64 UTF-8 bytes, no control characters), and files optionally allows exact configured .env paths as source aliases (at most eight, same alias rules). When present, the loader records — during the ONE load it already performs — where each approved key’s final value was observed to come from: the source category and approved source aliases, the evidenced precedence displacement (overriddenSourceAliases, lowest first), the ${NAME} expansion evidence (expanded plus the approved referenceAliases, at most 16 per key), and the schema effect derived only from input/output property presence (validated / introduced / removed / not-configured; introduced reports the appearance and never names a mechanism, since a schema default and a transform are indistinguishable by presence).

No configuration value, value hash, value length, raw key name, or file path is ever retained or served. Unapproved keys are never observed — no entry, and no counter disclosing that they exist; an unapproved file path contributes only the category file. loadConfig(runtime, { diagnostics }) builds the record with the snapshot; ConfigPlugin({ instance, diagnostics }) adopts that exact instance’s record (keeping each entry’s real environment/file origin — injection is how the snapshot reached the application, not where its values came from) or, for an opaque injected instance, reports every approved alias with origin and schema effect unknown and NO presence flag, without a single read of it. An adopted record is served exactly as the loader built it, under the loadConfig call’s approvals — the plugin’s own keys and files only enable the source and govern the opaque case, so the same policy belongs on both calls. Provenance adds no read to any configuration object: configured sections perform exactly the same IConfig.get calls with diagnostics disabled and enabled.

The plugin ALWAYS registers the source under the eager CAPABILITIES.CONFIG_DIAGNOSTICS token: absent diagnostics answers disabled; enabled with no approved resolved entries answers no-data; otherwise ready. The Diagnostics Connector serves it at GET /v1/config (see Diagnostics Connector and docs/diagnostics-protocol.md); snapshots are deeply frozen and bounded by the connector’s 256 KiB response ceiling.

Exports

ExportKind
ConfigPluginfunction
ConfigPluginOptionsinterface
loadConfigfunction
StructuralSchemainterface
defineConfigSectionfunction
getConfigSectionfunction
ConfigSectioninterface
ConfigDiagnosticsOptionsinterface

ValidationPlugin() (@setu-ts/validation-plugin)

Provides schema-based request validation with standardized error responses. Schemas are duck-typed via a structural safeParse() interface — no hard Zod dependency in the plugin itself.

Registration

import { ValidationPlugin } from '@setu-ts/validation-plugin';

app.register(
  ValidationPlugin({
    // ValidationPlugin's own ErrorFormat union — distinct from the exceptions
    // package's, which has no 'nestjs' arm.
    errorFormat: 'rfc9457', // 'default' | 'rfc9457' | 'rfc7807' | 'nestjs' | custom function
  }),
);

ValidationPluginOptions

interface ValidationPluginOptions {
  /** Error response format. Defaults to 'default'. */
  readonly errorFormat?: ErrorFormat | ValidationErrorFormatter;

  /**
   * Strip properties the schema does not declare. Applied once per middleware
   * at registration time via the schema's own `.strip()` (Zod-style). A schema
   * without `.strip()` is used unchanged.
   */
  readonly whitelist?: boolean;

  /**
   * Reject payloads carrying properties the schema does not declare. Applied
   * once per middleware at registration time via the schema's own `.strict()`
   * (Zod-style), and takes precedence over `whitelist` when both are set. A
   * schema without `.strict()` is used unchanged.
   */
  readonly forbidNonWhitelisted?: boolean;
}

Programmatic Validation

import { z } from 'zod';
import { CAPABILITIES, IValidationService, validatedStateKey } from '@setu-ts/common';

const CreateUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  age: z.number().int().min(18).max(120),
  role: z.enum(['admin', 'user', 'guest']).default('user'),
});

app.router.post('/users', {
  middleware: [
    async (ctx, next) => {
      const validation = ctx.services.get<IValidationService>(CAPABILITIES.VALIDATION);
      const body = await ctx.request.json();
      const result = validation.validate(CreateUserSchema, body);

      if (!result.success) {
        return ctx.response.status(400).json({ errors: result.error });
      }

      ctx.state.set(validatedStateKey('body'), result.value);
      await next();
    },
  ],
  handler: async (ctx) => {
    const body = ctx.state.get<z.infer<typeof CreateUserSchema>>(validatedStateKey('body'));
    // body is fully typed and validated
    const user = await createUser(body);
    return ctx.response.status(201).json(user);
  },
});

Validation Middleware Helpers

The helpers resolve IValidationService from the request context automatically. The five validateXxx helpers and createValidationMiddleware return ValidatorMiddleware<ValidatedOutput<S>> — a MiddlewareFunction (so every existing call site type-checks unchanged) with one added member, value(ctx):

import { z } from 'zod';
import {
  validateBody,
  validateCookies,
  validateHeaders,
  validateParams,
  validateQuery,
} from '@setu-ts/validation-plugin';

const listQuery = validateQuery(ListUsersQuerySchema);
const userIdParams = validateParams(z.object({ id: z.string().uuid() }));
const updateBody = validateBody(UpdateUserSchema);

app.router.get('/users', {
  middleware: [listQuery],
  handler: async (ctx) => {
    const query = listQuery.value(ctx); // ValidatedOutput<typeof ListUsersQuerySchema>
  },
});

app.router.put('/users/:id', {
  middleware: [userIdParams, updateBody],
  handler: async (ctx) => {
    const params = userIdParams.value(ctx);
    const body = updateBody.value(ctx);
    // both are validated and typed by their schemas
  },
});

ValidatedOutput<S> is the schema’s parsed output type — coercions and defaults included — inferred from the schema you pass, so the type follows the schema instead of being restated (or cast) in the handler. value(ctx) returns the stored value only when that validator ran on the request; it throws a TypeError naming the target otherwise — when the validator is not in the route’s chain, or a later validator for the same target overwrote it. Validated values are still stored in ctx.state under validatedStateKey(target) — the wire format is unchanged, and the @ValidateBody/@ValidateQuery/@ValidateParams resolvers read through it — so the old ctx.state.get(validatedStateKey('body')) read still works.

Using the Service’s middleware() Method

The IValidationService.middleware() method builds middleware with the formatter chosen at plugin construction time:

import { CAPABILITIES, IValidationService } from '@setu-ts/common';

app.router.post('/users', (ctx, next) => {
  const validation = ctx.services.get<IValidationService>(CAPABILITIES.VALIDATION);
  return validation.middleware(CreateUserSchema, 'body')(ctx, next);
});

Form body validation

validateBody(schema, { forms: true }) and @ValidateBody(schema, { forms: true }) also validate application/x-www-form-urlencoded and multipart/form-data bodies with the same schema they validate a JSON body with. Off by default: a cross-site HTML <form> reaches a route that accepts forms with no CORS preflight, so pair an opted-in route with form CSRF protection (csrfFormMiddleware from @setu-ts/session-plugin). Without the option a form body answers 415 “Unsupported Media Type”. The option is ValidationMiddlewareOptions in @setu-ts/common, the optional third parameter of IValidationService.middleware, and has no effect on any target but body. With it, a server-rendered <form> post validates against the schema a JSON client passes and reads back through value(ctx).

The first-party validation service requires application/json or any application/*+json media type, ignoring case and parameters, or a recognised form type when forms are enabled. Unsupported types, including text/plain and an absent header, answer 415 before any body read, through the application’s error responder. Clients must send Content-Type: application/json for JSON, including on an empty body-validated request. Routes reading ctx.request.json() directly are unchanged; replacement IValidationService implementations own their media-type handling. Cookie-authenticated routes still need csrfMiddleware or csrfFormMiddleware. The 415 takes the error responder’s format, while a 400 validation failure takes ValidationPlugin({ errorFormat }), so set both to the same format; the CLI templates set both to 'rfc9457'.

The form is converted to a record before validation: a field sent once is a single value, a repeated field becomes an array of its values in wire order, and a name ending [] is always an array (kept under the name as written). Values are strings or the framework’s FormFile — NOT a web File, so z.instanceof(File) refuses a file part and a schema checks the FormFile shape instead; numbers and booleans arrive as strings, so schemas use z.coerce for them. A form that carries the session plugin’s _csrf field reaches the schema, where a Zod object strips the unknown key by default and forbidNonWhitelisted (or the schema’s own .strict()) refuses it. A body the runtime refuses for size (RuntimePlugin({ maxBodyBytes })) answers 413 — on a validated route it used to answer 400 — and a form body that cannot be parsed answers the same 400 a malformed body does. The generated OpenAPI document still describes a validated body as application/json.

Sanitization

Sanitization is a standalone export (not a method on IValidationService):

import { SanitizationRules, sanitize } from '@setu-ts/validation-plugin';

const rules: SanitizationRules = {
  htmlEncode: true,
  stripTags: true,
  maxLength: 1000,
  trim: true,
};

const clean = sanitize(userInput, rules);

You can also create a reusable sanitizer function:

import { createSanitizer } from '@setu-ts/validation-plugin';

const sanitizer = createSanitizer({ htmlEncode: true, maxLength: 500 });
const clean1 = sanitizer(inputA);
const clean2 = sanitizer(inputB);

Error Response Formats

Default format

{
  "message": "Validation failed with 2 issue(s).",
  "errors": [{ "field": "email", "message": "Invalid email address", "code": "invalid_string" }]
}

RFC 9457 Problem Details

The 'rfc7807' alias produces a byte-identical body: this formatter’s type is a semantic URI that was never derived from the status code, so it was already valid under RFC 9457.

{
  "type": "https://setu-ts.dev/errors/validation",
  "title": "Validation Error",
  "status": 400,
  "detail": "The request contains 1 validation error(s).",
  "instance": "/users",
  "errors": [{ "field": "email", "message": "Invalid email address", "code": "invalid_string" }]
}

NestJS format

{
  "statusCode": 400,
  "message": ["email: Invalid email address"],
  "error": "Bad Request",
  "errors": [{ "field": "email", "message": "Invalid email address", "code": "invalid_string" }]
}

Custom Error Formatter

import { ValidationPlugin } from '@setu-ts/validation-plugin';

app.register(
  ValidationPlugin({
    errorFormat: (issues) => ({
      ok: false,
      fields: issues.map((i) => ({ name: i.path, reason: i.message })),
    }),
  }),
);

DatabasePlugin() (@setu-ts/database-plugin)

Portable data refusals and tenant resolution (M119e, since 0.11.0)

UnknownPayloadFieldError is defined in @setu-ts/common and re-exported unchanged here. MissingKeyColumnError(entity: string, column: string) names a missing Drizzle primary-key column and the entities.<Entity>.primaryKey remedy. Explicit configured keys are checked at connect for registered tables; query-only tables without a key override are checked when a repository is requested. TenantColumnUnresolvedError(entity: string, column: string) names a tenant column that resolves to no property or multiple properties, with a ColumnStrategy remedy. Both configuration errors carry no HTTP hint (masked 500). The tenant bridge resolves its own configured column to the Drizzle property once per entity; caller payload keys are never translated.

The database driver’s existing unavailable-errno classification uses the shared isConnectionFaultCode(code) predicate from @setu-ts/common. The eight-code set is module-private; status classification is unchanged.

Provides database access with repository pattern and unit of work.

Migration ledger and schema readiness (M113o)

createMigrationLedger(database: IDatabaseService, options?: MigrationLedgerOptions): IMigrationLedger uses the repository surface. MigrationLedgerOptions.entity defaults to the exported MIGRATION_LEDGER_ENTITY = 'setu_migrations'. IMigrationLedger.applied(): Promise<readonly string[]> reads only id with a 10,001-row limit; more than 10,000 rows throws the exported MigrationLedgerError (reason: 'oversized'). record(name: string, appliedAt: string): Promise<void> inserts { id: name, appliedAt } and propagates duplicate-create errors. remove(name: string): Promise<void> resolves even when absent. Entity options must be non-empty strings.

Every DatabaseConnectionOptions arm accepts readiness?: DatabaseReadinessOptions: { readonly migrations: readonly string[]; readonly ledgerEntity?: string }. Expected names must be unique non-empty strings, at most 10,000; ledgerEntity defaults to MIGRATION_LEDGER_ENTITY, and createMigrationLedger(database) without an explicit entity uses the ledgerEntity that connection was registered with, so the generated runner writes where readiness reads. Invalid readiness options throw TypeError at plugin construction, naming the option without echoing values. Absent readiness preserves the indicator’s status and data. Otherwise, after a non-down result, a separate 5-second cached, 2-second bounded ledger read adds data.migrations: unreadable/rejected/timed-out/oversized → down with { state: 'unreadable' }; missing expected names → degraded with { state: 'pending', pending: count }; current → unchanged status with { state: 'current', applied: count }, adding unknown: count for names this build does not list. Unknown names preserve rolling-deploy readiness. Already-down results bypass the ledger. Only counts, never names, appear in data. See the database-plugin README for application-owned SQL DDL, entity mappings, measured duplicate errors and the process-local memory caveat.

Registration

import { DatabasePlugin } from '@setu-ts/database-plugin';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from './generated/prisma/client.ts';

// Prisma 7 removed the Rust query engine, so a driver adapter is REQUIRED —
// `new PrismaClient()` with no argument does not compile.
const prismaClient = new PrismaClient({
  adapter: new PrismaPg({ connectionString: config.getOrThrow('DATABASE_URL') }),
});

app.register(
  DatabasePlugin({
    type: 'prisma',
    options: {
      prismaClient,
      logQueries: config.get('NODE_ENV') === 'development',
    },
  }),
);

DatabasePluginOptions is a union discriminated on type, and each arm names the options its adapter cannot run without: 'prisma' requires options.prismaClient, 'drizzle' requires both options.drizzleInstance and options.drizzleTables, 'mongodb' requires options.url (or an injected options.client), 'dynamodb' requires options.region (or an injected options.client), and 'custom' requires adapter. Omitting one is a compile error rather than a startup throw. The exported arms are MemoryDatabaseOptions, PrismaDatabaseOptions, DrizzleDatabaseOptions, MongoDatabaseOptions, DynamoDatabaseOptions and CustomDatabaseOptions; BuiltInDatabaseOptions is the union of the five built-in arms and keeps its published name, so an existing annotation carrying a memory configuration still compiles. PrismaAdapterOptions and DrizzleAdapterOptions narrow DatabaseAdapterOptions for their arm; MongoAdapterOptions and DynamoAdapterOptions are their arms’ dedicated driver option bags.

Three Prisma 7 prerequisites are the application’s to supply and are documented in the package README: the driver-adapter package, a prisma.config.ts (Prisma 7 rejects url in schema.prisma), and new PrismaPg({ connectionString }, { schema }) for a non-public PostgreSQL schema.

DatabaseAdapterOptions.transactionTimeout (ms, default 30000) raises Prisma’s ~5 s interactive-transaction default, which is too short for a full Unit of Work. It is read by the Prisma adapter only; Memory and Drizzle ignore it.

Prisma v7 clients are generated into an application-selected output path, so the Prisma adapter requires an application-created options.prismaClient; it never imports or constructs that client. DatabaseAdapterOptions.url remains source-compatible but is deprecated for Prisma configuration. DatabaseAdapterOptions.provider (type PrismaSqlProvider) names the SQL connector the injected client is bound to. Only the contains filter is connector-sensitive: it is escaped on PostgreSQL/MySQL/SQL Server/CockroachDB, passed through unchanged on MongoDB (whose contains compiles to a $regex match, where % and _ are already literal so escaping them would be wrong), and refused on SQLite (see the contains note above). When omitted, the adapter reads the client’s active provider structurally at connect() time; if it cannot be determined, a contains filter throws UnsupportedFilterOperatorError naming this option, so pass it explicitly in that case. Transaction isolation is connector-specific too: when the provider cannot be determined, a requested isolation level is refused by name, so pass provider explicitly. Drizzle requires both options.drizzleInstance and options.drizzleTables. The instance is a opaque DrizzleDatabase<T> configuration created by createDrizzleDatabase(database, transactionBridge); the registry’s tables must carry an id column and the adapter translates every repository field to a real Drizzle column. Drizzle create, update, and delete require a driver with RETURNING support so their results are actual driver rows; an unsupported dialect throws a descriptive error. Promise-aware SQLite Proxy and libsql-shaped Drizzle instances without execute() are accepted for repository, transaction, and typed-builder use. Calling IDatabaseService.query() on such an instance rejects with guidance to use Drizzle’s typed query builder. That refusal is permanent rather than pending: those drivers do expose all(), but on a raw statement the proxy protocol answers with positional rows, because Drizzle has no field map for a statement it did not build — and query<T>() promises row objects.

Synchronous callback drivers (better-sqlite3, Bun SQLite, Expo SQLite, and OP SQLite) are unsupported: their native transaction closes when the callback returns, before awaited UoW work can run. createDrizzleDatabase() rejects those published types at compile time, and startup rejects an unwrapped structural instance. The explicit source-owned bridge is the positive capability that asserts the selected driver awaits its callback Promise; unknown adopting-Promise and thenable wrappers are rejected at startup before native transaction work begins.

PrismaSqlProvider still carries a 'mongodb' member and the PASSTHROUGH_PROVIDERS mechanism that holds it, but that arm is unreachable on Prisma v7: a Prisma client for MongoDB cannot be constructed (generation succeeds, construction fails — Prisma states MongoDB “did not make the Prisma 7 release” and returns in Prisma 8). The 'mongodb' adapter arm above is the supported MongoDB route; the 'mongodb' provider string on the Prisma union is retained only because removing it is a published export (AI_GUIDELINES §9.2), and the contains-to-$regex reasoning behind PASSTHROUGH_PROVIDERS stays correct for any future Prisma-Mongo client.

Transactional idempotency store (M109b)

createDatabaseIdempotencyStore(options?: DatabaseIdempotencyStoreOptions) returns a RegistryFactory<ITransactionalIdempotencyStore> for IdempotencyPlugin’s transactional.store. entity defaults to 'Idempotency'; database selects database.<name>, or the default CAPABILITIES.DATABASE when omitted or 'default'. The factory resolves the database and runtime services at initialization. The record and business work must use the same database.

run creates a claim (id), executes business work through that transaction’s IUnitOfWork, creates the encoded result (${id}.r), and commits. It uses two creates, with no update or delete. find requires both rows with the idempotency kind and their correct roles. purge preserves incomplete, foreign-kind and invalid-envelope records, and deletes a complete record only when an in-transaction re-read still finds it expired. That re-read narrows but does not close one race: two concurrent purges can delete a record a client re-created between them (closed on MongoDB; about one round trip on PostgreSQL READ COMMITTED; the deferred commit on DynamoDB and D1). The portable repository does offer a conditional delete (deleteWhere(id, where), M105), but the purge does not use it: a record is two rows (id and ${id}.r) that must go together, and deferred-write transaction sources omit conditional members. verify runs the two-create pattern and rolls it back before the first call.

TransactionalStoreUnavailableError carries entity and reason: 'cosmos-unsupported', 'bigtable-unsupported', 'mongodb-standalone' or 'entity-unavailable'. An adapter refusal can be retained as its cause. A missing/unreadable entity fails startup.

BackendTier-C behavior
MemorySupported in one process; a loser is refused at commit and replays.
PostgreSQL / DrizzleSupported; the claim insert blocks the loser until the winner commits, then the loser replays. Each blocked call holds a connection.
PostgreSQL / PrismaAs Drizzle: a concurrent loser blocks on the winner’s claim insert before running fn, then replays. Prisma’s transactionTimeout does not interrupt that wait (measured with a 1 s loser timeout against a winner holding the key 8 s: the loser waited and replayed). A loser whose own work exceeds its timeout answers 'store-failed' (503); there is no timeout-to-409 mapping.
SQLite / DrizzleSupported with a Promise-aware transaction bridge; one writer at a time, with replay or busy/locked conflict depending on timing.
D1Supported via the custom adapter arm; deferred batch commits one business write, but concurrent calls execute fn twice and its outside effects run twice.
MongoDB replica setSupported with pre-existing collections; the concurrent loser gets conflict, then replays on retry.
DynamoDBSupported with deferred TransactWriteItems; a concurrent loser replays or gets conflict. Both calls execute fn; database work commits once, outside effects run twice. TransactionConflictException is documented-unverified on DynamoDB Local (M90f). Eventually consistent reads can also miss a just-committed record; commit uniqueness still prevents a second business write.
MongoDB standaloneRefused with mongodb-standalone.
Cosmos DBRefused with cosmos-unsupported: the record cannot share the business partition.
BigtableRefused with bigtable-unsupported.

Not verified: DynamoDB’s overlapping-service TransactionConflictException outcome on DynamoDB Local (M90f). Tier C was driven against a real Prisma 7.10 client on PostgreSQL once, by hand; the committed real-backend suite uses Drizzle.

Repository Pattern

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

app.router.get('/users', async (ctx) => {
  const db = ctx.services.get<IDatabaseService>('database');
  const userRepo = db.getRepository<User>('User');

  const users = await userRepo.findAll({
    where: { active: true },
    orderBy: { createdAt: 'desc' },
    limit: 20,
    offset: 0,
  });

  return ctx.response.json(users);
});

app.router.get('/users/:id', async (ctx) => {
  const db = ctx.services.get<IDatabaseService>('database');
  const user = await db.getRepository<User>('User').findById(ctx.params.id);

  if (!user) {
    return ctx.response.status(404).json({ error: 'User not found' });
  }

  return ctx.response.json(user);
});

app.router.post('/users', async (ctx) => {
  const db = ctx.services.get<IDatabaseService>('database');
  const body = await ctx.request.json<{ name: string; email: string }>();

  const user = await db.getRepository<User>('User').create({
    name: body.name,
    email: body.email,
  });

  return ctx.response.status(201).json(user);
});

Unit of Work (Transactions)

app.router.post('/orders', async (ctx) => {
  const db = ctx.services.get<IDatabaseService>('database');

  const order = await db.transaction(async (uow) => {
    const orderRepo = uow.getRepository<Order>('Order');
    const inventoryRepo = uow.getRepository<Inventory>('Inventory');
    const paymentRepo = uow.getRepository<Payment>('Payment');

    // All operations in same transaction
    const newOrder = await orderRepo.create(await ctx.request.json());

    const stock = await inventoryRepo.findById(newOrder.productId);
    await inventoryRepo.update(newOrder.productId, {
      quantity: stock!.quantity - newOrder.quantity,
    });

    await paymentRepo.create({ orderId: newOrder.id, amount: newOrder.total });

    return newOrder;
  });

  return ctx.response.status(201).json(order);
});

Isolation

transaction(work, options?) accepts TransactionOptions, whose optional isolation is one of 'read-uncommitted', 'read-committed', 'repeatable-read', or 'serializable'. Omission keeps the adapter default. A requested level is honoured or refused by name; it is never silently downgraded. Database-plugin adapters throw the caller-safe 501 UnsupportedIsolationLevelError. D1 throws its own CloudflareUnsupportedError, since the Cloudflare plugin cannot depend on another plugin’s error class.

AdapterHonoured levels
PrismaPostgreSQL, MySQL, and SQL Server: all four; CockroachDB and SQLite: serializable; MongoDB: none
Drizzleall four only when the application wraps its bridge with withIsolationSupport()
Memoryserializable (one process only)
MongoDBnone
D1, DynamoDB, Cosmos DB, Bigtablenone

withIsolationSupport() is an application declaration that its bridge forwards options. The type system cannot verify its body uses them; an unbranded bridge refuses every requested level rather than silently dropping it.

For a custom adapter, declare transactionIsolationLevels on IDatabaseAdapter; without that explicit capability the database service refuses every requested isolation level rather than assuming a zero-argument beginTransaction() honours it.

Typed Drizzle queries

interface DrizzleDatabase<TDatabase extends object> { /* package-owned typed witness */ }
interface DrizzleDatabaseIdentity { /* erased opaque configuration identity */ }
type DrizzleTransaction<TDatabase extends object> = /* Drizzle transaction callback parameter */;
type DrizzleTransactionBridge<TDatabase extends object> = <T>(
  database: TDatabase,
  work: (transaction: DrizzleTransaction<TDatabase>) => Promise<T>,
) => Promise<T>;

function createDrizzleDatabase<const TDatabase extends object>(
  database: TDatabase,
  transaction: DrizzleTransactionBridge<TDatabase>,
): DrizzleDatabase<TDatabase>;
function getDrizzleDatabase<TDatabase extends object>(
  service: IDatabaseService,
  database: DrizzleDatabase<TDatabase>,
): TDatabase;
function getDrizzleTransaction<TDatabase extends object>(
  unitOfWork: IUnitOfWork,
  database: DrizzleDatabase<TDatabase>,
): DrizzleTransaction<TDatabase>;

Pass the same package-created opaque configuration supplied in options.drizzleInstance. getDrizzleDatabase() infers and returns that complete configured type. getDrizzleTransaction() structurally derives Drizzle’s native transaction callback parameter from it, so schema and selected-row inference survive while outer-only operations (for example SQLite Proxy’s batch()) are absent. Native joins and repository writes still participate in the same commit or rollback:

import { eq } from 'drizzle-orm';
import {
  createDrizzleDatabase,
  getDrizzleDatabase,
  getDrizzleTransaction,
} from '@setu-ts/database-plugin';

const drizzleDatabase = createDrizzleDatabase(drizzleDb, (database, work) =>
  database.transaction(work),
);
const outer = getDrizzleDatabase(db, drizzleDatabase);
const rows = await outer.select().from(users);

await db.transaction(async (uow) => {
  await uow.getRepository<User>('User').create(newUser);

  const tx = getDrizzleTransaction(uow, drizzleDatabase);
  const joined = await tx
    .select({ userId: users.id, teamName: teams.name })
    .from(users)
    .innerJoin(teams, eq(users.teamId, teams.id));
});

The opaque configuration carries compile-time correlation while its database, bridge, and validity remain in package-private storage. It is frozen with a null prototype as defense in depth; mutation, spread, assignment, cloning, prototype inheritance, and cross-instance reuse do not create accepted identities. A caller cannot select a forged generic at the UoW call, and a configuration from another database throws. Use getDrizzleDatabase() for outer-only operations; getDrizzleTransaction() intentionally returns only the transaction-safe callback surface. The distinct functions also keep a service widened structurally to IUnitOfWork runtime-truthful. Memory, Prisma, and custom services/UoWs throw Drizzle query access requires adapter 'drizzle'; configured adapter is '<type>'. A structural service or Unit of Work not created by this package throws Drizzle query access requires a database-plugin service or unit of work.

Health status

Since M90b the database indicator gates on the service’s lifecycle FIRST, uncached — a closed database reads down immediately, never from an outcome cached before close. The gate is re-read after the probe settles as well, so a poll already in flight when close() begins reports down rather than publishing the answer the probe had cached a moment earlier. That gate reads DatabaseService.isClosed, a lifecycle-only member that reaches no adapter, so every IDatabaseAdapter.isReady() call happens behind it, inside a cached, bounded probe — 5-second TTL, 2-second bound, built on createCachedProbe — and an adapter whose readiness performs I/O cannot hang /health past the bound. Repeated polls cost at most one readiness read per TTL; gating on isHealthy() instead read the adapter on the one path deliberately outside that bound, and cost two reads per poll. The consequence is the ordinary TTL trade-off, and it is the same one every other M90b indicator makes: closing the database reads down on the very next poll, while an adapter that loses readiness on its own — a pool that ends itself — is served from the cache for up to the TTL before the indicator reports it. A Drizzle registration that supplies DrizzleAdapterOptions.poolStats — an application-owned callback reading the driver’s own documented pool API — also publishes the returned DatabasePoolCapacity snapshot ({ total, idle, waiting }) under data.capacity. Omitted, the payload carries no capacity fields. Capacity is data, not policy: no threshold is applied, and the snapshot changes one row only. Since M101a, when it shows every connection busy with callers waiting (idle === 0 and waiting > 0), the probe queues no SELECT 1 and the indicator reports up with reachable: 'unknown' while queries through the adapter are still completing (one completed within the last 10 seconds; with none, a full pool is a hung database and reports degraded; once getDrizzleDatabase/getDrizzleTransaction has handed out the native instance, whose queries bypass the adapter, progress is unobservable and a saturated pool reads up) — saturation, not an outage, so /ready does not pull every saturated replica at once. Without poolStats that row cannot be read, so a pool connection timeout or an unanswered probe reports degraded (a pool connection timeout reported down before M101a: the database never answered either way). Caller-facing pool-timeout status mapping is M90f. A snapshot the callback returns in a malformed shape is dropped exactly like an absent one — a broken reading is never published as a number. A callback that throws, or one whose counters violate the documented shape (a negative count, or idle exceeding total, which counts idle + in use), is dropped the same way: capacity is omitted for that poll and the indicator’s own lifecycle and reachability answer stands.

Since M95b the payload also carries reachable whenever the adapter carries a liveness probe (the optional IDatabaseAdapter.isHealthy?()): true — the backend answered; false — down; a probe that exists and did not answer inside the 2-second bound reports 'unknown' and the status degraded, never up — the claim that put a dead-database pod back in rotation (X51-1: the indicator used to read the lifecycle isReady(), which reports that connect() once succeeded and says nothing about the backend now). An adapter that ships NO probe reports up with reachable OMITTED and /ready answering 200 — the deliberate no-change row: a probe that was never written is evidence of nothing, and mapping a missing probe to degraded would fail /ready for every healthy Cosmos, Bigtable and DynamoDB application on upgrade (degraded already answers 503). Shipped probes: MongoDB (db.command({ ping: 1 })), Prisma and Drizzle (SELECT 1; Drizzle omits the probe for instances without execute()), memory (its own connection state — the process is the backend). Cosmos, Bigtable and DynamoDB omit the probe pending one optional member on their own client facades; their payload is unchanged. The concrete DatabaseService exposes the seam the indicator reads: hasReachabilityProbe (a synchronous presence read) and reachability() (Promise<boolean | undefined> — the bounded probe, undefined when no answer was produced), the database twin of IMessageBroker.reachability(). The lifecycle read still gates the probe, so an adapter whose own probe does not re-derive isReady() cannot report up for a connection it has already dropped.

Database Interface

interface IDatabaseService {
  getRepository<Entity>(entity: string): IRepository<Entity>;
  transaction<T>(work: (uow: IUnitOfWork) => Promise<T>): Promise<T>;
  query<T>(sql: string, params?: unknown[]): Promise<T[]>;
  migrate(): Promise<void>;
  isHealthy(): Promise<boolean>;
  close(): Promise<void>;
}

interface IRepository<Entity, Id extends EntityKey = string> {
  findById(id: Id): Promise<Entity | null>;
  findAll(options?: FindOptions): Promise<Entity[]>;
  findOne(options?: FindOptions): Promise<Entity | null>;
  findPage(options: PageOptions): Promise<Page<Entity>>;
  create(data: Partial<Entity>): Promise<Entity>;
  update(id: Id, data: Partial<Entity>): Promise<Entity>;
  delete(id: Id): Promise<boolean>;
  updateWhere?(id: Id, where: WritePrecondition, data: Partial<Entity>): Promise<Entity | null>;
  deleteWhere?(id: Id, where: WritePrecondition): Promise<boolean>;
  exists(id: Id): Promise<boolean>;
  count(options?: CountOptions): Promise<number>;
}

Every member except updateWhere? and deleteWhere? is required — including findPage, which is easy to miss because the sibling IDataSource.findPage? is optional. A class implementing IRepository directly, without extending BaseRepository, must supply findPage; the in-repo reference implementor is packages/database-plugin/test/fixtures/repository-implementor.ts, and the reader-side step is in docs/upgrading.md.

The concrete DatabaseService additionally exposes readonly isClosed: boolean (since 0.5.0) — a lifecycle-only read that reaches no adapter, added for the health indicator’s uncached gate. isHealthy() answers lifecycle AND adapter readiness together; isClosed answers lifecycle alone.

query() is the existing backend-specific raw-SQL escape hatch. It requires a configured Drizzle instance with execute(); typed builders obtained through the Drizzle accessors do not.

Every adapter binds params and never interpolates them, and the statement carries the connector’s own placeholders — $1… on PostgreSQL, ? on MySQL and SQLite. Prisma and D1 forward the text verbatim; the Drizzle adapter splits it at its placeholders and rebuilds it through Drizzle’s own SQL chunks, which emits an ascending-placeholder statement byte-identically. A placeholder count that disagrees with params, a gap in the $N sequence, or both placeholder styles in one statement is refused before the driver is reached, because a mis-bound parameter is silent. On PostgreSQL, ?, ?| and ?& are also jsonb key-containment operators that no scanner can tell from a placeholder; such a statement is refused or fails at the database, never mis-bound — write it with $N placeholders. Programmatic migrate() is unsupported by all current adapters and rejects with UnsupportedMigrationError because each ORM owns migrations through its CLI; it is a permanent framework capability boundary and therefore answers 501 Not Implemented through errorHandler.

FindOptions.filter and CountOptions.filter accept a portable expression tree in addition to the existing equality-only where map. where and filter are conjoined, and every built-in adapter evaluates the same operators: eq, contains, gt, gte, lt, lte, and in, nested with and / or. findOne() applies the same options as findAll() and returns the first match or null.

const users = await userRepo.findAll({
  where: { active: true },
  filter: {
    type: 'or',
    filters: [
      { type: 'comparison', field: 'name', operator: 'contains', value: 'Ada' },
      { type: 'comparison', field: 'age', operator: 'gte', value: 18 },
    ],
  },
});

An in with an empty list matches nothing, and a list containing null matches rows whose column is null — SQL IN never matches NULL by itself, so the SQL adapters emit an explicit null branch. contains is a substring match whose % and _ are always data rather than wildcards; its case sensitivity is the database’s, not the framework’s — Memory, D1 and Mongo match case-sensitively, while a LIKE-based backend follows the column’s collation (case-sensitive on PostgreSQL, case-insensitive on SQLite and most MySQL collations). Mongo compiles to $regex, to which MongoDB does not apply collation, so a case-insensitive collection collation does not make it case-insensitive. Collation control is not part of this contract.

On the Prisma adapter the connector decides how that guarantee is met. On PostgreSQL, MySQL, SQL Server, and CockroachDB the value is escaped and matched literally, because their LIKE defaults its escape character to backslash. On SQLite the guarantee is not expressible through Prisma’s filter API (Prisma emits no ESCAPE clause and SQLite has no default escape character), so a contains filter throws UnsupportedFilterOperatorError rather than return wrong rows; pass a raw query, or use the Memory/Drizzle adapter (both honour contains as a literal substring). When the adapter cannot determine the connector it throws the same error naming the provider option; pass provider (e.g. provider: 'postgresql') in the adapter options to name it explicitly.

The Memory adapter’s guarantees

MemoryAdapter is the default and is never given a schema, which fixes what it can enforce. An unknown select or orderBy field — one that no stored row carries — is refused by name, matching what Drizzle answers for the same call; a field present on at least one row counts as known, and an entity holding no rows accepts anything, since there is nothing to observe and nothing to return. An unknown where or filter field is not refused: without a schema the adapter cannot tell an unknown column from one absent on every row, and matching nothing is a defensible answer.

A create whose caller-supplied primary key is already stored is refused — the key is the one constraint this adapter knows, and a duplicate would leave a row no lookup by id can address; for the same reason an update cannot change a primary-key value (MongoDB’s immutable _id rule). Other uniqueness, column types, foreign keys, checks and defaults are not enforced and cannot be by a schema-less store. Use this adapter for development and tests, and run integration tests against the backend you deploy on.

Custom Adapters (external backends)

DatabasePluginOptions is a union discriminated on type. The 'custom' arm accepts any IDatabaseAdapter from @setu-ts/common, which is how a backend implemented outside this package is registered — no plugin imports another plugin.

import { DatabasePlugin } from '@setu-ts/database-plugin';
import { D1Adapter, type ID1Database } from '@setu-ts/cloudflare-plugin';

app.register(
  DatabasePlugin({
    type: 'custom',
    adapter: new D1Adapter(env.DB as ID1Database),
  }),
);

adapter is required by the union, so a 'custom' registration that omits it is a compile error rather than a startup throw. The plugin calls connect() on the adapter during register() and disconnect() on shutdown; it never constructs or replaces it. name and options apply as usual — logQueries routes a custom adapter’s data sources through the same single logging wrapper every built-in adapter uses.

The port to implement:

interface IDatabaseAdapter extends IOrmAdapter {
  transactionIsolationLevels?: readonly TransactionIsolationLevel[];
  isHealthy?(): Promise<boolean>;
  createDataSource(entity: string): IDataSource;
  beginTransaction(options?: TransactionOptions): Promise<IAdapterTransaction>;
  rawQuery<T>(sql: string, params?: unknown[]): Promise<T[]>;
}

interface IDataSource {
  findAll(query: NormalizedQuery): Promise<Record<string, unknown>[]>;
  findById(id: EntityKey): Promise<Record<string, unknown> | null>;
  create(data: Partial<Record<string, unknown>>): Promise<Record<string, unknown>>;
  update(id: EntityKey, data: Partial<Record<string, unknown>>): Promise<Record<string, unknown>>;
  delete(id: EntityKey): Promise<boolean>;
  updateWhere?(
    id: EntityKey,
    where: WritePrecondition,
    data: Partial<Record<string, unknown>>,
  ): Promise<Record<string, unknown> | null>;
  deleteWhere?(id: EntityKey, where: WritePrecondition): Promise<boolean>;
  findPage?(query: NormalizedQuery): Promise<PageResult>;
  count(where: Record<string, unknown>, filter?: FilterExpression): Promise<number>;
}

A data source owns query evaluation end to end — it applies where, orderBy, offset/limit and select itself, and BaseRepository must not re-apply any of them.

Conditional writes (M105, since 0.9.0). Both optional members address the supplied key AND require every predicate field to equal its value at the write. updateWhere returns the updated row or null; deleteWhere returns whether a row was deleted. An absent key and a predicate miss have the same outcome and write nothing. The key and predicate remain separate conjunctions even when the predicate names a key field.

WritePrecondition, exported by @setu-ts/common, is Readonly<Record<string, string | number>>. checkWritePrecondition(where, data?) returns a WritePreconditionCheck: { ok: false, problem }, or { ok: true, where, data } carrying private copies of the inputs. It requires a non-empty plain equality object, non-empty field names without a $ prefix or ., string or finite-number values, and, when supplied, a non-empty plain update payload. Each field is read once into the copy and the copy is what gets validated and written, so an input that changes between reads (a Proxy) cannot send something other than what passed. An own __proto__ key is refused, in the predicate and in the payload, because Node and Bun drop it from any object a backend rebuilds by assignment. Every implementation validates direct calls as well as repository calls. checkWritePrecondition’s refusal reasons quote no caller field name or value, and a Prisma validation refusal — whose message renders the whole query — becomes UnknownPayloadFieldError or InvalidFieldValueError with a fixed-message cause, on a direct data-source call as through a repository (M119e; it was UnsupportedQueryFeatureError before 0.11.0). No other guarantee is made about message text: key handling shared with findById/update/delete, adapter mapping and marshalling refusals, and driver or server errors (a type the column rejects, an unknown column) may quote caller keys, field names or values, as for every other repository operation. Responses mask them (X12-3), so they reach server logs only. Invalid inputs reject with UnsupportedQueryFeatureError feature 'write-precondition' (unbranded) — except a direct call to the D1 data source, which rejects with CloudflareUnsupportedError, since cloudflare-plugin cannot import database-plugin’s error. Through a repository the check runs in BaseRepository first, so every backend answers 'write-precondition' there. BaseRepository rejects missing support with UnsupportedQueryFeatureError feature 'conditional-write' (HTTP hint 501), before any I/O. Both members stay optional for implementors.

Data sourceWrite mechanism
Memory, outside transactionsSynchronous key lookup, predicate check and mutation
PrismaNative update/delete, key conjoined with AND: [where]; P2025 means no match
DrizzleKey and predicate joined with and(...), native RETURNING
MongoDB$and: [idFilter, whereFilter], findOneAndUpdate / deleteOne
D1Bound UPDATE / DELETE, WHERE key AND predicate, RETURNING
DynamoDBNative ConditionExpression, key existence AND aliased equality fields
Cosmos DBRead and check, then _etag IfMatch guarded replace / delete; retry 412 at most three rounds
BigtableCheckAndMutateRow with nested per-field conditions, newest cell capped before value comparison

Deferred transaction sources (memory overlay, D1 batch, DynamoDB buffer, Cosmos batch and Bigtable) omit both members: the result is unknown until commit, so the repository refuses 'conditional-write'. Prisma, Drizzle and MongoDB transaction sources carry them. The tenant and inbox bridges use the conditional path when offered and fall back only on absence or that named refusal. The outbox requires native conditional writes and refuses unsupported sources at startup. Other errors propagate. On that fallback the tenant bridge reads ownership and then writes by key, so a delete-and-recreate under a reused caller-supplied key in between can move the write onto another tenant’s row; generated keys avoid the reuse. Bigtable reads an updated row back separately; the tenant bridge checks the returned tenant on both paths before returning it.

__proto__ fields. The conditional members refuse an own __proto__ field in the predicate and the payload on every adapter. Cosmos DB and DynamoDB also refuse one in a plain create/update payload (DynamoDB at any depth, because the AWS SDK returns such an attribute without a value), and neither lets a stored __proto__ field set a read row’s prototype: Cosmos returns it as an ordinary field, DynamoDB omits the attribute the SDK cannot deliver.

Stored representation. Predicate scalars compare with the stored representation. DynamoDB date attributes encoded as ISO strings require the ISO string; Bigtable’s typed/json codecs use their configured scalar encoding. Neither member translates a Date or allows a document path or operator in the predicate.

Breaking for implementors as of M79. findById/update/delete moved from string | number to EntityKey, which adds a composite-record arm. A parameter is contravariant, so an adapter still declaring the scalar-only form is a compile error; callers passing a scalar are unaffected. The same release widened FilterComparison.field to string | readonly string[] (an array is a nested document path) and the gt/gte/lt/lte value to accept Date, so any code READING those fields must handle both forms.

findPage is optional. Every adapter this framework ships implements it; an adapter that cannot page by cursor omits it, and the repository refuses by name rather than returning an empty page — so absence means “this backend cannot page by cursor”, never “there are no more rows”.

isHealthy? is optional (M95b). It answers “is the backend reachable right now” — the liveness read the inherited lifecycle member isReady() was mistaken for. true means the backend answered; false means it was contacted and refused or failed; an adapter that cannot probe honestly omits the member, and the health indicator then reports up with reachable omitted rather than inventing an answer. The lifecycle read still gates the probe, so an adapter whose own probe does not re-derive isReady() cannot report up for a connection it has already dropped. Shipped probes: MongoDB (db.command({ ping: 1 }) through the optional IMongoDatabase.command? facade member), Prisma and Drizzle (rawQuery('SELECT 1')), memory (its own connection state).

Composite keys, nested paths and cursor pagination (M79)

@setu-ts/common exports the portable data-access contract these three features rest on, and is where a backend author imports it from — the codec lives there because cloudflare-plugin needs the identical encoding and a plugin may not import another plugin (AI_GUIDELINES §2.2).

@setu-ts/database-plugin re-exports the subset its own consumers reach for — EntityKey, PageResult, CursorPayload, CursorValue, encodeCursor, decodeCursor and keysetPredicate. sortFingerprint, mintNextCursor and resolveKeysetSort are not re-exported; import them from @setu-ts/common.

SymbolKindPurpose
EntityKeytypeA primary key: a scalar string/number, or a composite Readonly<Record<string, string | number>>. A record rather than an array, because a composite key is named columns and an array would make the caller depend on a column order the mapping owns.
PageResultinterface{ rows, nextCursor } returned by IDataSource.findPage. nextCursor is null exactly when the page is the last.
CursorPayloadinterfaceThe decoded contents of a cursor: orderedValues, keyValues, and a sortFingerprint.
CursorValuetypeA scalar a cursor can carry: string | number | Date. Dates survive the round trip as Date, not as ISO strings.
encodeCursorfunctionEncodes a CursorPayload as an opaque base64url token.
decodeCursorfunctionDecodes a token, or returns null when it is malformed — it never throws, because the token is untrusted caller input.
keysetPredicatefunctionBuilds the “row after this one” comparison as a portable FilterExpression.
sortFingerprintfunctionThe stable fingerprint embedded in every cursor; optional scope binds entity and key columns (since 0.11.0).
mintNextCursorfunctionMints the next-page cursor from the last row of a non-terminal page. Refuses a value that cannot survive the wire — null/undefined, a non-finite number, an invalid Date, or a non-scalar — naming the field, because each of those otherwise fails on the NEXT request or one frame from its cause.
resolveKeysetSortfunctionThe sort a keyset comparison is expanded over: the caller’s orderBy plus any key columns it lacks, appended ascending. A backend must order by this, not by orderBy — ordering by the caller’s fields alone leaves tied rows in an order the database picks freely, and the predicate then skips or repeats them. keysetPredicate derives its own comparison from the same function, so the two cannot disagree.

Composite keys

Scoped fingerprints (since 0.11.0). sortFingerprint(orderBy: Readonly<Record<string, OrderDirection>>, scope?: { readonly entity: string; readonly keyColumns: readonly string[] }): string returns the byte-identical legacy field:direction pairs without scope. With scope it returns JSON.stringify([scope.entity, scope.keyColumns, pairs]). Memory, Prisma, Drizzle, MongoDB, DynamoDB, Cosmos DB, Bigtable and D1 pass the resolved entity and key columns on mint and decode. Scope is a correctness check, not authorization; tenant predicates remain required.

Portable refusal classes (@setu-ts/common, since 0.11.0). UnknownPayloadFieldError(entity: string, field: string, options?: ErrorOptions) has name UnknownPayloadFieldError, a capped, single-line field diagnostic and the fixed hint { status: 400, title: 'Bad Request', detail: 'The request names a field this resource does not have.' }. InvalidFieldValueError(entity: string, options?: ErrorOptions) has name InvalidFieldValueError and hint { status: 400, title: 'Bad Request', detail: 'A supplied value does not fit the type of its field.' }. Its message is Entity '<entity>': the database refused a supplied value for its field type. No supplied value, key or field is included. Repository mappings retain a scrubbed driver error as cause. Drizzle PostgreSQL repositories map the four supplied-value SQLSTATE codes; D1 maps STRICT-table type refusals. Prisma validation errors containing Unknown argument `<field>` map to UnknownPayloadFieldError; other validation errors and known-request P2007 errors map to InvalidFieldValueError. This includes conditional writes, which now answer 400 rather than 501 for these refusals. A Prisma refusal cause is a new Error whose copied name and fixed message name only the original name and code, for example Prisma refused the input (PrismaClientKnownRequestError P2007).; the original object, query and payload are discarded.

findById, update and delete accept a composite record. Each adapter learns its key columns from a per-entity mapping whose primary-key field accepts a column list:

// Cloudflare D1
new D1Adapter(env.DB as ID1Database, {
  tables: { Membership: { table: 'memberships', primaryKey: ['tenant_id', 'user_id'] } },
});

// MongoDB — flat top-level key columns (the default for a composite key)
DatabasePlugin({
  type: 'mongodb',
  options: { url },
  mongoEntities: { Membership: { primaryKey: ['tenantId', 'userId'] } },
});

A repository declares its key type and addresses rows with a record:

const repo = db.getRepository<Membership, { tenantId: string; userId: string }>('Membership');
await repo.findById({ tenantId: 't1', userId: 'u1' });

A scalar key against a multi-column entity, or a record missing a column, is refused by name.

MongoDB idType: 'compound' stores the composite key as a subdocument _id. The subdocument is built in the mapping’s declared column order, never the caller’s key-object order: a Mongo subdocument _id is matched by exact, order-sensitive equality, so {userId, tenantId} does not match a document stored as {tenantId, userId}. The default for a composite key is flat top-level columns, which are order-independent and constrained by a unique index.

Prisma addresses a compound key through Prisma’s own compound-key field. The name is derived by joining the key columns with _, matching what Prisma generates for an unnamed @@id. A model declaring a named @@id must set compositeKeyName — Prisma rejects the derived name on such a model, so this is required rather than optional:

DatabasePlugin({
  type: 'prisma',
  options: { prismaClient },
  entities: {
    Membership: { keyColumns: ['tenantId', 'userId'] },
    Enrollment: { keyColumns: ['courseId', 'personId'], compositeKeyName: 'enrollmentKey' },
  },
});

Nested field paths

FilterComparison.field accepts a readonly string[] as a document path. A plain string is a flat field, unchanged. A dotted string is not a path — a column whose name legitimately contains a dot keeps its meaning.

await repo.findAll({
  filter: { type: 'comparison', field: ['address', 'city'], operator: 'eq', value: 'Kolkata' },
});

Every adapter that supports a path translates it natively; none filters in JavaScript. D1 is the explicit exception and refuses one by name. An empty path array is always refused — a filter that quietly matches everything is a data-exposure defect, not a no-op.

AdapterTranslation
Memorywalks the document
Mongothe native dotted key (address.city)
Prismathe JSON path filter — an array on PostgreSQL/CockroachDB, $.a.b on MySQL
Drizzleper dialect: PostgreSQL #>>, MySQL JSON_UNQUOTE(JSON_EXTRACT(…)), SQLite json_extract
D1refused by name (SQLite’s LIKE has no expressible escape character)

Two translation facts are worth knowing before relying on a path filter, both measured rather than inferred:

  • in is expanded, not translated. Neither Prisma’s JSON path filter nor any SQL dialect offers a path membership operator, so in becomes an OR of equality legs. An empty list compiles to a match-nothing predicate that binds no values.
  • Extraction normalises to text, and an ordered comparison against a number casts back. PostgreSQL’s #>> and MySQL’s JSON_UNQUOTE are text-valued while SQLite’s json_extract preserves the JSON type, so without normalising, one filter would mean two different things. Casting back for gt/gte/lt/lte on a number is what makes age > 9 match 30 — as text, '30' > '9' is false.

A Date is refused on a path filter on every SQL adapter: a JSON document has no date type, so the stored representation is whatever the writer chose. Compare an ISO string, or give the value its own column.

Prisma requires provider and Drizzle may require dialect for a path filter, because the syntax differs per connector and neither library publishes a discriminant the adapter can read reliably. Drizzle detects its dialect from the instance and needs the option only when detection fails; either way, an adapter that cannot determine the syntax refuses by name rather than emitting a guess — a guessed syntax returns wrong rows on the engine it guessed wrong about, where a refusal names the option that fixes it.

DatabasePlugin({
  type: 'drizzle',
  options: { drizzleInstance, drizzleTables, dialect: 'postgresql' },
});

Cursor pagination

let cursor: string | null = null;
do {
  const page = await repo.findPage({ orderBy: { createdAt: 'desc' }, limit: 50, cursor });
  handle(page.rows);
  cursor = page.nextCursor;
} while (cursor !== null);

offset is unchanged and not deprecated; the cursor is additive beside it. A query carrying both a non-zero offset and a cursor is refused, because the two express contradictory positions — “skip this many from the start” and “after this row”.

nextCursor carries one guarantee: it is non-null exactly when a further page exists — including a zero-row page that is not the last — and it is never derived from rows.length. Backends mint it by one of two mechanisms. The row-based adapters fetch limit + 1 rows and answer null precisely when the extra row is absent. The DynamoDB adapter instead carries the server’s own continuation key (LastEvaluatedKey) inside the token and answers null exactly when the server returned none: DynamoDB applies Limit before FilterExpression, so a filtered page can return fewer rows than limit — or none — while further matching rows remain, and a row-count probe would report the walk terminal and silently drop rows.

The cursor is opaque and carries a fingerprint of the sort it was minted under, so presenting it under a different orderBy is refused rather than served a silently wrong page. The resolved sort always ends in the entity’s key columns: over rows sharing a sort value, a walk without that tiebreaker silently loses rows — measured at four of six against live PostgreSQL and MongoDB.

ORDER BY on a Date column requires the Date arm of the ordered comparison, which is why that widening ships alongside. Cloudflare D1 refuses a Date by name: SQLite has no date type, so the adapter cannot know whether the column stores an ISO string or an epoch integer.

MongoDB backend ('mongodb' arm)

MongoAdapter is a document-store backend over the native mongodb driver (mongodb), registered through the same discriminated union as every other backend:

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'mongodb',
    options: {
      url: 'mongodb://127.0.0.1:27017/app',
      // or inject a constructed client so the driver is never imported:
      // client: new MongoClient(url),
    },
  }),
);

MongoAdapterOptions is the Mongo-specific option bag, and it is a union of two arms: one requires url (the connection string), the other requires client (an already-constructed IMongoClient). Supplying neither is a compile error rather than a connect() throw — the guarantee every other built-in arm gives. MongoAdapterOptionsBase is the half both arms share (objectIdCtor, database, collections). When client is present the lazy import('mongodb') never runs; the injected client is structural, so a driver of a different major that honours the same shapes is accepted. An injected client that uses ObjectId values supplies its objectIdCtor companion, so the adapter can convert 24-hex repository ids without importing the driver. database names the database; when absent the one encoded in url is used, and if neither yields a name connect() fails at startup naming the option. collections is a per-entity override ({ collection?, primaryKey?, idType? }); an unmapped entity uses its own name as the collection and 'id' as the primary key — the D1-shaped two-layer mapping, where the public surface is a per-entity override with a zero-config default and the internal target is unexported.

Identity maps _id to the configured primary key on read and back on write. Because a findOne({_id: "<24-hex>"}) misses when _id is an ObjectId, the driver id is converted with a 24-hex string test for an idType of 'auto' (the default), forced for 'objectId', and forbidden for 'raw'. The string half of that test is load-bearing rather than defensive: the driver’s own ObjectId.isValid answers true for any number while its constructor rejects one, so a collection keyed by application-assigned numbers is passed through verbatim — findById, update and delete accept string | number, and a numeric key must reach the driver unconverted. On read the inverse holds: an ObjectId is rendered as its 24-hex string, while a JSON scalar keeps its own type, so the value create() returns is the value findById() accepts. primaryKey may name _id itself, in which case the row keeps that field rather than having it renamed away. The primary key never travels in an update payload — MongoDB refuses a $set that would change _id, and update moves no row to a new key on any adapter.

find serves orderBy/offset/limit/select natively as sort/skip/limit/projection, and contains compiles to a $regex match with an escaped value (the inverse of the SQL contains), so % and _ in the searched value stay literal. An empty filter group compiles to its boolean identity — an empty and matches every document, an empty or matches none — because MongoDB refuses $and: []/$or: [] outright, and those are the answers Memory and Drizzle give.

rawQuery is refused by name with UnsupportedRawQueryError — MongoDB has no SQL, so an application reaches the injected client directly for native commands, exactly as it does for a Prisma raw query. Transactions use a startSession(); on a deployment that is not a replica set they are refused late, never at connect(). On the real driver the refusal surfaces at the first operation inside the transaction as the driver’s own unwrapped MongoServerError (code: 20, codeName: 'IllegalOperation'), not at beginTransaction() (measured, M107); MongoTransactionUnavailableError wraps only a startTransaction() that itself throws.

IMongoClient and IMongoObjectIdCtor are the exported injection seam, and the real driver implements their structural shapes: IMongoClient.connect() is Promise<unknown> because the driver’s own connect(): Promise<this> is not assignable to Promise<void>, and the adapter binds nothing; the same reconciliation admits the driver’s startTransaction(options?): void (the session member is (options?: unknown) => unknown) and its string-and-array Sort union (sort? is unknown on find/findOne, where the adapter builds a Record). The claim is pinned by a compile-time fixture — packages/database-plugin/test/types/mongo-seam.assert.ts assigns a real new MongoClient(...) to IMongoClient with NO cast under a static import of the driver, and fails pnpm run check the moment the seam drifts again. The types the client’s members reference — IMongoDatabase, IMongoSession, and the IMongoObjectId instance shape — are exported alongside them so the seam’s return types are nameable from the package entry, as are the collection-level shapes IMongoDatabase.collection() reaches (IMongoCollection, IMongoCursor, IMongoCollectionFindOneAndUpdateOptions, and the MongoOptions/MongoWriteOptions option bags).

DynamoDB backend ('dynamodb' arm)

DynamoAdapter is a key-value backend over the native AWS SDK v3 client (@aws-sdk/client-dynamodb), registered through the same discriminated union as every other backend:

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'dynamodb',
    options: {
      region: 'us-east-1',
      // `endpoint` addresses the local emulator in development and tests; absent
      // in production, where the SDK's own endpoint resolution applies.
      entities: { Order: { partitionKey: 'tenantId', sortKey: 'orderId' } },
    },
  }),
);

DynamoAdapterOptions is the DynamoDB-specific option bag, and it is a union of two arms: one requires region (with an optional endpoint — the emulator address — and credentials), the other requires client, an already-constructed client behind the structural IDynamoClient facade. Supplying neither is a compile error rather than a connect() throw — the guarantee every other built-in arm gives. DynamoAdapterOptionsBase is the half both arms share (entities, maxPageFetches). When client is present the lazy import('@aws-sdk/client-dynamodb') never runs. The adapter owns marshalling and deliberately does not use lib-dynamodb’s DocumentClient: automatic marshalling would hide two facts the adapter must decide — DynamoDB has no date type, so the encoding of a Date comparison is the mapping’s decision, and a DynamoDB N is an arbitrary-precision decimal that Number() degrades.

entities is the per-entity mapping ({ table?, partitionKey, sortKey?, indexes?, dateAttributes? }); an unmapped entity uses its own name as the table and 'id' as the partition key. A scalar key is accepted only for a partition-only entity — against a sort-keyed entity it is refused by name, because a GetItem carrying only the partition key is a server-side ValidationException. A composite key record is projected down to exactly the resolved key columns (an extra attribute is the same server-side refusal), and a record missing a column, or carrying an empty-string value, is refused by name. The key map itself is order-insensitive — the opposite of a Mongo compound _id subdocument — so no canonical ordering is applied.

Both single-row writes are conditional, because the unguarded forms violate the IDataSource contracts silently — both measured against the real emulator. create carries attribute_not_exists(<partitionKey>): an unguarded PutItem on an existing key silently overwrites the item and drops every attribute absent from the new one, answering 200. update carries attribute_exists(<partitionKey>) with ReturnValues: 'ALL_NEW': an unguarded UpdateItem on a missing key upserts a ghost item and returns it as though it were an update, so the conditional failure (ConditionalCheckFailedException) is what produces the documented rejection. delete reads ReturnValues: 'ALL_OLD' for its boolean — no read-before-delete round trip, and no constant true.

The access path is resolved from the caller’s query: an equality on the entity’s partition key (or on a configured index’s partition key, selecting that GSI) is a Query; everything else is a Scan, because a Query without a partition-key equality is a server-side ValidationException — the selection is forced, not an optimisation. Every predicate not folded into the key condition becomes a FilterExpression, and every attribute name is aliased through a generated placeholder, so a reserved word like status cannot reach the server raw. A non-empty select is pushed down as a ProjectionExpression, so the server returns only the projected attributes rather than whole items; repeated and empty field names are dropped first, because DynamoDB refuses a projection naming one path twice (Two document paths overlap with each other, even under distinct aliases) and refuses an empty attribute name outright. Push-down is safe for cursor paging: LastEvaluatedKey is computed independently of the projection, so a projection omitting the key columns still resumes correctly. orderBy is served only when it names exactly the resolved access path’s sort key (as ScanIndexForward); a non-key or multi-field orderBy is refused by name with UnsupportedQueryFeatureError, and a non-zero offset is refused the same way — both because the SDK accepts and silently discards the unrecognised parameter, answering 200 with unordered or unskipped rows, so forwarding one returns confidently wrong rows with no diagnostic anywhere in the stack.

Pagination is the server’s own continuation token carried inside the portable cursor codec, and the invariant is exact: nextCursor is non-null if and only if the response carried a LastEvaluatedKey — never derived from rows.length, because Limit is applied before FilterExpression and a filtered page can return fewer rows than the limit, or zero, and still not be the last. A bounded fill loop (maxPageFetches, default 10) keeps fetching while a page is short and a token remains; at the bound it returns what it has with a non-null cursor, never a terminal page. maxPageFetches must be a positive integer and is validated at construction: the loop runs while fetches < maxPageFetches, so Infinity would silently remove the very bound the option imposes (a findPage whose filter matches nothing would scan the whole partition to exhaustion) and NaN would make the comparison false on the first pass, collapsing the loop to a single server page — neither raises anything on its own, so both are refused by name up front. count() loops to exhaustion for the same reason — a paged COUNT response under-reports past the first page. A Date comparison must be declared per attribute (dateAttributes: { createdAt: 'iso' | 'epochMs' }) or it is refused by name naming the option, and an out-of-range decimal N is preserved on read as its string rather than degraded through Number(). Transactions buffer writes and flush them as one TransactWriteItems at commit — refused by name past 100 items, or on two operations for one item key — and reads inside a transaction see committed state (no read-your-own-writes), documented rather than emulated.

IDynamoClient and DynamoSdkModule are the exported injection seam, with createInjectedDynamoLoader and createLazyDynamoLoader as its two arms. The command and attribute shapes the client’s members reference — DynamoAttributeValue, DynamoEntityMapping, DynamoIndexMapping, DynamoDateEncoding, and the Dynamo*CommandInput/Output/Transact* closure — are exported alongside them so the seam’s return types stay nameable from the package entry. The internal target resolution, expression builder, marshaller and access-path resolver are not exported.

Cosmos DB backend ('cosmos' arm)

CosmosAdapter serves Azure Cosmos DB’s NoSQL (SQL) API over @azure/cosmos, registered through the same discriminated union as every other backend:

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'cosmos',
    options: {
      endpoint: 'https://my-account.documents.azure.com:443/',
      key: cosmosKey,
      database: 'app',
      containers: { Order: { container: 'orders', partitionKey: 'tenantId' } },
    },
  }),
);

Cosmos DB’s MongoDB API is a different wire protocol; it is served by the 'mongodb' arm pointed at a Cosmos connection string. That route is documented and not verified against a live account: the emulator’s MongoDB endpoint tops out at API version 4.0 (wire version 7) while the mongodb driver this package pins requires wire version 8, so it cannot be exercised there.

CosmosAdapterOptions is a union of two arms: one requires endpoint + key, the other requires client (an already-constructed structural ICosmosClient, which is how an Entra ID or connection-string client reaches the adapter). database is required on BOTH arms, because a Cosmos endpoint encodes no database name and there is nothing to fall back to. CosmosAdapterOptionsBase is the half both arms share (database, containers, logQueries). containers is a per-entity override ({ container?, primaryKey?, partitionKey? }); an unmapped entity uses its own name as the container and 'id' as the primary key — the D1-shaped two-layer mapping.

The adapter provisions nothing. Cosmos creates no database or container implicitly, and throughput, partition-key and indexing choices belong to the application; a missing container is refused by name at first use.

The partition key is discovered from the container definition and read once per container. A declared partitionKey is validated against it and refused by name on a mismatch — a point read carrying the wrong partition key answers 404 rather than an error, so an unvalidated mapping would report “not found” for every row of a healthy container.

Identity. A document is addressed by (partition key, id). findById point-reads when the key carries the partition key, or when the container partitions by the primary-key field; otherwise it resolves the row by a cross-partition query, which costs more request units and is refused by name when the id matches two documents, since an id is unique only within a partition. A primary key must be a string — the service refuses any other type, and converting silently would change the type a caller gets back from create().

update merges through a server-side patch while the payload fits one request and through a read-merge-replace guarded by _etag beyond it, raising CosmosConcurrentModificationError on a lost race. A payload that would change a partition-key value is refused by name, because such a replace answers 404 rather than moving the item.

contains compiles to CONTAINS, a literal substring match (no %/_ escaping, case-sensitive); in compiles to ARRAY_CONTAINS with the list bound as one array parameter, so an empty list matches nothing natively. offset/limit are served natively, with the contract’s -1 sentinel translated rather than passed through (Cosmos refuses LIMIT -1, and refuses OFFSET without LIMIT).

Pagination uses the portable keyset cursor, not a Cosmos continuation token — this adapter’s design choice, matching what the tested backend supports: measured against the emulator, an ORDER BY query returns no continuation token even with maxItemCount passed as a query option. The claim is scoped to that measurement rather than asserted of every Cosmos deployment. On a real account, keyset paging therefore needs a composite index over (sort field, id) for each paged container, because the walk always adds the key column as its tiebreaker.

rawQuery is refused by name with UnsupportedRawQueryError: a Cosmos SQL query is scoped to one container and the signature names none, so an application reaches the injected client directly.

Transactions are a deferred batch. beginTransaction() buffers every write and flushes it as one transactional batch at commit; rollback() discards it and sends nothing. The batch is atomic within one container and one partition-key value and caps at 100 operations, and a write crossing any of those bounds raises CosmosTransactionScopeError at that write. Reads inside a transaction observe committed state only — the deferred-write clause IDataSource documents. A buffered update is sent as a patch, so it writes only the fields its payload names and two updates of one row compose; an update too wide for a single patch request falls back to a whole-document replace, which cannot compose, so buffering one for a row the transaction has already written is refused rather than silently discarding the earlier write. rollback() is idempotent (commit() is not), because the framework rolls back inside the same catch that sees a failed commit — refusing there would replace the batch’s own per-operation diagnostic with a complaint about rollback.

What the arm deliberately cannot do, split by who could close it. Two are platform limits: Cosmos rejects a cross-container join with a 400 (a query addresses one container; its own JOIN unwinds an array inside a single item), and returns no continuation token for an ORDER BY query on the tested emulator. Three are contract limits: grouping is absent from NormalizedQuery, which carries no aggregate beyond count — the Cosmos dialect does support GROUP BY, so closing that is a common widening every adapter must answer; rawQuery is refused because the committed signature names no container; and request units, consistency levels, TTL and index policy are outside the portable contract by design (the M79 exclusion). An application needing any of them reaches the injected client, as it does for a Prisma raw query.

ICosmosClient and the shapes it reaches (ICosmosDatabase, ICosmosContainer, ICosmosItems, ICosmosItem, ICosmosQueryIterator, CosmosQuerySpec, CosmosQueryParameter, CosmosPartitionKeyValue) are the exported injection seam, together with the response and operation shapes those members return and accept (CosmosItemResponse, CosmosFeedResponse, CosmosRequestOptions, CosmosAccessCondition, CosmosPatchOperation, CosmosBatchOperation with its four arms CosmosBatchInsertOperation, CosmosBatchReplaceOperation, CosmosBatchPatchOperation and CosmosBatchDeleteOperation, CosmosBatchResponse, CosmosContainerDefinition) — so the seam’s own signatures are nameable from the package entry. The real SDK implements their structural shapes.

Cloud Bigtable backend ('bigtable' arm)

BigtableAdapter serves Google Cloud Bigtable over @google-cloud/bigtable, registered through the same discriminated union as every other backend:

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'bigtable',
    options: {
      projectId: 'my-project',
      instance: 'app-instance',
      tables: {
        Order: {
          table: 'orders',
          rowKey: { fields: ['tenantId', 'orderId'], separator: '#' },
          columnFamily: 'o',
          columns: { total: 'metrics:amount' },
        },
      },
    },
  }),
);

Bigtable inverts the DynamoDB problem. Its row key is a single lexicographically-sorted string, so findById fits it natively with no key object at all. What it lacks instead is everything around the key: there is no secondary index of any kind, so a predicate on a non-key column is a scan and orderBy is row-key order or nothing.

BigtableAdapterOptions is a union of two arms: one requires projectId (with an optional apiEndpoint for an emulator), the other requires client (an already-constructed structural IBigtableClient, which is how a client built with non-default credentials reaches the adapter). instance is required on BOTH arms, because a table is addressed as project/instance/table and neither a client nor a project encodes it. BigtableAdapterOptionsBase is the half both arms share (instance, tables, maxPageFetches, logQueries). tables maps each entity name to a BigtableEntityMapping — { table?, rowKey?, columnFamily?, columns?, valueEncoding? }, whose rowKey is a BigtableRowKeyMapping and whose valueEncoding is a BigtableValueEncoding; an unmapped entity uses its own name as the table, ['id'] as the row-key fields, 'cf' as the column family and tagged values. BigtableAdapter’s constructor also takes an optional second parameter, a BigtableClientLoader, which overrides the inject-or-lazy choice and whose owned flag decides whether disconnect() closes the client it produced.

The adapter provisions nothing and connect() issues no RPC. Column families, garbage-collection policies and split points belong to the application’s provisioning, and a missing table or instance is already reported by the service as 5 NOT_FOUND quoting the full resource path — while instance.getTables() is a table-ADMIN call a data-plane service account commonly cannot make, so probing at connect time would refuse a working configuration. Configuration mistakes are refused at construction instead, by name.

The row key is composed from logical fields. BigtableRowKeyMapping is { fields, separator?, prefix? }, defaulting to { fields: ['id'], separator: '#' }. A single-field key accepts a scalar EntityKey; a multi-field key requires a record naming every field and refuses a scalar, which cannot say which field it is. A key field’s type is not part of the row key — a numeric field renders as its decimal text, so 1 and '1' are one physical row and findById('1') answers the row stored under 1, whose id cell still decodes as the number. Choose one type per key field, and zero-pad a numeric one whose lexicographic order matters. A field value containing the separator is refused: two different logical keys would otherwise compose to one row key, so a write would silently overwrite an unrelated row. Composing a row key from several fields is a mapping concern rather than the composite-key contract concern, which is why this arm needs no common change — Bigtable’s key shares no type with DynamoDB’s partition/sort pair.

Key fields are written as ordinary cells AND recovered from the row key on read, with the cells winning. All three parts are load-bearing: a Bigtable row cannot exist with zero cells; the row key is bytes and records no type, so overlaying it would turn a numeric key field into a string; and a table written outside this framework has no key cells at all, which is what the parse-back serves.

Values are tagged by default. BigtableValueEncoding is 'tagged' | 'raw'. Tagged writes <tag>:<payload>, so a number, boolean, null, Date or object round-trips as itself; a cell carrying no recognised tag decodes as its raw string, which is the interop path. 'raw' writes String(value) and reads every cell as a string, removing that residual ambiguity for an application whose table is entirely foreign.

orderBy is the row key or nothing. An empty sort is honoured, and so is one naming exactly the mapped key fields, in order, all ascending — that IS the scan order. Everything else raises UnsupportedQueryFeatureError: a non-key field has no index, and descending is refused deliberately rather than shipped on reversed: true, because the emulator this adapter is tested against silently ignores that option (measured: it answered ascending with no error), so the path could not be verified. A non-zero offset is refused too — Bigtable has no row offset, and discarding scanned rows would read and bill them; findPage is the route.

Three narrowings reach the server, and nothing else. The row set (an eq on every key field is an exact key; an eq pinning a leading prefix is a prefix range with an exclusive successor end; a pinned prefix plus an in on the final field is an explicit key list), byte-exact value equality for each conjunctive non-key eq, and the column projection. Everything else is evaluated by the same evaluator the memory adapter uses as the portable reference, so the six backends cannot drift about what a FilterExpression means. The invariant is that a push-down may only ever match a superset of what the client-side evaluator keeps; every fallback widens. Two details are correctness requirements rather than tuning: a value test is an exact byte RANGE and never the SDK’s string form, which is a regex (measured, { value: 'a.*b' } matched both a.*b and axxb); and it is wrapped in a condition filter rather than chained, because a bare chain strips every non-matching cell and the row would come back carrying only the cell that matched. An ordered comparison on a key field is not pushed down either — the composed key is a string, so a numeric key field does not sort numerically inside it. The projection is interleaved with a one-cell arm rather than emitted bare: a filter that removes every cell of a row removes the row (the service answers with no empty row), so a bare projection would silently drop a row carrying none of the projected columns — unreachable for a row this adapter wrote, reachable for a table written elsewhere. count reuses that projection, restricted to the columns its predicate needs.

Pagination is a start-key cursor over the portable keyset codec, which is exactly Bigtable’s own continuation mechanism. nextCursor is non-null if and only if the page is non-terminal, never derived from rows.length: a client-side filter can empty a whole raw batch, so a page bounded by maxPageFetches (default 10) returns zero rows AND a cursor, minted from the last row scanned.

rawQuery is refused by name with UnsupportedRawQueryError: Bigtable has no query language behind query(sql, params) — its data plane is ReadRows, MutateRow and CheckAndMutateRow.

Transactions are one row. Bigtable’s only atomicity unit is the single row (a multi-row batch is atomic per entry, not as a whole), so beginTransaction() buffers writes, refuses a second row key at the write that crosses the bound with BigtableTransactionScopeError, and commits the buffer as ONE CheckAndMutateRow whose mutation list applies atomically and in order — a buffered delete followed by writes replaces the row wholesale. rollback() discards, sends nothing, and is idempotent. Reads inside a transaction observe committed state only. create and update are conditional writes: Bigtable’s insert is an upsert, so the CheckAndMutateRow match flag is what makes create refuse an existing row and update refuse an absent one — including inside a transaction, where a buffered create whose row turns out to exist is refused at commit.

What the arm deliberately cannot do. Platform limits: no secondary index of any kind, no row offset, no multi-row atomicity, and no descending scan this adapter can verify. Contract limits: grouping and joins are absent from NormalizedQuery, whose only aggregate is count; rawQuery has no Bigtable surface; and cell versioning plus garbage-collection policies are outside the portable contract by design — no other backend has a counterpart, so exposing them would invent a concept for one adapter. An application needing any of them reaches the injected client.

IBigtableClient and the shapes it reaches (IBigtableInstance, IBigtableTable, IBigtableRow, BigtableReadOptions, BigtableReadRow, BigtableRowData, BigtableCell, BigtableRowRange, BigtableRowBoundary, BigtableValueRange, BigtableFilter, BigtableMutation) are the exported injection seam, so an application implementing its own facade can name every signature. createInjectedBigtableLoader and createLazyBigtableLoader build the two loader arms; BigtableClientConfiguration is what the lazy one takes.

Exports

ExportKind
DatabasePluginfactory
DatabaseServiceclass
BaseRepository, UnitOfWorkclasses
MemoryAdapter, PrismaAdapter, DrizzleAdapter, MongoAdapter, DynamoAdapterclasses
PrismaRepository, DrizzleRepositoryclasses
UnsupportedFilterOperatorError, UnsupportedIsolationLevelError, UnsupportedMigrationError, UnsupportedRawQueryError, UnsupportedQueryFeatureErrorclasses
SerializationConflictError, DatabaseUnavailableError, DatabaseTimeoutError, UnknownPayloadFieldError, MissingKeyColumnError, TenantColumnUnresolvedErrorclasses
PageOptions, Pagetypes
PrismaCompositeKeyOptions, DrizzleCompositeKeyOptionstypes
EntityKey, PageResult, CursorPayload, CursorValue (re-exported from common)types
encodeCursor, decodeCursor, keysetPredicate (re-exported from common)functions
MongoTransactionUnavailableErrorclass
CosmosAdapterclass
CosmosTransactionScopeError, CosmosConcurrentModificationErrorclasses
CosmosDatabaseOptions, CosmosAdapterOptions, CosmosAdapterOptionsBase, CosmosEntityMappingtypes
ICosmosClient, ICosmosDatabase, ICosmosContainer, ICosmosItems, ICosmosItem, ICosmosQueryIteratorinterfaces (Cosmos injection seam)
CosmosQuerySpec, CosmosQueryParameter, CosmosPartitionKeyValuetypes (Cosmos query seam)
CosmosItemResponse, CosmosFeedResponse, CosmosRequestOptions, CosmosAccessCondition, CosmosPatchOperation, CosmosBatchOperation, CosmosBatchInsertOperation, CosmosBatchReplaceOperation, CosmosBatchPatchOperation, CosmosBatchDeleteOperation, CosmosBatchResponse, CosmosContainerDefinitioninterfaces (Cosmos operation seam)
PrismaSqlProvidertype
SqlJsonDialecttype
createPrismaDataSource, createDrizzleDataSource, createDrizzleDatabase, getDrizzleDatabase, getDrizzleTransaction, withIsolationSupportfunctions
DrizzleDatabase, DrizzleDatabaseIdentity, DrizzleTransaction, DrizzleTransactionBridgetypes
IDatabaseService, IRepository, IUnitOfWorkinterfaces
DatabasePluginOptions, BuiltInDatabaseOptions, CustomDatabaseOptions, DatabaseConnectionOptionstypes
MemoryDatabaseOptions, PrismaDatabaseOptions, DrizzleDatabaseOptions, MongoDatabaseOptions, DynamoDatabaseOptionsinterfaces
MongoAdapterOptions (union of two arms), MongoAdapterOptionsBase, MongoEntityMappingtypes
PrismaAdapterOptions, DrizzleAdapterOptionsinterfaces
DatabaseAdapterType, DatabaseAdapterOptionstypes
FindOptions, CountOptions, OrderDirection, FilterOperator, FilterComparison, FilterExpressiontypes
IDatabaseAdapter, IAdapterTransaction, IDataSource, NormalizedQuery, TransactionIsolationLevel, TransactionOptionsre-exports from common
DataSourcedeprecated alias of IDataSource
IMongoClient, IMongoDatabase, IMongoObjectId, IMongoObjectIdCtor, IMongoSessioninterfaces (Mongo injection seam)
IMongoCollection, IMongoCursor, IMongoCollectionFindOneAndUpdateOptions, MongoOptions, MongoWriteOptionsinterfaces (Mongo collection seam)
DynamoAdapterOptions (union of two arms), DynamoAdapterOptionsBase, DynamoEntityMapping, DynamoDateEncoding, DynamoIndexMappingtypes
IDynamoClient, DynamoAttributeValue, DynamoAttributeMap, DynamoExpressionAttributes, DynamoConditionExpression, and the Dynamo*CommandInput/Output/Transact* command-shape closureinterfaces (DynamoDB client seam)
DynamoClientConfiguration, DynamoClientLoader, DynamoCommandConstructor, DynamoSdkClient, DynamoSdkCommand, DynamoSdkModuletypes (DynamoDB SDK seam)
createInjectedDynamoLoader, createLazyDynamoLoaderfunctions

DataSource is retained under AI_GUIDELINES §9.2 — it is already published. It is now an alias of the promoted IDataSource (the same type), and will be removed in the next major version.

DatabaseAdapterType gained 'custom', then 'mongodb', then 'dynamodb', then 'cosmos', then 'bigtable'; DatabasePluginOptions became a union discriminated on type. All are additive for callers — every existing registration compiles unchanged. MongoDatabaseOptions, DynamoDatabaseOptions and CosmosDatabaseOptions each extend DatabaseConnectionOptions and carry their arm’s dedicated option bag (MongoAdapterOptions, DynamoAdapterOptions, CosmosAdapterOptions), as does BigtableDatabaseOptions with BigtableAdapterOptions, so a registration carrying a memory, Prisma, Drizzle or custom configuration still compiles unchanged.

Query refusals answer 501 Not Implemented (M89b, X19-1). The three query-shape errors — UnsupportedQueryFeatureError, UnsupportedFilterOperatorError and UnsupportedRawQueryError — carry an HttpStatusHint from @setu-ts/common, so @setu-ts/exceptions’ errorHandler answers them 501 with a caller-safe sentence naming the feature or operator and the adapter that refused it. Each is a condition the CALLER caused — the query asks for something the active backend does not implement, permanently — which is exactly what 501 states, and it is the shape a developer meets when SWITCHING BACKENDS: before this, an application that worked on Mongo answered 500 Internal Server Error on every ordered endpoint under DynamoDB, and the response said the server was broken.

The served detail is composed from this package’s own structured fields (feature, operator, connector, adapter), never from the error’s message — which remains the full diagnostic, is reachable only in the log, and is what errorHandler records. So the response gains a status and a sentence without gaining a disclosure channel; a hinted error is exempt from maskInternalErrors precisely because there is no driver output in its body to mask.

Driver conditions answer their classified status (M90f, X38-1/X35-2). DatabaseService classifies a driver rejection at its four interception sites (the repository wrappers, the beginTransaction() acquisition, the transaction catch, and query()) and throws SerializationConflictError — answered 409 Conflict, the canonical retry-me signal, for a backend-reported write conflict (PostgreSQL SQLSTATE class 40 including 40001, Prisma’s P2034 mapping of it, MongoDB’s TransientTransactionError label, DynamoDB’s TransactionConflictException name, Bigtable’s gRPC ABORTED, Cosmos 449) — or DatabaseUnavailableError — answered 503 Service Unavailable — for a transiently unreachable backend (SQLSTATE class 08, 57P03, the node-postgres pool timeout, MongoNetworkError/MongoServerSelectionError, gRPC UNAVAILABLE, Cosmos 429/503, and the net errno ECONNREFUSED family the AWS SDK surfaces as a bare error). Both carry the original driver error as cause — the SQLSTATE and the failing statement stay reachable for the log — and serve a fixed sentence, so the masking exemption stays narrow. Neither carries Retry-After: ErrorResponseInit has no header channel, and the framework holds no honest horizon for a pool’s saturation — 503 is itself the retryable signal (RFC 9110). The rate limiter’s Retry-After is the deliberate asymmetry (a fixed window genuinely knows when it resets). An unrecognised error keeps the masked 500, and an already-classified error is passed through untouched rather than wrapped twice. MongoDB’s conflict mapping is verified against a real replica set and PostgreSQL’s against a real server (SQLSTATE 40001 through Drizzle and Prisma’s P2034 alike); DynamoDB’s TransactionConflictException and Cosmos’s 449 are documented-unverified — neither was reproducible against DynamoDB Local or the local emulator, and Cosmos’s connect refusal is a startup failure that never reaches a client.

A timed-out query answers 504 Gateway Timeout (M113g, V9-33). DatabaseService classifies node-postgres’s query_timeout firing — the driver’s Query read timeout rejection — as a new timeout kind and throws DatabaseTimeoutError, branded 504 Gateway Timeout with the fixed detail “The database did not answer in time.”; the scrubbed driver error stays as cause for the log, and errorHandler reads the hint. It is not DatabaseUnavailableError: that class’s contract says the operation did not happen, and a timed-out statement may have run, so a write’s outcome is unknown and a retry of a write must be idempotent. The bound is the driver’s own — node-postgres’s query_timeout and connectionTimeoutMillis on the Pool the application hands Drizzle; the plugin adds no call deadline of its own, for the reasons the README’s Bounding a call section gives. The behaviour of a timed-out statement inside a transaction is measured in this milestone and documented there. Ships in 0.10.0.

A duplicate key answers 409 Conflict. A write that would duplicate a primary key or a unique index is thrown as DuplicateKeyError (exported from @setu-ts/common, so a package that may not import this one can raise or recognise it), where every backend used to reach the client as a masked 500. The signals, each measured against the real engine: PostgreSQL SQLSTATE 23505, Prisma’s P2002, MySQL’s ER_DUP_ENTRY (by probe; CI runs no MySQL), MongoDB code 11000, Cosmos 409, and SQLite’s UNIQUE constraint failed, which is the only signal Cloudflare D1 carries. The memory, DynamoDB and Bigtable adapters raise the class themselves. Numeric codes are read only under their own adapter, as for the conflict class. It is not retryable: the served detail says the conflicting write was rejected, never that it is safe to retry. The class carries the targeted entity when the failing repository call names one. At commit() a duplicate is the one class mapped, because the backends that detect it there (D1’s atomic batch, a Cosmos transactional batch, a DynamoDB TransactWriteItems whose cancellation reason sits on a create’s Put, a buffered memory or Bigtable create) apply nothing when they refuse; any other commit rejection is still rethrown unchanged.

Programmatic migrations answer 501 Not Implemented too. UnsupportedMigrationError names a distinct framework-capability refusal rather than pretending a migration is a raw query or a query shape. Every current adapter lacks programmatic migration support and delegates it to its own CLI, so the refusal is permanent and 501 is the honest response. Its hint carries a fixed sentence, never an operator-facing error message.

Not every UnsupportedQueryFeatureError is branded, and that is deliberate. The class is shared by two kinds of refusal: a caller-caused query shape, and a configuration refusal. Only the former is answered 501; the latter keeps the masked 500 that is correct for an internal fault. Branding the constructor unconditionally made a blank columnFamily — a value the developer wrote — answer every request 501 "Query feature 'mapping' is not supported by the 'bigtable' database adapter.", which is a lie twice over. The split is an allowlist of feature values (attribute-value, composite-key, cursor-pagination, key, nested-path, offset, order-by/orderBy, row-key, update); mapping, endpoint, date-encoding and transaction are excluded, and an unclassified value is not branded — so a feature name added later keeps its masked 500 until someone decides otherwise.

The four transaction and concurrency errors — MongoTransactionUnavailableError, CosmosTransactionScopeError, CosmosConcurrentModificationError and BigtableTransactionScopeError — are deliberately not branded and keep their masked 500: they may legitimately quote backend state, and a concurrency conflict is transient and retryable rather than permanent, which is a different contract statement deserving its own decision.

Transactional outbox store (M107)

createDatabaseOutboxStore(options?) returns a RegistryFactory<IOutboxStore> — the shipped implementation of the IOutboxStore port in @setu-ts/common over IDatabaseService (the M101c createDatabaseTenantDataStore shape). It is passed to the messaging plugin’s outbox.store option, which resolves it in onInit and runs verify() there.

import { createDatabaseOutboxStore } from '@setu-ts/database-plugin';

const store = createDatabaseOutboxStore({ entity: 'Outbox', database: 'orders' });
ExportKindNotes
createDatabaseOutboxStorefunctionResolves CAPABILITIES.DATABASE, or database.<name> for database: '<name>' ('default' is the bare token). Throws TypeError at the call for an empty entity
DatabaseOutboxStoreOptionsinterfaceentity? (default 'Outbox'), database? — must be the database the caller’s unit of work belongs to, or the row is never relayed by this store
OutboxStoreUnavailableErrorclassRejected by verify(); entity, reason ('bigtable', 'dynamodb-index', 'mongodb-replica-set', 'conditional-writes-unsupported', 'entity-unavailable'), the adapter error as cause
  • Discriminator. Every row carries kind: 'setu-outbox' (OUTBOX_RECORD_KIND); every read (scanPending, failedKeys, stats, purge) requires it, and a lookup by id before a transition treats a row of another kind as missing.
  • Conditional transitions. claim(id, { claimVersion, leaseUntil }) requires kind, pending and the read version, increments it by one and sets the lease. markSent/markFailure require the held version, including deleteNow through deleteWhere; a pending version miss is claim-lost. Status misses are not-pending or not-failed; another kind is missing. markInvalid(id, now) uses only kind/status, writes failed/invalid-row, clears the lease and preserves attempts. markFailure and release('retry') clear the lease; release preserves the version. Unexplained misses retry at most three rounds. Native conditional writes are required; no fallback remains. sentBy is only an operator diagnostic, absent from outcomes.
  • Columns. The row is the record’s own fields; an absent optional field is written as null and read back as absent. failedKeys selects only tenantId and orderingKey; purge deletes sent then discarded rows with settledAt < before, at most limit per status.
  • verify() runs scanPending(undefined, 1), then transaction(uow => uow.getRepository(entity).findAll({ where: { kind, status: 'pending' }, limit: 1 })), then updateWhere('setu-outbox-claim-probe', { kind, status: 'pending', claimVersion: -1 }, { claimVersion: 0 }) and deleteWhere with the same id and predicate — both, because retainSentMs: 0 deletes a sent row conditionally after it is published. No row can carry version -1 (append writes 0 and a claim only increments), so neither probe writes, even to a row appended under the probe id. The old SQL schema without claim_version is refused at the first step, the relay query. A conditional-write refusal is conditional-writes-unsupported. An UnsupportedQueryFeatureError from bigtable is 'bigtable'; one from dynamodb for orderBy is 'dynamodb-index' (a GSI { partitionKey: 'status', sortKey: 'position' }, projection ALL, is required); a cause chain carrying code: 20 with codeName: 'IllegalOperation' — what a standalone MongoDB answers at the first operation inside a transaction — is 'mongodb-replica-set'; anything else is 'entity-unavailable'. On memory and MongoDB a missing entity cannot be detected (both create lazily). Every refusal is a rejected promise.

Consumer inbox store (M108)

createDatabaseInboxStore(options?) returns a RegistryFactory<IInboxStore> — the shipped implementation of the IInboxStore port in @setu-ts/common over IDatabaseService. It is passed to the messaging plugin’s inbox.store option, which resolves it in onInit and runs verify() there.

import { createDatabaseInboxStore } from '@setu-ts/database-plugin';

const store = createDatabaseInboxStore({ entity: 'Inbox', database: 'orders' });
ExportKindNotes
createDatabaseInboxStorefunctionResolves CAPABILITIES.DATABASE (or database.<name>) and CAPABILITIES.RUNTIME. Throws TypeError at the call for an empty entity
DatabaseInboxStoreOptionsinterfaceentity? (default 'Inbox'), database? — must be the database the handlers write to
InboxStoreUnavailableErrorclassRejected by verify(); entity, reason ('cosmos-unsupported', 'bigtable-unsupported', 'mongodb-standalone', 'transaction-scope', 'entity-unavailable'), the adapter error as cause when there is one
  • run opens one transaction, creates the marker FIRST, then runs the work with the unit of work; any rejection rolls both back. A duplicate marker id rejects (the adapter’s DuplicateKeyError, or a write conflict on a MongoDB replica set).
  • Discriminator. Every row carries kind: 'setu-inbox' (INBOX_RECORD_KIND); every read (find, parked, stats, purge) requires it, and a row of another kind is missing.
  • Conditional writes. recordFailure compares kind and the read attempts value in the write, retrying misses at most five rounds; release requires kind and status: 'parked'. Without conditional support, or for a non-safe-integer count, the two-call fallback can lose an increment under concurrent failures.
  • verify() refuses Cosmos DB and Bigtable by adapter arm or class, runs purge(0, 1), then a transaction that creates two probe rows and always rolls back: a BigtableTransactionScopeError at the second row is 'transaction-scope', a cause chain carrying code: 20 with codeName: 'IllegalOperation' is 'mongodb-standalone', anything else 'entity-unavailable'. Nothing the probe writes survives.

Scoped RBAC sources (M110b)

createDatabaseGrantSource(options) and createDatabaseRoleSource(options) return RegistryFactory values for AuthPlugin’s scopedRbac option — the grant source for a { kind: 'custom', source } arm and the role source for customRoles — resolved in AuthPlugin’s onInit, so DatabasePlugin may be registered in either order. Both read the default CAPABILITIES.DATABASE service through the repository surface and portable FilterExpressions, so they run on every adapter; no SQL is written and no value is interpolated.

import { AuthPlugin } from '@setu-ts/auth-plugin';
import { createDatabaseGrantSource, createDatabaseRoleSource } from '@setu-ts/database-plugin';

const auth = AuthPlugin({
  rbac: { roles: { approver: { permissions: ['invoices:approve'] } } },
  scopedRbac: {
    sources: [{ kind: 'custom', source: createDatabaseGrantSource({ entity: 'role_grants' }) }],
    customRoles: createDatabaseRoleSource({ entity: 'tenant_roles' }),
  },
});
OptionGrant source (DatabaseGrantSourceOptions)Role source (DatabaseRoleSourceOptions)
entityRequired: one row per grantRequired: one row per (scope, role, permission)
fieldsGrantFields — subject / role / scopeType / scopeId (those defaults)RoleFields — scopeType / scopeId / role / permission (those defaults)
nameLog name (default 'database-grants')Log name (default 'database-roles')
limitMost matching rows, 1–10 001 (default 10 001); more refuses the questionMost matching rows, 1–100 000 (default 100 000); more refuses the question

A grant row whose scopeType and scopeId are both null is a GLOBAL grant; a row with only one null is passed through and dropped by AuthPlugin’s validator. A chain question is ONE query — the subject, and either a global row or a row in one of the asked scopes; an all question (sign-in timing) reads every row of the subject. Each question reads at most limit + 1 rows, and more than limit matching rows REJECT it — the check then denies — rather than returning an arbitrary subset, which would under-grant unpredictably; AuthPlugin’s own maxGrantsPerPrincipal bounds the union afterwards. The role source groups rows into one definition per (scope, role) and skips a row missing any column. Every field name must be an identifier ([A-Za-z_][A-Za-z0-9_]*), so it can never be read as an operator; an empty entity, a non-identifier field, or an out-of-range limit throws TypeError when the factory is called. A query does not start once the evaluator’s deadline has passed. Verified against real PostgreSQL (Drizzle) and MongoDB.

Multiple Databases

// Each named connection injects its own application-generated Prisma client;
// `options.url` is deprecated and unread — a v7 client carries its own
// connection configuration.
app.register(
  DatabasePlugin({
    type: 'prisma',
    name: 'primary',
    options: { prismaClient: primaryPrismaClient },
  }),
);

app.register(
  DatabasePlugin({
    type: 'prisma',
    name: 'analytics',
    options: { prismaClient: analyticsPrismaClient },
  }),
);

// Access by name
app.router.get('/analytics', async (ctx) => {
  const primaryDb = ctx.services.get<IDatabaseService>('database.primary');
  const analyticsDb = ctx.services.get<IDatabaseService>('database.analytics');
  // ...
});

AuthPlugin() (@setu-ts/auth-plugin)

RedisRateLimitStore.increment/reset wrap recognised connection faults in RateLimitStoreUnavailableError, preserving cause. With errorHandler, clients receive 503 and Rate limiting is temporarily unavailable. Other errors propagate unchanged. The constructor’s commandTimeoutMs defaults to 15000, accepts 0–2147483647, and throws RangeError for invalid values including NaN. Zero omits ioredis’s bound; an injected client keeps its own settings. Configure a larger bound when necessary.

Provides JWT and API-key authentication, local credential verification, RBAC authorization with role hierarchy, and short-circuiting route guards. All cryptography (HS256/RS256 JWT, PBKDF2-SHA256 password hashing) runs through Web Crypto via IRuntimeServices, so no npm package is involved in issuing or verifying a token, or in hashing a password. The package declares one optional driver — RedisRateLimitStore lazy-loads ioredis — which nothing imports unless that store is constructed.

Registers authentication under the existing capability token, JWT only when configured, and authorization when RBAC is configured:

  • IJwtService under CAPABILITIES.JWT ('jwt') — sign/verify/decode JWTs, only when jwt is supplied.
  • IAuthService under CAPABILITIES.AUTH ('authentication') — passive strategy chain + login.
  • IAuthorizationService under CAPABILITIES.AUTHORIZATION ('authorization') — RBAC checks, only when rbac is supplied.
  • IAuthorizationPolicyService under CAPABILITIES.AUTHORIZATION_POLICIES ('authorization-policies') — asynchronous, target-aware policy checks (M110a), ALWAYS registered, holding the policies option’s definitions and any class-form policies DecoratorPlugin registers.

JWT time boundaries (M113m). The first-party JwtService.verify, exposed as IJwtService, uses fractional seconds (runtime.now() / 1000) without clock tolerance. It rejects with Token expired once now >= exp, and with Token not yet valid while now < nbf: the not-before boundary is inclusive, and the expiry boundary is exclusive. Previously, a token could remain accepted for up to one second after exp; refresh before exp. The outside-issuer verifier (issuers) uses fractional seconds with clockToleranceSec: expiry is refused once now >= exp + clockToleranceSec (previously >), and not-before is refused while now + clockToleranceSec < nbf (unchanged).

Authorization policies (M110a). definePolicy({ name, abilities, before? }) validates a policy (lowercase kebab-case name, at least one ability, no ability named before) and returns a frozen copy typed by its ability names; register it through AuthPlugin({ policies }), which refuses a malformed policy or a duplicate name when AuthPlugin(...) is called. An ability is a check (principal, target) => boolean | Promise<boolean>, or { anonymous: true, check } to be called with null for an anonymous request. The rules are fixed: an unknown policy or ability REJECTS with UnknownPolicyError; an anonymous principal is denied (401) before a non-anonymous check runs; for a signed-in principal before runs first — true allows, undefined falls through, anything else (null included) denies; only a literal true allows; and a throw or rejection from before or the check denies and is logged once at error with the policy, ability, hook and serialized error — never the target or the principal. requirePolicy(policy, ability, target?) is the route guard: 501 with no policy service, 401 when an anonymous request is denied, 403 when a signed-in principal is denied, otherwise next(); target is a value or an extractor (ctx) => T | undefined | Promise<T | undefined> (a function is ALWAYS treated as an extractor), and an extractor’s throw propagates unchanged. An anonymous request to an ability that needs a principal is refused 401 BEFORE the extractor runs, so it costs no lookup and cannot learn from the extractor whether a record exists. Whether the ability needs a principal is read from the REGISTERED policy on each request, never from the guard’s own policy object, so a guard the startup scan never saw (a route added after start(), a global middleware) behaves the same; a guard naming an unregistered policy or ability rejects without running the extractor. An undefined principal passed to can/authorize is anonymous, exactly like null. A policy or ability name longer than 128 characters is truncated in an UnknownPolicyError’s message and fields, with the removed length noted. It is branded for OpenAPI deriveSecurity as authenticated unless the ability is anonymous; the brand is fixed from the guard’s own policy object when the guard is built, which the startup scan proves agrees with the registered policy only for routes present at start(). The service’s can resolves a boolean and authorize rejects a denial with AuthorizationDeniedError, which carries a 401/403 status hint whose title and detail are the guards’ own, so under errorHandler a thrown denial answers the guard’s exact body (without errorHandler the kernel answers 500). AuthPlugin’s onBootstrap hook scans every registered route and fails start() when a requirePolicy guard names a policy or ability that is not registered, or one whose registered ability disagrees with the guard’s policy object on anonymous (a different policy registered under the same name); it then SEALS the registry, so define after start() throws. Not scanned: a route added after start() and a guard added as global middleware — an unknown name there rejects per request (fail closed). A policy is identified by its NAME. See Authorization.

Scoped RBAC (M110b). scopedRbac (requires rbac) grants a catalogue role IN a scope — a ScopeRef { type, id } naming a tenant, organisation, team or region — rather than everywhere. AuthPlugin defines ONE built-in policy, SCOPED_RBAC_POLICY ('scoped-rbac', a reserved name an application policy may not take), on the M110a evaluator: every catalogue permission is an ability perm:<permission> and every catalogue role an ability role:<role>, and the target is a ScopedRbacTarget { scope, context? } — so no new capability token is added and the M110a rules (only true allows, 401 before the check for an anonymous request, a throw denies) apply unchanged. The wildcard * is never an ability. requireScopedRole(role | roles, { scope? }) is any-of and requireScopedPermission(permission | permissions, { scope? }) is all-of; scope is a ScopeSource — scopeFromTenant() (the default: the resolved ctx.request.tenant), scopeFromParam(param, type), a fixed ScopeRef, null (global grants only) or a function. Both guards answer 501 with no policy service, 401 anonymous BEFORE the scope source runs, 403 on a deny, and are refused at start() by the M110a scan when they name a permission or role outside the catalogue — so a typo is a startup error, never a silent 403. Grants come from sources, UNIONED: { kind: 'static', grants }, { kind: 'claims', map }, and { kind: 'custom', source } (any IGrantSource, or a RegistryFactory resolved at onInit, which is how createDatabaseGrantSource plugs in); one source that rejects, throws or exceeds sourceTimeoutMs DENIES the whole check (fail closed), logged with the source name, the scope TYPE and the error class only — never an id, a principal or a message. A grant applies in its own scope and, through inheritsFrom(scope, signal), in every scope that inherits from it (child requests inherit parent grants, never the reverse); the walk is depth-first with cycle refusal and the maxScopeDepth / maxScopeNodes bounds, any of which denies. A null-scope grant is global unless grantableIn[role] restricts the role to scopeTypes (and global: false, the default). Only grants decide (M113e, V9-28): the roles and permissions a principal carries itself count in no scope unless principalAuthorityIsGlobal: true, which turns each principal role into a global grant (still subject to grantableIn) and lets each principal permission, '*' included, satisfy every scoped permission check — the behaviour before 0.10.0. A non-boolean value is refused at AuthPlugin(...). The unscoped guards are unaffected. Tenant consistency: a check whose OWN scope has tenantScopeType (default 'tenant') and names a tenant other than the resolved request tenant denies — a route parameter cannot reach outside the caller’s tenant. That comparison needs the request: the guards and decorators pass it, and a direct policies.can(...) call made while serving a request must pass it as the target’s context — without it the scope is checked against the grants alone. Per-scope custom roles (customRoles, an IScopedRoleSource) bundle CATALOGUE permissions only, resolve against the roles defined in the grant’s OWN scope, cannot shadow a catalogue role, and are checked through permissions — a guard naming a custom role is refused at startup. timing decides revocation latency for static and custom sources: 'request' (default; memoised per request), { kind: 'cache', ttlMs, maxEntries } (an LRU on the monotonic clock with in-flight coalescing; up to ttlMs) and 'sign-in' (requires signIn; resolved once by IAuthSessionService.signIn, stored under a PRIVATE session key — never in claims, so a token carrying a same-named claim grants nothing — carried through a pending second factor, and in force until sign-out or session expiry; a source failure rejects sign-in with GrantResolutionError, a 503 status hint). A custom source is handed only the principal’s id and iss claim — the fields its shared answer is keyed by — never other claims, roles or permissions. A claims source is mapped on every check from the credential being checked and is never memoised, cached or coalesced, because two tokens for one principal can carry different claims (under 'sign-in' it is mapped once, from the credential that signed in). Every bound refuses an out-of-range value, NaN included, when AuthPlugin(...) is called. Scoped decisions are NOT observed by M98h authorizationDiagnostics — its collector watches only the global RBAC evaluator — so no scope identifier reaches the diagnostics connector. See Authorization.

jwt and rbac are optional. At least one passive strategy must come from jwt, issuers, apiKey, session, or strategies; local alone cannot recognize a later request. A JWT-only registration provides jwt and authentication; it deliberately does not register an authorization service.

What the guards then do (M89b, X18-2). requireAuth(), publicRoute(), and M100d’s requireMfa() resolve nothing and are unaffected: requireMfa() asks only whether the authenticated principal carries otp or pop in claims.amr, which needs no RBAC configuration. The four authorization guards — requireRole, requirePermission, requireAnyRole and requireAllPermissions — answer 501 Not Implemented with Authorization is not configured, short-circuiting before the handler. The status is 501 rather than 403 because nothing is wrong with the caller: the deployment cannot evaluate the policy at all, and the condition is permanent for that deployment. A principal that genuinely fails a policy check still gets 403; that path is unchanged. Before M89b these four resolved the capability unconditionally, so the registry’s throw escaped into the pipeline and the caller received a masked 500 Internal Server Error — a real fault with /health, /ready and /live all reporting up. They still fail closed either way; what changed is that the refusal is legible.

Session authentication and caller-supplied strategies (M73): session takes a SessionAuthOptions whose single required member, toPrincipal(view), maps the opened SessionView to the principal it carries — returning null continues the chain. When present, the plugin appends an internal SessionStrategy after the API-key strategy and requires the session capability (SessionPlugin) to be registered, or register() throws naming both plugins. strategies accepts caller-supplied IAuthStrategys, appended after every built-in in declaration order; a name colliding with any other strategy in the assembled chain throws at register(). The assembled order is fixed — jwt → api-key → session → caller-supplied — and the first non-null principal wins, so a request carrying both a bearer header and a session cookie authenticates by the JWT.

Tokens from an outside issuer (M100b): jwt verifies tokens this application issued; issuers: readonly TrustedIssuer[] verifies access tokens an outside identity provider issued, against its published key set. Each entry is { name, issuer, audience, keys: { jwksUri } | { discovery: true }, algorithms?, clockToleranceSec?, keySet?, toPrincipal } — audience and toPrincipal required. The internal issuers strategy runs immediately after the JWT strategy, reading the same header and scheme; the chain is jwt → issuers → api-key → session → caller-supplied. A token’s iss is read unverified only to select the entry whose issuer equals it exactly. none and HS* are refused before key lookup; keys are filtered by kty/crv/use/alg/key_ops/kid; RS256, PS256, ES256, ES384 and EdDSA (Ed25519) verify through runtime.subtle with zero npm dependencies. iss exact, aud contains audience, exp required, exp/nbf/future iat within clockToleranceSec (default 30, max 300). Expiry is refused at now >= exp + clockToleranceSec; not-before is refused while now + clockToleranceSec < nbf. Key sets are cached on the monotonic clock (keySet.ttlMs 10 min), an unknown kid refetches at most once per keySet.minRefreshIntervalMs (60 s) with concurrent refetches coalesced, the last good set survives fetch failures for keySet.maxStaleMs (24 h) and is then dropped, and each fetch is bounded by keySet.fetchTimeoutMs (5 s), 64 KiB and 64 keys. Every refusal — duplicate name or issuer, empty audience, unsupported algorithm, out-of-range tolerance or timing, a keySet.ttlMs above keySet.maxStaleMs, http without issuers or signIn, non-https non-loopback URL — throws AuthPluginConfigurationError from AuthPlugin(...). http?: IAuthHttp replaces the default fetch seam. The auth health indicator stays up with data.current: boolean and data.issuers carrying per-issuer stale/expired/unfetched states. It performs no I/O. typ: at+jwt (RFC 9068) is not enforced (only a Keycloak typ: "ID" claim is refused), so the API needs an audience distinct from any sign-in client id; multi-tenant Entra ID is not supported. IJwtService remains self-issued only. sub is unique only within its issuer, so whenever an issuer is not the only identity source — another issuer, a self-issued jwt (whose strategy also maps sub to the id), an API key, a session or a custom strategy — toPrincipal should namespace the id (`${claims.iss}|${claims.sub}`) — and every other source too (or keep their ids free of the separator), since namespacing one side does not stop a local id of the same form colliding. keySet.fetchTimeoutMs may not exceed 2147483647, since a larger timer delay overflows.

Sign-in with an outside provider (M100c): signIn: SignInConfig — { basePath?: string (default '/auth'), providers: readonly SignInProvider[], refreshPrincipal? } — makes the application an OAuth 2.0 / OpenID Connect relying party over the authorization-code flow. It requires SessionPlugin (register() throws naming both plugins otherwise), adds CAPABILITIES.AUTH_SESSION to provides and registers the plugin’s IAuthSessionService there, and appends an internal auth-session strategy after the session strategy (chain jwt → issuers → api-key → session → auth-session → caller-supplied) which returns the stored principal with claims.amr set to the recorded methods, overwriting any amr the principal carried. SignInProvider is a union discriminated on kind: OidcProvider (issuer; endpoints and keys from the same issuer-checked discovery document M100b uses; scopes must contain openid; optional rpInitiatedLogout: { postLogoutRedirectUri, idTokenHint?: true }) and OAuth2Provider (authorizationEndpoint, tokenEndpoint, userinfoEndpoint). Both share name (kebab-case), clientId, clientSecret?, tokenEndpointAuth? ('client_secret_basic' — the default with a secret — 'client_secret_post' or 'none'), scopes?, redirectUri (its path must end with <basePath>/<name>/callback; a leading mount prefix is allowed), failureRedirect? (same-origin path), toPrincipal(claims) and onTokens?(tokens: ProviderTokens). Routes: GET <basePath>/<name>/login, GET <basePath>/<name>/callback, and one POST <basePath>/logout. Login stores { state, provider, verifier, nonce?, returnTo, createdAt } in the user’s session (at most 3, 10-minute lifetime), sends PKCE S256 for every provider and never response_mode, and answers 503 provider-unavailable when discovery cannot be read. returnTo is kept only as a same-origin path of at most 256 bytes after percent-encoding non-ASCII (else /) and is never read from the callback URL. The callback refuses with 401 (or a failureRedirect?error=<code> redirect) and one of the fixed codes provider-denied, state-invalid (unknown, replayed, expired, another provider’s, or an RFC 9207 iss naming another issuer), exchange-failed and profile-unavailable, through respondWithError; toPrincipal returning null or throwing answers 403 principal-refused. The attempt is consumed before the exchange. The ID token is verified by the M100b verifier with audience = clientId, plus iss === issuer, nonce equality and, for several audiences, azp === clientId. Discovered endpoints must be https (or loopback http) and printable ASCII — an endpoint with a space, control character or non-ASCII code point answers the login 503 and drops RP-initiated logout to a local one — and the oauth2 userinfo read sends the access token as Authorization: Bearer. On success the callback calls IAuthSessionService.signIn(ctx, principal, { methods: ['fed'] }) (session id regenerated), hands the tokens to onTokens (a throwing onTokens does not undo the sign-in), and redirects to the stored returnTo. Logout calls signOut and redirects to /, or — for the one provider allowed to set rpInitiatedLogout, and only when the session signed in through it (IAuthSessionService.signIn clears that marker) — to its end_session_endpoint with client_id, post_logout_redirect_uri and, only when idTokenHint: true stored one, id_token_hint. refreshPrincipal(stored) re-reads the principal per request (null or a throw → anonymous). What signOut revokes depends on the session strategy: the store strategy deletes the entry, the default cookie strategy leaves a copied cookie valid until its maxAge. A sameSite: 'strict' session cookie is not sent on the provider’s redirect, so every callback fails closed as state-invalid. Every configuration refusal throws AuthPluginConfigurationError from AuthPlugin(...).

Phasing (M16b, shipped): refresh tokens and rate limiting shipped in M16b as standalone additions — RefreshTokenService (app-instantiated; NOT an IAuthStrategy, since a refresh token arrives in the request body, not as a passive header credential) and rateLimitMiddleware (a decoupled middleware factory with no capability token). Neither is an AuthPlugin option. IJwtService still exposes only sign/verify/decode — a refresh token is a signed JWT carrying type: 'refresh' and a jti.

Session and account lifecycle (M124, 0.11.0): signIn.providers may be empty with neither mfa nor passkeys — a password-only sign-in that registers CAPABILITIES.AUTH_SESSION, the auth-session strategy and the logout route only; a non-array providers still throws AuthPluginConfigurationError. signIn.sessionStamp?: SessionStamp, where SessionStamp = (principal: IPrincipal) => string | null | Promise<string | null>, is read once by IAuthSessionService.signIn before any write (a throw, or a value other than a string or null, rejects signIn and records nothing); its base64url SHA-256 digest (or null) is stored as stamp in the signed-in record, or in the pending second-factor record and copied on promotion. On every request the auth-session strategy resolves the principal (snapshot or refreshPrincipal), calls sessionStamp with it, and compares digests in constant time; a mismatch, a throw or a malformed value makes the request anonymous. A record without stamp reads as null, which matches only a user whose stamp is null. The plugin’s passkey registration routes admit through the same re-checks (refreshPrincipal, then the stamp), so a session either has ended cannot enrol a credential; IAuthSessionService.current(ctx) remains the synchronous, un-re-checked snapshot. A non-function sessionStamp throws AuthPluginConfigurationError from AuthPlugin(...). No API bumps the stamp: the application changes it on its own user record. requireAuth(options?: RequireAuthOptions), RequireAuthOptions = { readonly signInPage?: string }: without options, unchanged. With signInPage (a same-origin absolute path that safeReturnTo keeps unchanged, else AuthPluginConfigurationError when the guard is built), an unauthenticated GET/HEAD that is not X-Requested-With: XMLHttpRequest and carries Sec-Fetch-Mode: navigate (or, without that header, an Accept naming text/html or application/xhtml+xml) is answered 302 to signInPage with returnTo=safeReturnTo(path + query) added to its query; every other unauthenticated request, and a request for signInPage’s own path, gets the existing 401. Both unauthenticated answers append Accept, Sec-Fetch-Mode and X-Requested-With to Vary. safeReturnTo(value: string | undefined, fallback = '/'): string is exported unchanged: it returns value when it is a single-leading-slash path with no \, no control character and at most 256 bytes after non-ASCII characters are percent-encoded (the encoded form), else fallback.

Exports

ExportFileDescription
AuthPluginsrc/plugin/auth-plugin.tsPlugin factory
AuthPluginOptionssrc/interfaces/index.tsPlugin factory options (jwt / issuers / signIn / http / apiKey / local / rbac / policies / session / strategies / authorizationDiagnostics)
AuthMiddlewareOptionsrc/interfaces/index.tsGlobal authentication middleware priority and path exclusions
AuthPluginConfigurationErrorsrc/errors.tsInvalid passive-strategy or middleware configuration
AuthorizationDiagnosticsOptionssrc/interfaces/index.tsM98h opt-in for minimized authorization decision explanations through the diagnostics connector
IAuthorizationDiagnosticsSourcere-exportFrom @setu-ts/common — the source AuthPlugin registers under CAPABILITIES.AUTHORIZATION_DIAGNOSTICS
JwtOptionssrc/interfaces/index.tsJWT config (key material, algorithm, expected aud/iss, header/scheme)
ApiKeyOptionssrc/interfaces/index.tsAPI-key strategy config (header + validate callback)
LocalOptionssrc/interfaces/index.tsLocal credential config (verify callback)
SessionAuthOptionssrc/interfaces/index.tsSession strategy config (required toPrincipal callback)
TrustedIssuersrc/interfaces/index.tsM100b outside identity provider: issuer, audience, key source, algorithms, clock tolerance, key-set timings, toPrincipal
IssuerKeySourcesrc/interfaces/index.ts{ jwksUri } or { discovery: true }
IssuerAlgorithmsrc/interfaces/index.ts'RS256' | 'PS256' | 'ES256' | 'ES384' | 'EdDSA' issuer allowlist entry
IAuthHttpsrc/interfaces/index.tsOutbound seam: get for issuer key sets and discovery documents, post (form-encoded) for the sign-in token exchange
SignInConfigsrc/interfaces/index.tsM100c signIn option: basePath, providers, refreshPrincipal, sessionStamp (M124); providers: [] is a password-only sign-in
SignInProvidersrc/interfaces/index.tsOidcProvider | OAuth2Provider | SamlProvider, discriminated on kind (M100f added the saml arm)
SignInProviderBasesrc/interfaces/index.tsFields both provider arms share (name, clientId, credentials, redirectUri, toPrincipal, …)
OidcProvidersrc/interfaces/index.tsOpenID Connect provider: issuer (discovery), optional rpInitiatedLogout
OAuth2Providersrc/interfaces/index.tsPlain OAuth 2.0 provider: authorization, token and userinfo endpoints
TokenEndpointAuthsrc/interfaces/index.ts'client_secret_basic' | 'client_secret_post' | 'none'
ProviderTokenssrc/interfaces/index.tsThe provider’s token response, handed to onTokens and stored nowhere else
RefreshPrincipalsrc/interfaces/index.tssignIn.refreshPrincipal: per-request re-read of the stored principal; null → anonymous
SessionStampsrc/interfaces/index.tsM124 signIn.sessionStamp: reads the user’s stamp; its digest is recorded at sign-in and compared per request
PasswordHashersrc/services/password-hasher.tsPBKDF2-SHA256 hash/verify utility; 600,000 iterations by default, needsRehash for upgrades
MalformedPasswordHashErrorsrc/services/password-hasher.tsThrown by PasswordHasher.verify when stored is not a well-formed hash
PasswordHasherOptionssrc/services/password-hasher.tsM113e: { iterations? } — the PBKDF2 count for new hashes, 100,000–10,000,000, default 600,000
authMiddlewaresrc/middleware/auth-middleware.tsGlobal middleware: authenticates and populates ctx.request.user
requireAuthsrc/guards/index.tsGuard: require an authenticated principal (401); M124 { signInPage } redirects a browser navigation (302 with returnTo)
RequireAuthOptionssrc/interfaces/index.tsM124 requireAuth options: signInPage?
safeReturnTosrc/sign-in/return-to.tsM124 export: a same-origin returnTo path, else the fallback
requireRolesrc/guards/index.tsGuard: require a role (401/403)
requirePermissionsrc/guards/index.tsGuard: require a permission (401/403)
requireAnyRolesrc/guards/index.tsGuard: require any of the given roles
requireAllPermissionssrc/guards/index.tsGuard: require all of the given permissions
publicRoutesrc/guards/index.tsGuard: explicitly allow unauthenticated access
definePolicysrc/policies/define-policy.tsM110a: validates a policy and returns a frozen copy typed by its ability names and target; throws AuthPluginConfigurationError when malformed
requirePolicysrc/policies/policy-guard.tsM110a route guard over IAuthorizationPolicyService: 501 / 401 / 403 / next(); target value or per-request extractor; refused at startup when its policy ability is not registered
AuthorizationDeniedErrorsrc/policies/errors.tsM110a: authorize’s denial rejection — failure / policy / ability, branded with the guards’ 401/403 status hint
UnknownPolicyErrorsrc/policies/errors.tsM110a: rejection for an unregistered policy or ability (policy, ability?); unbranded, so a masked 500
PolicyDenialsrc/policies/errors.tsM110a: 'authentication-required' | 'insufficient-privileges'
requireScopedRolesrc/scoped/scoped-guards.tsM110b route guard: any-of catalogue roles in the request’s scope (scopeFromTenant() by default); 501 / 401 / 403 / next(); refused at startup for a name outside the catalogue
requireScopedPermissionsrc/scoped/scoped-guards.tsM110b route guard: all-of catalogue permissions in the request’s scope; otherwise as requireScopedRole
ScopedGuardOptionssrc/scoped/scoped-guards.tsM110b: { scope?: ScopeSource } for the two scoped guards
GrantResolutionReasonsrc/scoped/errors.tsM110b: 'source-failed' | 'source-timeout' | 'grant-limit' — GrantResolutionError.reason
GrantResolutionErrorsrc/scoped/errors.tsM110b: sign-in-timing grant resolution failed (reason, source?); 503 status hint, no caller input in the message
ScopedRbacOptionssrc/interfaces/index.tsM110b: the scopedRbac option — sources, inheritsFrom, tenantScopeType, grantableIn, customRoles, timing and the bounds; principalAuthorityIsGlobal (M113e, default false) makes the principal’s own roles and permissions count in every scope
GrantSourceConfigsrc/interfaces/index.tsM110b: static / claims / custom grant source arms
StaticGrantsrc/interfaces/index.tsM110b: { subject, role, scope } written into configuration
ClaimsGrantMappersrc/interfaces/index.tsM110b: (claims, principal) => ScopedGrant[] for the claims arm
ScopedRoleLimitsrc/interfaces/index.tsM110b: grantableIn entry — scopeTypes, global?
ScopedRbacTimingsrc/interfaces/index.tsM110b: 'request' / 'sign-in' / { kind: 'cache', ttlMs, maxEntries }
requireMfasrc/guards/index.tsM100d step-up guard: 401 anonymous, 403 second-factor-required when claims.amr lacks otp or pop; a non-array amr counts as absent
TotpServicesrc/mfa/totp-service.tsM100d app-instantiated TOTP service: beginEnrolment / confirmEnrolment / verify / verifyRecoveryCode / generateRecoveryCodes / disable / completeSignIn / completeSignInWithRecoveryCode
TotpServiceOptionssrc/mfa/totp-service.tsTotpService constructor options (store / runtime / issuer) — deliberately NO TTL option
TotpVerifyResultsrc/mfa/totp-service.ts'ok' | 'invalid' | 'locked' | 'not-enrolled'
RecoveryVerifyResultsrc/mfa/totp-service.ts'ok' | 'invalid' | 'locked' for a recovery-code check
TotpProofResultsrc/mfa/totp-service.ts'proof-required' | 'invalid' | 'locked' | 'not-enrolled' — why a factor-changing call was refused
ConfirmEnrolmentResultsrc/mfa/totp-service.tsconfirmEnrolment(principalId, code, proof?): { status: 'ok', recoveryCodes } (the first set is minted at confirmation) or { status: TotpProofResult }; replacing a confirmed factor needs proof of the current one
RecoveryCodesResultsrc/mfa/totp-service.tsgenerateRecoveryCodes(principalId, proof): { status: 'ok', recoveryCodes } replacing the set, or { status: TotpProofResult } — always needs proof of the current factor
DisableResultsrc/mfa/totp-service.tsdisable(principalId, proof?): 'ok' or a TotpProofResult; a confirmed factor needs proof of itself
TotpCompleteSignInResultsrc/mfa/totp-service.ts'signed-in' | 'invalid' | 'locked' | 'not-enrolled' | 'no-pending'
ITotpStoresrc/stores/totp-store.tsM100d pluggable factor store: getEnrolment / stageSecret / confirmSecret / deleteEnrolment / claimStep / reserveAttempt / clearAttempts / saveRecoveryCodes / consumeRecoveryCode; stageSecret, confirmSecret (compare-and-set on the awaiting secret), claimStep, reserveAttempt and consumeRecoveryCode must each be atomic
TotpEnrolmentsrc/stores/totp-store.tsStored factor record: secret, label, confirmed, monotonic lastClaimedStep, and the optional secret/label awaiting confirmation
ReserveAttemptResultsrc/stores/totp-store.ts{ allowed, count } from reserveAttempt — reserved BEFORE the credential is checked; a refused attempt is not recorded
MemoryTotpStoresrc/stores/totp-store.tsSingle-process ITotpStore; takes no constructor arguments (callers pass now to reserveAttempt)
MfaOptionssrc/interfaces/index.tsM100d signIn.mfa: required(principal, methods) and the sole owner of the pending-record TTL, pendingTtlMs (default 300 000, positive integer, refused otherwise at construction), and challengePath, the same-origin code form a provider callback redirects a pending sign-in to (else returnTo)
PasskeyOptionssrc/interfaces/index.tsM100e signIn.passkeys: rpId (host or registrable suffix of every origin), rpName, exact-match origins (https, or http on loopback), a required store, a required resolvePrincipal (null refuses the sign-in), userVerification (default required, enforced on every ceremony), and an optional mayRegister registration policy (with signIn.mfa configured and mayRegister absent, every registration — the first included — needs a recorded otp/pop; set, it decides the first passkey)
PasskeyRegistrationContextsrc/interfaces/index.tsM100e: what PasskeyOptions.mayRegister is told — the signed-in principal, the session’s recorded methods, and the principal’s existing credentialCount
IPasskeyStoresrc/stores/passkey-store.tsM100e pluggable credential store: listByPrincipal / findById / save / updateCounter / delete / claimChallenge. save(credential, { maxPerPrincipal }) MUST be an atomic compare-and-set (stores a NEW credential only when its id is absent AND the principal holds fewer than maxPerPrincipal, checked in the same step as the insert, answering 'saved' / 'duplicate' / 'limit' — a concurrent ceremony carrying the same id must never silently replace the first principal’s record, and a burst must never overshoot the cap), updateCounter MUST be an atomic compare-and-advance (stores the observed counter only when greater, or both zero, and reports whether it did), and claimChallenge must be atomic — the three close the duplicate-registration, clone and replay races
StoredPasskeysrc/stores/passkey-store.tsStored credential record: id, principal id, base64url user handle, JWK public key, COSE algorithm, counter, backedUp (display only), transports, attestation: 'unverified', created time
MemoryPasskeyStoresrc/stores/passkey-store.tsSingle-process IPasskeyStore; takes no constructor arguments; purges expired challenge claims in an amortized sweep so the claim map stays bounded
PasskeySaveOptionssrc/stores/passkey-store.tsM100e: what IPasskeyStore.save enforces atomically with the insert — maxPerPrincipal
PasskeySaveResultsrc/stores/passkey-store.tsM100e: IPasskeyStore.save’s outcome — 'saved', 'duplicate' or 'limit'
SamlProvidersrc/interfaces/index.tsM100f saml sign-in arm: this application as a SAML 2.0 service provider. entityId (AuthnRequest issuer, required audience, metadata), idp (entityId the assertion Issuer must equal, ssoUrl for the HTTP-Redirect AuthnRequest, certs — one or more PEM signing certificates for rotation overlap), acsUrl (path must end <basePath>/<name>/acs; every SubjectConfirmationData Recipient must equal it), toPrincipal(profile) (null or throw refuses with 403 principal-refused), optional failureRedirect, store (default MemorySamlRequestStore, single replica only) and module (injected library). Routes: GET …/login, POST …/acs, GET …/metadata (application/samlmetadata+xml). Assertions must be signed; IdP-initiated responses, encrypted assertions and single logout are refused or not offered. ACS refusals answer 401 (or failureRedirect?error=) with assertion-invalid or state-invalid only. Pending requests are bound to the browser by __Host-setu-saml (SameSite=None; Secure; HttpOnly; Path=/; Max-Age=600)
SamlProfilesrc/interfaces/index.tsM100f: the frozen verified identity SamlProvider.toPrincipal receives — issuer, nameID, optional nameIDFormat / sessionIndex, and attributes (string, or array for a multi-valued attribute)
SamlModulesrc/interfaces/index.tsM100f: structural shape of an injected SAML library ({ SAML }); replaces the lazy @node-saml/node-saml import
ISamlRequestStoresrc/stores/saml-request-store.tsM100f store port: saveRequest(request, now), peekRequest(id, now) (non-consuming read node-saml’s getAsync uses), consumeRequest(id, now) (MUST atomically delete and return — of two concurrent calls exactly one gets the record), claimAssertionId(id, retainUntil, now) (MUST be atomic; false for a reused id). The same store is handed to node-saml as its cacheProvider, so replicas sharing one agree about pending requests
SamlPendingRequestsrc/stores/saml-request-store.tsM100f pending login: requestId, provider, returnTo, binding (the cookie value), issuedAt, expiresAt (ms, 10 minutes after login)
MemorySamlRequestStoresrc/stores/saml-request-store.tsM100f single-process ISamlRequestStore; sweeps expired requests on write and assertion claims when their map has doubled. new MemorySamlRequestStore({ maxPendingRequests }) caps pending requests (default DEFAULT_MAX_PENDING_SAML_REQUESTS = 10,000; oldest evicted first; a non-positive-integer cap throws RangeError). A second replica holds its own map
MemorySamlRequestStoreOptionssrc/stores/saml-request-store.tsM100f options for MemorySamlRequestStore: maxPendingRequests? (positive integer; default DEFAULT_MAX_PENDING_SAML_REQUESTS) bounds what an unauthenticated login flood can hold
DEFAULT_MAX_PENDING_SAML_REQUESTSsrc/stores/saml-request-store.tsM100f default pending-request cap of MemorySamlRequestStore: 10_000
SamlRuntimeLoadErrorsrc/errors.tsM100f: register() rejects with it when a saml provider is configured and @node-saml/node-saml cannot be loaded (or the injected module has no SAML class); names the specifier and the Workers nodejs_compat flag; the underlying failure is cause
RefreshTokenServicesrc/services/refresh-token-service.tsRefresh tokens: issue / refresh (rotation) / revoke
RefreshTokenOptionssrc/services/refresh-token-service.tsRefreshTokenService constructor options
TokenPairsrc/services/refresh-token-service.ts{ accessToken, refreshToken } returned by issue/refresh
RefreshTokenStoresrc/stores/refresh-token-store.tsPluggable async store interface for refresh-token records
RefreshTokenRecordsrc/stores/refresh-token-store.tsRecord shape store implementations produce/consume
IRefreshTokenRotationsrc/stores/refresh-token-store.tsResult of atomically rotating a refresh record
MemoryRefreshTokenStoresrc/stores/refresh-token-store.tsDefault in-memory store with lazy expiry on get and amortised expiry sweeps on save and rotate; unexpired revoked records are retained for reuse detection
IAccessTokenRevocationStoresrc/stores/access-token-revocation-store.tsPluggable bounded access-token revocation interface
MemoryAccessTokenRevocationStoresrc/stores/access-token-revocation-store.tsSingle-process access-token revocation store with lazy expiry
rateLimitMiddlewaresrc/middleware/rate-limit-middleware.tsFixed-window rate limiter middleware factory (429 short-circuit)
RateLimitOptionssrc/middleware/rate-limit-middleware.tsrateLimitMiddleware(options) parameter, including exclude
DEFAULT_RATE_LIMIT_EXCLUDED_PATHSsrc/middleware/rate-limit-middleware.tsThe six operational paths exclude exempts by default; spread it to extend rather than replace
RateLimitStoresrc/stores/rate-limit-store.tsPluggable store interface (increment/reset)
RateLimitResultsrc/stores/rate-limit-store.ts{ count, resetTime } returned by increment
MemoryRateLimitStoresrc/stores/rate-limit-store.tsDefault in-memory fixed-window store
RedisRateLimitStoresrc/stores/redis-rate-limit-store.tsRedis-backed store (inject-or-lazy ioredis), namespacing keys under keyPrefix
DEFAULT_RATE_LIMIT_KEY_PREFIXsrc/stores/redis-rate-limit-store.ts'setu:ratelimit:' — the namespace RedisRateLimitStore applies when no keyPrefix is given
IAuthServicere-exportFrom @setu-ts/common
IJwtServicere-exportFrom @setu-ts/common
IAuthorizationPolicyServicere-exportFrom @setu-ts/common (M110a)
IAuthorizationServicere-exportFrom @setu-ts/common
IAuthStrategyre-exportFrom @setu-ts/common
IPrincipalre-exportFrom @setu-ts/common
JwtSignOptionsre-exportFrom @setu-ts/common
RbacConfigre-exportFrom @setu-ts/common
RoleDefinitionre-exportFrom @setu-ts/common

Registration

import { AuthPlugin } from '@setu-ts/auth-plugin';

app.register(
  AuthPlugin({
    jwt: {
      secret: config.get('JWT_SECRET'), // HS256; use privateKey/publicKey PEMs for RS256
      audience: 'my-app-users', // expected `aud`, enforced on verify
      issuer: 'my-app', // expected `iss`, enforced on verify
    },
    apiKey: {
      header: 'X-API-Key',
      validate: (key) => apiKeyService.validate(key), // (key) => Promise<IPrincipal | null>
    },
    local: {
      // (identifier, secret) => Promise<IPrincipal | null>
      verify: (identifier, secret) => userService.checkPassword(identifier, secret),
    },
    rbac: {
      roles: {
        admin: { permissions: ['*'], inherits: ['manager'] },
        manager: { permissions: ['users:read', 'users:write'], inherits: ['user'] },
        user: { permissions: ['profile:read', 'profile:write'] },
      },
    },
  }),
);

// AuthPlugin registers passive authentication globally at priority 300.

To attach authentication only to selected routes, set middleware: false and use the exported authMiddleware() there. Existing applications should remove a hand-added global copy; leaving it temporarily is correct but runs the strategy chain twice. middleware: { priority, exclude } moves the global stage or skips exact/regular-expression paths. No paths are excluded by default.

Login (Issue Token)

IAuthService.verifyCredentials({ identifier, secret }) resolves to an IPrincipal | null; mint a JWT with the separate IJwtService resolved from 'jwt' (or issue an access + refresh pair with RefreshTokenService — see Refresh Tokens below).

import { CAPABILITIES } from '@setu-ts/common';
import type { IAuthService, IJwtService } from '@setu-ts/common';

app.router.post('/auth/login', async (ctx) => {
  const auth = ctx.services.get<IAuthService>(CAPABILITIES.AUTH);
  const jwt = ctx.services.get<IJwtService>('jwt');
  const { username, password } = await ctx.request.json();

  const principal = await auth.verifyCredentials({ identifier: username, secret: password });
  if (!principal) {
    return ctx.response.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = await jwt.sign(
    { sub: principal.id, roles: principal.roles },
    { expiresIn: '1h', audience: 'my-app-users', issuer: 'my-app' },
  );
  return ctx.response.json({ accessToken });
});

Refresh Tokens (M16b)

RefreshTokenService is an app-instantiated class (like PasswordHasher) — it is NOT an AuthPlugin option and registers no service. It issues distinct typed access + refresh JWTs, each with a random jti; bearer authentication rejects type: 'refresh'. A pluggable server-side RefreshTokenStore tracks refresh-token families: rotation revokes the presented refresh token and mints a descendant, while replay or logout revokes the complete family. refresh()/revoke() never throw on a bad token: invalid, expired, or tampered input yields null/false.

To invalidate an access credential at logout before its regular expiry, construct one IAccessTokenRevocationStore and pass that exact instance to AuthPlugin({ jwt: { ... } }) and the RefreshTokenService. Its revoke(jti, expiresAt) entries are bounded, so setting accessTokenRevocationStore on the refresh service requires accessToken.expiresIn. The shipped MemoryAccessTokenRevocationStore is single-process; multi-instance deployments must supply a shared implementation. Custom RefreshTokenStore implementations must preserve familyId and make rotate(jti, successor) and revokeFamily(jti) linearizable per family: family revocation must durably block later rotations and include any earlier successor.

MemoryRefreshTokenStore expires records lazily on get() and through amortised sweeps on save(), starting at 1,024 entries and doubling the surviving size for the next sweep (minimum 1,024). Only expired records are removed; unexpired revoked records remain for rotation reuse detection. MemoryRateLimitStore follows the same sweep rule on increment(), retaining each entry’s window duration and dropping only expired windows; live limits are never evicted for a cap.

import {
  AuthPlugin,
  MemoryAccessTokenRevocationStore,
  MemoryRefreshTokenStore,
  RefreshTokenService,
} from '@setu-ts/auth-plugin';
import type { IJwtService, IRuntimeServices } from '@setu-ts/common';
import { createApplication } from '@setu-ts/kernel';
import { createRuntimeServices, RuntimePlugin } from '@setu-ts/runtime';

const accessTokenRevocations = new MemoryAccessTokenRevocationStore(createRuntimeServices());
const app = createApplication({
  plugins: [
    RuntimePlugin(),
    AuthPlugin({
      jwt: { secret: config.get('JWT_SECRET'), accessTokenRevocationStore: accessTokenRevocations },
    }),
  ],
});
await app.start();
const jwt = app.services.get<IJwtService>('jwt');
const runtime = app.services.get<IRuntimeServices>('runtime');
const refresh = new RefreshTokenService({
  jwt,
  store: new MemoryRefreshTokenStore(runtime),
  runtime,
  accessToken: { expiresIn: '15m', audience: 'my-app-users', issuer: 'my-app' },
  refreshTokenExpiresIn: '30d',
  accessTokenRevocationStore: accessTokenRevocations,
});

// Login: issue the pair after verifying credentials
app.router.post('/auth/login', async (ctx) => {
  const principal = await auth.verifyCredentials({ identifier, secret });
  if (!principal) return ctx.response.status(401).json({ error: 'Invalid credentials' });
  return ctx.response.json(await refresh.issue(principal)); // { accessToken, refreshToken }
});

// Refresh: rotate the pair (the presented refresh token is revoked)
app.router.post('/auth/refresh', async (ctx) => {
  const { refreshToken } = await ctx.request.json<{ refreshToken: string }>();
  const pair = await refresh.refresh(refreshToken);
  if (!pair) return ctx.response.status(401).json({ error: 'Invalid refresh token' });
  return ctx.response.json(pair);
});

// Logout: revoke the refresh family and its paired access credentials
app.router.post('/auth/logout', async (ctx) => {
  const { refreshToken } = await ctx.request.json<{ refreshToken: string }>();
  await refresh.revoke(refreshToken);
  return ctx.response.json({ ok: true });
});

Rate Limiting (M16b)

rateLimitMiddleware(options) is a standalone fixed-window limiter — added via app.middleware.add(...) like authMiddleware, independent of AuthPlugin (it needs no plugin registered, though its default key reads ctx.request.user when something upstream has populated one — see the key-resolution order below) and registered under no capability token. Requests are counted per key in a windowMs window; when the count exceeds max the middleware short-circuits with 429 (downstream stages, including the handler, do not run) and a JSON body { error: 'Too Many Requests', message }. Headers: always Retry-After on 429; with standardHeaders (default true) also RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset — RateLimit-Reset and Retry-After are both delta-seconds until the window resets (IETF draft semantics), never epoch timestamps. The default store is an in-memory fixed-window counter (single-process); pass store: new RedisRateLimitStore({ url, runtime }) for multi-instance deployments (ioredis is inject-or-lazy: pass client to inject, otherwise ioredis is lazily imported on first use). This limiter’s Retry-After is the honest case for the header — a fixed window genuinely knows when it resets — which is why the framework’s other backpressure statuses (the resilience shedding errors, X32-7; the database classifications, M90f) deliberately carry none: no component there holds an honest horizon, and the asymmetry is recorded beside each.

The 429 body is written through respondWithError, so it uses the application’s configured error format — including Problem Details under errorHandler({ format: 'rfc9457' }) — just like a guard failure. Operational probes are exempt by default: exclude is DEFAULT_RATE_LIMIT_EXCLUDED_PATHS (/live, /ready, /health, /metrics, /openapi.json, and /docs). A caller list replaces those defaults, so spread the constant to extend it; [] exempts nothing. Exempt paths do not increment a counter or receive RateLimit-* headers.

RedisRateLimitStore namespaces keys under keyPrefix (default 'setu:ratelimit:') so two applications sharing one Redis deployment do not count against each other. Pass '' to use the pre-namespacing keys.

The application constructs RedisRateLimitStore, so no plugin supplies its connection-error reporter. Pass connectionErrorReporter — built with createConnectionErrorReporter from @setu-ts/common over your logger — and the 'error' events of the client the store builds are logged (first error at warn, repeats at debug, recovery at info). Absent, the built client’s connection errors are dropped, so nothing reaches the console or a log; an injected client never gets a listener.

The default key is defaultRateLimitKey, which resolves in this order: the authenticated principal (user:<id>), the client IP published by ipSecurityMiddleware (ip:<address>), IRequest.ip (ip:<address>, the socket peer on Node, Deno and Bun), and only then one global 'anonymous' key. The limiter alone counts each socket peer separately; behind a proxy that is one bucket per proxy until IP security resolves the client. Workers and plain fetch have no peer.

To make the key per caller: run the limiter after authentication, pass your own keyGenerator, or register ipSecurityMiddleware with trustProxy plus trustedProxies or proxyHops. That last pairing is not optional. trustProxy alone takes the forwarded header’s leftmost entry, which is caller-supplied under a proxy that appends rather than overwrites (the standard nginx idiom), so a client rotating forged values would earn a fresh bucket per request — a weaker position than the shared 'anonymous' key. The two constraint options walk the header right to left and select the first entry the deployment’s own proxies did not add.

import {
  DEFAULT_RATE_LIMIT_EXCLUDED_PATHS,
  rateLimitMiddleware,
  RedisRateLimitStore,
} from '@setu-ts/auth-plugin';

// Global: 100 requests per minute; retain the default probe exclusions.
// Keyed by authenticated user, else the socket peer (ip:<peer>) on Node/Deno/Bun,
// else ONE shared 'anonymous' bucket (see above).
app.middleware.add(
  rateLimitMiddleware({
    windowMs: 60_000,
    max: 100,
    exclude: [...DEFAULT_RATE_LIMIT_EXCLUDED_PATHS, /^\/internal\//],
  }),
);

// Per-route, keyed by authenticated user, Redis-backed
app.router.post('/expensive', {
  middleware: [
    rateLimitMiddleware({
      windowMs: 60_000,
      max: 5,
      keyGenerator: (ctx) => ctx.request.user?.id ?? ctx.request.ip ?? 'anonymous',
      store: new RedisRateLimitStore({
        url: 'redis://localhost:6379',
        runtime,
        keyPrefix: 'orders-api:rl:',
      }),
      message: 'Too many expensive calls — try again shortly',
    }),
  ],
  handler: async (ctx) => {
    /* ... */
  },
});

Protecting Routes

Guards are free MiddlewareFunction factories. The authorization guards resolve IAuthorizationService from 'authorization', return 401 when no principal is attached and 403 when the check fails, and short-circuit (they do not call next()). authMiddleware always calls next(), so an unauthenticated request still reaches the guard. (publicRoute is used instead of public because public is a reserved word.) Role hierarchy is resolved transitively, and the wildcard permission '*' — held directly or granted by any (direct or inherited) role — satisfies every permission check.

import {
  publicRoute,
  requireAllPermissions,
  requireAnyRole,
  requireAuth,
  requirePermission,
  requireRole,
} from '@setu-ts/auth-plugin';

// Require authentication
app.router.get('/profile', {
  middleware: [requireAuth()],
  handler: async (ctx) => ctx.response.json(ctx.request.user),
});

// Require a role (admin satisfies 'user' via the configured `inherits` hierarchy)
app.router.delete('/users/:id', {
  middleware: [requireAuth(), requireRole('admin')],
  handler: async (ctx) => {
    /* ... */
  },
});

// Require a permission
app.router.post('/users', {
  middleware: [requireAuth(), requirePermission('users:write')],
  handler: async (ctx) => {
    /* ... */
  },
});

// Require any of several roles / all of several permissions
app.router.get('/reports', {
  middleware: [requireAuth(), requireAnyRole(['admin', 'manager'])],
  handler: async (ctx) => {
    /* ... */
  },
});
app.router.post('/bulk', {
  middleware: [requireAuth(), requireAllPermissions(['users:read', 'users:write'])],
  handler: async (ctx) => {
    /* ... */
  },
});

// Explicitly public route
app.router.get('/health', {
  middleware: [publicRoute()],
  handler: async (ctx) => ctx.response.json({ status: 'ok' }),
});

Accessing the Current User

authMiddleware writes the authenticated principal to ctx.request.user (one of the two middleware-written fields on IRequest — the other is tenant, written by the multi-tenancy plugin — so the shipped CurrentUser() parameter source resolves it).

app.router.get('/me', {
  middleware: [requireAuth()],
  handler: async (ctx) => {
    const user = ctx.request.user!;
    return ctx.response.json({ id: user.id, roles: user.roles, permissions: user.permissions });
  },
});

Password Hashing

PasswordHasher is an exported utility for provisioning passwords and verifying them inside a local.verify callback. It draws a random salt and derives a 32-byte key with PBKDF2-SHA256 via runtime.subtle / runtime.randomBytes.

import { PasswordHasher } from '@setu-ts/auth-plugin';

const hasher = new PasswordHasher(runtime); // IRuntimeServices resolved from the 'runtime' token
const stored = await hasher.hash('correct horse battery staple');
const ok = await hasher.verify(stored, 'correct horse battery staple'); // true
if (ok && hasher.needsRehash(stored)) {
  // persist await hasher.hash('correct horse battery staple') in place of `stored`
}

new PasswordHasher(runtime, options?: PasswordHasherOptions) (M113e, V9-50): options.iterations sets the PBKDF2 count for NEW hashes — an integer from 100,000 to 10,000,000, default 600,000; anything else (including NaN and fractions) throws TypeError at construction naming the bounds, not the value. verify ignores the option and reads the count stored in each hash, so every existing hash keeps verifying. needsRehash(stored): boolean is true when the stored count is below the configured one and false at or above it (lowering the configuration never downgrades a hash); it throws MalformedPasswordHashError on exactly the inputs verify refuses. A deployed Cloudflare Worker is reported to refuse PBKDF2 counts above 100,000 — unverified against a deployed Worker — so a Workers application passes { iterations: 100_000 }.

verify(stored, secret) throws the exported MalformedPasswordHashError when stored is not a well-formed pbkdf2$… string, instead of returning false. The two parameters are both plain strings, so a reversed call — password in the stored position — used to fail closed and silently: every correct password answered 401 Invalid credentials with nothing logged. The malformed branch detects exactly that mistake and names both positions; a genuinely wrong password still returns false.


HttpSecurityPlugin() (@setu-ts/http-security-plugin)

Provides HTTP transport security as a middleware-only plugin: CORS, security response headers, CSRF (stateless Origin/Referer validation), request-size limiting, and IP resolution. Registers no capability token and no service — each concern is registered as global middleware via ctx.middleware.add(...) and also exported as a standalone factory for per-route use.

Defaults: Security headers are ON by default; CORS, CSRF, request-size, and IP-security are opt-in via their option blocks. Each concern is secure-by-default when enabled.

Registration

import { HttpSecurityPlugin } from '@setu-ts/http-security-plugin';

app.register(
  HttpSecurityPlugin({
    cors: { origin: 'https://example.com', credentials: true },
    csrf: { trustedOrigins: ['https://example.com'] },
    requestSize: { maxBodySize: 2_097_152 },
    ipSecurity: { trustProxy: true },
  }),
);

Exports

ExportDescription
HttpSecurityPluginPlugin factory
corsMiddlewareCORS middleware factory
securityHeadersMiddlewareSecurity headers middleware factory
csrfMiddlewareCSRF middleware factory
requestSizeMiddlewareRequest-size middleware factory
ipSecurityMiddlewareIP security middleware factory
HttpSecurityPluginOptionsPlugin factory options (type)
CorsOptionsCORS middleware options (type)
CorsOriginMatcherDynamic origin matcher function (type)
SecurityHeadersOptionsSecurity headers options (type)
ContentSecurityPolicyOptionsCSP directive options (type)
StrictTransportSecurityOptionsHSTS options (type)
CsrfOptionsCSRF middleware options (type)
RequestSizeOptionsRequest-size options (type)
IpSecurityOptionsIP security options (type)

Options

OptionTypeDefaultDescription
cors?CorsOptions—Presence enables CORS (priority 200).
headers?SecurityHeadersOptionsdefault secure setOmitted → defaults ON. { enabled: false } → off.
csrf?CsrfOptions—Presence enables CSRF (priority 270).
requestSize?RequestSizeOptions—Presence enables size limiting (priority 180).
ipSecurity?IpSecurityOptions—Presence enables IP resolution (priority 120).

Per-concern Behavior

CORS (corsMiddleware)

Origin matching via origin (boolean/string/array/function). Preflight (OPTIONS + Origin + Access-Control-Request-Method) → 204 short-circuit with Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and (when configured) Access-Control-Max-Age. Credentials reflect specific origin (never *). Non-preflight disallowed origins call next() without CORS headers (browser enforces block).

Access-Control-Allow-Headers follows allowedHeaders, and omitting it is NOT the same as passing []. Omitted, an allowed origin’s preflight is answered by echoing that request’s own Access-Control-Request-Headers, and the response also carries Vary: Access-Control-Request-Headers — mandatory rather than cosmetic, since the answer now depends on a request header and a shared cache would otherwise serve one caller’s preflight to a caller asking for different headers. An explicit list allows exactly those headers; an explicit [] allows none. A denied origin echoes nothing. Echoing is the default because the previous empty-list default advertised every standard method and then refused content-type, so every browser blocked every JSON request; it does not widen the boundary, which the origin allowlist alone decides.

Vary: Origin is appended to every response for a request carrying an Origin header — including a denied one — so a shared cache cannot serve an allowed origin’s response to a denied origin or the reverse.

origin: true (reflect any origin) combined with credentials: true throws at construction: reflecting an arbitrary origin while allowing credentials lets any site the user visits read credentialed responses. List the origins, or pass a CorsOriginMatcher.

Security Headers (securityHeadersMiddleware)

Sets headers before next() so they persist through handler and downstream short-circuits. Default set: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Strict-Transport-Security: max-age=31536000; includeSubDomains. CSP and Permissions-Policy have no default (explicitly configure to enable). Per-header false omits that header.

CSRF (csrfMiddleware)

Stateless Origin/Referer validation for unsafe methods (POST, PUT, PATCH, DELETE). The request’s URL origin, requestOriginOf(ctx).origin, and — only when the request carries Sec-Fetch-Site: same-origin — the validated leftmost X-Forwarded-Proto/X-Forwarded-Host candidate (URL host when absent) are implicitly trusted. The gate is required: X-Forwarded-* are not forbidden request headers and the CORS middleware echoes requested headers by default, so a script on a credentialed CORS origin could otherwise name its own host; scripts cannot set Sec-Fetch-Site. A request whose Sec-Fetch-Site is present and anything but same-origin is checked against the URL origin and trustedOrigins only: a browser cannot send the target’s real public origin cross-site, and a trusted proxy that passes a client’s X-Forwarded-Host through would otherwise publish a host the attacker then matches. It does not grant proxy trust to other consumers. Since 0.11.0 (M119c) Origin: null passes when the request also carries Sec-Fetch-Site: same-origin — the pair a browser sends for a same-origin form POST under Referrer-Policy: no-referrer; acceptNullOriginSameOrigin?: boolean (default true; a non-boolean throws TypeError when the middleware is built) set to false restores the earlier refusal of every null origin. trustedOrigins adds further allowed origins. Both headers absent → pass through (non-browser clients). Optional customHeader requires that header on unsafe methods (403 when absent). exclude lists paths (exact string or RegExp) that skip the check entirely — checked before the method test, so an excluded path is never inspected. It is for a protocol endpoint whose own defences make the check redundant (the SAML ACS: its signed assertion, single-use request and binding cookie), not for an application form route. An origin allowlist cannot admit an IdP that serves Referrer-Policy: no-referrer (Keycloak does) — the browser posts with Origin: null, and trustedOrigins: ['null'] admits every opaque-origin POST on every route — so the documented SAML recipe is exclude: ['/auth/<provider>/acs'] here and in the session plugin’s form CSRF.

Request Size (requestSizeMiddleware)

Checks Content-Length against maxBodySize (default 1 MiB). Over limit → 413 short-circuit without reading body. Absent or malformed Content-Length → pass through.

IP Security (ipSecurityMiddleware)

Resolves client IP and publishes to ctx.state.set(CLIENT_IP_STATE_KEY, ip). With trustProxy: false (default), it uses the socket peer. With true, ipHeader defaults to X-Forwarded-For. trustedProxies walks right to left starting at the socket peer when available; an untrusted peer is the client and forwarded entries are ignored. With no peer, it walks the header alone. IPv4 CIDR is expanded; native IPv6 proxy addresses are compared literally. Every published address is normalized: an IPv4-mapped address becomes dotted IPv4, and an IPv6 address is published in canonical lowercase compressed form; a trusted literal is normalized the same way before comparison. proxyHops and the leftmost default retain their header-only selection and do not check the peer. Prefer trustedProxies for stable proxy addresses. trustedProxies and proxyHops are mutually exclusive; proxyHops must be a non-negative safe integer. Never short-circuits.

IpSecurityOptions.forwardedHeader?: 'x-forwarded' | 'forwarded' defaults to 'x-forwarded'. 'forwarded' reads only RFC 7239 Forwarded and conflicts with ipHeader; invalid values and the conflict throw at construction. Its parser handles quoted strings, escapes, IPv6 and IPv4 ports. A malformed element is a boundary, not a void: the elements to its right were written by later hops and are still read, while the trust walk stops at the boundary and never continues left of it; everything left of the boundary is one unreadable entry. An oversized field (over 8 KiB or 64 elements) is read from the right within those bounds instead of voiding the field, so a trusted proxy that appends its element still resolves the client behind a padded or malformed prefix. A trusted proxy that writes a comma inside a quoted value to the right of malformed input loses that element to the boundary and resolves no client (fail closed). A direct client keeps its own address, and a boundary with only trusted entries to its right yields no client rather than the proxy’s own address. In the default family a proxyHops header too short for the chain, or a chain of only trusted proxies, also resolves undefined, never the socket peer. Unknown or obfuscated hops cannot match a trusted proxy and are never returned as clients. The two header families are never merged.

The middleware publishes { protocol, host } under REQUEST_ORIGIN_STATE_KEY. Forwarded values are believed only with trustedProxies, a socket peer present and trusted, and the selected client from the forwarded chain; leftmost, proxyHops and any request without a peer publish the URL origin. The values are the leftmost X-Forwarded-Proto and X-Forwarded-Host for the default family (the trusted proxy must overwrite both), or proto and host from the selected Forwarded element. Protocol must be http or https (case-insensitive); host must be a DNS name (letters, digits, inner hyphens, no trailing dot) or a bracketed IPv6 literal, with an optional numeric port (the scheme default port is dropped, as a browser Origin never carries it), at most 259 characters, and must round-trip through new URL('http://' + host) unchanged except for case. Rejected or absent components fall back to the URL. requestOriginOf(ctx) and publicRequestUrl(ctx) in common expose that decision to session secure: 'auto', CSRF and the React Router bridge. Security-sensitive links should use a configured base URL. Workers has no peer; use trustProxy: true with ipHeader: 'CF-Connecting-IP', which Cloudflare overwrites; that header vouches for the client address only, and the public origin stays the request URL.


CachePlugin() (@setu-ts/cache-plugin)

RedisStore.get/set/delete/has/clear wrap recognised connection faults in CacheStoreUnavailableError, preserving the original as cause. With the exceptions errorHandler, this returns 503 and the fixed detail The cache is temporarily unavailable. Other errors propagate unchanged. Inspect cause for driver diagnostics.

Provides caching with multiple stores (Memory, Redis, Noop) and a transparent response-caching middleware. CacheService.getOrSet() offers coalesced programmatic read-through for callers that resolve the concrete exported service.

Registers ICacheStore under CAPABILITIES.CACHE.

Exports

ExportFileDescription
CachePluginsrc/plugin/cache-plugin.tsPlugin factory
CacheServicesrc/services/cache-service.tsWrapper applying prefix + defaultTTL
MemoryStoresrc/stores/memory-store.tsIn-memory LRU + TTL store
RedisStoresrc/stores/redis-store.tsRedis store via ioredis
NoopStoresrc/stores/noop-store.tsNo-op store (dev/test)
cacheMiddlewaresrc/middleware/cache-middleware.tsTransparent response-caching middleware
CacheStoreTypesrc/interfaces/index.ts'memory' | 'redis' | 'noop'
CacheStoreOptionssrc/interfaces/index.tsStore-specific options
CachePluginOptionssrc/interfaces/index.tsPlugin factory options
IRedisClientsrc/interfaces/index.tsStructural ioredis shape
CacheMiddlewareOptionssrc/interfaces/index.tsMiddleware options
CachedResponsePayloadsrc/interfaces/index.tsCached response shape
ICacheStoresrc/interfaces/index.tsRe-export from @setu-ts/common
CacheDiagnosticsOptionssrc/interfaces/index.tsOpt-in cache operation counters (M98i)

Registration

import { CachePlugin } from '@setu-ts/cache-plugin';

// Memory store (default)
app.register(CachePlugin());

// Redis store with URL
app.register(
  CachePlugin({
    store: 'redis',
    options: { url: 'redis://localhost:6379', prefix: 'myapp:' },
  }),
);

// Named multi-cache instance
app.register(CachePlugin({ name: 'session', options: { maxSize: 500 } }));

Programmatic API

import type { ICacheStore } from '@setu-ts/common';
import { CacheService } from '@setu-ts/cache-plugin';

app.router.get('/users/:id', async (ctx) => {
  const cache = ctx.services.get<ICacheStore>('cache');
  const cacheKey = `user:${ctx.params.id}`;

  // Try cache
  const cached = await cache.get<User>(cacheKey);
  if (cached) {
    return ctx.response.json(cached);
  }

  // Fetch from database
  const user = await getUser(ctx.params.id);

  // Cache for 1 hour
  await cache.set(cacheKey, user, 3600);

  return ctx.response.json(user);
});

For a programmatic read-through, resolve the exported CacheService and use getOrSet(). Concurrent cold reads for the same key share one factory invocation; a rejected leader leaves no in-flight entry, so each joined caller retries its own factory.

const cache = app.services.get<CacheService>('cache');
const user = await cache.getOrSet(`user:${id}`, async () => await getUser(id), 3600);

Health status

Since M90b the indicator reports two signals, and no longer conflates them: the store’s lifecycle (isReady()) and its reachability. The Redis store probes with a typed ping(); the memory and no-op stores report their live lifecycle truth (an in-process store has no separate backend to reach). The probe is cached for 5 seconds and bounded at 2 seconds through createCachedProbe, so polling /health never turns into backend load.

StatusMeaning
upThe store is connected and reachable, or cannot be probed (reachable is 'unknown').
downThe store is not connected, or is connected but unreachable.

data reports { store, name, reachable }, where reachable is true, false, or 'unknown' when the store offers no probe. A store that cannot probe is never reported as reachable — the absent capability stays explicitly 'unknown'.

Connection errors. The Redis store routes the 'error' events of the ioredis client it builds to the application logger through createConnectionErrorReporter (common): the first error of an outage at warn, identical repeats at debug, the recovery at info. Without a listener ioredis prints every reconnect failure to console.error, bypassing the logger and redaction. An injected client gets no listener — it belongs to the caller. Health semantics are unchanged; the indicator still reports the outage.

Command bound. Since M101a the Redis store’s options.commandTimeoutMs (default 15000, 0 disables) is the ioredis commandTimeout on the client it builds. A paused or partitioned Redis keeps its socket open, so a command used to wait forever; it now rejects inside the bound, is counted failed in the cache observations, and the cache indicator reports down. Not applied to an injected client. A value outside 0–2147483647 throws RangeError when CachePlugin(...) is called.

Cache diagnostics (M98i)

CachePlugin({ diagnostics: { enabled: true, alias: 'primary' } }) counts every backend call its OWN CacheService makes — get, set, delete, has, clear, with getOrSet counted as its internal get/set calls — for the local diagnostics connector’s GET /v1/cache. enabled must be the literal true; alias is 1–64 UTF-8 bytes with no control character; any other key is refused. All three refuse when CachePlugin(...) is called, with fixed messages that never echo a value.

Every instance, opted in or not, registers an ICacheDiagnosticsSource under CAPABILITIES.CACHE_DIAGNOSTICS with { multi: true } (not in provides); without the option it answers disabled and nothing is attached to the service — no clock read and no extra promise on any cache call. With it, each call reads the runtime monotonic clock once (and a second time on the first call per operation and one in every eight after it, which are the timed calls) and returns a promise derived from the backend’s that re-rejects with the original reason, so an unhandled backend rejection stays unhandled; results, null semantics, TTL, prefix, getOrSet coalescing and the ORIGINAL rejection reason are unchanged, and a synchronous backend throw is still thrown synchronously. A null get, a false has and a false delete are successful calls (miss / absent / notRemoved); a rejection is failed and never a collection failure. Records expire 60 seconds after their last observation and a snapshot whose freshest record is older than 30 seconds is stale. lastDurationMs is the most recent TIMED call’s duration (null when none in the retention window was timed). No eviction is ever counted or inferred. A clock failure latches collection-failed without changing any application result; closing the plugin detaches the collector before clearing it, and the source then answers disabled. Keys, prefixes, values, URLs, factory results and errors are never captured. Direct store calls and a CacheService constructed or registered by application code are outside coverage.

Cache Middleware

Transparent response-caching middleware that stores full HTTP responses (status, headers, body) and replays them on cache HIT without invoking the handler. Concurrent misses for one key coalesce within one process: the leader answers X-Cache: MISS and waiters replay a cacheable buffered response with X-Cache: COALESCED. A failed, streaming, or uncacheable leader is never replayed, so each waiter runs the origin itself. Multiple processes do not coordinate; an overlapping successful batch produces at most one origin call per process.

import { cacheMiddleware } from '@setu-ts/cache-plugin';

app.router.get('/users/:id', {
  middleware: [
    cacheMiddleware({
      ttlSeconds: 3600,
      key: (ctx) => `user:${ctx.params.id}`,
      bypass: (ctx) => ctx.request.query.refresh === 'true',
    }),
  ],
  handler: async (ctx) => {
    const user = await getUser(ctx.params.id);
    return ctx.response.json(user);
  },
});

Options

OptionTypeDefaultBehavior
ttlSecondsnumberstore defaultPer-route TTL override in seconds; when omitted the store’s defaultTtl applies
key(ctx) => string${method}:${url}Custom cache key generator. The tenant and locale discriminator segments are composed around this key too, so a tenant-aware application stores one entry per tenant even when a custom key is supplied
vary(ctx) => readonly string[]—Per-request discriminator values appended to the key after the tenant and locale segments. Each returned string is length-prefixed and joined in order, so two requests differing in any value never share an entry; omitted leaves the key unchanged
bypass(ctx) => boolean—When true, skip caching entirely for this request and pass through to the handler
storestringCAPABILITIES.CACHECapability token for the cache store to use
cacheableStatusesnumber[][200]HTTP status codes eligible for caching

Key composition

The key is tenant segment + locale segment + vary segment + base key. The tenant segment comes from ctx.request.tenant?.id and the locale segment (M103) from ctx.request.locale, each length-prefixed and empty when absent — so an application with neither multi-tenancy nor localization keeps byte-identical keys. Both segments are read when cacheMiddleware runs: a route-level cacheMiddleware runs after every global middleware and sees whatever global middleware set, while a GLOBAL registration must have a higher priority number than the middleware that sets each one — the tenant middleware (40) and the locale middleware (45 by default, or the configured middleware.priority). Where the locale middleware is applied per route instead (middleware.enabled: false), a global cache runs before it, so the cache belongs on that route, listed after localeMiddleware. A replaceLocale override is reflected only when it runs before the cache lookup; a route that changes the locale inside its handler must not be response-cached. The segments protect this plugin’s own store only — a shared cache or CDN relies on the Vary header the localization middleware writes.

ICacheStore Interface

interface ICacheStore {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
  delete(key: string): Promise<boolean>;
  has(key: string): Promise<boolean>;
  clear(): Promise<void>;
}

EventsPlugin() (@setu-ts/events-plugin)

Provides in-memory event bus for domain events.

Registration

import { EventsPlugin } from '@setu-ts/events-plugin';

app.register(
  EventsPlugin({
    async: true,
    errorHandler: (error, event) => {
      const logger = ctx.services.get('logger');
      logger.error('Event handler failed', { error, eventType: event.type });
    },
  }),
);

Handlers can also be subscribed declaratively, which is what setu generate event-handler wires for you. Each entry is a { type, handler } pair, and the plugin subscribes it through the same subscribeHandler you would call by hand — so the two routes cannot diverge:

import type { EventHandlerRegistration } from '@setu-ts/events-plugin';

const handlers: readonly EventHandlerRegistration[] = [
  { type: 'user-created', handler: new UserCreatedEventHandler() },
  // Or a factory that builds the handler from the service registry:
  { type: 'order-placed', handler: createOrderPlacedEventHandler },
];

app.register(EventsPlugin({ handlers }));

The handler accepts an instance or a RegistryFactory ((services: IServiceRegistry) => handler). A factory is called at the onInit phase — the first phase at which the registry holds every capability — so it can resolve any capability (the broker, the queue, the logger) and build the handler with it; the result subscribes through the same subscribeHandler the instance arm uses, so the two arms cannot diverge. A factory that throws rejects start(), naming the option and the entry. Instance handlers keep their register() timing byte-identically. The imperative subscribeHandler remains available for a handler wired from another plugin’s own register.

Defining Events

import { DomainEvent } from '@setu-ts/events-plugin';

class UserCreatedEvent extends DomainEvent<{ userId: string; email: string }> {
  readonly type = 'UserCreated';
}

class OrderPlacedEvent extends DomainEvent<{ orderId: string; total: number }> {
  readonly type = 'OrderPlaced';
}

Publishing Events

app.router.post('/users', async (ctx) => {
  const eventBus = ctx.services.get<IEventBus>('events');
  const user = await createUser(ctx.request.body);

  await eventBus.publish(
    new UserCreatedEvent({
      userId: user.id,
      email: user.email,
    }),
  );

  return ctx.response.status(201).json(user);
});

Recording aggregate-local domain events

Use createDomainEvents() as a field owned by an aggregate. It records local facts only: it does not resolve a capability, call IEventBus, save an aggregate, or provide an outbox guarantee.

import { createDomainEvents, type IDomainEvent } from '@setu-ts/events-plugin';

class Order {
  readonly events = createDomainEvents();

  place(event: IDomainEvent<{ readonly orderId: string }>): void {
    // Invariants and state mutation happen before the fact is recorded.
    this.events.record(event);
  }
}

const order = new Order();
// Save order first. The application then chooses to dispatch or persist each fact.
for (const event of order.events.pending()) {
  await dispatchAfterSave(event);
  order.events.remove(event);
}

pending() is an insertion-ordered, read-only snapshot rather than the mutable backing collection. remove(event) uses reference equality (===), removes the first matching occurrence, and returns false without changing the recorder if the reference is absent. clear() removes every pending fact. A reconstructed aggregate creates a new recorder and begins with no pending facts.

interface IDomainEvents {
  record<T>(event: IDomainEvent<T>): void;
  pending(): readonly IDomainEvent[];
  remove(event: IDomainEvent): boolean;
  clear(): void;
}

function createDomainEvents(): IDomainEvents;

Subscribing to Events

// In a plugin
app.register({
  name: 'user-event-handlers',
  version: '1.0.0',
  dependencies: ['events'],
  register(ctx) {
    const eventBus = ctx.services.get<IEventBus>('events');

    eventBus.subscribe<UserCreatedEvent>('UserCreated', async (event) => {
      const mailer = ctx.services.get<IMailer>('mail');
      await mailer.send({
        to: event.data.email,
        subject: 'Welcome!',
        body: 'Thank you for joining.',
      });
    });
  },
});

Event Interface

interface IEventBus {
  publish<T>(event: IDomainEvent<T>): Promise<void>;
  publishBatch(events: IDomainEvent[]): Promise<void>; // non-generic: batches are heterogeneous
  subscribe<T>(type: string, handler: EventHandler<T>): Unsubscribe; // returns an Unsubscribe fn
}

EventsPlugin Exports

  • EventsPlugin — Plugin factory that configures and registers the in-memory event bus with the given dispatch options (async, errorHandler) and subscribes any handlers supplied.
  • EventHandlerRegistration — One { type, handler } pair for the handlers option. Exported because that option is typed as an array of it, so without it a caller could not declare its own array in a variable.
  • InMemoryEventBus — In-memory publish/subscribe event bus implementing IEventBus.
  • DomainEvent — Base class for domain events, generated by defineDomainEvent.
  • IntegrationEvent — Semantic subclass of DomainEvent for integration events (no additional fields).
  • defineDomainEvent — Factory that binds DomainEvent and IntegrationEvent to a runtime, returning event IDs and timestamps from the runtime’s uuid and now services.
  • IDomainEvents — Aggregate-owned local recorder contract. Its snapshots are ordered and isolated from mutable recorder state; it never dispatches events itself.
  • createDomainEvents — Factory for a new empty IDomainEvents recorder, intended for an aggregate field rather than a framework base class.
  • IEventHandler — Class-based event handler interface with a handle(event) method.
  • subscribeHandler — Function that adapts an IEventHandler instance to the EventHandler signature and subscribes it to the bus; returns an Unsubscribe function.
  • EventsDiagnosticsOptions — The plugin’s opt-in event-dispatch observation option (diagnostics), typed as { enabled: true, alias: string, events: Record<string, string> }. The collector behind the bus observes ONLY the exact event types the events map lists, replacing each with its approved alias; payloads, event ids, aggregate ids and unapproved types are never retained. See “Event dispatch observations (M98j)” in the diagnostics section.

Re-exports from @setu-ts/common: IEventBus, IDomainEvent, EventHandler, Unsubscribe.


SsePlugin() (@setu-ts/sse-plugin)

Provides Server-Sent Events (SSE) for real-time, one-way server-to-client messaging over text/event-stream. Built on the Milestone 42 IResponse.stream() primitive and IRequestContext.signal abort lifecycle.

Registration

import { SsePlugin } from '@setu-ts/sse-plugin';

app.register(
  SsePlugin({
    heartbeatMs: 15000,
    retryMs: 3000,
  }),
);

Usage

import { CAPABILITIES } from '@setu-ts/common';
import type { ISseService } from '@setu-ts/common';

app.router.get('/events', async (ctx) => {
  const sse = ctx.services.get<ISseService>(CAPABILITIES.SSE);
  const conn = sse.open(ctx);

  // Send immediately after handler returns — the stream stays open
  conn.send({ id: '1', data: 'hello' });

  return conn.result;
});

// Broadcast to a named channel
app.router.post('/broadcast', async (ctx) => {
  const sse = ctx.services.get<ISseService>(CAPABILITIES.SSE);
  sse.channel('updates').publish({ data: { msg: 'announcement' } });
  return ctx.response.json({ ok: true });
});

Options

OptionTypeDefaultDescription
heartbeatMsnumberomittedWhen set, sends : heartbeat\n\n at this interval.
retryMsnumberomittedWhen set, sends retry: <ms>\n\n as the first frame.
scalingNoticebooleantrueLogs one info line at registration when no realtime backplane is registered, stating that channels broadcast in-process only. false silences the message; channel delivery is unaffected.
diagnosticsRealtimeDiagnosticsOptionsomittedM98l opt-in: { enabled: true, alias } counts stream opens, closes (backlog-guard closes separately as backpressureCloses) and enqueued frames, and reads the live connection and channel counts, for the diagnostics connector’s GET /v1/realtime. Validated when SsePlugin(...) is called. Absent, the plugin registers a disabled source and observes nothing.

Omitting an option disables that behaviour (no timer created).

Interface Reference

  • ISseService.open(ctx): ISseConnection — opens a new SSE connection; sets headers, returns a connection with result (HandlerResult) the handler must return.
  • ISseService.channel(name): SseChannel — returns a reusable named broadcast handle. Membership is allocated on add and reclaimed when the last member leaves. A held handle resolves current members by name, including members who joined after reclamation. Empty handles are not retained.
  • ISseService.peek(name): SseChannel | undefined — the non-allocating counterpart. Returns the channel when it has members and undefined otherwise, registering nothing. Use it wherever the name comes from a request. Added in M74; a required member, so a replacement implementation of ISseService must supply it.
  • ISseService.connectionCount: number — current open connections.
  • ISseService.channelCount: number — channels with members, reported by the health indicator as channels. Drops when the last member leaves and becomes zero at shutdown. Disconnect cleanup visits only channels joined by that connection. Added in M74; a required member, like peek.
  • ISseConnection.send(msg) — enqueue an encoded SSE frame (id:, event:, data: / multi-line data:, retry: + blank-line terminator).
  • ISseConnection.comment(text) — enqueue a comment frame (: text\n\n).
  • ISseConnection.close() — close the connection (idempotent).
  • ISseConnection.lastEventId — the value of the Last-Event-ID request header (for resume logic).
  • SseChannel.publish(msg) — broadcast to every open member, skipping closed ones.

Exports

SymbolKindDescription
SsePluginfunctionPlugin factory — registers ISseService under CAPABILITIES.SSE
SseServiceclassThe ISseService implementation
SseConnectionclassA live SSE connection over a ReadableStream
SsePluginOptionsinterfaceheartbeatMs, retryMs, scalingNotice, diagnostics
RealtimeDiagnosticsOptionstype (re-export)From @setu-ts/common — the M98l diagnostics option
ChannelPublishertypeForwards a local publish to other replicas; supplied by a backplane
ISseConnection, ISseService, SseChannel, SseMessagetype (re-export)From @setu-ts/common
CAPABILITIESconst (re-export)From @setu-ts/common

Notes

  • Built entirely on web-standard ReadableStream; no platform-specific server socket APIs.
  • Channels are in-process until a backplane is registered. Register RealtimeBackplanePlugin and every publish also reaches members on other replicas; with no CAPABILITIES.REALTIME_BACKPLANE provider the behavior is unchanged. SseChannel.size keeps reporting local membership either way.
  • SseMessage.data is JsonValue — a recursive JSON-safe type exported from @setu-ts/common. Until M74 the union was string | number | boolean | null | readonly unknown[] | Record<string, unknown>, whose last two arms admitted values JSON.stringify cannot represent, so this section’s own claim that the member “accepts any JSON-serializable value” was an aspiration rather than a guarantee. It is now enforced: a bigint (which throws) and a function or symbol value (which is silently dropped) are compile errors. A property written T | undefined still assigns, because JSON.stringify drops the key. Three limits remain and no type can close them: a circular structure throws at runtime; NaN, Infinity and -Infinity are members of number that JSON cannot represent, so JSON.stringify normalizes each to null and the value reaches the wire silently changed rather than refused (send it as a string when the distinction matters); and a named interface does not assign — TypeScript grants implicit index signatures only to object-literal types, which was true before the M70n widening and after it, so declare the payload with a type alias or extend Record<string, JsonValue | undefined>. What M70n’s widening actually bought was arrays, primitives and null. SseChannelImpl.publishLocal is the local-only delivery path the backplane subscriber uses; applications call publish.
  • Cloudflare Workers and other edge platforms bound long-lived connections by their own limits — the plugin opens the stream the same way everywhere, but the platform may truncate the connection.
  • The inject() method cannot read a streaming body and throws when it meets one; SSE integration tests must use a real socket (app.start({ port }) + fetch()).

WebSocketPlugin() (@setu-ts/websocket-plugin)

Provides full-duplex, bidirectional real-time messaging, completing the real-time story that SsePlugin covers one-way. Registers an IWebSocketService under CAPABILITIES.WEBSOCKET. Added in Milestone 46.

The RFC 6455 handshake is performed by the runtime’s HTTP adapter through the optional IHttpAdapter.setUpgradeRouter seam, so the same application code runs on Node, Deno, Bun, and Cloudflare Workers. The plugin never creates a server and never touches a runtime API.

Since M70a the middleware pipeline runs before the handshake. The adapter stores the router; the kernel terminal handler consults IWebSocketService.routeUpgrade after the pipeline has run without short-circuiting — and before route matching, so an application catch-all such as the SSR one ReactRouterPlugin mounts cannot shadow the upgrade. A guard that answers 401 therefore refuses the upgrade, metrics apply, and a draining application answers 503 — none of which happened before, which was the security defect M70a closed. On an accepted upgrade the adapter answers with the runtime’s own 101, which does not carry response headers a middleware set on ctx.response (security headers, Set-Cookie); a refused upgrade is an ordinary HTTP response and carries them all. Upgrade detection (the RFC 6455 header check) lives in the service’s own router, inside its error-reporting wrapper, because WsRouteTable matches on path alone and a routing failure has no other place to be logged. A non-conformant upgrade carrying a body is refused 400 by the kernel before any handshake is attempted, so the behaviour is one thing on all four adapters instead of a runtime-specific failure inside the upgrade call.

Since M73 the pipeline’s authenticated principal rides the upgrade: the kernel passes ctx.request.user to IWebSocketService.routeUpgrade, and onOpen’s WebSocketConnectionContext carries it as the optional user member — populated when a strategy authenticated the upgrade, omitted when it did not. An unauthenticated upgrade is anonymous, and a guard in the authentication band refuses it before the handshake. See the session arm of AuthPlugin for the cookie-backed strategy that produces it.

Registration

import { WebSocketPlugin } from '@setu-ts/websocket-plugin';

app.register(
  WebSocketPlugin({
    maxConnections: 10_000,
    heartbeatMs: 30_000,
    heartbeatPayload: 'ping',
    idleTimeoutMs: 90_000,
    maxMessageBytes: 1_048_576,
  }),
);

Options

OptionTypeDefaultBehavior
maxConnectionsnumber0Simultaneous open connections across all routes; 0 is unlimited. At the limit, upgrades get HTTP 503.
heartbeatMsnumber0Heartbeat interval; 0 disables it and creates no timer.
heartbeatPayloadstring'ping'The text frame sent each tick. Read only when heartbeatMs > 0.
idleTimeoutMsnumber0Inbound silence after which a connection is closed with 1001; 0 disables. Requires heartbeatMs > 0 — otherwise WebSocketPlugin() throws, so the option can never be silently inert.
maxMessageBytesnumber0Largest inbound frame; 0 is unlimited. A larger frame closes with 1009 and never reaches onMessage.
scalingNoticebooleantrueLogs one info line at registration when no realtime backplane is registered, stating that rooms broadcast in-process only. false silences the message; room delivery is unaffected.
routesreadonly WebSocketRouteEntry[]—Declarative exact-path route() registrations. An entry is a WebSocketRouteDefinition ({ path, handlers, options? }) or a RegistryFactory resolved at onInit.
behaviorsreadonly (IIngressBehavior | RegistryFactory<IIngressBehavior>)[]—Plugin-level chain around every route’s onMessage. It sees kind: 'websocket', the route path, and the frame; a configured chain returns a promise while preserving immediate execution for synchronous behaviours; a deferred next() delays the handler. No behaviours keep the direct synchronous invoke.
diagnosticsRealtimeDiagnosticsOptions—M98l opt-in: { enabled: true, alias } counts connection opens and closes (a failed handshake and an abnormal or errored close as failed) and every frame send writes or refuses, and reads the live connection and room counts, for the diagnostics connector’s GET /v1/realtime. Validated when WebSocketPlugin(...) is called. Absent, the plugin registers a disabled source and observes nothing.

WebSocketRouteOptions.guards is route-scoped: matching-route guards run before the handshake in declared order, and the first non-true decision refuses. It is distinct from plugin-level frame behaviors; no route-level behaviour arm exists.

Usage

import { CAPABILITIES, type IWebSocketService } from '@setu-ts/common';

const ws = app.services.get<IWebSocketService>(CAPABILITIES.WEBSOCKET);

ws.route(
  '/ws/chat',
  {
    onOpen: (conn, { query, headers }) => {
      conn.data.set('room', query.room ?? 'lobby');
      ws.room(query.room ?? 'lobby').add(conn);
    },
    onMessage: (conn, data) => {
      ws.room(conn.data.get('room') as string).broadcast(data, { except: conn });
    },
    onClose: (conn, { code, reason }) => logger.info('closed', { code, reason }),
    onError: (conn, error) => logger.error('socket error', { error }),
  },
  { protocols: ['chat'] },
);

Route options

OptionTypeDefaultPurpose
protocolsreadonly string[]omittedSubprotocol allow-list; the first client match is echoed back
heartbeatbooleantruefalse excludes this route’s connections from the shared heartbeat sweep — both the payload send AND the idle eviction

Set heartbeat: false when the route speaks its own liveness protocol. The sweeper sends heartbeatPayload as a raw text frame to every connection on every route, which a protocol client (a graphql-transport-ws peer, for instance) must treat as an invalid message; and it evicts on inbound silence, which is the normal state of a listen-only subscriber. The GraphQL plugin’s WebSocket route claims this opt-out.

Exports

ExportKindPurpose
WebSocketPluginfunctionCreates the plugin
WebSocketServiceclassThe IWebSocketService implementation registered under the token
WebSocketConnectionclassThe IWebSocketConnection implementation
RoomclassThe WebSocketRoom implementation
RoomRegistryclassOwns live rooms, creating on demand and discarding when empty
WsRouteTableclassExact-path route table with subprotocol selection
HeartbeatSweeperclassThe interval implementing heartbeatMs / idleTimeoutMs
WebSocketUnavailableErrorclassThrown by route() when the adapter offers no upgrade seam
resolveOptionsfunctionApplies option defaults and rejects a contradictory configuration
frameByteLengthfunctionMeasures a frame in bytes (text by UTF-8 encoding, not string length)
buildContextfunctionBuilds the WebSocketConnectionContext from an upgrade request
parseRequestedProtocolsfunctionParses a Sec-WebSocket-Protocol header into tokens
selectProtocolfunctionPicks the subprotocol to echo, or refuses
WebSocketPluginOptionstypeThe options above
RealtimeDiagnosticsOptionstype (re-export)From @setu-ts/common — the M98l diagnostics option
WsRoute, WsRouteMatchtypeRoute table entry and match result
HeartbeatOptionstypeResolved heartbeat configuration
RoomMembershipListenertypeJoin/leave callbacks a RoomRegistry gives each Room it creates
RoomPublishertypeForwards a local broadcast to other replicas; supplied by a backplane
LocalBroadcastOptionstypebroadcastLocal options — adds exceptId to exclude by connection ID

Notes

  • Routes match on exact path. Variable data travels in the query string and reaches onOpen via WebSocketConnectionContext.query. Pattern parameters (:id) are deliberately not supported: the kernel’s matcher is internal to @setu-ts/kernel and hand-rolling a second one would duplicate logic.
  • The heartbeat is an application-level frame, not an RFC 6455 ping. The web WebSocket API on Deno and Cloudflare Workers exposes no ping(), so a protocol ping would silently no-op on half the supported runtimes.
  • Node requires ws. It is an optional dependency, loaded lazily on the first accepted upgrade (AI_GUIDELINES §12.2) — a plain HTTP application never loads it. loadWsModule throws with the install command when it is absent. The other three runtimes need no dependency.
  • Upgrades on Node arrive on the raw upgrade event, not the fetch path, so they are only available after listen(). On Bun, upgrades likewise only work through listen(), since server.upgrade requires the serve-time websocket handlers. Deno and Workers upgrade on the fetch path.
  • A custom adapter without setUpgradeRouter degrades gracefully: the service still registers, the health indicator reports available: false, and route() throws WebSocketUnavailableError.
  • room(name) is get-or-create; peek(name) is the read that is not. room(callerSupplied).size creates a room per distinct name polled, and the registry reclaims never-joined rooms only on the next disconnection somewhere in this process — an idle application never does. IWebSocketService.peek(name): WebSocketRoom | undefined returns the room when one exists and undefined otherwise, registering nothing, and is what a presence or dashboard endpoint reading a request-supplied name should call. Added in M74; a required member, so a replacement implementation of IWebSocketService must supply it. At the registry level, RoomRegistry.get(name) creates on lookup too; use RoomRegistry.peek(name) for reads of client-supplied room names.
  • Rooms are in-process until a backplane is registered. Register RealtimeBackplanePlugin and every broadcast also reaches members on other replicas; with no CAPABILITIES.REALTIME_BACKPLANE provider the behavior is unchanged. RoomBroadcastOptions.except is honored on every replica: connection IDs come from runtime.uuid() and are globally unique, so the frame carries the excluded ID. Room.size keeps reporting local membership either way. Room.broadcastLocal is the local-only delivery path the backplane subscriber uses (its LocalBroadcastOptions adds exceptId); applications call broadcast.
  • A RoomRegistry keeps a reverse connection → rooms index, so evicting a disconnecting peer costs only the rooms that peer had actually joined rather than a scan of every live room. The index is maintained through the RoomMembershipListener the registry gives each Room it creates; a standalone new Room(name) takes no listener and is not tracked.
  • A failing upgrade router is logged, then refused with 500. The service catches its own routing errors and reports them through the logger capability when one is registered — the HTTP adapter’s UpgradeRouterStore backstop runs inside @setu-ts/runtime, which has no logger, so the cause would otherwise be lost. Register the LoggerPlugin to see it.
  • app.inject() cannot exercise a WebSocket; tests must bind a real socket (app.start({ port }) + new WebSocket(...)).
  • A websocket health indicator reports { available, connections, rooms, routes }. onClose closes every live connection with code 1001 and stops the heartbeat.

RealtimeBackplanePlugin() (@setu-ts/realtime-backplane-plugin)

Provides cross-replica fan-out for WebSocket rooms and SSE channels. Registers an IRealtimeBackplane under CAPABILITIES.REALTIME_BACKPLANE ('realtime-backplane'). Added in Milestone 47.

Rooms and channels hold membership in in-process sets, so behind a load balancer a broadcast reaches only the clients connected to the replica that issued it. WebSocketPlugin and SsePlugin resolve this token optionally, so registering this plugin is the entire change; removing it restores in-process behavior with no application code touched.

Registration

import { RealtimeBackplanePlugin } from '@setu-ts/realtime-backplane-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    RealtimeBackplanePlugin({ transport: 'redis', url: 'redis://localhost:6379' }),
    WebSocketPlugin(),
    SsePlugin(),
  ],
});

Its priority is PLUGIN_PRIORITY.HIGH, so the transport is connected before either consumer registers and subscribes.

Options

Discriminated on transport.

BackplaneCommonOptions.onHandlerError?: (error: unknown, frame: RealtimeFrame) => void (0.11.0) replaces default subscriber logging for every sync throw and async rejection on built-in transports. Hook failures are swallowed. Without it, a call-time logger warns on the first failure per handler, debugs repeats with a count and logs recovery once at info. Descriptions use describeError, with frame kind and name metadata but no frame data. The latest 100 failures are retained in handlerErrors in either mode. Direct transport constructors accept an optional trailing IRealtimeHandlerErrorReporter; omitted, they retain history only. Custom transports are used as supplied and own their reporting.

OptionApplies toDefaultDescription
transportall'memory''memory' | 'messaging' | 'redis' | 'custom'
topicall but memory'setu-ts.realtime'Broker topic / Redis channel. Every replica must agree on it
originalla fresh runtime.uuid()This replica’s identity. Override only to make a test deterministic
bus'memory''default'Named in-process bus; separate names stay isolated
url'redis'—Connection URL, read only on the lazy ioredis path
client / subscriber'redis'—Injected client pair. Required together — see Notes
module'redis'—An ioredis-shaped module, for testing without the real driver
commandTimeoutMs'redis'15000Per-command ioredis timeout on the two connections the lazy path builds, so a publish on an open-but-silent connection rejects instead of hanging. 0 disables; a value outside 0–2147483647 (the largest timer delay — beyond it the timer overflows to 1 ms and every command times out) throws RangeError at construction. Ignored for an injected pair
connectionErrorReporter'redis'logger-backedReceives the 'error'/'ready' events of the two connections the lazy path builds. The plugin defaults it to a createConnectionErrorReporter over the application logger (first error warn, repeats debug, recovery info); supply one to route them elsewhere. Without a listener ioredis prints every reconnect failure to console.error. Ignored for an injected pair, which belongs to the caller
instance'custom'—The IRealtimeBackplane to register, used as-is
localNotice'memory'trueLogs one info line at register() when the resolved transport is the process-local 'memory', naming 'redis'/'messaging' as the cross-process choices. false suppresses it, matching the consumers’ scalingNotice opt-out shape
diagnosticsall but 'custom'omittedM98l opt-in: { enabled: true, alias } counts publications (resolved or rejected, with the last duration) and arriving frames that reached local handlers, for the diagnostics connector’s GET /v1/realtime. A 'custom' transport is never observed. Absent, the plugin registers a disabled source

Registering the plugin bare is not a scaling fix: the default 'memory' transport is a real bus but a single-process one, and before the localNotice existed a bare registration also silenced the consumers’ startup notices without fanning anything out. The notice now comes from the plugin that knows its own transport.

The memory, messaging, and Redis transports retain the most recent 100 subscriber errors in handlerErrors, oldest first.

Transports

transportCrosses processesDependenciesNotes
'memory'NoNoneThe default, and a real single-process bus rather than a no-op
'messaging'YesA plugin providing messagingReuses any registered messaging broker; adds no dependency
'redis'Yesioredis (lazy)Redis pub/sub, over two connections
'custom'DependsNoneAny IRealtimeBackplane

Exports

SymbolKindDescription
RealtimeBackplanePluginfunctionPlugin factory
createBackplanefunctionTransport factory dispatching on the transport discriminant
MemoryBackplaneclassIn-process transport
MessagingBackplaneclassTransport over CAPABILITIES.MESSAGING
RedisBackplaneclassRedis pub/sub transport
isRealtimeFramefunctionGuard narrowing arriving broker traffic to a RealtimeFrame
adaptRedisModulefunctionNarrows an ioredis module to IRedisModule
loadRedisModulefunctionReal lazy import('ioredis')
RedisModuleErrorclassThrown when ioredis cannot be loaded or recognized
DEFAULT_TOPICconst'setu-ts.realtime'
DEFAULT_REDIS_COMMAND_TIMEOUT_MSconst15000, the default commandTimeoutMs
IRedisBackplaneClientinterfaceStructural facade for an injected Redis client; optional connect?() (M117) is called at start for an injected client whose status is 'wait'
IRedisModuleinterfaceStructural facade for the ioredis module
RealtimeBackplanePluginOptionstypeDiscriminated union of the four transport arms
BackplaneCommonOptionsinterfacetopic, origin, localNotice and diagnostics, shared by every arm but 'custom'
MemoryBackplaneOptionsinterfaceThe 'memory' arm
MessagingBackplaneOptionsinterfaceThe 'messaging' arm
RedisBackplaneOptionsinterfaceThe 'redis' arm
CustomBackplaneOptionsinterfaceThe 'custom' arm
IRealtimeBackplane, RealtimeFrame, RealtimeFrameHandler, RealtimeFrameKind, EncodedPayload, RealtimeDiagnosticsOptionstype (re-export)From @setu-ts/common
encodeFrameData, decodeFrameData, CAPABILITIESvalue (re-export)From @setu-ts/common

Notes

  • Loop prevention is an origin stamp. Every instance owns an origin (a runtime.uuid() by default); a subscriber drops frames carrying its own. An arriving frame is delivered through the consumer’s local-only path and never re-published, so a broadcast is delivered exactly once per replica.
  • One topic carries both kinds. RealtimeFrame.kind is 'ws-room' or 'sse-channel', and each consumer ignores the other — a room and a channel may legitimately share a name.
  • Redis needs two connections. A Redis connection in subscriber mode refuses every command other than (un)subscribe, so one connection cannot both publish and subscribe. That is a property of the protocol, not of ioredis. Injecting a client without a subscriber throws at construction rather than failing at the first publish; the lazy path builds both from url.
  • transport: 'messaging' with no messaging capability throws during register(), rather than failing silently per request.
  • A remote frame never creates a room or channel. It is delivered only to one that already exists locally, so a cluster-wide namespace cannot grow a replica’s maps without bound.
  • Binary WebSocket frames are base64-encoded for the wire (encodeFrameData / decodeFrameData, in @setu-ts/common because three packages need the identical shape). An SseMessage is already JSON-serializable and travels as its JSON encoding.
  • Delivery is at-most-once and inherits the transport’s guarantees. Frames are not persisted or replayed. On 'redis' a SHORT partition buffers rather than drops: ioredis’s default enableOfflineQueue: true holds publishes issued while disconnected and flushes them on reconnect, so frames arrive LATE (measured ~6 s) until the maxRetriesPerRequest budget (default 20, ~11 s) exhausts and the buffered commands reject with a warn per frame. A connection that stays open while the server answers nothing (paused, or partitioned with no reset) triggers no reconnect; its publish rejects after commandTimeoutMs (default 15 s, above that budget) rather than never settling. A publish before connect() or after close() rejects rather than resolving. Beyond commandTimeoutMs, ioredis defaults are not configurable through this plugin; inject a client/subscriber pair to change them.
  • RoomBroadcastOptions.except is honored cluster-wide. It names a live connection object, which means nothing in another process — but connection IDs come from runtime.uuid() and are therefore globally unique, so RealtimeFrame.exceptId carries the ID and every replica skips the matching member. Excluding a peer connected to a different replica works for the same reason.
  • Room.size / SseChannel.size remain local. A cluster-wide count is inherently asynchronous (a scatter-gather across replicas), so it cannot satisfy the synchronous committed size getter; exposing one is a contract decision — a separate async method — that a later milestone owns.

Health status

The realtime-backplane indicator reports up, including fan-out outages. Local request delivery still works. data.reachable: false exposes an unreachable backend; a transport without a probe reports 'unknown'. /ready therefore remains 200 for this shared dependency condition.

data reports { transport, origin, reachable }, where reachable is true, false, or 'unknown'.

SessionPlugin() (@setu-ts/session-plugin)

A cache-backed session inherits CacheStoreUnavailableError from Redis cache operations. With errorHandler, an outage answers 503 with value-free cache detail; the original driver failure stays in cause. Cookie-only sessions are unchanged.

Cookie-backed sessions and session-backed form CSRF. Registers a SessionService (ISessionService) under CAPABILITIES.SESSION ('session'). Added in Milestone 48.

The default is a self-contained encrypted cookie: AES-256-GCM under a key derived by HKDF-SHA256, entirely through IRuntimeServices.subtle (the Milestone 16 JwtService precedent), so the package has zero npm dependencies and works on Cloudflare Workers. Setting store moves the payload server-side and leaves only an opaque id in the cookie, which is what makes immediate revocation possible.

import { csrfTokenField, getSession, SessionPlugin } from '@setu-ts/session-plugin';

const app = createApplication({
  plugins: [RuntimePlugin(), SessionPlugin({ secret: mySecret, csrf: {} })],
});

app.router.post('/login', (ctx) => {
  const session = getSession(ctx);
  session.set('userId', user.id);
  session.regenerate(); // new id, same data — defeats session fixation
  return ctx.response.json({ ok: true });
});

app.router.get('/login', (ctx) =>
  ctx.response.html(`<form method="post">${csrfTokenField(ctx)}<button>Sign in</button></form>`),
);

With csrf enabled, that POST /login is itself an unsafe method and needs the form token before it can run: a bare POST /login is refused 403 because the token lives in the session that does not exist yet. The working sequence — and the one the README documents — is a safe request first (which mints the session and its token via getCsrfToken(ctx)), then the mutation carrying the token in the form field or the x-csrf-token header. An exemption for /login would be a hole, so the middleware verifies every method outside ignoreMethods.

cookie.sameSite (default 'lax') is evaluated against the site — scheme plus registrable domain — and a port is not part of a site, so two ports on one host are same-site and the session cookie rides a subresource request between them. Combined with ignoreMethods’ correct GET/HEAD/OPTIONS exemption, that leaves one assumption load-bearing: that your GET handlers have no side effects. A side-effecting GET is reachable from any co-hosted application through an <img> tag with the session cookie attached, and neither defence applies. See the package README’s “What SameSite does not separate”.

Options

OptionTypeDefaultBehavior
secretstring | readonly string[]resolvedIndex 0 seals; every entry can open, so rotation does not log users out
secretNamestringSESSION_SECRETName looked up in CAPABILITIES.SECRETS and then in runtime.env
mode'encrypt' | 'sign''encrypt''sign' is HMAC-only and leaves the payload READABLE by the client
store'memory' | 'cache' | ISessionStore—Omitted keeps the payload in the cookie; set moves it server-side
maxAgenumber (seconds)7200Enforced from a stamp inside the payload, not from the cookie’s Max-Age
rollingbooleanfalsetrue re-issues on every response, extending expiry
idleTimeoutMsnumber—Expiry after this long with no requests. Refreshed by ANY request including a read-only one, so setting it commits on every response to advance the activity stamp; it does not extend absolute expiry
maxCookieBytesnumber4096Throws SessionTooLargeError rather than emitting a cookie browsers drop
cookie.namestringsetu_sessionRenamed from hono_session — see the CHANGELOG migration note; pin the old name to preserve in-flight sessions
cookie.pathstring'/'
cookie.domainstring—Omitted produces a host-only cookie
cookie.sameSite'strict' | 'lax' | 'none''lax''none' forces Secure
cookie.secureboolean | 'auto'true'auto' follows requestOriginOf(ctx).protocol, including deletion; without IP security it follows the request URL. Conflicts with sameSite: 'none' at construction; invalid values are refused by option name.
cookie.httpOnlybooleantrue
csrfCsrfFormOptions—Presence registers csrfFormMiddleware at priority 325
csrf.fieldNamestring'_csrf'Form field carrying the token
csrf.headerNamestring'x-csrf-token'Header accepted as an alternative token source for fetch posts; an explicit name still wins. Read first, so a client that sends it triggers no body parse. No longer required for multipart/form-data, whose FIELD is read too (M94b) — but a token arriving as a multipart FILE part is refused
csrf.ignoreMethodsreadonly string[]['GET','HEAD','OPTIONS']Methods that skip verification
csrf.excludereadonly (string | RegExp)[]—Exact paths or regular expressions that skip form CSRF. Use only for a separately-mounted non-browser protocol surface; application form paths must remain protected.
csrf.exemptBearerbooleantrueM113e: a request carrying Authorization: Bearer <token> (scheme case-insensitive, non-empty token) skips verification only when a bearer (header-credential) strategy authenticated it AND the request restored no session (the session is new) — a browser never attaches one and a cross-origin page cannot add one without an approved preflight. The exemption is decided by the credential that authenticated the request and the session it restored, not by the header’s presence: a request carrying a valid session cookie still needs a token even with a valid bearer token, because the handler would otherwise act on that browser’s session (M113e, L3). A non-browser API client sends no session cookie, so its new session keeps the exemption. A request the bearer rule exempts also gets no browser session: whatever its handler writes to the session is not committed and no Set-Cookie is sent (M113e, L4), so it cannot plant a session in a browser whose cookie no longer restores. Otherwise, a junk bearer header beside a session cookie is still verified, and an unauthenticated bearer header is verified too (fails closed when the session or the credential source is missing). The check therefore runs after authentication (300) at priority 325, reading the credential source it recorded and the session session-plugin parks. A non-boolean is refused. Basic is never exempt. Honoured by csrfFormMiddleware and verifyCsrfToken alike; false restores the refusal
tenantBindingbooleantrueSeals the resolved tenant id into the session on commit; replaying it under a different tenant is refused with 403 before the handler. The compare runs on whichever side sees the tenant second — the session middleware when the tenant resolves first, the tenant middleware when it resolves after the session loads — so resolver priority does not matter. A tenant written by application code inside a handler is seen by neither compare before the handler: it is sealed at commit on an unbound session (a bound one is never re-bound) and compared on the next request. Inert when either side has no tenant; false disables both compare sites in every middleware order

Exports

ExportKindPurpose
SessionPluginfunctionThe plugin factory
SessionServiceclassISessionService implementation registered under the token
getSessionfunctionThe single accessor: getSession(ctx): ISession
sessionMiddlewarefunctionLoad/commit middleware (registered at 260; exported for standalone wiring)
csrfFormMiddlewarefunctionSynchronizer-token middleware (registered at 325 when csrf is present)
getCsrfTokenfunctionMints-and-stores on first call, then stable within the session
csrfTokenFieldfunctionRenders the minted token as a hidden field; the name follows the plugin’s configured csrf.fieldName (an explicit argument is an override; with no published config the shared '_csrf' default applies)
verifyCsrfTokenfunctionStandalone verification for handlers and React Router actions
CSRF_SESSION_KEYconstReserved session key holding the token ('__csrf')
CSRF_CONFIG_STATE_KEYconstctx.state key under which csrfFormMiddleware publishes its resolved config for csrfTokenField to read ('session-plugin:csrf-config')
MemorySessionStoreclassMap-backed store; requires injected clock and timers
CacheSessionStoreclassStore over any ICacheStore resolved from CAPABILITIES.CACHE
SessionSecretMissingErrorclassThrown during register() when no adequate secret resolves
SessionMiddlewareMissingErrorclassThrown by getSession when the middleware did not run
CsrfTokenMismatchErrorclassThrown by verifyCsrfToken; the middleware converts it to 403
SessionTooLargeErrorclassThrown when a committed cookie would exceed maxCookieBytes
SessionModetype'encrypt' | 'sign'
SessionPluginOptionsinterfaceThe factory’s parameter
SessionCookieOptionsinterfaceThe cookie block
CsrfFormOptionsinterfaceThe csrf block, and verifyCsrfToken’s options
SessionServiceDepsinterfaceRuntime capabilities the service is constructed with
MemorySessionStoreDepsinterfaceThe memory store’s required clock and timer injection
PublishedCsrfConfiginterfaceThe shape published under CSRF_CONFIG_STATE_KEY — a frozen, request-local { fieldName }, never the middleware’s own shared config
CacheSessionStoreOptionsinterfaceThe cache store’s key namespacing

Notes

  • ISessionService.fromHeaders(headers) is the headers-only read for non-HTTP entry points. It opens the session from a Headers object alone — a WebSocket onOpen handler, an auth strategy reading a cookie — and returns a read-only SessionView ({ id, data }) or null. It runs the same envelope-open, snapshot-parse, and store-read path as the load behind from(ctx), so it inherits real revocation on the store strategy, but it never commits, never advances the seen stamp, and never writes. It is a required member: an application that implements ISessionService itself must add it (see the CHANGELOG migration note).
  • Nothing in application code writes a Set-Cookie. The middleware commits after next() returns, and only when the session is dirty, regenerated, destroyed, or rolling is on. A pure read emits no header, so sessions do not defeat downstream caching. This works after the handler called a terminal response method because the kernel’s response builder appends headers without consulting whether it ended, and snapshot() hands the adapter its live Headers rather than a clone — cloning would collapse repeated Set-Cookie values into one comma-joined header.
  • A request that throws is not committed. The error handler is about to replace the response, and persisting a half-applied mutation from a failed request is worse than dropping it.
  • Concurrent writes are last-snapshot-wins on both strategies. Two overlapping requests each load a complete session snapshot and commit a complete replacement. A server-side store makes revocation immediate, but does not merge independently changed keys. Keep session writes to one request and put concurrently updated state behind a store with an explicit concurrency strategy.
  • mode: 'sign' does not hide its payload. It protects integrity only; anyone holding the cookie can read its claims. It exists to pair with the store strategy, where the cookie carries nothing but an opaque id. 'encrypt' is the default so the exposing choice is never accidental.
  • On the store strategy, a session holding only reserved keys stays in the cookie (M113e, V9-46). When every key is the form-CSRF token (CSRF_SESSION_KEY) or the tenant binding (SESSION_TENANT_BINDING_KEY in @setu-ts/common), the commit seals the data into the cookie under an internal held: 'cookie' marker and writes no store row, and the load restores it without a store read; a marker over any other key is refused as no session. The first write of another key writes the row and returns the cookie to an opaque id; a commit that leaves only reserved keys on a stored session deletes its row, so a copy of the earlier id-only cookie restores nothing. Anonymous traffic rendering a form therefore adds no row. A build before 0.10.0 reading such a cookie misses the store and starts a fresh session. No ISessionStore member changes.
  • Expiry is server-authoritative. A cookie’s Max-Age is client-controlled, so maxAge is enforced from a wall-clock stamp inside the sealed payload. Both stamps come from runtime.now() rather than hrtime(), because they are serialized and compared across processes.
  • ISession.set(key, undefined) removes the key rather than storing undefined. Storing it would make has(key) report a key that serialization drops, so presence would be true before a commit and false after the next load; treating it as an unset keeps has truthful across the round-trip.
  • A commit rejected for size persists nothing. The maxCookieBytes guard runs before the store write, so a session whose cookie the browser would drop does not leave an unreachable row occupying its TTL.
  • An idle timeout is refreshed by activity, so it commits on every request. idleTimeoutMs measures time since the last request, not since the last write, so any request — a read-only one included — advances the activity stamp. That requires re-issuing the cookie (and rewriting the stored entry on the store strategy) on every response, exactly as rolling does. It does not extend absolute expiry; maxAge stays absolute unless rolling is also set, and the two compose.
  • A destroyed session deletes every id it has held. When regenerate() and destroy() happen in one request, session.id is the post-regeneration id that was never stored, while the cookie the client presented carries the previous one — so both are removed. Deleting only the current id would leave the earlier row readable until its TTL, and a stolen copy of the original cookie would keep authenticating after an explicit destroy.
  • Rotation is O(1). Each key is addressed by a short non-secret kid (a truncated HKDF output under its own info label) carried in the envelope, so opening is a lookup rather than a trial decryption against every key.
  • The envelope is v1.<kid>.<a>.<b>. Web Crypto’s subtle.encrypt returns the authentication tag appended to the ciphertext, so unlike node:crypto there is no separate tag segment. Every malformed input decodes to “no session” rather than throwing.
  • The cookie strategy cannot revoke. A stolen cookie stays valid until it expires, and mass invalidation means rotating the secret. Use a store when that matters.
  • store: 'cache' shares a blast radius with application cache data: a clear() elsewhere logs everybody out. Keys are namespaced (session:), but a dedicated cache instance is the production recommendation.
  • Form CSRF here is a different mechanism from http-security-plugin’s csrfMiddleware, not the same feature configured twice. That one is a stateless Origin/Referer check; this is a synchronizer token in session data. A progressive-enhancement <Form> post cannot set a custom header, so it can satisfy this and not that. Running both is intended — 270, then 325 after authentication (300).
  • Both form encodings are read for the token (M94b) through the request’s shared formData accessor (or the same parseFormBody fallback), so a token can arrive in a multipart FIELD — but only ever as a STRING: a token submitted as a multipart FILE part is refused as absent, because a client chooses freely whether a part carries a filename and a non-string must never reach the timing-safe comparison. The configured header is read first, so a client that sends it triggers no body parse at all, and a non-form request still reports the ordinary mismatch rather than the accessor’s 415.
  • csrfTokenField(ctx) is the form carrier for the plugin’s configured field name. The csrfFormMiddleware publishes the field name it will verify into ctx.state under CSRF_CONFIG_STATE_KEY — before its ignoreMethods/exclude short-circuits, because the GET that renders the form is itself an ignored method — and the helper reads it, so the rendered field name and the verified field name are ONE resolution. The published value is a frozen, request-local PublishedCsrfConfig ({ fieldName }) and deliberately NOT the middleware’s own ResolvedCsrfConfig: that object is resolved once at registration and shared by every request, so publishing it would hand the verifier’s configuration to any handler holding the context — one ctx.state.get(CSRF_CONFIG_STATE_KEY).ignoreMethods.add('POST') turned a 403 into a 200 for every later request in the process. An explicit csrfTokenField(ctx, { fieldName }) argument is an override (for a standalone middleware on a different name); with no published config and no argument — a request the middleware never saw, such as a React Router action — the shared '_csrf' default applies. The helper returns trusted generated markup and escapes the name it renders; in an escaping Hono template use raw(csrfTokenField(ctx)) at the application rendering boundary. It does not register or bypass CSRF verification: SessionPlugin({ csrf: {} }) still globally checks every unsafe method not in ignoreMethods, so a form needs the documented safe-render-then-submit sequence.
  • The 403 body does not disclose the reason. It would tell an attacker whether the session or the token was at fault.
  • React Router reaches the session through the Milestone 44 plugin’s existing populateLoadContext hook, calling the same getSession(ctx). No plugin imports another.
  • A session health indicator reports { strategy, mode, keys, store } and goes down when a configured store reports unhealthy. onClose closes the store.

ReactRouterPlugin() (@setu-ts/react-router-plugin)

Embeds React Router 8 framework mode as a first-party plugin so a Setu-TS application can serve a React frontend with Server-Side Rendering (SSR) and file-based routing. React Router’s framework-mode createRequestHandler is mounted behind a kernel catch-all route; static client assets are served over runtime.fs?.readFile.

A route-middleware refusal on a document request is rendered by React Router’s ErrorBoundary as HTML with the thrown status. The kernel error responder governs kernel routes and is deliberately not applied to that document protocol; API callers needing Problem Details call a kernel API route.

Registration

import { ReactRouterPlugin } from '@setu-ts/react-router-plugin';

app.register(
  ReactRouterPlugin({
    serverBuildPath: './build/server/index.js',
    assetsDir: './build/client/assets',
    assetUrlPrefix: '/assets/',
    basename: '/',
    mode: 'production',
  }),
);

Usage in Routes

The plugin registers its own catch-all route internally — the consumer does not manually register SSR routes. The plugin owns all HTTP verbs at the configured basename pattern:

import { CAPABILITIES, ISsrService } from '@setu-ts/common';

// The plugin handles SSR automatically at the catch-all.
// Any route that NAMES its path beats the catch-all, in either registration order:
// the kernel ranks candidates by static segments (descending), then wildcards
// (ascending), then registration index. So /login, /openapi.json and /api/users/:id
// all win over /*, and registration order does not enter into it.
// Known limit: the ranking compares COUNTS, so /a/* loses to /:x/b on /a/b.
// Before M70g a `*` counted as a static segment, so single-segment routes TIED with
// /* and whichever registered first won — which silently removed /openapi.json and
// /docs from every full-stack application.
app.router.get('/api/health', (ctx) => {
  return ctx.response.json({ status: 'ok' });
});

Options

OptionTypeDefaultDescription
serverBuildPathstringone of twoPath to the React Router Vite server build (default export = ServerBuild). Correct for the socket targets; on Cloudflare Workers the build arrives as a module — supply serverBuild. Exactly one of the two is supplied (a union type since 0.10.0 — breaking for implementors who extends ReactRouterPluginOptions).
serverBuildunknownomittedThe namespace of the app’s compiled server build, supplied as a module (M113d §3.10) — the form a bundled Cloudflare Worker uses, where no path can be resolved at runtime. Mutually exclusive with serverBuildPath at the type level.
loadRequestHandler(buildPath, mode, options?) => Promise<SsrRuntime>omittedInjectable seam for lazy loading. When omitted, the default loads the build (by path or module). Passed serverBuildPath, or the '<serverBuild>' placeholder on the module arm, where options.serverBuild carries the module.
assetsDirstringomittedFilesystem root of the built client bundle. Omit to disable the static-asset route.
assetUrlPrefixstring/assets/URL prefix for the asset route.
publicFilesbooleantrueAlso serve files from the client build ROOT (Vite copies public/ there — robots.txt, favicon.ico) with must-revalidate, not immutable: those files are not content-hashed. false reproduces the previous prefix-only behaviour
basenamestring/Mount prefix for the SSR catch-all. MUST match react-router.config.ts basename for flat routes.
populateLoadContext(ctx: IRequestContext, context: RouterLoadContext) => voidomittedAdds app values to the per-request React Router context, on top of the keys the plugin always sets.
mode'production' | 'development''production'Passed to createRequestHandler(build, mode).

Interface Reference

  • ISsrService.render(ctx): Promise<HandlerResult> — delegates the request to React Router and writes back the result (streaming or buffered).
  • ReactRouterPlugin(options) — returns an IPlugin with async register() that mounts the SSR catch-all, optional static-asset route, and a react-router health indicator.
  • createStaticAssetHandler({ fs, assetsDir, assetUrlPrefix }) — returns a RouteHandler for serving built client assets with immutable caching. Traversal containment: request paths containing .. are rejected and every path is resolved under assetsDir; additionally, when the runtime provides IFileSystem.realPath (the Node/Deno/Bun adapters do), both the assets root and the target are canonicalized and the target must stay inside the root — so a symlink inside assetsDir pointing outside it is not served (404). When realPath is absent (edge runtimes), containment degrades to the lexical .. guard.
  • assembleHandler(build, createRequestHandler, mode): SsrRequestHandler — assembles an RR request handler from a pre-loaded ServerBuild and the createRequestHandler factory. Pure function; unit-testable without I/O.
  • loadRequestHandler(serverBuildPath, mode, options?): Promise<SsrRuntime> — lazily imports the app-provided server build and react-router, unwraps the ServerBuild (default export), and returns { handler, createLoadContext }. Both come from the SAME module object so the provider instance is always an instance of the class the handler’s instanceof check tests. The optional options parameter accepts { rrImportHook?: () => Promise<Record<string, unknown>> } — a test-seam that replaces the react-router import. Throws when the resolved module exposes no RouterContextProvider export (i.e. react-router earlier than 8).
  • createLoadContextFactory(rr): () => RouterLoadContext — pure seam building the per-request context factory from a loaded react-router module namespace. @throws when the module has no RouterContextProvider export.
  • assertSsrRuntime(value): SsrRuntime — validates an injected loadRequestHandler result during register(). @throws a message naming what was received when handler or createLoadContext is missing — notably for a bare handler function, the pre-populateLoadContext shape. Without this, a wrong-shaped seam registers cleanly and then fails every request with an opaque 500.
  • bridgeRequestToRR(ctx, handler, createLoadContext, populateLoadContext?): Promise<HandlerResult> — bridges a kernel IRequestContext into a web Request (omitting the body for GET/HEAD), builds a fresh context provider, invokes the RR handler, and maps the resulting Response back onto ctx.response. Its web Request uses publicRequestUrl(ctx): loaders and actions see the published public origin, or the original URL byte for byte without publication.
  • class SsrService implements ISsrService — holds a resolved RR request handler, its createLoadContext factory, and the optional populateLoadContext hook; its render(ctx) method delegates to bridgeRequestToRR and returns the HandlerResult.
  • servicesContext: RouterContextKey<IServiceRegistry | null> — context key holding the kernel service registry. Always set by the plugin.
  • userContext: RouterContextKey<IPrincipal | null> — context key holding the authenticated principal, or null on an anonymous request.
  • contextKeyFor<T>(name, defaultValue): RouterContextKey<T> — returns the key for a name, memoised, so the same name always yields the same object. Use it for any key an application declares for itself; see the note below on why a { defaultValue } literal silently fails there.
  • viteDevExternals({ packages, resolve }): ViteDevExternalsPlugin — a Vite plugin for development mode under Deno, passed to vite.createServer({ plugins }). It resolves each listed package, and any subpath of one, with resolve (pass import.meta.resolve from the development entry) and marks the result external, in the SSR environment only: a browser cannot import that specifier, so a client import is left to Vite. It appends the resolved specifiers to the SSR environment’s resolve.builtins in configResolved, appending because a configured list replaces Vite’s default one, which holds the Node built-ins. The patterns are jsr: and npm: specifiers, plus the directory of each package mapped to a file: URL. Vite’s runner then imports the module natively instead of loading a second copy. It is typed structurally (ViteDevExternalsOptions, ViteDevExternalsPlugin, ViteDevExternalId, ViteHookContext, ViteResolvedEnvironments), so this package imports no Vite. setu new --template full-stack emits a dev.ts that uses it on Deno, Node and Bun; on Node and Bun Vite externalises node_modules packages anyway, so it changes nothing there.
  • interface RouterContextKey<T> — { readonly defaultValue?: T }. Structurally identical to React Router’s RouterContext<T>, so keys from this package and keys from createContext<T>() are interchangeable.
  • interface RouterLoadContext — get<T>(key) / set<T>(key, value); the per-request context React Router passes to loaders, actions, and middleware.
  • interface SsrRuntime — { handler: SsrRequestHandler; createLoadContext: () => RouterLoadContext }.
  • type PopulateLoadContext — (ctx: IRequestContext, context: RouterLoadContext) => void.

Reading the load context in a route module

import { servicesContext, userContext } from '@setu-ts/react-router-plugin';
import { CAPABILITIES, type ILogger } from '@setu-ts/common';

export async function loader({ context }: Route.LoaderArgs) {
  const services = context.get(servicesContext);
  const user = context.get(userContext); // null when anonymous
  services?.get<ILogger>(CAPABILITIES.LOGGER).info('ssr loader');
  return { user };
}

Declaring your own context keys

An application that puts its own values on the request context — a session, a CSRF token, a service it resolved once — must create those keys with contextKeyFor, not with a { defaultValue } literal:

// app/lib/context-keys.server.ts
import { contextKeyFor } from '@setu-ts/react-router-plugin';
import type { ISession } from '@setu-ts/common';

export const sessionContext = contextKeyFor<ISession | null>('app.session', null);

Keys are matched by identity, and in a framework-mode application the declaring module reliably exists twice: Vite inlines application modules into the server build, while the runtime loads setu.config.ts from source. Two hand-written key objects then look identical and match nothing — context.get() returns the default, so a session reads as null and a CSRF token as an empty string, with no error anywhere. Resolving by name through this package gives both copies the same object.

That guarantee needs this package to be a single module instance, which means the server build must treat @setu-ts/* as external:

// vite.config.ts
export default defineConfig({
  environments: {
    ssr: {
      build: {
        rollupOptions: { external: ['@setu-ts/react-router-plugin'] },
      },
    },
  },
});

Declared under environments.ssr.build, deliberately: React Router builds through Vite’s Environment API, and neither a top-level ssr.external nor environments.ssr.resolve.external reaches that build. setu new --template full-stack emits all of this already.

Development mode needs the same guarantee from Vite’s dev SSR runner, which loads route modules itself. viteDevExternals keeps framework packages and workspace libraries external, resolved by the server runtime, so route modules and the entry share one module instance. Context keys from contextKeyFor() match by identity; a second copy would read different keys.

The same externalisation is what lets a server-only module (*.server.ts) import a framework package by value at all. Client-reachable modules must stick to import type, which is erased: the client bundle has to inline what it imports; server framework packages must stay on the server.

Notes

  • The React Router context is a real RouterContextProvider. React Router 8 checks initialContext instanceof RouterContextProvider inside createRequestHandler and answers 500 Unexpected Server Error for anything else — it does not degrade — and the static handler repeats the check nominally whenever route middleware runs. The plugin therefore constructs the provider from the same react-router module the handler came from. Breaking change (unreleased): the getLoadContext option and the LoadContextFunction type are removed, because a function returning Record<string, unknown> cannot produce a valid context under any wrapping. Migrate by mutating the provider instead of returning an object, and reading values through context keys:

    // Before — produced a 500 on every SSR request against react-router.
    ReactRouterPlugin({
      serverBuildPath,
      getLoadContext: (ctx) => ({ db: myDb, user: ctx.request.user }),
    });
    
    // After
    import { createContext } from 'react-router';
    export const dbContext = createContext<Db | null>(null);
    
    ReactRouterPlugin({
      serverBuildPath,
      populateLoadContext: (_ctx, context) => context.set(dbContext, myDb),
    });
    // `servicesContext` and `userContext` are already set — populateLoadContext augments, never replaces.
  • Vite is never imported by the plugin. In production the app feeds this plugin a compiled build; Vite stays an app-level, build-time concern. For a development loop with HMR and React Fast Refresh, the app runs Vite in-process and supplies a loadRequestHandler that returns a handler over a build thunk — no plugin change required. See docs/react-router-dev.md for the verified recipe, including the base-prefix proxy route and the version pins React Router requires.

  • @react-router/node is excluded. Only core react-router (createRequestHandler) is lazy imported. @react-router/node’s installGlobals() is unnecessary on web-standard runtimes.

  • Static-asset serving uses runtime.fs?.readFile. On edge platforms where fs is absent, assets degrade to a 404. SSR document rendering still works.

  • File-based routing (flatRoutes) is supported transparently. It is baked into the compiled ServerBuild by the React Router Vite plugin at build time — M44 serves it without any plugin surface.

  • @react-router/fs-routes (if used) is an app-level devDependency, never imported by the plugin.


WorkerPoolPlugin() (@setu-ts/worker-pool-plugin)

The indicator stays up even without a worker host. data.available: false and data.reason expose the condition while /ready remains 200. Task submission still throws WorkerPoolUnavailableError; migrate alerts to data.available.

Runs CPU-bound work (image processing, report generation, large data transforms) on real worker threads, off the event loop, behind the capability model. Registers an IWorkerPool under CAPABILITIES.WORKER_POOL. Task handlers are addressed by module specifier, never by closure — closures cannot cross a thread boundary. Inputs and outputs travel by structured clone.

Registration

import { WorkerPoolPlugin } from '@setu-ts/worker-pool-plugin';

app.register(
  WorkerPoolPlugin({
    defaultPoolSize: 4, // default: runtime.workers.availableParallelism()
    taskTimeoutMs: 10_000, // default: 30_000; 0 disables
    maxQueue: 1024, // default: 1024
    pools: {
      // per-task-module overrides, keyed by the specifier passed to run()
      'file:///app/tasks/resize.ts': { size: 2, taskTimeoutMs: 60_000 },
    },
  }),
);

Authoring a task module

A task module is an ES module your application owns. It registers its handler at module top level with defineWorkerTask from the runtime package’s ./worker subpath:

// tasks/resize-image.ts — runs on a worker thread
import { defineWorkerTask } from '@setu-ts/runtime/worker';

defineWorkerTask<Uint8Array, Uint8Array>(async (imageBytes) => {
  return await resize(imageBytes);
});

Usage

import { CAPABILITIES } from '@setu-ts/common';
import type { IWorkerPool } from '@setu-ts/common';

app.router.post('/thumbnail', async (ctx) => {
  const pool = ctx.services.get<IWorkerPool>(CAPABILITIES.WORKER_POOL);
  const bytes = await ctx.request.bytes();
  const thumb = await pool.run<Uint8Array, Uint8Array>(
    new URL('./tasks/resize-image.ts', import.meta.url).href,
    bytes,
    { timeoutMs: 15_000 }, // optional per-call timeout override
  );
  return ctx.response.header('content-type', 'image/png').send(thumb);
});

Options

OptionTypeDefaultDescription
maxWorkersnumberSee budget rule belowTotal worker slots across all modules; Infinity disables.
startupTimeoutMsnumber10000Deadline for a spawned worker to signal ready: a positive integer from 1 through 2147483647. 0 cannot disable it; an invalid value throws RangeError.
defaultPoolSizenumberavailableParallelism()Workers per pool.
maxQueuenumber1024Pending-task bound per pool; exceeding it throws.
taskTimeoutMsnumber30000Per-task timeout; 0 disables; at most 2147483647. Timed-out worker dies.
poolsRecord<string, TaskPoolOptions>{}Per-module { size?, maxQueue?, taskTimeoutMs? }.
hostIWorkerHostruntime.workersInjected host, wins over the runtime’s; for tests.

Interface Reference

  • WorkerPoolService — the IWorkerPool implementation the plugin registers under CAPABILITIES.WORKER_POOL; exported for direct construction in tests. Its constructor refuses an invalid maxWorkers, startupTimeoutMs or task timeout with RangeError, as the plugin factory does.
  • TaskPoolOptions / WorkerPoolPluginOptions — the per-module overrides and plugin options in the table above.
  • IWorkerPool.run<TInput, TOutput>(taskModule, input, options?): Promise<TOutput> — run a task, creating the pool for taskModule lazily on first use.
  • IWorkerPool.stats(): readonly TaskPoolStats[] — one snapshot per pool ({ taskModule, workers, busy, queued, completed, failed }).
  • IWorkerPool.shutdown(): Promise<void> — terminate every worker, reject pending tasks (called by the plugin’s onClose).

Errors (exported for instanceof)

  • WorkerPoolUnavailableError — the runtime has no worker support (e.g. Cloudflare Workers) or the pool was shut down.
  • WorkerTaskError — the task handler threw, or the worker crashed; carries taskModule, remoteName, and remoteStack.
  • WorkerTaskTimeoutError — the task exceeded its timeout; the worker was terminated and replaced. Carries taskModule and timeoutMs.
  • WorkerExitError — the worker thread ended while its task was in flight, on a runtime that reports worker exits (Node, Bun; Deno reports none). Carries taskModule and the exit code (null when the runtime reports none).
  • WorkerQueueFullError — the pool’s pending queue is at its bound. Carries taskModule and limit.

Sizing and shared memory

Each module has its own pool; size bounds that pool and maxWorkers bounds their sum. The service-wide default is max(availableParallelism(), defaultPoolSize ?? 0, sum(pools[*].size)). Explicit pool sizes therefore fit concurrently; modules using fallback sizes share the budget. maxWorkers: Infinity restores the previous unbounded sum. Other values must be positive safe integers; NaN, zero, negative and fractional values throw RangeError at plugin or service construction, naming the option and refused value.

When the budget is full, a requester evicts another module’s ready idle worker; otherwise it waits. Freed capacity is reserved for waiting modules, with modules that have queued tasks and no slots served first, then FIFO among equals. A busy module hands over a worker after a task settles if another module has no slot. Timeouts include time waiting for this budget. Rotation costs a worker spawn; size maxWorkers at least as large as the number of task modules active concurrently when that cost matters. Workers stay resident between bursts; there is no timed idle reaping.

Timeouts are validated. taskTimeoutMs (plugin-wide and per pool) and a per-call timeoutMs must be 0 (disabled) or a positive integer no greater than 2 147 483 647, the largest delay a runtime timer honours. Anything else throws RangeError at construction, or rejects that run() call before admission: NaN and negative values used to disable the timeout silently, and larger values overflowed the timer and timed every task out after about 1 ms. Each option is read once and the pools use exactly the validated value; only the own enumerable keys of pools are read, so an entry inherited through a prototype is ignored.

Untimed tasks hold their slot. With taskTimeoutMs: 0, or a per-call run() option of timeoutMs: 0, a task that never settles keeps its worker. Once every slot is held that way, other task modules wait (until their own timeouts, or forever if theirs are disabled too) until the process restarts. The pool does not reclaim a running task the application allowed to run indefinitely; register() warns when taskTimeoutMs is 0 under a finite maxWorkers (a per-call timeoutMs: 0 cannot be seen at registration, so it gets no warning). Keep a timeout on modules that share the budget, or opt out of the bound with maxWorkers: Infinity.

Containers: CPU limits lower availableParallelism() (measured on Deno and Node with --cpus=2); pods without CPU limits see all node cores. Set an explicit bound for the pod’s memory budget. A trivial module measured about 12 MB per worker on Deno, 13.5 MB on Node and 1.5 MB on Bun; imports and task data increase that cost. The bound counts pool slots, not threads or memory. A worker the pool terminates (on a timeout, a startup deadline or a hand-over) leaves the budget at once, but on Deno Worker.terminate() does not stop a thread that never yields: a timed-out CPU-bound task keeps its thread, memory and CPU until its code returns. Measured on Deno with maxWorkers: 1: five timed-out busy-loop tasks left 0 slots and four extra threads at about 500% CPU. A slot is released to the budget before its thread has exited, and the next waiter spawns at once, so at a hand-over the terminated thread and its replacement can briefly be alive together: measured on Node 24 with 20 ms sampling and maxWorkers: 3, a cold first burst peaked at 6 threads in 3 of 3 runs, one burst above the bound for about 0.7 s. Treat a task timeout as a correctness bound rather than a resource bound for CPU-bound code that can loop, and have long tasks check a deadline and return.

Task granularity: a measured trivial round trip takes about 7–17 µs. Batch very small work so serialization and scheduling do not cost more than the computation.

SharedArrayBuffer: structured clone shares it instead of copying it. A buffer nested in the input can be written by the worker and read by its caller after the promise settles. A task’s settlement does not mean its worker has stopped using the buffer: on Deno a task that times out keeps running, and keeps writing, after its promise has rejected (measured: 50 of 200 bytes written at the rejection, all 200 a second later). After a successful result the worker is done with the buffer. After a rejection, a timeout especially, treat the buffer as still shared and do not reuse it. While a task runs, do not touch the buffer unless coordinating access with Atomics. SAB-backed views are refused by BodyInit and some SubtleCrypto operations: copy into an ordinary ArrayBuffer-backed view first. An ordinary ArrayBuffer input is copied, so the caller’s original stays unchanged.

See apps/worker-pool for a runnable off-thread, fairness and shared-memory smoke check. A synchronous spawn failure rejects the oldest pending task as a crash, clears its timer, and holds no worker budget. The pool continues scheduling the remaining queue; repeated spawn failures settle one task per attempt rather than leaving queued tasks stranded.

A worker that does not signal ready within startupTimeoutMs (default 10 s, applied even with taskTimeoutMs: 0) is terminated, its slot returns to the budget, and the oldest waiting task for that module rejects with WorkerTaskError (remoteName: 'WorkerStartupTimeout'). When a queued task expires while another module has work and no worker, the expiring module yields a starting worker to it, so a module that never becomes ready cannot starve the others under steady demand. Callbacks from removed slots are ignored. A worker error that settles no task (an idle worker crashing, or a startup crash with nothing queued) is logged as a warning with its taskModule, since it rejects nothing a caller could see. Termination throws/rejections are contained; shutdown waits at most 1,000 ms per termination, including already-retired slots. This bounds waiting, not physical thread exit: a failing host can leave a worker alive. Module metadata remains retained for the service lifetime. NaN or Infinity legacy default/pool sizes contribute zero to the derived budget; their per-pool behavior remains unchanged, while independently valid configured modules remain usable.

Notes

  • Runtime support. Threads come from IRuntimeServices.workers, implemented on Node (node:worker_threads), Deno, and Bun (web Worker). On Cloudflare Workers there are no threads: the plugin still registers, but run() rejects with WorkerPoolUnavailableError and the health indicator reports available: false. One codebase deploys everywhere.
  • Error vs crash. A thrown handler is a healthy worker reporting failure (WorkerTaskError, the worker remains eligible for reuse or budget hand-over). A worker-level crash drops the worker and re-dispatches its queued work to survivors. A timeout terminates and replaces the worker (in-flight JS cannot be cancelled).
  • Structured clone only. input/output must be structured-clonable — no functions or class instances. A clone failure surfaces as a rejected run() on both dispatch paths (immediately, and when the task is dispatched later from the queue); the worker remains usable, and the pool keeps serving under the shared budget.
  • A worker that ends its own thread settles its in-flight task with WorkerExitError and frees the slot, independently of the task timeout — where the runtime reports the exit. It does on Node (node:worker_threads 'exit') and on Bun (its non-standard 'close'); it does not on Deno, whose web Worker emits no host-side event at all when a worker calls self.close(), and where a later postMessage still resolves. On Deno the task timeout therefore remains the only backstop, so keep one on any pool whose task module can terminate itself. The pool reports which case it is in through exitDetection in its health payload, and warns once at register() when taskTimeoutMs resolves to 0 on a runtime that cannot report an exit.
  • Node .ts task modules need an app-level loader/build, exactly as the frontend build is the app’s responsibility (AI_GUIDELINES §12.2); the plugin consumes the module specifier as given.
  • Health indicator worker-pool reports { available, exitDetection, pools, budget }; budget is { maxWorkers, workers }, with the resolved bound (null for Infinity) and the sum of pool slots.
  • Metrics (opt-in by capability). When CAPABILITIES.METRICS is registered, the plugin publishes six series, all labelled task_module: gauges worker_pool_workers, worker_pool_busy_workers, worker_pool_queued_tasks, and counters worker_pool_tasks_completed_total, worker_pool_tasks_failed_total (also labelled reason: handler/timeout/crash/clone/shutdown) and worker_pool_tasks_rejected_total (reason: queue_full/pool_closed/unavailable). No plugin option enables them and none disables them — the instruments exist exactly when the metrics capability does. ..._failed_total summed over reason equals the health payload’s failed; ..._rejected_total counts refusals that never became tasks, which the health payload cannot see. Gauges are written from the health snapshot on each state change, so the two surfaces cannot disagree, and no interval timer is armed.

Diagnostics Connector (@setu-ts/diagnostics-plugin)

Authenticated promotion periodically reclaims per-session rate buckets that have refilled to their full burst. Sweeps use doubling thresholds starting at 64; drained and partially refilled buckets keep their limits.

An authenticated local connector between a native devtool client and M98a’s kernel diagnostics, served over a runtime-owned IPv4 loopback listener. Deno and Node, with bounded polling only; no browser UI, no application data, no control commands, and no environment fallback. Activation is explicit: DiagnosticsPlugin({ enabled: true, port, sessionId, sessionKey, ttlMs?, maxSessionLifetimeMs? }) — an omitted or false enabled, an invalid port or credential, an unsupported runtime, or an application without M98a diagnostics refuses activation. enabled is typed as the LITERAL true, not boolean: there is no disabled mode, so enabled: !isProduction — which would throw at composition and stop a production application booting — is a compile error rather than an outage. Keep the connector out of production by deciding whether to CONSTRUCT it, ideally from a development-only entry point the production graph never imports; the package README has the worked composition and the Deno permission grant that refuses the bind independently. Loopback is not authentication (the HMAC-SHA-256 signed protocol is) and not encryption (a privileged local sniffer reads authenticated bytes; remote and production use are unsupported).

ExportKindSince
DiagnosticsPluginfunction0.8.0
IDiagnosticsPlugininterface0.8.0
DiagnosticsPluginOptionsinterface0.8.0
createDiagnosticsClientfunction0.8.0
IDiagnosticsClientinterface0.8.0
DiagnosticsClientOptionsinterface0.8.0
DiagnosticsSessionLifetimeinterface0.8.0

IDiagnosticsPlugin.revoke() immediately disables authorization, drops key references, and closes the listener without stopping the parent application; it is idempotent, and a revoked instance never reactivates. createDiagnosticsClient({ endpoint, sessionId, sessionKey, subtle, fetch, timing }) requires endpoint to be exactly http://127.0.0.1:<port>; the client performs the signed status pairing automatically, serializes calls with strictly increasing sequence numbers, verifies every response MAC over the exact bounded bytes before parsing, and marks failed pairing terminal. After pairing, every response’s signed x-setu-instance header and every body instanceId (core snapshot and event batch included) must equal the paired instance; a mismatch is the fixed connection error. Every body is checked against its exact DTO before it is returned — the core snapshot’s node and edge records and the event batch’s events and cursor included (see docs/diagnostics-protocol.md) — and every result is deeply frozen. A middleware node’s priority and an event’s statusCode are left unranged because the application sets both: either may be any finite number (a status is not necessarily a valid HTTP status), and the kernel omits a non-finite value rather than letting it serialize to null.

Session renewal (M98o). maxSessionLifetimeMs explicitly opts a session into renewal; it must be an integer from ttlMs through 43,200,000 (12 hours of awake, monotonic time). Without it, status bytes and timer behavior are unchanged. With it, signed status adds renewal: { maxRemainingMs }, client.session(): Promise<DiagnosticsSessionLifetime> returns the fresh authenticated lifetime, and client.renew() extends the same key/session/instance/sequence space by at most one TTL window without crossing the activation-time cap. Both returned lifetime values are deeply frozen non-negative safe integers and expiresInMs <= maxRemainingMs; renewal support is represented by renewal === null, not an exported error type. Consumers should renew at roughly half the remaining window. The connector serves the authenticated, replay-protected GET /v1/renew; the client still treats every non-200 as its fixed connection error and never parses unsigned refusal bodies.

Health observations (M98d). The status body now carries an inspectors manifest — { health: true, configuration: true, queues: true, traces: true, authorization: true, cache: true, scheduler: true, events: true, realtime: true, storage: true, outboundHttp: true } (configuration is true since M98e, queues since M98f, traces since M98g, cache since M98i, authorization since M98h, events since M98j, scheduler since M98k, realtime since M98l, storage since M98m and outboundHttp since M98n, all below) — and the connector serves a first inspector operation, GET /v1/health. The client reads it through client.health(): Promise<HealthDiagnosticsSnapshot>. The connector resolves the optional health source under CAPABILITIES.HEALTH_DIAGNOSTICS once, at registration: an absent source answers a typed unsupported snapshot (no indicator runs, startup never fails), a registered-but-disabled source answers disabled, and a throwing source answers a value-free collection-failed snapshot — as does a source whose projected DTO fails the exact validator (an unknown enum, a non-finite or negative measurement, an oversized alias, more than 64 observations, a malformed shape), so nothing unvalidated is ever signed — none of which changes the application’s readiness. The snapshot is the health plugin’s minimized DTO (approved alias, framework status, outcome state, monotonic timing only — no indicator data, no error text, no absolute time), projected field-by-field and bounded by the same 256 KiB response ceiling as every other operation. A client paired against a legacy M98b status body (no manifest) resolves all inspectors to false and its health() answers unsupported without sending the request. The full wire shape is in docs/diagnostics-protocol.md.

Queue observations (M98f). The connector serves GET /v1/queues?after=<N>&limit=<N> (the same canonical query grammar as /v1/events), read through client.queues(after: number, limit?: number): Promise<QueueDiagnosticsBatch> — same argument bounds as read() (non-negative safe-integer cursor, limit 1–128, default 128). At bootstrap the connector reads every IQueueDiagnosticsSource registered under CAPABILITIES.QUEUE_DIAGNOSTICS (every QueuePlugin instance contributes one as a multi provider; at most 16 are read, the rest counted in truncatedSources). Because one session is permitted, the CONNECTOR keeps one cursor per source: each authenticated queue read drains every source’s new attempts, in registration order, into a bounded 1,024-event merge ring, then serves the requested page of that ring under M98a’s cursor contract (exclusive after, exact lost for merge-ring eviction, an empty page echoes its cursor, a cursor beyond the merge sequence is refused invalid-request). A source ring that wrapped between two reads is reported on THAT source’s status lost, never in the batch lost. A source that throws or answers a batch failing the connector’s exact validator is reported collection-failed with failure: 'source-read-failed' and nothing it returned is merged; the merged frame runs the exact QueueDiagnosticsBatch validator before it is signed, and depths — never events — are trimmed to keep it within 256 KiB (truncatedDepths). With no queue source registered the batch is state: 'unsupported'; a client whose negotiated manifest has queues: false answers that frozen batch, echoing its cursor, without sending the request. IDiagnosticsClient.queues is a new REQUIRED member — additive for callers; a structural implementation of IDiagnosticsClient must add it (the package has not yet been published).

Configuration provenance (M98e). The connector serves the config plugin’s value-free provenance snapshot at GET /v1/config, read through client.configuration(): Promise<ConfigDiagnosticsSnapshot>. The source is resolved under CAPABILITIES.CONFIG_DIAGNOSTICS — the ConfigPlugin registers one EAGERLY, always, so “no config plugin” (typed unsupported) is distinguishable from “present but off” (the plugin’s own disabled answer). A throwing source, or one whose projected DTO fails the exact validator (an unknown origin or schema effect, a sourceAlias on a non-file origin, an oversized alias, an alias carrying a C0/C1 control character, an over-budget alias array, a malformed shape), answers a value-free collection-failed snapshot, so nothing unvalidated is ever signed; the native client runs the same validator. The connector also re-checks the instance binding on the projected copy it signs, so a source whose instanceId reads differently the second time is refused rather than signed, and it copies every source-supplied list itself — each item read once, never through the source’s own map or toJSON — so the validator checks exactly what is signed. The same rules apply to GET /v1/health. Every string from the first-party source is an application-approved display alias: no configuration value, value hash, value length, raw key name, or file path is ever carried, and unapproved keys are never observed at all — no counter discloses that they exist. The negotiated manifest governs configuration() exactly as it governs health(): a legacy pairing answers unsupported without sending the request.

Scheduler observations (M98k). The connector serves GET /v1/scheduler, read through client.scheduler(): Promise<SchedulerDiagnosticsResponse>. At bootstrap it resolves every ISchedulerDiagnosticsSource registered under CAPABILITIES.SCHEDULER_DIAGNOSTICS (each SchedulerPlugin instance contributes one as a multi provider) and refuses to start with more than 16. Each authenticated read calls every source’s synchronous snapshot() once, copies only plain own data properties, and isolates a throwing or malformed source as a value-free collection-failed snapshot; duplicate non-null SOURCE aliases or a body over 256 KiB collapse the response to collection-failed with no sources. With no source the response is unsupported; a client whose negotiated manifest has scheduler: false answers that locally without a request. IDiagnosticsClient.scheduler is a new REQUIRED member — additive for callers; a structural implementation must add it. The wire shape is in docs/diagnostics-protocol.md.

Cache observations (M98i). The connector serves GET /v1/cache, read through client.cache(): Promise<CacheDiagnosticsResponse>. At bootstrap it resolves every ICacheDiagnosticsSource registered under CAPABILITIES.CACHE_DIAGNOSTICS (each CachePlugin instance contributes one as a multi provider) and refuses to start with more than 16. Each authenticated read calls every source’s synchronous snapshot() once, copies only plain own data properties, and isolates a throwing or malformed source as a value-free collection-failed snapshot; duplicate non-null aliases or a body over 256 KiB collapse the response to collection-failed with no sources. With no source the response is unsupported; a client whose negotiated manifest has cache: false answers that locally without a request. IDiagnosticsClient.cache is a new REQUIRED member — additive for callers; a structural implementation must add it. The wire shape is in docs/diagnostics-protocol.md.

Storage observations (M98m). The connector serves GET /v1/storage, read through client.storage(): Promise<StorageDiagnosticsResponse> — the same aggregate shape, the same s1…s16 session-local ids and the same more-than-16-refuses-startup rule as the M98i cache operation. At bootstrap it resolves every IStorageDiagnosticsSource registered under CAPABILITIES.STORAGE_DIAGNOSTICS (each StoragePlugin instance contributes one as a multi provider) and refuses to start with more than 16. Each authenticated read calls every source’s synchronous snapshot() once, copies only plain own data properties, and isolates a throwing or malformed source as a value-free collection-failed snapshot; duplicate non-null aliases or a body over 256 KiB collapse the response to collection-failed with no sources. With no source the response is unsupported; a client whose negotiated manifest has storage: false answers that locally without a request. IDiagnosticsClient.storage is a new REQUIRED member — additive for callers; a structural implementation must add it. The wire shape is in docs/diagnostics-protocol.md.

Trace observations (M98g). The connector serves GET /v1/traces?after=<N>&limit=<N> (the same canonical query grammar as /v1/events), read through client.traces(after: number, limit?: number): Promise<TraceDiagnosticsBatch> — same argument bounds as read(). The TelemetryPlugin ALWAYS registers one ITraceDiagnosticsSource under CAPABILITIES.TRACE_DIAGNOSTICS: without its diagnostics option it answers disabled; with the option but a custom tracerProviderFactory or no exporter it answers unsupported with the fixed coverage reason (custom-provider / noop-no-provider); with the option on the built-in OTel provider an additional span processor — appended AFTER the exporter processor in the same provider constructor, never wrapping it, never exporting, never throwing into OTel, and closed only by the provider’s own shutdown — minimizes every finished SAMPLED span to the approved field set before retention. Only exact raw span names listed in TraceDiagnosticsOptions.operations are observed, each replaced by its approved alias (the M98d alias shape: 1–64 UTF-8 bytes, no control characters, unique; at most 128 operations, serviceAlias never derived from the OTel resource). Records carry sequence, the two aliases, W3C-validated trace/span/parent identifiers (all-zero rejected; parentSpanId present exactly when parentVisibility is observed or remote-or-unobserved), at most eight validated link identifier pairs, kind, outcome (ok/error/unset), durationMs, ageMs and parentVisibility — never the span name, attributes, events, resource labels, tracestate, baggage, exceptions or a status message. The batch reports state, coverage, instrumentation (the Node-only families whose registry outcome said enabled), sampler (the configured description or unknown), M98a’s cursor contract over a 1,024-record ring (lost is per batch and exact), and a saturating droppedSpans counter. A parent is an identifier relationship only — no edge is fabricated — and ageMs describes arrival at ONE process, never a global timeline; correlation joins equal trace ids across independently authenticated sessions and grants no discovery authority. A source that throws or fails the exact validator is answered collection-failed; with no source the batch is unsupported with coverage: 'unknown'; a client whose negotiated manifest has traces: false answers that frozen batch, echoing its cursor, without sending the request. IDiagnosticsClient.traces is a new REQUIRED member — additive for callers; a structural implementation of IDiagnosticsClient must add it (the package has not yet been published).

Authorization decision explanations (M98h). The connector serves GET /v1/authorization?after=<N>&limit=<N> (the same canonical query grammar as /v1/events), read through client.authorization(after: number, limit?: number): Promise<AuthorizationDiagnosticsBatch> — same argument bounds as read(). AuthPlugin ALWAYS registers one IAuthorizationDiagnosticsSource under CAPABILITIES.AUTHORIZATION_DIAGNOSTICS: without its authorizationDiagnostics option it answers disabled; with the option it observes the first-party RbacService — the boolean IAuthorizationService surface and every guard’s status, body and short-circuit behaviour are unchanged — and answers no-data/ready while it does. The observer records, per decision, only the approved role and permission aliases (raw names, principal identifiers, request paths and error text are never retained), the per-step outcome, the fixed reason vocabulary, the evaluation count actually performed — a compound that short-circuits reports the steps it really took, never a fabricated full sweep — stepsTruncated (a compound over 16 steps is RETAINED with its real result and the true stepsEvaluated, its step list holding only the first 16), and ageMs. ruleAliases is the set of DISTINCT requested rules in first-requested order (at most 128); stepsEvaluated counts every evaluated occurrence, so a request repeating a rule may report more evaluated steps than it names rules. A decision whose requested rule lacks an approved alias is dropped before buffering and counted in a saturating droppedUnapproved; partial rule lists are never emitted. An unapproved granting role never drops a decision — it is omitted, leaving no viaRoleAlias. The source re-verifies its provider at read time through the new optional IServiceRegistry.isCurrent?(token, instance) identity predicate — non-resolving, never returning a service, never instantiating a lazy factory — and LATCHES unsupported terminal: coverage: 'provider-identity-unavailable' when the registry lacks the predicate, coverage: 'custom-provider' when the current CAPABILITIES.AUTHORIZATION provider is not the exact RbacService instance the plugin created. A direct custom-service decision is never guessed from booleans or status; latching clears retained state and never resumes. A source that throws or fails the exact validator answers collection-failed; a client whose negotiated manifest has authorization: false answers the frozen unsupported batch, echoing its cursor, without sending the request. IDiagnosticsClient.authorization is a new REQUIRED member — additive for callers; a structural implementation of IDiagnosticsClient must add it (the package has not yet been published). IServiceRegistry.isCurrent is OPTIONAL so third-party registry-shaped implementations keep compiling; its absence conservatively disables explanations rather than falling back to a resolving get.

Event dispatch observations (M98j). The connector serves GET /v1/event — a SNAPSHOT operation with no query, unlike the paged kernel-event stream /v1/events (one letter apart; the manifest key events names THIS inspector) — read through client.events(): Promise<EventDiagnosticsResponse>. The EventsPlugin ALWAYS registers one IEventDiagnosticsSource under CAPABILITIES.EVENTS_DIAGNOSTICS as a multi provider (it is an optional dependency of the connector, not a claimed capability, and never appears in the plugin’s provides): without the plugin’s diagnostics option it answers disabled; with the option the collector behind the bus reduces each dispatch to counters BEFORE anything is retained. Only event types whose exact raw name appears in EventsDiagnosticsOptions.events are observed, each replaced by its approved alias (1–64 UTF-8 bytes, no controls, unique, at most 64 entries, own-property lookup); records aggregate per (alias, operation) — publish (every publication, including no-subscriber ones) and handler (each existing handler’s await; async publication completion is not handler completion) — over at most 64 slots with 60-second retention, a 30-second stale threshold and saturating counters; started is counted when a boundary begins and count when it settles, so started - count is work in flight. Observation never changes dispatch: a thrown errorHandler propagates exactly as without diagnostics, and a failing clock latches the source collection-failed without rejecting a publish. Event payloads, event ids, aggregate ids, handler names, unapproved type names and error text never enter a record. At bootstrap the connector reads every registered source in registration order (more than 16 refuses startup with a fixed configuration error), assigns session-local positional sourceId values s1…s16, collapses a body over the 256 KiB budget to the fixed collection-failed response with no sources, answers a throwing or validator-refusing source with its own value-free collection-failed snapshot, and answers duplicate non-null aliases with a fixed collection-failed response listing NO sources; the aggregate state is ready if any source is ready, otherwise collection-failed, stale, no-data, disabled. A client whose negotiated manifest has events: false answers a frozen unsupported response without sending the request. IDiagnosticsClient.events is a new REQUIRED member — additive for callers; a structural implementation of IDiagnosticsClient must add it (the package has not yet been published).

Realtime lifecycle observations (M98l). The connector serves GET /v1/realtime — a SNAPSHOT operation with no query — read through client.realtime(): Promise<RealtimeDiagnosticsResponse>. The WebSocket, SSE and realtime-backplane plugins each ALWAYS register one IRealtimeDiagnosticsSource under CAPABILITIES.REALTIME_DIAGNOSTICS as a multi provider (never in provides): without their diagnostics option it answers disabled, and a 'custom' backplane is never observed. Each snapshot carries a fixed sourceKind. A websocket or sse source counts open, close and send at the connection and reads current gauges (openConnections and groups — rooms or channels) from the plugin’s OWN service on every authenticated read; it is ready whenever those were read, even after every record expired. A backplane source counts backplane-publish (the transport’s publish() resolving — never peer delivery — with its duration) and backplane-receive (after the transport’s own shape and origin filters), has unsupported gauges, and is no-data, ready or stale by record age. backpressureCloses is a number only on an sse close record (SSE backlog-guard closes, 0 when none) and null everywhere else; lastDurationMs is a number only on backplane-publish. Records expire 60 seconds after their last observation. No frame, message, close reason, connection id, room or channel name, header, query, principal, Last-Event-ID or backplane origin enters a record. The connector reads the sources at bootstrap (more than 16 refuses startup), assigns positional s1…s16, answers a throwing or validator-refusing source with a value-free snapshot of kind unknown, collapses duplicate non-null aliases or an over-budget body to a fixed collection-failed response with no sources, and refuses any kind/operation, kind/gauge or backpressure combination outside these rules — and so does the client. A client whose negotiated manifest has realtime: false answers a frozen unsupported response without sending the request. IDiagnosticsClient.realtime is a new REQUIRED member — additive for callers; a structural implementation must add it.

Outbound HTTP attempt observations (M98n). The connector serves GET /v1/outbound-http — a SNAPSHOT operation with no query — read through client.outboundHttp(): Promise<OutboundHttpDiagnosticsResponse>. Sources come from the SDK’s createObservedFetch helper: its plugin registers one IOutboundHttpDiagnosticsSource under CAPABILITIES.OUTBOUND_HTTP_DIAGNOSTICS as a multi provider (never in provides). A source holds at most one record counting started, count, responses and failures, with lastStatusClass ('2xx' | '3xx' | '4xx' | '5xx' | 'other', null until a response), lastDurationMs (time to headers, null until a settlement) and ageMs. The connector reads the sources at bootstrap (more than 16 refuses startup), assigns s1…s16, answers a throwing or invalid source with a value-free collection-failed snapshot, collapses duplicate non-null aliases or an over-budget body to a fixed no-source response, and refuses any snapshot breaking responses + failures === count, count <= started or the state rules — and so does the client. A client whose negotiated manifest has outboundHttp: false answers a frozen unsupported response without sending the request. IDiagnosticsClient.outboundHttp is a new REQUIRED member.

The listener side ships in @setu-ts/common + @setu-ts/runtime: RuntimePlugin provides ILocalDiagnosticsListenerFactory under CAPABILITIES.LOCAL_DIAGNOSTICS_LISTENER (LocalDiagnosticsListenerOptions { port, handler } → ILocalDiagnosticsListener.close()), binding exactly one 127.0.0.1 listener on Deno or Node and rejecting every listen on unsupported platforms before any bind. The full wire specification lives in docs/diagnostics-protocol.md.

SecretsPlugin() (@setu-ts/secrets-plugin)

Provides secret management: registers an ISecretManager under CAPABILITIES.SECRETS, backed by a pluggable provider with a monotonic-clock read-through cache. The default provider is 'env' (zero-dependency, every runtime). No cloud SDK is a hard dependency — each cloud provider accepts an injected client facade or lazily imports its SDK (AI_GUIDELINES §12.2). Secret values are never logged.

Registration

import { SecretsPlugin } from '@setu-ts/secrets-plugin';

// Environment variables (default provider)
app.register(SecretsPlugin());

// HashiCorp Vault (KV v2, over fetch — zero dependency)
app.register(
  SecretsPlugin({
    provider: 'vault',
    options: { address: 'https://vault.example.com', token: vaultToken, mount: 'secret' },
  }),
);

// AWS Secrets Manager (KMS-backed)
app.register(SecretsPlugin({ provider: 'aws-kms', options: { region: 'us-east-1' } }));

provider: 'aws-kms' retrieves named secrets from AWS Secrets Manager, which encrypts values with AWS KMS. KMS alone cannot store/retrieve named secrets by path, so get/rotate go through Secrets Manager.

Usage

import { CAPABILITIES } from '@setu-ts/common';
import type { ISecretManager } from '@setu-ts/common';

const secrets = ctx.services.get<ISecretManager>(CAPABILITIES.SECRETS);
const dbPassword = await secrets.get('database/password'); // env: DATABASE_PASSWORD
const exists = await secrets.has('database/password');
await secrets.rotate('database/password', newPassword); // throws for the env provider

Options

OptionProviderDescription
provider—'env' (default), 'aws-kms', 'gcp', 'azure', 'vault'.
options.cacheTtlallRead-cache TTL in seconds; 0 disables. Default 300.
options.prefixenvPrefix prepended to the derived env key.
options.configFallbackenvDefault true: fall back to string values from CAPABILITIES.CONFIG for missing environment keys. false reads env only. Non-booleans throw at plugin construction naming options.configFallback; other providers ignore it. Absent CONFIG is silent.
options.region / accessKeyId / secretAccessKeyaws-kmsAWS client config (ignored when client injected).
options.endpointaws-kmsLocalStack / emulator / private endpoint for the lazy client (ignored when client injected).
options.projectIdgcpGCP project id for resource paths.
options.endpointgcpPrivate/regional endpoint for the lazy client as host or host:port, no scheme; TLS only (ignored when client injected).
options.vaultUrlazureKey Vault URL.
options.address / token / mountvaultVault server address, token, KV mount (default secret).
options.clientaws-kms/gcp/azureInjected structural client facade (bypasses lazy import).
options.httpvaultInjected fetch-shaped function (defaults to global fetch).
options.requestTimeoutMsvaultBound on one Vault request in ms; 0 disables. Default 5000. A value outside 0–2147483647 throws RangeError at startup.

Exports

  • SecretsPlugin(options?) — plugin factory.

  • SecretsService — the ISecretManager implementation (provider + read cache).

  • EnvProvider, AwsKmsProvider, GcpSecretManagerProvider, AzureKeyVaultProvider, HashiCorpVaultProvider — provider classes.

  • SecretProviderUnavailableError — since M101a, a provider that cannot be reached, answered 503 Service Unavailable through a status hint. HashiCorpVaultProvider rejects with it when a request fails on the network or does not answer inside options.requestTimeoutMs — the body read included, so headers followed by silence count as no answer; the transport error is kept as cause for the log and never reaches the response body. A Vault that answers with an HTTP error is reachable and keeps its handling: 404 reads as null, any other error status rejects with a plain Error. provider names the unreachable provider. Since M101a the provider also percent-encodes each /-separated segment of a secret name and refuses an empty, . or .. segment, or a name longer than 4096 characters once encoded, before sending anything; a read body over 1 MiB is refused with a plain Error, with memory held per read bounded by the same 1 MiB; and a name quoted in any secrets-plugin error message has its C0 and C1 control characters, DEL, and U+2028/U+2029 escaped as \uXXXX, cut after 256 characters.

  • ReadOnlySecretProviderError — the read-only refusal, answered 501 Not Implemented (X20-2). Thrown (as a rejection — never a synchronous throw) by EnvProvider.set, the provider’s only write method; SecretsService.rotate() reaches it by delegating to set, so both public write paths answer identically. Nothing is wrong with the caller — the configured provider cannot store or rotate secrets, permanently — which is what 501 states.

    What set() means per provider — one portable method, four meanings. rotate() delegates to the provider’s set(), so the table below describes both public write paths. The divergence is documented rather than removed: making set() mean one thing is the ROADMAP’s X20-3, a contract question deliberately ungrouped.

    Providerset() on an existing secretset() on a name that does not existVerified against a real backend
    envrejectsrejectsyes — readonly-status.test.ts pins the 501 refusal
    vault (KV v2)writes a new versioncreates the secretyes — real HashiCorp Vault (X20)
    aws-kmswrites a new versionrefuses — PutSecretValueCommand requires the secret to exist; the provider has no create pathyes — real LocalStack (X20 addendum)
    gcpadds a new versionrefuses — addSecretVersion requires the secret container to pre-existno — no local emulator; stated, not claimed
    azurewrites a new versioncreates the secretno — no local emulator; stated, not claimed

    A bootstrap that assumes set() creates is correct for vault and azure, and wrong for aws-kms and gcp. On those two, create the secret out of band before the first set().

  • SecretsServiceOptions, SecretsPluginOptions, SecretsProviderType, SecretsProviderOptions, AwsKmsProviderOptions, GcpSecretManagerProviderOptions, AzureKeyVaultProviderOptions, HashiCorpVaultProviderOptions — option types.

  • IAwsSecretsClient, IGcpSecretsClient, IAzureSecretsClient, IVaultHttp — structural injection types.

  • ISecretManager — re-exported from @setu-ts/common (get / has / rotate).

Health status

Since M90b the indicator reports two signals, and no longer conflates them: the provider’s lifecycle (isReady()) and its reachability. Vault is probed with an unauthenticated request to /v1/sys/health — no secret read, no token; EnvProvider reports its lifecycle truth. The AWS, GCP, and Azure providers publish real reachability ONLY when the injected facade exposes the optional isHealthy() member: a cloud SDK facade with no non-mutating probe is reported reachable: 'unknown' rather than reading a secret as a probe — a read is not a health check and would alter the cache and billing profile of the capability. The probe is cached for 5 seconds and bounded at 2 seconds through createCachedProbe.

StatusMeaning
upThe provider is connected and reachable, or cannot be probed (reachable is 'unknown').
downThe provider is not connected, or is connected but unreachable.

data reports { provider, reachable }, where reachable is true, false, or 'unknown'.

Notes

  • EnvProvider is read-only: rotate() and provider set reject. It reads IRuntimeServices.env first, then the config snapshot for a missing key when options.configFallback is enabled (default true). Both paths accept strings only, including empty strings; environment strings are returned unchanged, including ${NAME} references. Non-string config values are missing, never coerced. Without CONFIG it silently reads only env. The optional CONFIG dependency orders even a replacement config provider before secrets.
  • Direct construction accepts new EnvProvider(env, { prefix?, config? }), where config is Pick<IConfig, 'get'> and is consulted only when the environment lacks a string at the derived key. On Node the environment map is live; Deno and Workers use copies. The service’s normal options.cacheTtl applies to found values from either source; misses are never cached. Provider health remains lifecycle-only.
  • ConfigPlugin’s .env-only keys now resolve through this fallback. A validation schema must declare the secret key or pass it through; a stripped key remains missing. ${NAME} references in config-sourced secrets are expanded by default: use ConfigPlugin({ expandVariables: false }) for literal references in secrets containing $, or supply the secret in the real environment.
  • Workers normally build config from the same string bindings as runtime.env, so the fallback adds no keys and envFilePath requires unavailable filesystem support. An injected ConfigPlugin({ instance }) or another IConfig can supply keys the environment lacks, including the snapshot used by createFullStackAppFromConfig.
  • Secret names use provider-specific path syntax; EnvProvider maps a name to an env key by prepending options.prefix, uppercasing and replacing /, -, . with _ (e.g. database/password → DATABASE_PASSWORD). The same key is used for config.

AuditPlugin() (@setu-ts/audit-plugin)

Provides an immutable audit trail: registers an IAuditLogger under CAPABILITIES.AUDIT, backed by a pluggable storage backend. log() stamps each entry with an internally assigned id (runtime.uuid()) and wall-clock timestamp (runtime.now()), deep-freezes the record (immutability), then appends it to storage. The default backend is 'memory' (zero-dependency, runs on every runtime including Cloudflare Workers) — non-durable, so production should select 'log', 'database', or 'file'. No database driver is a hard dependency: the 'database' backend takes an injected client facade (IAuditDbClient), never the database capability token.

Registration

import { AuditPlugin } from '@setu-ts/audit-plugin';

// In-memory (default — non-durable)
app.register(AuditPlugin());

// Route records through the resolved logger
app.register(AuditPlugin({ storage: 'log', options: { level: 'info' } }));

// Persist to a database via an injected client (inject-only)
app.register(
  AuditPlugin({ storage: 'database', options: { client: myDbClient, table: 'audit_logs' } }),
);

// Append JSONL to a file (Node/Deno/Bun only — requires runtime.fs)
app.register(AuditPlugin({ storage: 'file', options: { path: './audit.log' } }));

Usage

import { CAPABILITIES } from '@setu-ts/common';
import type { IAuditLogger } from '@setu-ts/common';

const audit = ctx.services.get<IAuditLogger>(CAPABILITIES.AUDIT);
await audit.log({
  action: 'user.delete',
  resource: 'user',
  resourceId: '123',
  userId: ctx.request.user?.id,
  result: 'success',
  before: { active: true },
  after: { active: false },
});

IAuditLogger is write-only (like ILogger); AuditEntry is the write shape and carries no id/timestamp. Stored records add both internally and are immutable once written. Retrieval is not part of the public capability this milestone.

Options

OptionBackendDescription
storage—'memory' (default), 'log', 'database', 'file'. Unknown values throw at registration.
options.levellogLogger method to emit at: 'info' (default), 'warn', or 'error'.
options.loggerlogInjected ILogger overriding ctx.logger (throws at registration when neither is present).
options.clientdatabaseInjected IAuditDbClient; required for 'database' (throws when absent).
options.tabledatabaseTable name for insert/select; default 'audit_logs'.
options.pathfileJSONL file path; default './audit.log' (throws at registration when runtime.fs is absent).

Exports

  • AuditPlugin(options?) — plugin factory.
  • AuditService — the IAuditLogger implementation (stamps id/timestamp, deep-freezes, delegates to storage).
  • MemoryAuditStorage, LogAuditStorage, DatabaseAuditStorage, FileAuditStorage — storage backend classes (exported for direct construction/injection).
  • AuditPluginOptions, AuditStorageType, AuditStorageOptions — option types.
  • IAuditDbClient — structural database client facade for the 'database' backend (insert(table, row) / select(table, criteria?)).
  • IAuditLogger, AuditEntry — re-exported from @setu-ts/common.
  • StoredAuditEntry, AuditQuery — the return and parameter types of the exported storage classes’ query members. Exported so those signatures are nameable by a consumer (the M52c NormalizedQuery lesson); the read path belongs to the storages, not to IAuditLogger.

Notes

  • The 'memory' backend is non-durable (in-process array, lost on restart). It is the zero-dependency default so audit works on every runtime out of the box; select a durable backend for production.
  • The 'log' backend’s query() returns [] — the log sink is the durable trail; read audit records through the logging backend, not this object.
  • The 'file' backend uses read-modify-write over runtime.fs (the committed IFileSystem has no native append) and serializes concurrent appends; on shutdown the plugin’s onClose drains any in-flight write. The target file’s parent directory is created recursively on first write, so a path in a not-yet-existing directory does not fail with ENOENT.
  • The 'database' backend delegates equality filtering (action/resource/result/userId/ resourceId) to the injected client’s select WHERE; time-range (from/to), ordering, and limit are applied in-process.

CQRS (@setu-ts/cqrs-plugin)

Provides command/query separation with buses.

Registration

import { CqrsPlugin } from '@setu-ts/cqrs-plugin';
import type { CqrsRequest, IPipelineBehavior } from '@setu-ts/common';

// Example behavior implementations
const loggingBehavior: IPipelineBehavior = {
  handle: async (request: CqrsRequest, next: () => Promise<unknown>) => {
    console.log(`Executing ${request.type}`);
    const result = await next();
    console.log(`Completed ${request.type}`);
    return result;
  },
};

const timingBehavior: IPipelineBehavior = {
  handle: async (request: CqrsRequest, next: () => Promise<unknown>) => {
    const start = Date.now();
    const result = await next();
    console.log(`${request.type} took ${Date.now() - start}ms`);
    return result;
  },
};

app.register(
  CqrsPlugin({
    behaviors: [loggingBehavior, timingBehavior],
  }),
);

Defining Commands and Queries

import type { CqrsCommand, CqrsQuery } from '@setu-ts/cqrs-plugin';

// A request is routed by its string `type`; `data` carries the payload.
// Both a class instance (below) and a plain `{ type, data }` object satisfy the contract.
class CreateUserCommand implements CqrsCommand<{ name: string; email: string }> {
  readonly type = 'CreateUserCommand';
  constructor(public readonly data: { name: string; email: string }) {}
}

class GetUserQuery implements CqrsQuery<{ id: string }> {
  readonly type = 'GetUserQuery';
  constructor(public readonly data: { id: string }) {}
}

Implementing Handlers

import type { ICommandHandler, IQueryHandler } from '@setu-ts/cqrs-plugin';

class CreateUserHandler implements ICommandHandler<CreateUserCommand, string> {
  constructor(private db: IDatabaseService) {}

  async handle(command: CreateUserCommand): Promise<string> {
    const user = await this.db.getRepository<User>('User').create(command.data);
    return user.id;
  }
}

class GetUserHandler implements IQueryHandler<GetUserQuery, User> {
  constructor(private db: IDatabaseService) {}

  async handle(query: GetUserQuery): Promise<User> {
    const user = await this.db.getRepository<User>('User').findById(query.data.id);
    if (!user) throw new Error('User not found');
    return user;
  }
}

Registering Handlers

Two ways, and both end at the same commandBus.register(type, handler) call.

Declaratively, through the plugin’s options — which is what a handler needing no constructor dependencies wants, and what setu generate command-handler wires for you:

import type { CommandHandlerRegistration, QueryHandlerRegistration } from '@setu-ts/cqrs-plugin';

const commandHandlers: readonly CommandHandlerRegistration[] = [
  { type: 'CreateUserCommand', handler: new CreateUserHandler() },
  // Or a factory that builds the handler from the service registry:
  { type: 'PlaceOrderCommand', handler: createPlaceOrderCommandHandler },
];
const queryHandlers: readonly QueryHandlerRegistration[] = [
  { type: 'GetUserQuery', handler: new GetUserHandler() },
];

app.register(CqrsPlugin({ commandHandlers, queryHandlers }));

Each entry is a { type, handler } pair because that is exactly what the bus takes — the type is a string the request carries, not something derivable from the handler’s class. The handler accepts an instance or a RegistryFactory ((services: IServiceRegistry) => handler), and behaviors accepts the same instance | RegistryFactory<IPipelineBehavior> union. Factories are called at the onInit phase — the first phase at which the registry holds every capability — so a factory can resolve any capability (the event bus, a database, the logger) and build the handler or behavior with it. A factory that throws rejects start(), naming the option and the entry, with the original error preserved as cause. Instance entries keep their register() timing byte-identically.

Imperatively, from a plugin — the route to take when a handler needs a capability, since the buses only exist once CqrsPlugin has registered and IApplication exposes no lifecycle hook:

app.register({
  name: 'user-handlers',
  version: '1.0.0',
  dependencies: ['cqrs', 'database'],
  register(ctx) {
    const commandBus = ctx.services.get<ICommandBus>('command-bus');
    const queryBus = ctx.services.get<IQueryBus>('query-bus');
    const db = ctx.services.get<IDatabaseService>('database');

    commandBus.register('CreateUserCommand', new CreateUserHandler(db));
    queryBus.register('GetUserQuery', new GetUserHandler(db));
  },
});

Using in Routes

app.router.post('/users', async (ctx) => {
  const commandBus = ctx.services.get<ICommandBus>('command-bus');
  const userId = await commandBus.execute<string>(new CreateUserCommand(ctx.request.body));
  return ctx.response.status(201).json({ id: userId });
});

app.router.get('/users/:id', async (ctx) => {
  const queryBus = ctx.services.get<IQueryBus>('query-bus');
  const user = await queryBus.execute<User>(new GetUserQuery({ id: ctx.params.id }));
  return ctx.response.json(user);
});

Messaging (@setu-ts/messaging-plugin)

Provides message broker abstraction for cross-service integration events.

Registration

import { MessagingPlugin } from '@setu-ts/messaging-plugin';

// In-memory broker (for development/testing)
app.register(
  MessagingPlugin({
    broker: 'memory',
  }),
);

// Redis Streams broker
app.register(
  MessagingPlugin({
    broker: 'redis-streams',
    url: config.get('REDIS_URL'),
    defaultQueue: 'myapp-events',
  }),
);

Connection errors. The 'redis-streams' broker routes the 'error' events of the ioredis client it builds to the application logger through createConnectionErrorReporter (common): the first error of an outage at warn, identical repeats at debug, the recovery at info. Without a listener ioredis prints every reconnect failure to console.error, bypassing the logger and redaction. An injected client gets no listener — it belongs to the caller. Health semantics are unchanged; the indicator still reports the outage.

RabbitMQ consumer recovery (0.9.0). Durable consumer groups retry transient handler failures through durable quorum Q.retry.<delay>ms queues (queue-level TTL, default-exchange DLX back to Q, at-least-once dead-lettering with reject-publish overflow, so a copy is held rather than dropped while Q cannot take it). Delays must be nonempty, nondecreasing positive integer milliseconds ≤2147483647. Total attempts and dead-letter cap must be positive safe integers. Invalid options throw RangeError at construction. Deserialize failures and IntegrationEventRejectedError dead-letter immediately; then isRetryable runs (false dead-letters, throwing logs and retries), then the attempt budget applies. The original is acked after the persistent retry/dead-letter publish is accepted on the same captured channel. Disposition failure leaves it unacked, closes that channel (returning it to Q) and starts the reconnect-and-replay recovery, which re-declares the helper queues; a late confirm or a crash between publish and ack can duplicate it. Handlers must be idempotent. Q.dead carries preserved properties/headers (less expiration, userId, CC and BCC, which the broker would act on) plus x-setu-attempts, x-setu-topic, and a 1 KiB UTF-8 x-setu-error description. Changing the cap requires draining and deleting Q.dead. Private exclusive queues and RPC reply inboxes retain nack-and-discard. consumerRetry: false retains the operator-DLX path. Prefetch is re-applied on reconnect, which re-declares retry and dead queues before consuming. Retry/dead copies are mandatory; correlated basic.return rejects disposition despite a positive confirm. The framework replaces x-setu-disposition-id per copy, preserving the message ID. Recovery requires confirm channels with on/off return listeners and close(), including injected clients: subscribe() refuses a retrying consumer group without them, before declaring anything, rather than letting every failure stay unacked until prefetch stalls the consumer. With retries enabled, group names ending in .dead or .retry.<digits>ms are reserved, and helper names must fit 255 UTF-8 bytes. Names are validated before declaration (at construction for declarative instances). Rename conflicting groups or set consumerRetry: false; migrate existing queues occupying helper names before enabling retries.

Redis Streams recovery (0.9.0). Requires Redis ≥6.2 (XPENDING IDLE). Failed messages redeliver, including after restart, so handlers must be idempotent. The first retry delay must exceed the longest handler runtime: it also guards against another replica stealing in-flight work. Reclaim atomically claims up to ten idle pending entries per pass; delivery metadata and acknowledgement share the new-entry path. Deserialize/missing-payload failures and IntegrationEventRejectedError dead-letter immediately. Other failures use the classifier and total delivery budget. Dead-letter XADD MAXLEN ~ to <topic>.dead.<group> precedes source XACK, with original fields plus x-setu-source-id and x-setu-deliveries; crashes between writes can duplicate entries. Pending consumers survive shutdown, while empty consumers are deleted and old foreign empty consumers are swept.

All counts are positive safe integers; all new millisecond options are positive integers ≤2147483647. Empty/decreasing delay arrays and invalid numeric values throw RangeError at construction. No consumerRetry: false arm exists. Injected IRedisStreamsClient facades require xpending(...args) returning [id, owner, idleMs, deliveries][], xclaim(...args) returning ([id, fields] | null)[], and xinfo(...args) returning consumer field/value arrays, plus xgroup accepting DELCONSUMER and returning a number for that command. Redis 6.2 null claims of trimmed entries are acknowledged; Redis 7 removes them server-side. Empty race-loser claims are never acknowledged. See the messaging README for defaults, retention, ordering, and command cost.

Plugin Options

MessagingPluginOptions is a discriminated union keyed on broker — exactly mirroring packages/messaging-plugin/src/interfaces/index.ts, which is the source of truth. Each arm carries the fields the source defines; the cloud brokers split into an injected-transport arm (no production credentials) and a production arm (credentials required, client typed never). The shared MessagingBrokerType has eight literals.

type MessagingBrokerType =
  'memory' | 'redis-streams' | 'rabbitmq' | 'nats' | 'kafka' | 'pubsub' | 'service-bus' | 'custom';

/** Present on every arm. */
interface MessagingCommonOptions {
  /** Instance name for multi-instance support (registers under messaging.<name>). */
  name?: string;
  /** Serializer for message payloads. @defaultValue new JsonSerializer() */
  serializer?: ISerializer;
  /** Create producer and consumer spans when telemetry is registered. @defaultValue true */
  tracing?: boolean;
}

// ── Default (in-memory). `broker` is optional so MessagingPlugin() and {} are valid. ──
interface MemoryMessagingOptions extends MessagingCommonOptions {
  broker?: 'memory';
}

// version:history
/** Shared retry shape; defaults and lease semantics depend on the broker. @since 0.9.0 */
interface ConsumerRetryOptions {
  readonly maxAttempts?: number;
  readonly delaysMs?: readonly number[];
  readonly isRetryable?: (error: unknown) => boolean;
}

// ── Redis Streams ────────────────────────────────────────────────────────────────────
interface RedisStreamsMessagingOptions extends MessagingCommonOptions {
  broker: 'redis-streams';
  /** Connection URL. */
  url?: string;
  /** Injected client — bypasses the lazy npm import. */
  client?: IRedisStreamsClient;
  /** Default consumer group / queue name. */
  defaultQueue?: string;
  /** XREADGROUP poll interval in ms. */
  pollIntervalMs?: number;
  /** XREADGROUP block timeout in ms. */
  blockSizeMs?: number;
  /** Total delivery budget, including initial delivery. Default 5. */
  consumerRetry?: ConsumerRetryOptions;
  /** Pending reclaim interval, default 5000 ms. */
  reclaimIntervalMs?: number;
  /** Approximate per-group dead-letter MAXLEN, default 10000. */
  deadLetterMaxLen?: number;
  /** Foreign empty consumer inactivity threshold, default 3600000 ms. */
  consumerIdleSweepMs?: number;
  /**
   * Publish acknowledgement bound in ms, from 0 through 2147483647. Zero disables the bound;
   * an expired publish may still be delivered. Consumer reads are unaffected.
   * @defaultValue 15000 @since 0.11.0
   */
  publishTimeoutMs?: number;
}

// ── RabbitMQ ─────────────────────────────────────────────────────────────────────────
interface RabbitMqMessagingOptions extends MessagingCommonOptions {
  // version:history
  /** Group retry policy, default 5 attempts and [5000, 30000, 120000, 600000] ms. @since 0.9.0 */
  consumerRetry?: false | ConsumerRetryOptions;
  // version:history
  /** Q.dead retention cap, default 10000; changing requires draining/deleting Q.dead. @since 0.9.0 */
  deadLetterMaxLength?: number;
  // version:history
  /** Unacked delivery limit per consumer, integer 1–65535, default 32. @since 0.9.0 */
  prefetch?: number;
  broker: 'rabbitmq';
  /** AMQP connection URL. */
  url?: string;
  /** Injected AMQP connection. */
  client?: IAmqpConnection;
  /**
   * Topic exchange name, at most 255 UTF-8 bytes (an AMQP short string; longer
   * is refused at construction). @defaultValue 'messaging'
   */
  exchangeName?: string;
  /**
   * Default consumer group / queue name. A private queue is named
   * `<defaultQueue>-<uuid>`, so it must be at most 218 UTF-8 bytes; longer is
   * refused at construction.
   */
  defaultQueue?: string;
  // version:history
  /**
   * Publish every message persistent (`delivery_mode` 2), so it survives a broker restart.
   * `false` restores the pre-0.9.0 transient publishes. @defaultValue true @since 0.9.0
   */
  persistentMessages?: boolean;
  // version:history
  /**
   * Bound on one `publish()` in ms — every broker round trip, including the publisher confirm.
   * `0` disables it; outside `0`–`2147483647` throws `RangeError` at `MessagingPlugin(...)`.
   * @defaultValue 15000 @since 0.9.0
   */
  publishTimeoutMs?: number;
}

// ── NATS (JetStream) ─────────────────────────────────────────────────────────────────
// version:history — the `@since` below records when the member appeared, not the
// current release; an `@since` is never bumped.
interface NatsMessagingOptions extends MessagingCommonOptions {
  /**
   * Publish acknowledgement bound in ms, from 0 through 2147483647. Zero disables the bound;
   * an expired publish may still be delivered. Passed to the client as its own publish
   * `timeout` and held by the framework's deadline as well; consumer reads are unaffected.
   * @defaultValue 15000 @since 0.11.0
   */
  publishTimeoutMs?: number;
  /** Lazy connection only: defaults -1 (forever) / 2000 ms. @since 0.10.0 */
  reconnect?: { maxAttempts?: number; waitMs?: number };
  broker: 'nats';
  /** NATS connection URL. */
  url?: string;
  /** Injected NATS connection. */
  client?: INatsConnection;
  /** Header factory required for propagation on an injected NATS connection. */
  headersFactory?: () => INatsHeaders;
  /** JetStream stream name. @defaultValue 'MESSAGING' */
  streamName?: string;
  /**
   * Subjects the broker may create the stream with when the stream is absent.
   * No default and no catch-all: NATS refuses `subjects: ['>']` without
   * `no_ack`, and `no_ack` makes every JetStream publish reject unobserved
   * (M90d / X28-2). Supplied and the stream is absent → the broker creates it
   * with exactly these subjects. Absent and the stream is absent → startup
   * rejects with `JetStreamStreamError`, naming the stream and both remedies.
   * An existing stream is never touched either way. An RPC-capable stream must
   * also cover `rr.req.<topic>` and `rr.inbox.>` (M101b); NATS refuses two
   * streams with overlapping subjects, so one stream per account owns
   * `rr.inbox.>`.
   * @since 0.5.0
   */
  streamSubjects?: readonly string[];
  /**
   * Shared queue-less group prefix; the group is `<defaultQueue>.<topic>`.
   * A newly created group starts at new messages; later subscribers resume it.
   * An explicit queue becomes the JetStream durable consumer name with each
   * character the nats client refuses (`.`, `*`, `>`, `/`, `\`, whitespace)
   * escaped as `_` + two hex digits — `orders.eu` → `orders_2eeu` (M101b). A
   * queue whose encoding collides with another queue's, or that is reused
   * across topics, rejects `subscribe()` with
   * `NatsConsumerNameCollisionError`. The raw queue is recorded as consumer
   * metadata, which needs NATS 2.10+.
   * @defaultValue 'messaging-consumers'
   */
  defaultQueue?: string;
}

// ── Kafka ────────────────────────────────────────────────────────────────────────────
interface KafkaMessagingOptions extends MessagingCommonOptions {
  // version:history
  /** Publish bound in ms; default 15000, 0 unbounded. Timeout may still deliver. @since 0.10.0 */
  publishTimeoutMs?: number;

  broker: 'kafka';
  /** Kafka bootstrap brokers. */
  brokers?: readonly string[];
  /** Injected Kafka client factory. */
  client?: IKafkaFactory;
  /** Kafka client ID. @defaultValue 'messaging-client' */
  clientId?: string;
  /**
   * Prefix of the derived default consumer group. Since M90d a subscription
   * with no `queue` uses `<defaultQueue>:<topic>`: members of one consumer
   * group must subscribe the same topics, so the previously shared group
   * collapsed to empty assignments (nothing delivered) the moment an
   * application subscribed two topics. Same topic across instances still
   * load-balances; a caller-supplied `queue` names the group itself.
   * @defaultValue 'messaging-consumers'
   */
  defaultQueue?: string;
  /** Request-reply topic; must already exist on the broker. @defaultValue 'messaging.replies' */
  replyTopic?: string;
  // version:history
  /**
   * Retry budget (M101b). Forwarded to `new Kafka({ retry })` unless `client` is
   * injected, and read by `subscribe()` to retry `UNKNOWN_TOPIC_OR_PARTITION` —
   * which a KRaft broker answers to the request that auto-creates a topic, and
   * which kafkajs does not retry. Exponential: `retries` after the first attempt,
   * from `initialRetryTime`, growing by `multiplier` up to `maxRetryTime`
   * (kafkajs's defaults: 5 / 300 ms / 2 / 30 000 ms). Still unknown after the
   * budget → `KafkaTopicUnavailableError`. Every field is validated at
   * construction, `factor` (kafkajs's jitter) included, held to [0, 1].
   * @since 0.9.0
   */
  retry?: {
    maxRetryTime?: number;
    initialRetryTime?: number;
    /** kafkajs's jitter, between 0 and 1. */
    factor?: number;
    multiplier?: number;
    retries?: number;
  };
}

// ── GCP Pub/Sub — injected transport (client required; credentials optional) ─────────
interface PubSubMessagingOptionsInjected extends MessagingCommonOptions {
  // version:history
  /** Publish bound in ms; default 15000, 0 unbounded. Timeout may still deliver. @since 0.10.0 */
  publishTimeoutMs?: number;

  broker: 'pubsub';
  /** Injected transport (bypasses the lazy SDK load). Required for this arm. */
  client: IPubSubTransport;
  /** GCP project ID. Optional when client is injected. */
  projectId?: string;
  /** Service-account credentials. Optional when client is injected. */
  credentials?: unknown;
  /**
   * Prefix of the per-topic default subscription. Since M101b a subscription with
   * no `queue` uses `<defaultQueue>.<topic ID>` (a fully-qualified topic name
   * contributes only its ID): Pub/Sub subscription names are
   * project-global, so the previously shared name attached a second topic to the
   * first topic's subscription. An existing subscription bound to another topic is
   * refused with `PubSubSubscriptionBoundElsewhereError`; a name over 255
   * characters is refused at `subscribe()`.
   * @defaultValue 'messaging-consumers'
   */
  defaultQueue?: string;
  replyTopic?: string;
  /**
   * Refused on this arm (compile error, and `RangeError` from a standalone
   * `GcpPubSubBroker`): the injected transport creates its own subscriptions, so
   * the broker cannot switch ordering on. Pass `enableMessageOrdering` to
   * `adaptPubSubModule` when building the transport instead.
   */
  enableMessageOrdering?: never;
}

// ── GCP Pub/Sub — production (projectId required; client must be omitted) ────────────
interface PubSubMessagingOptionsProduction extends MessagingCommonOptions {
  // version:history
  /** Publish bound in ms; default 15000, 0 unbounded. Timeout may still deliver. @since 0.10.0 */
  publishTimeoutMs?: number;

  broker: 'pubsub';
  /** GCP project ID. Required for production. */
  projectId: string;
  /** Service-account credentials. SDK ADC is used when omitted. */
  credentials?: unknown;
  /** Mutually exclusive with the injected arm — client?: never. */
  client?: never;
  /** Per-topic default subscription prefix — see the injected arm. @defaultValue 'messaging-consumers' */
  defaultQueue?: string;
  replyTopic?: string;
  /**
   * Create the broker's own subscriptions with message ordering (M106), which a
   * native `orderingKey` needs to be delivered in order. Fixed when a
   * subscription is created and it costs throughput, so it is opt-in.
   * @defaultValue false
   */
  enableMessageOrdering?: boolean;
}

/** GCP Pub/Sub options — exclusive union of injected and production arms. */
type PubSubMessagingOptions = PubSubMessagingOptionsInjected | PubSubMessagingOptionsProduction;

// ── Azure Service Bus — injected transport (client required; credentials optional) ───
interface ServiceBusMessagingOptionsInjected extends MessagingCommonOptions {
  broker: 'service-bus';
  /** Injected transport (bypasses the lazy SDK load). Required for this arm. */
  client: IServiceBusTransport;
  /** Connection string. Optional when client is injected. */
  connectionString?: string;
  /** Administration connection string. Optional when client is injected. */
  adminConnectionString?: string;
  defaultQueue?: string;
  replyTopic?: string;
}

// ── Azure Service Bus — production (connectionString required; client must be omitted) ─
interface ServiceBusMessagingOptionsProduction extends MessagingCommonOptions {
  broker: 'service-bus';
  /** Connection string for the Service Bus namespace. Required for production. */
  connectionString: string;
  /** Connection string for the administration client. Defaults to connectionString. */
  adminConnectionString?: string;
  /** Mutually exclusive with the injected arm — client?: never. */
  client?: never;
  defaultQueue?: string;
  replyTopic?: string;
  /** SDK retry budget for the data client. Optional on this arm only (M90b / X28-6). */
  retryOptions?: ServiceBusRetryOptions;
}

/** Azure Service Bus SDK retry budget — passed ONLY to `ServiceBusClient`, never to the
 * administration client. `mode` is translated to the SDK's numeric `RetryMode` before the
 * client is constructed (the SDK compares the value with `===` against its enum). Omission
 * preserves the Azure SDK default (`maxRetries: 3`, `retryDelayInMs: 30000`,
 * `maxRetryDelayInMs: 90000`, `mode: 'fixed'`, `timeoutInMs: 60000`); `maxRetries: 0` is the
 * documented short budget for a deployment that must fail fast toward a dead broker rather
 * than hold a request for the default chain.
 */
interface ServiceBusRetryOptions {
  maxRetries?: number;
  retryDelayInMs?: number;
  maxRetryDelayInMs?: number;
  mode?: 'fixed' | 'exponential';
  timeoutInMs?: number;
}

ServiceBusOptions — the exported options type of a directly constructed ServiceBusBroker — carries one more member (since M95b): dataPlaneEvidenceMs?: number, how long a positive data-plane outcome stays authoritative. Default 5000. A negative outcome remains authoritative until a successful publish or a positive management probe contradicts it; elapsed time alone never reports a known outage as healthy. It is a broker option rather than a plugin-wide one because no other broker has two planes to choose between; the plugin arm does not forward it.

It must be a safe positive integer, and the constructor REFUSES anything else rather than accepting a value that reads as configured while the window misbehaves. NaN — what Number(env.X) yields for an unset or misspelled variable — would freeze the positive-evidence window, pinning reachability() at one boot-time successful publish; 0 or a negative value would discard every positive outcome instantly. The option therefore has no disable arm: omit it for the default.

/** Azure Service Bus options — exclusive union of injected and production arms. */
type ServiceBusMessagingOptions =
  ServiceBusMessagingOptionsInjected | ServiceBusMessagingOptionsProduction;

// ── Custom — inject any IMessageBroker implementation ────────────────────────────────
interface CustomMessagingOptions extends MessagingCommonOptions {
  broker: 'custom';
  /** Pre-built IMessageBroker instance. Required for this arm. */
  instance: IMessageBroker;
}

/** The factory's single options parameter. */
type MessagingPluginOptions =
  | MemoryMessagingOptions
  | RedisStreamsMessagingOptions
  | RabbitMqMessagingOptions
  | NatsMessagingOptions
  | KafkaMessagingOptions
  | PubSubMessagingOptions
  | ServiceBusMessagingOptions
  | CustomMessagingOptions;

Broker-specific logger options remain string sinks ({ error: (message: string) => void }). When a broker catches a failure it renders the error name, message, safe classifier fields, bounded cause chain, and bounded aggregate members into that string; no structured driver object is passed to the application callback.

Every MessagingPluginOptions arm also inherits these ingress-registration options:

OptionTypeBehavior
subscriptionsreadonly SubscriptionEntry[]Declarative subscribe() calls. A SubscriptionDefinition is { topic, handler, options? }; factory entries resolve during async onInit, so subscriptions are established before the application serves. When a behaviors factory is declared, DELIVERY is held until onInit has resolved the chain, so no message reaches a handler through a partial one.
behaviorsreadonly (IIngressBehavior | RegistryFactory<IIngressBehavior>)[]Wraps subscribe handlers in declared order with kind: 'messaging', topic, message payload, and available headers. No delivery attempt is invented. With no behaviours, no decorator is applied.
chainReadyTimeoutMsnumberBounds a dispatch held on the behaviour-chain gate (armed only when a behaviors FACTORY is declared). A held dispatch past the bound rejects with ChainGateTimeoutError, whose message names register(); the gate itself stays, so later dispatches refuse the same way rather than delivering through a partial chain. Default 10_000; 0 waits forever. Must be finite, non-negative, and no greater than 2_147_483_647 — NaN, a negative value, Infinity, or a larger value throws a RangeError naming the option at registration. Ignored when no factory is configured, because the gate is then never armed.

The chain deliberately does not wrap or add a registration arm for respond(): its handler returns a value, unlike the void-returning subscribe handlers, and remains forwarded unchanged.

Publish timing. publish resolves once every matching subscription’s work item has been handed to dispatch — never once every handler has returned — the same guarantee real brokers give, so a plugin publishing during its own register() cannot deadlock startup against the gate. A handler that rejects never rejects the publish and never becomes an unhandled rejection: on the in-memory broker, which has no ack model and no redelivery, the failure path terminates in a report through the application’s logger. InMemoryBrokerOptions.onDispatchError supplies that reporter for applications constructing InMemoryBroker directly (the plugin always supplies one); absent it, the rejection is observed and dropped. A supplied reporter may return a promise; synchronous throws and asynchronous rejections from it are swallowed as the failure-path terminus.

Cloud brokers require production credentials OR an injected transport:

// GCP Pub/Sub — production (requires projectId)
app.register(
  MessagingPlugin({
    broker: 'pubsub',
    projectId: 'my-gcp-project',
  }),
);

// GCP Pub/Sub — injected transport
app.register(
  MessagingPlugin({
    broker: 'pubsub',
    client: myPubSubTransport,
  }),
);

// Azure Service Bus — production (requires connectionString)
app.register(
  MessagingPlugin({
    broker: 'service-bus',
    connectionString: 'Endpoint=sb://...',
  }),
);

// Azure Service Bus — injected transport
app.register(
  MessagingPlugin({
    broker: 'service-bus',
    client: myServiceBusTransport,
  }),
);

Publishing Messages

import { CAPABILITIES } from '@setu-ts/common';

app.router.post('/orders', async (ctx) => {
  const broker = ctx.services.get<IMessageBroker>(CAPABILITIES.MESSAGING);
  const order = await createOrder(ctx.request.body);

  await broker.publish('order.created', {
    orderId: order.id,
    total: order.total,
    customerId: order.customerId,
  });

  return ctx.response.status(201).json(order);
});

Subscribing to Messages

app.register({
  name: 'order-processor',
  version: '1.0.0',
  dependencies: [CAPABILITIES.MESSAGING],
  register(ctx) {
    const broker = ctx.services.get<IMessageBroker>(CAPABILITIES.MESSAGING);

    broker.subscribe(
      'order.created',
      async (message, metadata) => {
        console.log('Processing order', message.orderId);
        await processOrder(message);
      },
      {
        queue: 'order-processor',
      },
    );
  },
});

Request-Reply (RPC)

Beyond fire-and-forget publish/subscribe, the broker supports brokered request-reply: a caller sends a request and awaits a single correlated reply. A responder registered with respond returns the reply value; correlation, the reply channel, and timeout are handled internally.

// Responder side — its resolved value is returned to the caller.
const broker = ctx.services.get<IMessageBroker>(CAPABILITIES.MESSAGING);
await broker.respond<{ userId: string }, { name: string }>('user.lookup', async (req) => {
  const user = await users.findById(req.userId);
  return { name: user.name };
});

// Caller side — resolves with the responder's reply.
const reply = await broker.request<{ userId: string }, { name: string }>(
  'user.lookup',
  { userId: '42' },
  { timeoutMs: 3000 }, // defaults to 5000 when omitted
);

Signatures (on IMessageBroker, from @setu-ts/common):

interface RequestOptions {
  /** Reply wait budget in ms. @defaultValue 5000 */
  readonly timeoutMs?: number;
}

type RequestHandler<TReq = unknown, TRes = unknown> = (
  message: TReq,
  metadata: MessageMetadata,
) => TRes | Promise<TRes>;

request<TReq, TRes>(topic: string, message: TReq, options?: RequestOptions): Promise<TRes>;
respond<TReq, TRes>(
  topic: string,
  handler: RequestHandler<TReq, TRes>,
  options?: SubscribeOptions,
): Promise<ISubscription>;

Pass options.queue to respond to load-balance requests across competing responders.

request rejects with one of four exported error classes (import from @setu-ts/messaging-plugin for instanceof handling):

ErrorThrown when
RequestTimeoutErrorNo reply arrived within timeoutMs.
RemoteHandlerErrorThe responder threw; .remoteMessage carries the remote message.
MessagingNotSupportedErrorDeprecated — no broker throws this. Retained for instanceof compatibility.
PublishTimeoutErrorThe request’s publish outlived publishTimeoutMs; the message may still be delivered. Carries topic and timeoutMs.

Two more error classes are exported for the NATS broker’s startup prerequisites (M90d / X28-3). Each carries the platform error as cause when there is one — the absent-stream refusal below is a configuration verdict this broker reaches on its own, so that one arm has no cause:

ErrorThrown when
JetStreamUnavailableErrorconnect() finds no JetStream on the server — start it with the -js flag. The raw 503 the server answered is the cause.
JetStreamStreamErrorThe stream is absent and streamSubjects was not supplied (the message names both remedies), or the server refused the stream read/create (cause carries it).

Three more name failures the real brokers impose and a permissive fake accepts (M101b). Each is thrown by subscribe(), so a declared subscription rejects start() with it:

ErrorThrown when
PubSubSubscriptionBoundElsewhereErrorThe Pub/Sub subscription already exists and is bound to another topic. Carries subscription, boundTopic (fully qualified, or '(unknown)' when the service reported none) and requestedTopic.
NatsConsumerNameCollisionErrorThe queue’s JetStream consumer name already belongs to a different queue (an encoding collision such as orders.eu / orders_2eeu) or a different topic. Carries queue, existingQueue and consumerName.
KafkaTopicUnavailableErrorThe Kafka broker still reports the topic unknown after the retry budget — it does not auto-create. Carries topic and groupId; the kafkajs UNKNOWN_TOPIC_OR_PARTITION error is the cause. Pre-create the topic.

Broker support. Request-reply is available on all supported broker types — in-memory, Redis Streams, RabbitMQ, NATS, Kafka, GCP Pub/Sub, Azure Service Bus, and custom (which delegates to the injected IMessageBroker).

Kafka has one operational prerequisite. Replies travel on a shared reply topic (replyTopic, default 'messaging.replies') which must already exist — the broker creates no topics, so either pre-create it or enable auto.create.topics.enable; the same holds for every subscribed topic, whose absence on a non-auto-creating broker rejects with KafkaTopicUnavailableError. Each broker instance reads that topic under its own consumer group, so every instance receives every reply and discards those it did not originate; give a high-traffic service its own replyTopic to bound that fan-out.

RPC and pub/sub are separate channels. request/respond travel on a channel derived from the topic, not on the topic itself. A plain subscribe('orders', …) therefore never observes an RPC request, and a plain publish('orders', …) is never consumed by a responder on 'orders'. The two can share a topic name safely.

Multiple Broker Instances

import { CAPABILITIES } from '@setu-ts/common';

app.register(
  MessagingPlugin({
    broker: 'redis-streams',
    name: 'events',
    url: config.get('EVENTS_REDIS_URL'),
    defaultQueue: 'events',
  }),
);

app.register(
  MessagingPlugin({
    broker: 'redis-streams',
    name: 'audit',
    url: config.get('AUDIT_REDIS_URL'),
    defaultQueue: 'audit',
  }),
);

// Access by namespaced token
const eventsBroker = ctx.services.get<IMessageBroker>('messaging.events');
const auditBroker = ctx.services.get<IMessageBroker>('messaging.audit');

Events Messaging Bridge

The EventsMessagingBridge forwards domain events from EventsPlugin to a messaging broker:

import { EventsMessagingBridge } from '@setu-ts/messaging-plugin';

app.register(
  EventsMessagingBridge({
    eventTypes: ['user.created', 'user.updated'],
    token: CAPABILITIES.MESSAGING,
    topicMapping: (eventType) => eventType.toLowerCase(),
    errorHandler: (error, eventType) => {
      console.error(`Failed to forward ${eventType}:`, error);
    },
  }),
);

Exports

// Plugin factories
export { MessagingPlugin } from '@setu-ts/messaging-plugin';
export { EventsMessagingBridge } from '@setu-ts/messaging-plugin';

// Broker implementations
export { InMemoryBroker } from '@setu-ts/messaging-plugin';
export { RedisStreamsBroker } from '@setu-ts/messaging-plugin';
export { RabbitMqBroker } from '@setu-ts/messaging-plugin';
export { NatsBroker } from '@setu-ts/messaging-plugin';
export { KafkaBroker } from '@setu-ts/messaging-plugin';
export { GcpPubSubBroker } from '@setu-ts/messaging-plugin';
export { ServiceBusBroker } from '@setu-ts/messaging-plugin';

// Adapter / load helpers. The `*SdkModule` types describe the SDK shape each
// `adapt*` consumes, so a consumer can type a substitute module.
export { adaptPubSubModule, loadPubSubModule } from '@setu-ts/messaging-plugin';
export { adaptServiceBusModule, loadServiceBusModule } from '@setu-ts/messaging-plugin';
export type { PubSubSdkModule, ServiceBusSdkModule } from '@setu-ts/messaging-plugin';

// Serializer
export { JsonSerializer } from '@setu-ts/messaging-plugin';
export type { ISerializer } from '@setu-ts/messaging-plugin';

// Request-reply, gate, publish-timeout, and NATS-prerequisite error classes
export {
  ChainGateTimeoutError,
  CloudBrokerUnavailableError,
  JetStreamStreamError,
  JetStreamUnavailableError,
  KafkaTopicUnavailableError,
  MessagingNotSupportedError,
  NatsConsumerNameCollisionError,
  PubSubSubscriptionBoundElsewhereError,
  PublishTimeoutError,
  RemoteHandlerError,
  ReplyInboxUnavailableError,
  RequestTimeoutError,
} from '@setu-ts/messaging-plugin';

// Option types
export type {
  ConsumerRetryOptions,
  CustomMessagingOptions,
  EventsMessagingBridgeOptions,
  InMemoryBrokerOptions,
  KafkaMessagingOptions,
  KafkaOptions,
  MemoryMessagingOptions,
  MessagingBrokerType,
  MessagingCommonOptions,
  MessagingPluginOptions,
  NatsMessagingOptions,
  NatsOptions,
  PubSubMessagingOptions,
  RabbitMqMessagingOptions,
  RabbitMqOptions,
  RedisStreamsMessagingOptions,
  RedisStreamsOptions,
  ServiceBusMessagingOptions,
  ServiceBusRetryOptions,
} from '@setu-ts/messaging-plugin';

// Port types (structural)
export type {
  IPubSubSubscription,
  IPubSubTransport,
  PubSubOptions,
} from '@setu-ts/messaging-plugin';
export type {
  IServiceBusProcessErrorArgs,
  IServiceBusReceiver,
  IServiceBusSubscribeOptions,
  IServiceBusSubscription,
  IServiceBusTransport,
  ServiceBusOptions,
} from '@setu-ts/messaging-plugin';

// Transactional outbox (M107)
export {
  OutboxEnvelopeTooLargeError,
  OutboxNotReadyError,
  OutboxRelayUnscheduledError,
  OutboxRowStateError,
  OutboxStoreVerifyTimeoutError,
  OutboxUnknownTenantError,
} from '@setu-ts/messaging-plugin';
export type {
  IOutbox,
  OutboxCommonOptions,
  OutboxHealthOptions,
  OutboxOptions,
  OutboxRelayOptions,
  OutboxStoreEntry,
  OutboxSweepResult,
  OutboxWriteInput,
} from '@setu-ts/messaging-plugin';

// Consumer inbox (M108)
export {
  InboxConsumerConflictError,
  InboxNotConfiguredError,
  InboxNotReadyError,
  InboxPurgeUnscheduledError,
  InboxRowStateError,
  InboxStoreVerifyTimeoutError,
} from '@setu-ts/messaging-plugin';
export type {
  IInbox,
  InboxOptions,
  InboxPurgeOptions,
  InboxReleaseResult,
  InboxStoreEntry,
  IntegrationEventInboxHandler,
  IntegrationEventInboxOptions,
  IntegrationEventSubscribeOptions,
  ParkedInboxEntry,
} from '@setu-ts/messaging-plugin';

// Re-exported types from @setu-ts/common
export type {
  IMessageBroker,
  ISubscription,
  MessageHandler,
  MessageMetadata,
  SubscribeOptions,
} from '@setu-ts/messaging-plugin';

Kafka Commit Model: Kafka uses the producer/consumer commit model — handler success auto-commits; a thrown handler prevents commit.


Publish options — ordering key, de-duplication ID and headers

publish takes an optional trailing PublishOptions (from @setu-ts/common):

interface PublishOptions {
  readonly orderingKey?: string; // ≤ 128 UTF-8 bytes
  readonly deduplicationId?: string; // ≤ 128 UTF-8 bytes
  readonly headers?: Readonly<Record<string, string>>; // ≤ 32 headers, ≤ 1024 bytes each
}

publish<T>(topic: string, message: T, options?: PublishOptions): Promise<void>;

Every option is carried as a transport header beside any native mapping, so it is observable through MessageMetadata.headers on every broker and never silently dropped. Use the exported constants ORDERING_KEY_HEADER ('x-setu-ordering-key') and DEDUPLICATION_ID_HEADER ('x-setu-deduplication-id') to read them.

BrokerorderingKeydeduplicationId
Kafkamessage key → one partition, in orderheader only
Pub/Subnative orderingKey (needs an ordering subscription)header only
NATSheader onlyNats-Msg-Id → server de-duplicates within the stream’s dedup window
Service Busheader onlymessageId → de-duplicated only on an entity with duplicate detection
RabbitMQheader onlyheader, and the messageId property
Redis Streamsheader onlyheader only
In-memoryheader onlyheader only
WorkersBrokerenvelope field, surfaced as the headerenvelope field, surfaced as the header

What orderingKey promises. It decides placement — the same key reaches the same partition, retry queue or ordered subscription where the broker has one — not the order handlers finish in. What a handler failure does to order differs per broker:

  • Order kept by blocking: Kafka (a throwing handler leaves the offset uncommitted, so one failing message stalls its whole partition until it succeeds) and Pub/Sub on an ordering subscription.
  • Service Bus — the condition decides, so it is always stated. Measured 2026-10-07 both ways: at the broker’s default maxConcurrentCalls (1) the abandoned message is redelivered before the next is handled (order kept by blocking); at maxConcurrentCalls: 2 the later message is handled first (order lost).
  • Order lost on retry: RabbitMQ, Redis Streams and NATS redelivery — later messages for the key are handled while the failed one waits.

A consumer that needs order compares the delivered envelope’s version and drops or defers a stale message.

What a consumer may trust, and what a producer must not derive.

  1. A delivered x-setu-ordering-key or x-setu-deduplication-id header is a hint written by whoever published the message — validation runs on the publish side only — so a foreign or compromised producer can send any value under these names. Use it to order or de-duplicate your own work, never to authorize anything.
  2. A deduplicationId derived from request input lets the caller who chooses it suppress another message with the same id for the broker’s window (NATS’s duplicate_window, 120 s by default; Service Bus’s configured detection window). Derive it from a producer-assigned id — the envelope id publishIntegrationEvent uses by default is one.
  3. An orderingKey derived from request input lets a caller concentrate load on one Kafka partition or one Pub/Sub ordering key (1 MB/s per key), and on a log-compacted Kafka topic it lets the caller erase an earlier message that carries the same key. Derive it from an aggregate the application owns.

Invalid options are rejected with a RangeError as a rejected promise (never a synchronous throw), naming the field and the rule; a refused value is never echoed. A header name a broker or its server acts on (traceparent, tracestate, cc, bcc, payload, nats-*, x-setu-*, RabbitMQ’s x-death / x-delivery-count / x-acquired-count / x-delay and the x-first-death-* / x-last-death-* forms) is refused on every broker, compared case-insensitively. A goog prefix is refused too, as a precaution: Pub/Sub’s reservation of it is stated only by third-party documentation, and the emulator accepts goog attributes.

The Cloudflare Workers broker has no transport header channel, so it carries all three options as envelope fields and surfaces them on delivery as the same headers. On publish it refuses invalid options with the same RangeError as every other broker; on delivery, an envelope orderingKey, deduplicationId or header entry that fails the shared rules — or every caller header, when the record holds more than MAX_PUBLISH_HEADERS entries — is dropped, never surfaced, since a foreign producer may have written the envelope.

One exception to “never silently dropped” above: a NatsBroker given an injected connection and no headersFactory has nothing to build NATS headers with, so caller headers and the ordering key are dropped and reported once through the logger; the de-duplication id is still applied, as nats.js’s native msgID.


Trace context across the broker

When CAPABILITIES.TELEMETRY is registered, the plugin wraps the broker so publish, subscribe, request and respond create producer and consumer spans, writing a W3C traceparent on publish and parenting delivery from the header it reads back. MessagingPlugin({ tracing: false }) opts out; with no telemetry capability registered, behaviour is unchanged.

Each broker uses the header channel its transport actually provides:

BrokerChannelNotes
memoryin-process metadataNo wire; headers are handed straight to the subscriber.
redis-streamsextra XADD field/value pairs beside payloadAny non-payload field is read back as a header.
rabbitmqAMQP properties.headersField-table values are normalized — see below.
natsMsgHdrsNeeds a header factory — see the caveat below.
kafkarecord headersString values; a delivered Buffer value is decoded as UTF-8.
pubsubmessage attributesPub/Sub attribute values must be strings.
service-busapplicationPropertiesSDK values are normalized — see below.

MessageMetadata.headers is populated by every first-party broker: it carries the headers the broker read, and {} when the transport carried none. Three transports can deliver values that are not strings — AMQP field tables carry numbers, booleans, timestamps and byte arrays; Service Bus types its application properties number | boolean | string | Date | null; kafkajs delivers Buffer and permits arrays — so each is normalized to satisfy the declared Record<string, string>. A byte value is decoded as UTF-8, a number or boolean is stringified, a Date becomes ISO-8601, and the first element of a repeated header is taken. A value with no faithful string form (a nested table, null, NaN) is dropped rather than rendered as [object Object], so a missing key reads as absent rather than as a corrupted value. It is absent only for a 'custom' broker that does not supply it. Branch on emptiness rather than on the member’s presence.

Two cases drop the header rather than propagating, both by construction:

  • NATS with an injected client. A MsgHdrs can only be built by the nats module’s headers() function, and an injected connection carries no module. Pass NatsOptions.headersFactory (for example () => nats.headers()) alongside the client. Without it the broker publishes normally and reports the dropped headers once through its logger.
  • A Pub/Sub or Service Bus transport injected via client that predates this release. IPubSubTransport.publish and IServiceBusTransport.send gained an optional third parameter (attributes / applicationProperties), and their delivered-message callbacks gained a matching optional member. A two-parameter implementation stays assignable and simply ignores the header. M90d widened the delivered callbacks further with optional messageId and timestamp members (X28-4); the same assignability holds — an implementation supplying neither simply produces metadata without them.

Message identity on the metadata

Every first-party broker populates MessageMetadata.messageId and .timestamp from what its transport actually assigns (M90d / X28-4 closed the two cloud brokers that were not reading them): in-memory assigns runtime.uuid() and runtime.now(); Redis Streams uses the entry id and its timestamp; RabbitMQ uses properties.messageId/properties.timestamp (assigned on publish when the producer did not); NATS uses the JetStream stream sequence and the delivery’s info.timestampNanos; Kafka uses partition:offset and the record timestamp; Pub/Sub reads message.id/message.publishTime; Service Bus reads message.messageId/message.enqueuedTimeUtc. An absent member means the transport carried none — never “the adapter did not look” — so a de-duplication read of metadata.messageId is meaningful on all seven.


Health status

Since M70c the indicator reports two signals: the broker’s lifecycle (isReady()) and its reachability. A ready-but-unreachable broker is down with data.reachable: false — the distinction an operator needs to tell “we never started” from “the broker restarted under us”. An unprobeable broker (e.g. the custom arm without isHealthy) is up with data.reachable: 'unknown', honestly reporting “we did not check”.

Since M90b the Service Bus broker’s transport implements the probe it documents: one administration round trip (getNamespaceProperties()) proves the namespace is reachable, and a positively identified 401/403 counts as reachable too — the namespace ANSWERED with an auth verdict, which a send/listen-only credential legitimately produces and which is not a network outage. Every broker’s probe is cached for 5 seconds and bounded at 2 seconds through createCachedProbe, so a dead broker can no longer hold a health poll for the full transport timeout, and repeated polls cost at most one round trip per TTL.

StatusMeaning
upThe broker is connected and reachable, or cannot be probed (reachable is 'unknown').
downThe broker is not connected, or is connected but unreachable.

data reports { broker, reachable }, where reachable is true, false, or 'unknown'.

Since M95b reachability reads the plane the application actually uses. The Service Bus broker records the outcome of every real publish — the data plane — and reachability() consults it FIRST: a positive success resolves true for ServiceBusOptions.dataPlaneEvidenceMs (default 5000), while a network-layer failure (a rejection carrying no statusCode; a rejected topic or a quota error is an application-level fact, never an outage) resolves false until a successful publish or a positive management probe contradicts it. Since M101a that retained failure is answered at once: the management probe runs in the background and only a true answer clears the outcome, for the NEXT read. Before, reachability() awaited the probe, whose own 2-second bound tied the indicator’s, so the indicator’s bound fired first and a recorded outage was reported up. A failure inside a window the indicator has already cached as up is reported at the first poll after that 5-second cache expires. The plane distinction is the substance: the management round trip proves the management plane is reachable — evidence about the data plane, never proof of it — and that gap is what let a stopped namespace report up while every publish threw. Two further changes: every arm’s probe is bounded by the indicator’s createCachedProbe (5-second TTL, 2-second bound), so a probe that cannot answer — a hung broker, the condition a stopped one never produces — settles reachable: 'unknown' instead of holding /health open; and the RabbitMQ probe is a real round trip (a throwaway channel open/close), replacing the connection-fault flag read that a hung broker never trips. Residual exposure, stated rather than implied: a deployment whose Service Bus management plane is unreachable and that publishes nothing keeps reporting reachable: 'unknown' with status up until its first publish — an operator who needs the signal can publish synthetically.

Kafka and Pub/Sub also own cached probes (5 s TTL, 2 s bound), protecting direct isHealthy() callers. Kafka uses describeCluster() on a second <clientId>-health client (zero retries, 1500 ms request timeout, 1000 ms connection timeout); a request-timeout error anywhere within five cause links means unknown, another rejection means false. A faulted supervisor returns false immediately. An injected IKafkaFactory.admin?(options?: { retry?: { retries: number } }): unknown provides the probe admin; injected timeouts belong to the application. Without it reachability is unknown. The probe admin connects at startup and disconnects at shutdown. The separate group admin connects lazily through one memoized promise and closes at shutdown.

Pub/Sub uses getTopics({ pageSize: 1, autoPaginate: false, gaxOpts: { timeout: 1500, retry: null } }). PubSubSdkModule includes this SDK member; IPubSubTransport.isHealthy?() returns Promise<boolean | undefined>. Resolution means true; gRPC 14 means false, 4 means unknown, any other numeric code (including 7/16 authorization responses) means true; no numeric code means false. An omitted transport probe means unknown. Unknown still reports up. See the per-broker probe table.

The Kafka, Pub/Sub, RabbitMQ, Redis Streams and NATS plugin arms — and their KafkaOptions, PubSubOptions, RabbitMqOptions, RedisStreamsOptions and NatsOptions shapes — accept publishTimeoutMs (default 15000 ms, 0 unbounded). Out-of-range or non-finite values throw RangeError at MessagingPlugin(...) and at broker construction. Every publish entry point, RPC and outbox relay publishes use the bound; the timeout is the exported PublishTimeoutError, which names the broker, topic and bound and warns that delivery may still occur. It does not cancel the transport. On Redis Streams the bound covers the publish’s XADD only; on NATS it is passed to the client as its own publish timeout as well.

Fresh Kafka groups whose every partition offset is -1 capture high watermarks before run(). On the first join, members seek assigned, still-uncommitted partitions to the minimum start offset advertised by current members. Messages published after any member’s subscribe() resolves reach the group even during concurrent first starts; history before the first member’s subscription is not replayed, provided every group member is trusted: any client able to join the group can move a new group’s start earlier (never later), without a log line. Joining needs only Read on the group while committing also needs Read on the topic, so under split ACLs a principal that cannot commit can still cause that replay; restrict group membership. Advertisements are keyed by topic; member metadata above 64 KiB and offsets longer than 19 digits are refused, and the first refusal per subscription is logged. subscribe() does not wait for the join. Existing committed groups and later joins are untouched. This requires admin and a loadable kafkajs module. Injected factories try loading the module once at connect(); without it members seek only their own offsets and concurrent first starts can miss messages. Without admin the step is skipped. A member crashing before the join takes its start offsets with it. Admin/metadata failures warn once per subscription through KafkaOptions.logger.warn? (fallback error), naming topic/group; invalid member metadata is skipped individually while usable snapshots still contribute to the minimum. Admin or seek failures leave affected partitions with latest-offset behavior.

NatsOptions.reconnect and NatsMessagingOptions.reconnect configure only lazily built connections: maxAttempts defaults to -1 (unbounded), waitMs to 2000; forwarded as maxReconnectAttempts / reconnectTimeWait. The former must be an integer ≥ -1; the latter an integer 1–2147483647, else construction throws RangeError naming the field. Supplying an injected client together with reconnect throws TypeError. A finite exhausted budget closes the connection; the indicator stays down until the application replaces it.

Integration event contracts

The contract layer ON TOP OF the transport: a declarable, versioned integration event with a portable wire envelope — payload data, never transport headers, so every broker arm carries it without an adapter change. No IMessageBroker method is added and no capability token is added.

import {
  causedBy,
  defineIntegrationEvent,
  onIntegrationEvent,
  publishIntegrationEvent,
} from '@setu-ts/messaging-plugin';

// The contract: four required fields (plus an optional `orderingKey`
// selector, below), validated eagerly. The topic MUST end with the
// exact `.v${version}` suffix — the versioned-topic rollout policy enforced
// by the factory, not documented prose. A pre-existing unversioned topic has
// no definition; keep using the raw broker.publish/subscribe surface for it.
const orderPlaced = defineIntegrationEvent<{ orderId: string }>({
  type: 'orders.placed',
  version: 1,
  topic: 'orders.placed.v1',
  // `parse` is the validation boundary: a bare `as` checks nothing at runtime.
  // Narrow it for real — see the messaging-plugin README for a worked parser.
  parse: (value) => value as { orderId: string },
});

// Producer: builds the envelope (below) and publishes it to the definition's
// topic. parse is NEVER run on publish — a producer CAN publish a payload its
// own consumers reject, and that surfaces at the consumer.
await publishIntegrationEvent(
  runtime,
  broker,
  orderPlaced,
  { orderId: '123' },
  {
    correlationId: 'root-1', // each field optional; omitted when absent
    causationId: 'e-0',
    aggregateId: 'order-1',
    aggregateVersion: 3,
  },
);

// Consumer: produces the existing SubscriptionDefinition — plugs straight
// into MessagingPlugin({ subscriptions }). The handler receives the parsed
// payload, the envelope (rebuilt with data = the parsed value), and the
// transport metadata.
const subscription = onIntegrationEvent(
  orderPlaced,
  async (payload, envelope) => {
    await provisionOrder(payload.orderId, envelope.id);
  },
  { queue: 'billing' },
);
// provisionOrder: the application's own work, resolved from the parsed
// payload; envelope carries the identity/correlation fields; the third
// handler argument is the transport MessageMetadata.

// Correlation propagation: the chain-root rule, extracted.
const causal = causedBy(envelope); // { correlationId: envelope.correlationId ?? envelope.id, causationId: envelope.id }

Publish options (M106). publishIntegrationEvent takes an optional sixth argument, PublishOptions (see “Publish options” above), validated once as on every broker. Two defaults differ from a raw broker.publish:

  • deduplicationId defaults to the envelope id, so a retried publish of the SAME envelope is de-duplicated where the broker supports it. A caller-supplied deduplicationId wins.
  • orderingKey comes from the caller first, then from the definition’s optional orderingKey(envelope) selector, then none. The selector is called only when the caller supplied no key; a selector that throws, or returns a value the rules refuse, rejects the publish.
const orderShipped = defineIntegrationEvent<{ orderId: string }>({
  type: 'orders.shipped',
  version: 1,
  topic: 'orders.shipped.v1',
  parse: (value) => value as { orderId: string },
  // Keep every event for one order on one partition / ordering key.
  orderingKey: (envelope) => `order-${envelope.data.orderId}`,
});

await publishIntegrationEvent(runtime, broker, orderShipped, { orderId: '123' }, undefined, {
  headers: { 'x-tenant': 'acme' },
});

The wire envelope, published as the message payload:

FieldTypePresent always?Meaning
idstringyesProducer-assigned identity (runtime.uuid())
typestringyesThe contract’s semantic event name
versionnumberyesThe contract version
occurredAtstringyesPublish time, ISO-8601 (new Date(runtime.now()).toISOString())
datapayloadyesThe event payload, carried verbatim
correlationIdstringoptionalID of the causal chain root
causationIdstringoptionalID of the directly causing event
aggregateIdstringoptionalID of the aggregate the event concerns
aggregateVersionnumberoptionalVersion of the aggregate the event concerns

occurredAt is a string, not a Date: payloads round-trip through the serializer’s JSON.parse, so a Date would arrive as a string on every transport. The consumer’s structural check REQUIRES the five mandatory fields at their primitive types, checks type and version for exact equality, and IGNORES unknown extra top-level fields — an additive envelope change is never a breaking deployment. Payload strictness belongs to parse, where the application owns the policy. The optional causal fields are checked on both sides: correlationId, causationId and aggregateId must be strings, and aggregateVersion — the only number among them — must be FINITE, because JSON serialization maps NaN/Infinity to null, so a non-finite one would reach consumers as null.

occurredAt must be an ISO-8601 instant in the interoperable RFC 3339 profile — a full date, a time to at least seconds, and an explicit Z or a numeric offset (2026-01-01T00:00:00Z, 2026-01-01T00:00:00.000Z, 2026-01-01T00:00:00+05:30). A date-only value, an RFC 2822 date, or a zone-LESS timestamp is refused: Date.parse accepts all three, and the zone-less form is read in each engine’s own local time — measured, 2026-01-01T00:00:00 becomes 2025-12-31T18:30:00.000Z on a +05:30 host — so one event would mean a different instant on every consumer.

The ten exported symbols of this section: the four functions above (defineIntegrationEvent, publishIntegrationEvent, onIntegrationEvent, causedBy), the IntegrationEventRejectedError class (rejections, below), and five types:

  • IntegrationEventDefinition<T> (interface) — the contract defineIntegrationEvent returns and both directions read: readonly type, version, topic, parse: (value: unknown) => T, and the optional orderingKey?(envelope): string | undefined selector (M106). parse runs on the consumer side only.
  • IntegrationEventEnvelope<T> (interface) — the wire shape in the table above. The handler receives it with data rebuilt to the parsed value, so envelope.data === payload holds on every delivery.
  • IntegrationEventMetadata (interface) — the optional fifth argument of publishIntegrationEvent: correlationId/causationId/aggregateId (string) and aggregateVersion (number), each omitted from the envelope when absent. causedBy() returns a value assignable to it.
  • IntegrationEventHandler<T> (type) — (payload: T, envelope: IntegrationEventEnvelope<T>, metadata: MessageMetadata) => void | Promise<void>: parsed payload first, envelope second, transport metadata third.
  • onIntegrationEvent overloads (M108). With options.inbox the call takes an IntegrationEventInboxHandler<T, S> — the same three arguments plus the inbox transaction’s unit of work, S inferred from the handler’s annotation — and returns a RegistryFactory<SubscriptionDefinition> (see Consumer inbox). Without it the call is unchanged and returns a SubscriptionDefinition; the inbox overload is declared first, and the legacy options take inbox?: never, so an options variable carrying inbox is typed as the factory it is.
  • IntegrationEventRejectionReason (type) — 'malformed' | 'type-mismatch' | 'version-mismatch' | 'parse', the reason discriminant of IntegrationEventRejectedError (table below).

A refused delivery throws IntegrationEventRejectedError before the handler runs. The rejection follows the broker’s OWN failure path, and that path differs per arm — it is not a retry guarantee. RabbitMQ nacks with requeue DISABLED, so a refused message is dead-lettered when a DLX is configured and discarded otherwise, and logs the failure. NATS naks, which redelivers while the stream retains the message, and (since PR #287) also logs it. The in-memory broker reports through onDispatchError and drops. Because a rejection here is deterministic — the same envelope fails the same way on every delivery — redelivery cannot resolve it, so a dead-letter queue rather than a retry is where a refused event is inspected. Its message carries the whole diagnostic — the structured fields serve an instanceof branch on a path that surfaces the error object:

reasonFault
malformedNot an object, or a mandatory field missing or of the wrong primitive type
type-mismatchThe envelope’s type differs from the definition
version-mismatchThe envelope’s version differs from the definition
parseThe definition’s parser threw; the thrown value is carried as cause verbatim

When MessagingPlugin({ behaviors }) is configured, the behaviour chain runs BEFORE the wrapper, so IngressContext.payload is the raw envelope, never the parsed payload — a short-circuiting behaviour must be able to refuse a message without the framework parsing it first. A handler needing a resolved capability uses the existing RegistryFactory<SubscriptionDefinition> arm: (services) => onIntegrationEvent(definition, handlerFor(services)).

Mapping a local domain event (M93a’s createDomainEvents()) onto a published integration event is application policy, written out explicitly — event.type is deliberately not the integration type, and event.occurredOn (when the fact happened) is deliberately not the envelope’s occurredAt (when it was published).

Transactional outbox (M107)

MessagingPlugin({ outbox }) writes an integration event as a row in the SAME database transaction as the business change, through the caller’s own unit of work, and relays committed rows to the broker afterwards. The store is reached through the IOutboxStore port in @setu-ts/common — normally createDatabaseOutboxStore() from @setu-ts/database-plugin — never by resolving CAPABILITIES.DATABASE.

import { CAPABILITIES } from '@setu-ts/common';
import { createDatabaseOutboxStore, DatabasePlugin } from '@setu-ts/database-plugin';
import type { IDatabaseService } from '@setu-ts/database-plugin';
import { MessagingPlugin } from '@setu-ts/messaging-plugin';
import type { IOutbox } from '@setu-ts/messaging-plugin';
import { SchedulerPlugin } from '@setu-ts/scheduler-plugin';

app.register(SchedulerPlugin());
app.register(DatabasePlugin({ type: 'memory' }));
app.register(MessagingPlugin({ outbox: { store: createDatabaseOutboxStore() } }));
await app.start();

const db = app.services.get<IDatabaseService>(CAPABILITIES.DATABASE);
const outbox = app.services.get<IOutbox>(CAPABILITIES.OUTBOX);
await db.transaction(async (uow) => {
  await uow.getRepository('Order').create(order);
  await outbox.write(uow, orderPlaced, { orderId: order.id });
});
outbox.dispatch(); // optional: sweep now rather than at the next tick

The promise. At-least-once delivery of every committed row. At most one relay holds a row’s claim at a time, provided the relays’ wall clocks agree within relay.maxClockSkewMs. A relay starts publishing only while its claim leaves room for the publish, status write and clock skew. Any number of relays keep per-key order among FIRST publishes, provided rows of one key commit in write order and the writers’ clocks agree. Delivery order is what the broker gives it (as documented by the broker); consumers compare aggregateVersion.

Never exactly once: a repeat carries the same envelope and deduplication id, which a consumer inbox absorbs. Two windows remain: a relay can pause AFTER the fence check and resume after its lease; and a publish the broker accepts after it was abandoned can arrive late. Those repeats can arrive out of order. A relay paused past its claim BEFORE the fence check publishes nothing.

ExportKindNotes
IOutboxinterfacewrite(scope, definition, payload, input?) → envelope id; dispatch() (never throws); sweep(); purge(); release(id, action, options?)
OutboxOptionstypeOutboxCommonOptions plus exactly one of store (one store) and stores (per tenant id); supplying both is a compile error
OutboxCommonOptionsinterfacemaxEnvelopeBytes, background, relay, health, retainSentMs, purgeBatch, purgeIntervalMs
OutboxRelayOptionsinterfaceschedule, intervalMs, pageSize, scanLimit, publishLimit, maxFailedScan, maxAttempts, backoff bounds, the sweep deadline, claimLeaseMs, maxClockSkewMs
OutboxHealthOptionsinterfacedegradedAfterMs (default 60 000), overlapWindowMs (default 600 000)
OutboxStoreEntrytypean IOutboxStore or a RegistryFactory<IOutboxStore>, resolved in onInit
OutboxWriteInputinterfacemetadata?, options? (PublishOptions, the publishIntegrationEvent precedence), tenantId?
OutboxSweepResultinterfaceorigin, scanned, published, failures, poisoned, endedBy
OutboxEnvelopeTooLargeErrorclasswrite — the serialized envelope exceeds maxEnvelopeBytes (bytes, limit); the caller’s transaction rolls back
OutboxRelayUnscheduledErrorclassstart() — the relay is scheduled (the default) but no CAPABILITIES.SCHEDULER is registered
OutboxUnknownTenantErrorclasswrite / release with per-tenant stores naming no tenant, or one with no store; the tenant id is never quoted
OutboxRowStateErrorclassrelease — outcome: 'invalid-id' (400), 'missing' (404) or 'not-failed' (409, with the row’s status); each is status-hinted (withHttpStatusHint), so an errorHandler answers the mapped status with the class’s own title and detail
OutboxNotReadyErrorclassany call before onInit resolved and verified the store
OutboxStoreVerifyTimeoutErrorclassstart() — a store’s verify() did not settle within relay.storeTimeoutMs (timeoutMs); no tenant or store is named

Wiring. With outbox set the plugin also provides CAPABILITIES.OUTBOX (outbox.<name> for a named instance), declares CAPABILITIES.SCHEDULER and CAPABILITIES.METRICS as optional dependencies (ordering only), and registers an outbox (outbox.<name>) health indicator. Without it, nothing changes. Every numeric option is validated when MessagingPlugin(...) is called; a value out of range, NaN or a fraction throws a RangeError naming the option, and relay.publishTimeoutMs + 2 * relay.storeTimeoutMs >= relay.sweepDeadlineMs is refused.

  • onInit. With relay.schedule (default true) and no scheduler registered, start() rejects OutboxRelayUnscheduledError before any store I/O. Otherwise each store entry is resolved, each store’s verify() runs — a refusal (OutboxStoreUnavailableError from the bridge) fails start() — the outbox is activated, and two jobs are scheduled with IScheduler.every: outbox-relay[.<name>] every relay.intervalMs (default 1000) and outbox-purge[.<name>] every purgeIntervalMs (default 60 000). With relay: { schedule: false } nothing is scheduled; call sweep() and purge() yourself (on Cloudflare Workers, from a Cron Trigger).
  • One deadline. relay.sweepDeadlineMs defaults to 30 000 on the monotonic clock and bounds the sweep and purge across all stores, EXCEPT that the status write settling an already-invoked publish runs to its own storeTimeoutMs even when a pause has exhausted the budget: a sweep that overran by a pause can outlive the deadline by at most one storeTimeoutMs, and the shutdown drain that waits for it is bounded by the same amount. The strict relation is publishTimeoutMs + 2 * storeTimeoutMs < sweepDeadlineMs; the default 15 000 ms reserve leaves the first 15 000 ms for row starts. A purge call running past the deadline rejects; deletions already completed stay completed.
  • Shutdown. The relay drains in an onShutdown hook, which the kernel runs before ANY close hook — the broker’s and the database’s included: the outbox becomes closing (dispatch() is a no-op and a failure writes no attempt), both jobs are removed, and the in-flight sweep is awaited. An onClose hook repeats the same drain idempotently, because a failed start() runs close hooks only; it is registered ahead of the broker’s own close hook, so the drain precedes the disconnect on that path too.
  • Health. down with ready: false before the store is verified and once closing; down with reachable: false when stats() does not answer (cached 5 s, bounded 2 s); up with data.reasons from a fixed vocabulary — cluster-wide, from the store: oldest-pending-age (past health.degradedAfterMs), failed-rows, failed-scan-cap (a store’s failed count reaches relay.maxFailedScan); per instance, from this relay: blocked-key-cap, store-write-failing, relay-overlap (a replica that never sweeps reports none of these). data carries counts, ages in ms and the last local sweep’s counts — never a tenant id, ordering key, topic or error text.
  • Metrics. With CAPABILITIES.METRICS registered: counters outbox_published_total, outbox_publish_failures_total, outbox_poisoned_total (labels outbox, topic) and outbox_overlaps_total (outbox, kind); gauges outbox_pending_rows and outbox_oldest_pending_seconds (outbox), written from each fresh store read the health indicator makes. topic comes only from a decoded row and is capped at 100 distinct values per instance, then other; an undecodable poisoned row is labelled invalid-row. Every write is guarded — a failing metrics backend never reaches the relay. The two gauges are written ONLY when the health indicator reads the store, so they are as fresh as the last /health poll.
  • Ordering limits. The relay reads the PENDING set, never a watermark: a row whose transaction commits after a later row of its key was published is still published (measured on real PostgreSQL), but after that later row — order holds only among rows of one key that commit in write order. position is taken from each writer’s clock, clamped per process only, so across replicas order also assumes the writers’ clocks agree.
  • Claims and overlap. relay.claimLeaseMs defaults to 30 000, range 1–3 600 000; it must cover publishTimeoutMs + 2 * storeTimeoutMs + maxClockSkewMs. maxClockSkewMs defaults to 5 000, range 0–60 000; zero assumes perfectly agreeing clocks and is unsafe across hosts. A held row blocks its key until now ≥ leaseUntil + maxClockSkewMs; before publishing the relay requires now + publishTimeoutMs + storeTimeoutMs + maxClockSkewMs ≤ leaseUntil. Kinds fenced, claim-lost and duplicate degrade health as relay-overlap for the configured window, for any sweep origin. Claim-time contention has no shipped signal. retainSentMs: 0 disables only duplicate. A shared scheduler lock saves redundant scans; without it every replica scans every interval. The lock TTL rule no longer bears on correctness. DynamoDB’s conditional claim checks the latest base item even after a stale GSI page (AWS’s documented model, exercised on DynamoDB Local rather than the service).
  • Retention. purge() deletes sent and discarded rows whose settledAt is older than retainSentMs (default 7 days), at most purgeBatch per status per store; failed rows are never purged. retainSentMs: 0 deletes at mark-sent.
  • release has no built-in authorization. It is an operator capability; the application gates the route that calls it. It takes effect at the relay’s next lap.
  • Tenancy. write(…, { tenantId }) records the tenant (column isolation, one store) or selects the store (stores, database per tenant). The selected store must read the database the caller’s unit of work writes to — a caller obligation nothing can check, because a unit of work carries no database identity; a mismatched row is relayed under the wrong tenant, or never. The tenant is never a published header.
  • Trust. A row is re-validated on every read (size, id, options, ordering-key agreement, topic format) and a failing row is failed with lastError: 'invalid-row', never published; but a VALID row’s topic is published as stored. Protect the outbox table like the broker credentials.
  • Trace. write stores the active traceparent; each row is published inside an outbox relay <topic> span parented to it, so request → relay → publish → receive is one trace. A row without a valid traceparent gets SpanOptions.root, never the span of the request that dispatched the sweep. A custom broker without the framework-header channel (WorkersBroker) drops the producer’s traceparent.
  • Cloudflare Workers. relay: { schedule: false }, background: waitUntil, and Cron Triggers on WorkersCron calling outbox.sweep() and outbox.purge(), with the outbox in a D1 table through D1Adapter. Driven at unit level (D1Adapter over real SQLite); not on real workerd.
  • Backends. The per-backend requirements are under Transactional outbox store in the database plugin; the messaging plugin README carries the DDL (PostgreSQL, SQLite/D1) and the adapter mappings.

Consumer inbox (M108)

MessagingPlugin({ inbox }) plus onIntegrationEvent(definition, handler, { inbox: { consumer } }) records each event a consumer applied, keyed by (consumer, topic, envelope id), in the SAME transaction as the handler’s writes. The store is reached through the IInboxStore port in @setu-ts/common — normally createDatabaseInboxStore() from @setu-ts/database-plugin.

import { createDatabaseInboxStore, DatabasePlugin } from '@setu-ts/database-plugin';
import type { IUnitOfWork } from '@setu-ts/database-plugin';
import { MessagingPlugin, onIntegrationEvent } from '@setu-ts/messaging-plugin';

app.register(DatabasePlugin({ type: 'memory' }));
app.register(
  MessagingPlugin({
    inbox: { store: createDatabaseInboxStore() },
    subscriptions: [
      onIntegrationEvent(
        personHired,
        async (payload, _envelope, _metadata, uow: IUnitOfWork) => {
          await uow.getRepository('PayrollRecord').create({ id: payload.personId });
        },
        { inbox: { consumer: 'payroll' } },
      ),
    ],
  }),
);

The promise. For one consumer name, the handler’s writes through the supplied unit of work are committed at most once per topic and envelope id while the marker is retained; a delivery after the marker is purged is processed again. Nothing is promised about effects outside that unit of work, about two processes using one consumer name with different handlers, or about a database other than the store’s.

ExportKindNotes
IInboxinterfaceparked(limit?) (never the envelope); release(rowId, 'retry' | 'discard'); purge()
InboxOptionsinterfacestore, maxAttempts?, retainMs? (7 days), storeTimeoutMs? (5000), maxParkedEnvelopeBytes? (262 144), purge?
InboxPurgeOptionsinterfaceschedule? (true), intervalMs? (60 000), batch? (100)
InboxStoreEntrytypean IInboxStore or a RegistryFactory<IInboxStore>, resolved in onInit
ParkedInboxEntryinterfacerowId, consumer, topic, envelopeId?, attempts, updatedAt, lastError?
InboxReleaseResultinterfacetopic, and for retry the stored envelope (absent when too large or unreadable)
IntegrationEventInboxHandlertype(payload, envelope, metadata, scope: S)
IntegrationEventInboxOptionsinterfaceconsumer (a valid publish id, required), instance? (resolves inbox.<instance>)
IntegrationEventSubscribeOptionstypeSubscribeOptions & { inbox: IntegrationEventInboxOptions }
InboxNotConfiguredErrorclassresolution — reason: 'unregistered' (no MessagingPlugin({ inbox })) or 'foreign-provider' (a replacement provider of the token)
InboxConsumerConflictErrorclassresolution — a second subscription with the same consumer on the same topic in one inbox
InboxNotReadyErrorclassstate: 'not-started' (resolution or a call before the store is verified) or 'closed'
InboxPurgeUnscheduledErrorclassstart() — purge.schedule (the default) without a CAPABILITIES.SCHEDULER
InboxStoreVerifyTimeoutErrorclassstart() — verify() did not settle within storeTimeoutMs
InboxRowStateErrorclassrelease — outcome: 'invalid-id' (400), 'missing' (404) or 'not-parked' (409, with the marker’s status); each is status-hinted (withHttpStatusHint), so an errorHandler answers the mapped status with the class’s own title and detail
  • Delivery. Validate the envelope; read the marker (present → acknowledge); parse; then ONE transaction that creates the marker first and runs the handler. After ANY rejection the marker is read again: present → acknowledge (another delivery handled it); absent → the handler failed. Row ids are a SHA-256 of (consumer, topic, envelope id), so any string id is keyable and one consumer’s topics never suppress each other; the raw id is stored only when it is a valid publish id.
  • Queue. The broker queue defaults to inbox. plus 16 hex characters of a 64-bit FNV-1a hash of the consumer and the topic — legal on every broker, and one per pair barring an accidental 64-bit collision (both names are application configuration; build neither from untrusted input) — so the subscription is a durable consumer group that redelivers. Service Bus creates no subscriptions, so set queue explicitly there and create that subscription.
  • Retention. purge() deletes rows of every status older than retainMs, parked markers and their envelopes included, at most purge.batch per status per run — so the table stays within the window only while inflow per status stays below that rate.
  • Logs. A failed store call is logged with the error’s class name only (the errorKind log field), never its message, which a driver may fill with the statement’s bound parameters.
  • Failures. Without maxAttempts a failure is rethrown and the broker’s budget applies (NATS redelivers without limit and Kafka blocks the partition). With it, failures are counted outside the transaction and at the limit the delivery is parked and acknowledged; a parse rejection parks at once.
  • Wiring. With inbox set the plugin also provides CAPABILITIES.INBOX (inbox.<name>), declares CAPABILITIES.SCHEDULER as an optional dependency, registers an inbox health indicator (down before verification and after close, up with data.reasons: ['parked-rows'], up/reachable: 'unknown' when the count does not answer within 2 s), verifies the store at onInit before any inbox subscription is resolved, schedules inbox-purge[.<name>], and closes in an onClose hook registered after the broker’s, so a delivery in flight during stop() is not refused.
  • Backends. Memory, Prisma, Drizzle, D1, a MongoDB replica set and DynamoDB; Cosmos and Bigtable are refused. The DDL is in the messaging plugin README.

Queue (@setu-ts/queue-plugin)

Provides background job queue with Memory and Redis adapters.

Exports

  • QueuePlugin — Plugin factory for registering the queue service
  • QueueAdapterType — 'memory' | 'redis' | 'rabbitmq' | 'sqs'
  • QueuePluginOptions — Plugin configuration options (includes client, url, prefix?, sqs?)
  • MemoryQueue — In-memory queue adapter for development/testing
  • RedisQueue — Redis-backed queue adapter for production
  • RedisQueueOptions — Redis adapter configuration, including keyPrefix? (default queue:) and visibilityTimeoutMs? (default 30000)
  • RabbitMqQueue — RabbitMQ queue adapter via amqplib (polling via basicGet, TTL+DLX for delays)
  • RabbitMqQueueOptions — RabbitMQ adapter configuration (includes url, client, prefix?, persistentMessages?, publishTimeoutMs?, reportWarning?)
  • SqsQueue — AWS SQS queue adapter via @aws-sdk/client-sqs (receipt-handle bookkeeping, ApproximateReceiveCount attempt ladder, visibility-timeout backoff, dead-letter ordering)
  • SqsQueueOptions — SQS adapter configuration (queues, deadLetterQueues?, region?, credentials?, endpoint?, client?)
  • ISqsTransport — Structural SQS transport port (injected via SqsQueueOptions.client)
  • SqsReceivedMessage — SQS received message shape (body, receiptHandle, approximateReceiveCount)
  • SnsPublisher — SNS publisher for fan-out (SNS→SQS pairing)
  • SnsPublisherOptions — SNS publisher configuration
  • ISnsTransport — Structural SNS transport port
  • adaptSqsModule / loadSqsModule — SQS SDK adapter and lazy loader
  • SqsSdkModule — Shape of the SQS SDK module adaptSqsModule consumes; exported so a consumer can type a substitute module
  • adaptSnsModule / loadSnsModule — SNS SDK adapter and lazy loader
  • SnsSdkModule — Shape of the SNS SDK module adaptSnsModule consumes
  • QueueBackendUnavailableError — Thrown when a cloud queue backend is unavailable (e.g., SQS on Cloudflare Workers)
  • SqsDelayTooLongError — Thrown when SQS delay exceeds 900 s
  • SqsQueueNotConfiguredError — Thrown when a job name has no queue URL mapping
  • IQueue — Queue service interface (re-exported from @setu-ts/common)
  • IJob<T> — Job interface (re-exported)
  • JobProcessor<T> — Job processor type (re-exported)
  • AddJobOptions — Options for queue.add() (re-exported)
  • ProcessOptions — Options for queue.process() (re-exported)
  • QueueDepths — { ready, processing, dead } per job name, as the health indicator publishes them
  • RecurringOptions — Options for queue.addRecurring() (re-exported)
  • QueueLogger — Minimal error/warn logger surface the service reports background failures through (structurally compatible with ILogger)
  • QueueDiagnosticsOptions — Opt-in minimized attempt/depth observations for the diagnostics connector (M98f): { enabled: true, instanceAlias, queues, depths? }
  • QueueDepthDiagnosticsOptions — Separately bounded depth collection: { intervalMs, timeoutMs, concurrency }

Registration

import { QueuePlugin } from '@setu-ts/queue-plugin';

// Memory adapter (development/testing)
app.register(
  QueuePlugin({
    adapter: 'memory',
    pollIntervalMs: 1000,
    defaultMaxAttempts: 3,
  }),
);

// Redis adapter (production)
app.register(
  QueuePlugin({
    adapter: 'redis',
    url: config.get('REDIS_URL'),
    pollIntervalMs: 1000,
    defaultMaxAttempts: 3,
  }),
);

// Named instance for multi-queue support
app.register(
  QueuePlugin({
    adapter: 'memory',
    name: 'background',
    pollIntervalMs: 2000,
  }),
);

// RabbitMQ adapter (production, requires amqplib)
app.register(
  QueuePlugin({
    adapter: 'rabbitmq',
    url: config.get('RABBITMQ_URL'),
    prefix: 'myapp.queue',
    pollIntervalMs: 1000,
    defaultMaxAttempts: 3,
  }),
);

// SQS adapter (production, requires @aws-sdk/client-sqs)
app.register(
  QueuePlugin({
    adapter: 'sqs',
    sqs: {
      region: 'us-east-1',
      queues: {
        'send-welcome-email': 'https://sqs.us-east-1.amazonaws.com/123456789012/welcome-emails',
        'process-payment': 'https://sqs.us-east-1.amazonaws.com/123456789012/payments',
      },
      deadLetterQueues: {
        'process-payment': 'https://sqs.us-east-1.amazonaws.com/123456789012/payments-dlq',
      },
    },
    pollIntervalMs: 1000,
    defaultMaxAttempts: 3,
  }),
);

Connection errors. The 'redis' adapter routes the 'error' events of the ioredis client it builds to the application logger through createConnectionErrorReporter (common): the first error of an outage at warn, identical repeats at debug, the recovery at info. Without a listener ioredis prints every reconnect failure to console.error, bypassing the logger and redaction. An injected client gets no listener — it belongs to the caller. Health semantics are unchanged; the indicator still reports the outage.

Command bound. Since M101a the 'redis' adapter’s commandTimeoutMs option (default 15000, 0 disables) is the ioredis commandTimeout on the client it builds, so a paused server makes add() reject and a poll record the failure instead of waiting forever. Not applied to an injected client. A value outside 0–2147483647 throws RangeError when QueuePlugin(...) is called. A depth row in the observations describes the latest cycle only: a name that cycle did not read has no row, and the source’s failure plus depthCoverage: 'partial' say why — an unreadable depth is never a retained zero.

RabbitMQ durability. Since 0.9.0 the 'rabbitmq' adapter publishes every job persistent (persistentMessages, default true) through a publisher-confirm channel. Its ready/delay/dead queues are durable, and a durable queue keeps only persistent messages across a broker restart — before 0.9.0 a restart discarded every job not yet processed. add() resolves once RabbitMQ has accepted the job; a retry or dead-letter acknowledges the reserved job only after its replacement is accepted, so a broker failure in between can run a job twice but never loses it. publishTimeoutMs (default 15000, 0 disables) bounds each publish, queue declarations included; a value outside 0–2147483647 throws RangeError when QueuePlugin(...) is called. A rejection is not proof the job was dropped. An injected connection without createConfirmChannel() keeps a plain channel and the adapter reports one warning through the logger.

Redis namespaces

RedisQueueOptions.keyPrefix and the flat QueuePluginOptions.keyPrefix default to queue:, preserving existing keys. Every Redis key, including recurring and dead-letter keys, uses this prefix. Use a distinct prefix per application on a shared Redis. It must be a non-empty string of at most 128 characters with no control characters; invalid values throw RangeError at adapter or Redis plugin construction. The RabbitMQ-only prefix option does not configure Redis.

RabbitMQ recovery (0.10.0). Connection or channel faults drive automatic reconnect with full-jitter backoff through the common supervisor. Readiness reports down during the fault window. An injected connection receives only a new channel. Successful recovery clears local delivery bookkeeping and lazily re-declares queues. Unacked jobs return to the broker for at-least-once redelivery, possibly with the original attempt count. Disconnect stops scheduled retries and disposes listeners.

Delivery claims (0.10.0). RabbitMQ uses a fresh runtime UUID for each delivery. Redis 5+ uses atomic Lua scripts, server TIME for lease deadlines, the reserving replica’s nowMs (the clock that wrote availableAtMs) to decide which jobs are due, and INCR <keyPrefix><name>:claimseq tokens shared across replicas. Redis’s visibilityTimeoutMs (also a flat QueuePluginOptions field) defaults to 30000, must be from 1000 to 2147483647, and is validated at adapter and Redis plugin construction. The service renews every Math.floor(visibilityTimeoutMs / 3) ms with no overlapping calls and reports a failed or false renewal once per delivery, while the processor continues. Each reserve reclaims up to its limit of expired claims and removes payload-less orphans. Attempts become max(stored attempts, 1) + stalls; dispatch dead-letters an over-budget job without running it. Successful settlement clears the claim and stalls. Redis and RabbitMQ reject stale ack, requeue and dead-letter calls with internal StaleClaimError (not barrel-exported); the service reports failed settlement and the new delivery remains intact. These tokens fence deliveries, not Redis writers; ACLs remain the boundary. Delivery is at least once, including on pauses longer than the Redis lease. A live stuck processor keeps renewing. Drain older replicas before upgrading: their processing entries are immediately reclaimable. An injected Redis facade without eval retains the prior multi-command behavior, no claims or reclaim, and connect() reports once through the connection-error reporter, when one is configured, that visibilityTimeoutMs is inactive.

Declarative processors and behaviours

QueueProcessorDefinition<T = unknown> declares processor(job: IJob<T>): void | Promise<void> as a method. Typed definitions and typed inline processors fit the processors list. The method is called with its definition as this; its member is no longer readonly. QueueProcessorEntry is unchanged.

OptionTypeBehavior
processorsreadonly QueueProcessorEntry[]Declarative process() registrations. A QueueProcessorDefinition is { name, processor, options? }; factory entries resolve at onInit. An array containing a factory registers wholly in onInit, in DECLARED order, because process() is last-wins on a job name. A behaviors factory additionally holds dispatch until the chain is final.
behaviorsreadonly (IIngressBehavior | RegistryFactory<IIngressBehavior>)[]Wraps every processor in declared order with kind: 'queue', job name, the delivered IJob, and its attempt count. A short circuit acknowledges the job; a throw preserves retry, onFailed, and final dead-letter behavior.

Both arms coexist with imperative queue.process() calls. With no behaviours, a processor receives the original job directly and no chain is allocated.

Queue observations (M98f)

The plugin accepts an opt-in diagnostics option that exposes actual attempts and supported depths as minimized observations through the diagnostics connector’s GET /v1/queues. It never changes a processor, a retry, a failure callback, or a settlement.

app.register(
  QueuePlugin({
    adapter: 'redis',
    url: config.get('REDIS_URL'),
    diagnostics: {
      enabled: true,
      instanceAlias: 'mailer',
      // Exact job-name -> display-alias allowlist; unlisted names are neither observed nor counted.
      queues: { 'send-welcome-email': 'welcome-emails' },
      // Optional, separately bounded depth counting; absent by default.
      depths: { intervalMs: 30000, timeoutMs: 2000, concurrency: 2 },
    },
  }),
);

Every QueuePlugin instance — named or not, observed or not — registers one IQueueDiagnosticsSource under CAPABILITIES.QUEUE_DIAGNOSTICS with { multi: true } and WITHOUT claiming the token in provides, so named instances never collide in the resolver. An absent diagnostics option registers an inert source answering disabled and adds nothing to dispatch: no ring, no alias map, no timer, no hook. enabled is the LITERAL true, refused otherwise when QueuePlugin(...) is called; every option is validated there with fixed messages that never echo a value. instanceAlias and each queues alias are a SHAPE — 1–64 UTF-8 bytes with no control character, queue aliases unique, at most 64 queues — and approving an exact alias IS authorizing its disclosure. The instance alias never derives from the plugin name.

An observed attempt is recorded from dispatch until the adapter’s settlement call RETURNED: the approved queue alias, a session-local j<N> alias for the raw job id (a bounded 4,096-entry LRU map; an eviction is counted and gives a later retry a new alias), the attempt number, monotonic durationMs/ageMs, the processor outcome (completed / retryable-error / terminal-error), and the settlement: acknowledged / requeued / dead-lettered when the call completed on an adapter that confirms it, failed when it rejected (the rejection is rethrown exactly as before), and unknown when it completed on an adapter that cannot confirm it. The queue metrics keep their timing — they are recorded before the settlement call — so an outcome is never presented as settlement proof. At most 1,024 attempts are retained and 2,048 observed in flight; droppedAttempts counts an attempt dropped because that bound was reached, because its persisted attempt number was not a positive safe integer, or because its runner failed before reporting a settlement (which releases the slot). No payload, header, raw id, claim token, credential, attempt limit or thrown value is captured: the observer signatures cannot accept them.

AdapterSettlement evidenceDepths
memoryconfirmed (in-process)process-local
redisconfirmed (awaited server commands)shared-backend when the client can count
rabbitmqunknown — an AMQP ack has no broker confirmation (publishes are confirmed)unavailable
sqsunknown — a lapsed claim or failed dead-letter send is absorbed and loggedunavailable

depths is its own opt-in because counting costs backend work: one cycle at bootstrap and one per intervalMs (1,000–300,000), never overlapping, at most concurrency (1–4) count calls in flight, each reported timed-out after timeoutMs (1–30,000) while it keeps its slot until it actually settles. Only approved job names this instance has a processor for are counted. A diagnostic read never counts, reserves, acknowledges, retries or dead-letters anything. unavailable is never reported as zero, and shared-backend counts from several sources or replicas describe the same inventory and must never be summed. The collector closes first on shutdown, clearing its timers and everything it retained.

Trace propagation across the queue hop

A queue job is deferred work whose whole diagnostic value is “which request caused this”. When a CAPABILITIES.TELEMETRY provider is registered, the plugin carries a W3C traceparent from the enqueue to the processor, so a job joins the trace of the request that enqueued it:

  • queue.add() runs inside a enqueue <name> producer span and writes the span’s traceparent into AddJobOptions.headers, merged on top of anything the caller supplied.
  • Every processor — registered through the processors arm, through a processors factory, or imperatively through the resolved capability — runs inside a process <name> consumer span parented from IJob.headers.
  • The map is carried end to end by all four adapters (memory, redis, rabbitmq, sqs) and is exposed to an M86 ingress behaviour as IngressContext.headers, the same member the 'messaging' arm uses.

Without a telemetry capability nothing is injected and behaviour is byte-identical to before: a job carries no framework-written header. A headers map the CALLER passed is still delivered — it is a public option on a public contract, so its delivery never depends on which capabilities happen to be registered.

A job carrying no traceparent — enqueued before this channel existed, or by a non-framework producer — starts a root span rather than failing. addRecurring is deliberately untraced: a recurring job fires on a schedule, so the call that registered it is not the cause of any particular run.

Known limitation. When behaviors are configured, a behaviour runs OUTSIDE the consumer span, because the behaviour chain is applied by a service subclass that is necessarily outermost at dispatch. The processor’s own work is correctly parented either way, and with no behaviours configured the question does not arise. messaging-plugin composes the other way round and runs behaviours inside the span.

Adding Jobs

import type { AddJobOptions, IQueue } from '@setu-ts/queue-plugin';

app.router.post('/users', async (ctx) => {
  const queue = ctx.services.get<IQueue>('queue');
  const user = await createUser(ctx.request.body);

  // Add a background job
  await queue.add('send-welcome-email', {
    userId: user.id,
    email: user.email,
  });

  // Add a delayed job (delayMs in milliseconds)
  await queue.add(
    'send-reminder',
    {
      userId: user.id,
    },
    { delayMs: 86400000 },
  ); // 24 hours

  // Add with custom max attempts
  const options: AddJobOptions = { maxAttempts: 5 };
  await queue.add('process-payment', paymentData, options);

  return ctx.response.status(201).json(user);
});

Processing Jobs

import type { IJob, IQueue } from '@setu-ts/queue-plugin';

app.register({
  name: 'job-processors',
  version: '1.0.0',
  dependencies: ['queue', 'mail'],
  register(ctx) {
    const queue = ctx.services.get<IQueue>('queue');

    queue.process(
      'send-welcome-email',
      async (job: IJob<{ userId: string; email: string }>) => {
        await sendEmail({
          to: job.data.email,
          subject: 'Welcome!',
          body: 'Thank you for joining.',
        });
      },
      { concurrency: 3 },
    );

    queue.process('send-reminder', async (job) => {
      await sendReminder(job.data.userId);
    });
  },
});

Recurring Jobs

import type { IQueue, RecurringOptions } from '@setu-ts/queue-plugin';

const queue = ctx.services.get<IQueue>('queue');

// Every hour using cron expression
const hourlyOptions: RecurringOptions = { cron: '0 * * * *' };
await queue.addRecurring('cleanup-old-sessions', {}, hourlyOptions);

// Every day at 9 AM
await queue.addRecurring('daily-report', { type: 'summary' }, { cron: '0 9 * * *' });

Failure Reporting

The worker loop, the recurring-schedule loop, and the job runner all report failures through the logger capability when one is registered — a failing job logs at error with the job id, name, attempt count and the retry delay (or the dead-letter decision), and an adapter outage logs the poll failure. Nothing is required: with no LoggerPlugin registered the queue keeps running and reports nowhere, and a throwing logger can never take the worker loop down.

Dead-Lettered Jobs

A job that fails on its final attempt is dead-lettered and never delivered again. MemoryQueue exposes its dead set for assertions in tests; the Redis transport keeps its dead set in Redis, and the RabbitMQ transport keeps its dead set in a per-name dead queue (he.queue.<name>.dead).

import { MemoryQueue } from '@setu-ts/queue-plugin';

const adapter = new MemoryQueue();
// ... jobs fail through all their attempts ...

// getDeadLetters<T>(name: string): readonly StoredJob<T>[]
const dead = adapter.getDeadLetters('send-email');
console.log(dead.length, dead[0]?.attempts); // 1 3

Type Reference

// AddJobOptions
interface AddJobOptions {
  readonly delayMs?: number; // Delay before job becomes available (ms)
  readonly maxAttempts?: number; // Maximum retry attempts (default: 3)
  // Transport headers to carry with the job, delivered as `IJob.headers`.
  // Delivered whether or not telemetry is registered; with a telemetry
  // capability present the framework ADDS a `traceparent` alongside them.
  readonly headers?: Readonly<Record<string, string>>;
}

// ProcessOptions
interface ProcessOptions {
  readonly concurrency?: number; // Jobs processed concurrently (default: 1)
  // Invoked ONCE when a job has exhausted its attempts, immediately before it
  // is dead-lettered. Does NOT fire on an attempt that will be retried. A
  // callback that throws or rejects is reported through the logger and
  // swallowed — the dead-letter still happens.
  readonly onFailed?: (job: IJob, error: unknown) => void | Promise<void>;
}

// RecurringOptions
interface RecurringOptions {
  readonly cron: string; // Cron expression (e.g., '0 * * * *')
}

// IJob<T>
interface IJob<T = unknown> {
  readonly id: string;
  readonly name: string;
  readonly data: T;
  readonly attempts: number;
  // Transport headers, mirroring `MessageMetadata.headers` exactly: `{}` means
  // the channel was read and carried nothing; ABSENT means there was no
  // channel. Never substituted — an implementation whose transport cannot
  // carry a header omits the member.
  readonly headers?: Readonly<Record<string, string>>;
}

// IQueue interface
interface IQueue {
  add<T>(name: string, data: T, options?: AddJobOptions): Promise<string>;
  process<T>(name: string, processor: JobProcessor<T>, options?: ProcessOptions): void;
  addRecurring<T>(name: string, data: T, options: RecurringOptions): Promise<void>;
}

Health status

Since M70c the indicator reports two signals: the adapter’s lifecycle (isReady()) and its reachability (isHealthy()).

StatusMeaning
upThe adapter is connected and reachable, or cannot be probed (reachable is 'unknown').
downThe adapter is not connected, or is connected but unreachable.

Since M70k the payload also carries queues, keyed by job name, when the adapter can count its states cheaply — the memory adapter (in process) and the Redis adapter (ZCARD). The key is OMITTED rather than reported as zeros on RabbitMQ and SQS, whose counts need a management API or GetQueueAttributes: “this adapter cannot tell you” and “there is nothing there” are different answers, and an operator acting on a dead-letter alert needs to tell them apart.

Since M90b the payload also carries backlog whenever at least one depth read succeeded: the sum of each successfully-read name’s ready + processing — unfinished work. dead is deliberately EXCLUDED: a dead-lettered job is terminal, already visible per name, and would otherwise make an attended dead-letter queue read as “backlog”. A partial read sums what it could read. backlog is a fact, not a threshold — no status changes because of it and no admission policy reads it.

The counts are read only once the adapter has reported itself reachable, so a down payload carries { adapter, reachable } and no queues. Counting against a backend already known to be unreachable would cost a failing round trip per name on every probe interval and tell an operator nothing that reachable: false does not. The reachability probe itself is cached for 5 seconds and bounded at 2 seconds through createCachedProbe, so concurrent health polls share one backend round trip per TTL and a hung backend cannot hold the response past the bound.

{
  "adapter": "RedisQueue",
  "reachable": true,
  "backlog": 3,
  "queues": { "thumbnail": { "ready": 0, "processing": 0, "dead": 1 } }
}

data reports { adapter, reachable }, where reachable is true, false, or 'unknown' when the adapter has no liveness check.

Scheduler (@setu-ts/scheduler-plugin)

Provides cron jobs, fixed-interval recurring jobs, and one-shot delayed jobs, with retry and distributed locking.

Registers IScheduler under CAPABILITIES.SCHEDULER ('scheduler').

Execution is in-process and time-driven — jobs are not durably persisted, so a restart drops the schedule until the registering plugin re-creates it. For durable background work, use Queue instead.

Exports

  • SchedulerPlugin — Plugin factory for registering the scheduler service; throws SchedulerUnavailableError at register() on Cloudflare Workers, where its timers cannot fire — use WorkersCron with [triggers] crons there instead
  • SchedulerUnavailableError — Thrown by register() when the runtime platform cannot run the scheduler (Cloudflare Workers); catch it by identity to branch on the refusal
  • SchedulerPluginOptions — Plugin configuration options (timezone?, distributedLock?)
  • DistributedLockOptions — Lock configuration (enabled?, storage?, url?, client?, lock?, ttlMs?, acquireTimeoutMs?, commandTimeoutMs?). Since M101a every lock acquire is bounded by acquireTimeoutMs (default 5000, 0 waits indefinitely), whichever lock is in use: an acquire still unsettled at the bound is a skipped fire — logged, counted lockFailed in the execution observations, and re-armed for the next slot — and a token it returns later is released. commandTimeoutMs is the ioredis commandTimeout on the client RedisLock builds (default acquireTimeoutMs, or 15000 when that is 0; never applied to an injected client or lock); a timed-out SET may still apply on the server, so RedisLock releases that exact token before rethrowing. Either value outside 0–2147483647, or a commandTimeoutMs above a non-zero acquireTimeoutMs, throws RangeError when SchedulerPlugin(...) is called
  • IDistributedLock — Lock seam (acquire/release) for a custom lock implementation; optional isHealthy?(): Promise<boolean | undefined> reports reachable, unreachable, or unknown
  • IRedisLockClient — Structural ioredis shape accepted by distributedLock.client; optional ping?(): Promise<unknown> supports lock reachability probes; eval(...) returns Promise<unknown> (M117, widened from Promise<number | string | null> so a fromNodeRedis adapter satisfies it; the lock ignores the result)
  • IScheduler — Scheduler service interface (re-exported from @setu-ts/common)
  • ScheduledJob<T> — Job instance handed to the handler (re-exported)
  • SchedulerJobHandler<T> — Handler callback type (re-exported)
  • ScheduleOptions<T> — Options for cron()/every()/delay() (re-exported)
  • RetryOptions — Retry configuration (re-exported)
  • SchedulerBackoff — 'fixed' | 'exponential' (re-exported)

Registration

import { SchedulerPlugin } from '@setu-ts/scheduler-plugin';

// Process-local locking (default)
app.register(SchedulerPlugin());

// Distributed locking via Redis, for multi-instance deployments
app.register(
  SchedulerPlugin({
    timezone: 'UTC',
    distributedLock: {
      enabled: true,
      storage: 'redis',
      url: config.get('REDIS_URL'),
      ttlMs: 30000,
    },
  }),
);

timezone defaults to 'UTC', and 'UTC' is the only supported value — any other value throws from the factory. Cron expressions are always evaluated in UTC.

When distributedLock is absent or enabled: false, a process-local memory lock is used. With enabled: true and storage: 'redis', an ioredis client is lazily imported (ioredis) unless you inject one via client, or supply your own IDistributedLock via lock (which takes priority over storage). ttlMs defaults to 30000 and must exceed the job’s worst-case runtime — if the lock expires mid-run, another instance may start the same job.

A Redis lock client the plugin builds routes the 'error' events of the ioredis client it builds to the application logger through createConnectionErrorReporter (common): the first error of an outage at warn, identical repeats at debug, the recovery at info. Without a listener ioredis prints every reconnect failure to console.error, bypassing the logger and redaction. An injected client gets no listener — it belongs to the caller. A lock that cannot reach Redis still fails its acquire; the health indicator exposes that outage in data.lock.reachable.

Multi-instance deduplication works across replicas sharing one lock backend. cron and every fires each claim a never-released slot lock keyed on the fire’s intended time (scheduler:job:<name>:<slot>), so two replicas whose timers land anywhere in the same intended slot run the handler exactly once between them; ttlMs bounds how long a claimed slot is remembered, so it must exceed the maximum skew between replicas. delay jobs claim their slot at REGISTRATION, keyed on the job name plus a :once suffix (scheduler:job:<name>:once), because a delay’s intended fire time is now + delayMs and carries per-replica startup skew — the name is what identifies “the same one-shot job” across replicas. The suffix is load-bearing: the bare scheduler:job:<name> is the per-handler mutex, and a slot holding that key would make the mutex acquire at fire time always lose to the slot’s own claim. A delay’s slot is released when the job leaves the registry (on final settlement or remove), on disconnect, or by TTL expiry, so re-registering the same name after it fired gets a fresh slot and fires again.

A delay job is lost if the replica that claimed it leaves before firing. The claim is decided at registration, so a replica that already found the slot held never re-attempts: if the claiming replica crashes or shuts down gracefully between registering the delay and firing it, the original due fire is lost on every replica and nothing reports it. A graceful shutdown releases the slot, so a replica that registers the same name afterwards can claim it and schedule a new fire. The exposure window is the delay itself, so it matters for a long delayMs on a replica set that may lose a pod in that window. cron and every are unaffected: they claim at fire time, so the next fire re-contends. A separate per-handler mutex preserves overlap protection — a second fire of a job whose previous fire is still running is skipped locally. every jobs arm on an absolute epoch grid ((floor(now / interval) + 1) * interval), so replicas registered at different instants agree on slot keys; the first fire may come sooner than one full interval after registration.

Declarative jobs and behaviours

OptionTypeBehavior
jobsreadonly SchedulerJobEntry[]Declarative cron()/every()/delay() registrations. SchedulerJobDefinition is a union discriminated by trigger, so each arm requires its expression, intervalMs, or delayMs; factory entries resolve at onInit. When a behaviors factory is declared, a fire is held until onInit has resolved the chain.
behaviorsreadonly (IIngressBehavior | RegistryFactory<IIngressBehavior>)[]Wraps every job handler in declared order with kind: 'scheduler', job name, delivered ScheduledJob, and its 1-based attempt. Throws use the job’s normal retry behavior.

The chain runs inside the distributed lock: a replica that does not acquire the lock runs neither a behaviour nor the handler. With no behaviours configured, the handler receives its job directly and no chain is allocated.

Scheduling Jobs

import type { IScheduler } from '@setu-ts/common';

app.register({
  name: 'scheduled-jobs',
  version: '1.0.0',
  dependencies: ['scheduler'],
  async register(ctx) {
    const scheduler = ctx.services.get<IScheduler>('scheduler');

    // Cron expression (5-field, UTC)
    await scheduler.cron('cleanup-temp-files', '0 2 * * *', async () => {
      await cleanupTempFiles();
    });

    // Every 5 minutes
    await scheduler.every('health-check', 300000, async () => {
      await runHealthCheck();
    });

    // One-time delayed job — auto-removed once it fires
    await scheduler.delay(
      'send-followup',
      86400000,
      async (job) => {
        await sendFollowupEmail(job.data.userId);
      },
      { data: { userId: '123' } },
    );

    // With retry
    await scheduler.cron(
      'sync-external-api',
      '*/30 * * * *',
      async () => {
        await syncFromExternalApi();
      },
      {
        retry: {
          limit: 3,
          delay: 10000,
          backoff: 'exponential',
        },
      },
    );
  },
});

Every scheduling call is async and throws if name is already scheduled. Job names are unique per scheduler instance.

Cron expressions use the standard 5 fields (minute hour day-of-month month day-of-week) and support *, lists (1,15), ranges (1-5), and steps (*/30, 1-59/2). An invalid field or expression throws.

Retry Behavior

Without retry, a handler that throws runs once and the failure is logged. With retry, the job is re-attempted up to limit total attempts (limit: 3 means at most 3 runs, not 3 retries after the first). delay is the base delay in milliseconds before the first retry; backoff: 'fixed' reuses it unchanged, while backoff: 'exponential' doubles it per attempt. job.attempts is 1-based and increments on each attempt. A job that exhausts its retries is logged and does not crash the scheduler; recurring jobs still fire on their next scheduled tick.

Managing Jobs

const scheduler = ctx.services.get<IScheduler>('scheduler');

// Pause (idempotent — pausing a paused job is a no-op)
await scheduler.pause('cleanup-temp-files');

// Resume (idempotent — resuming a running job is a no-op)
await scheduler.resume('cleanup-temp-files');

// Remove
await scheduler.remove('cleanup-temp-files');

// Next run time, as epoch milliseconds
const nextRun = await scheduler.getNextRun('cleanup-temp-files');

All four throw if no job with that name exists — including after a delay job has fired and auto-removed itself. getNextRun additionally throws if the job is currently paused.

resume re-arms from the current time rather than resuming the original countdown: cron jobs compute the next fire from now, delay jobs re-arm the full original delayMs from now, and every jobs resume on the next epoch grid boundary of their interval ((floor(now / intervalMs) + 1) * intervalMs) — not a full interval after now. This is breaking versus 0.1.0-alpha.8, whose contract stated the interval “restarts from now”: the fire may now come sooner than one full interval after resume (never later). Grid alignment at resume is deliberate — it is the same alignment that makes replicas agree on fire times and slot keys, and a resume that restarted the phase would let one replica’s resumed job drift out of slot agreement with the others.

Timing and registration refusals (M113m)

delay, every, cron and retry backoff wait in chunks of at most 2147483647 ms and check the wall clock on every wake. A job never runs before its due time, including after an early wake or a clock step backwards. There is no upper limit on a delay or interval: long delays and monthly cron waits are honoured instead of being clamped by Node, Deno or Bun to about 1 ms.

The async registration call rejects with a RangeError for these values:

RegistrationRefused valueMessage fragment
delaydelayMs not finite or < 0delayMs must be a finite number >= 0
everyintervalMs not finite or <= 0intervalMs must be a finite number > 0
delay, every, cronretry.delay not finite or < 0retry.delay must be a finite number >= 0

The message names the job, option and rule without echoing the rejected value. Zero delayMs and zero retry.delay are accepted; large finite values are accepted. These refusals are breaking: fix the value before registering the job.

Disconnect, remove and pause cancel pending retry backoff without another attempt. A one-shot that already succeeded or exhausted its retries is removed even while paused and is not re-run. A one-shot paused mid-retry stays registered; resume starts it from attempt 1 using the original delay. Recurring jobs resume at the next cron/interval boundary. Remaining backoff is discarded. A cancelled fire is not reported as success: diagnostics settle it with false, counting it in failed because there is no separate cancellation counter. An in-flight handler may still reach a final success or failure after cancellation. If a job is resumed while that handler is still running, it is armed only once the handler settles, so the two never overlap. Disconnect releases every one-shot :once slot this service holds, each release bounded by acquireTimeoutMs; resume() rejects on a disconnected service, and after reconnecting a one-shot re-claims its slot, so a replica that claimed it meanwhile runs the job instead. A pause, remove or disconnect during that re-claim wins over the resume, and a second resume() waits for the first.

IScheduler Interface

interface IScheduler {
  cron<T>(
    name: string,
    expression: string,
    handler: SchedulerJobHandler<T>,
    options?: ScheduleOptions<T>,
  ): Promise<void>;
  every<T>(
    name: string,
    intervalMs: number,
    handler: SchedulerJobHandler<T>,
    options?: ScheduleOptions<T>,
  ): Promise<void>;
  delay<T>(
    name: string,
    delayMs: number,
    handler: SchedulerJobHandler<T>,
    options?: ScheduleOptions<T>,
  ): Promise<void>;
  pause(name: string): Promise<void>;
  resume(name: string): Promise<void>;
  remove(name: string): Promise<void>;
  getNextRun(name: string): Promise<number>;
}

interface ScheduledJob<T = unknown> {
  readonly id: string;
  readonly name: string;
  readonly data: T;
  readonly attempts: number;
}

Health status

The plugin registers a 'scheduler' health indicator with data: { connected, lock: { reachable: true | false | 'unknown' } }. Status stays lifecycle: connected → up, disconnected → down. Lock reachability is diagnostic because a shared lock outage stops scheduled fires while request serving can continue; alert on data.lock.reachable.

The indicator probes only while connected, caches answers for 5 s and bounds each probe at 2 s using runtime clocks and timers. A missing IDistributedLock.isHealthy, an undefined answer, or a timed-out/rejecting custom probe reports 'unknown'. Disconnected checks report down with connected: false and lock.reachable: 'unknown' without probing, even if an answer was cached.

MemoryLock.isHealthy() answers true. RedisLock.isHealthy() answers false before connect and after disconnect, undefined for a client without optional ping, and otherwise true when PING resolves or false when it rejects. A built client’s PING is bounded by its configured commandTimeout; injected clients keep their own configuration.

An onClose hook clears all armed timers and disconnects the Redis lock on shutdown.


Resilience (@setu-ts/resilience-plugin)

Composes four pure, in-process resilience patterns — circuit breaker, retry with backoff, timeout, and bulkhead — around an arbitrary () => Promise<T>. Zero external dependencies.

Registers IResilienceService under CAPABILITIES.RESILIENCE ('resilience').

The service is stateless at the plugin level: it holds no timers, connections, or global state, so it registers no health indicator and no onClose hook. wrap builds the pattern chain once and returns a hardened callable; the circuit-breaker and bulkhead state persist across every invocation of that callable and are garbage-collected with it. Resilience protects an arbitrary async function — it is not HTTP middleware and does not itself retry HTTP requests, talk to Redis, or persist breaker state across instances.

Exports

  • ResiliencePlugin — Plugin factory registering the resilience service
  • ResiliencePluginOptions — Plugin configuration: defaultCircuitBreaker?, defaultRetry?, defaultBulkhead?, each consumed when a wrap sets the matching field to true
  • TimeoutError — Thrown when a call exceeds its per-attempt timeout deadline; answered 504 Gateway Timeout — the framework acted as an intermediary to a protected call that did not answer in time
  • BulkheadFullError — Thrown when a bulkhead sheds a call (concurrency saturated, queue full); answered 503 Service Unavailable — shedding is the bulkhead’s purpose
  • CircuitOpenError — Thrown when an open circuit breaker fails fast without invoking the call; answered 503 Service Unavailable — the same load-shedding semantics on behalf of a failing dependency

The three statuses are part of the contract (X32-7), and none of the three carries Retry-After: the hint channel (ErrorResponseInit) has no header, the bulkhead’s queue drains on the order of one protected call’s latency — which the framework does not measure — and 503 is itself the retryable signal (RFC 9110). The rate limiter’s Retry-After is the deliberate asymmetry: a fixed window genuinely knows when it resets.

  • IResilienceService — Resilience service interface (re-exported from @setu-ts/common)
  • WrapOptions — Pattern-selection options for wrap (re-exported)
  • CircuitBreakerPolicy — threshold, timeout (rolling failure window ms), resetTimeout (re-exported)
  • RetryPolicy — limit, delay, backoff (re-exported)
  • BulkheadPolicy — maxConcurrent, maxQueue? (re-exported)
  • BackoffStrategy — 'fixed' | 'exponential' (re-exported)

Registration

import { ResiliencePlugin } from '@setu-ts/resilience-plugin';

app.register(
  ResiliencePlugin({
    defaultCircuitBreaker: { threshold: 5, timeout: 10_000, resetTimeout: 30_000 },
    defaultRetry: { limit: 3, delay: 100, backoff: 'exponential' },
    defaultBulkhead: { maxConcurrent: 10, maxQueue: 20 },
  }),
);

Programmatic API

import type { IResilienceService } from '@setu-ts/common';
import { CircuitOpenError, TimeoutError } from '@setu-ts/resilience-plugin';

const resilience = ctx.services.get<IResilienceService>('resilience');

// `true` uses the plugin default; a policy object overrides per-wrap.
const guarded = resilience.wrap(() => externalApi.fetchRates(), {
  bulkhead: true,
  circuitBreaker: true,
  retry: { limit: 3, delay: 100, backoff: 'exponential' },
  timeout: 2000,
});

try {
  const rates = await guarded();
} catch (error) {
  if (error instanceof CircuitOpenError) {
    /* fail-fast: dependency is down */
  }
  if (error instanceof TimeoutError) {
    /* attempt exceeded the 2000ms deadline */
  }
}

The wrap signature and its options:

type ResilientCall<T> = (signal: AbortSignal) => Promise<T>;
type HardenedCall<T> = (signal?: AbortSignal) => Promise<T>;

interface IResilienceService {
  wrap<T>(fn: ResilientCall<T>, options?: WrapOptions): HardenedCall<T>;
}

interface WrapOptions {
  readonly circuitBreaker?: boolean | CircuitBreakerPolicy;
  readonly retry?: boolean | RetryPolicy;
  readonly timeout?: number;
  readonly bulkhead?: boolean | BulkheadPolicy;
}

When multiple patterns are enabled they compose in one fixed order, outermost to innermost: bulkhead → circuit breaker → retry → timeout → fn. A queue-full bulkhead never touches the breaker, retry, or fn; an open breaker fails fast before any retry attempt; each retry attempt gets its own timeout. A field set to true with no matching default* policy configured throws at wrap time.

Hoist the wrapped call. The state-preserving closure is built once per wrap — a wrap() written inside a handler constructs a fresh breaker on every request, so the breaker never opens while retry and timeout keep working (the broken shape looks identical to the working one). Wrap at module or plugin scope and call the returned closure per request.

Cancellation

Each attempt is handed an AbortSignal. On a timeout deadline that signal is aborted with the same TimeoutError instance the returned promise rejects with, so a timeout has one error identity whether observed through catch or through signal.reason:

const guarded = resilience.wrap((signal) => fetch(url, { signal }), { timeout: 2000 });
await guarded(); // rejects with TimeoutError, and the fetch is aborted

The returned callable also accepts a caller-owned signal. An outer abort propagates into the protected call, stops the retry loop between attempts, and rejects a call still queued behind the bulkhead:

const controller = new AbortController();
const pending = guarded(controller.signal);
controller.abort(); // pending rejects with the caller's reason

Cancellation is cooperative: a call that ignores the signal still runs to completion, and only the caller’s await rejects. Passing a zero-argument () => Promise<T> remains valid — it simply cannot be cancelled.

Breaker/bulkhead state is per-process and per-wrap; there is no shared state across instances.


Storage (@setu-ts/storage-plugin)

Provides file storage abstraction.

Registration

import { createUploadMiddleware, getUploadedFile, StoragePlugin } from '@setu-ts/storage-plugin';
import type { IStorage } from '@setu-ts/common';
import { CAPABILITIES } from '@setu-ts/common';

app.register(
  StoragePlugin({
    provider: 's3',
    options: {
      bucket: config.get('S3_BUCKET'),
      region: config.get('AWS_REGION'),
      accessKeyId: config.get('AWS_ACCESS_KEY_ID'),
      secretAccessKey: config.get('AWS_SECRET_ACCESS_KEY'),
    },
  }),
);

Upload middleware

The upload surface is a free exported middleware factory (createUploadMiddleware), NOT a method on IStorage. Parsed files are exposed through the committed per-request ctx.state bag plus a typed getUploadedFile() helper.

const uploadMw = createUploadMiddleware({
  fieldname: 'file', // parts with any other name are dropped
  maxSize: 10 * 1024 * 1024, // per file; exceeding it answers 413
  allowedMimeTypes: ['image/jpeg', 'image/png'], // optional
  maxFiles: 5, // optional; exceeding it answers 400
});

app.router.post('/upload', {
  middleware: [uploadMw],
  handler: async (ctx) => {
    // `getUploadedFile` returns `UploadedFile | undefined`, and the fieldname
    // must match the middleware's — it filters parts to that name.
    const file = getUploadedFile(ctx, 'file');
    if (!file) return ctx.response.status(400).json({ error: 'No file' });

    const storage = ctx.services.get<IStorage>(CAPABILITIES.STORAGE);
    // `file.name` is the form field name; `file.filename` is the client's original file name.
    const key = `uploads/${file.filename}`;
    // Without `contentType` the object is stored as `application/octet-stream`
    // and the signed URL below downloads it instead of rendering it.
    await storage.put(key, file.data, { contentType: file.mimeType });

    const url = await storage.getSignedUrl(key, { expiresIn: 3600 });
    return ctx.response.json({ url, key });
  },
});

Refusal statuses: a body or file over its limit answers 413; a malformed body, a disallowed MIME type and too many files answer 400. A body refused by RuntimePlugin({ maxBodyBytes }) before the middleware ever sees it also answers 413, served from that refusal’s own status hint — it is not reported as a malformed multipart, which is what a client would otherwise be told to fix.

maxBodyBytes (default 50 MB) caps the body the middleware will PARSE, with the effective bound min(maxSize * 2 + framing, maxBodyBytes). It bounds the parse THIS middleware triggers — which, now that forms are read through the shared memoized accessor (M94b), is not necessarily the request’s first parse — not the initial read: the body is read lazily by the first consumer (M87), and RuntimePlugin({ maxBodyBytes }) (M90a) is the bound that covers every reader, including a CSRF middleware that parsed the same form earlier. IRequest still exposes no body stream, so no middleware can decline to read; capping the read for every reader needs a streaming request body.

Since M94b the middleware parses through the request’s formData accessor (or the same shared parseFormBody when the request omits it), and a part carrying NO filename under the field name is a plain form value, not an upload — the web standard’s answer; see the CHANGELOG migration note.

Usage — buffered download

app.router.get('/files/:key', async (ctx) => {
  const storage = ctx.services.get<IStorage>(CAPABILITIES.STORAGE);
  const file = await storage.get(ctx.params.key);
  return ctx.response.header('content-type', 'application/octet-stream').send(file);
});

Usage — streaming download (getStream?)

app.router.get('/files/stream/:key', async (ctx) => {
  const storage = ctx.services.get<IStorage>(CAPABILITIES.STORAGE);
  const stream = await storage.getStream!(ctx.params.key);
  return ctx.response.header('content-type', 'application/octet-stream').stream(stream);
});

Demand-driven streaming is a property of S3Provider, not of getStream? in general. On that path, serving a large object to a slow client does not pull the object into memory: the provider stays 2-3 MiB ahead of the consumer (measured constant in elapsed time and in bytes delivered), a consumer that stops reading stops the wire, and cancelling releases the upstream connection. On every other provider a large object becomes memory-resident regardless of how slowly the client reads — GcsProvider and AzureBlobProvider stream natively but drain their SDK stream eagerly with no demand signal (cancelling releases the upstream stream, but cannot undo what has already been drained), while MemoryProvider and LocalStorageProvider have no native getStream at all and take StorageService’s buffered fallback. The storage-plugin README carries the per-provider table. Note also that process RSS does not measure any of this — a large download raises the allocator high-water mark while retaining nothing, and importing the AWS SDK costs ~29 MiB by itself.

Providers

The plugin ships five named providers plus a first-class B2 preset that reuses S3 under the hood.

ProviderTypeKey optionsAuth / SDKNotes
MemoryProvider'memory'(none)N/A (zero-dep)Default. In-process map. Every runtime incl. Cloudflare.
LocalStorageProvider'local'rootDir?: stringN/A (runtime.fs seam)Node/Deno/Bun only. Throws on connect when fs absent.
S3Provider's3'bucket, region, accessKeyId, secretAccessKey, endpoint?Lazy @aws-sdk/client-s3 + presignerR2 and MinIO via endpoint. Real presigned GET URLs.
GcsProvider'gcs'bucket, projectId?Lazy @google-cloud/storageReal signed URLs via file.getSignedUrl.
AzureBlobProvider'azure'containerName, connectionString?, accountName?, accountKey?Lazy @azure/storage-blobSAS requires accountKey. No @azure/identity needed.
B2 preset'b2'bucket, region, accessKeyId, secretAccessKeySame as S3 (reuses S3Provider)Endpoint defaults to https://s3.<region>.backblazeb2.com. No separate class.

All cloud providers support an injectable client option that bypasses the lazy import, but the three types are NOT alike and the difference decides what you can pass:

Option typeMirrors its SDK?What injecting it means
IGcsClientyesA real @google-cloud/storage client fits structurally.
IAzureBlobClientyesA real @azure/storage-blob client fits structurally.
IS3BackendnoThis package’s own backend surface — implementing it, not handing over an S3Client.

IS3Backend was named IAwsS3Client before the alpha.9 release, which promised something it never was: @aws-sdk/client-s3’s surface is send(command), so a real S3Client was refused with Injected S3 client is missing required methods. There is consequently no supported way to configure the underlying SDK client (custom retry policy, timeout, proxy agent). The old name is still exported as a deprecated alias of the same type (AI_GUIDELINES §9.2), so existing imports keep compiling; new code should use IS3Backend.

IStorage methods

MethodDescription
put(path, data, options?: PutObjectOptions): Promise<void>Stores an object. options carries contentType and metadata; S3, GCS, Azure and Cloudflare R2 record them on the object, while the memory and local providers accept and do not persist them.
get(path: string): Promise<Uint8Array>Retrieves an object. Throws if absent.
delete(path: string): Promise<boolean>Deletes an object. Returns true if present.
exists(path: string): Promise<boolean>Checks existence.
getSignedUrl(path: string, options: SignedUrlOptions): Promise<string>Creates a time-limited URL. Per-provider semantics: Memory → synthetic memory://…?expires=…; LocalStorage → rejects; S3 → presigned GET; GCS → signed URL; Azure → SAS (requires accountKey).
getStream?(path: string): Promise<ReadableStream<Uint8Array>>Optional. Streams an object for zero-copy downloads. Native on S3/GCS/Azure; Memory/Local fall back to wrapping get(path) in a one-chunk stream. Absent objects throw.

Per-provider getSignedUrl behavior

ProviderBehavior
MemoryProviderReturns deterministic synthetic URL memory://<encoded-key>?expires=<epoch-seconds>. Test/process affordance only — never grants real access.
LocalStorageProviderRejects with Error('LocalStorageProvider does not support signed URLs; use the s3, gcs, or azure provider').
S3ProviderReal presigned GET URL via getSignedUrl(GetObjectCommand, { expiresIn }).
GcsProviderReal signed URL via file.getSignedUrl([{ action: 'read', expires }]).
AzureBlobProviderReal SAS URL via generateBlobSASQueryParameters. Requires accountKey; rejects if only managed-identity / account-name-only config.

Health status

Since M70c the indicator reports two signals: the provider’s lifecycle (isReady()) and its reachability (isHealthy()).

StatusMeaning
upThe provider is connected and reachable, or cannot be probed (reachable is 'unknown').
downThe provider is not connected, or is connected but unreachable.

data reports { provider, reachable }, where reachable is true, false, or 'unknown' when the provider has no liveness check.

Storage diagnostics (M98m)

StoragePlugin({ diagnostics: { enabled: true, alias: 'primary' } }) counts every public call its OWN StorageService makes — put, get, delete, exists, getSignedUrl, getStream — for the local diagnostics connector’s GET /v1/storage. enabled must be the literal true; alias is 1–64 UTF-8 bytes with no control character; any other key is refused. All three refuse when StoragePlugin(...) is called, with fixed messages that never echo a value.

Every instance, opted in or not, registers an IStorageDiagnosticsSource under CAPABILITIES.STORAGE_DIAGNOSTICS with { multi: true } (not in provides); without the option it answers disabled and nothing is attached to the service — no clock read and no extra promise on any storage call. With it, each call reads the runtime monotonic clock twice (at start and at settlement — every call is timed, so ageMs, lastDurationMs and lastBytes describe one settlement) and returns a promise derived from the provider’s that re-rejects with the original reason, so an unhandled provider rejection stays unhandled; results, null semantics, the getStream? fallback and the ORIGINAL rejection reason are unchanged, and a synchronous provider throw reaches the caller as a rejection with the same error, exactly as without the option. A null get is impossible (absent objects throw and settle as failed); a false exists and a false delete are successful calls. getSignedUrl mints a URL, not a data transfer: its record carries lastDurationMs: null and lastBytes: null. lastBytes is the byte length of the last settled buffer for put and get, and null for the non-buffered operations. A getStream counts the open, not the drain. Records expire 60 seconds after their last observation and a snapshot whose freshest record is older than 30 seconds is stale. A clock failure latches collection-failed without changing any application result; closing the plugin detaches the collector. Object paths, stored bytes, content types, signed URLs and error text never reach the collector, the wire, or the client.

MailPlugin() (@setu-ts/mail-plugin)

Provides email sending: registers an IMailer under CAPABILITIES.MAIL, backed by a pluggable provider. The default provider is 'log' (zero-dependency, every runtime — records/logs each message instead of sending it). No mail SDK is a hard dependency — the SMTP and SES providers accept an injected client facade or lazily import their package (AI_GUIDELINES §12.2), and SendGrid sends over web-standard fetch. A zero-dependency template engine renders named {{ variable }} bodies for sendTemplate.

Registration

import { MailPlugin } from '@setu-ts/mail-plugin';

// Log provider (default) — records mail instead of sending; for tests/local dev
app.register(MailPlugin({ defaults: { from: 'noreply@myapp.com' } }));

// SMTP via nodemailer (Node/Deno/Bun only — not Cloudflare Workers)
app.register(
  MailPlugin({
    provider: 'smtp',
    options: {
      host: config.get('SMTP_HOST'),
      port: 587,
      auth: { user: config.get('SMTP_USER'), pass: config.get('SMTP_PASS') },
    },
    defaults: { from: 'noreply@myapp.com' },
    templates: { welcome: { html: '<h1>Hi {{ name }}</h1>', text: 'Hi {{ name }}' } },
  }),
);

// SendGrid v3 HTTP API (zero dependency, Workers-portable)
app.register(
  MailPlugin({
    provider: 'sendgrid',
    options: { apiKey: config.get('SENDGRID_API_KEY') },
    defaults: { from: 'noreply@myapp.com' },
  }),
);

Usage

import { CAPABILITIES } from '@setu-ts/common';
import type { IMailer } from '@setu-ts/common';

app.router.post('/users', async (ctx) => {
  const mailer = ctx.services.get<IMailer>(CAPABILITIES.MAIL);
  const user = await createUser(ctx.request.body);

  await mailer.send({
    to: user.email,
    subject: 'Welcome to MyApp',
    html: '<h1>Welcome!</h1><p>Thank you for joining.</p>',
    text: 'Welcome! Thank you for joining.',
  });

  // Using templates — `subject` is required on the envelope (the committed
  // IMailer.sendTemplate signature keeps it); the template supplies html/text.
  await mailer.sendTemplate(
    'welcome',
    {
      to: user.email,
      subject: 'Welcome to MyApp',
    },
    {
      name: user.name,
    },
  );

  return ctx.response.status(201).json(user);
});

Options

OptionProviderDescription
provider—'log' (default), 'smtp', 'ses', 'sendgrid'.
defaults.fromallSender applied when a message omits from; else send throws.
templatesallNamed body templates for sendTemplate: { html?, text? } strings, or { view, text? } view components rendered through CAPABILITIES.VIEW (M102).
options.host / port / secure / authsmtpnodemailer transport config (ignored when transport injected).
options.transportsmtpInjected ISmtpTransport facade (bypasses the lazy import).
options.region / accessKeyId / secretAccessKeysesAWS client config (ignored when client injected).
options.clientsesInjected ISesClient facade (bypasses the lazy SDK import).
options.apiKey / endpointsendgridSendGrid API key (Bearer) and endpoint (default v3 send URL).
options.httpsendgridInjected fetch-shaped function (defaults to global fetch).
options.sinklogCalled with each sent OutgoingMail — a read-back seam.

Exports

  • MailPlugin(options?) — plugin factory. createProvider(type, options, ctx) — provider builder.
  • MailService — the IMailer implementation (default-from resolution + template dispatch).
  • TemplateEngine, escapeHtml — the template renderer (both arms; render is asynchronous since M102) and its HTML escaper.
  • LogProvider, SmtpProvider, SesProvider, SendGridProvider — provider classes.
  • adaptNodemailerModule / loadNodemailerModule / toNodemailerMessage / validateSmtpTransport, adaptSesModule / loadSesModule / toSesInput / validateSesClient, toSendGridBody — provider adapter/mapper/validator helpers.
  • MailServiceOptions, MailPluginOptions, MailProviderType, MailProviderOptions, MailTemplate, MailStringTemplate, MailComponentTemplate, OutgoingMail, RenderedTemplate, LogProviderOptions, SmtpProviderOptions, NodemailerModule, SesProviderOptions, SesSdkModule, SendGridProviderOptions — types.
  • ISmtpTransport, ISesClient, IMailHttp — structural injection types.
  • IMailer, MailMessage — re-exported from @setu-ts/common.

Notes

  • IMailer.isHealthy?() (OPTIONAL, added in 0.7.0) reports the transport’s REACHABILITY, distinct from whether the mailer was constructed: true reachable, false contacted and unreachable, undefined when the question cannot be asked — never read undefined as healthy. MailService implements it by delegating to the configured provider’s own probe, so a provider with none yields undefined. Every built-in provider that ships a probe answers concretely — log is always true (it touches no network) and sendgrid calls the scopes endpoint — so the undefined arm is reached by the two providers that DROP their probe when the injected dependency cannot support one: ses with a client exposing no isHealthy, and smtp with a transport exposing no verify(). The mail health indicator reads reachability through this same method rather than past it to the provider, so the capability and the indicator cannot disagree. It exists on IMailer so a HOLDER of the capability can ask — notification-plugin’s email channel does, and AI_GUIDELINES §2.2 forbids it importing mail-plugin to find a probe.
  • sendTemplate’s envelope is Omit<MailMessage, 'html' | 'text'> — subject stays REQUIRED; the template provides the html/text bodies only, never the subject.
  • MailTemplate is a union of two arms that never mix in one template (a template carrying both view and html is a compile error). String arm (MailStringTemplate): in an HTML template, interpolated data values are HTML-escaped (& < > " '); text templates substitute raw. A placeholder whose key is absent from data, or an unknown template name, rejects. Component arm (MailComponentTemplate, M102): view and the optional text are Components rendered through the IViewEngine registered under CAPABILITIES.VIEW, with data passed verbatim as each component’s props — view into html, text into text (verbatim; write it as a plain (props) => string function, since one written with the html tag or JSX is HTML-escaped by its runtime and puts entities such as &amp; into a plain-text body). Escaping is the rendering runtime’s (an html tagged template and a JSX component escape; a hand-written template literal does not). There is NO missing-key check on this arm: an absent key renders as undefined rather than throwing, because the committed sendTemplate types data as Record<string, unknown>; the compile-time route is engine.render(Component, props) by hand, then mailer.send. MailPlugin declares CAPABILITIES.VIEW in optionalDependencies and resolves the engine ONCE at register(); a component template configured with no provider fails at register() naming both remedies (register ViewPlugin or any other provider of the token, or remove the component templates) — never on the first sendTemplate. Only the registry is consulted: a container-supplied engine registered by DecoratorPlugin lands after this plugin has run. A rendering failure (ViewRenderError, UnresolvedSuspenseError) propagates unwrapped and the provider is never reached.
  • TemplateEngine.render(name, data) returns Promise<RenderedTemplate> (asynchronous since M102, because IViewEngine.render may answer a promise); every refusal is a rejection.
  • LogProvider never sends real email — .messages retains the most recent 1,000 messages in send order, while sink receives every message and logging uses ctx.logger. Like every provider, it refuses a send outside connect().. disconnect() with 'LogProvider is not connected', so a send after app.stop() rejects. SmtpProvider needs raw sockets, so it is Node/Deno/Bun only; SendGridProvider is the Cloudflare Workers-portable path.

Health status

Since M70c the indicator reports two signals: the provider’s lifecycle (isReady()) and its reachability (isHealthy()).

StatusMeaning
upThe provider is connected and reachable, or cannot be probed (reachable is 'unknown').
downThe provider is not connected, or is connected but unreachable.

data reports { provider, reachable }, where reachable is true, false, or 'unknown' when the provider has no liveness check (e.g. the log provider always reports true).

Notifications (@setu-ts/notification-plugin)

Provides multi-channel notifications.

Registration

import { NotificationPlugin } from '@setu-ts/notification-plugin';

app.register(
  NotificationPlugin({
    channels: {
      // Email delegates to the `IMailer` from MailPlugin (M29) — no options here.
      email: { provider: 'mail' },
      sms: {
        provider: 'twilio',
        options: {
          accountSid: config.get('TWILIO_SID'),
          authToken: config.get('TWILIO_TOKEN'),
          from: config.get('TWILIO_FROM'), // required — the sender number
        },
      },
      push: {
        provider: 'fcm',
        options: {
          projectId: config.get('FCM_PROJECT_ID'),
          clientEmail: config.get('FCM_CLIENT_EMAIL'),
          privateKey: config.get('FCM_PRIVATE_KEY'), // PEM PKCS#8, from the service-account JSON
        },
      },
      slack: { provider: 'slack', options: { webhookUrl: config.get('SLACK_WEBHOOK') } },
    },
  }),
);

ChannelConfig is discriminated on provider, so each entry’s options are checked against that provider’s option type: omitting from above is a compile error, not a startup throw.

Usage

app.router.post('/orders', async (ctx) => {
  const notifier = ctx.services.get<INotifier>('notification');
  const order = await createOrder(ctx.request.body);

  // Multi-channel
  await notifier.send({
    channels: ['email', 'sms'],
    to: { email: order.customerEmail, phone: order.customerPhone },
    subject: 'Order Confirmed',
    body: `Your order ${order.id} has been confirmed.`,
  });

  // Slack-only
  await notifier.send({
    channels: ['slack'],
    to: { channel: '#orders' },
    body: `New order: ${order.id}`,
  });

  return ctx.response.status(201).json(order);
});

Options

OptionProviderDescription
channels—Required map of dispatch name → ChannelConfig. Keys are the names a caller passes in send({ channels: [...] }).
channels.*.provider—'mail' | 'twilio' | 'fcm' | 'slack' — selects the channel class and transport.
options.accountSid / authToken / fromtwilioTwilio credentials and sender number; all three required (construction throws).
options.projectIdfcmFirebase project id, addressed by the v1 messages:send URL; required (construction throws).
options.clientEmail / privateKeyfcmService-account email and PEM PKCS#8 key that sign the OAuth2 assertion; required unless tokenSource is supplied.
options.tokenSourcefcmOverrides token acquisition (e.g. a GCP metadata server); when set the credential fields above are unused.
options.webhookUrlslackSlack incoming-webhook URL; required (construction throws).
options.httptwilio, fcm, slackInjected INotificationHttp (defaults to createDefaultNotificationHttp(), i.e. global fetch).

The mail arm takes no options: email transport is configured on MailPlugin (M29) and resolved through CAPABILITIES.MAIL.

Exports

  • NotificationPlugin(options) — plugin factory. createChannel(name, config, ctx?) — channel builder. createProvider(config, ctx?) — transport builder, overloaded so each ChannelConfig arm returns its own transport port (mail → IMailer, twilio → SmsTransport, fcm → PushTransport, slack → SlackTransport).
  • NotificationService — the INotifier implementation (parallel fan-out + AggregateError).
  • EmailChannel, SmsChannel, PushChannel, SlackChannel — channel classes (address extraction and payload shaping).
  • TwilioProvider, FcmProvider, SlackProvider — zero-dependency HTTP transports.
  • createDefaultNotificationHttp(fetchImpl?) — the fetch-backed default INotificationHttp.
  • NotificationPluginOptions, ChannelsMap, ChannelConfig, MailChannelConfig, TwilioChannelConfig, FcmChannelConfig, SlackChannelConfig, ProviderType, NotificationTransport, TwilioProviderOptions, FcmProviderOptions, SlackProviderOptions — configuration types.
  • INotificationHttp, NotificationHttpResponse, SmsTransport, SmsMessage, PushTransport, PushMessage, SlackTransport, SlackMessage — injection/transport types.
  • INotifier, NotificationMessage — re-exported from @setu-ts/common.

Notes

  • INotifier has exactly one method, send. There is no sendEmail / sendSms / sendSlack — single-channel dispatch is send({ channels: ['sms'], … }).
  • Channels dispatch in parallel via Promise.allSettled: one failing channel never aborts the others. When any fail, send throws an AggregateError (message 'One or more notification channels failed') whose errors are always Error instances, one per failed channel, including unknown channel names. An empty channels array resolves without dispatching.
  • Each channel reads its own address key out of to and throws when it is absent: to.email (email), to.phone (sms), to.token (push). Slack’s to.channel is optional — omitted, the webhook’s default channel applies. subject becomes the mail subject (defaulting to '(no subject)') and the push notification title; SMS and Slack ignore it. Built-in channels do not read metadata — it is there for custom NotificationChannel implementations.
  • Configuring an email channel without a registered mail capability throws during register, so the misconfiguration surfaces at startup rather than at first send.
  • All three HTTP providers are zero-dependency (web-standard fetch) and Workers-portable; the email channel inherits M29’s provider constraints (SmtpProvider needs raw sockets). FcmProvider targets FCM HTTP v1 (POST /v1/projects/{projectId}/messages:send), authenticating with an OAuth2 bearer token minted from a service account: it signs an RS256 JWT assertion with runtime.subtle and caches the resulting token until shortly before it expires, so a send costs one request in the steady state. A push channel using the default signer therefore requires CAPABILITIES.RUNTIME and throws during register without it; supplying tokenSource removes that requirement.
  • A notification health indicator reports 'up' with the configured channel names. There is no onClose: the providers hold no socket, timer, or connection.

Feature Flags (@setu-ts/feature-flags-plugin)

A failed provider keeps feature-flags up with data.healthy: false and data.detail when available. /ready stays 200 while the last good snapshot serves. Alert on data.healthy instead of the overall health status.

Provides feature flag capability.

Registration

import { FeatureFlagsPlugin } from '@setu-ts/feature-flags-plugin';

app.register(
  FeatureFlagsPlugin({
    provider: 'config',
    options: {
      flags: {
        'new-dashboard': { enabled: true, percentage: 50 },
        'beta-features': { enabled: false, users: ['user1', 'user2'] },
        'dark-mode': { enabled: true },
      },
    },
  }),
);

Usage

app.router.get('/dashboard', async (ctx) => {
  const flags = ctx.services.get<IFeatureFlags>('feature-flags');
  const user = ctx.request.user;

  if (flags.isEnabled('new-dashboard', { userId: user?.id })) {
    return ctx.response.json({ dashboard: 'new' });
  }

  return ctx.response.json({ dashboard: 'old' });
});

// Middleware (free-function guard — IFeatureFlags has no middleware method)
import { createFlagGuard } from '@setu-ts/feature-flags-plugin';

app.router.get('/beta', {
  middleware: [createFlagGuard('beta-features', { fallback: '/not-found' })],
  handler: async (ctx) => {
    /* ... */
  },
});

Options

OptionProvider(s)RequiredDescription
providerallyesDiscriminant: 'config', 'memory', 'database', 'launchdarkly', or 'custom'
options.flags'config', 'memory'config: yes, memory: noStatic Readonly<Record<string, FlagDefinition>>
options.store'database'yesInjected IFlagStore providing { loadFlags(): Promise<...> }
options.refreshIntervalMs'database'noPoll cadence; defaults to 30000
options.sdkKey'launchdarkly'unless clientLaunchDarkly SDK key; a missing key with no client throws at register()
options.client'launchdarkly'noPrebuilt ILaunchDarklyClient; suppresses the lazy SDK import
options.module'launchdarkly'noSDK module to adapt instead of importing — the test seam
options.fallbackValue'launchdarkly'noValue the sync path returns on a cold context; defaults to false
options.initTimeoutSeconds'launchdarkly'noInitial-connection budget; defaults to 5. A timeout degrades, never throws
options.ldOptions'launchdarkly'noForwarded verbatim as the SDK init() second argument
maxCachedContexts'launchdarkly'noWrapper option: LRU snapshot cap, default 10,000; positive integer or RangeError at provider construction. Hits refresh recency; evicted contexts return fallback while refilling
options.instance'custom'yesPre-built FlagProvider instance

Exports

SymbolKindDescription
FeatureFlagsPluginfunctionPlugin factory — registers IFeatureFlags under CAPABILITIES.FEATURE_FLAGS
FeatureFlagServiceclassService implementing IFeatureFlags
createProviderfunctionProvider factory dispatching on the provider discriminant
ConfigProviderclassImmutable config-backed provider
MemoryProviderclassMutable in-process provider with setFlag/removeFlag/replaceFlags
DatabaseProviderclassPolled database-backed provider
LaunchDarklyProviderclassLaunchDarkly-backed provider bridging its async SDK to the sync contract
toLaunchDarklyContextfunctionMaps a FlagContext to the SDK’s single-kind user context
adaptLaunchDarklyModulefunctionNarrows an SDK module to ILaunchDarklyModule
loadLaunchDarklyModulefunctionReal lazy import('@launchdarkly/node-server-sdk')
LaunchDarklyModuleErrorclassThrown when the SDK cannot be loaded or does not expose init()
ILaunchDarklyClientinterfaceInjection facade for the SDK client
ILaunchDarklyModuleinterfaceInjection facade for the SDK module
ILaunchDarklyFlagsStateinterfaceThe snapshot facade — its getFlagValue is the synchronous read
LaunchDarklyContextinterfaceThe SDK evaluation context this provider builds
createFlagGuardfunctionFree-function route guard — short-circuits to redirect/404 when flag is off
FlagProviderinterfacePort implemented by all providers, and by the 'custom' arm’s instance
FlagProviderStatusinterfaceHealth status shape ({ healthy, detail? })
FlagProviderTypetype'config' | 'memory' | 'database' | 'launchdarkly' | 'custom'
FlagDefinitioninterface{ enabled, percentage?, users?, tenants? }
IFlagStoreinterfaceStructural facade for DatabaseProvider
FeatureFlagsPluginOptionstypeDiscriminated union of the four provider option shapes
ConfigProviderOptionsinterfaceThe 'config' arm — requires options.flags
MemoryProviderOptionsinterfaceThe 'memory' arm — options.flags optional
DatabaseProviderOptionsinterfaceThe 'database' arm — requires options.store
LaunchDarklyProviderOptionsinterfaceThe 'launchdarkly' arm
LaunchDarklyProviderConfiginterfaceThat arm’s configuration shape
CustomProviderOptionsinterfaceThe 'custom' arm — requires options.instance
FlagGuardOptionsinterfacecreateFlagGuard options (fallback, statusCode, context)
IFeatureFlags, FlagContexttype (re-export)From @setu-ts/common

Notes

  • IFeatureFlags.isEnabled is synchronous; providers refresh their state out of band.
  • Tenant restriction. FlagDefinition.tenants is a restriction, not an allowlist: when present and non-empty, the flag is false for any context whose tenantId is not in the list — including a context with no tenant — and it is evaluated ahead of every other rule, so it cannot be overridden by users or enabled: true. When absent, evaluation is unchanged. FlagContext.tenantId is optional, so existing callers and implementors are source-compatible; a context without a tenantId only matters against a flag that scopes itself. createFlagGuard derives tenantId from ctx.request.tenant?.id (omitting it when absent) unless options.context is supplied.
  • The allowlist (users) overrides enabled: false — { enabled: false, users: ['user1'] } evaluates to true for userId: 'user1'.
  • Percentage rollout uses deterministic FNV-1a 32-bit bucketing over `${flag}:${userId}`.
  • FlagContext.attributes is accepted but not consumed by the built-in evaluators; the 'custom' arm is its extension point.
  • IFeatureFlags.isEnabledAsync is optional and additive. Providers with a purely local snapshot (config, memory, database) have nothing to await, so FeatureFlagService resolves the synchronous evaluation for them. Both entry points funnel through one provider, so a configured option governs them identically.
  • LaunchDarkly. Every evaluation method on the Node server SDK is asynchronous, so it cannot directly satisfy the synchronous isEnabled. LaunchDarklyProvider bridges it using the one synchronous read the SDK does offer, LDFlagsState.getFlagValue: it fetches a per-context snapshot asynchronously and reads it synchronously thereafter. On a cold context — the first evaluation for a given userId — isEnabled returns the configured fallbackValue and schedules a background refill, so every later read for that user is real LaunchDarkly state. start() prewarms the anonymous context and an SDK update event drops the cache. Use isEnabledAsync wherever a wrong answer on a cold context would matter; it awaits boolVariation and carries no such caveat. The provider is Node/Deno/Bun only (the SDK uses node:events and Node HTTP), and the SDK is never a hard dependency.

Multi-Tenancy Plugin (@setu-ts/multi-tenancy-plugin)

Provides multi-tenancy support: tenant resolution, tenant context, tenant-scoped repositories, cache-key isolation, and pluggable database-isolation strategies. The cache-key isolation is applied by cacheMiddleware (from @setu-ts/cache-plugin) reading ctx.request.tenant and composing a length-prefixed tenant segment into the cache key — not by the cache.prefix string stamped into ctx.state, which no package reads.

Registration

import { MultiTenancyPlugin } from '@setu-ts/multi-tenancy-plugin';

app.register(
  MultiTenancyPlugin({
    resolver: 'header',
    header: { name: 'x-tenant-id' },
    database: 'column-per-tenant',
    cache: { prefix: true },
  }),
);

Usage

app.router.get('/users', async (ctx) => {
  const tenancy = ctx.services.get<IMultiTenancyService>('multi-tenancy');
  const tenant = ctx.request.tenant;

  // Tenant-aware repository — reads ctx.request.tenant set by middleware
  const userRepo = tenancy.getRepository<User>(ctx, 'User');
  const users = await userRepo.findAll(); // Scoped to current tenant

  // Cache-key prefixing
  const prefixed = tenancy.prefixCacheKey(tenant!.id, 'users:list'); // 'acme:users:list'

  return ctx.response.json(users);
});

Note: getRepository requires ctx because the framework has no ambient request context (no AsyncLocalStorage). The middleware resolves the tenant first; getRepository reads it from ctx.request.tenant. Calling getRepository before the middleware runs throws TenantNotResolvedError.

Non-HTTP work uses getRepositoryFor(tenantId, entity) (since 0.4.0) — the ctx-free entry point, modelled on prefixCacheKey: a queue processor, scheduled job, or ingress behaviour holds no IRequestContext, so the tenant id comes from the work item’s own payload and the repository scopes to the id GIVEN. The id is trusted input — nothing resolves it — so keep using getRepository(ctx, …) on the HTTP path, where the middleware-resolved tenant is authoritative:

// Inside an ingress behaviour (or any background work): no IRequestContext exists.
const payload = ctx.payload as { tenantId: string };
const repo = tenancy.getRepositoryFor<{ id: string }>(payload.tenantId, 'Order');
await repo.create({ id: 'ord-1' });

Options

OptionTypeRequiredDescription
resolver'subdomain' | 'header' | 'path' | 'jwt' | ITenantResolver | ITenantResolver[]yesDiscriminant for tenant resolution method, or a custom resolver / array of resolvers
subdomain.baseDomain?stringnoStripped from host to isolate tenant label; absent → first host label is tenant id
header.name?stringnoHeader to read; default 'x-tenant-id'
path.segment?numbernoPath segment index to read; default 0
jwt.claim?stringnoJWT claim to read; default 'tenant_id'
jwt.headerName?stringnoAuthorization header name; default 'authorization'
jwt.decode?(token: string) => Record<string, unknown> | nullnoCustom decoder; falls back to IJwtService.decode from CAPABILITIES.JWT if absent
database'column-per-tenant' | 'schema-per-tenant' | 'database-per-tenant' | ITenantIsolationStrategynoIsolation strategy the data store is expected to implement; default 'column-per-tenant'
dataStore?ITenantDataStore | RegistryFactory<ITenantDataStore>noInjected CRUD backend; default MemoryTenantDataStore. A factory (for example createDatabaseTenantDataStore()) is resolved once at onInit, so it may read a capability such as CAPABILITIES.DATABASE — DatabasePlugin may be registered after the tenancy plugin
cache.prefix?booleannoWhen true, stamps resolved prefix into ctx.state via getTenantCachePrefix()
cache.separator?stringnoDefault ':' — used by prefixCacheKey and the ctx.state stamp
required?booleannoWhen true, short-circuits unresolved requests at 400 without calling next()
rejectionStatus?numbernoStatus code for required-tenant short-circuit; default 400
exclude?readonly (string | RegExp)[]noPaths that skip tenant resolution entirely (exact string or RegExp.test); default the six operational probes (/live, /ready, /health, /metrics, /openapi.json, /docs); [] restores the previous behaviour
middlewarePriority?numbernoPriority for auto-added middleware; default 40

Exports

SymbolKindDescription
MultiTenancyPluginfunctionPlugin factory — registers IMultiTenancyService under CAPABILITIES.MULTI_TENANCY
tenantMiddlewarefunctionMiddleware factory — resolve tenant, set ctx.request.tenant, short-circuit when required
getTenantCachePrefixfunctionAccessor reading the tenant cache prefix from ctx.state
TENANT_CACHE_PREFIX_STATE_KEYconstModule-level key constant ('multi-tenancy-plugin:cache-prefix')
SubdomainResolverclassResolves tenant from URL host subdomain
HeaderResolverclassResolves tenant from configurable HTTP header
PathResolverclassResolves tenant from configurable path segment index
JwtResolverclassResolves tenant from JWT claim (uses unverified decode)
ColumnPerTenantclassColumn-per-tenant isolation strategy (default)
SchemaPerTenantclassSchema-per-tenant isolation strategy
DatabasePerTenantclassDatabase-per-tenant isolation strategy
MemoryTenantDataStoreclassZero-dependency in-process default store with cross-tenant isolation
TenantDataStoreNotReadyErrorclassThrown by getRepository/getRepositoryFor before a factory dataStore resolves in onInit
TenantNotResolvedErrorclassThrown by getRepository when tenant has not been resolved
MultiTenancyPluginOptionsinterfaceThe argument type of MultiTenancyPlugin(...)
ResolverConfigtypeThe resolver option’s type — discriminant string, custom resolver, or chain
DatabaseStrategyKindtypeThe database option’s discriminant strings
SubdomainResolverOptions, HeaderResolverOptions, PathResolverOptions, JwtResolverOptionsinterfacesPer-resolver option shapes, also the resolver constructors’ argument types
TenantCacheOptionsinterfaceThe cache option shape (prefix, separator)
MemoryTenantDataStoreOptionsinterfaceMemoryTenantDataStore constructor options (generateId)
ITenantDataStoreinterfaceInternal port for tenant-scoped CRUD (app injection seam)
ITenantIsolationStrategytypeDiscriminated union with 'column' | 'schema' | 'database' arms; narrow on kind
Re-exported from common: IMultiTenancyService, ITenantRepository, ITenant, ITenantResolver, CAPABILITIEStypes/constConvenience re-exports — canonical definitions stay in @setu-ts/common

Notes

  • No npm dependencies. All resolvers parse strings; strategies are pure derivation; memory store is in-process maps; cache prefixing is concatenation.
  • JwtResolver uses unverified decode. Tenant identity comes from an unsigned token claim. This is acceptable only alongside auth middleware which separately verifies the token.
  • PathResolver reads path segments by index, not router :param values. The resolver receives IRequest (not IRequestContext), so it cannot access ctx.params.
  • SubdomainResolver’s baseDomain constrains resolution, it does not merely strip. With baseDomain: 'example.com', only a strict subdomain resolves (acme.example.com → acme, left-most label for deeper hosts, port ignored). The apex (example.com), an unrelated host (evil.com), and a bare suffix match (notexample.com) all resolve to no tenant. Without baseDomain, the first label of any multi-label host is the tenant and a single-label host (localhost) resolves to none.
  • MemoryTenantDataStore hands out detached snapshots. Every entity returned by create/findAll/find/findById/update is a shallow copy, so mutating a returned object never rewrites the stored record. Read paths (findAll/find/findById/delete/a missing update) allocate no partition, so requests carrying unknown tenant ids cannot grow the store.
  • Strategy-derived partitioning. The strategy passed to register() is handed to the data store via useIsolation(). MemoryTenantDataStore derives its partition scope from it (column-stamping; the schema/database labels as partition-map keys — no physical schema or database is created). A store that never receives useIsolation partitions by raw tenant id.
  • A strategy NAMES the isolation; a store delivers it. The shipped database bridge, createDatabaseTenantDataStore from @setu-ts/database-plugin, is the one store told the strategy over a real backend, and it supports 'column' only: it conjoins the tenant column to every read and stamps it on every write, and throws TenantStoreStrategyUnsupportedError at startup for 'schema'/'database' (IRepository offers no schema or database switch). Its column is the strategy’s (new ColumnPerTenant('org_id') names another), and key lookups go through the repository’s own findById, so a primary key not named id is addressed as its adapter is configured. A register() warning fires when a non-'column' strategy is selected with no dataStore injected, and the JwtResolver arm of resolver warns that tenant identity comes from an UNVERIFIED claim.
  • Auto-added at priority 40. Runs after observability (metrics 20 / telemetry 30), before auth (300). Exported for manual re-ordering.

Health (@setu-ts/health-plugin)

HealthPluginOptions.details?: boolean defaults to false. HTTP /health, /live, and /ready return only { status, timestamp, checks: { name: { status } } }, omitting each indicator’s data and latencyMs. HealthPlugin({ details: true }) restores the full HTTP report; non-booleans throw TypeError at construction. Only the HTTP bodies change: the programmatic health service returns full reports, the gRPC bridge is unchanged, and diagnostics keep recording minimized observations that never include indicator data. HTTP status rules are unchanged.

What degraded means

/ready runs only the indicators that count toward readiness (never self) and returns 200 only when every one of them is up; degraded and down both return 503. An indicator outside readiness still runs on /health. /health returns 200 for up or degraded, and 503 for down. /live remains 200.

Use degraded when this replica should stop taking traffic. A shared problem that does not stop request serving belongs in the indicator’s data: draining every replica would remove healthy serving capacity. Alert on those data fields.

Provides health check endpoints with pluggable indicators.

Service Interface

Resolve the service via ctx.services.get<IHealthService>('health'). The service provides:

interface IHealthService {
  registerIndicator(name: string, indicator: HealthIndicatorFn, options?: HealthIndicatorOptions): void;
  check(): Promise<HealthReport>;
  checkLive(): Promise<HealthReport>;
  checkReady(): Promise<HealthReport>;
}

interface HealthReport {
  readonly status: HealthStatus;
  readonly timestamp: string;
  readonly checks: Readonly<
    Record<string, Readonly<HealthCheckResult & { readonly latencyMs?: number }>>
  >;
}

Since M119b every indicator carries a readiness membership — whether it counts toward /ready — exposed through three additive, optional members: the exported HealthIndicatorOptions interface ({ readiness?: boolean }; absent means the indicator counts — the previous behaviour), the optional IHealthIndicator.readiness? member a plugin or application indicator instance sets, and the options? parameter on both registration surfaces — IHealthApi.register(name, indicator, options?) (the plugin context’s ctx.health.register) and IHealthService.registerIndicator(name, indicator, options?) above. checkReady() runs the indicators that are not self and whose effective membership is true; an indicator outside readiness still runs in check() (/health) and is absent from the /ready report. checkLive() is unchanged. HealthPluginOptions.readiness ({ include?: readonly string[], exclude?: readonly string[] }) overrides a contributor’s declared membership by exact indicator name: include forces a named indicator into readiness, exclude forces it out. The override is applied inside HealthService.registerIndicator, so it covers every registration path, including a plugin that calls IHealthService.registerIndicator directly; a directly constructed service takes it as new HealthService(runtime, { readiness }). Both HealthPlugin(...) and that constructor refuse an invalid option with TypeError — a readiness that is not a plain object (an array included), a list that is not an array, an empty or non-string name, the name self, or a name in both lists; the message names the option and never echoes a name. After every plugin has initialized (at bootstrap), names that match no registered indicator produce one count-only warning.

Since M70c each indicator’s result is projected to the declared {@linkcode HealthCheckResult} shape — status, and data when present — before it enters the report. An indicator that returns anything else (a typo’d details instead of data, or its own latencyMs) has that field dropped rather than published: excess-property checking does not survive Promise.resolve({ ... }) at the generic call, so the mistyped field type-checks, and /health is frequently the least protected endpoint in a deployment. The latencyMs in the report is always the one the health service measured.

Since M90b the selected indicators run concurrently, each raced against a per-indicator deadline (indicatorTimeoutMs, default 5,000 — a positive finite number of milliseconds; anything else — zero, negative, NaN, Infinity — throws. HealthService is barrel-exported, so the same validation runs in its constructor as in HealthPlugin: constructing the service directly cannot bypass the check and store a value the deadline timer cannot honour). Sequential awaiting multiplied outage latency — a 2-second outage across six dependency indicators held /health for 12+ seconds — and one never-settling indicator left the whole endpoint pending forever. A timeout is recorded as { status: 'down', data: { reason: 'timeout' } } and a rejection as { status: 'down', data: { reason: 'error' } } with the thrown value never serialized into the report. A result that is not a framework result — not an object, or a status outside up/degraded/down — is recorded as { status: 'down', data: { reason: 'invalid-result' } } and its value is never published: such a status has no severity rank, so aggregating it used to let it hide another indicator’s down (/health answered 200 degraded with a check down). A data value that is not an object is omitted; each indicator’s latencyMs is measured individually; and checks keeps registration order, so the report’s shape is stable even though execution is not.

interface HealthCheckResult {
  readonly status: HealthStatus;
  readonly data?: Readonly<Record<string, unknown>>;
}

type HealthStatus = 'up' | 'down' | 'degraded';

Registration

import { createHttpIndicator, HealthPlugin } from '@setu-ts/health-plugin';

app.register(
  HealthPlugin({
    endpoints: {
      health: '/health',
      live: '/live',
      ready: '/ready',
    },
    indicators: [
      createHttpIndicator('external-api', {
        url: 'https://api.example.com/health',
        timeoutMs: 3000,
      }),
      // Or a factory that builds the indicator from the service registry:
      (services) => createDatabaseIndicator(services),
    ],
  }),
);

indicators accepts an instance or a RegistryFactory ((services: IServiceRegistry) => IHealthIndicator), the exported HealthIndicatorEntry union. A factory is called at the onInit phase — the first phase at which the registry holds every capability — and, because HealthPlugin registers at priority 100, before the database and every other ordinary capability plugin, so a factory can resolve the capability it exists to probe and build the indicator with it. Factories are registered at the head of the existing onInit hook, before the CAPABILITIES.HEALTH_INDICATOR contribution drain. A factory that throws rejects start(), naming the option and the entry. Instance indicators keep their register() timing byte-identically.

Custom Health Indicators

app.register({
  name: 'custom-health',
  version: '1.0.0',
  dependencies: ['health'],
  register(ctx) {
    const health = ctx.services.get<IHealthService>('health');

    health.registerIndicator('external-api', async () => {
      const http = ctx.services.get<IHttpClient>('http-client');
      try {
        await http.get('/health', { timeout: 3000 });
        return { status: 'up', data: { responseTime: 123 } };
      } catch {
        return { status: 'down', data: { error: 'Connection failed' } };
      }
    });
  },
});

Endpoints

EndpointMethodDescriptionStatus Codes
/healthGETOverall health (all indicators)200 (up/degraded), 503 (down)
/liveGETLiveness (self indicator only)200 (always, unless self is down)
/readyGETReadiness (readiness members, excluding self)200 (all up), 503 (any down/degraded)

Response

GET /health
{
  "status": "up",
  "timestamp": "2024-01-01T00:00:00.000Z",
  "checks": {
    "self": { "status": "up" },
    "database": { "status": "up" },
    "cache": { "status": "up" },
    "external-api": { "status": "up" }
  }
}

Status aggregation rules:

  • /live: Always 200 as long as the process responds (self indicator always returns ‘up’)
  • /ready: 200 when all indicators that count toward readiness are ‘up’, 503 when any is ‘degraded’ or ‘down’
  • /health: 200 when no participating indicator is ‘down’ (degraded stays 200), 503 when any is ‘down’

Health observations (M98d)

The plugin accepts an opt-in diagnostics option that exposes the health checks’ outcomes as minimized observations through the M98b diagnostics connector — it never changes /health, /live, or /ready.

import { HealthPlugin } from '@setu-ts/health-plugin';

app.register(
  HealthPlugin({
    indicators: [databaseIndicator],
    diagnostics: {
      enabled: true,
      // Exact registered-name -> display-alias allowlist; unlisted indicators are never retained.
      indicators: { 'database.check': 'database' },
      staleAfterMs: 30000, // optional; default 30000
      // Optional bounded scheduled collection; absent by default.
      scheduled: {
        indicators: ['database.check'],
        intervalMs: 30000,
        timeoutMs: 5000,
        concurrency: 2,
      },
    },
  }),
);

diagnostics.enabled is the LITERAL true, not boolean: an absent option is the disabled path (an inert source is still registered under CAPABILITIES.HEALTH_DIAGNOSTICS, answering disabled), and enabled: false (or any value other than true) is refused when HealthPlugin(...) is called — every diagnostics option is validated there, with fixed messages that never echo a value. indicators is the exact registered-name to display-alias allowlist — at most 64 entries, each alias unique, 1–64 UTF-8 bytes, no control characters; an indicator whose registered name is not a key is never retained. staleAfterMs (default 30000) turns the snapshot’s state stale once any retained observation is older than that many monotonic milliseconds from capture; an observation itself has no stale state, only its growing ageMs. scheduled — when present — must name a subset of the approved indicator names and supplies a cadence (intervalMs, 1,000–300,000), a per-check reporting deadline (timeoutMs, 1–30,000 — a reporting bound, not a cancellation), and a concurrency cap (1–4); at most 16 indicators are scheduled. Each cycle covers every scheduled indicator not still in flight, starting from a rotating cursor so none is starved. A timed-out callback that has not settled keeps its concurrency slot — and only that slot — until it does, so it is never replaced early, and while fewer than concurrency callbacks are hung the other indicators keep refreshing. Once every slot is held by a hung callback, no scheduled check starts until one settles; the work stays bounded, each stalled alias keeps its last observation with a growing ageMs (or stays never-observed), and the snapshot’s state turns stale once any retained observation ages past staleAfterMs. An approved name no indicator is registered under stays never-observed, and the plugin logs one count-only warning at bootstrap. An indicator result whose status is not up/degraded/down is observed as failed; the value is never retained.

The plugin retains only the latest outcome per approved alias (never a history). Each observation carries the approved alias, the framework’s own status (present only when reported), the outcome state (reported / timed-out / failed / never-observed), and monotonic latencyMs/ageMs. No indicator data, no error text, and no absolute time is ever projected; the snapshot is deeply frozen and bounded by the connector’s 256 KiB response ceiling (later entries omitted and truncated set when the bound is hit). The source is registered under CAPABILITIES.HEALTH_DIAGNOSTICS and consumed by the Diagnostics Connector’s GET /v1/health; the wire shape and inspector manifest are in docs/diagnostics-protocol.md.


Metrics (@setu-ts/metrics-plugin)

Built-in HTTP series carry method and status labels. The method label preserves GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, and PATCH, compared case-sensitively; all other methods use _OTHER on both success and error paths.

Provides Prometheus metrics.

Label values are escaped per text format 0.0.4 (backslash, double quote, line feed). Any other control character (carriage return, NUL, the rest of C0 and DEL) has no escape in that format and is replaced with U+FFFD in label values and HELP text, so an application-supplied value cannot split an exposition line.

Service Interface

Resolve the service via ctx.services.get<IMetricsService>('metrics'). Each factory method is get-or-create: the first call for a name constructs and registers the instrument; later calls return the same handle. Record methods are value-first (inc(value?, labels?), set(value, labels?), observe(value, labels?)), matching the committed IMetric.observe.

interface MetricOptions {
  readonly help?: string; // defaults to the metric name
  readonly labels?: readonly string[];
  readonly buckets?: readonly number[]; // histogram; falls back to defaultBuckets
  readonly quantiles?: readonly number[]; // summary; falls back to defaultQuantiles
  readonly maxSamples?: number; // summary sliding-window size (default 512)
}

interface IMetricsService {
  counter(name: string, options?: MetricOptions): ICounter;
  gauge(name: string, options?: MetricOptions): IGauge;
  histogram(name: string, options?: MetricOptions): IHistogram;
  summary(name: string, options?: MetricOptions): ISummary;
  get(name: string): IMetric | undefined;
}

interface ICounter extends IMetric {
  inc(value?: number, labels?: Readonly<Record<string, string>>): void;
}
interface IGauge extends IMetric {
  set(value: number, labels?: Readonly<Record<string, string>>): void;
  inc(value?: number, labels?: Readonly<Record<string, string>>): void;
  dec(value?: number, labels?: Readonly<Record<string, string>>): void;
}
interface IHistogram extends IMetric {
  observe(value: number, labels?: Readonly<Record<string, string>>): void;
  readonly buckets: readonly number[];
}
interface ISummary extends IMetric {
  observe(value: number, labels?: Readonly<Record<string, string>>): void;
  readonly quantiles: readonly number[];
}

The declarative MetricConfig (type and help required) remains the shape for ctx.metrics.register(name, config) and the plugin’s customMetrics option. NamedMetricConfig is that shape plus name, and is exported from @setu-ts/metrics-plugin — customMetrics is typed as an array of it, so without the export a caller could pass an inline literal but could not declare its own array in a variable. A pre-registered metric appears in the scrape output as its # HELP and # TYPE lines from startup, before anything records on it. The GET /metrics scrape endpoint responds with Content-Type: text/plain; version=0.0.4; charset=utf-8.

Registration

import { MetricsPlugin } from '@setu-ts/metrics-plugin';

app.register(
  MetricsPlugin({
    endpoint: '/metrics',
    defaultMetrics: true,
    httpMetrics: true,
    // Replaces the default ['/health', '/live', '/ready'] exclusion set; the
    // plugin's own endpoint is always excluded either way.
    excludePaths: ['/health', '/live', '/ready'],
    customMetrics: [
      { name: 'users_total', help: 'Total users', type: 'counter' },
      { name: 'active_connections', help: 'Active connections', type: 'gauge' },
    ],
  }),
);

Custom Metrics

app.router.post('/users', async (ctx) => {
  const metrics = ctx.services.get<IMetricsService>('metrics');
  const user = await createUser(ctx.request.body);

  metrics.counter('users_total').inc();
  metrics.gauge('active_connections').inc();

  return ctx.response.status(201).json(user);
});

// Histogram
app.router.get('/search', async (ctx) => {
  const metrics = ctx.services.get<IMetricsService>('metrics');
  const histogram = metrics.histogram('search_duration_seconds', {
    labels: ['query_type'],
    buckets: [0.1, 0.5, 1, 5],
  });

  const start = Date.now();
  const results = await search(ctx.request.query);
  histogram.observe((Date.now() - start) / 1000, { query_type: 'full-text' });

  return ctx.response.json(results);
});

Prometheus Endpoint

GET /metrics

# HELP users_total Total users
# TYPE users_total counter
users_total 1234

# HELP active_connections Active connections
# TYPE active_connections gauge
active_connections 42

# HELP http_request_duration_seconds HTTP request duration
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.1"} 100
http_request_duration_seconds_bucket{le="0.5"} 150
...

Telemetry (@setu-ts/telemetry-plugin)

Provides OpenTelemetry distributed tracing. The TelemetryPlugin registers an ITelemetryService under CAPABILITIES.TELEMETRY ('telemetry'), exposing manual span creation via withSpan plus a request-span middleware at priority 30 (inside metrics at 20, outside auth at 300) that wraps every inbound HTTP request in a server span with W3C traceparent/tracestate propagation.

The OpenTelemetry SDK is a heavy optional dependency: the plugin lazy-loads @opentelemetry/sdk-trace-base and an exporter via dynamic imports of the bare package names, failing with a clear error when the package is absent. A NoopTelemetryService is the default when no exporter is configured, so the plugin always registers a usable service with zero npm deps in that mode.

An injectable tracerProviderFactory option lets tests (and consumers with a pre-built provider) bypass the lazy import entirely.

Options

OptionTypeRequiredDescription
serviceNamestringYes (real mode)Service name reported to the exporter
serviceVersionstringNoService version (default: '1.0.0')
exporter'otlp' | 'console'NoExporter kind; absent = noop mode
endpointstringYes (otlp)OTLP HTTP endpoint URL
headersRecord<string, string>NoOptional OTLP HTTP headers
sampling{ type: 'traceidratio'; ratio: number }NoSampling config (default ratio: 1.0)
tracerProviderFactory() => Promise<TracerHost>NoInjectable factory to bypass lazy import
middlewarebooleanNoRegister request-span middleware (default: true)
spanProcessor'simple' | 'batch'NoSpan processor ('simple' by default)
instrumentationsInstrumentationsConfigNoAuto-instrumentation config (runtime-gated no-op)
contextPropagationbooleanNoActivate real OTel spans (default: true)
contextManagerFactory() => Promise<{ enable(): unknown; disable(): unknown }>NoInjectable context-manager factory — it returns a promise of a manager, not a manager. The return type is structural, so it can resolve to an OTel context manager with no import from this package
diagnosticsTraceDiagnosticsOptionsNoOpt-in minimized completed-span observations for the local diagnostics connector (M98g, below)

Trace diagnostics (M98g)

TraceDiagnosticsOptions is the opt-in observation policy for the local diagnostics connector’s GET /v1/traces operation (documented in the Diagnostics Connector section). It requires the LITERAL enabled: true, a serviceAlias, and an operations map of exact raw span name → approved alias (at most 128; each alias 1–64 UTF-8 bytes, no control character, unique — approving an exact alias IS authorizing its disclosure; serviceAlias is never derived from the OTel resource). Every option is validated at TelemetryPlugin(...) construction with a fixed, value-free RangeError. When present with the built-in OTel provider, the plugin appends an internal span processor AFTER the exporter processor in the same provider constructor: the exporter path is byte-identical (measured — the exporter still receives every span), the processor never exports, never throws into OTel, and unapproved spans are counted and dropped before anything is read. The plugin ALWAYS registers an ITraceDiagnosticsSource under CAPABILITIES.TRACE_DIAGNOSTICS (provides names it): without the option it answers disabled, with the option under a custom tracerProviderFactory or without an exporter it answers unsupported with the fixed coverage reason. Sampling, activation and export behavior are unchanged. See Diagnostics Connector for the served contract.

Span nesting and broker propagation

In real OTel mode, withSpan activates the span while its callback runs, so nested work inherits the active parent. Implicit inheritance holds only when the plugin is in real OTel mode, contextPropagation is not false, and the async-local context manager registered successfully; noop mode, fallback mode, contextPropagation: false, and a failed registration — the optional package not loading, or the registration call throwing — all leave spans flat. A host that already owns a context manager is NOT a failure: the plugin adopts it and nesting still works. A failed registration is logged, never thrown. Where the parent relationship must hold regardless of activation, pass parentContext explicitly. When MessagingPlugin finds telemetry, it creates publish <topic> producer spans and receive <topic> consumer spans, writes W3C traceparent on the transport, and parents delivery from the header. Set tracing: false to opt out. All first-party brokers expose the read transport headers through MessageMetadata.headers, using {} when the channel is empty.

Auto-instrumentation

Milestone 24b adds the instrumentations option — a per-kind map of true | InstrumentationConfig keys: http, fetch, ioredis, redis (M117: node-redis v5/v6 through @opentelemetry/instrumentation-redis ^0.69.0, an optional peer; a command sent through fromNodeRedis is traced as redis-<COMMAND>), amqplib, kafkajs. Each key enables one auto-instrumentation. On non-Node runtimes (Deno, Bun, Cloudflare Workers) all instrumentations degrade to a documented no-op — they never throw. When tracerProviderFactory returns a host with a truthy otelProvider, the registry calls setTracerProvider + enable() on each loaded instrumentation instance; when otelProvider is absent, the registry returns a no-op handle immediately.

Each instrumentation uses the inject-or-lazy seam: when InstrumentationConfig.instrumentation is set, the instance is used directly (inject path); otherwise the registry lazy-loads the OTel package via a literal dynamic import of its bare package name (lazy path). Any loader failure is caught and recorded as a failure outcome — the plugin never throws from instrumentation setup.

Every outcome is reported through the plugin’s logger (ctx.logger, read at call time): an enabled instrumentation logs one line at debug, a failure one line at warn carrying kind and reason. The plugin declares the logger capability in optionalDependencies, so the kernel orders a plugin-provided logger (e.g. LoggerPlugin) before it — the standard configuration (RuntimePlugin + LoggerPlugin + TelemetryPlugin) reports every outcome. A failure therefore remains a no-op rather than a throw, but is no longer silent — without a logger registered, the outcomes are still recorded on the registry handle and nothing is emitted.

Span Processor

The spanProcessor option selects between 'simple' (default) and 'batch' span processing. Both are exported from the pinned @opentelemetry/sdk-trace-base@^2.9.0.

Registration

import { TelemetryPlugin } from '@setu-ts/telemetry-plugin';

// Noop mode (zero dependencies)
app.register(TelemetryPlugin({ serviceName: 'my-service' }));

// Real mode with console exporter
app.register(
  TelemetryPlugin({
    serviceName: 'my-service',
    serviceVersion: '1.0.0',
    exporter: 'console',
  }),
);

// Real mode with OTLP exporter
app.register(
  TelemetryPlugin({
    serviceName: 'my-service',
    exporter: 'otlp',
    endpoint: config.get('OTLP_ENDPOINT'),
    sampling: { type: 'traceidratio', ratio: 0.1 },
  }),
);

Manual Spans

import { CAPABILITIES } from '@setu-ts/common';
import type { ITelemetryService } from '@setu-ts/common';

app.router.post('/orders', async (ctx) => {
  const telemetry = ctx.services.get<ITelemetryService>(CAPABILITIES.TELEMETRY);

  const order = await telemetry.withSpan('create-order', async (span) => {
    span.setAttribute('customerId', ctx.request.body.customerId);
    span.setAttribute('total', ctx.request.body.total);

    const order = await createOrder(ctx.request.body);

    span.setAttribute('orderId', order.id);
    span.setStatus('ok');

    return order;
  });

  return ctx.response.status(201).json(order);
});

Request-Span Middleware

The plugin registers telemetryMiddleware at priority 30 by default. It:

  1. Extracts traceparent/tracestate from ctx.request.headers (W3C Trace Context)
  2. Starts a server span named <METHOD> <path>
  3. Stores the span on ctx.state under TELEMETRY_SPAN_KEY ('telemetry-plugin:span')
  4. Sets HTTP attributes (http.method, http.route, http.status_code)
  5. Injects traceparent into the response headers

Downstream handlers can read the active span via:

import { TELEMETRY_SPAN_KEY } from '@setu-ts/telemetry-plugin';
import type { ISpan } from '@setu-ts/common';

const activeSpan = ctx.state.get(TELEMETRY_SPAN_KEY) as ISpan | undefined;

Contract Types

The telemetry contract is framework-owned and exported from @setu-ts/common (zero dependencies — importable without the OTel SDK installed). The telemetry-plugin translates these to OTel types at its implementation seam.

ExportKindShape / description
ITelemetryServiceinterfacewithSpan<T>(name: string, fn: (span: ISpan) => Promise<T>, options?: SpanOptions): Promise<T> — the only manual span-creation API; ends the span exactly once, even if fn throws. Resolved under CAPABILITIES.TELEMETRY. Plus the OPTIONAL activeSpanContext?(): SpanContext | undefined (see below).
ISpaninterfacesetAttribute(key, value): this, setAttributes(attrs): this, setStatus(status): void, recordException(error): void, end(): void, spanContext(): SpanContext.
SpanContextinterface{ readonly traceId: string; readonly spanId: string; readonly traceFlags: string } — all lowercase hex (32/16/2 chars). Returned by ISpan.spanContext().
SpanStatusunion'ok' | 'error' | 'unset' — argument to ISpan.setStatus.
SpanKindunion'internal' | 'server' | 'client' | 'producer' | 'consumer' — SpanOptions.kind (default 'internal').
SpanAttributeValueunionstring | number | boolean | ReadonlyArray<string | number | boolean>.
SpanOptionsinterface{ readonly kind?: SpanKind; readonly attributes?: Readonly<Record<string, SpanAttributeValue>>; readonly parentContext?: TelemetryContext; readonly root?: boolean } — 3rd arg to withSpan. Pass parentContext to parent a span explicitly; implicit linking depends on context activation — see note below. root: true (M107) starts a parentless span, ignoring both parentContext and the active span; an ITelemetryService implementation that ignores it starts the span under the ACTIVE span.
TelemetryContextinterfaceOpaque parent-context handle carrying the extracted W3C fields (_opaque, optional traceId/spanId/traceFlags/tracestate). Consumers must not inspect it beyond passing it back via SpanOptions.parentContext.
TraceparentSourceinterfaceStructural traceId?/spanId?/traceFlags? input accepted by contextToTraceparent; both TelemetryContext and SpanContext satisfy it.
TELEMETRY_CONTEXT_OPAQUEunique symbolBrand for TelemetryContext._opaque (Symbol.for('he.telemetry.context')); prevents structural mixups.

Implicit parent/child linking is conditional. Since M75 the plugin DOES register an OTel ContextManager — the AsyncLocalStorageContextManager from the optional @opentelemetry/context-async-hooks — so in real OTel mode a withSpan nested inside another auto-parents. That holds only while a context manager is active: the plugin either registers its own or adopts one the host already owns, and both nest. It does NOT hold in noop or fallback mode, under contextPropagation: false, or when the optional package cannot be loaded and registration therefore fails — there, spans are recorded but unparented.

Pass parentContext (or the extracted context) on SpanOptions wherever the relationship must hold regardless of activation. The request-span middleware always passes the incoming traceparent as the parent explicitly, so cross-process propagation (incoming header → server span) works out of the box in every mode.

Joining log records to spans

ITelemetryService.activeSpanContext?(): SpanContext | undefined reports the identifiers of the span that is active RIGHT NOW, so a signal emitted OUTSIDE a withSpan call — a log record, above all — can name the trace it belongs to. withSpan hands its span only to its own callback, so without this a consumer that is merely running inside a span cannot ask which one.

TracerHost carries the matching optional activeSpanContext?(); it is where the read is actually possible, because only the host can see the OTel context. Both are OPTIONAL, so every existing implementor stays source-compatible, and the ABSENCE is meaningful rather than substituted: a service or host that cannot see an active span omits the member, and a consumer then enriches nothing rather than emitting empty identifiers that would join a record to no trace. TracerHost declares it only alongside activate — with no registered context manager nothing is ever active, so a host that cannot activate could only ever answer undefined. NoopTelemetryService omits it.

@setu-ts/logger-plugin is the consumer that makes this more than a getter: with a telemetry capability registered it enriches every record with trace_id and span_id. See the logging section for the field names and the absent-telemetry behaviour.


OpenAPI (@setu-ts/openapi-plugin)

OpenApiPluginOptions.swagger?: boolean | SwaggerOptions defaults to self-hosted Swagger UI. Install optional peer swagger-ui-dist (^5) and provide runtime.fs (Deno needs --allow-read). true and {} select self-hosting; false disables UI. Registration loads the guarded CommonJS swagger-ui-dist/absolute-path.js helper through its default export, then reads three fixed files once. /docs/swagger-ui.css, /docs/swagger-ui-bundle.js, and /docs/swagger-ui-standalone-preset.js serve CSS/JavaScript bytes with Cache-Control: public, max-age=3600; endpoint moves their prefix. These routes, the UI and the spec endpoint never appear as API operations. A missing package throws OptionalDependencyUnavailableError, naming installation or swagger: { assets: 'cdn' }. No filesystem refuses self-hosting and names CDN and custom-base remedies.

ExportKindContract
OperationIdErrorclassRefuses blank or duplicate declared ids; readonly reason: 'duplicate' | 'blank'
SwaggerOptionstype{ readonly assets?: 'self' | 'cdn' | { readonly baseUrl: string } }
SwaggerUiOptionsinterfacespecUrl, optional title, assets with the same modes, and local endpoint (default /docs) for swaggerUiHtml

CDN mode uses https://unpkg.com/swagger-ui-dist@5.33.3 with SHA-384 SRI and crossorigin="anonymous" on all three asset tags. { baseUrl } uses the same hashes and requires the unmodified 5.33.3 files. The base must be printable ASCII with no backslash, ? or #, and either an absolute HTTP(S) URL already in its serialized form or a path starting with a single /; it is written into the page as given, so a URL carrying credentials is refused. Invalid modes or bases throw TypeError at construction. Workers needs an external mode. The REST, microservice and full-stack starters select 'cdn' on Workers when no Swagger asset mode is chosen, and keep any explicit choice. CLI projects declare the package in npm dependencies, or the Deno import map, and set "scarfSettings": { "enabled": false } in a generated standalone or npm workspace-root package.json (Scarf ignores a member’s setting), opting out of the install-time analytics hook swagger-ui-dist depends on (@scarf/scarf). Workers templates select CDN automatically and do not declare the package.

Provides automatic OpenAPI documentation.

Registration

import { OpenApiPlugin } from '@setu-ts/openapi-plugin';

app.register(
  OpenApiPlugin({
    // OpenAPI spec metadata
    title: 'My API',
    version: '1.0.0',
    description: 'A sample API built with Setu-TS',
    servers: [
      { url: 'https://api.myapp.com', description: 'Production' },
      { url: 'http://localhost:3000', description: 'Local' },
    ],
    securitySchemes: {
      bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
      apiKey: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
    },
    // Document-level requirement, inherited by every operation that does not
    // declare its own. Omit it and no operation is marked as protected.
    security: [{ bearerAuth: [] }],
    // Or derive each operation's requirement from the guards on its route, so
    // the document tracks what actually enforces instead of a second
    // declaration that can drift from it.
    deriveSecurity: { scheme: 'bearerAuth' },
    // Router paths to leave out of the document. Matched exactly against the
    // fully-resolved router pattern: router-style (`/todos/:id`, not the
    // template `/todos/{id}`) and including any `router.group()` prefix.
    exclude: ['/internal/debug'],
    // Plugin names whose routes are left out, matched against `RouteInfo.owner`.
    // Defaults to ['health-plugin', 'metrics-plugin']; pass [] to document them.
    excludeOwners: ['health-plugin', 'metrics-plugin'],
    // Fill requestBody and parameters from the validation middleware guarding
    // each route. On by default; pass false for the pre-derivation document.
    deriveRequestSchemas: true,
    // Document each operation's success status from what its handler declared
    // (`@HttpCode` / `@Redirect`) instead of assuming 200. On by default.
    deriveResponseStatus: true,
    // Endpoint configuration
    endpoint: '/docs', // Path for Swagger UI HTML (default: '/docs')
    specEndpoint: '/openapi.json', // Path for OpenAPI JSON spec (default: '/openapi.json')
    swagger: true, // Whether to serve Swagger UI (default: true)
  }),
);

Operation ids and descriptions

Optional RouteSchema.operationId and RouteSchema.description (since 0.10.0) carry programmatic declarations and @ApiOperation({ operationId, description }) metadata. The id is used verbatim; without it, ids keep the derived method/path format (GET /users/:id → get-users-by-id). The description is emitted when declared, including an empty string.

Declared ids are claimed before derived ones, regardless of route order: a derived get-users becomes get-users-2 if another included route declares get-users. Excluded paths and owners neither claim nor collide. OpenApiGenerator.generate(routes) throws OperationIdError for blank (empty or whitespace-only) or duplicate declared ids. OpenApiPlugin performs the same route scan at bootstrap, failing start() without building or caching a document. A route added after bootstrap is checked when the document is first built; a failure then answers 500.

OperationIdError extends Error is exported from @setu-ts/openapi-plugin (since 0.10.0). Its constructor is (reason: 'duplicate' | 'blank', message: string); readonly reason names the refusal, name is 'OperationIdError', and the diagnostic names the declared id and affected routes as METHOD path. The allocation helper is internal; no service or generator method is added.

Component names and x-setu-unrepresentable[].at follow the chosen id. SDK method and type names therefore change when an existing declaration starts taking effect; see Upgrading. Distinct OpenAPI ids can still collide in SDK codegen: declared getUsers beside derived get-users is valid OpenAPI but is refused by the SDK with Duplicate operation name. Choose ids with distinct sanitized TypeScript names.

Documenting Authentication

Declaring securitySchemes is what gives Swagger UI its Authorize button; without it a protected route cannot be exercised from the page at all. Pair it with security to state that operations require authentication by default, and let an individual route opt out:

app.router.post('/login', {
  // An EMPTY array marks the operation public, overriding the document-level
  // requirement. Omitting `security` entirely would leave `/login` documented
  // as needing the token it issues.
  schema: { security: [] },
  handler: async (ctx) => {
    /* ... */
  },
});

app.router.delete('/todos/:id', {
  middleware: [requireAuth()],
  // Narrower than the document default — this operation needs a scope.
  schema: { security: [{ oauth2: ['write:todos'] }] },
  handler: async (ctx) => {
    /* ... */
  },
});

RouteSchema.security enforces nothing. Authentication is enforced by authMiddleware and the requireXxx guards; this describes the route for readers and for generated clients.

Deriving Request Schemas From Validation Middleware

A route carrying validateBody(schema) already states its request shape, from a first-party plugin, on the route. deriveRequestSchemas (default true) reads that schema and fills the operation’s requestBody and parameters, so the shape is not written twice:

app.router.post('/orders', {
  middleware: [validateBody(PlaceOrderSchema), validateQuery(ListQuerySchema)],
  handler,
});
// -> requestBody from PlaceOrderSchema, query parameters from ListQuerySchema,
//    and a documented 400 (which is what the middleware answers).

Every helper @setu-ts/validation-plugin ships brands the middleware it returns with RouteValidationMetadata (@setu-ts/common), and so does IValidationService.middleware(...) — both entry points, identically. No plugin imports another; the Symbol.for-keyed brand in common is the whole channel, exactly as RouteSecurityMetadata is for guards.

Rules and limits, stated rather than left to discovery:

  • A declared schema field always wins, per field. Declaring schema.body and carrying validateQuery(...) gives the declared body and the derived query.
  • The LAST brand for a target wins when a route carries two, because that is the value the handler receives: each validation middleware writes validated:<target> as it passes, so the final writer’s parsed value is the one in ctx.state by the time the handler runs. The request must still satisfy EVERY brand, since any of them can short-circuit with a 400; the document shows the shape the handler sees, not that conjunction.
  • cookies derives nothing. RouteSchema has no cookies field, so there is no declared counterpart — and @setu-ts/sdk’s client generator refuses an in: 'cookie' parameter outright, so emitting one would turn a working document into a hard codegen failure for its consumers.
  • A derived route gains 400: { description: 'Bad request' } unless it declares its own. The status is real; no body schema is emitted, because the shape depends on the plugin’s configured errorFormat, which the generator cannot see.
  • deriveRequestSchemas: false disables derivation only. Owner exclusion, the derived operationId format for routes without a declared id and schema deduplication are unconditional, so this does not restore the whole pre-derivation document; pass excludeOwners: [] to document the operational routes again.

Unlike deriveSecurity this is ON by default, because nothing has to be configured for it: a security requirement names a scheme that cannot be inferred from a guard, while the schema on the route IS the schema the document wants.

Deriving The Success Status From The Handler

With no schema.response, an operation is documented as 200: { description: 'Successful response' } — which is wrong for every route that answers something else, and the wrongness is load-bearing: @setu-ts/sdk’s generator types the success body under that key, so a 201 route’s generated client types its success as 200 and a caller reading the created record gets nothing.

deriveResponseStatus (default true) reads the status the handler DECLARED. Every route @setu-ts/decorator-plugin’s @HttpCode(status) or @Redirect(url, status?) produces brands its handler with RouteResponseMetadata (@setu-ts/common), and the generator reads that brand off RouteInfo.definition.handler. No plugin imports another; the Symbol.for-keyed brand in common is the whole channel, exactly as RouteSecurityMetadata is for guards and RouteValidationMetadata is for request shapes.

@Controller('/orders')
class OrderController {
  @HttpCode(201)
  @Post('/')
  create() {
    return { id: 'o-1' };
  }
}
// -> responses: { '201': { description: 'Resource created' } }

Rules and limits, stated rather than left to discovery:

  • A DECLARED schema.response always wins, tested !== undefined rather than by length: an empty map is a caller saying “no documented responses”. A route carrying @ApiResponse(...) is therefore untouched by the derivation.
  • The HANDLER carries the brand, not a middleware. A guard and a validator ARE middleware; a success status is a property of the handler, and a decorated route may carry no middleware at all.
  • Only the status is derived. @ResponseHeader contributes nothing — an OpenAPI response-header entry needs a schema and a description the declaration does not carry, so deriving one would put an under-specified headers object into every document that used the decorator.
  • A @Redirect route is documented under its 3xx.
  • deriveResponseStatus: false restores the assumed 200 for every branded route. Like deriveRequestSchemas: false it disables this derivation only; owner exclusion, the derived operationId format for routes without a declared id and schema deduplication are unconditional.

Like deriveRequestSchemas and unlike deriveSecurity, this is ON by default, because nothing has to be configured for it. It is safe to default on because the brand is new surface: no handler written before @HttpCode existed carries one, so an application that changes nothing gets a byte-identical document. What DOES change the document is adopting the decorator — which is the point of it, and which moves that operation’s generated success type from 200 to the declared status.

Excluding Operational Routes

excludeOwners (default ['health-plugin', 'metrics-plugin']) drops routes by the plugin that registered them, read from RouteInfo.owner. Without it, /health, /live, /ready and /metrics are documented and flow into every generated client as getHealth, getLive, getReady and getMetrics alongside the real API.

Owners rather than paths, because those endpoints are configuration: HealthPlugin({ endpoints }) and MetricsPlugin({ endpoint }) both accept a path, so a static path list silently stops excluding a renamed one. Pass excludeOwners: [] to document them again.

Deriving Authentication From Guards

Declaring security per route makes the document a second source of truth that can drift from the guards. deriveSecurity closes that: every guard @setu-ts/auth-plugin ships is branded with RouteSecurityMetadata, and the generator reads the brand off the route’s middleware.

app.register(
  OpenApiPlugin({
    securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } },
    deriveSecurity: { scheme: 'bearerAuth' },
  }),
);

// Documented as requiring bearerAuth — no `schema.security` needed.
app.router.get('/todos/:id', { middleware: [requireAuth()], handler });

// Documented as public.
app.router.post('/login', { middleware: [publicRoute()], handler });

Rules, in precedence order: a requirement declared on schema.security always wins; otherwise a route carrying at least one branded guard is derived (authenticated: true beats false, matching what the middleware chain does); otherwise the operation carries no key and inherits the document-level security.

Three limits, stated rather than left to discovery:

  • Only route-level middleware is inspected. Middleware added with app.middleware.add() is not on the route and is not consulted — correct for authMiddleware(), which populates the principal and never rejects.
  • Roles and permissions are not expressible. An OpenAPI requirement names a scheme, and no declared scheme can be inferred from 'admin', so requireRole('admin') documents only that authentication is required. A 403 remains a surprise the document cannot warn about.
  • The scheme name is configured, never inferred. A guard cannot know whether the document calls its scheme bearerAuth or jwt. An undeclared name is refused at register().

Defining Route Schemas

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

const CreateUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
});

const ErrorSchema = z.object({
  error: z.string(),
  message: z.string(),
});

app.router.post('/users', {
  schema: {
    body: CreateUserSchema,
    response: {
      201: UserSchema,
      400: ErrorSchema,
      409: ErrorSchema,
    },
    tags: ['Users'],
    summary: 'Create a new user',
  },
  handler: async (ctx) => {
    /* ... */
  },
});

Zod Version Support

The supported Zod range is >=4.6.6 <5. Schemas are recognized by the public toJSONSchema method; the plugin does not import Zod.

  • Zod v4 is converted through schema.toJSONSchema() and adapted from JSON Schema draft 2020-12 to OpenAPI 3.1: the dialect $schema key is dropped, formats and constraints pass through verbatim (format, pattern, contentEncoding, …), reused schemas are hoisted into components/schemas with their pointers rewritten, and a recursive schema’s root-cycle pointer forces that schema into components so no bare # ref survives in the document.

  • A type union is always spelled anyOf, on every supported zod v4. Draft 2020-12 permits two spellings, and zod switched between them across v4 releases: through 4.4 a union of bare types emitted anyOf: [{ "type": "string" }, { "type": "null" }], and from 4.5 it collapses to "type": ["string", "null"]. The transformer normalizes the collapsed form back, so one schema produces one document whichever in-range zod the application installs, and the document stays convertible to OpenAPI 3.0 (where a type array is not legal). Sibling keywords stay beside the anyOf rather than being distributed into its arms — { "type": ["string", "null"], "minLength": 2 } becomes { "minLength": 2, "anyOf": [{ "type": "string" }, { "type": "null" }] }, which asserts the same thing without applying a string constraint to the null arm. Only schema positions are rewritten: a default, const, enum, example or vendor-extension value is instance DATA and is preserved verbatim, even when it happens to carry its own type key. And where a node already asserts anyOf, the normalized union is conjoined through allOf rather than taking the anyOf key, since the two are independent assertions over the same instance. A union whose arms carry anything beyond a bare type (a constraint, a format, a const, an items) is emitted as anyOf by zod itself and passes through untouched.

  • Unrepresentable nodes degrade, never throw. A type zod cannot represent in JSON Schema (z.date(), z.bigint()) still becomes an empty schema, but the operation owning it now carries a machine-readable vendor extension:

    {
      "x-setu-unrepresentable": [
        { "at": "post-events", "reason": "zod v4 type 'date' has no JSON Schema representation" }
      ]
    }

    at names the operationId, so the affected route is identifiable without diffing documents. The extension is absent when every schema is representable. Nothing is logged when a node degrades: the document is built on the first getSpec() / /openapi.json request, not at startup, so inspect it (or test for x-setu-unrepresentable) in CI. For dates, z.iso.datetime() represents a date-time in JSON Schema.

  • Schemas without toJSONSchema, including Zod 3 schemas, are unrecognized. They degrade to {} and report the existing not a recognized zod schema diagnostic through x-setu-unrepresentable. Upgrade Zod to at least 4.6.6 before regenerating documents.

Request and response are documented from different sides

A zod schema has two shapes: what a client may SEND, and what the server holds after parsing. They differ for anything carrying a .default(), a .transform(), a .coerce, or the ordinary key-stripping of z.object. Every request-side position — body, params, query, headers — is documented from the input side; response is documented from the output side.

This is what stops a document contradicting the server it describes. A field with .default('free') is optional for a client (that is what a default is for) and always present in a response, so it is absent from the request body’s required and present in the response’s. A .transform() field documents its SOURCE type on a request rather than the unrepresentable {} its result produces. And on the three object modes, measured against zod 4.4:

schemaunknown key at runtimerequest body documentsresponse documents
z.objectaccepted, stripped(no additionalProperties)additionalProperties:false
z.strictObjectrejectedadditionalProperties:falseadditionalProperties:false
z.looseObjectaccepted, keptadditionalProperties:{}additionalProperties:{}

ZodToOpenApi.transform(schema, io?) takes the side as an optional second argument, defaulting to 'output', so a direct call uses the response view.

Deduplication follows the side. A schema whose two views are identical — any v4 schema with no default, transform, coercion or object-mode difference — is hoisted into ONE component shared by every site, exactly as before. A schema whose views differ and that is used on both sides yields one component per side, because the two sites do not describe the same shape. A schema registered through addSchema('Name', …) keeps that name for the output side and gains a NameInput twin when a request site reaches it, so the contributor’s chosen name still appears on both sides. The twin is identified by SCHEMA, never by name alone — registering both Address and an unrelated AddressInput is legal, and the twin then takes the next free suffixed name rather than adopting the unrelated component. It belongs to the document that needed it: a later generate() with no request site for that schema drops it, exactly as it drops every other per-document component.

Notes

  • Every RouteSchema position the generator reads becomes part of the operation: body becomes the application/json request body, response becomes the responses map, tags/summary/operationId/description become the operation metadata, and params, query, and headers become parameters with in: 'path', in: 'query', and in: 'header' respectively. Path parameters are always required: true (they come from the path template); query and header parameters take their required flag from the schema. Header parameters are emitted verbatim — per OpenAPI 3.1, tooling ignores definitions named Accept, Content-Type, and Authorization, so the generator does not filter them out.
  • A path parameter the params schema does not describe is emitted as { type: 'string' } rather than the empty schema, which OpenAPI reads as “any type”. Every path segment arrives as a string, so the empty form made Swagger UI render an untyped box and client generators emit unknown. A declared params entry always wins, per parameter.
  • The plugin’s own specEndpoint and endpoint are never documented as operations — a spec that lists /openapi.json and /docs describes its own delivery mechanism, and those entries flow into every generated client. The routes are still served; only the document entries are omitted. Custom endpoint paths are honored. Anything else the document should omit goes in exclude, which matches the fully-resolved router pattern — a route registered inside router.group('/internal', …) is matched by its prefixed path, and an entry matching no route is silently ignored.
  • A security requirement naming a scheme absent from securitySchemes is refused at register(). Emitting it would produce a document that is invalid per the specification — Swagger UI renders a lock on every operation with no Authorize button — and nothing downstream can detect it, since the spec endpoint still answers 200.
  • An unrestricted decorated route marked @Public is documented with an empty security array, so it opts out of a document-level requirement. @Public never exempts a route from an enforced @Roles/@Permissions declaration; when one is present, the public marker is omitted and the middleware’s derived requirement documents the protection truthfully.

Accessing the Spec

// The spec is available at /openapi.json
// The Swagger UI is available at /docs

// Programmatic access
const openapi = ctx.services.get<IOpenApiService>('openapi');
const spec = openapi.getSpec();

CLI (@setu-ts/cli)

Devtool enabling requires all existing @setu-ts/* pins to equal this CLI version (^VERSION). A generated factory must accept the composition as its second argument and use both spreads:

import type { IPlugin } from '@setu-ts/common';
import type { IKernelApplication, KernelDiagnosticsOptions } from '@setu-ts/kernel';

export function createApp(
  _env?: Readonly<Record<string, unknown>>,
  devtool?: { plugins?: readonly IPlugin[]; diagnostics?: KernelDiagnosticsOptions },
): IKernelApplication {
  return createApplication({
    plugins: [RuntimePlugin(), ...(devtool?.plugins ?? [])],
    ...(devtool?.diagnostics !== undefined ? { diagnostics: devtool.diagnostics } : {}),
  });
}

Keep the existing plugin list. The generated entry checks registration and app.diagnostics after start, stops and exits 1 if the composition was dropped. Workspace connectors allocate from devtoolBasePort, recorded on first allocation (default basePort + 1000, capped at 65535); the first explicit --devtool-port records that range start. Reallocation updates CLI-rendered entries and refuses edited entries before any write. Standalone defaults probe 4919 through 5019 and report the chosen port; new --devtool also writes it into the project README. Re-enabling an unchanged standalone entry retains its recorded port without probing again.

@setu-ts/cli ships the setu executable: project scaffolding and plugin-aware code generation. It is a Node program (Node 22.23.4+, 24.22.1+ or 26.12.1+), published to npm with a bin, so it runs without an install or installs globally; @setu-ts/create makes npm create @setu-ts the same as setu new:

npx @setu-ts/cli new my-app
npm create @setu-ts my-app -- --template rest
npm install --global @setu-ts/cli               # then: setu new my-app

A scaffolded project depends on the framework packages from npm under their own names, pinned to the CLI’s own version — on every target, Deno included (npm:@setu-ts/… in its import map). Every target installs with its own package manager: npm install (Node, Workers), bun install (Bun), deno install (Deno).

The CLI loads a project’s TypeScript — setu.config.ts for plugin commands, a custom schematic — through tsx, from the project’s own node_modules. A Deno project sets nodeModulesDir: "auto", so deno install creates that directory; until it has, plugin commands refuse by name and name the command that fixes it. A Deno project pinning the framework from JSR (jsr:@setu-ts/…, 0.8 and earlier) is refused the same way, pointing at the upgrade guide.

@setu-ts/cli/main — the process boundary

setu new --logger console|pino and setu generate app --logger console|pino (since 0.11.0) select the logger for rest, microservice, class-based, and full-stack, including styled templates. The default is Pino on Node, Deno and Bun, and console on Cloudflare Workers. Pino adds pino: 10.x to runtime dependencies (the same range in Deno’s import map). Explicit console adds no Pino dependency. --logger pino on Workers is refused by name before writing. A minimal host or workspace root also refuses the flag because it registers no logger. Interactive new asks Logger? only for a host with logging; --yes takes the runtime default. --dry-run reports the selected logger and writes nothing.

ExportKindDescription
runMainfunction(runtime: MainRuntime, host: MainProcess) => Promise<number> — runs one invocation and returns its exit code; never exits
nodeProcessfunction(onInterrupt?) => MainProcess — the Node host: process.argv, process.cwd(), console output, one shared readline reader
emitInterruptfunctionDelivers an interrupt to this process’s SIGINT handlers as an event, never as a real signal; nodeProcess’s default
MainRuntimeinterfaceThe runtime services runMain reads (fs, the clock, onSignal)
MainProcessinterfaceThe host: args, cwd(), isTerminal(), prompt(message, signal?) => Promise<string | null>, log, error, portAvailable, dispose?

Breaking (0.9.0): MainProcess.prompt is asynchronous and takes the run’s interrupt signal, so an interrupt ends a pending question rather than waiting for Enter; denoProcess is removed, replaced by nodeProcess. Both executables set process.exitCode rather than calling process.exit, so output still draining to a pipe is not cut off.

@setu-ts/create

ExportKindDescription
createArgsfunction(argv: readonly string[]) => string[] — the setu new arguments npm create stands for

Its create executable runs runMain with createArgs(process.argv.slice(2)), so it accepts exactly what setu new accepts.

Commands

setu build [--dir <path>] [--dry-run] checks CLI wiring and bundles one Node project or workspace member into dist/main.js and a source map, plus dist/src/migrations/run.js when present. It never boots the app. Libraries are inlined and npm packages remain external; runtime devDependency imports fail with the importing file named. Dry-run checks and reports without bundling or writing. Workspace roots and non-Node targets refuse with exit 2; build failures exit 1. Generated Node build scripts type-check before invoking this command; production start runs the bundle. Use start:dev and start:debug for development. See CLI build.

Every socket-target scaffold includes test/app.test.ts: it starts the application’s own createApp() through createTestApp, injects its smoke-test endpoint, and stops it. Workers needs the platform environment and emits no smoke test; its test task permits an empty suite. setu add testing pins @setu-ts/testing in Deno’s import map or npm’s devDependencies.

# Scaffold a project (creates ./my-app)
setu new my-app
setu new my-app --runtime deno                 # node (default) | deno | bun | cloudflare-workers (manifest + start command; code is portable)
setu new my-app --template rest                # rest | microservice | class-based | full-stack
setu new my-app --template microservice --runtime bun
setu new my-app --template rest --style class-based   # functional | class-based (rest, microservice)
setu new my-app --template class-based          # byte-identical alias of --template rest --style class-based
setu new my-app --template rest --env-file config/.env.local
setu new my-app --template rest --logger console  # console | pino; since 0.11.0

# Scaffold a monorepo (creates ./acme, no member yet)
setu new acme --workspace
setu new acme --workspace --port 4100          # base port for its members
setu new acme --workspace --transport redis    # http | grpc | memory | redis | rabbitmq | nats | kafka
setu generate app orders --template microservice
setu generate app billing --template microservice
setu generate app reports --template rest --logger console
setu generate app shipping --template microservice --depends-on orders --depends-on billing
setu generate app orders --devtool                   # + main.dev.ts, dev/check tasks, a devtool port
setu devtool enable orders                           # same composition, on a member that exists
setu devtool enable                                  # standalone project: no member name
setu workspace ports --reallocate

# Install a framework package into this project
setu add auth                                  # short name
setu add auth-plugin                           # bare package name
setu add @setu-ts/auth-plugin                  # full specifier — all three are the same command
setu add cache --dir ./services/orders
setu add cache --dry-run                       # report the manifest edits, write nothing

# Check that every seam artifact is wired (read-only), or rewrite the managed barrels
setu check                                     # exit 1 on an error finding; standalone or workspace
setu check --strict                            # warnings fail too
setu sync                                      # rewrite CLI-managed barrels from their directories
setu sync --dry-run                            # print the barrels it would write

# Write or check this project's AGENTS.md brief
setu agents                                    # standalone, workspace root or member
setu agents --check                            # exit 1 when a section is missing or stale
setu agents --dry-run                          # print the paths, write nothing
setu new my-app --agents                       # scaffold, then write the brief

# Commands this application's plugins provide
setu commands
setu db:migrate up 3                           # runs a plugin-registered command

# Generate code
setu generate module orders                    # functional by default; class output with decorator-plugin
setu generate schema create-user               # requires @setu-ts/validation-plugin
setu generate service pricing --module users   # place a feature file inside an existing module
setu generate plugin my-plugin
setu generate controller user-profile
setu generate service user-profile
setu generate route users
setu generate middleware rate-limit
setu generate guard admin-only                 # requires @setu-ts/auth-plugin
setu generate health-indicator external-api    # requires @setu-ts/health-plugin
setu generate metric orders-placed             # requires @setu-ts/metrics-plugin
setu generate command-handler create-user      # requires @setu-ts/cqrs-plugin
setu generate query-handler get-user           # requires @setu-ts/cqrs-plugin
setu generate event-handler user-created       # requires @setu-ts/events-plugin
setu generate job send-welcome-email
setu generate migration add-users-table        # requires @setu-ts/database-plugin and @setu-ts/common

# Custom schematics, loaded from .setu-ts/schematics/<schematic>.ts
setu generate custom my-schematic order-item

# Aliases
setu n my-app
setu g service user-profile

# Print the plan, write nothing
setu g controller user-profile --dry-run

Any casing of the name produces identical output: setu g controller user-profile and setu g controller UserProfile emit the same file.

Options

OptionCommandsBehavior
--runtime deno|node|bun|cloudflare-workersnew, generateOn new, selects the entry shape and manifest and defaults to node. On generate, passes SchematicOptions.runtime to the schematic (read by custom schematics) and otherwise detects the target from the existing project’s manifest. An unknown value is a usage error (2) on both.
--example <app>newDownloads a standalone Node example from the CLI release tag on GitHub. Dry-run lists paths without fetching. Refuses composition flags and non-Node runtimes; unknown or excluded examples exit 1 with alternatives.
--workspacenewCreates a monorepo root instead of a project: a root manifest whose member globs are apps/* and libs/* (a deno.json workspace array, or a package.json workspaces one under --runtime node|bun), plus a setu.workspace.json, with no member. Both globs are written once, so neither a service nor a library ever rewrites the root. Refuses --template, and --runtime cloudflare-workers — each Worker is its own deploy unit — rather than ignoring them.
--port <n>new --workspace, generate app, adoptOn new --workspace, the base port: the first member binds it and each later one takes the next free number above the highest in use. On generate app, the port THIS member binds instead of the allocated one — refused when another member already holds it. Defaults to 3000. A usage error on a standalone new, or when it is not an integer 1–65535.
--env-file <path>new, generate appConfigPlugin-backed Deno, Node, and Bun templates emit that gitignored dotenv file plus a tracked <path>.example, and use it as ConfigPlugin({ envFilePath, envFileOptional: true }). The file is optional because it is gitignored: the project still starts on a fresh clone, in CI, and in a container, reading configuration from the environment. A template that needs a value names it in both files — full-stack emits SESSION_SECRET, with a development value in the ignored file and an empty one in the committed example. Refused by minimal templates, workspace roots, and Cloudflare Workers, which read request bindings rather than a filesystem. Plugin construction precedes ConfigPlugin registration, so construction-time values must come from this environment source.
--depends-on <member>generate appRepeat for each existing prerequisite. The member records the names in setu.workspace.json; the root dev runner starts prerequisites first and waits for /ready. Duplicate or missing names are usage errors, as is using it anywhere but generate app.
--module <name>generate, add viewPlace a controller, route, service, sse or schema (or add view’s controller) inside the named feature module. The name must be an admitted module, else the refusal lists them. The first four require it once the project has modules, and a flat project refuses it. The file lands in src/modules/<name>/; a function controller is registered by the module’s CLI-owned index.ts, and a class controller or service is added to the @Module declaration’s controllers or providers array when that is a plain array literal (otherwise the line to add is printed). A module-scoped controller mounts at /<module>/<name>, and a module-scoped service registers under the token <module>.<name>-service. Every other schematic is app-wide and refuses the flag.
--adopt-controllersgenerate moduleThe first module only: move every file under src/controllers/ and src/services/ into it, keeping subfolders and URL paths, rewriting relative imports and switching setu.config.ts, all or nothing.
--devtoolnew, generate appOpts a Deno or Node project/member into the local diagnostics connector through a runtime-neutral main.dev.ts entry that the production graph never imports. Node adds a dev script (tsx main.dev.ts) and a diagnostics-plugin devDependency; its existing check script covers the entry. Deno adds dev and widens check. A member records an allocated devtoolPort. Bun, Workers and starter-composed templates are refused by name. The entry reads SETU_DEVTOOL_SESSION_ID and SETU_DEVTOOL_SESSION_KEY from runtime services, refuses invalid pairs, and never generates, writes or prints credentials. The launcher names SETU_DEVTOOL_MEMBER; scripts/dev.mjs (Node) or scripts/dev.ts (Deno) forwards all three only to that member and blanks siblings. Node selects npm run dev for that member, npm run start:dev for others, and keeps selected children in the launcher process group. Stale runners are refused with a delete-and-rerun remedy. All three names are published CLI surface; the Deno root task grants environment access to exactly them. Node enablement writes only the member package.json, refuses conflicting framework pins or a developer-owned dev script, prints npm install, and never runs it. Older CLI-owned entries remain recognized.
--devtool-port <n>new, generate app, devtool enableThe IPv4 loopback port the connector binds. Range-checked like --port; refused when it equals any member’s application or devtool port. Absent, it is allocated in a workspace and probes 4919 through 5019 standalone.
--transport <name>new --workspaceHow the workspace’s services talk to each other: http (default), grpc, memory, redis, rabbitmq, nats, kafka, pubsub, service-bus. Recorded in setu.workspace.json; every member added later inherits it. A usage error on a standalone project, on generate app, or for an unknown value.
--transport-url <url>new --workspaceReplaces the baked local fallback for the endpoint-shaped broker transports. A usage error for http, grpc and memory, which have no broker to address, and for pubsub and service-bus, whose connection value is a project id or a secret read from the environment — that refusal names the variable.
--style functional|class-basednew, generate appThe decorator-and-DI axis, separate from --template: functional (default) installs neither DecoratorPlugin nor DiPlugin; class-based installs both together. Accepted on the styleable templates rest and microservice; refused with no template to apply to, with an unknown value, and on full-stack, which composes through a starter and has no controller or ingress seam. --template class-based is a byte-identical alias of --template rest --style class-based.
--broker <name>new (standalone)The project’s own message broker: memory (default), redis, rabbitmq, nats, kafka, pubsub, service-bus. Rewrites the template’s MessagingPlugin wiring to that arm, adds the arm’s connection variable to the generated dotenv pair, and emits docker/compose.yaml starting the broker so the scaffold can complete app.start(). Refused — each with its own reason, never silently ignored — on Cloudflare Workers (the runtime swap removes both wirings), starter-composed templates (full-stack; a factory-rendered plugin list would drop the rewrite), templates registering no messaging (rest and no template; microservice registers messaging in every style), unknown names, and --workspace/generate app (a workspace’s transport is chosen once with --transport).
--queue <name>new (standalone)The job queue backend: memory (default), redis, rabbitmq. Same overlay as --broker, applied to the QueuePlugin wiring, sharing the connection variable and Compose service when both flags name the same arm. Refused for the brokers the queue does not support (nats, kafka, pubsub, service-bus) and everywhere --broker is refused.
--redis-client <name>new, add (with a Redis arm)M117: ioredis (default, output unchanged) or node-redis. A node-redis project depends on redis instead of ioredis and wires each Redis-backed plugin with client: nodeRedisClient(<url>) from a generated src/node-redis.ts (one client per plugin; the backplane also gets subscriber), keeping setu.config.ts free of third-party imports. --workspace --transport redis --redis-client node-redis records redisClient: 'node-redis' in the workspace manifest (absent = ioredis); generate app and member setu add follow it. Refused (exit 2) without a Redis arm, on Cloudflare Workers, on generate app, inside a workspace member, and for an unknown value; an unknown stored redisClient is refused (exit 1) by name.
--yes, -ynewTake every default and ask nothing. A no-op when no prompter is present, never an error.
--scope <scope>generate libraryThe import scope a shared library is named under, without the leading @. Defaults to the workspace directory name.
--name <member>adoptThe member name the converted project takes. Defaults to the project directory name.
--agentsnewWrite the project’s AGENTS.md agent brief after scaffolding, through the same writer setu agents uses. On --workspace, the brief is written for the new root. Opt-in: setu new writes no brief without it, and setu add does not run the writer.
--checkagentsRender every brief the command would write and compare it with the section on disk, writing nothing. Exit 0 when every file matches; exit 1 naming each file that is missing a section or whose section differs. Refused together with --dry-run (exit 2). Card presence is not checked, so it does not depend on whether install has run.
--dir <path>new, generate, add, agentsOperate on this directory instead of the working directory. A relative path is resolved against the working directory.
--dry-runnew, generate, add, agents, workspace portsPrints would create <path> per file and performs zero writes and zero directory creations. On add, prints would update <manifest> instead, since it edits files rather than creating them. On agents, prints would write <path> per brief. workspace ports prints would update <path> for each managed file instead of writing the workspace manifest.
--help, -hbothPrints usage and exits 0. setu generate --help lists only the schematics available here.
--version, -v—Prints the version read from the package’s own deno.json and exits 0.

Every command refuses an option outside its own set — subcommand-aware for generate app, generate library, and workspace ports — with exit 2 and nothing written, suggesting the nearest known name when one is within a small edit distance (--templat suggests --template; --base-port suggests --port). A flag kept deliberately recognized for a named refusal (--di and --depends-on on new, the transport flags and --runtime on generate app) still reaches that specific message. Plugin-registered commands accept only --dir and --config: a CliCommandHandler receives positionals only, so no plugin command can read any other flag.

setu add <plugin>

Adds a framework package to the project’s manifest, pinned to the version of the CLI that added it — the same rule setu new follows, so a project’s framework packages stay on one version instead of drifting per install.

The name resolves three ways, all equivalent: a short name (auth), the bare package name (auth-plugin), or the full specifier (@setu-ts/auth-plugin). It is an explicit allow-list rather than “anything under @setu-ts/”, because the range written is the CLI’s OWN version, which is only correct for packages released as one version with it — so a typo is refused, naming what is accepted, rather than pinned to a version that does not exist.

Deno projects update only deno.json imports, even when a full-stack frontend has its own package.json. Node, Bun and Workers update every manifest they carry. Every pin comes from npm: a Deno import holds npm:@setu-ts/<name>@^<version> and a package.json dependency holds ^<version>. Testing uses npm devDependencies. Existing key order is retained; sorted maps receive a sorted insertion, otherwise the new framework key follows the existing scope block. Re-adding the same pin leaves the dependency map unchanged.

The twelve safe zero-configuration providers listed in the CLI guide are registered above the emitted development-plugin anchor in either generator style. In a functional project, events, cqrs, health and metrics are registered with the generated-artifact barrel they consume, for example EventsPlugin({ handlers: EVENT_HANDLERS }), and an empty barrel is created when absent, so a handler a later setu generate writes is live. Class-based projects keep the bare call; their handlers arrive through the ingress barrel. Unrecognized source is preserved. Starter compositions print their option arm; installing a package does not authorize a duplicate registration. Existing development diagnostics modules are refreshed with names-only approval maps and known plugin calls receive development-only options.

It writes the manifest and reports the install command rather than running it. The command matches the project’s toolchain, detected from its manifests the same way setu generate detects it:

ProjectPrinted command
Denodeno install --min-dep-age 0
Nodenpm install
Bunbun install
Cloudflare Workersnpm install

That is deliberate. On the day of a release the pin is younger than Deno’s 24-hour minimum-dependency-age policy, so the flags need to be visible rather than buried in a failing subprocess — and it keeps the command free of the run permission. No registry mapping is printed: every package resolves from the default npm registry.

A workspace root is refused (exit 2, nothing written) — recognised by setu.workspace.json, a deno.json workspace key, or a package.json workspaces key. Framework packages are pinned in each member, because setu generate reads the member’s manifest for plugin gating; run setu add <plugin> --dir <member directory> instead.

Since 0.11.0 (M119f), optional peers supplied by setu add are also recorded in the workspace root’s package.json dependencies when the target is a listed Node/Bun Setu workspace member. The same CLIENT_RANGES ranges remain in the member; existing peer entries in either manifest are preserved. Both edited paths are printed, and the writes share rollback handling. An absent root manifest keeps member-only behavior; JSONC or invalid/unreadable JSON is refused by the root filename before any writes. Deno workspaces and standalone projects are unchanged. See Optional peers in a workspace.

Exit codes follow the table below: 0 on success, 1 when the directory holds no manifest or a manifest cannot be parsed, 2 for an unknown package name, a missing argument, or a workspace root.

setu agents

Writes or checks the project’s agent brief: AGENTS.md in the project directory. The CLI owns one section, delimited by <!-- setu-agents:begin --> and <!-- setu-agents:end -->, and regenerates it from the project’s real state — the installed @setu-ts/* packages, style, runtime, the scripts its manifest defines, and the schematics available to it. The line after begin reads “setu agents reads manifests and overwrites these markers’ contents.” Every byte outside the markers is preserved, so a developer-owned preamble survives a rewrite.

The marker arrangements have one behaviour each: an absent file is created containing only the section; a file with no markers gets the section at the top, one blank line, then its existing bytes; exactly one begin followed by exactly one end has the bytes between them replaced; and any other arrangement (an end before begin, two begins, a begin with no end) exits 1 naming the file and the problem, writing nothing. A path that is not a regular file, or is over 1 MiB, is refused the same way.

--check renders every brief it would write and compares it with the section on disk: exit 0 when all match, exit 1 naming each file that is missing a section or whose section differs, and nothing written. Card presence is not part of the check, so a CI checkout can run it before install. --dir selects the project or workspace, a relative path resolved against the working directory. --dry-run prints would write <path> per file and is refused together with --check. A real write prints one line per file: created, updated or unchanged, then the path. Outside a Setu-TS project the command exits 2; an unreadable manifest or a runtime marker whose metadata cannot be inspected exits 1 naming the file.

In a workspace it may be run from the root or any member directory: it writes the root AGENTS.md and every member’s apps/<name>/AGENTS.md, and --check checks all of them. The root brief covers the workspace commands, the member table and the transport; each member’s brief is its standalone brief plus a pointer at the root file.

Writing a workspace on POSIX, from its root or a member, checks every target’s directory chain up to /: each directory must belong to the invoking user or root and must not be a symlink, the same ownership rule as git’s safe.directory. Permission bits are not checked, so a directory you own but others can write is trusted. --check writes nothing and skips the check; --dry-run applies it. A failure exits 1, names the directory and reason, writes nothing, and asks you to run setu agents in a workspace you own; it never falls back to standalone. This check is skipped when ownership metadata or the invoking uid is unavailable (including Windows); it does not protect against another process running as the same user. Missing directories pass ownership validation only when their nearest existing ancestor and its chain pass; a missing member still exits 1 with <member>: not a project, writing nothing. Standalone projects are unchecked.

Every published library package ships an AGENT_CARD.md in its npm tarball — beside dist, src, README.md and LICENSE in the generated files list — so a normal install delivers it to node_modules/@setu-ts/<pkg>/AGENT_CARD.md, the path the brief names. In a workspace member the named path is the workspace root’s, ../../node_modules/@setu-ts/<pkg>/AGENT_CARD.md. After writing, one advisory line per missing card is printed (card not found: <path> — run install) without changing the exit status; a project with no node_modules under Yarn Plug’n’Play or a Deno project without nodeModulesDir gets the setting to change instead. cli and create carry no card: they are run, never imported.

setu new --agents runs the same writer after scaffolding. Nothing else runs it: setu add prints Run `npx @setu-ts/cli@<version> agents` to update AGENTS.md. (the project’s pinned CLI invocation for its runtime) after a successful add when the project already has a sectioned brief, and neither command edits an existing file silently.

Exit codes

CodeMeaning
0Success (including --help and --version).
1Runtime error: a gated schematic’s plugin is absent, a target file exists, a write failed, the application failed to load or start, a command handler threw, or a command name is registered twice.
2Usage error: unknown command or schematic, missing argument, unknown --runtime, an unusable --broker/--queue/--redis-client value, an option the command does not recognize, or a name that cannot form an identifier (empty after normalization, or digit-leading).
130Interrupted or cancelled; built-in writes are rolled back, or plugin application shutdown was attempted. Incomplete recovery is reported.

Rollback covers SIGINT (Ctrl+C) and caught errors only. SIGTERM, SIGKILL (kill -9) and power loss end the command without rollback. Re-running setu adopt converges; an interrupted new or generate can leave the files it wrote, which the next run lists in its overwrite refusal.

For plugin commands, an already-aborted CliDependencies.interrupt prevents boot. Aborting during a handler stops awaiting its result and awaits app.stop() before returning 130. The CLI cannot cancel arbitrary handler code or undo external side effects; the application’s shutdown hooks must release its resources. Startup is allowed to settle before checking interruption and shutting down.

Interactive scaffolding

At an interactive terminal, setu new asks for the choices it already accepts as flags — on a standalone project: runtime, template, broker, queue; on a workspace: runtime and transport. Questions are asked only for absent flags, only when --yes is absent, and only when a prompter was supplied at all; the broker and queue questions fire only when the answers already collected make the flag legal under the same predicate the command refuses on. Every prompted value is expressible as a flag, so prompts are never a second configuration surface, and --dry-run stays exact.

Non-interactive by construction in three layers, all failing closed to the documented defaults: CliDependencies.ask is OPTIONAL and no programmatic caller passes it; src/main.ts supplies the terminal implementation only behind Deno.stdin.isTerminal(); and Deno’s own prompt() returns null on a non-terminal.

The two exported types:

ExportKindMembers
PrompterinterfaceSince 0.9.0, select(question, choices): Promise<PromptSelection> — answer, unavailable, or cancelled.
PromptChoiceinterface{ value, label } — the value written into the flag record, and one descriptive line shown above the question.
PromptSelectiontypeSince 0.9.0, { kind: 'answer', value } | { kind: 'unavailable' } | { kind: 'cancelled' }.

createTerminalPrompter is deliberately NOT exported: its only consumer is the executable entry point, which imports it directly.

Decorators and DI are optional

AI_GUIDELINES states that decorators are optional, dependency injection is optional, and that everything has a programmatic API — no feature requires decorators or reflection. The CLI has two coherent compositions on the --style axis: functional is the default; --style class-based opts into both decorators and dependency injection. The axis is separate from --template, and --template class-based is a byte-identical alias of --template rest --style class-based.

You wantScaffold withYou get
Neithersetu new appThe runtime plugin alone. g route, g middleware, g plugin, g service, g job all work and are wired.
Functionalsetu new app --template restRouter functions and explicit request context; g module creates a registered route with GET and POST handlers.
Class-basedsetu new app --template rest --style class-basedDecoratorPlugin + DiPlugin, plus decorated controllers, injectable services, and class module registration.

--style is accepted on the styleable templates rest and microservice, by setu new and setu generate app; it is refused with no template to apply to, with an unknown value, and on full-stack, which composes through a starter and has no controller or ingress seam.

The independent --di flag is no longer supported. It is refused with guidance to use --style class-based (with --template rest or microservice), which always installs the decorator and DI pair together, and --template nest is refused with a message naming class-based as its new name. --template full-stack therefore has no DI opt-in from the CLI; an application composing the starter directly still has FullStackStarterOptions.di.

The style is read from the target project’s manifest on every setu generate, so it PERSISTS: a project holding decorator-plugin keeps producing decorated classes — including one that holds it without di-plugin, which only a project predating --template class-based can — and a project without it keeps producing functional output. One consequence is worth stating plainly: setu generate service has no registration site in a functional project. A plain exported function has none to have — no plugin option takes a list of functions — so nothing wires it for you. It still emits a managed src/services/index.ts, but that barrel is a convenience re-export rather than a registration: setu.config.ts does not import it, and you import from it where the function is needed. In a class-based project the same path holds the APP_SERVICES array of @Injectable classes, which the scaffolded config does pass to DecoratorPlugin.

Node development runs TypeScript through tsx; production runs the bundle. Node’s built-in support (--experimental-strip-types) ERASES types without transforming code, so it cannot run a decorator — V8 has not shipped them, so even a TC39 standard decorator is a bare SyntaxError: Invalid or unexpected token there — or the constructor parameter property setu generate module emits (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX). A generated Node project therefore declares tsx in devDependencies and develops with tsx watch main.ts, which transforms both; its tsconfig.json sets no decorator option, because standard decorators need none. Bun compiles TypeScript outright and Deno and Workers never invoke it, so no other target carries the dependency.

The Workers target carries an npm manifest as well as deno.json. wrangler bundles src/index.ts with esbuild, which does not read a Deno import map, so a scaffolded Workers project emits package.json (@setu-ts/* dependencies from npm, wrangler pinned in devDependencies, and dev/deploy scripts), and npm install && npx wrangler dev works as printed. The Deno target does NOT get one: it resolves through the import map, and a package.json switches Deno to node_modules resolution.

The decorator-free way to serve HTTP is setu generate route. It emits register<Name>Routes(router: IRouterApi) and is ungated, so it works in a project with no plugins at all — and it is wired on every host, including the no-template one. g controller and g module stay gated on @setu-ts/decorator-plugin (they emit @Controller, so an ungated project would get source whose own import cannot resolve), and their refusal names g route as the alternative:

The "controller" schematic requires @setu-ts/decorator-plugin, which is not installed in /path/to/app.
Install it, then run this command again.
Or run `setu generate route user-profile` — it registers handlers on the router API, so it needs no decorators.

Monorepo workspaces

A Setu workspace is one repository holding several deployable services, each its own scaffolded project under apps/. The root is a Deno workspace by default and an npm workspace under --runtime node|bun; see Node and Bun workspaces for what that changes.

setu new acme --workspace                                  # the root: deno.json + setu.workspace.json
cd acme
setu generate app orders --template microservice           # apps/orders, port 3000
setu generate app billing --template microservice          # apps/billing, port 3001
deno task dev                                              # dependency-aware startup, gated by /ready

The root manifest declares members by glob — "workspace": ["./apps/*"] in deno.json, or "workspaces": ["apps/*"] in package.json on the npm arm — so adding one creates a directory and rewrites nothing. Framework packages are pinned in each MEMBER’s own manifest, never at the root: setu generate detects installed plugins by reading one directory’s manifest and never walks up, so root-only pins would make every gated schematic refuse inside a member. Members’ dependencies resolve independently, so two members may install different plugin sets.

Adding a service registers it with its callers. Every member carries a CLI-owned src/discovery/services.ts that is regenerated for all members on each setu generate app:

/** The port this workspace member binds. */
export const SERVICE_PORT = 3000;

/** Every OTHER member of this workspace, by service name. */
export const SERVICE_ENDPOINTS = {
  billing: [
    {
      host: Deno.env.get('BILLING_HOST') ?? '127.0.0.1',
      port: 3001,
    },
  ],
};

The member’s main.ts binds SERVICE_PORT and — when its template installs @setu-ts/service-discovery-plugin — its setu.config.ts passes SERVICE_ENDPOINTS to ServiceDiscoveryPlugin({ provider: 'static', services: SERVICE_ENDPOINTS }); the full-stack factory passes the same typed option through its starter composition. So the port a member binds and the port its siblings dial are the same datum, and discovery.resolveUrl('billing') works from any sibling with no configuration. A member without that plugin is still listed in every other member’s map; being reachable and consuming the map are separate properties.

That module is managed: the CLI rewrites it and a hand edit is lost. The data it is rendered from is setu.workspace.json at the root, which records each member’s name, port, and optional dependsOn list — edit a port there and the next setu generate app rewrites every module from it. setu workspace ports --reallocate instead assigns currently bindable ports and rewrites all managed maps and deployment artifacts in one operation.

deno task dev (or the Node/Bun equivalent) reads that dependency metadata at runtime. It starts a prerequisite first, waits until http://127.0.0.1:<port>/ready succeeds, then starts each dependent; it names a cycle or readiness timeout and terminates children on failure or shutdown. A discovery map does not imply a startup dependency, so only services declared with --depends-on are gated.

Each sibling’s host is overridable — <MEMBER>_HOST — and the fallback is the local address. Both halves matter: deno task dev on one machine needs loopback, and inside a container loopback is the container ITSELF, so a fixed value would have every member dial itself on its sibling’s port. The generated Compose stack and Kubernetes objects set those variables to the service names.

It is still only a LOCAL topology. A deployed one comes from a real discovery backend (provider: 'consul', 'kubernetes', 'dns'), not from this file.

Container and Kubernetes artifacts

generate app also writes, and regenerates for the whole workspace on every member:

PathWhat it is
docker/DockerfileOne parameterized image for every member: docker build -f docker/Dockerfile --build-arg MEMBER=orders -t orders:dev . The context is the workspace root, and every libs/ member is copied in — a service that imports a shared library cannot otherwise resolve it.
.dockerignoreAt the workspace ROOT, which is where Docker reads it — one under docker/ is read by nothing. Keeps .git and every node_modules out of the context, so the host’s copy is never laid over the one the image installed.
docker/compose.yamlEvery member plus the transport’s backing service, with each member’s allocated port published and its siblings’ hosts set to their service names.
k8s/members.yamlA Deployment and a Service per member. ${NAMESPACE} and ${WORKSPACE} are placeholders — pipe it through envsubst.

All are managed, for the same reason the discovery modules are: a stack that names two of three members is worse than none. M39 owns this repository’s OWN deployment objects; these are the generated project’s.

The images and objects carry M39’s cluster findings rather than defaults — a numeric runAsUser (runAsNonRoot refuses a named one, and only under Kubernetes), /tmp as the only mount (an emptyDir over the module cache masks the cache baked in at build time), a pinned namespace, and a terminationGracePeriodSeconds that is real only because the generated entry installs a SIGTERM handler. Probes are tcpSocket, because /live and /ready exist only when a member registers HealthPlugin. No Ingress is generated: which controller, host and issuer are cluster decisions, and a guessed Ingress routes nothing.

RefusalWhy
generate app outside a workspaceNo setu.workspace.json in the target directory. Exits 1 naming setu new <name> --workspace.
A member name already in useExits 1 naming the directory it already has. The managed exemption covers regenerated modules only, never a member’s own source.
--runtime cloudflare-workersdeno, node and bun all host a workspace; Workers does not, and that is a topology difference rather than a missing profile — each Worker is its own deploy unit with its own wrangler.toml, so several in one repository are several deployments, not members sharing a root manifest and a lockfile. Exits 2 naming setu new <name> --runtime <target> for a standalone project.
--template full-stack on a broker transportThat template composes its curated plugin set through a starter factory, so a broker’s MessagingPlugin rewrite cannot be applied without silently leaving the starter default in place. The command refuses it rather than scaffolding a member that reaches nobody. grpc is supported: its GrpcPlugin({ basePath: '/grpc' }) contribution is registered after the starter factory returns, and the generated form-CSRF policy exempts that RPC prefix only. http, grpc, and memory are allowed; the root gains nodeModulesDir when the frontend member arrives.
generate app --port on a taken portTwo members on one port cannot both bind, while every sibling’s map names both — so one name would resolve to the other service. Exits 1 naming the member that holds it. A free port is honoured, and allocation still derives from the highest in use, so a hand-picked port moves the ceiling rather than being reused.
new --workspace --diThe independent DI switch is retired. Exits 2 directing the caller to --style class-based (with --template rest or microservice).
A port in setu.workspace.json outside 1–65535Refused on read, naming the value and the field. Every port there is written into a member’s entry point AND into every sibling’s map, so one bad number breaks the workspace: app.start() throws Invalid port (out of range), and 0 is worse still — it binds an arbitrary free port, so the member looks healthy while every sibling is refused. Exits 1.
A workspace with no port leftbasePort and its members reach 65535. Exits 1 rather than allocating 65536.

Inter-service transport

The workspace decides how its services reach each other, because members can only meet on a bus they share — a per-member choice would make a workspace whose services silently cannot talk expressible in one flag. generate app therefore refuses --transport and names the workspace flag.

--transportWhat every member getsProven by
http (default)Nothing extra. Members call each other at discovery.resolveUrl(name).Three members, one calling both peers, asserting 200 and the body.
grpcGrpcPlugin({ basePath: '/grpc' }), co-serving Connect/gRPC on the member’s own port alongside HTTP. The full-stack template exempts this protocol prefix from form CSRF, leaving its own form routes protected.Three members, one calling both peers at /grpc/grpc.health.v1.Health/Check, asserting the decoded SERVING.
memoryThe messaging plugin’s own in-process default, named rather than implied. Messages never leave the process.—
redisMessagingPlugin({ broker: 'redis-streams', url })A real Redis: one service publishes, another receives the payload. Swapping to memory makes that test fail.
rabbitmq / nats / kafkaThe matching MessagingPlugin arm.Type-checked in the generated project against the plugin’s discriminated union; not run in CI, which holds no such broker.
pubsubMessagingPlugin({ broker: 'pubsub', projectId }), the project id read from PUBSUB_PROJECT_ID.Two generated members over the real Pub/Sub emulator: one publishes, the other receives the payload.
service-busMessagingPlugin({ broker: 'service-bus', connectionString }), read from SERVICE_BUS_CONNECTION_STRING.Two generated members over Microsoft’s emulator, started from the generated compose file and its generated entity config.

There is no raw-TCP transport, and --transport tcp says so rather than quietly handing back HTTP under another name. Every inter-service path here is HTTP over TCP or a broker client over TCP.

Every transport with a connection value reads it from the environment, with the local address as the baked fallback: Deno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379'. One mechanism, because a literal endpoint is unreachable from inside a container — the Compose stack and the Kubernetes objects override the variable with the broker’s service name.

--transport-url replaces that fallback for the endpoint-shaped arms. It is refused for pubsub and service-bus, naming the variable instead: a GCP project id is not a URL, and an Azure connection string carries a shared-access key, which is not something to write into a file you commit. Their fallbacks are the vendors’ own local-emulator settings, which is what lets an emulator workspace run with no configuration at all. Two operational facts they carry, because a developer cannot guess either: Pub/Sub does not create topics (publish posts to an existing one), and the Service Bus emulator creates no entities at run time — every topic must be declared in the generated docker/servicebus-config.json with a messaging-consumers subscription before it starts.

Serving your own gRPC services needs descriptors, and the toolchain to build them ships. --transport grpc makes every member a Connect server immediately, which is why the health service answers with no configuration. A service of your own needs a Protobuf-ES descriptor, which only a compiler can produce — so each member gets an example proto/, both buf manifests, and:

deno task proto:gen      # descriptors land in src/gen/

Nothing needs buf or protoc on your PATH: both the compiler and the codegen plugin run through Deno’s npm compatibility, and the member’s import map carries @bufbuild/protobuf so the generated file compiles. Hand the descriptor to grpc.addService(definition, implementation).

Node and Bun workspaces

A workspace targets the toolchain you name, because the framework’s own claim is runtime independence and only the MONOREPO was Deno-only:

setu new acme --workspace --runtime node    # or bun, or deno (the default)

The runtime is recorded in setu.workspace.json and every later command reads it back. It is a WORKSPACE-wide choice, like the transport and for a stronger reason: members share one root manifest and one lockfile, so generate app --runtime is refused when it disagrees with the workspace — a Node member inside a Deno workspace is not a member at all. An absent field means deno, so every workspace created before this keeps its shape.

denonodebun
Root manifestdeno.json workspacepackage.json workspacessame as node
Installdeno installnpm installbun install
Run every memberdeno task devnpm run devnpm run dev
Environment read in generated sourceDeno.env.get(x)process.env.Xprocess.env.X
Library test runner@std/testingnode:testbun test
Imagedenoland/denonode:24-alpineoven/bun

Three facts were measured, and each shaped the result. Bun needs no root shape of its own — it reads npm workspaces — but it installs into each MEMBER’s node_modules as well as the root, so the generated ignore file and image account for both. No registry mapping is needed: every member takes the framework from npm, so the root carries no .npmrc (before 0.9.0 one mapped the @jsr scope, and without it no framework package resolved). And a sibling library resolves by its package name under npm exactly as it does under Deno, so libraries needed no per-runtime design beyond their manifest.

Cloudflare Workers is refused as a workspace target, with the reason: each Worker is its own deploy unit with its own wrangler.toml, so several in one repository are several deployments rather than members of one.

Shared libraries

Code two services both need is a workspace member of its own:

setu generate library shared          # libs/shared, importable as @acme/shared

It needs no wiring at all, and that is a property of the workspace rather than a convenience: a member declaring name and exports is importable by every sibling under exactly that name, with no import-map entry anywhere. It holds on both arms, which is why libraries need no per-runtime design — under Deno the workspace resolves the member directly, and under npm the symlinks the install creates in the root node_modules do the same.

import { shared } from '@acme/shared';

The scope defaults to the workspace directory name and --scope overrides it. Nothing is recorded in setu.workspace.json: a library has no port and is not a service, so it must never appear in a discovery map or in the Compose stack, and the directory plus the root’s ./libs/* glob already are the record. A workspace created before libraries existed has that glob added to its root; anything else about the root is left alone.

Converting an existing project

cd my-service
setu adopt --port 4000     # my-service becomes a workspace holding apps/my-service

It moves only files this CLI emits, from the renderer’s own list rather than a directory walk. That is a correctness property: a workspace has one lockfile and one history, both at the top, so .git, CI configuration, deno.lock and node_modules stay at the root. Anything else you added there stays too.

Because IFileSystem has no rename, each move is copy → verify → delete, in that order: a failure part-way leaves the file in both places, which is recoverable and reported, rather than in neither. The member’s entry is rewritten to bind its allocated port; if it no longer carries the literal this replaces, the two lines to change are printed instead of guessed at.

RefusalWhy
A directory that is already a workspacesetu.workspace.json exists. Exits 1 naming generate app.
A directory with no setu.config.tsNot a Setu project: there is no application factory to become the member’s. Exits 1.
A --name that cannot form an identifierExits 2. The default comes from the directory name, so this is reachable without the flag.

Plugin gating

setu generate reads the target project’s deno.json imports (falling back to package.json dependencies + devDependencies) to learn which @setu-ts packages are installed. It never imports or boots the project. A schematic whose backing plugin is absent is refused with exit code 1, naming the package to install, and setu generate --help marks it unavailable.

Overwrite protection

A generate that would overwrite any existing file writes NOTHING at all — every planned path is checked before the first write, so a multi-file schematic can never leave a half-written tree.

The one exception is a managed file: a path the CLI generated itself and regenerates, declared by the schematic as GeneratedFile.managed. Managed paths are exempt from the check and rewritten in place; every other path keeps the refusal above, including within the same command. The exemption is per file rather than a --force flag, so a mistyped setu g service user can never clobber hand-written work.

The managed files are the CLI-owned seam barrels — one index.ts per generated family (src/modules/, src/controllers/, src/services/, src/middleware/, src/plugins/, src/health/, src/metrics/, src/cqrs/, src/events/) — and each functional module’s own src/modules/<name>/index.ts, which setu generate module and setu generate … --module <name> regenerate. The development-only source policy src/devtool/diagnostics.ts is also CLI-managed. No flag can make an arbitrary path managed.

Generated code is wired

Every artifact below reaches its registration site with no edit to a file you own. A wired schematic emits its artifact plus its family’s seam barrel; the barrel is regenerated from a scan of its directory, and the scaffolded setu.config.ts already imports it. Some artifacts have no registration site, and the reason is recorded rather than left to discovery.

SchematicEmitted atRegistration site
modulesrc/modules/<name>/; the first module switches a flat project to modules (--adopt-controllers moves the flat controllers and services in)registerModules(app.router, app.services) from src/modules/index.ts, plus DecoratorPlugin({ modules }) via MODULES (class mode)
controllersrc/controllers/<name>.controller.ts; src/modules/<module>/<name>.controller.ts with --module (required once the project has modules)DecoratorPlugin({ controllers }) via APP_CONTROLLERS (class mode) or registerGeneratedRoutes; in a module, its @Module declaration (class) or its index.ts (function)
servicesrc/services/<name>.service.ts; src/modules/<module>/<name>.service.ts with --module (required once the project has modules)DecoratorPlugin({ services }) via APP_SERVICES, or in a module its @Module({ providers }) — see below
routea function controller: src/controllers/<name>.controller.ts; src/modules/<module>/<name>.controller.ts with --moduleregisterGeneratedRoutes(app.router, app.services) in createApp(), or the module’s index.ts
schemasrc/shared/schemas/<name>.schemas.ts; src/modules/<module>/<name>.schemas.ts with --moduleNothing — the feature that uses it imports it
ssesrc/controllers/<name>.controller.ts; src/modules/<module>/<name>.controller.ts with --module; plus src/hooks/use-<name>.ts ONLY when react-router-plugin and sdk are both installedAs for controller; the hook, when emitted, remains application-local
ws-routesrc/plugins/<name>.plugin.ts; class-based: src/ingress/<name>-ws-route.ingress.ts...GENERATED_PLUGINS in createApplication({ plugins }); class-based: DecoratorPlugin({ ingress })
middlewaresrc/middleware/<name>.middleware.tsapp.middleware.add(...) over GENERATED_MIDDLEWARE
pluginsrc/plugins/<name>.plugin.ts...GENERATED_PLUGINS in createApplication({ plugins })
health-indicatorsrc/health/<name>.indicator.tsHealthPlugin({ indicators: [...HEALTH_INDICATORS] })
metricsrc/metrics/<name>.metric.tsMetricsPlugin({ customMetrics: [...CUSTOM_METRICS] })
command-handlersrc/cqrs/<name>.command-handler.ts; class-based: src/ingress/<name>-command-handler.ingress.tsCqrsPlugin({ commandHandlers: COMMAND_HANDLERS }); class-based: DecoratorPlugin({ ingress })
query-handlersrc/cqrs/<name>.query-handler.ts; class-based: src/ingress/<name>-query-handler.ingress.tsCqrsPlugin({ queryHandlers: QUERY_HANDLERS }); class-based: DecoratorPlugin({ ingress })
event-handlersrc/events/<name>.event-handler.ts; class-based: src/ingress/<name>-event-handler.ingress.tsEventsPlugin({ handlers: EVENT_HANDLERS }); class-based: DecoratorPlugin({ ingress })
guardsrc/guards/<name>.guard.tsNone — per route, by design (see below)
jobsrc/jobs/<name>.job.ts; class-based: src/ingress/<name>-job.ingress.tsFunctional + queue: QueuePlugin({ processors: [...QUEUE_PROCESSORS] }); standalone without queue; class-based: DecoratorPlugin({ ingress })
migrationsrc/migrations/<stamp>-<name>.tsNone — the framework ships no migration runner

Registration details for artifacts with conditional or per-route wiring:

  • guard is applied per route, with app.router.get(path, { handler, middleware: [requireX()] }) or @UseGuards(requireX()) on a controller or handler. auth-plugin publishes no guard list, and the only barrel-shaped alternative — the global pipeline — would answer 401 for /health, /metrics and every public route. The emitted guard delegates to requirePermission('<name>'), enforcing the installed authorization provider for anonymous and insufficient-permission requests. A wiring that must not be applied is not a wiring.
  • job uses the installed queue. Functional projects with queue-plugin emit a processor factory and the managed src/jobs/index.ts barrel for QueuePlugin({ processors: [...QUEUE_PROCESSORS] }). Without queue they emit a standalone function. Class-based projects require queue and emit a decorated processor through the ingress seam; without queue the command refuses before writing and names setu add queue. Each processor polls only its own job name; producers enqueue that name explicitly.
  • migration has no consumer. No plugin registers a CLI command, so there is no setu db:migrate and nothing reads migration files. Apply them from your own script or your ORM’s tooling.
  • service is shaped on the detected plugin set. With @setu-ts/decorator-plugin installed it emits @Injectable({ token: '<name>-service' }) plus the barrel entry, so the class resolves from services.get('<name>-service') (or the DI container, when DiPlugin is registered). Without that package it emits a plain class and no barrel — the schematic stays ungated, so it keeps working in a project with no plugins at all.

Two artifacts cannot share a name, and generate refuses (exit 1) rather than producing something broken:

  • route, controller and module all mount /<name>. The kernel’s router keys routes by method and path, so a duplicate silently overwrites and one artifact becomes unreachable.
  • service and module both register @Injectable({ token: '<name>-service' }). The decorator plugin keeps the first class registered under a token, so the wrong service would be injected.

The first check runs in every style — functional modules are scanned in the name-conflict path, so a route beside a module of the same name is refused in a functional project too, where the two files no longer share a path. The second applies only when decorator-plugin is installed, since that token collision cannot exist without it. A repeat generate of the same schematic is the ordinary overwrite refusal instead.

A project scaffolded before a seam existed has no import of its barrel. Each barrel’s header states the exact lines to add to setu.config.ts; add them once and every later generate is wired. The rest, microservice and class-based templates emit every applicable seam from scaffold time, so a new project is wired before anything is generated — and so does the no-template path, for the three seams that need no plugin (route, middleware, plugin). --template full-stack is deliberately not a host: its layering is routes → features → services, it composes through a starter factory, and its createApp has no plugin array to spread into.

Artifacts generated before their family gained a second export are skipped, and reported. A barrel imports specific symbols from each artifact, and two families gained one in this release: middleware now exports a <SCREAMING>_MIDDLEWARE_PRIORITY constant and metric a <SCREAMING>_METRIC declaration. An artifact generated earlier has the right filename and lacks that export, so listing it would put an unresolvable import in the barrel. The scan admits a file only when it exports everything the barrel will name, and prints what it left out:

Skipped src/middleware/audit-log.middleware.ts: it does not export
  AUDIT_LOG_MIDDLEWARE_PRIORITY, so it cannot be listed in the generated barrel.
  Regenerate it to bring it up to date.

Delete and regenerate the artifact to bring it back in. The same rule keeps a hand-written module in a scanned directory out of the barrel — the flat-family form of the precondition that admits a src/modules/ directory only when it holds both canonical files. Export detection reads the source text rather than parsing it (this package has no TypeScript parser), so a declaration (export const X) and a named re-export (export { y as X }) are recognized while an aliased default export is not; an undetected export means the artifact is skipped and reported, never that a broken barrel is written.

Which seams a host carries depends on which plugins it registers: the no-template path carries the three that need none (src/controllers/, src/middleware/, src/plugins/), the functional templates carry only the seams their plugins consume, and class-based additionally carries controller, service and module barrels. microservice additionally carries src/cqrs/ and src/events/, because it is the only template registering CqrsPlugin and EventsPlugin.

Domain modules

setu generate module <name> is the aggregate schematic, and it is ungated — it runs in every project shape, including one scaffolded with no template. Which of two file sets it emits is decided by whether @setu-ts/decorator-plugin is installed.

Functional (the default composition):

src/modules/<name>/<name>.service.ts        in-memory `Map` store — replace with a repository
src/modules/<name>/<name>.routes.ts         register<Name>Routes — GET /, GET /:id, POST / (201), PATCH /:id
src/modules/<name>/<name>.schemas.ts        one Zod value and a `z.infer` type per shape (with `validation-plugin`)
src/modules/<name>/index.ts                 CLI-owned, regenerated — exports `register<Name>Module(router, services?)` and nothing else
src/modules/<name>/<name>.test.ts           in-process contract test (socket targets only)
src/modules/<name>/<name>.e2e.test.ts       real-HTTP contract test (socket targets only)
src/modules/index.ts                        the activation barrel (managed — regenerated)

The module registers through registerModules(app.router, app.services) in setu.config.ts — wired from scaffold time on every functional host, and printed to add (import line and call) on a project scaffolded before the call existed — so the module answers GET /<name>, GET /<name>/:id, POST /<name> and PATCH /<name>/:id with no edit to setu.config.ts. setu generate module writes nothing under src/controllers/ any more, and a route and a module sharing one name is refused before anything is written, because functional modules are scanned in the name-conflict path. A pre-M114 functional module (service and index, no <name>.routes.ts) is neither admitted nor reported by the module scan: its routes stay wired through the src/controllers/index.ts barrel.

Class-based (--template class-based, or any project holding decorator-plugin):

src/modules/<name>/<name>.service.ts        @Injectable, token '<name>-service'
src/modules/<name>/<name>.controller.ts     @Controller('/<name>'), class-level @Inject, @Params(…, Ctx())
src/modules/<name>/<name>.module.ts         @Module({ controllers, providers }) — application-owned; never rewritten
src/modules/<name>/<name>.schemas.ts        one Zod value and a `z.infer` type per shape (with `validation-plugin`)
src/modules/<name>/<name>.test.ts           in-process contract test (socket targets only)
src/modules/<name>/<name>.e2e.test.ts       real-HTTP contract test (socket targets only)
src/modules/index.ts                        the aggregate barrel  (managed — regenerated)

The aggregate barrel exports MODULE_CONTROLLERS and MODULE_SERVICES, and the class-based template scaffolds a setu.config.ts that already imports both and passes them to DecoratorPlugin:

import { MODULE_CONTROLLERS, MODULE_SERVICES } from './src/modules/index.ts';

DecoratorPlugin({ controllers: [...MODULE_CONTROLLERS], services: [...MODULE_SERVICES] });

So generating a module changes only files the CLI owns. Regenerating over an existing module still refuses on that module’s own files; the barrel is rewritten either way and lists each module once.

A project scaffolded before this schematic existed has no barrel import: in class mode, add the two lines above to setu.config.ts once; in functional mode, setu g module prints the registerModules import and call to add. Every later setu g module is then wired automatically. --template full-stack is deliberately not a host: its layering is routes → features → services and it has no src/modules/ concept.

--module <name> places a service, route (functional only), controller or schema inside an admitted module: in functional mode the file lands in src/modules/<name>/ and the module’s CLI-owned index.ts is regenerated to register it, with a module-scoped route or controller mounting at /<module>/<name>; in class-based mode the file is written and the @Module({ providers | controllers }) line to add is printed — the declaration is never edited — and a module-scoped service registers under the token <module>.<name>-service (for example billing.invoice-service).

A host template declares @std/testing and @std/expect so the emitted test runs with no further setup — as a deno.json import on Deno and Cloudflare Workers. Node and Bun declare neither: their emitted test uses the built-in node:test and bun:test, because @std/testing/bdd calls Deno.test. A directory is admitted as a module only in the project’s style — class-based: it holds <name>.controller.ts, <name>.service.ts and <name>.module.ts; functional: it holds <name>.routes.ts and an index.ts exporting register<Name>Module — so unrelated folders under src/modules/ (a shared-helpers directory, say) are left out of the barrel rather than breaking it, and an admitted-shape directory missing its registrar is skipped and reported.

The emitted controller’s handlers take only the arguments their @Params(...) names and return plain values, which the plugin serializes as JSON. That is a constraint of DecoratorPlugin, not a style choice: it builds a handler’s argument list from that declaration alone and never passes the request context positionally, so an undeclared ctx: IRequestContext parameter arrives undefined and the first ctx.response throws — a 500 on every request. A handler that needs the context (to set a status code, or to stream) declares Ctx() among its sources, which is how the generated create answers 201.

The service’s @Injectable token is explicit ('<name>-service') and the controller’s @Inject names that exact string, because emitDecoratorMetadata is unavailable under Deno, so a parameter’s type cannot be read. The module works with and without DiPlugin: with a container the service is constructed through it, without one it resolves from the kernel’s service registry.

Project templates

setu new always emits a setu.config.ts exporting createApp() — one place the project’s plugin list lives. main.ts imports it to start the server, and setu imports it to discover plugin commands, so the two can never disagree. The factory deliberately does NOT start the application.

// setu.config.ts (--template rest)
import { createApplication, type IKernelApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { ConfigPlugin } from '@setu-ts/config-plugin';
// … logging, validation, security, health, metrics, OpenAPI, decorators
import { errorHandler } from '@setu-ts/exceptions';

export function createApp(): IKernelApplication {
  const app = createApplication({
    plugins: [RuntimePlugin(), ConfigPlugin({ envFilePath: '.env', envFileOptional: true })],
  });
  app.middleware.add(errorHandler({ format: 'rfc9457' }));
  app.router.get('/', (ctx) => ctx.response.json({ message: 'Hello, World!' }));
  return app;
}
TemplatePlugin set
(none)RuntimePlugin only.
restRuntime, Config, Logger, Validation, HttpSecurity, Health, Metrics, OpenApi + RFC 9457 errorHandler().
microservicerest plus Messaging, Queue, Resilience, Telemetry, ServiceDiscovery ('static' arm), Cqrs, Events.
class-basedrest plus DecoratorPlugin and DiPlugin, an @Injectable service, and a @Controller.
full-stackA React Router 8 SSR app: the full plugin set via createFullStackAppFromConfig, plus an app/ skeleton.

Three of the four templates emit inline wiring, not imports of the @setu-ts/*-starter packages, so a scaffolded project owns an explicit, editable plugin list. full-stack is the exception, with cause: its composition is twenty-two plugins, and a generated file a human is meant to open and edit should not begin with twenty-two imports they did not choose. A general --starter flag for the other three is still deferred (see “Not in this release”).

--template full-stack

// setu.config.ts (--template full-stack)
import { createFullStackAppFromConfig } from '@setu-ts/full-stack-starter';
import type { IKernelApplication } from '@setu-ts/kernel';
import { getCsrfToken, getSession } from '@setu-ts/session-plugin';
import { csrfContext, sessionContext } from './app/lib/context-keys.ts';

export async function createApp(): Promise<IKernelApplication> {
  return await createFullStackAppFromConfig((config) => ({
    reactRouter: {
      serverBuildPath: './build/server/index.js',
      assetsDir: './build/client/assets',
      populateLoadContext: (ctx, context) => {
        context.set(sessionContext, getSession(ctx));
        context.set(csrfContext, getCsrfToken(ctx));
      },
    },
    session: { secret: config.getOrThrow<string>('SESSION_SECRET'), csrf: {} },
  }));
}

The emitted app/ tree is the deliverable: routes → features → services → models, flatRoutes _app/_auth layout groups each wrapped in their own layout, the ~/* alias, the .server.ts convention, and one worked feature. What it deliberately does not contain is a lib/session.server.ts, lib/csrf.server.ts, lib/sse.server.ts, lib/kv.server.ts or lib/service-logger.server.ts — those are the session, SSE, secrets and logger capabilities, reached through the registry the SSR plugin attaches to every request.

Session reaches loaders through a key the application declares, never a plugin-to-plugin import: getSession takes an IRequestContext, which a loader never sees, while populateLoadContext receives exactly that. RouterContextKey is exported from the SSR plugin so app code can declare keys without importing react-router on the server.

Notes:

  • The generated factory is async. setu awaits it during command discovery, and main.ts awaits it too, so nothing else changes.
  • No hello-world route. An exact / handler takes precedence over the SSR catch-all under the M70g specificity rule (a wildcard ranks below any route naming its path, in either registration order), so it would shadow the application’s own index route.
  • Every runtime target is supported. Cloudflare Workers omits assetsDir and envFilePath: with no filesystem the asset handler would answer 404 for every asset, and a dotenv path would make ConfigPlugin throw. Static assets come from the platform binding and configuration from the request env binding.
  • The frontend build runs on npm even when the server runs on Deno — the one documented exception to the Deno-only toolchain. Deno and Workers targets get a standalone package.json for Vite and React Router; Node and Bun targets get those dev dependencies merged into the package.json they already have. The Deno start task additionally carries --allow-read, which the SSR plugin needs to import its own server build and read client assets.
  • React Router is pinned to v8, matching the react-router the SSR plugin imports.

A class-based project (the class-based alias, or rest/microservice with --style class-based) additionally emits src/services/greeting.service.ts and src/controllers/greeting.controller.ts, registered through the seam barrels that setu.config.ts passes to DecoratorPlugin; the controller declares its dependency in the class-position @Inject('greeting-service') list. It supports every runtime target. On Cloudflare Workers, the microservice template swaps its socket-bound messaging and queue plugins for the platform implementation.

Plugin-contributed commands

A plugin publishes commands with ctx.cli.register(name, handler); the CLI discovers them by loading setu.config.ts and starting the application with no port, so registration happens without binding a socket. The application is always stopped afterwards, including when a handler throws.

setu commands          # list what this application's plugins provide
setu db:migrate up 3   # positionals after the name reach the handler

Handlers receive positionals only. setu consumes its own flags, so pass a plugin command’s flags after --:

setu db:migrate -- --verbose --dry

Built-in verbs (new, n, generate, g, commands, help) are matched first and always win, so a plugin cannot shadow them — and those paths never import your project. Only an unmatched first positional triggers a boot.

Two plugins registering the same command name is an error (1) that runs neither: which registration wins would otherwise depend on plugin load order.

Generated plugin example

setu g plugin my-plugin

Generates src/plugins/my-plugin.plugin.ts plus the managed src/plugins/index.ts barrel that registers it. The .plugin.ts suffix is what lets the barrel be regenerated from a directory scan without admitting a module you hand-wrote in the same folder:

import { createCapabilityToken } from '@setu-ts/common';
import type { IPlugin, IPluginContext } from '@setu-ts/common';

/** Capability token this plugin provides. */
export const MY_PLUGIN = createCapabilityToken('my-plugin');

/** The service registered under {@linkcode MY_PLUGIN}. */
export interface IMyPluginService {
  /** Replace with the capability this plugin publishes. */
  describe(): string;
}

/**
 * Registers the my-plugin capability.
 *
 * @returns The plugin to pass to `createApplication({ plugins: [...] })`
 */
export function MyPluginPlugin(): IPlugin {
  return {
    name: 'my-plugin',
    version: '0.1.0',
    provides: [MY_PLUGIN],
    register(ctx: IPluginContext): void {
      const service: IMyPluginService = {
        describe: () => 'my-plugin',
      };
      ctx.services.register(MY_PLUGIN, service);
    },
  };
}

Custom schematics

setu generate custom <schematic> <name> resolves .setu-ts/schematics/<schematic>.ts and loads it with a real dynamic import(). The module must export a schematic function (or a default export that is a function):

// .setu-ts/schematics/readme.ts
import type { DerivedNames, GeneratedFile, SchematicOptions } from '@setu-ts/cli';

export function schematic(
  names: DerivedNames,
  options: SchematicOptions,
): readonly GeneratedFile[] {
  return [{ path: `docs/${names.kebab}.md`, contents: `# ${names.pascal}\n` }];
}

DerivedNames carries raw, kebab, camel, pascal, and screaming. SchematicOptions carries the target runtime, the detected plugins set, now() — an injected clock, so timestamped output stays deterministic — and modules?, the domain modules already present under src/modules/. With --module, it also carries targetModule? — the admitted feature module receiving a service, controller, route or schema — and moduleArtifacts?, the registrars already present inside that module (the per-module counterpart of artifacts?), which the route and controller schematics read to regenerate the module’s index.ts. Schematics perform no I/O; the command layer gathers the project state they need and writes what they return, which is what makes --dry-run exact.

modules is optional so that a harness written before it existed still compiles; setu generate always supplies it. targetModule and moduleArtifacts are optional for the same published-interface reason, and are supplied only when --module names an admitted module. GeneratedFile.bytes, when present, supplies exact binary contents instead of encoding contents. CliDependencies.fetch optionally injects the example downloader; it defaults to globalThis.fetch.

GeneratedFile carries an optional managed flag — see “Overwrite protection”.

Programmatic API

ExportKindPurpose
runClifunctionRuns the CLI and RETURNS an exit code; never calls Deno.exit.
CliDependenciestypeThe boundary bundle runCli requires, including optional prompting, interruption and the verified logical working directory.
deriveNamesfunctionProduces the five naming forms every schematic uses.
DerivedNamestypeThe result of deriveNames.
GeneratedFiletype{ path, contents, managed?, bytes? } — one file a schematic asks to create.
Schematictype(names, options) => readonly GeneratedFile[].
SchematicOptionstypeThe second parameter of every schematic (runtime/plugins/now/modules?, and with --module the optional targetModule? and moduleArtifacts?).
PROGRAM_NAMEconst'setu' — interpolated into every usage string.
TemplateNametypeThe --template value union, for callers building argv programmatically.
ModuleLoadertypeThe seam a custom schematic module is loaded through.
AppLoadertypeThe seam setu.config.ts is loaded through (CliDependencies.loadApp).
PromptChoiceinterfaceOne value and label offered by a Prompter.
PrompterinterfaceProgrammatic interactive-question seam.
PromptSelectiontypeAnswer, unavailable input, or user cancellation.
detectPluginsfunctionReads a project manifest and returns the installed @setu-ts names.

CliDependencies has no default: src/main.ts owns the process boundary (Deno.args, Deno.cwd(), console, the real filesystem, and the single Deno.exit), so every other path is testable without terminating the runner. Its optional logicalCwd member carries the shell’s PWD only when it is absolute and resolves to the physical working directory; setu agents uses it solely to refuse a write through a symlinked workspace member, never to authorize one.

Not in this release

  • Starter-backed scaffolding. --template emits inline wiring. A setu new --starter path that scaffolds a project importing createRestApp and friends is deferred — the starters themselves shipped in Milestone 36 and can be depended on directly.
  • Flags for plugin commands. CliCommandHandler receives positionals only; giving handlers a parsed flag record would widen a committed common contract. Forward flags with -- instead.
  • Plugin installation. setu generates and dispatches; it does not edit your manifest.

REST API Application

Starter exports and option arms

The three starters share one option chain: FullStackStarterOptions extends MicroserviceStarterOptions extends RestStarterOptions, so an arm added to the REST tier is available on all three.

ExportKindPackage
createRestAppfunctionrest-starter
buildRestPluginsfunctionrest-starter
RestStarterOptionstyperest-starter
RealtimeArmtypeall three (re-exported along the tier’s pin chain)
createMicroserviceAppfunctionmicroservice-starter
buildMicroservicePluginsfunctionmicroservice-starter
MicroserviceStarterOptionstypemicroservice-starter
createFullStackAppfunctionfull-stack-starter
buildFullStackPluginsfunctionfull-stack-starter
createFullStackAppFromConfigfunctionfull-stack-starter
fullStackConfigOffunctionReturns the exact config snapshot used by the config-driven factory.
FullStackConfigUnavailableErrorclassThrown when the accessor receives an app built by another factory.
FullStackStarterOptionstypefull-stack-starter

Each arm is one plugin’s option object, threaded through unchanged. Gated arms are absent unless supplied, so a starter called with no options registers exactly its always-on set:

ArmGatingEffect
database, authgatedAdds DatabasePlugin / AuthPlugin.
sessiongatedAdds SessionPlugin; with csrf, its form-CSRF middleware at priority 325.
digatedAdds DiPlugin. Changes how every decorated service is constructed — see the note below.
realtime.websocketgatedAdds WebSocketPlugin.
realtime.ssegatedAdds SsePlugin.
realtime.backplanegatedAdds RealtimeBackplanePlugin at PLUGIN_PRIORITY.HIGH, so it precedes both consumers.
everything elseonRegistered with defaults when the arm is omitted.

Notes:

  • realtime: {} adds nothing and is not an error. backplane: {} selects the in-process 'memory' transport, whose discriminant is optional.
  • backplane: { transport: 'messaging' } needs a broker. The microservice and full-stack tiers always register MessagingPlugin; the REST tier does not, so on that tier the backplane’s own register() throws naming MessagingPlugin. The starter adds no second check of its own.
  • di is opt-in because it is not additive. DecoratorPlugin branches on the presence of a container, so with this arm each @Injectable becomes a container provider honoring its scope; without it those classes are constructed directly and registered in the ServiceRegistry.
  • Workers portability varies by arm. di, realtime.websocket, realtime.sse, and a 'memory' backplane are Workers-portable. A 'redis' backplane is not (raw socket); a 'messaging' backplane is portable only if its broker is.
  • The full-stack tier leaves SchedulerPlugin out on Cloudflare Workers. The plugin refuses that platform at register(), so registering it unconditionally made every full-stack Workers application fail to start. The starter reads the platform with detectRuntime(), as its own RuntimePlugin() does, and omits the scheduler there unless a scheduler arm is passed; schedule on Workers with cloudflare-plugin’s WorkersCron.
  • session is gated because it cannot be defaulted. SessionPlugin throws during register() without an adequate secret, so an always-on arm would stop every starter application from booting until one was supplied. It is also genuinely optional: a token-authenticated API has no cookie.
  • No arm sets a plugin’s name. Each registers on the bare capability token; register a second instance yourself on the returned app.

Composing from configuration

import { createFullStackAppFromConfig } from '@setu-ts/full-stack-starter';

const app = await createFullStackAppFromConfig(
  (config) => ({
    // A Prisma v7 client is generated and constructed by the application; the
    // adapter never builds one, so `url` is not a database option.
    database: { type: 'prisma', options: { prismaClient } },
    session: { secret: config.getOrThrow<string>('SESSION_SECRET'), csrf: {} },
  }),
  { config: { envFilePath: ['.env.local', '.env'] } },
);

await app.start({ port: 3000 });

Plugin options must be decided before the plugins are constructed, which is before ConfigPlugin has registered anything. This factory closes that ordering gap for every option at once: it builds runtime services, loads configuration, calls the resolver, and passes the SAME snapshot into the application under config.instance — so app.services.get(CAPABILITIES.CONFIG) returns the exact object the resolver saw, rather than a second one read a moment later.

Signature: (build: (config: IConfig) => FullStackStarterOptions, options?: FromConfigOptions) => Promise<IKernelApplication>, where FromConfigOptions = { config?: ConfigPluginOptions; env?: Readonly<Record<string, unknown>> }. The resolver is called exactly once; if it throws, or configuration fails to load, the returned promise rejects and no partially-composed application exists.

fullStackConfigOf(app): IConfig returns that exact snapshot for post-factory code. It throws the exported FullStackConfigUnavailableError when the application was built by another factory.

env is required on Cloudflare Workers. Bindings arrive as the env argument of the fetch handler, never process-wide, so runtime services built before a request report an EMPTY environment there — the composition would see no configuration at all, and a resolver calling getOrThrow would fail on the first request and every request after it, because the boot promise is memoised. Pass the handler’s env straight through; non-string values (KV, D1, R2 bindings) are ignored. Omit it on Node, Deno, and Bun, where the detected runtime reads the environment itself. setu new --template full-stack wires this for you on all four targets.

This is why no plugin option carries a config-key shorthand (urlFromConfig, endpointFromConfig): such a field would need its value at the same impossible moment. secretFromConfig is further out of reach — secrets are served by secrets-plugin under CAPABILITIES.SECRETS, which exists only after registration, so a plugin needing one resolves it lazily at use time.

A complete REST API using the REST starter:

import { createRestApp } from '@setu-ts/rest-starter';
import { validatedStateKey } from '@setu-ts/common';
import { z } from 'zod';

const app = createRestApp({
  config: {
    validationSchema: z.object({
      PORT: z.coerce.number().default(3000),
      DATABASE_URL: z.string().url(),
      JWT_SECRET: z.string().min(32),
    }),
  },
  database: {
    // The v7 client is generated and constructed by the application and
    // carries its own connection configuration.
    type: 'prisma',
    options: { prismaClient },
  },
  auth: {
    jwt: {
      // Illustrative: in production, resolve via IConfig or similar
      secret: process.env.JWT_SECRET,
      expiresIn: '1h',
    },
  },
  openapi: {
    title: 'My API',
    version: '1.0.0',
  },
});

// Define schemas
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string().email(),
});

const CreateUserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
});

// Routes
app.router.get('/users', {
  schema: {
    response: { 200: z.array(UserSchema) },
    tags: ['Users'],
    summary: 'List all users',
  },
  handler: async (ctx) => {
    const db = ctx.services.get('database');
    const users = await db.getRepository('User').findAll();
    return ctx.response.json(users);
  },
});

app.router.post('/users', {
  // `schema.body` DOCUMENTS the route; `validateBody` is what enforces it and
  // writes validatedStateKey('body'). Declaring the schema alone leaves that key unset.
  middleware: [app.services.auth.requireAuth(), validateBody(CreateUserSchema)],
  schema: {
    body: CreateUserSchema,
    response: { 201: UserSchema, 400: z.object({ error: z.string() }) },
    tags: ['Users'],
    summary: 'Create a user',
    security: [{ bearerAuth: [] }],
  },
  handler: async (ctx) => {
    const db = ctx.services.get('database');
    const user = await db.getRepository('User').create(ctx.state.get(validatedStateKey('body')));
    return ctx.response.status(201).json(user);
  },
});

app.router.get('/users/:id', {
  schema: {
    params: z.object({ id: z.string().uuid() }),
    response: { 200: UserSchema, 404: z.object({ error: z.string() }) },
    tags: ['Users'],
    summary: 'Get a user by ID',
  },
  handler: async (ctx) => {
    const db = ctx.services.get('database');
    const user = await db.getRepository('User').findById(ctx.params.id);
    if (!user) return ctx.response.status(404).json({ error: 'Not found' });
    return ctx.response.json(user);
  },
});

await app.start({ port: 3000 });
console.log('API running at http://localhost:3000');
console.log('Docs at http://localhost:3000/docs');

Microservice Application

A microservice with messaging, queue, and telemetry:

import { createMicroserviceApp } from '@setu-ts/microservice-starter';

const app = createMicroserviceApp({
  database: {
    // The v7 client is generated and constructed by the application and
    // carries its own connection configuration.
    type: 'prisma',
    options: { prismaClient },
  },
  messaging: {
    broker: 'rabbitmq',
    options: {
      // Illustrative: in production, resolve via IConfig or similar
      url: process.env.RABBITMQ_URL,
    },
    exchange: 'orders',
  },
  queue: {
    adapter: 'redis',
    options: {
      // Illustrative: in production, resolve via IConfig or similar
      url: process.env.REDIS_URL,
    },
  },
  telemetry: {
    serviceName: 'order-service',
    exporter: 'otlp',
    endpoint:
      // Illustrative: in production, resolve via IConfig or similar
      process.env.OTLP_ENDPOINT,
  },
});

// Subscribe to events from other services
app.register({
  name: 'event-subscribers',
  version: '1.0.0',
  dependencies: ['messaging'],
  register(ctx) {
    const broker = ctx.services.get('messaging');

    broker.subscribe('user.created', async (message) => {
      console.log('New user created', message.userId);
      // Create a welcome order or similar
    });
  },
});

// Process background jobs
app.register({
  name: 'job-processors',
  version: '1.0.0',
  dependencies: ['queue'],
  register(ctx) {
    const queue = ctx.services.get('queue');

    queue.process(
      'process-payment',
      async (job) => {
        await processPayment(job.data);
      },
      { concurrency: 5 },
    );
  },
});

// API endpoints
app.router.post('/orders', async (ctx) => {
  const db = ctx.services.get('database');
  const broker = ctx.services.get('messaging');
  const queue = ctx.services.get('queue');

  const order = await db.getRepository('Order').create(ctx.request.body);

  // Publish event
  await broker.publish('order.created', { orderId: order.id });

  // Queue background job
  await queue.add('process-payment', { orderId: order.id });

  return ctx.response.status(201).json(order);
});

await app.start({ port: 3001 });

CQRS Application

A CQRS application with event sourcing:

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { LoggerPlugin } from '@setu-ts/logger-plugin';
import { ConfigPlugin } from '@setu-ts/config-plugin';
import { DatabasePlugin } from '@setu-ts/database-plugin';
import { EventsPlugin } from '@setu-ts/events-plugin';
import { CqrsPlugin } from '@setu-ts/cqrs-plugin';
import { OpenApiPlugin } from '@setu-ts/openapi-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    LoggerPlugin({ level: 'info' }),
    ConfigPlugin({ validationSchema: AppConfigSchema }),
    // A Prisma v7 client is application-generated, so the adapter is handed one
    // rather than reading a connection URL of its own.
    DatabasePlugin({ type: 'prisma', options: { prismaClient } }),
    EventsPlugin(),
    CqrsPlugin(), // add cross-cutting behaviors via `behaviors: [myBehavior]` (typed IPipelineBehavior[])
    OpenApiPlugin({ title: 'CQRS API', version: '1.0.0' }),
  ],
});

// Register handlers
app.register({
  name: 'command-handlers',
  version: '1.0.0',
  dependencies: ['cqrs', 'database', 'events'],
  register(ctx) {
    const commandBus = ctx.services.get('command-bus');
    const db = ctx.services.get('database');
    const eventBus = ctx.services.get('events');

    commandBus.register('CreateUser', new CreateUserHandler(db, eventBus));
    commandBus.register('UpdateUser', new UpdateUserHandler(db, eventBus));
    commandBus.register('DeleteUser', new DeleteUserHandler(db, eventBus));
  },
});

app.register({
  name: 'query-handlers',
  version: '1.0.0',
  dependencies: ['cqrs', 'database'],
  register(ctx) {
    const queryBus = ctx.services.get('query-bus');
    const db = ctx.services.get('database');

    queryBus.register('GetUser', new GetUserHandler(db));
    queryBus.register('ListUsers', new ListUsersHandler(db));
    queryBus.register('SearchUsers', new SearchUsersHandler(db));
  },
});

// Routes use command/query buses
app.router.post('/users', async (ctx) => {
  const commandBus = ctx.services.get('command-bus');
  const userId = await commandBus.execute({
    type: 'CreateUser',
    data: ctx.request.body,
  });
  return ctx.response.status(201).json({ id: userId });
});

app.router.get('/users/:id', async (ctx) => {
  const queryBus = ctx.services.get('query-bus');
  const user = await queryBus.execute({
    type: 'GetUser',
    data: { id: ctx.params.id },
  });
  return ctx.response.json(user);
});

await app.start({ port: 3000 });

Plugin Creation

Basic Plugin

import type { IPlugin, IPluginContext } from '@setu-ts/common';

export interface RateLimitOptions {
  windowMs: number;
  max: number;
  message?: string;
}

export function RateLimitPlugin(options: RateLimitOptions): IPlugin {
  return {
    name: 'rate-limit',
    version: '1.0.0',
    dependencies: ['logger'],
    provides: ['rate-limit'],
    register(ctx: IPluginContext) {
      const logger = ctx.services.get('logger');

      // Register a service
      const rateLimiter = new RateLimiterService(options);
      ctx.services.register('rate-limit', rateLimiter);

      // Register middleware
      ctx.middleware.add(
        (ctx, next) => {
          const ip = ctx.request.ip;
          if (!rateLimiter.check(ip)) {
            return ctx.response.status(429).json({
              error: 'Too Many Requests',
              message: options.message || 'Rate limit exceeded',
            });
          }
          return next();
        },
        { priority: 100 },
      );

      // Register health check
      ctx.health.register('rate-limit', async () => ({
        status: 'up',
        data: { requests: rateLimiter.getRequestCount() },
      }));

      // Register CLI command
      ctx.cli.register('rate-limit:stats', () => {
        console.log('Rate limit stats:', rateLimiter.getStats());
      });

      logger.info('Rate limit plugin registered', { windowMs: options.windowMs, max: options.max });
    },
  };
}

Plugin with Configuration

export function DatabasePlugin(options: DatabasePluginOptions): IPlugin {
  return {
    name: 'database',
    version: '1.0.0',
    dependencies: ['logger', 'config'],
    provides: ['database'],
    register(ctx) {
      const config = ctx.services.get('config');
      const logger = ctx.services.get('logger');

      // Validate environment
      ctx.environment.validate({
        DATABASE_URL: { required: true, type: 'string' },
      });

      // Use config or options
      const url = options.url ?? config.get('DATABASE_URL');
      const db = new DatabaseService({ ...options, url });

      ctx.services.register('database', db);

      // Health check
      ctx.health.register('database', async () => {
        const healthy = await db.isHealthy();
        return { status: healthy ? 'up' : 'down', data: { url } };
      });

      // Metrics
      ctx.metrics.register('db_query_duration_seconds', {
        type: 'histogram',
        help: 'Database query duration',
        labels: ['operation'],
        buckets: [0.01, 0.1, 0.5, 1, 5],
      });

      // Lifecycle
      ctx.lifecycle.onShutdown(async () => {
        logger.info('Closing database connection');
        await db.close();
      });
    },
  };
}

Plugin with Decorators

export function MyPlugin(options: MyPluginOptions): IPlugin {
  return {
    name: 'my-plugin',
    version: '1.0.0',
    dependencies: ['decorator-plugin'],
    register(ctx) {
      // Register custom decorators
      ctx.decorators.register('MyRoute', (metadata, target, propertyKey) => {
        // Register route from decorator metadata
        ctx.router.get(metadata.path, target[propertyKey]);
      });

      // Register a service that works with decorators
      ctx.services.register('my-service', new MyService(options));
    },
  };
}

Custom Middleware

Programmatic Middleware

// Define middleware as a function
function requestLogger(): MiddlewareFunction {
  return async (ctx, next) => {
    const logger = ctx.services.get<ILogger>('logger');
    const start = Date.now();

    logger.info('Request received', {
      method: ctx.request.method,
      path: ctx.request.path,
      requestId: ctx.request.id,
    });

    await next();

    const duration = Date.now() - start;
    logger.info('Response sent', {
      method: ctx.request.method,
      path: ctx.request.path,
      status: ctx.response.status,
      duration,
    });

    if (duration > 5000) {
      logger.warn('Slow request detected', { duration, path: ctx.request.path });
    }
  };
}

// Register globally
app.middleware.add(requestLogger(), { priority: 50 });

// Register for specific route
app.router.get('/users', {
  middleware: [requestLogger()],
  handler: async (ctx) => {
    /* ... */
  },
});

Middleware with Options

function rateLimit(options: { max: number; windowMs: number }): MiddlewareFunction {
  const requests = new Map<string, number[]>();

  return async (ctx, next) => {
    const ip = ctx.request.ip;
    const now = Date.now();
    const windowStart = now - options.windowMs;

    const userRequests = (requests.get(ip) || []).filter((t) => t > windowStart);
    userRequests.push(now);
    requests.set(ip, userRequests);

    if (userRequests.length > options.max) {
      return ctx.response.status(429).json({
        error: 'Too Many Requests',
        retryAfter: Math.ceil(options.windowMs / 1000),
      });
    }

    return next();
  };
}

app.middleware.add(rateLimit({ max: 100, windowMs: 60000 }));

Middleware Class

import { CAPABILITIES } from '@setu-ts/common';

class AuthMiddleware implements IMiddleware {
  constructor(private authService: IAuthService) {}

  async handle(ctx: RequestContext, next: () => Promise<void>): Promise<void> {
    const token = ctx.request.headers.get('authorization')?.replace('Bearer ', '');

    if (!token) {
      return ctx.response.status(401).json({ error: 'Unauthorized' });
    }

    try {
      const user = await this.authService.verifyToken(token);
      ctx.request.user = user;
      return next();
    } catch {
      return ctx.response.status(401).json({ error: 'Invalid token' });
    }
  }
}

// Register
const auth = app.services.get<IAuthService>(CAPABILITIES.AUTH);
app.middleware.add(new AuthMiddleware(auth));

Custom Decorators

Decorators require the DecoratorPlugin to be registered.

Using Built-in Decorators

import {
  ApiOperation,
  ApiTags,
  Body,
  Controller,
  CurrentUser,
  Get,
  Param,
  Params,
  Post,
  UseGuards,
} from '@setu-ts/decorator-plugin';

@Controller('/users')
@ApiTags('Users')
class UserController {
  constructor(private userService: UserService) {}

  @Get('/')
  @ApiOperation({ summary: 'List all users' })
  async list() {
    return this.userService.findAll();
  }

  @Get('/:id')
  @Params(Param('id'))
  async getById(id: string) {
    return this.userService.findById(id);
  }

  @Post('/')
  @UseGuards(requireAuth())
  @Params(Body(), CurrentUser())
  async create(body: CreateUserDto, user: User) {
    return this.userService.create(body, user.id);
  }
}

Defining Custom Decorators

import { Controller, createDecorator, Custom, Get, Params } from '@setu-ts/decorator-plugin';

// Method decorator
export const Cacheable = (ttl: number) => createDecorator('cacheable', { ttl });

// Parameter source, declared inside @Params(...)
export const CurrentTenant = () => Custom<string>('current-tenant');

// Usage
@Controller('/api')
class ApiController {
  @Get('/data')
  @Cacheable(3600)
  @Params(CurrentTenant())
  async getData(tenantId: string) {
    return this.service.getDataForTenant(tenantId);
  }
}

How Decorators Work

Decorators store metadata in a plain object. The DecoratorPlugin reads this metadata and registers routes, services, and middleware with the kernel. No reflection is required — the metadata store is explicit.

// This is what the decorator does internally:
// Stores metadata in a plain object
metadataStore.controllers.set(UserController, {
  path: '/users',
  routes: [
    { method: 'GET', path: '/', handler: 'list' /* ... */ },
    { method: 'POST', path: '/', handler: 'create' /* ... */ },
  ],
});

// DecoratorPlugin reads this and calls:
app.router.get('/users', userController.list.bind(userController));
app.router.post('/users', {
  middleware: [requireAuth()],
  handler: userController.create.bind(userController),
});

Service Discovery (@setu-ts/service-discovery-plugin)

Provider reachability probes are invoked as methods, retaining their receiver. Healthy Kubernetes and Consul providers therefore report reachable and allow /ready to return 200. Stale-cache degraded semantics remain unchanged.

The discovery service caches reachability for 5 s, coalesces concurrent reads, and bounds each probe at 2 s; a hung or rejecting probe reports false. Consul and Kubernetes probe requests carry an AbortSignal aborted after 2 s by runtime timers, cleared on completion. Health reads now issue at most one backend probe request per 5 s window per service instance. Before M113b, the detached provider call threw before I/O and issued none.

Turns a logical service name into a reachable address, balances across the instances behind it, and takes them out of rotation when callers report failures. Registers an IServiceDiscovery under CAPABILITIES.SERVICE_DISCOVERY ('service-discovery'). Zero npm dependencies — the HTTP providers run on web-standard fetch and the DNS provider on the optional IRuntimeServices.dns.

Registration

import { ServiceDiscoveryPlugin } from '@setu-ts/service-discovery-plugin';

app.register(
  ServiceDiscoveryPlugin({
    provider: 'consul',
    address: 'http://127.0.0.1:8500',
    strategy: 'round-robin',
  }),
);

Usage

import { CAPABILITIES, type IServiceDiscovery } from '@setu-ts/common';

const discovery = ctx.services.get<IServiceDiscovery>(CAPABILITIES.SERVICE_DISCOVERY);

const instances = await discovery.resolve('billing');
const instance = await discovery.pick('billing', { strategy: 'weighted-random' });
const url = await discovery.resolveUrl('billing', '/invoices');

discovery.report(instance!, 'failure');

const unsubscribe = await discovery.watch('billing', (list) => {
  console.log(`billing now has ${list.length} instances`);
});

Contract

interface IServiceDiscovery {
  resolve(serviceName: string): Promise<readonly ServiceInstance[]>;
  pick(serviceName: string, options?: PickOptions): Promise<ServiceInstance | null>;
  resolveUrl(serviceName: string, path?: string, options?: PickOptions): Promise<string | null>;
  report(instance: ServiceInstance, outcome: ServiceOutcome): void;
  watch(
    serviceName: string,
    listener: (instances: readonly ServiceInstance[]) => void,
  ): Promise<Unsubscribe>;
}

interface ServiceInstance {
  readonly id: string;
  readonly serviceName: string;
  readonly host: string;
  readonly port: number;
  readonly secure?: boolean;
  readonly weight?: number;
  readonly tags?: readonly string[];
  readonly metadata?: Readonly<Record<string, string>>;
}

interface PickOptions {
  readonly strategy?: LoadBalanceStrategy;
}

type LoadBalanceStrategy = 'round-robin' | 'random' | 'weighted-random';
type ServiceOutcome = 'success' | 'failure';

Registration and deregistration of this instance are deliberately not on the contract — the plugin drives its own lifecycle hooks, so a registerSelf()/deregisterSelf() pair here would be surface no application code path reads.

Providers

ServiceDiscoveryPluginOptions is a union discriminated on provider, so a missing per-arm credential is a compile error rather than a startup throw.

ArmReadswatch()Runtimes
'static'A literal list in the optionsFires once, then neverAll, incl. Workers
'consul'GET /v1/health/service/:serviceBlocking queries (push)All, incl. Workers
'kubernetes'EndpointSlices from the API serverWatch stream (push)All, incl. Workers¹
'dns'SRV or A/AAAA recordsPolled at watchIntervalMsDeno, Node, Bun
'custom'The application’s own providerWhatever it implementsAny

¹ Workers needs an explicit token: it has no file system to read the projected service-account token from.

Options

OptionArmsDefaultBehavior
providerall—Discriminant; always explicit
cacheTtlMsall30_0000 disables caching so every resolve hits the backend
strategyall'round-robin'Overridable per pick() call via PickOptions.strategy
ejectionallsee belowfalse disables outlier ejection entirely
selfRegistrationconsul, custom—Throws SelfRegistrationNotSupportedError on the other arms
watchIntervalMsstatic, dns30_000Absent from the push-based arms rather than silently unread
servicesstatic—Unknown name resolves to [], never a throw
addressconsul—Agent base URL
tokenconsul—Sent as X-Consul-Token; the header is omitted entirely when unset
datacenterconsul—Sent as ?dc=
waitSecondsconsul30Blocking-query wait, clamped to Consul’s documented maximum of 600
namespacekubernetes—Required
apiServerkubernetesin-cluster envAbsent both, register() throws
tokenkubernetesprojected token fileUsed verbatim; otherwise the file is re-read behind a 60 s memo
portNamekubernetes—Unset with one port uses it; unset with several throws
modedns—'srv' honours RFC 2782 priority tiers; 'a' reads address records
domainTemplatedns'{service}.service.consul'{service} is substituted with the requested name
portdns ('a' only)—Mandatory on that arm: DNS address records carry no port
secureconsul, k8s, dnsfalseSets ServiceInstance.secure, which decides the https scheme
httpconsul, kubernetesfetchOverrides createDefaultDiscoveryHttp()
discoverycustom—The application’s own DiscoveryProvider, used as supplied

Outlier ejection

report(instance, outcome) feeds a per-process ejection tracker. Defaults: { failureThreshold: 5, windowMs: 30_000, durationMs: 30_000, maxEjectionPercent: 50 }. A 'success' clears that instance’s window and un-ejects it immediately.

pick() filters ejected instances; resolve() does not — it reports what discovery knows, while pick() reports what is usable. maxEjectionPercent caps the share of a service ejected at once, and when every instance is ejected anyway pick() falls back to the unfiltered list rather than returning null: a correlated failure ejects the whole pool at once, and serving nothing turns a partial outage into a total one.

This is a different mechanism from IResilienceService.wrap’s circuit breaker, not a duplicate of it. wrap breaks a call site; ejection removes a pool member while the call site stays open. They compose by re-pick()ing inside the wrapped call.

Self-registration

Only the Consul arm (and a custom provider implementing registerSelf) can advertise this instance. Registration runs at onBootstrap; deregistration runs at onStopping, the lifecycle hook that fires before the application starts refusing requests, so the change propagates while traffic is still being served. selfRegistration.drainDelayMs (default 0) then holds that window open.

selfRegistration.check is not optional and cannot be disabled — it defaults to { httpPath: '/health', intervalSeconds: 10, deregisterAfterSeconds: 60 }. onBootstrap runs before the socket binds, so the instance is advertised a moment before it can serve; that window is harmless only because Consul marks a newly registered service critical until its first check passes and every read here sends passing=true.

Exports

ExportKindPurpose
ServiceDiscoveryPluginfunctionThe plugin factory
ServiceDiscoveryPluginOptionstypeThe discriminated option union
StaticDiscoveryOptionsinterfaceThe 'static' arm
ConsulDiscoveryOptionsinterfaceThe 'consul' arm
KubernetesDiscoveryOptionsinterfaceThe 'kubernetes' arm
DnsDiscoveryOptionstypeThe 'dns' arm (SrvDnsDiscoveryOptions | ADnsDiscoveryOptions)
CustomDiscoveryOptionsinterfaceThe 'custom' arm
StaticServiceDefinitioninterfaceOne entry of a static service list
EjectionOptionsinterfaceEjection tuning
SelfRegistrationinterfaceWhat this instance advertises
SelfRegistrationCheckinterfaceThe mandatory health check
DiscoveryProviderinterfaceThe provider port, for the 'custom' arm
StaticProviderclassThe 'static' provider
ConsulProviderclassThe 'consul' provider
KubernetesProviderclassThe 'kubernetes' provider
DnsProviderclassThe 'dns' provider
IDiscoveryHttpinterfaceThe injectable HTTP seam (buffered + streaming)
DiscoveryHttpResponseinterfaceBuffered response shape
DiscoveryHttpStreaminterfaceStreaming response shape
createDefaultDiscoveryHttpfunctionThe default seam over fetch
DiscoveryUnavailableErrorclassCold backend failure (branded 503), missing DNS resolver, k8s multi-port
SelfRegistrationNotSupportedErrorclassselfRegistration on an arm that cannot register

Notes

  • Registers a service-discovery health indicator reporting provider, cachedServices, watchedServices, ejectedInstances, and degraded. It reads the cache’s own observed state and never issues a backend call, so a health scrape does not become load against Consul. 'degraded' means a refresh failed and a stale snapshot is being served; 'down' is unreachable by construction, because with nothing cached the caller already received a DiscoveryUnavailableError.
  • onClose unsubscribes every active watch and clears ejection state.
  • The cache is read-through on the monotonic clock, with per-service in-flight coalescing (a burst of concurrent pick()s for one cold service issues one backend read) and stale-on-failure. A watch event invalidates that name’s entry immediately.
  • pick and resolveUrl funnel through one implementation, so both honour the same configured strategy and the same ejection filter.
  • Ejection state is per-process. A cluster-wide view is a distributed-consensus problem and is not attempted.
  • In-cluster Kubernetes needs DENO_CERT / NODE_EXTRA_CA_CERTS pointed at /var/run/secrets/kubernetes.io/serviceaccount/ca.crt; no code change fixes the cluster CA from inside the process. The http option is the escape hatch for a caller-supplied TLS-configured client.

Health status

Since M70c the indicator composes three facts. everResolved is false until the first successful provider read, so a backend that was never reached is down, not up (the X10-3 fix). degraded means a stale cache is being served. isHealthy() is the cached reachability probe (Consul /v1/status/leader, Kubernetes a limit=1 EndpointSlice LIST).

StatusMeaning
upThe provider is reachable, or has resolved at least one service before. A name never cached still fails while the backend is down, so alert on data.degraded and data.reachable.
downThe provider has never resolved and is unreachable.

data reports { provider, cachedServices, watchedServices, ejectedInstances, degraded, reachable, everResolved }.

Since M113o, serving a stale cache or losing backend reachability after resolution keeps up. data.degraded and data.reachable preserve visibility without draining serving replicas.

Programmatic vs Decorator API

The framework provides both APIs for every feature. They are equivalent.

Routing

Programmatic:

app.router.post('/users', {
  middleware: [requireAuth(), validateBody(CreateUserSchema)],
  schema: { body: CreateUserSchema, response: { 201: UserSchema } },
  handler: async (ctx) => {
    const userService = ctx.services.get('userService');
    const user = await userService.create(ctx.state.get(validatedStateKey('body')));
    return ctx.response.status(201).json(user);
  },
});

Decorator:

@Controller('/users')
class UserController {
  @Post('/')
  @UseGuards(requireAuth())
  @ValidateBody(CreateUserSchema)
  @ApiResponse(201, UserSchema)
  @Params(Body())
  async create(body: CreateUserDto) {
    return this.userService.create(body);
  }
}

Service Registration

Programmatic:

app.register({
  name: 'services',
  version: '1.0.0',
  register(ctx) {
    ctx.services.register('userService', new UserService());
    ctx.services.register('orderService', new OrderService());
  },
});

Decorator (requires DiPlugin):

@Injectable()
class UserService {
  /* ... */
}

@Injectable()
class OrderService {
  /* ... */
}

// DiPlugin auto-discovers @Injectable classes

Event Handling

Programmatic:

app.register({
  name: 'event-handlers',
  version: '1.0.0',
  dependencies: ['events'],
  register(ctx) {
    const eventBus = ctx.services.get('events');
    eventBus.subscribe('UserCreated', handleUserCreated);
  },
});

Decorator:

@EventHandler('UserCreated')
class UserCreatedHandler {
  async handle(event: UserCreatedEvent) {
    /* ... */
  }
}

Developer Ergonomics

Type Safety

Everything is fully typed. No any in public APIs.

// Config is typed
const port = config.get<number>('PORT'); // number
const url = config.getOrThrow<string>('DATABASE_URL'); // string

// Services are typed
const db = ctx.services.get<IDatabaseService>('database');
const user = await db.getRepository<User>('User').findById('123'); // User | null

// Routes are typed
app.router.get('/users/:id', {
  schema: {
    params: z.object({ id: z.string().uuid() }),
    response: { 200: UserSchema },
  },
  handler: async (ctx) => {
    const id = ctx.params.id; // string (validated as UUID)
    // ...
  },
});

Error Messages

Standardized error responses across the framework. errorHandler defaults to format: 'default'; configure format: 'rfc9457' for the Problem Details body below.

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "User with id 123 not found",
  "instance": "/users/123"
}

See API Reference: @setu-ts/exceptions for when type is about:blank versus a concrete problem type URI.

IDE Support

  • Full TypeScript intellisense
  • JSDoc on all public APIs
  • Type inference for services, config, and routes
  • Auto-completion for plugin options

Hot Reload

Setu-TS does not include a built-in dev server. Use deno run --watch directly:

deno run --watch main.ts

Debugging

// Enable debug logging
app.register(LoggerPlugin({ level: 'debug', pretty: true }));

// Debug specific plugin
app.register(
  LoggerPlugin({
    level: 'debug',
    filter: (level, msg, metadata) => {
      return metadata?.plugin === 'database';
    },
  }),
);

Testing

import { createTestApp } from '@setu-ts/testing';

const app = await createTestApp({
  plugins: [
    RuntimePlugin(),
    LoggerPlugin({ transport: 'noop' }),
    DatabasePlugin({ type: 'memory' }),
  ],
});

// Inject requests without a server
const response = await app.inject({
  method: 'POST',
  url: '/users',
  body: { name: 'John', email: 'john@example.com' },
});

expect(response.statusCode).toBe(201);
expect(response.json().name).toBe('John');

Mocking Plugins

import { createMockPlugin, createTestApp } from '@setu-ts/testing';

const mockDb = createMockPlugin({
  name: 'database',
  service: {
    getRepository: () => ({
      findAll: async () => [{ id: '1', name: 'John' }],
      findById: async (id) => ({ id, name: 'John' }),
    }),
  },
});

const app = await createTestApp({
  plugins: [RuntimePlugin(), mockDb],
});

Graceful Shutdown

const app = createApplication({
  gracefulShutdown: true,
  shutdownTimeout: 10000,
});

// Framework handles SIGTERM and SIGINT automatically
// Calls onShutdown hooks for all plugins
// Waits for in-flight requests to complete
// Closes database connections, message brokers, etc.

Composition Over Configuration

Start minimal and add plugins as needed:

// Start with just kernel + runtime
const app = createApplication({
  plugins: [RuntimePlugin()],
});

// Add logging
app.register(LoggerPlugin());

// Add config
app.register(ConfigPlugin({ validationSchema: AppConfigSchema }));

// Add database (memory adapter, zero configuration)
app.register(DatabasePlugin());

// Add auth
app.register(AuthPlugin({ jwt: { secret: config.get('JWT_SECRET') } }));

// Add OpenAPI docs
app.register(OpenApiPlugin({ title: 'My API', version: '1.0.0' }));

await app.start();

Replace Any Plugin

// Replace the default logger with a custom one
app.register({
  name: 'logger',
  version: '1.0.0',
  provides: ['logger'],
  register(ctx) {
    ctx.services.register('logger', new MyCustomLogger(), { override: true });
  },
});

// Replace the database with a mock
app.register({
  name: 'database',
  version: '1.0.0',
  provides: ['database'],
  register(ctx) {
    ctx.services.register('database', new MockDatabase(), { override: true });
  },
});

API Reference: @setu-ts/common

Runtime/error contract memberConsumerBehavior
IRuntimeServices.reportError?(record: Readonly<Record<string, unknown>>): voidKernel unhandled errors and errorHandler 5xx, only without a loggerLast-resort JSON line to stderr (Workers platform log channel); serialization and write failures never throw
IErrorResponder.statusFor?(error: unknown): numberRequest loggerPure prediction of the owner’s served status rule; no clamp reporting; custom response-hook status overrides are not reflected

isConnectionFault(error: unknown): boolean walks up to five cause objects, stops at cycles, and recognises the eight node/net connection-fault codes plus measured ioredis 5 outage rejections and, since M117, node-redis 6’s: messages The client is closed, The client is offline, Socket closed unexpectedly, Connection timeout, and the pattern Socket timeout timeout. Expecting data, but didn't receive any in <n>ms. (all name: 'Error'). It never mutates errors. Command errors such as WRONGTYPE remain unclassified.

node-redis (M117, 0.11.0). fromNodeRedis(client: NodeRedisClientLike | NodeRedisClientFactory, options?: NodeRedisAdapterOptions): NodeRedisAdapter wraps a node-redis client (redis v5/v6) in one ioredis-shaped adapter that every Redis-backed plugin’s client option accepts (IRedisClient, IRedisQueueClient, IRedisStreamsClient, IRedisBackplaneClient, IRedisLockClient, IRedisIdempotencyClient, IRateLimitRedisClient). NodeRedisClientLike is structural — isOpen, isReady, connect(), sendCommand(args), close(), destroy(), on/off, subscribe(channel, listener), unsubscribe(channel) — and no package imports redis. Commands go through sendCommand with ioredis’s argument rule (undefined dropped, arrays spread one level, numbers as strings). NodeRedisAdapterOptions.commandTimeoutMs (default 15000; 0 disables; a finite number up to 2147483647, otherwise RangeError) bounds every command and the lazy connect, rejecting with Error('Command timed out'); the node-redis command is not cancelled. onError(error) receives the client’s 'error' events through a listener the adapter always attaches. RESP3 map replies become RESP2 shapes (XREADGROUP/XREAD → [key, value] pairs; other maps → flat arrays). connect() resolves at once for an open client and connects once otherwise; quit() is idempotent, closes with close() and falls back to destroy() past the bound; disconnect() destroys; status is 'wait', 'connecting', 'ready' or 'end'. Pub/sub: subscribe(channel) delivers (channel, message) to on('message', …) listeners. Cluster and sentinel clients are not supported. NodeRedisClientFactory (() => NodeRedisClientLike | Promise<NodeRedisClientLike>) may be passed instead of a client: it is called once, on the first connect (a rejection is retried on the next), and listeners registered with on/off before then are attached when the client exists. A factory that imports redis inside it is how a client built at module load is traced: OpenTelemetry patches @redis/client as Node loads it, after TelemetryPlugin has registered. A quit() before or during the first connect opens no socket, and one issued while a connect is in flight closes the socket that connect opens.

isConnectionFaultCode(code: unknown): boolean returns true only for a string in the module-private set: ECONNREFUSED, ECONNRESET, ETIMEDOUT, EHOSTUNREACH, ENETUNREACH, ENOTFOUND, EAI_AGAIN, EPIPE. Use this predicate instead of the removed, unreleased CONNECTION_FAULT_CODES export; callers cannot mutate the classification vocabulary.

The contract layer every other package builds on. Implemented in Milestone 1; this section is the authoritative export list (AI_GUIDELINES §10.5). All exports carry full JSDoc.

Realtime subscriber dispatch (0.11.0)

dispatchRealtimeFrame(handlers: Iterable<RealtimeFrameHandler>, frame: RealtimeFrame, reporter: IRealtimeHandlerErrorReporter): boolean snapshots subscribers, isolates synchronous throws and returned thenable rejections, and reports each completion. It returns false only for synchronous throws; asynchronous outcomes are unknown at return, preserving the M98l receive count.

IRealtimeHandlerErrorReporter exposes failed(handler, error, frame): void and succeeded(handler): void; implementations must not throw. createRealtimeHandlerErrorReporter accepts RealtimeHandlerErrorReporterOptions (source, call-time logger, optional onHandlerError(error, frame)) and returns that interface. A WeakMap tracks failures per handler: first failure warns, subsequent failures debug with repeats starting at 1, the next success logs subscriber recovered after N failures at info and rearms the warning. Diagnostics use describeError and metadata { source, error, frameKind, frameName }, never frame data. The hook replaces all default logging and receives every failure. Logger and hook failures are swallowed; no clock or timers are involved. Both backplane provider packages consume these exports.

Diagnostics alias character policy

hasForbiddenAliasCharacter(value: string): boolean returns true for any Unicode control (Cc), format (Cf), line separator (Zl, U+2028) or paragraph separator (Zp, U+2029) character, including bidi overrides and zero-width format characters. Each refusal reads “contains a control, format or line-separator character”. Diagnostics sources, kernel labels and the connector wire validation share this pure predicate; each keeps its byte limit and fixed error message. Printable Unicode such as é remains accepted. The browser SDK maintains the same policy locally to preserve its type-only common imports.

Redaction

createRedactionService(policy) compiles a synchronous RedactionPolicy once and returns an IRedactionService. Policies classify dot paths (* matches one segment and ** matches any number) and map classifications to a Redactor. Matching is case-insensitive by default; createRedactionService(policy, { caseSensitive: true }) enables case-sensitive matching. When patterns overlap, the most specific match wins; equally specific patterns retain declaration order. eraseRedactor replaces values with '[Redacted]', while createMaskRedactor({ keep: 4 }) preserves a trailing suffix of strings. redactRecord() never mutates caller metadata and descends only into plain objects and arrays. Since 0.11.0 it returns a snapshot: each plain object and array it walks is read once and copied, unclassified subtrees included, so what a logger serializes is exactly what redaction checked (a getter cannot change it afterwards). Earlier versions returned unclassified subtrees by identity. The logger, telemetry, and audit plugins accept either a policy or an IRedactionService through their redaction option; this is an option-passed pure utility, not a capability token.

RedactionPolicy has three members:

MemberTypePurpose
fieldsReadonly<Record<string, DataClassification | FieldRedaction>>Dot-path patterns and the classification or FieldRedaction each carries
redactorsReadonly<Record<string, Redactor>>Redactor selected by the matched classification
defaultRedactorRedactorFallback when redactors has no entry for that classification

FieldRedaction is { readonly classification: DataClassification; readonly redactor?: Redactor } — the value arm that lets one classification carry two treatments. For every matched field, redactValue/redactRecord select a redactor in this order: the matched field’s own redactor (when it has one), then redactors[classification], then defaultRedactor, then eraseRedactor. RedactionContext.classification always reports the matched classification. A field’s redactor and classification are read as own properties only: an inherited redactor is ignored, and an entry without its own string classification is erased.

DataClassification is one of four framework constants or any application-defined string (which reaches redactors[<string>]):

ConstantValue
DATA_CLASSIFICATIONS.PII'pii'
DATA_CLASSIFICATIONS.PHI'phi'
DATA_CLASSIFICATIONS.PCI'pci'
DATA_CLASSIFICATIONS.SECRET'secret'

Values (runtime exports)

ExportKindPurpose
CAPABILITIESconstStandard capability tokens — the single source of truth. Includes SSE: 'sse' (SSE hub), SSR: 'ssr' (SSR framework), WORKER_POOL: 'worker-pool' (worker thread pool), REALTIME_BACKPLANE: 'realtime-backplane' (cross-replica fan-out), SESSION: 'session' (cookie sessions), AUTH_SESSION: 'auth-session' (signed-in principal), VIEW: 'view' (view rendering), LOCALIZATION: 'localization' (message catalogues and locale resolution, M103), OUTBOX: 'outbox' (transactional outbox, outbox.<name> for a named messaging instance, M107), INBOX: 'inbox' (consumer inbox, inbox.<name> for a named messaging instance, M108)
createCapabilityToken(name)functionValidates and creates a custom (optionally dot-namespaced) token; throws TypeError on invalid names
isConnectionFault(error)functionRecognises connection outages through a bounded, cycle-safe cause walk, including measured ioredis rejection shapes.
fromNodeRedis(client, options?)functionM117: wraps a node-redis client in the ioredis-shaped adapter every Redis-backed plugin’s client option accepts
NodeRedisClientFactorytypeM117: creates the node-redis client on the adapter’s first connect, so redis can load after TelemetryPlugin registers its instrumentation
NodeRedisClientLiketypeM117: the structural node-redis client fromNodeRedis takes
NodeRedisAdapterOptionstypeM117: commandTimeoutMs, onError
NodeRedisAdaptertypeM117: the adapter fromNodeRedis returns
isConnectionFaultCode(code)functionReturns true only for a string in the private eight-code node/net connection-fault set.
encodeFrameData(data)functionEncodes a WebSocket payload for a realtime backplane; binary becomes base64
decodeFrameData(payload)functionDecodes a backplane payload back to string or Uint8Array
createCachedProbe(options)functionBuilds a cached, coalesced, time-bounded reachability probe from { probe, hrtime, ttlMs?, timeoutMs?, setTimer?, clearTimer? }. hrtime and the timer seam come from IRuntimeServices so a custom runtime’s clock and timers are honoured; the timers fall back to the ambient ones. Every plugin’s isHealthy() is built through it so a /health scrape cannot become load against the backend; a probe that rejects or exceeds timeoutMs resolves false
withDeadline(run, options)functionSince M101a. Runs one backend call under a bound whose expiry is a recorded failure: run receives an AbortSignal to forward; the call is raced against the deadline (an injected seam may ignore the signal), and on expiry the signal is aborted and the promise REJECTS with options.onTimeout(). The call’s own rejection is never swallowed. timeoutMs: 0 arms no timer. The request-path counterpart of createCachedProbe, which never rejects. Prefer a client’s native per-command timeout where one exists
deadlineRangeError(name, timeoutMs)functionSince M101a. Returns the RangeError naming name for a deadline outside 0–2147483647 (including NaN), or null when valid — so an option holder can refuse a bad value at construction with the same message withDeadline uses
DeadlineOptionstypeSince M101a. { timeoutMs, onTimeout, timing? } for withDeadline; timing is the setTimer/clearTimer surface, typically resolveProbeTiming(ctx.runtime)
resolveProbeTiming(runtime)functionResolves a probe’s clock-and-timer surface — { hrtime, setTimer, clearTimer } bound to the injected IRuntimeServices (e.g. ctx.runtime), ready to spread into createCachedProbe’s options. NO ambient performance.now()/Date.now() fallback: all time access outside packages/runtime goes through IRuntimeServices (AI_GUIDELINES §4), so a caller with no runtime to inject has no clock the probe may lawfully read
createConnectionErrorReporter(options)functionBuilds a de-duplicating sink for one connection’s error events from { source, logger }, where logger is a thunk read at CALL time. The first error of a run, or one whose rendering differs from the previous, logs at warn; a repeat of the previous rendering logs at debug with a repeats count; recovered() after at least one error logs once at info and resets, so the next outage warns again. The error is rendered by describeError — name, message, safe classifiers, aggregate members and the cause chain — and never its stack. Neither method throws — a failing logger or an unstringifiable value is swallowed, since the caller is an event-emitter listener. An accessor returning undefined drops the event (no log sink is registered; the owning package’s isHealthy probe still reports the outage)
attachConnectionErrorReporter(client, reporter?)functionAttaches a reporter to a client’s 'error' (→ report) and 'ready' (→ recovered) events, returning false when the value exposes no on method. With no reporter the listeners still attach and drop every event, so a built client never writes to the console. For clients a package BUILT: an injected client belongs to the caller, and adding an 'error' listener to it would silence the caller’s own handling, since ioredis falls back to console.error only when no listener exists
parseCookie(header)functionParses a Cookie header into a name→value record; percent-decodes, strips RFC 6265 quoting, first occurrence wins. Here because the session plugin and the decorator plugin’s Cookie() source both need it and no plugin may import another
serializeCookie(n, v, a?)functionSerializes a Set-Cookie value; percent-encodes so a payload cannot inject attributes, and forces Secure alongside SameSite=None. Throws TypeError on an invalid name or a non-integer maxAge
isWorkerReadySignal(m)functionGuard: narrows a worker message to a WorkerReadySignal
isWorkerTaskRequest(m)functionGuard: narrows a worker message to a WorkerTaskRequest
isWorkerTaskReply(m)functionGuard: narrows a worker message to a WorkerTaskReply
PLUGIN_PRIORITYconstWell-known plugin priority bands (HIGHEST…LOWEST)
createToken<T>(name)functionCreates a typed DI token (M125): a frozen { name } object that IContainer.register, resolve and has accept and match by identity, and from which resolve infers T with no type argument. Pure — it keeps no counter or other state, so two calls with one name are two distinct tokens. A DI token, not a capability token: it is never checked against the capability-token grammar.
ok(value) / err(error)functionResult constructors
isOk(r) / isErr(r)functionResult type guards
unwrap(r)functionReturns the Ok value or throws the Err error
some(value) / none()functionOption constructors (none() returns a frozen singleton)
isSome(o) / isNone(o)functionOption type guards
fromNullable(v)functionConverts T | null | undefined to Option<T>
serializeError(value)functionSerializes any thrown value into a plain SerializedError ({ name, message, stack?, cause?, classifiers?, errors?, omittedErrorCount? }) with the cause chain followed to a bounded depth. classifiers only carries strings, finite numbers, and booleans from code, errno, syscall, severity, constraint, codeName, and statusCode; payload objects such as query data and $metadata are omitted. errors is present only for an AggregateError. Classifier strings cap at 512 Unicode code points, AggregateError.errors caps at eight direct members, and one call produces at most 64 error nodes; a truncated aggregate reports omittedErrorCount. A non-Error value yields { name: 'Error', message: String(value) }. Pure — no runtime-specific APIs. Here so the logger plugin (metadata normalization), the kernel (fallback-500 logging), exceptions, grpc-plugin, and notification-plugin can all serialize without importing one another.
describeError(value)functionRenders any thrown value as ONE bounded diagnostic line for a string log sink: Name: message (code=…, …) [inner; inner] <- cause, built from the same serializeError record, so a non-Error value yields Error: <string> and an AggregateError lists its members. Whitespace and control or format characters collapse to a single space (one value cannot inject a line break), and the line caps at 8192 Unicode code points with … [truncated]. Pure and total — it never throws, whatever getters a value carries. Shared by createConnectionErrorReporter, the idempotency plugin’s claim/release/settle lines, and the messaging plugin’s broker error lines, which re-export it (M119g).
causeMessage(value)functionExtracts a caller-facing message from any thrown value: its message when it is an Error, String(value) otherwise. The one-line counterpart of serializeError — that shapes a record for a log sink, this supplies the sentence that goes inside another error’s message. Pure. Here because resolveRegistryEntry and @setu-ts/scheduler-plugin’s declared-job refusal must produce the SAME sentence for the two arms of one option, and §2.2 forbids one reading the other’s copy.
parseJsonBody(text)functionParses one request-body text as JSON — the ONE parse all three IRequest.json() implementations (@setu-ts/runtime’s served HTTP path, the kernel’s inject(), @setu-ts/testing’s MockRequest) share, so a malformed body answers 400 at every producer rather than a masked 500 on the served path while the test path answered 400 (X37-1). Throws MalformedRequestBodyError, carrying the platform SyntaxError as cause (M90f)
DuplicateKeyErrorclassA write that would duplicate a primary key or a unique index, branded 409 Conflict with the fixed detail “A record with the same unique key already exists. The conflicting write was rejected.” Carries the targeted entity when known and the driver error as cause, never served. Not retryable. In common because @setu-ts/database-plugin and @setu-ts/cloudflare-plugin raise it and any store recording “already processed” with a unique insert must recognise it
assertPrimaryKeyUnchangedfunctionSince M113g. The one shared primary-key check every update/updateWhere of the eight IDataSource implementations (the seven database-plugin adapters and cloudflare-plugin’s D1 data source) runs before any I/O; throws PrimaryKeyChangeError when the payload carries an own key column whose value is not undefined and does not equal the addressed key’s value for that column. An equal value is accepted, compared as a string for string, number, bigint and ObjectId-like values and by time for Date values. In common because cloudflare-plugin raises it too and §2.2 forbids it importing database-plugin (the DuplicateKeyError precedent). Ships in 0.10.0
PrimaryKeyChangeErrorclassSince M113g. An update/updateWhere payload that changes a row’s primary key, branded 400 Bad Request at construction with the fixed detail “An update cannot change a row’s primary key.” Thrown by assertPrimaryKeyUnchanged before any I/O; the message names the entity and the column, never a key value; errorHandler reads its hint. In common for the DuplicateKeyError reason — every database-plugin adapter and cloudflare-plugin’s D1 data source raise it, and an application must be able to instanceof it without importing either. Ships in 0.10.0
InvalidCursorErrorclassSince M113g. A cursor the backend cannot use — a malformed token, a sort-fingerprint mismatch, or a decoded key-value list whose length or kinds do not fit the target — branded 400 Bad Request at construction with the fixed detail “The pagination cursor is not valid for this query.” Thrown (rejected) by the eight findPage implementations before any backend call; the message names the entity and which check failed, never the token or its values; errorHandler reads its hint. In common for the DuplicateKeyError reason. Ships in 0.10.0
OptionalDependencyUnavailableErrorclassThrown when an optional, lazily-loaded dependency could not be imported (M113d). Constructor: (dependency: string, options?: { cause?: unknown; requiredBy?: string }). Carries the bare package name in dependency and the original rejection as cause. Since 0.11.0, optional requiredBy names the loading npm package in the message only (no instance field); omitted names use generic wording. The message directs installation where that package is installed, within the version range that package declares for it, usually the workspace root in npm/Yarn/Bun workspaces, or choosing an option that does not need it. A startup/configuration fault, so no HTTP status hint. In common because every converted loader catch (14 published packages and cli) throws it, and the named-error wrappers in gRPC, SAML and GraphQL carry it as cause; §2.2 forbids a plugin importing another plugin. Ships in 0.10.0
MalformedRequestBodyErrorclassThe malformed request body rejection, branded 400 Bad Request at construction with the fixed detail “The request body could not be parsed as JSON.” The cause is the platform SyntaxError — kept for the log, never served (M90f)
parseFormBody(body, contentType)functionParses one request body as a form — the ONE parse all three IRequest.formData?() implementations (@setu-ts/runtime’s served HTTP path, the kernel’s inject(), @setu-ts/testing’s MockRequest) share, and the function every accessor-less fallback path calls (M94b). Adopts the web FormData semantics — ordered getAll for repeated names, entries() in wire order, file-versus-text discriminated on filename !== undefined (filename="" is still a file) — over a framework-owned value whose bytes stay synchronous. Throws UnsupportedFormEncodingError (415) for a non-form content-type (JSON, a missing content-type, a multipart type with no boundary=); an UNPARSEABLE multipart body yields an EMPTY form, the promoted parser’s released behaviour, documented as the accessor’s limit (M94b)
formEncodingOf(contentType)functionClassifies a content-type as 'urlencoded' or 'multipart' — case-insensitively and parameter-tolerantly — or undefined when the type is not a parseable form, a multipart type with no boundary= included. The ONE classifier the upload middleware’s multipart guard and the CSRF verifier’s form guard both read, replacing two private includes() checks that had drifted on case folding (M94b)
FormBody / FormFile / FormValue / FormEncodingtypesThe form read IRequest.formData?() resolves: a read-only get/getAll/entries view over string | FormFile values, where FormFile carries the parser’s own SYNCHRONOUS Uint8Array — the web File’s async, copying byte access is deliberately not adopted, because it would add a second full copy per file part on the upload path this abstraction exists to make cheaper. Named FormBody, never FormData, so it cannot shadow the global. The write side is not adopted: a request body is a fact about the request, not a collection a handler edits (M94b)
UnsupportedFormEncodingErrorclassThe not-a-form rejection behind parseFormBody and IRequest.formData?(), branded 415 Unsupported Media Type at construction — the MalformedRequestBodyError precedent: answered in the application’s configured format rather than a masked 500, and exported so a handler catching its own formData() rejection can use instanceof (M94b)
resolveResponseStatus(status, target)functionReturns a status the web Response constructor accepts, clamping anything outside [200, 599], any non-integer, and the null-body statuses 204/205/304 (in range, but the Fetch standard forbids the body every path here writes — unclamped, the runtime mapper would drop that body and serve a bodiless 204, discarding the error) to 500 and reporting it through the logger capability when the target carries one. Applied by respondWithError before it delegates, and by createErrorResponder’s responder before it builds the error, so the formatted body’s status member and the written status cannot disagree. Exists because three shipped call sites take a status the APPLICATION authors (FlagGuardOptions.statusCode, the multi-tenancy rejectionStatus, a WebSocketGuardDecision.status), so a typo such as 4004 for 404 otherwise threw RangeError out of the error path itself.
respondWithError(ctx, init)functionWrites an error response through the request’s published IErrorResponder (the application’s configured format), falling back to { error, detail? } when errorHandler has not published one. The seam that lets a package that produces error responses but may not import @setu-ts/exceptions answer in the configured format (M70f).
respondWithAuthorizationFailure(ctx, failure)functionWrites the standard 401, 403, or 501 authorization refusal through respondWithError, so independent authorization entry points retain identical configured formatting. The four AuthorizationFailure arms: authentication-required (401 “Unauthorized”), not-configured (501 “Not Implemented”), insufficient-privileges (403 “Forbidden”, no role or permission named), and second-factor-required (403 “Forbidden”, “Second factor required” — names only the required action, never the factor or the policy, M100d).
authorizationFailureInit(failure)functionSince M110a. The ONE owner of each standard refusal’s { status, title, detail } (a frozen HttpStatusHint): respondWithAuthorizationFailure writes it and AuthPlugin’s AuthorizationDeniedError brands itself with it, so a guard’s refusal and a thrown denial answer the same body under the configured error format
ERROR_RESPONDER_STATE_KEYconstexceptions:error-responder, the ctx.state key under which errorHandler publishes its IErrorResponder
brandErrorResponder(fn, r)functionAttaches errorHandler’s resolved IErrorResponder to its middleware function under ERROR_RESPONDER_BRAND, so the kernel — which runs the drain 503, the malformed-request 400, and the request hooks BEFORE the pipeline — can read the same responder at startup (M70f re-review)
errorResponderOf(fn)functionReads the brand off a middleware function, returning the attached IErrorResponder (or undefined). The kernel’s only route to the resolved formatter for the pre-pipeline sites
ERROR_RESPONDER_BRANDconstA Symbol.for brand pairing with the two functions above; Symbol.for (not Symbol()) so two copies of the package in one process resolve the same key
withHttpStatusHint(error, hint)functionBrands an Error with the status, title and caller-safe detail it should be answered with, and returns the same reference. status must be an integer in 400–599; throws TypeError if the error is not extensible. The channel by which a package that may not import @setu-ts/exceptions (§2.2) states how its own error maps to a response — @setu-ts/database-plugin brands its three query-shape refusals 501 — where respondWithError cannot help because the thrower holds no IRequestContext (M89b)
httpStatusHintOf(error)functionReads the hint back from a thrown value, or undefined when it carries none. Accepts unknown, because a catch binds one. errorHandler calls it before wrapping a non-HttpError into a 500; a foreign or malformed value under the same global symbol reads as absent rather than being trusted — including a status that is not an integer in 400–599
HTTP_STATUS_HINTconstSymbol.for('setu.http.status-hint'), the key the two functions above use. Symbol.for (not Symbol()) so two copies of the package in one process resolve the same key
validatedStateKey(target)functionReturns `validation-plugin:validated-${target}` — the ctx.state key under which validation-plugin’s middleware writes a validated value and decorator-plugin’s Body()/Query()/Param() sources read it back. Exported so two packages agree on the wire format byte-for-byte instead of each hardcoding the literal (the M47 frame-codec precedent)
REQUEST_ORIGIN_STATE_KEYconsthttp-security-plugin:request-origin; the trusted { protocol, host } IP security publishes for the common accessor (M113n)
RequestOrigintype{ readonly protocol: 'http' | 'https'; readonly host: string; readonly origin: string } returned by requestOriginOf (M113n)
requestOriginOf(ctx)functionReturns the published public origin or one derived from ctx.request.url; makes no proxy trust decision (M113n)
publicRequestUrl(ctx)functionSubstitutes the published origin while keeping path and query; returns the original URL byte for byte without publication (M113n)
CLIENT_IP_STATE_KEYconsthttp-security-plugin:client-ip, the cross-package key ipSecurityMiddleware writes and rateLimitMiddleware reads
SESSION_STATE_KEYconstsession-plugin:session, the ctx.state key under which session-plugin’s middleware parks the live ISession and multi-tenancy-plugin’s tenant middleware reads it back for the tenant-binding compare (M101c, V8-7)
CREDENTIAL_SOURCE_STATE_KEYconst auth-plugin:credential-source, the ctx.state key under which auth-plugin’s authentication middleware records the credential of the strategy that authenticated the request (‘header’ or ‘cookie’), unset when nothing authenticated it; session-plugin’s form CSRF reads it and exempts only a request a header-credential strategy authenticated, so a junk bearer header beside a session cookie stays verified (M113e)
SESSION_TENANT_BINDING_KEYconst__setu_tenant, the reserved session-payload key under which the sealed tenant binding is stored and read by both compare sites (M101c, V8-7)
SESSION_TENANT_BINDING_STATE_KEYconstsession-plugin:tenant-binding, set to true by session-plugin’s middleware when tenantBinding is on; multi-tenancy-plugin’s tenant-side compare runs only when it reads true, so tenantBinding: false disables both compare sites in every middleware order (M101c, V8-7)
tenantBindingMismatch(session, tenantId)functionThe pure tenant-binding compare shared by the two compare sites (M101c, V8-7): true only when the session is bound to a DIFFERENT tenant than the one resolved for the request; inert for an unbound session or a tenant-less request
createPathMatcher(patterns)functionBuilds a path-exclusion predicate from readonly PathPattern[]; strings match exactly and regular expressions are tested against the path.
installRequestIdentityGuard(prototype)functionInstalls the identity guard once on a request class’s prototype, so its instances need no per-request seal; called by the runtime’s served request, the kernel’s inject() request and @setu-ts/testing’s mock (0.11.0)
sealRequestIdentity(request)functionInstalls the one-implicit-write request identity guard for user, tenant and locale
replacePrincipal(request, principal)functionDeliberately replaces request.user after it has been guarded
replaceTenant(request, tenant)functionDeliberately replaces request.tenant after it has been guarded
replaceLocale(request, locale)functionDeliberately replaces request.locale after it has been guarded — the localization middleware’s write, and the application’s way to apply a signed-in user’s saved preference once authentication has run (M103)
isPromiseLike(value)functionDuck-typed thenable test (M87) — see the note below the Types table

Types

GroupExports
TokensCapabilityToken, StandardCapability
Shared typesHttpMethod, RuntimePlatform, LogLevel, LifecyclePhase, HealthStatus, MetricType, PluginPriority, JsonValue — the recursive JSON-safe value type (M74); SseMessage.data is typed with it, and its object arm admits undefined because JSON.stringify drops such a key
UtilitiesResult<T, E>, Ok<T>, Err<E>, Option<T>, Some<T>, None, PathPattern — one path-exclusion entry (string | RegExp); a string is an exact match, never a prefix
Plugin contractIPlugin, IPluginContext, IApplication, StartOptions
Plugin context APIsIMiddlewareApi, MiddlewareOptions, IRouterApi, IEnvironmentApi, EnvVarSpec, IHealthApi, IMetricsApi, IOpenApiApi, IDecoratorApi, DecoratorHandler, ICliApi, CliCommandHandler, ILifecycleApi, IMetadataStore
Service registryIServiceRegistry, RegisterOptions, ServiceFactory<T>, RegistryFactory<T>, resolveRegistryEntry
HTTPIRequest, IResponse, IRequestContext, IMiddleware, MiddlewareFunction, NextFunction, RouteHandler, RouteDefinition, RouteSchema, SecurityRequirement, SECURITY_METADATA, RouteSecurityMetadata, withSecurityMetadata, securityMetadataOf, VALIDATION_METADATA, RouteValidationMetadata, withValidationMetadata, validationMetadataOf, RESPONSE_METADATA, RouteResponseMetadata, withResponseMetadata, responseMetadataOf, HandlerResult, ResponseSnapshot, ResponseSnapshotInit, UPGRADE_INTENT, WebSocketUpgradeIntent, setUpgradeIntent, upgradeIntentOf, isWebSocketUpgradeRequest, HTTP_STATUS_HINT, HttpStatusHint, withHttpStatusHint, httpStatusHintOf
RuntimeIRuntimeServices, IFileSystem, IHttpAdapter, IWorkerHost, IWorkerHandle, TimerHandle, ServerHandle, StatResult
DI (optional)IContainer, Constructor<T>, ServiceScope, Provider<T>, ClassProvider<T>, FactoryProvider<T>, ValueProvider<T>, ProviderOptions<T>, InjectionToken<T>, ContainerToken<T> — string | InjectionToken<T> | Constructor<T>, the token any IContainer method accepts — and InjectEntry, one inject element: a ContainerToken or { token, optional: true }. See “DI container contract (M125)” below
LoggingILogger, LogMetadata
ConfigIConfig
ValidationIValidationService, ValidationTarget, ValidationIssue
HealthIHealthIndicator, HealthIndicatorFn, HealthIndicatorOptions, HealthCheckResult, IHealthService, HealthReport, HealthStatus, CachedProbeOptions, ProbeTiming, ConnectionErrorReporter, ConnectionErrorReporterOptions, ConnectionErrorLogger
MetricsIMetric, MetricConfig, IMetricsService, ICounter, IGauge, IHistogram, ISummary, MetricOptions
AuthIPrincipal, IJwtService, JwtSignOptions — IPrincipal is { id: string; roles?: readonly string[]; permissions?: readonly string[]; claims?: Readonly<Record<string, unknown>> }
DatabaseIOrmAdapter, ITransaction, IDatabaseAdapter, IAdapterTransaction, IDataSource, NormalizedQuery, OrderDirection, TransactionOptions, TransactionIsolationLevel, ITransactionIsolationSupport — the data-access port, promoted from database-plugin in M52c so a backend can live in another package (cloudflare-plugin’s D1Adapter is the first); the last three carry portable transaction isolation (M90g)
CacheICacheStore
EventsIEventBus, IDomainEvent<T>, EventHandler<T>, Unsubscribe
MessagingIMessageBroker, PublishOptions, ISubscription, MessageHandler<T>, MessageMetadata, SubscribeOptions, RequestOptions, RequestHandler<TReq, TRes>, ORDERING_KEY_HEADER, DEDUPLICATION_ID_HEADER, MAX_PUBLISH_ID_BYTES, parsePublishOptions, ParsedPublishOptions, publishIdProblem, isValidPublishId, MAX_PUBLISH_HEADERS, MAX_PUBLISH_HEADER_NAME_BYTES, MAX_PUBLISH_HEADER_VALUE_BYTES, RESERVED_HEADER_NAMES, RESERVED_HEADER_PREFIXES, publishHeaderNameProblem, publishHeaderValueProblem
IngressIngressKind, IngressContext<TPayload>, BehaviorLike<TWork, TResult>, IIngressBehavior, composeBehaviorChain
QueueIQueue, IJob<T>, JobProcessor<T>, AddJobOptions, ProcessOptions, RecurringOptions
SchedulerIScheduler, ScheduledJob<T>, SchedulerJobHandler<T>, ScheduleOptions<T>, RetryOptions, SchedulerBackoff
SecretsISecretManager
AuditIAuditLogger, AuditEntry
ResilienceICircuitBreaker, CircuitState, IResilienceService, WrapOptions, CircuitBreakerPolicy, RetryPolicy, BulkheadPolicy, BackoffStrategy, ResilientCall, HardenedCall
StorageIStorage, SignedUrlOptions
MailIMailer, MailMessage
NotificationsINotifier (with optional sendSettled?), NotificationMessage, ChannelSendResult
ErrorsIErrorResponder, ErrorResponseInit, ErrorResponderTarget, SerializedError, AuthorizationFailure — the request-scoped error responder seam, standard authorization refusals, and the pure error serializer (M70f)
Feature flagsIFeatureFlags, FlagContext
Multi-tenancyIMultiTenancyService, ITenantRepository, ITenantResolver, ITenant
SSRISsrService
SSEISseService, ISseConnection, SseChannel, SseMessage
Realtime backplaneIRealtimeBackplane, RealtimeFrame, RealtimeFrameHandler, RealtimeFrameKind, EncodedPayload
WebSocketIWebSocketService, IWebSocketConnection, IWebSocketTransport, WebSocketRoom, RoomBroadcastOptions, WebSocketHandlers, WebSocketRouteOptions, WebSocketConnectionContext, WebSocketCloseEvent, WebSocketReadyState, WebSocketEventSink, WebSocketUpgradeDecision, WebSocketUpgradeRouter, WebSocketUpgradeGuard, WebSocketGuardDecision
Worker poolIWorkerPool, WorkerRunOptions, TaskPoolStats, WorkerReadySignal, WorkerTaskRequest, WorkerTaskReply, WorkerErrorShape
SessionISessionService, ISession, ISessionStore, SessionData, SessionView, CookieAttributes
Auth sessionIAuthSessionService, SignInOptions, SignInOutcome, AuthMethod
Authorization policiesIAuthorizationPolicyService, PolicyDefinition, PolicyAbility, PolicyCheck, AnonymousPolicyCheck, PolicyRef, PolicyAbilityInfo, PolicyTarget — the asynchronous, target-aware check (M110a) served under CAPABILITIES.AUTHORIZATION_POLICIES (can / authorize / describe / define). The bare PolicyDefinition (A = string, T = never, before a METHOD signature) is the type-erased form a registry stores, which is what makes every typed policy assignable to it under strictFunctionTypes
Scoped RBACSCOPED_RBAC_POLICY, scopedPermissionAbility, scopedRoleAbility, isScopeType, MAX_SCOPE_ID_LENGTH, ScopeRef, ScopedGrant, GrantQuery, IGrantSource, IScopedRoleSource, ScopedRoleDefinition, ScopedRbacTarget, ScopeSource, scopeFromTenant, scopeFromParam — the M110b contracts AuthPlugin’s built-in scoped-rbac policy evaluates and DecoratorPlugin/DatabasePlugin name without importing AuthPlugin. A scope type is lowercase kebab-case (isScopeType); an id is non-empty and at most MAX_SCOPE_ID_LENGTH (256) UTF-16 code units, refused rather than truncated. GrantQuery is { kind: 'chain', scopes } (the check’s scope and those it inherits from, plus global grants) or { kind: 'all' } (sign-in timing only). scopeFromParam and scopeFromTenant resolve undefined for an absent, empty or over-long id, which leaves the scope unresolved and the check DENIES (never a global check)
Service discoveryIServiceDiscovery, ServiceInstance, PickOptions, LoadBalanceStrategy, ServiceOutcome
DNSIDnsResolver, SrvRecord
gRPCIGrpcService, GrpcServiceDefinition, GrpcServingStatus, RpcFetchHandler
CloudflaresplitWorkerEnv, SplitWorkerEnv
View renderingIViewEngine, Component — the view port (render(component, props): string | Promise<string>) and the structural component type it renders, named by @Render and renderView (M92)
LocalizationILocalizer, LocalizationMessage, PluralForms, MessageCatalogue — the localization port (t(key, values?), locale, locales, forLocale(tag)) served under CAPABILITIES.LOCALIZATION, and the catalogue shape: a string with {name} placeholders or a CLDR plural record with other required (M103)
OutboxIOutboxStore, IOutboxWriteScope, OutboxRecord, OutboxStatus, OutboxKey, OutboxTransition, OutboxStoreStats, OUTBOX_RECORD_KIND — the transactional-outbox store port (append, scanPending, failedKeys, claim, markInvalid, markSent, markFailure, release, stats, purge, verify; every method rejects, never throws), the row shape (every field a JSON scalar, times as epoch ms; claimVersion starts at zero and is claimable in [0, Number.MAX_SAFE_INTEGER - 1], leaseUntil starts at zero; PostgreSQL bigint, SQLite/D1 64-bit INTEGER, JSON numbers elsewhere), the conditional-transition outcome (applied / missing / not-pending / not-failed / claim-lost), and the 'setu-outbox' discriminator every row carries and every read requires (M107)
InboxIInboxStore, InboxRecord, InboxStatus, InboxIds, InboxFailureUpdate, InboxReleaseOutcome, InboxStoreStats, INBOX_RECORD_KIND — the consumer-inbox store port (find, run, recordFailure, park, parked, release, stats, purge, verify; every method rejects, never throws; run creates the marker first in one transaction), the row shape (every field a JSON scalar, times as epoch ms), the release outcome (applied / missing / not-parked), and the 'setu-inbox' discriminator every row carries and every read requires (M108)
IdempotencyIIdempotencyStore, IdempotencyClaimRequest, IdempotencyClaimResult, IdempotencySettleResult, IIdempotencyService, IdempotentRouteOptions, IdempotencyKeySource, IdempotencyFingerprintSource, IdempotentIngressOptions, IdempotentIngressCommonOptions, IngressIdempotencyKeySource, IngressIdempotencyFingerprintSource — the store port, the service contract served under CAPABILITIES.IDEMPOTENCY, and the route/ingress option types (M109a). The record is an opaque string so every store holds identical bytes; the mechanism lives in @setu-ts/idempotency-plugin. IngressContext.consumer (below) is the dispatch identity the ingress key is scoped by.

DI container contract (M125). The container is optional (no plugin may require it), and its contract changed in 0.11.0:

  • Tokens. Every IContainer method takes a ContainerToken<T>: a string, an InjectionToken<T> from createToken<T>(name), or a class (Constructor<T>). Object tokens are matched by identity. resolve on an InjectionToken<T> or a class returns T without a type argument; resolve<T>(token: string) is unchanged.
  • Visible dependencies. ClassProvider.inject and the new FactoryProvider.inject are readonly InjectEntry[]. FactoryProvider.useFactory is (...deps: never[]) => T and is called with the resolved dependencies in inject order; an absent optional dependency arrives as undefined. A factory without inject is opaque to boot validation.
  • ProviderOptions<T> (generic, default unknown) adds dispose?: (instance: T) => void | Promise<void>, called by dispose() for each instance the container created.
  • forRequest(ctx: IRequestContext): IContainer returns the request’s scope, created on first call and disposed through ctx.defer when the request finishes. Without ctx.defer it throws.
  • dispose(): void | Promise<void> disposes what the container created — on a scope its scoped instances and the transients it built, on the root its singletons — in reverse creation order, each even if an earlier one failed — an asynchronous one bounded at 5000 ms, recorded as a named timeout when it expires — then throws (or rejects with) one AggregateError. It returns a promise only when a dispose function did. Repeat calls are no-ops; a disposed scope refuses further resolves. Instances from the service-registry fallback are never disposed.
  • ServiceScope. scoped means one instance per scope; a request reaches its scope through forRequest. Resolving a scoped token on the root container is refused, and so is a transient there whose graph reaches a scoped provider.
  • Breaking for implementors: forRequest and dispose are required members (0.11.0).

IRequestContext.defer?(cleanup) (M125) — registers per-request cleanup. The kernel runs cleanups in reverse registration order once the request finishes: before a buffered response is handed back (awaiting any that return a promise), and when a streaming body ends, errors or is cancelled; on the failure path, after the 500 is written. Each asynchronous cleanup is bounded at 5000 ms: on expiry the kernel reports it and moves on, and the cleanup’s later outcome is ignored. A cleanup’s throw or rejection is reported at warn through the logger capability when one is registered, and never changes the response; a defer after completion runs at once. The member is optional, so no implementor breaks: the kernel’s context implements it, createTestContext does not. A request that never calls it stays on the synchronous path.

IRequestContext.slot?<T>(key, create) (M125) — owner-keyed per-request storage. It returns the value this request holds under key, compared by object identity, and on the first call for that key stores and returns create(); a throwing create stores nothing, so a later call retries. Entries cannot be enumerated or deleted and live as long as the context, so a key the owner keeps private gives it storage no other code can read or replace through slot. create must not call slot with its own key. The kernel’s context keeps the first entry without allocating; the member is optional, and createTestContext does not implement it. forRequest keeps each request’s scope here.

isPromiseLike(value) (M87) — reports whether a value is thenable, by the duck-typed test (typeof value.then === 'function') rather than instanceof Promise. @setu-ts/kernel and @setu-ts/runtime both use it on the request path to decide whether a handler result needed awaiting. instanceof answers false for a promise from another realm and for userland promise libraries — all of which satisfy RouteHandler’s declared Promise<HandlerResult> structurally — and treating one as already-settled sends the response while the handler is still running. Pair it with Promise.resolve, which returns a native promise unchanged and adopts a foreign thenable.

ResponseSnapshot.responseInit (M88, @internal) — an optional snapshot-local ResponseSnapshotInit header input used only by the kernel-to-runtime response conversion. The runtime consumes it before it reads snapshot.headers, allowing normal terminal responses to avoid materializing the framework’s native Headers object; native servers may derive headers such as Content-Length from that input. Middleware and application code must continue to use the documented live snapshot.headers view; snapshots with explicitly mutated or repeated headers use that existing path unchanged.

Contract notes:

  • IPluginContext.runtime is non-optional: a runtime provider is mandatory and registers first, so every plugin may rely on it (ARCHITECTURE.md §7).

  • ILifecycleApi.onStopping(fn) runs at the very start of stop(), before the application begins refusing new requests with a 503 and before the socket closes — the only hook that fires while the application is still serving normally. It exists for “tell the outside world to stop routing here” work, most obviously deregistering from service discovery; doing that in onShutdown leaves callers routed at a closed port for up to one health-check interval. Hooks run LIFO and are awaited, so a slow hook delays shutdown. A rejecting hook surfaces from stop(), but only after the drain, the socket close, and the onShutdown/onClose phases have run: a failing hook must not be able to prevent the application from stopping. With no hook registered the phase is skipped and stop() behaves exactly as before.

  • Schema positions (RouteSchema, IValidationService, IOpenApiApi) are typed unknown so common carries no validator dependency; the validation plugin narrows them (Zod by default).

  • RouteSchema.operationId?: string and RouteSchema.description?: string (since 0.10.0) declare operation metadata. OpenAPI uses the id verbatim, derives it only when absent, and emits the description. Included declared ids must be nonblank and unique; OperationIdError from @setu-ts/openapi-plugin refuses invalid declarations at bootstrap and document generation.

  • RouteSchema.security is documentation only — it describes an operation for the OpenAPI document and for client generation, and enforces nothing. Authentication is enforced by middleware and guards. An empty array declares the operation public and is not the same as omitting the field, which leaves it inheriting the document-level requirement.

  • Declaring is not the only way a document learns about authentication. withSecurityMetadata brands a MiddlewareFunction with a RouteSecurityMetadata ({ authenticated: boolean }), and securityMetadataOf reads it back — the channel by which @setu-ts/openapi-plugin derives an operation’s requirement from the guards @setu-ts/auth-plugin produced, without either package importing the other. SECURITY_METADATA is created with Symbol.for, so two copies of common in one process resolve the same key. The brand is symbol-keyed and non-enumerable: the middleware’s identity and behaviour are unchanged. It carries authentication PRESENCE only — a role is not a security scheme, so requireRole('admin') brands { authenticated: true } and nothing more.

  • withHttpStatusHint brands an Error rather than a middleware function, and the reason it exists is that respondWithError cannot reach every site: a data source throwing from deep inside an adapter holds no IRequestContext, so its error arrives at errorHandler as a plain Error, is normalized to a 500, and is masked. That is right for a driver fault and wrong for a refusal the caller caused. HttpStatusHint is an ErrorResponseInit whose detail is required, and errorHandler builds the response from the hint’s own status/title/detail — never from the error’s message, which is the operator-facing diagnostic and stays log-only. Both entry points construct the response through one implementation, so a hinted throw and a respondWithError call carrying the same values answer byte-identically under every configured format. A hinted error is exempt from maskInternalErrors, and the exemption is narrow by construction rather than by trust: what it serves is a fixed sentence the brand site wrote, so there is no driver diagnostic in the body for masking to remove. A brand on a deliberately thrown HttpError is ignored — that error already states its own status.

    status must be an integer in 400–599, and a hint outside that range is treated as ABSENT so the error takes the ordinary masked-500 path. Two reasons, and the second is why the floor is 400 rather than 200: the web Response constructor throws RangeError outside [200, 599], so an unserveable status would make the error handler itself the fault — a throw escaping the one catch that exists to contain throws — and a hint says how an ERROR is answered, which is never a success or a redirect. This is deliberately stricter than the ErrorResponseInit it extends, whose status is a plain number that an application authors (FlagGuardOptions.statusCode is the shipped example) and so may legitimately be any serveable status, not only an error one. ErrorResponseInit.status is guarded separately, by resolveResponseStatus, against the serveability bound [200, 599] rather than this error-ness one.

  • withValidationMetadata brands a MiddlewareFunction with a RouteValidationMetadata ({ target: ValidationTarget; schema: unknown }), and validationMetadataOf reads it back — the same mechanism, for the same reason, applied to request shape rather than authentication. Every helper @setu-ts/validation-plugin ships carries it, as does IValidationService.middleware(schema, target), and @setu-ts/openapi-plugin reads it to fill an operation’s requestBody and parameters. VALIDATION_METADATA is created with Symbol.for for the same cross-copy reason, the brand is symbol-keyed and non-enumerable, and the schema travels by REFERENCE — a reader transforms it with whatever schema support it has, and identity is what the OpenAPI generator’s deduplication keys on. A foreign value under the same global symbol reads as absent rather than being trusted.

  • withResponseMetadata brands a RouteHandler with a RouteResponseMetadata ({ status: number }), and responseMetadataOf reads it back — the same mechanism as the two brands above, applied to the status a handler answers with on success. Every route @setu-ts/decorator-plugin’s @HttpCode or @Redirect produces carries it, and @setu-ts/openapi-plugin reads it (deriveResponseStatus, on by default) to document the operation under that status instead of the assumed 200. RESPONSE_METADATA is created with Symbol.for for the same cross-copy reason, and the brand is symbol-keyed and non-enumerable.

    The carrier is the HANDLER rather than a middleware function, and that is a design decision rather than a detail: a guard and a validator ARE middleware, while a success status is a property of the handler, and a decorated route may carry no middleware at all — branding a synthetic middleware purely to hold it would add a pipeline entry that does nothing. status must be an integer in 200–599, the range the web Response constructor accepts; a brand outside it reads as ABSENT rather than being trusted, because a status no runtime could serve is not a status a document should claim. It carries the status ONLY: a response header is not represented, since an OpenAPI response-header entry needs a schema and a description a @ResponseHeader('X-Total', '42') declaration does not carry.

  • IRequest.json() rejects with MalformedRequestBodyError — branded 400 — when the body is not valid JSON (X37-1). The read is lazy (M87) and happens inside the handler, so the rejection reaches errorHandler through the ordinary throw path and is answered 400 in the configured format; a handler that CATCHES its own json() rejection keeps full control of what is served. The same rejection comes from all three producers, so an in-process inject() observes exactly what a served request observes. Added in Milestone 90f.

  • IRequest.formData?() resolves a FormBody for both form encodings through the shared parseFormBody, memoized exactly like json() — the in-flight promise is cached, a rejection with it. It throws UnsupportedFormEncodingError, branded 415, when the content-type is neither form encoding, and yields an EMPTY form for a multipart body it cannot parse: the promoted parser’s released behaviour, stated as the accessor’s limit rather than hidden behind a status. The same parse runs at all three producers, so an in-process inject() observes exactly what a served request observes; a request WITHOUT the optional accessor (an out-of-repo implementor) falls back to the same parseFormBody(await request.bytes(), contentType) — the fallback is the same function, not a second implementation. Optional on the signal?/fs?/ raw? precedent. Added in Milestone 94b.

  • IRequest.raw?: Request and IRequestContext.raw?: Request carry the undisturbed web Request the HTTP adapter received, alongside the mapped framework request whose body has already been buffered. The kernel terminal handler reads it to decide a WebSocket upgrade (which needs the native request) and to reconstruct a gRPC request. Optional, on the M42 signal? / M44 fs? precedent: a custom adapter may omit it, and the terminal handler then treats the request as neither an upgrade nor RPC and falls through to the 404 — it never throws. inject() populates it, so an injected request can exercise both paths. Added in Milestone 70a.

  • setUpgradeIntent(request, intent) brands an IRequest with a WebSocketUpgradeIntent ({ sink, protocol? }) and upgradeIntentOf(request) reads it back, under the UPGRADE_INTENT symbol. This is how the kernel tells the HTTP adapter “the pipeline ran, nothing short-circuited, perform the handshake”. The IRequest is the channel rather than IRequestContext.state because the adapter holds the former and never sees the context — the kernel builds and discards it internally, so nothing written to ctx.state could reach the adapter. UPGRADE_INTENT uses Symbol.for, so two copies of common in one process agree. Added in Milestone 70a.

  • isWebSocketUpgradeRequest(headers) is the shared RFC 6455 §4.2.1 predicate: Upgrade must equal websocket case-insensitively and Connection must contain the upgrade token, matched after splitting on commas — a substring test would also claim Connection: no-upgrade. It lives in common because the kernel decides whether a request is an upgrade and does not depend on @setu-ts/runtime, which re-exports this same function rather than keeping a copy. Added in Milestone 70a.

  • HandlerResult is an opaque brand only the kernel constructs; handlers obtain it from IResponse terminal methods (json, text, send, redirect, stream).

  • IResponse has two header setters with distinct semantics: header(name, value) replaces any existing value for name (Headers.set), while appendHeader(name, value) adds a value without removing existing ones (Headers.append). appendHeader is the correct way to emit multiple headers of the same name — most notably several Set-Cookie headers (e.g. access + refresh cookies). Both chain (return this).

  • IResponse.setHeaders(headers: Readonly<Record<string, string>>): IResponse (0.11.0) — sets several headers at once, exactly as header(name, value) called for each own enumerable string-keyed entry in order, with the same validation and errors. Passing the SAME frozen object on every request lets the kernel validate it once and copy it into each response in one step; any other object is applied entry by entry. HttpSecurityPlugin’s security headers use it. Fastify’s reply.headers(obj), Express’s res.set(obj) and Koa’s ctx.set(obj) are the precedent for the batch call; the template cache is Setu’s. Breaking for IResponse implementors, including test fakes typed as IResponse: add the member as a loop over header(), or use @setu-ts/testing’s createTestContext.

  • IResponse.html(body: string): HandlerResult — sends an HTML response with content-type: text/html; charset=utf-8. The charset is not optional: a bare text/html lets a browser sniff the encoding. Added because every CSRF-protected form example was setting the header by hand. Breaking for out-of-repo IResponse implementors (both in-repo implementors — the kernel’s ResponseBuilder and testing’s MockResponse — implement it).

  • IResponse.stream(body: ReadableStream<Uint8Array>): HandlerResult — sends a streaming response body. The runtime maps this to new Response(streamBody, { status, headers }); streaming is free on every platform (Node via Hono, Deno, Bun, Cloudflare Workers) with no buffer-then-send. Added in Milestone 42.

  • IResponse.snapshot() returns a discriminated union keyed on streaming: when false, body is Uint8Array | string | null (buffered); when true, body is ReadableStream<Uint8Array> (live stream). This allows middleware to safely inspect the response without draining a live stream — middleware that reads the body must check streaming first. Widened from the flat shape added in Milestone 11 to a discriminated union in Milestone 42.

  • A body written at a null-body status (204, 205, 304) is dropped when the response is mapped to a web Response, and a stream body is cancel()led so its source is released. The Response constructor rejects a body at these statuses, and the mapping runs in the HTTP adapter after the pipeline, so passing one through raised a TypeError no middleware or errorHandler could catch. Dropping matches Express and Fastify and follows RFC 9110 §15.3.5; headers are preserved, which RFC 9110 permits. IResponse.snapshot() is unchanged and still reports whatever body was written — the drop happens at the mapping boundary, not in the response model.

  • IRequest.ip?: string — the socket peer on Node, Deno and Bun; behind a proxy read CLIENT_IP_STATE_KEY for the resolved client. Workers and plain fetch calls have no peer.

  • IRequest.signal?: AbortSignal — an abort signal that fires when the underlying HTTP connection is severed (client disconnect, timeout). Populated by the HTTP adapter from the native Request.signal; optional because injected / test requests may not carry one. Added in Milestone 42.

  • IRequest.user, IRequest.tenant and IRequest.locale each allow one implicit assignment per request; a later assignment throws. replacePrincipal, replaceTenant and replaceLocale are the explicit escapes for an intentional replacement. This catches late accidental overwrites, not authorization bypasses: a write before authentication is still the permitted first write. Every request the framework creates — a served request, an inject() request and a createTestContext request — carries this guard on its class prototype (installRequestIdentityGuard, 0.11.0), so on those the three fields are inherited accessors: Object.keys(request) and JSON.stringify(request) do not list them. A request an application builds itself is guarded per instance by the kernel, as before, and its fields stay own and enumerable.

  • IRequest.locale?: string (M103) — the request’s resolved BCP 47 tag, written by the localization plugin’s middleware at priority 45 and absent without that plugin or on a path it excludes. It is a flagged, optional, source-compatible widening on the tenant precedent. Three things to keep in mind: a reader running BELOW priority 45 sees undefined (the priority table orders it, exactly as for tenant at 40); a response cache keys on the value present when the cache runs, so an override must happen before the lookup to be reflected there; and it is a preference, never an authorization input — any client can select any supported locale.

  • The application service registry seals after runBootstrap(). Its register, registerFactory, and unregister methods then throw; request-scoped child registries remain mutable. Startup-time override: true mutations log at info, and successful unregisters log at warn through the logger capability.

  • IRequestContext.signal: AbortSignal — required abort signal (always present). Populated by createRequestContext from the native Request.signal; falls back to a non-aborting sentinel for injected/test contexts so handlers always have a live signal to listen on. Added in Milestone 42.

  • CAPABILITIES.SSE ('sse') — the capability token under which the SsePlugin registers the ISseService. The service provides real-time, one-way server-to-client messaging over an SSE stream built on IResponse.stream(). Added in Milestone 43.

  • CAPABILITIES.SSR ('ssr') — the capability token under which the React Router plugin registers the ISsrService. The service provides server-side rendering by delegating to React Router’s request handler and writing back the result via IResponse. Added in Milestone 44.

  • CAPABILITIES.REALTIME_BACKPLANE ('realtime-backplane') — the capability token under which RealtimeBackplanePlugin registers an IRealtimeBackplane. Consumed optionally by the WebSocket and SSE plugins: present, their rooms and channels fan out across replicas; absent, they broadcast in-process exactly as before. Added in Milestone 47.

  • CAPABILITIES.WEBSOCKET ('websocket') — the capability token under which the WebSocket plugin registers the IWebSocketService. The service provides full-duplex, bidirectional real-time messaging: exact-path routes with lifecycle handlers, named broadcast rooms, and an application-level heartbeat. Distinct from SSE, which is one-way. Added in Milestone 46.

  • CAPABILITIES.SESSION ('session') — the capability token under which SessionPlugin registers the ISessionService. Provides cookie-backed sessions for server-rendered applications: an encrypted self-contained cookie by default, or an opaque id over an ISessionStore. Distinct from AUTHENTICATION, which establishes who a caller is — a session carries per-visitor state and exists for anonymous visitors too. Added in Milestone 48.

  • CAPABILITIES.GRPC ('grpc') — the capability token under which GrpcPlugin registers the IGrpcService. The service provides gRPC/Connect co-serving on the same port as ordinary Hono routes through the kernel terminal handler. The deprecated optional IHttpAdapter.setRpcHandler? seam is no longer consulted. Added in Milestone 49.

  • CAPABILITIES.CLOUDFLARE ('cloudflare') — the capability token under which CloudflarePlugin registers ICloudflareBindings: typed access to a Worker’s KV, R2, D1, Queues, service and Durable Object bindings, its string variables, and waitUntil. Added in Milestone 52.

  • CAPABILITIES.GRAPHQL ('graphql') — the capability token under which GraphqlPlugin registers the IGraphqlService. The service provides schema-first and code-first GraphQL execution over ordinary kernel routes with media-type negotiation. Added in Milestone 51.

  • Contribution-token pattern: HTTP_ADAPTER and the five contribution tokens (HEALTH_INDICATOR, METRIC_REGISTRATION, OPENAPI_SCHEMA, CLI_COMMAND, DECORATOR_HANDLER) are multi-provider capabilities. The kernel collects plugin contributions registered under these tokens via services.getAll(); the corresponding first-party plugins aggregate and expose them. HTTP_ADAPTER is single-provider — the runtime plugin registers its IHttpAdapter there.

  • METADATA_STORE ('metadata-store') is the single-provider capability backing IPluginContext.metadata; the DecoratorPlugin registers its IMetadataStore there. It is distinct from OPENAPI so an OpenAPI plugin registering under OPENAPI does not populate ctx.metadata.

Kernel diagnostics contracts

The read-only DTOs and reader interface the kernel’s optional IApplication.diagnostics serves (Milestone 98a): IDiagnosticsSource (snapshot() / read(after, limit?)), DiagnosticsSnapshot, DiagnosticsNode, DiagnosticsEdge, DiagnosticsEvent, DiagnosticsBatch, and the closed vocabularies DiagnosticsNodeKind, DiagnosticsEdgeKind, DiagnosticsSnapshotState, DiagnosticsFailureCode, DiagnosticsEventKind, DiagnosticsEventOutcome and DiagnosticsEventStage. They are type-only exports: @setu-ts/common ships no diagnostics runtime value and no capability token, because the reader is reached through the application, never resolved from the registry. Activation, label allowlists, and the projection bounds are the kernel’s — see Kernel diagnostics.

Inspector contracts (0.8.0)

The inspector-side contracts the optional addon packages register and the Diagnostics Connector consumes: IHealthDiagnosticsSource under CAPABILITIES.HEALTH_DIAGNOSTICS (M98d, served by the health plugin — see Health) and IConfigDiagnosticsSource under CAPABILITIES.CONFIG_DIAGNOSTICS (M98e, served by the config plugin — see ConfigPlugin). The shared vocabulary DiagnosticsInspectorState distinguishes unsupported (a connector-side answer: the operation is implemented but no source is registered) from disabled (the owning plugin’s answer: present but not opted in). The M98e DTOs — ConfigProvenanceOrigin (environment/file/unknown), ConfigSchemaEffect (not-configured/validated/introduced/removed/unknown), ConfigProvenanceEntry, and ConfigDiagnosticsSnapshot — are type-only exports carrying application-approved aliases and evidence only; the privacy rules are the config plugin’s and the wire shape is docs/diagnostics-protocol.md’s.

Cache observation contracts (M98i). CAPABILITIES.CACHE_DIAGNOSTICS ('cache-diagnostics') is a MULTI-provider token: every CachePlugin instance registers one ICacheDiagnosticsSource (snapshot(): CacheDiagnosticsSnapshot — synchronous, performs no cache operation). The DTOs are CacheDiagnosticsSnapshot (state, alias | null, coverage: 'owned-instance', records, dropped), CacheDiagnosticsRecord (alias, CacheDiagnosticsOperation, count, lastDurationMs, ageMs, and the non-negative saturating counters succeeded, failed, hits, misses, present, absent, removed, notRemoved) and the connector’s CacheDiagnosticsResponse. See the diagnostics-connector section for the wire operation.

Scheduler observation contracts (M98k). CAPABILITIES.SCHEDULER_DIAGNOSTICS ('scheduler-diagnostics') is a MULTI-provider token: every SchedulerPlugin instance registers one ISchedulerDiagnosticsSource (snapshot(): SchedulerDiagnosticsSnapshot — synchronous, never acquires a lock, invokes a handler, or reads the job registry). The DTOs are SchedulerDiagnosticsSnapshot (state, alias | null, coverage: 'owned-instance', records, dropped), SchedulerDiagnosticsRecord (the JOB alias, SchedulerDiagnosticsOperation ('fire' | 'attempt'), count, lastDurationMs | null, ageMs, and the non-negative saturating counters started, succeeded, failed, contended, lockFailed, retryAttempts, lastLatenessMs) and the connector’s SchedulerDiagnosticsResponse. Lateness is max(0, actualStart - intendedFire); a skipped local fire is never a globally missed execution and no missed counter exists. See the diagnostics-connector section for the wire operation. Storage observation contracts (M98m). CAPABILITIES.STORAGE_DIAGNOSTICS ('storage-diagnostics') is a MULTI-provider token: every StoragePlugin instance registers one IStorageDiagnosticsSource (snapshot(): StorageDiagnosticsSnapshot — synchronous, performs no storage operation). The DTOs are StorageDiagnosticsSnapshot (state, alias | null, coverage: 'owned-instance', records, dropped), StorageDiagnosticsRecord (alias, StorageDiagnosticsOperation, count, lastDurationMs, ageMs, succeeded, failed, lastBytes) and the connector’s StorageDiagnosticsResponse. The operation vocabulary is the six public calls put, get, delete, exists, getSignedUrl, getStream; lastDurationMs and lastBytes are null for getSignedUrl, and lastBytes is null for the non-buffered operations. Unlike the M98l realtime contracts, the collector is NOT shared in common — a single plugin owns it — so the option and collector types live in @setu-ts/storage-plugin. See the diagnostics-connector section for the wire operation.

Event observation contracts (M98j). CAPABILITIES.EVENTS_DIAGNOSTICS ('event-diagnostics') is a MULTI-provider token: every EventsPlugin instance registers one IEventDiagnosticsSource (snapshot(): EventDiagnosticsSnapshot — synchronous, never publishes, invokes a handler or enumerates subscriptions). The DTOs are EventDiagnosticsSnapshot (state: EventDiagnosticsState, alias | null, coverage: EventDiagnosticsCoverage, records, dropped), EventDiagnosticsRecord (alias, EventObservationOperation, count, started, succeeded, failed, noSubscribers, lastDurationMs, ageMs) and the connector’s EventDiagnosticsResponse. See the diagnostics-connector section for the wire operation.

Realtime observation contracts (M98l). CAPABILITIES.REALTIME_DIAGNOSTICS ('realtime-diagnostics') is a MULTI-provider token: the WebSocket, SSE and realtime-backplane plugins each register one IRealtimeDiagnosticsSource (snapshot(): RealtimeDiagnosticsSnapshot — synchronous; it never sends, closes, publishes, subscribes, or creates or enumerates a room or channel). The DTOs are RealtimeDiagnosticsSnapshot (state, alias | null, sourceKind: RealtimeSourceKind | 'unknown', coverage: 'owned-instance', gauges: RealtimeDiagnosticsGauges, records, dropped), RealtimeDiagnosticsGauges (state: RealtimeGaugeState, openConnections | null, groups | null), RealtimeDiagnosticsRecord (alias, RealtimeObservationOperation, count, lastDurationMs, ageMs, succeeded, failed, backpressureCloses) and the connector’s RealtimeDiagnosticsResponse. Because the three owning plugins need the identical collector and may not import one another, common also ships it once: compileRealtimeDiagnosticsAlias(options) validates a RealtimeDiagnosticsOptions ({ enabled: true, alias }, alias 1–64 UTF-8 bytes, no control character, no other key; fixed messages that never echo a value) and createRealtimeObservationCollector(init: RealtimeObservationCollectorInit) returns an IRealtimeObservationCollector (enabled, observe, observePublish, snapshot, close, and the frozen snapshot-only source facade a plugin registers). A null alias gives an inert collector that reads neither the clock nor the gauges. RealtimeGaugeReading is the gauge reader’s return shape. The collector is pure and bounded — no I/O, no timer, no registry access — and a failing clock or gauge reader latches collection-failed without changing any application result. See the diagnostics-connector section for the wire operation.

Outbound HTTP observation contracts (M98n). CAPABILITIES.OUTBOUND_HTTP_DIAGNOSTICS ('outbound-http-diagnostics') is a MULTI-provider token whose producer is the SDK’s createObservedFetch registration plugin. IOutboundHttpDiagnosticsSource.snapshot(): OutboundHttpDiagnosticsSnapshot is synchronous and never performs a request. The DTOs are OutboundHttpDiagnosticsSnapshot (state, alias | null, coverage: 'owned-instance', at most one record), OutboundHttpDiagnosticsRecord (alias, operation: 'attempt', started, count, responses, failures, lastStatusClass: OutboundHttpStatusClass | null, lastDurationMs, ageMs), OutboundHttpStatusClass and the connector’s OutboundHttpDiagnosticsResponse. See the diagnostics-connector section for the wire operation.

Trace observation contracts (M98g). CAPABILITIES.TRACE_DIAGNOSTICS ('trace-diagnostics') is a SINGLE-provider token: the TelemetryPlugin always registers one ITraceDiagnosticsSource (read(instanceId, after, limit?): TraceDiagnosticsBatch — synchronous, requires a non-empty instance id the batch must match, M98a cursor contract over a 1,024-record ring, limit 1–128, throws the fixed value-free RangeError for a bad instance, a bad cursor, or a cursor beyond the source’s sequence). The DTOs are TraceDiagnosticsBatch and TraceObservation; the closed vocabularies are TraceSourceState, TraceCoverage, TraceInstrumentationKind (http, fetch, ioredis, redis since M117, amqplib, kafkajs), TraceOutcome, TraceParentVisibility and the TraceSamplerDescription shape. Minimization precedes retention: only exact approved operation aliases, validated W3C identifiers, at most eight validated link pairs, kind, outcome, duration and monotonic age are retained; span names, attributes, events, resources, tracestate, baggage, exceptions and status messages never reach collector state (canary-asserted). See the diagnostics-connector section for the wire operation.

Queue observation contracts (M98f). CAPABILITIES.QUEUE_DIAGNOSTICS ('queue-diagnostics') is a MULTI-provider token: every QueuePlugin instance registers one IQueueDiagnosticsSource (read(after, limit?): QueueDiagnosticsSourceBatch — synchronous, M98a cursor contract, limit 1–128) under it with { multi: true }, never in provides. The source-side DTOs are QueueDiagnosticsSourceBatch, QueueSourceAttemptObservation and QueueSourceDepthObservation; the connector’s merged DTOs are QueueDiagnosticsBatch, QueueDiagnosticsSourceStatus, QueueAttemptObservation and QueueDepthObservation; the closed vocabularies are QueueProcessorOutcome, QueueSettlementState, QueueDepthScope, QueueDepthCycleCoverage, QueueDepthCoverage, QueueSourceFailure, QueueDiagnosticsFailure and QueueSourceState. Every member is a bounded primitive or an approved alias: no payload, header, raw job id, claim token, credential or error text is expressible. See Queue observations and docs/diagnostics-protocol.md.

Auth-session contract (M100c). CAPABILITIES.AUTH_SESSION ('auth-session') resolves the IAuthSessionService that AuthPlugin registers when its signIn option is configured — the one owner of “this session is signed in as this principal”. signIn(ctx, principal, options: SignInOptions): Promise<SignInOutcome> records { principal, methods, at } in the request’s session and regenerates the session id (session fixation); SignInOptions.methods is a list of AuthMethod ('pwd' | 'otp' | 'pop' | 'fed', RFC 8176 values). SignInOutcome is { status: 'signed-in' } | { status: 'second-factor-required' } (M100d): when signIn.mfa.required(principal, methods) returns true the record is NOT written, the principal is instead held in a PendingSignIn, and the second arm is returned — so a caller must branch on the status rather than assume success. current(ctx) reads the recorded principal without running the strategy chain, pending(ctx) reads the held-back PendingSignIn ({ principal, methods, at }, or null when none is stored or it has expired), and signOut(ctx) destroys the session. All three require the session middleware to have run (signIn rejects, the others throw). What signOut revokes depends on the session strategy: the store strategy deletes the entry, while the default encrypted-cookie strategy leaves a copied cookie valid until its maxAge. Promotion out of the pending state is package-internal (promotePending) and is NOT part of IAuthSessionService: an exported promotion would let a caller assert it verified a factor, and a caller-supplied method is not evidence. Only the package’s own verifiers reach it — a guarantee about the framework’s surface, not a sandbox: application code can still call signIn with methods: ['pwd', 'otp'] and is trusted to record only methods it verified — and signIn.mfa.pendingTtlMs (default 300 000 ms) is the single owner of how long a pending record survives — TotpService has no TTL option of its own.

Authorization observation contracts (M98h). CAPABILITIES.AUTHORIZATION_DIAGNOSTICS ('authorization-diagnostics') is a SINGLE-provider token: the AuthPlugin always registers one IAuthorizationDiagnosticsSource (read(instanceId, after, limit?): AuthorizationDiagnosticsBatch — synchronous, requires a non-empty instance id the batch must match, M98a cursor contract over a 1,024-decision ring, limit 1–128, throws the fixed value-free RangeError for a bad instance, a bad cursor, or a cursor beyond the source’s sequence). The DTOs are AuthorizationDiagnosticsBatch, AuthorizationDecisionObservation and AuthorizationDecisionStep; the closed vocabularies are AuthorizationSourceState, AuthorizationCoverage (rbac-not-configured / provider-identity-unavailable / custom-provider / unknown), AuthorizationDecisionOperation and AuthorizationDecisionReason. Every member is a bounded primitive or an application-approved alias: no principal identifier, role or permission value, request path, raw rule, credential or error text is expressible, and a step count is a count only — it never carries a rule name that was not approved. See the diagnostics-connector section and docs/diagnostics-protocol.md.

Registry current-provider identity (M98h). IServiceRegistry gains one OPTIONAL method, isCurrent?(token, instance): boolean — a non-resolving identity predicate that reports whether instance is still the registration get would select for token, without resolving a lazy factory, enumerating services, or returning the current value. ServiceRegistry implements it as an identity comparison (request-scoped children delegate to their parent). It is optional on purpose: third-party registry-shaped implementations keep compiling, and a consumer that needs a current-provider check must treat an absent isCurrent as “cannot verify” — never as a fallback to a resolving get, which could instantiate a custom lazy service. The M98h authorization source is the first consumer: its absence latches provider-identity-unavailable and a false result latches custom-provider, both terminal.

Ingress behaviours

IngressKind is 'queue' | 'scheduler' | 'messaging' | 'websocket'. IngressContext<TPayload> is the immutable work envelope supplied to an ingress behaviour: { kind, name, payload, attempt?, headers?, consumer? }. Queue and scheduler populate the 1-based attempt; messaging supplies transport headers when available and WebSocket frames supply neither optional field.

consumer (M109a) is a DISPATCH identity, not a capability and not a state bag: the subscription’s SubscribeOptions.queue when given, otherwise subscription:<instance>:<n> (unique per subscription per process) on 'messaging', and the job name on 'queue'; it is absent on 'scheduler' and 'websocket'. It exists so per-consumer state — an idempotency record — is keyed per subscriber rather than per topic. The envelope’s “no state, no services” rule stands: a behaviour still reaches a capability through its RegistryFactory arm.

IIngressBehavior.handle(context, next) is the void-result contract for non-HTTP work. BehaviorLike<TWork, TResult> is the structural shape shared with CQRS, and composeBehaviorChain runs behaviours in declared order. A behaviour that does not call next() short-circuits the terminal handler; a thrown error follows that ingress’s existing error path. The common composer is also consumed internally by CQRS; it adds no CQRS surface.

Idempotency

CAPABILITIES.IDEMPOTENCY ('idempotency') names the service an idempotency provider registers; the port (IIdempotencyStore), the service contract (IIdempotencyService) and the route/ingress option types live in @setu-ts/common so a store adapter (memory, Redis, a Cloudflare Durable Object) and the decorator plugin can consume them without importing one another (AI_GUIDELINES §2.2). The mechanism — IdempotencyPlugin, idempotent(), idempotentIngress(), @Idempotent — lives in @setu-ts/idempotency-plugin; see that package’s section. The guarantee is no duplicate processing within the limits of the store — it is not a single-execution guarantee.

Tier C adds ITransactionalIdempotencyStore, with find(id), run(claim, work), purge(before, limit) and verify(). run owns the transaction and passes its scope to work, which returns { result: string, value: R }. find returns a TransactionalIdempotencyRecord or undefined when either row is missing or foreign. IDEMPOTENCY_RECORD_KIND is the exported value 'setu-idempotency'.

TransactionalIdempotencyClaim carries id, fingerprint, createdAt and expiresAt. TransactionalIdempotencyRecord adds the encoded result. The timestamps are epoch milliseconds; expiresAt makes a record eligible for purge, and it remains authoritative until purged.

IdempotentWithinOptions requires key, namespace and scope; it accepts optional fingerprint: unknown and ttlMs: number. Keys are 1–255 printable non-space ASCII characters, excluding "; namespace is 1–256 printable ASCII characters, and scope is 0–512. Scope must come from authenticated identity, typically JSON.stringify([tenantId, principalId]); '' deliberately makes the key global. Do not join parts with a separator that can appear inside them: distinct identities can collide. The default TTL is 86,400,000 ms, bounded to an integer from 60,000 to 2,592,000,000 ms. IdempotentWithinResult<R> carries value: R and replayed: boolean; value is the JSON round trip on both paths, so dates become strings and JSON-omitted members are absent.

The new IIdempotencyService contract includes two required tier-C members:

within<R, S = unknown>(
  options: IdempotentWithinOptions,
  fn: (scope: S) => Promise<R>,
): Promise<IdempotentWithinResult<R>>;
purgeTransactional(): Promise<number>;

S is the caller’s unchecked annotation of the store scope, normally IUnitOfWork. Without a configured transactional store, both members reject with IdempotencyConfigurationError('transactional', …). A replacement provider must implement both.

WebSocketUpgradeGuard is a route guard that receives a WebSocketConnectionContext and returns either true or a { status } refusal (WebSocketGuardDecision). WebSocketRouteOptions.guards is an optional readonly array of those guards; the matched route runs them in declared order before its handshake and the first refusal wins.


API Reference: @setu-ts/kernel

The plugin kernel: resolves plugin dependencies, builds the middleware pipeline and router, validates environment variables, and dispatches requests. Implemented in Milestone 2; route matching was delegated to Hono in Milestone 22 (behind the unchanged IRouterApi contract). This section is the authoritative export list (AI_GUIDELINES §10.5). All exports carry full JSDoc.

Route patterns are static segments, :name parameters (a word, \w+, other than __proto__), a final optional :name?, * segments and a final prefix*. Since 0.11.0 anything else is refused at registration with an error naming the unsupported part — a Hono regular-expression parameter (:name{…}, whose /-spanning form TrieRouter matches in time quadratic in the path length), an empty segment, a * inside a non-final segment, an optional parameter that is not last. router.group() joins its prefix and each path with exactly one /. A request may carry one extra trailing slash (/users/ reaches /users); a static route registered with a trailing slash answers only that spelling, while a parameter route’s trailing slash is not significant (/users/:id/ answers /users/42). A pattern written without a leading slash is the same route as its slash-prefixed spelling.

Values (runtime exports)

ExportKindPurpose
createApplicationfunctionCreates a kernel application with optional pre-registered plugins

Types

ExportKindPurpose
ApplicationOptionstypeOptions for createApplication ({ plugins?: IPlugin[]; diagnostics?: KernelDiagnosticsOptions })
IKernelApplicationtypeIApplication extended with inject() for serverless request injection, and unregister(name)
InjectRequesttypeSynthetic request shape for inject() ({ method, url, headers?, body? } — bytes verbatim with no content-type default, a Blob defaulted from its own non-empty type, URLSearchParams urlencoded-defaulted, plain object and string JSON-defaulted, anything else refused)
InjectResponsetypeResponse shape returned by inject() ({ statusCode, headers, body, json<T>() })
KernelDiagnosticsOptionstypeKernel-diagnostics activation passed as ApplicationOptions.diagnostics ({ labels?: KernelDiagnosticsLabelOptions }); its PRESENCE is the activation, and an omitted option allocates nothing
KernelDiagnosticsLabelOptionstypeThe four exact-match label allowlists (plugins/capabilities/routes/middleware), each at most 256 entries of at most 160 UTF-8 bytes with no control characters

Contract notes:

  • unregister(name: string): boolean removes every pending plugin carrying the name before start(), returning true when one or more matched. Two pending plugins may share a name — the kernel refuses duplicates at start(), not at register() — so removing only the first would leave one running while the caller was told the name was gone. Plugins do not run until start(), so removing one means its register() — and any eager side effect inside it, such as a database adapter’s connect() — never happens. That is the one thing overriding a capability cannot do: an override replaces what consumers resolve, after the real plugin has already run. It throws once the application has started, mirroring register(), because start() has already read the plugin list and a silent false would report success for an operation that can have had no effect. It throws once any plugin has REGISTERED, not merely once start() was called: plugin resolution can fail before anything runs — an unsatisfied dependency, a cycle, no runtime provider — and removing the offending plugin is exactly how that is corrected, so those failures leave unregister available. Removing a plugin another declares in dependencies is not refused here — start() reports it, naming the dependent plugin and the unsatisfied capability. It does not name the removed plugin when its name differs from the token it provided, which is the usual case (database-plugin provides database), so a failure after a without reads as a missing capability rather than as the exclusion that caused it. @setu-ts/testing’s createTestApp({ app, without }) is the intended caller.
  • A failed start() is retryable only if it failed before any plugin registered — plugin resolution (an unsatisfied dependency, a cycle, no runtime provider), which is the same boundary unregister uses. Correct the plugin list and call start() again. Once any plugin’s register() has run, a second start() throws Cannot retry start() after plugins have registered … Create a new application instead.: the plugins that ran left their services (in a registry that may already be sealed), lifecycle hooks, routes and middleware behind, and possibly resources outside the kernel, none of which can be un-run. The failed attempt’s onClose hooks have already run. Restart after a successful start() + stop() is not supported either — start() throws Application has already been started.
  • hasPlugin(name: string): boolean reports whether a plugin carrying that name is pending. A pure read — it resolves and constructs nothing. It exists so a caller applying several exclusions can validate the whole set before removing any of them: unregister mutates immediately, so removing as it goes would leave earlier exclusions applied when a later name is misspelled, and the throw is recoverable, so a caller that catches it and reuses that composition root would be handed a silently altered one. createTestApp checks every without entry through this member first and names all unmatched entries in one error. It answers against the registered plugin list, which is the PENDING list only before start() — startup does not clear it, so afterwards the member still reports true for a plugin that has already run.
  • Listening requires CAPABILITIES.HTTP_ADAPTER (registered by the runtime plugin) and a port option. Without either, start() skips server creation — inject() and tests need no server.
  • The kernel’s own error responses (malformed request URL or malformed percent-escape in the path → 400; unmatched path → 404; unhandled error → 500; a request arriving while stop() is draining → 503) are written through the error responder seam (respondWithError in @setu-ts/common). With errorHandler registered they answer in the application’s configured format; with no errorHandler registered they fall back to the no-handler shape { error, detail? } — which is not the same as the default formatter’s { statusCode, message, details? } body (see the exceptions contract notes). Error formatting belongs to the exceptions package, not the kernel; the kernel only supplies the status and message. The unhandled-error 500 additionally logs the error through CAPABILITIES.LOGGER when a logger is registered.
  • inject() body semantics. InjectResponse.body is text: a byte body written with response.send(bytes) is UTF-8 decoded rather than reported as null, and json() parses it. A streaming response cannot be presented as text without draining the live stream, so inject() throws and points at app.fetch() with a web Request instead.
  • app.fetch() rejects rather than throwing synchronously when no http-adapter capability is registered, so the Workers export default { fetch: app.fetch } entry point sees a failed promise instead of an unhandled exception.
  • app.fetch is bound to its application, so it works passed around as a bare function. That entry point needs it: Cloudflare calls exported.fetch(request, …), which made this the exported object, and before the binding every such request answered 500.
  • Contribution-token pattern: ctx.health.register(), ctx.metrics.register(), ctx.openapi.addSchema(), ctx.cli.register(), and ctx.decorators.register() funnel contributions into multi-provider services under the Step-1 tokens; consumers retrieve them with services.getAll().
  • ctx.runtime is a lazy getter that resolves CAPABILITIES.RUNTIME on access, so the runtime plugin itself does not trip over it during its own registration.
  • Route middleware uses the same next()-chaining semantics as the global pipeline: a stage that responds without calling next() short-circuits, and the handler does not run. As defense-in-depth, a stage that responds AND calls next() still does not let downstream stages overwrite the response (the chain stops once the response is ended).

API Reference: @setu-ts/runtime

RuntimePlugin and runtime adapters providing IRuntimeServices for Node.js, Deno, Bun, and Cloudflare Workers.

M23 replaced the old HTTP server adapters with the new IHttpAdapter contract (setHandler/fetch/listen/close) and added the Cloudflare Workers adapter.

M87 widened two of those members to accept and return sync-or-async — setHandler now takes (request: IRequest) => IResponse | Promise<IResponse> and fetch returns Response | Promise<Response>. This is breaking for an out-of-repo adapter: the handler’s return type sits in a contravariant position, so an implementation declaring the narrower Promise-only shape is no longer a faithful implementor (TypeScript’s bivariant method parameters mean it still compiles — the break surfaces when a handler that answers synchronously reaches it). Callers are unaffected, since await works on both.

The widening exists because a request the kernel can answer without awaiting must not be wrapped back into a promise: @hono/node-server serves a response through its synchronous fast path only when the handler returns a non-promise, so a single eagerly-async link foreclosed that path for every request. An adapter implementing this contract should branch on promise-ness rather than awaiting:

const result = this.handler(frameworkRequest);
return isPromiseLike(result) ? Promise.resolve(result).then(finish) : finish(result);

Values (runtime exports)

ExportKindPurpose
RuntimePluginfunctionCreates the runtime plugin (registers CAPABILITIES.RUNTIME)
detectRuntimefunctionDetects the current runtime platform ('node' | 'deno' | 'bun' | 'cloudflare-workers')
createRuntimeServicesfunctionCreates IRuntimeServices for the detected platform, without an application
buildNodeHostfunctionBuilds a NodeHost from injected NodeModules (defaults to real node: built-ins)
buildBunHostfunctionBuilds a BunHost from injected BunModules (defaults to node: built-ins)
createDenoRuntimeServicesfunctionCreates IRuntimeServices backed by Deno APIs
createNodeRuntimeServicesfunctionCreates IRuntimeServices backed by Node.js APIs
createBunRuntimeServicesfunctionCreates IRuntimeServices backed by Bun APIs
createCloudflareRuntimeServicesfunctionCreates IRuntimeServices backed by Cloudflare Workers APIs (edge-compatible)
DenoHttpAdapterclassDeno HTTP server adapter implementing IHttpAdapter
NodeHttpAdapterclassNode.js HTTP server adapter implementing IHttpAdapter
BunHttpAdapterclassBun HTTP server adapter implementing IHttpAdapter
CloudflareWorkersHttpAdapterclassCloudflare Workers HTTP adapter implementing IHttpAdapter (fetch-only, no listen)
createWebWorkerHostfunctionCreates an IWorkerHost over the web Worker API (Deno/Bun); throws if Worker absent
createNodeWorkerHostfunctionCreates an IWorkerHost over node:worker_threads
defineWorkerTaskfunction@setu-ts/runtime/worker subpath. Registers a worker module’s task handler
isDenoHttpServerHandlefunctionType guard for DenoHttpServerHandle
isNodeHttpServerHandlefunctionType guard for NodeHttpServerHandle
isBunHttpServerHandlefunctionType guard for BunHttpServerHandle

WebSocket upgrade support (Milestone 46) — shared primitives:

ExportKindPurpose
isWebSocketUpgradeRequestfunctionWhether request headers describe an RFC 6455 upgrade (Upgrade + Connection)
createWebSocketTransportfunctionWraps a web-API socket as an IWebSocketTransport
normalizeFramefunctionNormalizes an inbound frame payload to string | Uint8Array
toReadyStatefunctionMaps the web WebSocket numeric readyState to WebSocketReadyState
toTransportErrorfunctionCoerces an error-event payload into a real Error

Per-runtime upgrade seams:

ExportKindPurpose
bindDenoSocketToSinkfunctionBinds a Deno.upgradeWebSocket socket’s on* handlers to a WebSocketEventSink
createBunWebSocketHandlersfunctionBuilds the serve-time handler object for Bun.serve’s websocket option
bindCloudflareSocketToSinkfunctionAccepts a Workers WebSocketPair server half and binds it to a sink
createDefaultCloudflareWebSocketHostfunctionBuilds the Workers host from the real WebSocketPair global
buildUpgradeResponseInitfunctionBuilds the Workers 101 ResponseInit carrying the client socket
NodeUpgradeCoordinatorclassOwns the ws server for one Node adapter and performs the raw handshake
adaptWsModulefunctionNarrows an imported ws module to WsModuleLike
loadWsModulefunctionLazily imports ws; throws with the install command if absent
bindWsSocketToSinkfunctionBinds a ws socket’s events to a WebSocketEventSink
createWsTransportfunctionWraps a ws socket as an IWebSocketTransport
toWsReadyStatefunctionMaps a ws numeric ready state to WebSocketReadyState
createUpgradeRequestfunctionRebuilds a web Request from Node’s upgrade event arguments
asUpgradeEmitterfunctionProbes a Node server handle for the raw upgrade event
rejectRawUpgradefunctionWrites an HTTP status line to a raw socket and destroys it

Types

ExportKindPurpose
RuntimeOptionstypeOptions for RuntimePlugin (platform, env)
CreateRuntimeServicesOptionstypeOptions for createRuntimeServices (platform, adapters, env)
RuntimeAdapterFactoriestypePlatform → runtime adapter factory map
GlobalScopetypeInjectable global scope shape for detectRuntime
DenoHosttypeHost interface for the Deno adapter (extension point)
DenoFileInfotypeFile info returned by DenoHost.stat()
DenoDirEntrytypeDirectory entry yielded by DenoHost.readDir() (an AsyncIterable)
NodeHosttypeHost interface for the Node adapter (extension point)
NodeFsInfotypeFile info returned by NodeHost.stat()
NodeModulestypeInjectable Node built-ins for buildNodeHost (testing seam)
BunHosttypeHost interface for the Bun adapter (extension point)
BunFileInfotypeFile info returned by BunHost.stat()
BunModulestypeInjectable built-ins for buildBunHost (testing seam)
DenoHttpServerHandletypeInternal server handle for DenoHttpAdapter
NodeHttpServerHandletypeInternal server handle for NodeHttpAdapter
BunHttpServerHandletypeInternal server handle for BunHttpAdapter
CloudflareWorkersHttpServerHandletypeInternal server handle for CloudflareWorkersHttpAdapter
DenoServeHosttypeInjectable host interface for DenoHttpAdapter (extension point)
NodeServeHosttypeInjectable host interface for NodeHttpAdapter (extension point)
BunServeHosttypeInjectable host interface for BunHttpAdapter (extension point)
BunServertypeBun server handle returned by Bun.serve
HttpAdapterFactoriestypePlatform→adapter factory map for RuntimePlugin
WebWorkerGlobalstypeInjectable seam for createWebWorkerHost (Worker + concurrency)
WebWorkerLiketypeMinimal web Worker shape the host consumes
NodeWorkerModulestypeInjectable seam for createNodeWorkerHost (Worker + availableParallelism)
NodeWorkerLiketypeMinimal worker_threads.Worker shape the host consumes
WebSocketLiketypeMinimal web WebSocket shape the adapters drive
DenoWebSocketLiketypeA web socket exposing the on* handler properties (Deno)
DenoWebSocketUpgradetypeResult shape of Deno.upgradeWebSocket ({ socket, response })
BunServerWebSockettypeMinimal Bun ServerWebSocket shape, carrying data
BunSocketDatatypePer-socket data Bun carries from upgrade() to its handlers ({ sink })
BunWebSocketHandlerstypeThe serve-time handler object for Bun.serve’s websocket option
CloudflareServerSockettypeWorkers server-half socket (accept() + addEventListener)
CloudflareWebSocketPairtypeA created Workers pair ({ client, server })
CloudflareWebSocketHosttypeInjectable seam for the Workers upgrader (extension point)
CloudflareUpgradeResponseInittypeThe Workers 101 response init, including the webSocket member
WsModuleLiketypeStructural facade over the ws module ({ WebSocketServer })
WsServerLiketypeStructural facade over a noServer ws WebSocketServer
WsSocketLiketypeStructural facade over a ws socket
NodeIncomingMessagetypeMinimal node:http.IncomingMessage shape for building an upgrade Request
RawUpgradeSockettypeMinimal raw duplex socket shape a refusal writes to
UpgradeEmittertypeA server handle that emits the raw upgrade event

Contract notes:

  • The Bun and Deno host defaults are backed by real APIs (retro review, Part 2). buildBunHost() builds the default BunHost from node:fs (sync), node:os, and node:process — which Bun implements — NOT from members of the Bun global: Bun’s file API is Bun.file() / Bun.write(), and it has no readFile/stat/readdir/mkdir/rm/realPath/hostname/exit. Only Bun.version is read from the global. On the Deno side, DenoHost.readDir() is named and shaped after the real Deno.readDir — an AsyncIterable, consumed with for await. Both defaults are exercised against their real APIs by the test suite, not only through injected fakes.

  • IRuntimeServices.env is a snapshot on Deno and Workers, taken when the services were created (Deno.env.toObject(), the per-invocation Workers bindings object), and a live pass-through of process.env on Node and Bun. Nothing in the framework mutates the environment at runtime.

  • M23 replaced M39’s HTTP server adapters. The IHttpAdapter contract now exposes the web-standard fetch entry: setHandler installs the framework handler, fetch is the universal entry point callable without listen (Cloudflare Workers), listen binds a real TCP socket, and close tears it down. Adapters (NodeHttpAdapter, DenoHttpAdapter, BunHttpAdapter, CloudflareWorkersHttpAdapter) are registered under CAPABILITIES.HTTP_ADAPTER via RuntimePlugin.

  • M46 added a fifth, OPTIONAL member: setUpgradeRouter?(router). It installs a WebSocketUpgradeRouter consulted for every inbound WebSocket upgrade before the request is mapped to an IRequest and enters the middleware pipeline. That ordering is a correctness requirement, not an optimization: the shared mapping pre-reads the body via arrayBuffer(), and Deno.upgradeWebSocket fails once the request body has been disturbed. The router answers accept (with the WebSocketEventSink the adapter binds its native socket into), reject (with a status), or null to fall through — so installing a router never changes the behavior of non-WebSocket traffic. Because the member is optional, adapters written before this seam remain valid implementations; consumers must degrade gracefully when it is absent (see WebSocketPlugin, which reports available: false and fails route() with a typed error). All four first-party adapters implement it.

  • M49 added a sixth, OPTIONAL member: setRpcHandler?(handler). It installs an RpcFetchHandler consulted for every inbound request after the WebSocket upgrade short-circuit (where one exists) and before the request is mapped to an IRequest. The ordering is again a correctness requirement: a gRPC exchange needs the raw streaming body and emits trailers, neither of which IRequest/IResponse can express, and the shared mapping pre-reads the body. The handler answers a Response (handled as RPC) or null to fall through, so installing one never changes the behavior of non-RPC traffic. The handler is consulted exactly once per request, and one returning null MUST leave the body unread — the adapter maps that same Request afterwards, so a consumed body makes the fall-through fail with “Body already consumed”. Decide from method, URL and headers (the gRPC plugin matches on a path prefix alone); a handler that must inspect the body has to read request.clone(). Because the member is optional, adapters written before this seam remain valid implementations; consumers must degrade gracefully when it is absent (see GrpcPlugin, which reports available: false and throws GrpcUnavailableError from handleRequest). All four first-party adapters implement it.

  • Socket peer (M113n). IRequest.ip?: string is the socket peer on Node, Deno and Bun; the client behind a proxy is CLIENT_IP_STATE_KEY. IPv4-mapped peers are normalized to dotted IPv4. Workers, plain adapter.fetch() and the local diagnostics listener supply no peer. NodeServeHost.serve’s fetch environment adds incoming?: { socket?: { remoteAddress?: string | undefined } } (matching Node’s native member). DenoServeHost.serve’s callback accepts optional info?: { remoteAddr?: { hostname?: string } }. BunServer adds optional requestIP?(request: Request): { address: string } | null. Older injected hosts that supply none of these still compile and produce no peer.

  • The RuntimePlugin is mandatory in every application. It registers at PLUGIN_PRIORITY.HIGHEST so its services are available to all other plugins during registration.

  • Each adapter factory accepts an injectable *Host interface (the documented extension point for custom runtimes). The default host binds to the real runtime global via a single sanctioned as cast; no other casts are used.

  • detectRuntime() accepts an injectable globals parameter (default globalThis) so all detection branches are testable without real runtimes.

  • createRuntimeServices(options?) builds services without an application. Use it when the runtime is needed before any plugin exists — reading the environment to decide which plugins to register, which is what createFullStackAppFromConfig does. RuntimePlugin.register calls this same function, so detection, the platform → adapter lookup, and the No runtime adapter factory for platform: <p> error exist once rather than twice.

    import { createRuntimeServices } from '@setu-ts/runtime';
    import { loadConfig } from '@setu-ts/config-plugin';
    
    const config = await loadConfig(createRuntimeServices());

    Building a second instance alongside the application’s own is safe: the adapters are stateless facades over platform globals, holding no connection, cache, or handle registry, and nothing compares them by identity. One caveat — env is a snapshot taken at construction, not a live view, so a variable set between two constructions is visible only to the later instance.

  • env on Cloudflare Workers. There is no ambient environment on the edge: bindings and variables arrive as the env argument of the fetch handler. Both RuntimePlugin and createRuntimeServices therefore take an env option, and without it runtime.env is empty on Workers, so ConfigPlugin and the secrets EnvProvider read nothing. Pass what the platform provides:

    import { env } from 'cloudflare:workers';
    
    const app = createApplication({ plugins: [RuntimePlugin({ env })] });

    Only the record’s string entries populate runtime.env, which is contracted as Readonly<Record<string, string | undefined>>; object bindings are filtered out by the pure splitWorkerEnv in common and reached through CAPABILITIES.CLOUDFLARE instead. Passing them through unfiltered would hand ConfigPlugin a [object Object] for every KV namespace. The option is ignored on Deno, Node, and Bun, which read their own ambient environment.


API Reference: @setu-ts/exceptions

Exception factory functions, HttpError, error formatters, and the global error handler middleware. This is a plain package (not a plugin) — it depends on @setu-ts/common only. Register the middleware via the application’s pipeline.

Values (exceptions exports)

ExportKindPurpose
HttpErrorclassThe single HTTP error type (extends Error, carries statusCode)
badRequestfunctionFactory → 400 HttpError
unauthorizedfunctionFactory → 401 HttpError
forbiddenfunctionFactory → 403 HttpError
notFoundfunctionFactory → 404 HttpError
conflictfunctionFactory → 409 HttpError
validationErrorfunctionFactory → 422 HttpError wrapping ValidationError[]
tooManyRequestsfunctionFactory → 429 HttpError
internalServerErrorfunctionFactory → 500 HttpError (accepts cause for error chaining)
notImplementedfunctionFactory → 501 HttpError
serviceUnavailablefunctionFactory → 503 HttpError
statusTitlefunctionResolves a status code to a human-readable title
STATUS_TITLESconstReadonly record of well-known status-code → title mappings
errorHandlerfunctionCreates the global error-handler MiddlewareFunction
defaultFormatterconstFramework-standard error body formatter ({ statusCode, message })
rfc9457FormatterconstRFC 9457 Problem Details formatter
rfc7807FormatterconstDeprecated. RFC 7807 Problem Details formatter
selectFormatterfunctionResolves an ErrorFormat or custom function to a formatter
ERROR_TYPE_BASEconstBase URI for framework problem types (https://setu-ts.dev/errors)

Types

ExportKindPurpose
ValidationErrortypeA single validation failure ({ field, message, code? })
HttpErrorInittypeOptions object for HttpError.from()
ErrorHandlerOptionstypeOptions for errorHandler() ({ format?, includeStackTrace?, logErrors?, maskInternalErrors?, respond? }); respond(error: HttpError, ctx: IRequestContext) returns or resolves a HandlerResult or undefined fallback.
ErrorHandlerFormattertype(error: Error, ctx?) => Record<string, unknown>
ErrorFormattype'default' | 'rfc9457' | 'rfc7807' (this package’s union, no 'nestjs')
DefaultErrorBodytypeFramework-standard error body shape
ProblemDetailstypeRFC 9457 Problem Details body shape

Contract notes:

  • Composition over inheritance: there is exactly one HttpError class. Every factory function returns an HttpError with a pre-set statusCode — no BadRequestError extends HttpError hierarchy.

  • cause chaining: internalServerError(message, cause) forwards cause to the ES2022 Error cause chain. The error handler logs it when a logger is registered (the logged cause is serialized through serializeError, so a nested Error never renders as {}).

  • Error format is the framework’s error-body contract: errorHandler is the single place an application configures how every error body is written. It publishes a request-scoped IErrorResponder (via respondWithError in @setu-ts/common) before next(), and every short-circuiting site — the kernel’s own 404/400/500/503 terminals, the storage, multi-tenancy, session, auth, http-security, and feature-flags middleware — answers through it. So with errorHandler({ format: 'rfc9457' }) registered, every error those sites produce is RFC 9457. validation-plugin is the deliberate exception: it formats validation failures itself and takes its own errorFormat option, so an application configures the two to agree — the CLI templates and rest-starter pair ValidationPlugin({ errorFormat: 'rfc9457' }) with the handler for exactly that reason. With no errorHandler registered, every responder site falls back to the no-handler shape { error, detail? } — a site cannot answer in its own ad-hoc shape. That fallback is a different body from the default formatter’s { statusCode, message, details? }: it is the framework’s pre-formatter shape, written directly by respondWithError, and it is what an application without errorHandler keeps receiving byte-for-byte.

  • Application-owned caught-error response: ErrorHandlerOptions.respond runs after a caught value has been normalized, status-hinted or masked, and resolved to a serveable status. It receives that HttpError and the live IRequestContext; returning or resolving a HandlerResult built through ctx.response bypasses framework formatting, so the application owns the returned status, headers, and body (for example, an HTML error page). Returning or resolving undefined is the only fallback signal and preserves the configured formatter exactly. Logging retains the unmasked diagnostic and records the selected response status. This hook is deliberately limited to errorHandler’s caught-error path: the M70f responder still formats unmatched-path 404, malformed-request 400, shutdown-drain 503, and other responder-based terminals, including when no full request context exists. The status ownership is the part easiest to lose — renderView takes no status, so the natural spelling serves 200 for every branded error, and the one-line fix belongs in the hook:

    async respond(error, ctx): Promise<HandlerResult | undefined> {
      if (!wantsHtml(ctx)) return undefined; // API clients keep Problem Details
      ctx.response.status(error.statusCode); // the callback owns the status — without it, 200
      return await renderView(ctx, ErrorPage, { status: error.statusCode });
    }
  • RFC 9457 compliance: when format: 'rfc9457', the response body carries type, title, status, detail (and instance from the request path) with Content-Type: application/problem+json. The message field is absent in this mode (Problem Details uses detail). The media type follows the RESOLVED formatter, so passing the exported rfc9457Formatter function as format produces the same body and the same content type as the 'rfc9457' alias.

  • type is about:blank for status-only problems. RFC 9457 §4.2 registers about:blank for a problem carrying “no semantics beyond the HTTP status code”, which is every error this package produces except the one from validationError(). That one defines an errors extension member, so it is a distinct problem type identified by https://setu-ts.dev/errors/validation — the same URI @setu-ts/validation-plugin emits for it. Clients that need to distinguish errors should read status, which is what it is for.

    {
      "type": "about:blank",
      "title": "Not Found",
      "status": 404,
      "detail": "User 42 does not exist",
      "instance": "/users/42"
    }
  • 'rfc7807' is deprecated but unchanged. RFC 7807 was obsoleted by RFC 9457 in July 2023. The 'rfc7807' alias and the rfc7807Formatter export are retained through a deprecation period (AI_GUIDELINES §9.2) and still emit the status-derived type (https://setu-ts.dev/errors/404) they always did — a deprecated symbol must not silently change behavior (§9.4). Both spellings carry application/problem+json. Removal is scheduled for v1.0.0.

  • Logger is optional: errorHandler logs via ILogger resolved from ctx.services.get(CAPABILITIES.LOGGER) only when a logger is registered; otherwise logging is silently skipped.

  • maskInternalErrors (default true): an unhandled error that is not an HttpError and resolves to status ≥ 500 is masked to a generic detail before the response is built, so the failing SQL and its bound parameter values never reach the client. The real error is still written to the logger first. An HttpError (a message the developer chose for the caller) and every 4xx pass through unmasked, and maskInternalErrors: false restores the previous body verbatim. This is a behaviour change from the previous release — see the CHANGELOG.

  • includeStackTrace is config-supplied: pass config.get('NODE_ENV') === 'development' — never read process.env directly. The stack trace is secondary to the message, which is what carries the failing statement and its bound parameters; maskInternalErrors (default true) is what guards that message, not includeStackTrace. Masking wins over this option: a masked error carries no stack in the body even when this is true, because a stack begins with the very message that was masked. Set maskInternalErrors: false to see an internal error’s stack.

  • Short-circuit: when next() throws, errorHandler produces a response (HandlerResult) without re-invoking next().


API Reference: @setu-ts/di-plugin

Optional dependency injection container plugin. Registers an IContainer under CAPABILITIES.DI_CONTAINER. The service registry remains the primary resolution mechanism; this container is a convenience layer for constructor injection, lifetimes and disposal. No other plugin depends on it. Implemented in Milestone 8; compiled graph, boot validation and the request scope in Milestone 125. This section is the authoritative export list (AI_GUIDELINES §10.5). All exports carry full JSDoc.

Values (di-plugin exports)

ExportKindPurpose
DiPluginfunctionPlugin factory — registers IContainer under CAPABILITIES.DI_CONTAINER, closes it at the end of bootstrap
ContainerBuilderclassFluent builder for configuring and creating a DiContainer
createContainerfunctionConvenience factory for a standalone IContainer
DiContainerclassThe IContainer implementation; adds close(), which ends registration and validates the graph
CircularDetectorclassCycle detector used by the graph compiler’s walk (exported for testing and advanced use)
ProviderRegistryclassToken-keyed provider store with hierarchical lookups
ContainerValidationErrorclassThrown when a compile or close() finds missing, cyclic or captive dependencies; carries findings
ScopeRequiredErrorclassThrown when a scoped token, or a transient reaching one, is resolved on the root container
ScopeDisposedErrorclassThrown by resolve on a disposed scope
ContainerClosedErrorclassThrown by register on a closed root container
RequestScopeUnavailableErrorclassThrown by forRequest when the request context has no defer (for example one from createTestContext)

ScopeManager was removed in 0.11.0: the compiled graph keeps singletons on their compiled entries and scoped instances in a per-scope slot array.

Types

ExportKindPurpose
DiPluginOptionstypeOptions for DiPlugin() ({ defaultScope?, autoRegister? })
ContainerConfigtypeConfiguration for DiContainer constructor
ExternalResolvertypeSubset of IServiceRegistry for auto-registration fallback
ProviderEntrytypeA provider paired with its resolved scope (internal building block)

Contract notes:

  • Optional: no plugin depends on the DI container. When DiPlugin is not registered, ctx.container is undefined and services resolve directly from the ServiceRegistry.
  • Three provider forms: ClassProvider (constructor injection via inject), FactoryProvider (a factory called with its inject dependencies), ValueProvider (pre-built value) — all defined in @setu-ts/common, with the token and inject rules in “DI container contract (M125)”.
  • Three lifetimes: singleton (one instance, shared with every scope), scoped (one instance per scope — a request’s scope comes from forRequest(ctx)), transient (new instance every resolve, tracked by the scope that built it). Default is singleton.
  • Open, then closed: a container accepts registrations and resolves while open; a resolve compiles that token’s subgraph on demand. DiPlugin closes the container after the last bootstrap hook, the moment the service registry seals; a register on the closed root then throws ContainerClosedError. A standalone container stays open until its owner calls close().
  • Boot validation: closing compiles every provider into a direct factory and validates the whole graph once — a missing dependency, a cycle (reported A → B → A), and a singleton reaching a scoped provider directly or through transients — throwing one ContainerValidationError that lists every finding. Validation instantiates nothing. A factory without inject is opaque; DiPlugin lists such tokens in one debug line.
  • Compiled resolution: no resolve runs the cycle detector or looks up a dependency by token; a top-level resolve(token) reads one Map. Singletons are still created lazily, on first resolve.
  • Scopes: createScope() and forRequest(ctx) return child containers that share singletons with their parent. A scope may register tokens its parent chain does not have, and they live as long as the scope (a singleton lifetime there is stored as scoped); registering one it has throws, because the parent’s compiled graph cannot be rebound from below.
  • Request scope: forRequest(ctx) creates the scope on first call for that request, keeps it in ctx.slot under a key private to the container (a context without slot falls back to a per-container WeakMap), and registers its disposal with ctx.defer. A request that never calls it pays one undefined check and stays on the kernel’s synchronous path. A scope allocates its own registry only when it registers a token, and instances of its own registrations are held by the scope that resolves them, so request volume never grows anything the root holds.
  • Disposal: ProviderOptions.dispose runs from dispose() — for a request scope when the request finishes, for the root in DiPlugin’s onClose hook. Each asynchronous dispose is bounded at 5000 ms, on the runtime’s timers when a runtime is registered (ContainerConfig.timing); one that never settles is reported in the AggregateError and the rest still run, so app.stop() is never held indefinitely. The bound is per disposal, not a total: k disposals that never settle take about k × 5000 ms, which can exceed the 10 s shutdown drain. A request scope reports each disposal failure itself, through DiPlugin’s logger at warn (ContainerConfig.reportDisposeError), whenever it settles — also after the kernel’s 5000 ms cleanup bound has already reported its own timeout.
  • Auto-registration (autoRegister, default true since 0.9.0): a token not in the container falls back to the kernel’s ServiceRegistry, checked with has() during validation and resolved lazily once, then cached as a singleton; false confines resolution to the container’s own registrations. Explicit DI registrations always take precedence, and framework capability tokens (CAPABILITIES.LOGGER, etc.) work as dependencies without pre-registration.
  • No runtime-specific APIs: the container uses no Date.now(), crypto.*, or process.* — it is pure TypeScript and runtime-independent.

API Reference: @setu-ts/decorator-plugin

Optional decorator and metadata system plugin. Provides NestJS-style decorators as syntactic sugar over the kernel’s programmatic API. Decorators capture metadata in a plain MetadataStore (no reflect-metadata); the DecoratorPlugin reads that store at registration and registers routes, services, and middleware with the kernel. The store is published under CAPABILITIES.METADATA_STORE so ctx.metadata resolves to it. Decorators are inert unless the DecoratorPlugin is registered — they write to the shared singleton regardless, but only the plugin reads it. Implemented in Milestone 9; this section is the authoritative export list (AI_GUIDELINES §10.5). All exports carry full JSDoc.

TC39 standard decorators — no compiler option required. The surface needs no experimentalDecorators, no emitDecoratorMetadata, and no compilerOptions entry of any kind: Deno and Bun parse standard decorators unconfigured. Declaring an option leaves Deno’s other defaults in force (measured on Deno 2.9.6), so an existing compilerOptions block needs no edit either. Node needs a transform (tsx) because V8 has not shipped decorators.

The standard proposal has no parameter position, so handler arguments are declared at the method level with @Params(...) and constructor dependencies at the class level with @Inject(...); both are positional, the Nth entry binding the Nth argument. @Params is checked against the handler’s own signature, which the legacy parameter decorators never were.

Importing the package installs Symbol.metadata when the runtime lacks it. Node 24 and Bun 1.4 do not define the well-known symbol, so TypeScript’s decorator emit hands every decorator context.metadata === undefined. Evaluating @setu-ts/decorator-plugin sets Symbol.metadata ??= Symbol.for('Symbol.metadata') once, at module load — a write-if-absent global mutation, the one module-level exception to AI_GUIDELINES §11.5 (maintainer-approved for M111a). On Deno, which defines the symbol, it is a no-op. The registered key is the one tsx, esbuild and Bun’s compiler already use, so two copies of the package and those transforms agree on one carrier. A decorated class evaluated before the package is imported — or a bundle that dropped the import as side-effect-free — fails at class definition with a named DecoratorMetadataUnavailableError whose message gives both remedies, rather than a bare TypeError; the package manifest declares no sideEffects: false, and the Node manifest generator refuses one that would let a bundler drop the installing module.

Values (decorator-plugin exports)

ExportKindPurpose
DecoratorPluginfunctionPlugin factory — registers MetadataStore, routes/services, explicitly activated @Module trees, and classes explicitly listed in ingress during onInit
MetadataStoreclassIMetadataStore implementation (the concrete store)
metadataStorevalueThe process-wide singleton decorators write to and the plugin reads
ControllerfunctionClass decorator — base path prefix
VersionfunctionClass decorator — API version prefix
Get/Post/Put/Patch/Delete/Head/OptionsfunctionHTTP method decorators
ProcessorfunctionMethod decorator — registers a bound handler with IQueue.process(name, handler, options) during onInit
Cron/EveryfunctionMethod decorators — schedule the bound handler by its method name through IScheduler.cron() / every() during onInit
OnEventfunctionMethod decorator — subscribes the bound handler to an IEventBus event type during onInit
SubscribefunctionMethod decorator — subscribes the bound handler to an IMessageBroker topic during onInit
Gateway with OnOpen/OnMessage/OnClosefunctionClass and method decorators — assemble one IWebSocketService.route() declaration from a gateway class during onInit
CommandHandler/QueryHandlerfunctionMethod decorators — register bound ICommandHandler / IQueryHandler objects during onInit
UseIngressBehaviors/UsePipelineBehaviorsfunctionMethod decorators — scope IIngressBehavior to one supported non-HTTP ingress handler, or IPipelineBehavior to one command/query handler; wrong decorator category is refused at startup
ParamsfunctionMethod decorator binding handler arguments to sources, positionally; type-checked against the handler’s signature
ServicefunctionParameter source for @Params(...) (M125): Service<T>(token) resolves token from the request’s DI scope (container.forRequest(ctx)) on every call, so a singleton controller reaches scoped services per request. token is any ContainerToken<T>. Without a registered DI container it is reported at startup as unresolvable. A controller constructor that reaches a scoped service fails the boot, and its error names this source
InjectablefunctionClass decorator — marks a class for DI registration
InjectfunctionClass decorator declaring constructor injection tokens, one per argument in argument order
OptionalfunctionWraps a token inside @Inject(...); that argument receives undefined when the token has no provider
Roles/PermissionsfunctionClass/method decorator — authorization requirements. ENFORCED when enforceRoles is not false: each decorated route gets middleware resolving CAPABILITIES.AUTHORIZATION per request, answering 401 without a principal and 403 on a failed check with the same bodies the equivalent @UseGuards(requireRole(...)) spelling produces. With no authorization provider the route FAILS CLOSED — 501, never served unguarded — and DecoratorPlugin warns once per route. Method-level declarations override class-level; a route carrying both @Roles and @Permissions requires (any role) AND (any permission)
PublicfunctionMethod decorator — contributes security: [] only for a route without an enforced @Roles/@Permissions restriction. It does NOT exempt a route from a guard or from enforcement; a restricted route keeps its derived OpenAPI security requirement
UseGuards/UseInterceptors/UseFiltersfunctionClass/method pipeline decorators
ValidateBody/ValidateQuery/ValidateParamsfunctionMethod decorators — attach validation schemas. ENFORCED when a CAPABILITIES.VALIDATION provider is registered and enforceSchemas is not false: the capability’s middleware is appended LAST in the route’s chain (after guards), answering 400 before the handler while preserving guard 401/403 precedence. Without such a provider the schemas stay description-only and DecoratorPlugin logs one warning per affected route
RenderfunctionMethod decorator — attaches a view component to the route; the handler returns the component’s PROPS bag and the framework answers text/html; charset=utf-8, never JSON. The handler’s return is type-checked against the component’s props. The engine is resolved from CAPABILITIES.VIEW once at register() (the token joins optionalDependencies), and a rendered route with no provider FAILS at register() naming the controller, the handler and both remedies. A FIXED status or header alongside a rendered body is declared with @HttpCode/@ResponseHeader; a computed one goes through @Params(Ctx()) (M92, M97b)
HttpCodefunctionMethod decorator — the success status a handler answers with when it returns a plain value. Written to the response builder BEFORE the method runs, so a returned HandlerResult (ctx.response.status(202).json(...)) still wins and a @Render route composes with it. Must be an integer in [200, 599], refused at register() otherwise; NOT narrowed to 2xx. 204/205/304 serve bodiless. Derived into the OpenAPI document via deriveResponseStatus (M97b)
ResponseHeaderfunctionMethod decorator — one fixed response header; repeatable for DISTINCT names. The same name twice, an invalid name or value, or Location alongside @Redirect are each refused at register(). A multi-valued header wants ctx.response.appendHeader(...) through @Params(Ctx()). Deliberately NOT derived into the document: an OpenAPI response-header entry needs a schema and description the declaration does not carry (M97b)
RedirectfunctionMethod decorator — sets the status and Location; 302 by default. The status must be an integer in [300, 399], and the target must be non-blank and a value the runtime will carry as a Location header — both refused at register(), because an invalid header value throws while the response headers are written (500 on every request) and a blank one serves a redirect no client can follow. It does NOT short-circuit: a decorator cannot decline to call the method, so the handler still runs and a plain return is still serialised. One handler may not carry both @Redirect and @HttpCode, since both set the status. Derived into the document as its 3xx (M97b)
IdempotentfunctionMethod decorator — marks an UNSAFE route idempotent. Its middleware is appended LAST in the route’s chain, after guards, declarative authorization and the validation band, so a refused or invalid request consumes no key. Requires a CAPABILITIES.IDEMPOTENCY provider: without one register() fails naming the controller and handler, and @Idempotent on a safe method (GET/HEAD/OPTIONS) is refused too. The stored response is replayed by whichever store the provider serves (M109a)
Policy / Ability / RequirePolicyfunctionSince M110a. @Policy(name) marks a class as an authorization policy; @Ability({ anonymous? }) marks each ability method (a method named before is the policy’s before hook). DecoratorPlugin({ policies }) constructs each class like a controller — with constructor injection — and registers it with AuthPlugin’s IAuthorizationPolicyService before any controller. @RequirePolicy(policy, ability, target?) (repeatable, all-of, top to bottom) takes a @Policy class or a definePolicy definition and requires the ability on a route: 501 with no policy service, 401 / 403 on a denial, the same bodies as AuthPlugin’s requirePolicy
ScopedRoles / ScopedPermissionsfunctionSince M110b. Class/method decorators requiring catalogue roles (any-of) or permissions (all-of) in the request’s scope, over AuthPlugin’s built-in scoped-rbac policy; the optional second argument is a ScopeSource (default scopeFromTenant()). Validated at register(); 501 / 401 / 403 like requireScopedRole / requireScopedPermission
RenderDecoratortypeThe type Render returns: a standard method decorator whose value parameter is narrowed to (...args: never[]) => P | HandlerResult | Promise<P | HandlerResult>
ApiTagsfunctionClass decorator — OpenAPI tags
ApiOperation/ApiResponsefunctionMethod decorators — OpenAPI operation metadata
ModulefunctionClass decorator grouping controllers, providers, and imported modules for DecoratorPlugin({ modules })
createDecoratorfunctionCustom class/method decorator factory
Body/Query/Param/Header/Cookie/CurrentUser/CtxfunctionBuilt-in parameter SOURCES, declared inside @Params(...); Ctx yields the active IRequestContext without reserving the application custom type name context
CustomfunctionDeclares a parameter source resolved by a resolver registered under the same name
resolveParametersfunctionResolves an ordered argument array from parameter metadata
resolveParameterfunctionResolves a single parameter value
registerParameterResolverfunctionRegisters a resolver for a custom parameter type
getParameterResolverfunctionLooks up a custom parameter resolver
clearParameterResolversfunctionClears the custom resolver registry (tests)
parseCookiesfunctionParses a Cookie header into a name→value record
discoverControllersfunctionAuto-discovers decorated classes from a directory

Types

ExportKindPurpose
DecoratorPluginOptionstypeOptions for DecoratorPlugin() (autoDiscover?, controllersPath?, controllers?, services?, ingress?, modules?, enforceSchemas?, enforceRoles?, policies?)
AbilityOptions / PolicyClassAbility / PolicyClassTargettypeSince M110a. @Ability’s options, and the helpers typing @RequirePolicy’s ability (a policy class’s method names) and target (read off the method’s second parameter)
InjectableOptionstypeOptions for @Injectable() (scope?, token?)
ModuleOptionstypeOptions for @Module() (controllers?, providers?, imports?; no exports)
ApiOperationConfigtypeConfig for @ApiOperation() (operationId?, summary?, description?); all three fields reach RouteSchema; OpenAPI uses a declared id verbatim and emits the description
ApiResponseConfigtypeConfig for @ApiResponse() (status, description?, schema?)
HttpMethodDecoratortype(path?: string) => SetuMethodDecorator
ParamSourcetypeOne entry in a @Params(...) declaration; carries the resolved value type
SourceValuestypeMaps a source tuple onto the handler parameter tuple it binds
InjectTokentypestring | OptionalToken — one entry in an @Inject(...) list
OptionalTokentypeA token wrapped by Optional(...), marking that argument absent-tolerant
SetuClassDecoratortypeA standard class decorator that records metadata and leaves the class unchanged
SetuMethodDecoratortypeA standard method decorator that records metadata and leaves the method unchanged
SetuClassOrMethodDecoratortypeA standard decorator valid in either position, discriminating on context.kind
MiddlewareLiketypeMiddlewareFunction | (new () => IMiddleware) — accepted by pipeline decorators
CustomParameterResolvertype(ctx, metadata?) => unknown | Promise<unknown>
ParameterMetadatatypeParameter metadata captured by a @Params(...) source
ParameterTypetype'body' | 'query' | 'param' | 'header' | 'cookie' | 'custom'
DiscoveryOptionstypeConfig for discoverControllers() (path, extensions?, exclude?)
DiscoveryResulttypeResult of discovery (controllers, services, errors)
ModuleImportertype(specifier: string) => Promise<unknown> — injectable module loader

Contract notes:

  • Inert without the plugin: decorators write to the metadataStore singleton at class-definition time regardless of whether the plugin is registered. Only DecoratorPlugin.register() reads the store and calls the kernel APIs; without it, no routes/services/middleware are registered.

  • Modules are activation groups, not DI boundaries: DecoratorPlugin({ modules }) flattens imports depth-first, registers imported providers before the importing module’s controllers, and deduplicates classes by identity. A class passed as a module without @Module metadata logs a warning and contributes nothing. There is no exports option because application service visibility is not module-scoped.

  • Validation schemas are enforced, not just described (enforceSchemas, default true): for each of schema.body/query/params present on a route, registerController resolves CAPABILITIES.VALIDATION and appends that capability’s middleware LAST in the route’s chain — innermost, after guards, so an unauthenticated request is refused by its guard rather than told 400 with a body that names the schema’s field paths. With no validation provider registered, a decorated schema stays description-only (OpenAPI) and ONE warning per route names the controller, handler, affected targets and ValidationPlugin; nothing throws. enforceSchemas: false keeps schemas description-only and silences the warning.

  • @Roles/@Permissions are enforced, not just described (enforceRoles, default true): registerController appends one middleware per present restriction — roles first, so a route carrying both kinds is refused by the one that failed — after guards and BEFORE interceptors, ordinary middleware, filters, and validation, so no later stage can short-circuit a declared restriction. The middleware resolves CAPABILITIES.AUTHORIZATION PER REQUEST, so a provider registered after register() is honoured; with none, the route answers 501 Not Implemented / "Authorization is not configured" and register() warns once per affected route naming the controller, handler, restriction and both remedies (register a provider, or enforceRoles: false). enforceRoles: false keeps the metadata description-only and silences the warning — the pre-M89a behaviour. The appended middleware is M57-branded, so deriveSecurity documents decorated routes.

  • @RequirePolicy is validated at register() and always enforced (M110a). Each @RequirePolicy appends one middleware AFTER @Roles/@Permissions and BEFORE interceptors, ordinary middleware, filters and validation — so a target extractor reading the body sees the UNVALIDATED body. register() throws, naming the route, when no CAPABILITIES.AUTHORIZATION_POLICIES provider is registered or when the named policy or ability is not registered (an ordinary method of a policy class type-checks and is refused here), or when the referenced class or definition does not declare that ability with the registered policy’s anonymous flag (a different policy sharing the registered one’s name — the same refusal requirePolicy meets at startup). An anonymous request to a non-anonymous ability is refused before the target extractor runs. enforceRoles does not govern it. Listing policies with no provider, a class without @Policy, or one without an @Ability() method throws too. The middleware is branded authenticated unless the ability is anonymous, and a route’s @Public OpenAPI marker is omitted when any @RequirePolicy requires a principal. The two packages may not import each other, so @RequirePolicy and requirePolicy are two thin middlewares over one service, pinned together by a parity test.

  • @ScopedRoles / @ScopedPermissions are validated at register() and always enforced (M110b). @ScopedRoles(roles, scope?) (any-of) and @ScopedPermissions(permissions, scope?) (all-of) are the class form of AuthPlugin’s requireScopedRole / requireScopedPermission; scope is a ScopeSource defaulting to scopeFromTenant(). Class level sets a default, method level overrides it, per decorator. Each appends one middleware — roles, then permissions — AFTER @Roles/@Permissions and @RequirePolicy. An empty list or a non-string name throws at decoration time; register() throws, naming the route, when no CAPABILITIES.AUTHORIZATION_POLICIES provider is registered or a name is outside the scoped RBAC catalogue (so AuthPlugin({ rbac, scopedRbac }) is required, and a per-scope custom role cannot be named). Per request: 501 with no policy service, 401 for an anonymous request before the scope source runs, 403 on a deny; an unresolved scope denies. The middleware is branded authenticated, and the route’s @Public OpenAPI marker is omitted. Refusal bodies are byte-identical with the guards’, pinned by a parity test under errorHandler({ format: 'rfc9457' }).

  • Body()/Query()/Param() read the VALIDATED value when one exists. Each checks ctx.state under validatedStateKey(target) first — presence-tested with has, so a validated null or 0 is honoured — and falls back to the raw source when absent. A Zod transform or default therefore reaches the handler instead of being discarded. Declare that parsed type on the source — Body<T>(), Query<T>() — since the source cannot infer it from the schema. Header() and Cookie() deliberately read their raw sources: headers resolve case-insensitively through headers.get(name), which the validated record would break, and no schema key exists for cookies. An absent header or cookie resolves to undefined, matching their string | undefined type.

  • No reflection: metadata is stored in plain Maps keyed by class reference, not via Reflect.getMetadata(). No reflect-metadata dependency.

  • Decorator composition: cross-cutting decorators (@Params, @ValidateBody, @Roles, …) run before the HTTP-verb decorator; the store accumulates per-method and derives one RouteMetadata per (method, HTTP verb) at read time, so metadata is correct regardless of application order. Class-level guards/interceptors/middleware run before method-level; method-level @Roles/@Permissions override class-level; @Public sets the isPublic flag (OpenAPI security: [] only for unrestricted routes — no guard bypass).

  • Handler return values: a controller method either returns a value (serialized as JSON by the plugin’s handler wrapper) or returns a HandlerResult from ctx.response.*.

  • Discovery: discoverControllers walks via IRuntimeServices.fs (absent on edge platforms → empty result with a warning) and loads modules with await import() (no require/eval). Snapshot-diff against the store attributes newly-decorated classes to each file. Discovery failures never crash the application.

  • Ctx() response control: Ctx resolves the live IRequestContext, so a decorated handler can configure ctx.response (status, headers, or a stream) and return its HandlerResult; it is a built-in custom parameter type and needs no resolver registration. It is recognised by a marker registered with Symbol.for, so it keeps working if two copies of the package share a process, and an application’s own Custom('context') still reaches its own resolver.

  • Controllers and the DI container: controllers are built once, during register(), and are registered in the container as singleton whatever its defaultScope (M125). A scoped service reaches a handler through @Params(Service(token)); an @Injectable class is also registered under the class itself, so resolve(UserService) returns the same instance as its string token.

  • Startup diagnostics: when a logger is registered, DecoratorPlugin.register() warns (never throws) about two silent misconfigurations. A class passed in controllers that carries no @Controller metadata registers no routes — the usual cause is two copies of the package, where decorators populate one copy’s metadata store while the plugin reads the other’s, so every route 404s. And a custom parameter with no matching resolver reaches the handler as undefined; the warning names the controller, handler, and parameter index. The parameter check reflects the resolvers registered when the plugin registers, so call registerParameterResolver before app.start().

  • Custom decorators: createDecorator records class/method metadata replayed against DecoratorHandlers registered via ctx.decorators.register() (collected under CAPABILITIES.DECORATOR_HANDLER). Custom(name, metadata?) declares a parameter source resolved by resolveParameters via registerParameterResolver; the context and current-user built-ins resolve directly to ctx and ctx.request.user, respectively.

  • @Inject is a class decorator, and a token is always required. The list is positional: the Nth entry names the Nth constructor argument. The TC39 proposal has no parameter position, so there is nowhere else to put it.

    @Injectable({ token: 'user-repository' })
    @Inject(CAPABILITIES.DATABASE, CAPABILITIES.LOGGER)
    class UserRepository {
      constructor(
        private db: IDatabase,
        private logger: ILogger,
      ) {}
    }

    Wrap an entry in Optional(...) to let that argument receive undefined when the token has no provider. Optional means the dependency is absent, not that construction may fail: an error raised while building a token that IS provided propagates rather than being swallowed.

    @Injectable()
    @Inject(CAPABILITIES.DATABASE, Optional(CAPABILITIES.CACHE))
    class ReportService {
      constructor(
        private db: IDatabase,
        private cache?: ICacheStore,
      ) {}
    }

    A token cannot be inferred from the parameter’s type: that needs emitDecoratorMetadata, which Deno does not support, and no source in this repo reads design:paramtypes. A list shorter than the constructor leaves the trailing arguments undefined — a positional list cannot have gaps, so the legacy “parameter N has no @Inject token” and “declares both @Inject forms” refusals no longer have any reachable input and are gone. Method parameters bind with @Params(...).

    The list is read in declaration order and reaches the constructor in that order. The legacy index-keyed assembly is gone with the parameter form it existed for: parameter decorators evaluated in REVERSE argument order, so tokens had to be stored by index and re-sorted.

  • The container is preferred whenever the class is registered in it, with or without @Injectable. A @Controller carries no @Injectable, so a constructor-injected controller in a DiPlugin application resolves through the container — where its dependencies live.

  • Optional(token) marks an injected dependency absent-tolerant, not construction fallible. It wraps an entry inside the class-level @Inject(...) list, in the position of the argument it describes, and never replaces the token — one is still required, for the same emitDecoratorMetadata reason above:

    @Injectable({ token: 'report-service' })
    @Inject(CAPABILITIES.DATABASE, Optional(CAPABILITIES.CACHE))
    class ReportService {
      constructor(
        private db: IDatabase,
        private cache?: ICacheService,
      ) {}
    }

    When the token has no provider the argument receives undefined; when it HAS one it is resolved normally, so an error raised while building it — a circular dependency, a throwing factory — propagates rather than being masked as absence. Optional cannot be misplaced any more — it is an argument to @Inject(...) rather than a decorator, so the three legacy refusals it needed have no reachable input and are gone. One throw remains, for a caller writing to the store directly: an optional index that the @Inject(...) list names no token for is refused at register(), since it would otherwise pass undefined for an argument nothing declares.

    Both construction paths honor it identically — the DI container when one is registered, the kernel’s service registry otherwise. On the container path a class carrying @Optional registers as a useClass provider whose inject entries mark the optional positions ({ token, optional: true }, M125), so its dependencies resolve from the scope that resolves it — a request scope included — and boot validation sees them.

  • No runtime-specific APIs: the package uses no Date.now(), Deno, process, or fs — all file/time operations go through IRuntimeServices.


Testing Package (@setu-ts/testing)

First-party testing utilities for the Setu-TS framework: a test application factory, mock plugin builder, request injector, mock request context, service registry double, fixture manager, and streaming-response reader.

Exports

ExportFileDescription
createTestAppsrc/test-app.tsTest application factory
TestAppOptionssrc/test-app.tsFactory options (union of the two arms below)
TestAppFromPluginssrc/test-app.tsHand-assembled arm
TestAppFromAppsrc/test-app.tsComposition-root arm
overrideCapabilitysrc/override-capability.tsCapability replacement plugin builder
overrideProvidersrc/override-provider.tsDI container provider replacement builder
createMockPluginsrc/mock-plugin.tsMock plugin builder
MockPluginOptionssrc/mock-plugin.tsBuilder options
collectStreamsrc/inject.tsCollect streaming response body
injectsrc/inject.tsInject HTTP requests into test app
StreamingBodysrc/inject.tsStream collector result shape
createTestContextsrc/mock-context.tsCreate a mock IRequestContext
TestContextOptionssrc/mock-context.tsContext builder options
MockResponsesrc/mock-context.tsFake IResponse double
MockServiceRegistrysrc/mock-registry.tsFake IServiceRegistry double
FixtureManagersrc/fixtures/fixture-manager.tsAssemble mock plugins per-test
IKernelApplication(re-export from @setu-ts/kernel)Kernel application interface
InjectRequest(re-export from @setu-ts/kernel)Shape for inject() request
InjectResponse(re-export from @setu-ts/kernel)Shape for inject() response

Registration

import { createTestApp } from '@setu-ts/testing';
import { RuntimePlugin } from '@setu-ts/runtime';
import { DatabasePlugin } from '@setu-ts/database-plugin';

const app = await createTestApp({
  plugins: [RuntimePlugin(), DatabasePlugin({ type: 'memory' })],
});

Or from the application the project already composes, which keeps the test’s composition — middleware, error handling, health indicators, route ordering — the one production has:

import { createTestApp, overrideCapability } from '@setu-ts/testing';
import { CAPABILITIES } from '@setu-ts/common';
import { createApp } from '../setu.config.ts';

const app = await createTestApp({
  app: createApp(),
  without: ['database'],
  overrides: [overrideCapability(CAPABILITIES.MAIL, fakeMailer)],
});

Options

TestAppOptions — createTestApp(options?) — is a union of two mutually exclusive arms, so supplying both plugins and app is a compile error rather than a runtime throw.

New scaffold factories return IKernelApplication (or Promise<IKernelApplication>). For an older project, replace the factory’s IApplication annotation with IKernelApplication, imported from @setu-ts/kernel. Await an async factory before passing its result to createTestApp.

TestAppFromPlugins — assemble by hand (unit scope):

OptionTypeDefaultBehavior
pluginsIPlugin[][]Pre-registered before start(). Must include a runtime capability provider (RuntimePlugin(), or a mock providing CAPABILITIES.RUNTIME) whenever autoStart is left true — the kernel requires one and throws otherwise.
autoStartbooleantruetrue calls await app.start() (no port, so no socket) before returning. false returns the un-started app — required to register further plugins or to add global middleware.

TestAppFromApp — start from the composition root:

OptionTypeDefaultBehavior
appIKernelApplicationrequiredAn already-constructed, not yet started application — typically a scaffolded project’s createApp() from setu.config.ts, or a starter factory’s return value.
withoutreadonly string[][]Plugin names dropped via IKernelApplication.unregister before start(), so each plugin’s register() — and any eager side effect in it — never runs. Throws naming an entry the app does not hold.
overridesreadonly IPlugin[][]Plugins appended after without is applied, usually overrideCapability results. Exists because the default autoStart: true leaves no window in which a caller could register them.
autoStartbooleantrueAs above.

without and overrides are not interchangeable. An override replaces a service after the real plugin’s register() has run, so an eager side effect inside it — DatabasePlugin calls adapter.connect() there — has already happened. Only without prevents that.

overrideCapability(token: CapabilityToken, service: object): IPlugin takes no options. The plugin it returns declares no provides (a second declaration of a live token is refused by the kernel before any plugin runs), registers with { override: true }, and orders itself through an optionalDependencies edge on the token plus PLUGIN_PRIORITY.HIGHEST — after the provider, whatever its band, and before an ordinary consumer. It throws during start() when nothing provides token — a mistyped token would otherwise register the double under a nonsense name and leave the real service serving — and when token is a multi-provider capability. Both checks are raised from an onInit hook rather than from the plugin’s own register(), because the override registers early: at that point a multi-provider capability has not accumulated its providers yet, and a token the override does not shadow may still be registered by a later plugin. onInit runs once every plugin has registered, and a throw there fails start() exactly as one in register() would. ServiceRegistry.getAll returns the single and multi registrations concatenated, so an override of one ADDS a provider rather than replacing the existing ones: every real provider would still run while the caller was told the capability was overridden. Detection is generic — a provider count taken after the write, not a list of known tokens — so the kernel’s five (health-indicator, metric-registration, openapi-schema, decorator-handler, cli-command) and any capability an application registers itself with { multi: true } are refused alike. The count resolves nothing that is being replaced: the override lands in the single map first, so a registerFactory provider is never constructed. Exclude the plugin that registers the provider instead.

overrideCapability is ordered after the provider and before ordinary consumers, so it reaches a consumer that resolves the capability during its own register() as well as one that resolves it per request — NotificationPlugin resolves CAPABILITIES.MAIL while registering, and an override beneath it is seen by every notification it sends. Ordering comes from an optionalDependencies edge on the token, which the resolver visits ahead of any priority number, plus an early priority that places it before a PLUGIN_PRIORITY.NORMAL consumer — so a provider in any band is replaced.

Two bounds remain. It does not undo the provider’s eager side effects: the provider’s register() has run, so a database adapter’s connect() already happened — without is what prevents that. And it requires the provider to declare the token in provides, which is what the ordering edge hangs on and how a plugin is depended upon at all; a provider that registers a capability without declaring it fails startup with Capability '<token>' is already registered. For either, exclude the provider and supply the double ahead of its consumers — without: ['mail-plugin'] plus a createMockPlugin at PLUGIN_PRIORITY.HIGH.

overrideProvider<T>(token: ContainerToken<T>, provider: Provider<T>, options?: ProviderOptions<T>): IPlugin replaces a provider in the DI container, which overrideCapability cannot reach: with DiPlugin registered, DecoratorPlugin puts each @Injectable class into the container, and the container refuses a second registration of a token. The plugin depends on CAPABILITIES.DI_CONTAINER and registers at PLUGIN_PRIORITY.NORMAL, so it runs after DiPlugin and before DecoratorPlugin (PLUGIN_PRIORITY.LOW). It registers the double on the container, then publishes a container that swallows the application’s later registration of the same token and passes every other call through, so every class that injects the token is constructed with the double. start() fails from register() when the application has no container or the real provider was registered first, and from an onInit hook when nothing registered, resolved or checked for the token, which is how a mistyped token surfaces. The token may be a string, a createToken token or a class (M125); the double also applies inside request scopes (forRequest), and dispose() passes through to the real container.

overrideCapability replaces; createMockPlugin provides. createMockPlugin declares the token in provides, which satisfies a dependent plugin’s dependencies check and which the kernel refuses when a real plugin already declares it. Use it in an application that does not register the real plugin.

Unhandled errors. Neither arm installs errorHandler — this package depends on common and kernel only, so a default would mean a second RFC 9457 formatter that @setu-ts/exceptions’ own tests do not drive. The plugins arm therefore answers the kernel’s { error, detail? } fallback; the app: arm inherits whatever responder the composition root registered, and is the answer for response-shape fidelity.

MockPluginOptions — createMockPlugin(options):

OptionTypeDefaultBehavior
namestringrequiredPlugin name, and the capability token when provides is omitted.
serviceobjectrequiredThe mock service registered under the token.
providesstringnameOverrides the token when the plugin name and capability token differ.
prioritynumberomittedRegistration priority passed to the kernel resolver.
register(ctx: IPluginContext) => void | Promise<void>omittedExtra registration (middleware, routes, hooks) run after the service is registered.

TestContextOptions — createTestContext(options?):

OptionTypeDefaultBehavior
requestPartial<IRequest>GET http://localhost/Overrides on the mock request (method, url, path, headers, ip, user, tenant, signal).
bodyunknownnoneBacks json(), text() and bytes(). A non-string body is JSON-stringified once, so all three readers agree.
runtimeIRuntimeServicesinternal monotonic fakeSupplies ctx.id (uuid()) and ctx.startTime (hrtime()).
startTimenumberruntime.hrtime() (0)Highest-precedence monotonic origin — never pass Date.now().
servicesIServiceRegistrynew MockServiceRegistry()Any implementation, including a real kernel registry from a started app.
responseIResponsenew MockResponse()The response builder on ctx.response.
paramsRecord<string, string>{}Path parameters.
queryRecord<string, string>parsed from the request URLQuery parameters.
stateMap<string, unknown>new Map()Request-scoped state.
signalAbortSignallive, never-abortingctx.signal. Precedence: request.signal > signal > default, matching the kernel.

Programmatic Testing

import { collectStream, createMockPlugin, createTestContext, inject } from '@setu-ts/testing';
import { FixtureManager, MockServiceRegistry } from '@setu-ts/testing';

// Mock a plugin service
const mockDb = createMockPlugin({
  name: 'database',
  service: { find: async () => [] },
});

// Use FixtureManager to assemble mocks
const fixtures = new FixtureManager();
fixtures.mock('cache', { get: async () => null, set: async () => {} });

// Inject a request directly
const response = await inject(app, {
  method: 'GET',
  url: '/users',
});

// Create a fake context for handler-level tests
const ctx = createTestContext();
const mockRegistry = new MockServiceRegistry();

// Register a streaming route so collectStream has data to read
app.router.get('/stream', (c) => {
  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      controller.enqueue(new TextEncoder().encode('hello'));
      controller.enqueue(new TextEncoder().encode(' world'));
      controller.close();
    },
  });
  return c.response.stream(stream);
});

// Collect a streaming body — use fetch() which returns a real web Response
const fetchRes = await app.fetch(new Request('http://localhost/stream'));
const stream = await collectStream(fetchRes);
console.log(stream.text); // → "hello world"

Notes

  • The testing package does not start an HTTP server; createTestApp returns a started kernel application that can be exercised via inject() or fetch().
  • plugins must include a runtime provider. The package depends only on common and kernel, so it cannot import RuntimePlugin to supply one for you, and the kernel treats the runtime capability as mandatory at start(). await createTestApp() with no plugins therefore rejects with No plugin provides the mandatory 'runtime' capability. Pass RuntimePlugin(), or for a dependency-free unit test createMockPlugin({ name: 'runtime', service: fakeRuntime, provides: CAPABILITIES.RUNTIME }).
  • Adding global middleware requires autoStart: false. start() compiles the pipeline, after which app.middleware.add(...) throws Cannot add middleware after the pipeline has been compiled. Routes are unaffected — app.router.get(...) works on a started app.
  • A mock providing only runtime cannot serve fetch(). The real RuntimePlugin also provides http-adapter; without it app.fetch() throws No HTTP adapter registered. inject() needs only runtime, so unit tests use inject() and fetch() assertions belong to integration/e2e tests on the real RuntimePlugin().
  • The free-function inject() accepts a web Request, and reading its body consumes it. The same Request cannot be injected twice, nor injected and then passed to app.fetch() — the second call throws and names the cause instead of silently sending no body. Build a separate Request per call.
  • inject() returns body: string | null, where a byte body from response.send(bytes) is UTF-8 decoded and null means the response genuinely had no body. A streaming response cannot be rendered as a string without draining the live stream, so inject() throws for one — read those through app.fetch() plus collectStream instead.
  • createTestContext uses a built-in monotonic fake runtime (hrtime: () => 0) by default — inject your own IRuntimeServices when you need controllable timing. Its timers are inert no-ops, so a fixture can never leak a callback past the test that created it.
  • MockServiceRegistry and MockResponse reproduce their kernel counterparts’ observable semantics (factory caching, single-vs-multi registration and getAll merge order, override policy, the verbatim not-registered message, content-type defaults, and the snapshot() discriminated union) so a test cannot pass against the double and fail against the real thing.

SDK — Client SDK (@setu-ts/sdk)

Portable, zero-npm-dependency client SDK for consuming Setu-TS APIs from browsers and servers. Does not register a plugin or resolve capability tokens — it is an external-consumer library.

Installation

npm install @setu-ts/sdk@^0.11.0

createClient()

Factory that returns an IHttpClient. Requires a base URL; accepts default headers, an injectable fetch seam, a timing seam, resilience policies, rate-limit policy, and interceptor arrays.

ClientRequest.path must be relative — no leading slash, no absolute URL. A leading-slash path would discard baseUrl’s own path prefix, and an absolute URL would leave baseUrl’s origin entirely, which is what makes the per-origin breaker and rate limiter meaningful. A path that violates the rule throws ClientRequest.path must be relative (no leading slash). at request time.

import { createClient } from '@setu-ts/sdk';

const client = createClient({
  baseUrl: 'https://api.example.com',
  headers: { 'X-Trace-Id': 'abc' },
});

const res = await client.request<User>({
  method: 'GET',
  path: 'users/123',
});
console.log(res.data); // User

IHttpClient

The public client interface. Its single generic method is request<TResponse, TBody>.

interface IHttpClient {
  request<TResponse, TBody = unknown>(
    request: ClientRequest<TBody>,
  ): Promise<ClientResponse<TResponse>>;
}

ClientOptions

interface ClientOptions {
  baseUrl: string;
  headers?: Record<string, string>;
  fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
  timing?: IClientTiming;
  retry?: ClientRetryPolicy;
  circuitBreaker?: CircuitBreakerPolicy;
  rateLimit?: ClientRateLimitPolicy;
  idempotency?: ClientIdempotencyOptions;
  requestInterceptors?: ClientRequestInterceptor[];
  responseInterceptors?: ClientResponseInterceptor[];
}

ClientRetryPolicy adds the client-side server-hint cap to the shared retry policy:

type ClientRetryPolicy = RetryPolicy & {
  readonly maxRetryAfterMs?: number;
};
OptionConsumerBehavior
baseUrlHttpClient URL resolverRequired base for every relative ClientRequest.path
headersHttpClient request builderCloned into each request; request-specific values win
fetchHttpClient transportCalled after policy gates; defaults to global fetch
timingretry, breaker, limiterOptional; defaults to createDefaultClientTiming()
retryretry strategylimit must be a positive safe integer; every configured or derived delay must be finite, non-negative, and at most 2_147_483_647 ms
maxRetryAfterMsretry strategyClientRetryPolicy member; same timer-safe bound. A larger Retry-After surfaces the response error immediately, without sleeping or retrying
circuitBreakerorigin breaker mapthreshold < 1 throws at construction
rateLimitorigin limiter mapNon-positive maxRequests/windowMs throws
requestInterceptorsrequest pipelineRun once in array order before resilient execution
responseInterceptorsresponse pipelineRun in array order after successful parse; skipped on failure

When maxRetryAfterMs is absent, the cap is the policy’s largest computed backoff: delay for fixed backoff, or delay * 2 ** (limit - 1) for exponential backoff. A Retry-After at or below the effective cap replaces the computed delay. Policy validation rejects runtime strings and non-finite or timer-overflowing values so the derived cap cannot fail open or be clamped into an immediate retry by the runtime.

ClientRequest

interface ClientRequest<TBody = unknown> {
  method: string;
  path: string;
  query?: Record<string, string | string[]>;
  headers?: Record<string, string>;
  json?: TBody;
  idempotencyKey?: string;
  signal?: AbortSignal;
}

ClientRequest.idempotencyKey supplies a caller-owned key. ClientOptions.idempotency enables generated keys through ClientIdempotencyOptions: methods?: readonly string[] defaults to ['POST', 'PATCH'], header?: string defaults to 'Idempotency-Key', and generateKey?: () => string defaults to 32 lowercase hex characters from 16 random bytes. Methods must be 1–16 HTTP tokens and the header must be an HTTP token. Default key generation requires crypto.getRandomValues at construction. Both supplied and generated keys are validated before network I/O: 1–255 characters from 0x21–0x7E, excluding ".

One key is set before request interceptors and reused for every retry attempt. A static default key header is refused when idempotency is configured, as is a supplied key conflicting with a merged request header. When idempotency is configured and a request in one of its methods already carries the configured header, that header’s value is validated and used as the key, and no key is generated — the path a generated operation takes when its document declares the header. Without idempotency, merely setting the header does not opt into keyed retries. With a retry policy, a keyed request can retry any method, including 409, as well as the existing 408/425/429/5xx and transport failures. A keyed request outside the safe method set (for example, POST or PATCH) never retries a parsing or interceptor failure after a 2xx arrives. Safe methods retain their existing retry behavior. Without either idempotency option, retry behavior is unchanged.

Keyed POST/PATCH retries are safe ONLY when the server de-duplicates on that key, for example with idempotent() or within. Against a server that does not, a retried POST can execute twice. A keyed request carries a non-safelisted header, so a cross-origin browser call triggers a CORS preflight; the server must list the configured header (default Idempotency-Key) in Access-Control-Allow-Headers.

OperationRequestOptions

interface OperationRequestOptions {
  readonly idempotencyKey?: string;
  readonly signal?: AbortSignal;
}

The per-call options every generated operation takes as its optional trailing request parameter, after the path parameters and after opts. idempotencyKey and signal carry the same meaning as the same-named fields of ClientRequest, and the generated code forwards only those two fields. headers is deliberately absent: header parameters the document declares are already typed fields of the operation’s opts, and a free header map would conflict with the key header.

ClientResponse

interface ClientResponse<T> {
  status: number;
  headers: Headers;
  data?: T;
}

Interceptors

type ClientRequestInterceptor = (ctx: ClientRequestContext) => void | Promise<void>;
type ClientResponseInterceptor<T> = (
  response: ClientResponse<T>,
  request: ClientRequestContext,
) => ClientResponse<T> | Promise<ClientResponse<T>>;

Request interceptors receive a mutable ClientRequestContext (resolved URL and Headers) and execute once in registration order before the outbound attempt sequence. Response interceptors receive a successful ClientResponse<T> and its immutable request description; they are skipped entirely when the request throws.

createTraceContextInterceptor(source) reads source.activeSpanContext?() per request, validates the W3C identifiers, and sets traceparent unless the caller already supplied it. Missing or invalid active contexts set nothing.

IClientTiming and createDefaultClientTiming()

interface IClientTiming {
  now(): number;
  sleep(ms: number, signal?: AbortSignal): Promise<void>;
}

function createDefaultClientTiming(): IClientTiming;

now() uses performance.now() (monotonic); sleep() uses setTimeout with an abort listener. Inject a deterministic implementation in tests.

createObservedFetch() (M98n)

interface ObservedFetchOptions {
  readonly alias: string; // 1–64 UTF-8 bytes, no control characters
  readonly fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
  readonly timing?: Pick<IClientTiming, 'now'>;
}
interface ObservedFetch {
  readonly fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
  readonly plugin: IPlugin;
}
function createObservedFetch(options: ObservedFetchOptions): ObservedFetch;

Opt-in outbound attempt counters for the local diagnostics connector. fetch defaults to the SDK’s call-time globalThis.fetch; timing defaults to createDefaultClientTiming() and is always called as timing.now(), probed once at construction. The wrapper forwards the caller’s arguments unread and its receiver unchanged — except the helper object itself, forwarded as undefined — calls the wrapped fetch exactly once, and returns the identical value or rethrows the identical value; a synchronous throw stays synchronous (a deliberate departure from the framework’s promise rule, so wrapping never changes application behavior). It reads only the resolved value’s status. Register plugin in exactly one application: it registers the source under CAPABILITIES.OUTBOUND_HTTP_DIAGNOSTICS, closes the helper on shutdown, refuses another application (the same application registering it again reopens the source rather than refusing — though the kernel currently refuses a retried start() of any application registering RuntimePlugin, so this path is not a working recovery). Its name is outbound-http-diagnostics-<32 hex>, drawn from crypto.getRandomValues (required; Node ≥ 19). Construct the helper only when a devtool composition is present. Construction refusals are fixed messages that never echo a value.

ClientRateLimitPolicy

interface ClientRateLimitPolicy {
  maxRequests: number;
  windowMs: number;
}

Per-origin sliding-window limiter. When the window is full, the client waits until the oldest retained timestamp expires.

Re-exported policy types

RetryPolicy, CircuitBreakerPolicy, and BackoffStrategy are re-exported from @setu-ts/common so consumers can name their types without adding common to their own manifest.

Authentication interceptors

function createBearerAuthInterceptor(
  token: string | (() => string | Promise<string>),
): ClientRequestInterceptor;

function createApiKeyAuthInterceptor(
  key: string | (() => string | Promise<string>),
  headerName?: string,
): ClientRequestInterceptor;

createBearerAuthInterceptor sets Authorization: Bearer <token>. createApiKeyAuthInterceptor sets the header named X-API-Key (default) or headerName. Each accepts a literal or an async value provider and sets its header only when the request has not already supplied that header.

Errors

ErrorWhen thrownFields
HttpClientErrorNon-2xx HTTP responsestatus, headers, body
ClientCircuitOpenErrorCircuit breaker is open for the origin(none)
OpenApiCodegenErrorInvalid OpenAPI document during codegenpath, method

ClientCircuitOpenError is named distinctly from the resilience plugin’s CircuitOpenError to avoid a barrel collision.

generateOpenApiClient()

Pure function that turns an M21-compatible OpenAPI 3.1 document into TypeScript source.

import { generateOpenApiClient } from '@setu-ts/sdk';

const source = generateOpenApiClient(document, {
  sdkImport: '@setu-ts/sdk',
  factoryName: 'createApi',
});

OpenApiCodegenOptions

interface OpenApiCodegenOptions {
  sdkImport?: string;
  factoryName?: string;
  apiTypeName?: string;
}
OptionDefaultDescription
sdkImport'@setu-ts/sdk'Generated type-import specifier
factoryName'createApi'Exported generated factory name
apiTypeName'Api'Exported interface the factory returns (see below)

Generated naming contract

Emitted symbolDerivation
Operation methodlower-camelCase from operationId, split on non-alphanumeric runs, interior casing preserved (listUsers → listUsers)
Component typePascalCase from the component name (User → export type User)
Argument interfacePascalCase from operationId plus Args (listUsers → ListUsersArgs)
Client interfaceapiTypeName, PascalCase-sanitized (default Api); the factory’s written-out return type
Error unionPascalCase from operationId plus Error, with guard is<Operation>Error — emitted for every declared non-2xx response, auto-follow redirects (301, 302, 303, 307, 308) included — Fetch returns one unfollowed when it carries no Location
Error body aliasPascalCase from operationId plus Error<status>Body, emitted only when the rendered body spans lines
Request body aliasPascalCase from operationId plus Body, emitted only when the body schema is inline and spans lines
Response aliasPascalCase from operationId plus Response<status>, emitted only when a 2xx schema is inline and spans lines
Parameter aliasPascalCase from operationId plus the parameter name plus Param, emitted only when the parameter schema is inline and spans lines
Leading digit / reserveddigit run prefixed n; reserved word prefixed _; a name that sanitizes to nothing becomes operation
Local-identifier renamea path parameter whose identifier is client, opts, request or encodeURIComponent gets a _ prefix (positional, so callers are unaffected); an operation whose method name is client or encodeURIComponent gets a _ prefix — each name otherwise shadows a factory binding or the global the generated paths call
Duplicate derived namea name the document or the caller WROTE throws OpenApiCodegenError naming both originals — component schemas, *Args, *Error unions, the guard, the client interface and the apiTypeName/factoryName options share ONE registry. A HOISTED alias instead allocates: its preferred name, then …Response<status>Body on the success arm, then a registry-allocated 2, 3, … suffix

No multi-line type is written at a use site. An inline (non-$ref) request body, parameter or success response is hoisted into an exported alias, so every reference to it is a single-line name. This is not cosmetic: a rendered type lands at several indentation levels, and a success type lands at two of them at once — the client interface’s signature and the client.request<…> type argument — so no single indentation is correct for a multi-line object literal, and deno fmt reindents whatever is emitted. Hoisting also makes the shape nameable by a consumer. A schema that @setu-ts/openapi-plugin derived from validation middleware and used once is inline, so this is the ordinary case rather than an exotic one.

The factory has a written-out return type. createApi(client: IHttpClient): Api, with export interface Api { … } listing every operation’s signature. An inferred return type is a JSR slow type: it blocks automatic .d.ts generation, so a consumer could not publish a package containing the generated file — while the file’s own header tells them not to edit it. Naming the interface is also the only way a consumer can name the client’s type.

Declared error responses are typed. For each operation declaring an observable non-2xx response the generator emits a union discriminated on the literal status, plus a narrowing guard:

export type GetUserByIdError =
  | (HttpClientError<NotFound> & { readonly status: 404 })
  | (HttpClientError<GetUserByIdError409Body> & { readonly status: 409 });
export function isGetUserByIdError(e: unknown): e is GetUserByIdError { … }

HttpClientError is generic in its body (HttpClientError<TBody = unknown>), so the bare name keeps meaning exactly what it did. The union must be discriminated on status to be usable — HttpClientError<A> | HttpClientError<B> is not, because status is number on both arms. A default response and range codes such as 4XX are skipped: they name no single status.

Every declared 3xx keeps a typed error arm, including the statuses the web transport follows (301, 302, 303, 307, and 308): Fetch returns such a response unfollowed when it carries no Location, and the client throws HttpClientError with that status. An operation declaring an auto-follow redirect or the OpenAPI 3XX range returns unknown, even when it also declares a 2xx schema: the runtime Location selects a follow target body the source operation does not describe.

Generated output is deno fmt- and deno lint-clean. Two-space indentation, nested inline object types indented, no lint pragma ({} is emitted as Record<PropertyKey, never>, which is both what the schema means and what ban-types accepts), signatures wrapped one parameter per line past 100 columns, and a path template too long for one line emitted as an equivalent […].join(''). The two committed fixtures under packages/sdk/test/fixtures/ are real generator output and are covered by the repository’s own fmt and lint gates — they carry no exclusion.

Path parameters are emitted as positional arguments (each substituted and percent-encoded, including a placeholder sharing a segment with literal text such as /files/{id}.json); query parameters, headers, and the JSON body live on opts. opts is required when any of its fields is required. Query keys and header names keep their original OpenAPI spelling; only the TypeScript field identifier is derived, and header values are stringified so a non-string header schema compiles.

Every operation ends with an optional trailing request?: OperationRequestOptions, after the path parameters and after opts; it is optional even when opts is required. The generated code forwards only the idempotencyKey and signal fields, each carrying the same meaning as the same-named field of ClientRequest.

All eight operation slots are generated, including trace. Path-item-level parameters are merged into every operation on that path, with an operation’s own entry overriding a shared one of the same name and in. Document text emitted into a comment has comment terminators escaped and line breaks collapsed, and every emitted string literal and path template is escaped for its own context, so a hostile document cannot inject code into the generated file.

generateOpenApiClient throws OpenApiCodegenError (carrying path and method where applicable) instead of emitting a client that misbehaves or does not compile, for: a missing operationId; two operations deriving onto one name; two emitted TYPE names colliding — component schemas, *Args interfaces, *Error unions, the narrowing guards, the client interface and the apiTypeName/factoryName options all draw from ONE registry, so a component named ListUsersArgs beside an operation listUsers is refused rather than emitting two declarations of one name; a cookie parameter; a path placeholder with no matching in: 'path' parameter; an in: 'path' parameter absent from the template; two placeholders deriving onto one argument name; a document component named OperationRequestOptions, the type the generated operations’ trailing request parameter claims from sdkImport, refused like ClientResponse; and a malformed local $ref.

A hoisted alias is the one emitted name that does NOT throw on a collision — it allocates. The four hoisted aliases (a request body, a parameter, a success response, an error body) name an anonymous inline schema the document never named, so the generator may pick any identifier for it and a clash is its own to resolve, whereas every throwing name above derives from something the document or the caller wrote. Each alias asks the registry for its preferred name first, so no alias that generates today is renamed; a success response then tries …Response<status>Body, which reads correctly beside a component of the same name where the numeric form (…Response2002) would read as a status code; and every arm finally takes a 2, 3, … suffix allocated through the registry, since a suffix is not a namespace and a document may legally declare the suffixed name too. One internal function owns both the decision to hoist and the allocation, so every hoisted alias follows this rule and a future one gets it by construction. This matters because @setu-ts/openapi-plugin names a reused response schema ${operationId}Response${statusCode}, the identical derivation: a schema that is both reused (so it becomes that component) and enclosed in an inline multi-line body (so the body is hoisted) used to abort generation with Duplicate generated name.

SdkOpenApi* types

SdkOpenApiDocument, SdkOpenApiPathItem, SdkOpenApiOperation, SdkOpenApiParameter, SdkOpenApiRequestBody, SdkOpenApiResponse, and SdkOpenApiSchema are the structural OpenAPI 3.1 subset accepted by the generator. They are intentionally different from the openapi-plugin types (which have different shapes) and take the SdkOpenApi* prefix to avoid a barrel collision.


API Reference: @setu-ts/grpc-plugin

gRPC/Connect co-serving on the same port as ordinary Hono routes. Registered under CAPABILITIES.GRPC. Added in Milestone 49.

createApplication() returns an application descriptor; there is no new Application() / app.use() API. Plugins register during app.start(), so do not resolve CAPABILITIES.GRPC before start() resolves — the capability does not exist yet and the lookup throws. Pass services through the services option (below) or call addService after start().

Registration

import { GrpcPlugin } from '@setu-ts/grpc-plugin';

GrpcPlugin({
  basePath: '/', // default — the root
  reflection: true, // default — grpc.reflection.v1.ServerReflection
  health: true, // default — grpc.health.v1.Health (bridged to M20)
  services: [], // initial service definitions
  connectModule: undefined, // inject for testing; otherwise lazy-loaded
  interceptors: [], // default — application Connect interceptors
});

Usage

Pass services through the plugin’s services option at construction:

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { GrpcPlugin } from '@setu-ts/grpc-plugin';
import { MyServiceDefinition, myServiceImpl } from './my-service.ts';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    GrpcPlugin({
      services: [{ definition: MyServiceDefinition, implementation: myServiceImpl }],
    }),
  ],
});

await app.start({ port: 3000 });

// AFTER start(): late registration through the resolved capability.
const grpc = app.services.get<IGrpcService>(CAPABILITIES.GRPC);
grpc.addService(AnotherDefinition, anotherImpl);

Options

OptionTypeDefaultDescription
basePathstring/URL prefix that marks a request as RPC. Defaults to the root so clients reach procedures without a path prefix; detection stays segment-aware, so prefix-adjacent routes (/grpcfoo) are untouched. Requests outside this prefix fall through to Hono.
reflectionbooleantrueRegister grpc.reflection.v1.ServerReflection. Bidi streaming — requires HTTP/2 or in-process fetch.
healthbooleantrueRegister grpc.health.v1.Health (Check only), bridged to the M20 health plugin.
servicesArray<{ definition, implementation? }>[]Initial services to register at startup. The recommended registration path: the capability does not exist before start().
connectModuleConnectRuntimeomittedInjected Connect runtime for tests; omitted triggers lazy import() of four npm specifiers.
interceptorsreadonly unknown[][]Application Connect interceptors, forwarded to Connect router construction (createConnectRouter({ interceptors })). Composed after the built-in handler-error logging, so a handler throw is logged before an application interceptor observes it. Absent: none installed.

Native gRPC-binary requests (application/grpc, +proto, +json) are refused with a Trailers-Only UNIMPLEMENTED (grpc-status: 12). No runtime the plugin loads on exposes the HTTP/2 trailers the native protocol requires. Connect and gRPC-Web are fully supported.

Exports

ExportKindPurpose
GrpcPluginfunctionPlugin factory — registers IGrpcService under CAPABILITIES.GRPC
GrpcServiceclassThe IGrpcService implementation; exported so tests can compose it without subclassing
adaptConnectModulefunctionStructural adaptation of raw Connect/Protobuf modules into the internal ConnectRuntime port
GrpcUnavailableErrorclassDeprecated — nothing throws it since M70a; handleRequest needs no adapter capability
GrpcRuntimeLoadErrorclassThrown by loadConnectModule when any of the four npm specifiers cannot be imported
GrpcDescriptorErrorclassThrown when an embedded descriptor set cannot be decoded or lacks its expected service
GrpcPluginOptionstypeThe factory parameter shape
ConnectModuleLiketypeThe four-module bundle adaptConnectModule accepts

ConnectRuntime and the structural Connect facades are not exported. They are an internal port; publishing them would commit the package to a shape that tracks Connect’s own API.

Notes

  • Co-serves with Hono. gRPC requests are detected by path prefix only (basePath, which defaults to / — the root, so clients reach procedures at the bare method path). Content-type sniffing is deliberately not used because Connect’s real unary content types include application/json and application/proto. A path outside basePath returns null and falls through to the Hono pipeline unchanged.
  • The middleware pipeline runs first. Since M70a the kernel dispatches gRPC from its terminal handler, after the pipeline and before route matching — so auth, metrics, security headers and the shutdown drain apply to RPC exactly as to ordinary routes, a draining application answers 503, and an application catch-all cannot shadow a claimed path. GrpcPlugin no longer calls adapter.setRpcHandler, which is deprecated and consulted by nothing.
  • IGrpcService.claims(request) is what keeps ordinary 404s intact. handleRequest returns Promise<Response> and never null, so it answers 404 both for a path outside basePath and for a claimed path with no such procedure — the two are indistinguishable once dispatched. The kernel therefore asks claims() before dispatching. Without that guard, registering the plugin would change every unmatched route in the application from the kernel’s {"error":"Not Found"} (application/json) to gRPC’s Not Found (text/plain). claims is optional on the contract for source compatibility, and the kernel treats an implementor that lacks it as claiming nothing. A root basePath normalizes to '', which contains every path, so there claims reports only registered procedures rather than the whole application — otherwise a root-mounted service consulted before route matching would 404 every ordinary route.
  • IGrpcService.available is now always true. It used to report whether the resolved HTTP adapter implemented setRpcHandler; dispatch no longer depends on any adapter capability.
  • The request handed to handleRequest is reconstructed from the mapped IRequest (method, URL, headers, buffered body), because the framework mapping has already consumed the original body. Cloning every request before mapping would tax the whole application to serve the gRPC minority. Trailers do not survive the round trip — M49 already records that native gRPC-binary trailers work on no runtime this plugin runs on, so no working path regresses.
  • inject() CAN exercise RPC. Since M70a the kernel dispatches gRPC from its terminal handler, and inject() attaches the undisturbed web Request as IRequest.raw before running the pipeline, so an injected request reaches RPC dispatch exactly as a socket request does — the integration suite drives gRPC through inject(). The retired adapter interceptor is not involved: no setRpcHandler seam is consulted on any path.
  • Bidi streaming requires HTTP/2. grpc.reflection.v1.ServerReflection is bidi-only. Over a real HTTP/1.1 socket, bidi calls fail at the transport. Unary, server-streaming, and client-streaming work on every runtime.
  • Health bridge maps degraded → NOT_SERVING. 'up' → SERVING (1), 'down' → NOT_SERVING (2), 'degraded' → NOT_SERVING (2). The health plugin already withdraws a degraded replica from its Service via /ready (503), so reporting SERVING here would leave the two health faces of one process disagreeing — gRPC clients would keep load-balancing onto a replica HTTP has taken out of rotation. Since M70c the bridge agrees with /ready; a client relying on SERVING while a process is degraded now sees NOT_SERVING (see the CHANGELOG).
  • Check honors the service field. The empty string means “the whole server” and returns the mapped aggregate health. A name the server does not serve returns SERVICE_UNKNOWN (3), regardless of overall health.
  • List and Watch are unimplemented. Connect auto-responses unimplemented for methods not provided by the bridge.
  • Reflection covers four request variants. list_services, file_by_filename, file_containing_symbol (services, methods, messages, nested types, enums and extensions), and all_extension_numbers_of_type. file_containing_extension answers UNIMPLEMENTED — the framework registers no extensions. An unknown filename, symbol or type answers NOT_FOUND (5).
  • Reflection sees the app’s own protos. A registered service’s DescFile and its transitive dependencies are indexed, so file_containing_symbol resolves types declared in imported protos too. Nothing beyond the registered services and the plugin’s own two files is exposed.
  • Descriptors are addressed by their suffixed proto path. Reflection clients ask for example/echo.proto; Protobuf-ES’s DescFile.name drops the .proto suffix while proto.name retains it, and the registry keys on the latter.
  • Lazy loading. The four npm specifiers (@connectrpc/connect@^2.1.2, @bufbuild/protobuf@^2.7.0, @bufbuild/protobuf@^2.7.0/wkt) are loaded on first register(). Absence throws GrpcRuntimeLoadError with the install command.
  • No adapter seam. Since M70a the kernel dispatches gRPC from its terminal handler after the middleware pipeline; dispatch depends on no adapter capability, so the plugin serves on every runtime and IGrpcService.available is always true. The retired setRpcHandler? member is consulted by nothing, and GrpcUnavailableError remains exported only as published surface — nothing throws it.
  • Native gRPC-binary is refused by design. Native gRPC (application/grpc, +proto, +json) relies on HTTP/2 response trailers (specifically grpc-status) for proper status signaling, and no fetch-based server runtime exposes them to a Response — including Deno’s Deno.serve, Node.js, and Bun. Every native request is therefore answered with a Trailers-Only UNIMPLEMENTED (HTTP 200, content-type: application/grpc, grpc-status: 12) instead of half-serving the protocol. This is a deliberate design decision, not a platform bug. Since 0.7.0 the refusal is decided from the request HEADERS through the optional IGrpcService.refuses?, which the kernel consults BEFORE it reads the body — without that, a client-streaming or bidirectional call held its request stream open, the read never resolved, and the caller received no frames at all rather than the refusal. Connect-JSON and gRPC-Web work completely on all runtimes; point native gRPC clients at a gRPC-Web-capable proxy or switch them to Connect (see the CHANGELOG migration notes).

API Reference: @setu-ts/cloudflare-plugin

Cloudflare Workers platform bindings, published under CAPABILITIES.CLOUDFLARE, plus optional KV-backed cache, R2-backed storage, and a Queues-backed IQueue. Zero npm dependencies. Added in Milestone 52; Queues, Cron Triggers, and the Cache API response cache added in Milestone 52b.

Registration

import { env, waitUntil } from 'cloudflare:workers';
import { CloudflarePlugin } from '@setu-ts/cloudflare-plugin';

app.register(
  CloudflarePlugin({
    env, // required — the Worker's bindings and variables
    waitUntil, // the platform background-work sink
    requireBindings: ['CACHE_KV'], // fail at register() rather than at first use
    cache: { binding: 'CACHE_KV', prefix: 'cache:', defaultTtlSeconds: 300 },
    storage: { binding: 'UPLOADS', prefix: 'user-uploads/' },
    queue: { binding: 'JOBS' },
  }),
);

Consuming a queue and receiving Cron Triggers need module-level handler exports the platform invokes directly — fetch is not involved — so the application assembles them beside it:

import {
  createQueueHandler,
  createScheduledHandler,
  WorkersCron,
} from '@setu-ts/cloudflare-plugin';

await app.start();

const cron = new WorkersCron();
cron.on('0 3 * * *', () => rebuildReports(app)); // must match wrangler.toml [triggers] crons

export default {
  fetch: app.fetch,
  queue: createQueueHandler(app),
  scheduled: createScheduledHandler(cron),
};

Nothing in the package imports cloudflare:workers; the application passes env in. That specifier is unresolvable outside a Worker toolchain, so importing it here would break deno check on every other runtime — and injection is what the platform docs recommend for testability.

Options

OptionTypeDefaultConsumer / behavior
envCloudflareWorkerEnvrequiredBindingRegistry — the record every accessor reads
waitUntilWaitUntilHost—resolveWaitUntil — delegated to; omit off Workers
requireBindingsreadonly string[][]register() — throws naming every absent entry
cache.bindingstring—KvCacheStore — the KV namespace serving CAPABILITIES.CACHE
cache.namestring'default'Plugin factory — derives cache.<name> when not 'default'
cache.prefixstring—KvCacheStore — key prefix; required to call clear()
cache.defaultTtlSecondsnumber—KvCacheStore.set — applied when ttlSeconds is omitted
storage.bindingstring—R2Storage — the R2 bucket serving CAPABILITIES.STORAGE
storage.namestring'default'Plugin factory — derives storage.<name> when not 'default'
storage.prefixstring—R2Storage — object-key prefix
queue.bindingstring—WorkersQueue — the producer binding serving CAPABILITIES.QUEUE
queue.namestring'default'Plugin factory — derives queue.<name> when not 'default'
queue.maxDelaySecondsnumber86400WorkersQueue.add — a larger delayMs throws rather than being truncated
messaging.bindingstring—WorkersBroker — the producer binding serving CAPABILITIES.MESSAGING; validated at register()
messaging.namestring'default'Plugin factory — derives messaging.<name> when not 'default'
messaging.rpc.bindingstring—WorkersBroker — the Durable Object namespace serving reply inboxes; absent, request/respond throw
messaging.rpc.defaultTimeoutMsnumber5000RequestCorrelation — reply budget when RequestOptions.timeoutMs is omitted
durableObject.bindingstring—DurableObjectBackplane — the namespace serving CAPABILITIES.REALTIME_BACKPLANE; validated at register()
durableObject.namestring'default'Plugin factory — derives realtime-backplane.<name> when not 'default'
durableObject.topicstring'realtime'DurableObjectBackplane — the idFromName value every replica shares; two apps sharing a namespace must differ

Exports

ExportKind
CloudflarePluginfactory
ICloudflareBindingsinterface
CloudflarePluginOptions, KvCacheOptions, R2StorageArm, WorkersQueueArm, WorkersMessagingArm, WorkersMessagingRpcArm, DurableObjectArmtypes
KvCacheStore, KvCacheStoreOptions, CacheClockclass + types
KvSessionStore, KvSessionStoreOptionsclass + types
R2Storage, R2StorageOptionsclass + types
D1Adapter, D1AdapterOptions, D1EntityMappingclass + types
WaitUntilHost, LoggerSourcetypes
WorkersQueue, WorkersQueueOptions, JobIdSourceclass + types
createQueueHandler, QueueHandler, QueueHandlerOptionsfn + types
WorkersBroker, WorkersBrokerOptions, ReplyInboxBinding, BrokerRuntimeclass + types
createMessagingHandler, MessagingHandler, MessagingHandlerOptionsfn + types
ReplyInboxObjectCore, ReplyInboxObjectCoreOptionsclass + type
WorkersCron, WorkersCronOptions, CronHandlerclass + types
createScheduledHandler, ScheduledHandlerfn + type
cacheApiMiddleware, CacheApiMiddlewareOptions, ICacheApifn + types
assessCacheability, CacheabilityInput, CacheRefusalfn + types
RealtimeBackplaneObjectCore, RealtimeBackplaneObjectCoreOptionsclass + type
DistributedLockObjectCore, DistributedLockObjectCoreOptionsclass + type
DurableObjectBackplane, DurableObjectBackplaneOptionsclass + type
DurableObjectLock, DurableObjectLockOptionsclass + type
IdempotencyObjectCore, IdempotencyObjectCoreOptionsclass + type
DurableObjectIdempotencyStore, DurableObjectIdempotencyStoreOptionsclass + type
IIdempotencyObjectStatetype
asUpgradeResponse, DurableObjectUpgradeResponsefn + type
createDefaultDurableObjectWebSocketHost, DurableObjectWebSocketHost, DurableObjectWebSocketPairfn + types
IDurableObjectState, IDurableObjectStorage, IDurableObjectWebSocket, IDurableObjectClientSocket, DurableObjectMessageEventtypes
IKvNamespace, IR2Bucket, IR2Object, IR2ObjectBody, ID1Database, ID1PreparedStatement, D1Result, IQueueProducer, IQueueMessage, IQueueMessageBatch, IScheduledController, IServiceBinding, IDurableObjectNamespace, CloudflareWorkerEnv, KvPutOptions, KvListOptions, KvListResult, QueueSendOptionstypes
isKvNamespace, isR2Bucket, isD1Database, isQueueProducer, isDurableObjectNamespaceguards
CloudflareBindingMissingError, CloudflareUnsupportedError, CloudflareObjectNotFoundError, CloudflareRequestTimeoutError, CloudflareRemoteHandlerErrorerrors

D1Adapter — D1 as a first-class database

Implements the committed IDatabaseAdapter (from @setu-ts/common), so a Worker serves CAPABILITIES.DATABASE through the ordinary repository and Unit-of-Work surface. Constructed by the application and handed to DatabasePlugin, matching KvSessionStore: those plugin options are read when the plugin is constructed, before any application exists, so an adapter published in the service registry could never reach it.

import { env } from 'cloudflare:workers';
import { DatabasePlugin } from '@setu-ts/database-plugin';
import { D1Adapter, type ID1Database } from '@setu-ts/cloudflare-plugin';

app.register(
  DatabasePlugin({
    type: 'custom',
    adapter: new D1Adapter(env.DB as ID1Database, {
      tables: { User: { table: 'users', primaryKey: 'user_id' } },
    }),
  }),
);
OptionDefaultBehavior
tables{}Per-entity { table, primaryKey } overrides
D1EntityMapping.tablethe entity namePhysical table name; validated as a SQL identifier before quoting
D1EntityMapping.primaryKey'id'Key column used by findById / update / delete

Transactions. D1 has no interactive transaction — BEGIN TRANSACTION is rejected by the platform — and batch() is its only unit of atomicity. beginTransaction() therefore buffers every write and flushes the whole buffer as one batch() at commit(); rollback() discards the buffer and sends nothing. Two consequences, both deliberate:

  • No read-your-own-writes inside a transaction. Reads run immediately against committed state, so a row written earlier in the same transaction is not visible to a later read within it.
  • create() inside a transaction requires an explicit primary key, and throws CloudflareUnsupportedError naming the constraint when one is absent — a deferred INSERT cannot report a generated key to a caller that awaits create() before the flush. Outside a transaction create() uses RETURNING * and returns the real persisted row, generated columns included.

Identifiers and limits. Values are always bound (?N); identifiers cannot be, so table and column names are validated against [A-Za-z_][A-Za-z0-9_]* and double-quoted, throwing CloudflareUnsupportedError otherwise. D1 binds at most 100 parameters per query, and every builder whose parameter count varies with the caller’s query — select, insert, update, count — refuses a statement that would exceed it rather than letting D1 fail with a message that points at the SQL instead of the caller’s query. (Find-by-id and delete bind exactly one value and so cannot.)

The binding is validated where the adapter is built. new D1Adapter(env.DB) throws CloudflareBindingMissingError when the binding is absent or not D1-shaped, naming what arrived and pointing at the d1_databases stanza. Without that check a mistyped binding name would register cleanly, report up from the database health indicator, and fail every query with a bare TypeError. The isD1Database guard is exported alongside isKvNamespace / isR2Bucket.

Not verified against live D1. CI holds no Cloudflare account. Every generated statement is asserted verbatim, and the whole surface is driven against a real SQLite engine (the engine D1 runs), including batch rollback.

Durable Objects — realtime backplane and distributed lock

DurableObjectBackplaneOptions.onHandlerError?: (error: unknown, frame: RealtimeFrame) => void (0.11.0) receives every subscriber throw or rejection instead of default logging. The default reporter uses common’s shared dispatch, describeError, a call-time logger, first failure at warn, repeats at debug with a count, and recovery at info. Metadata includes frame kind and name, never data. Hook and logger failures are swallowed. This option belongs to direct backplane construction; the CloudflarePlugin arm uses default reporting.

Both need a Durable Object class the application exports plus a wrangler stanza; no plugin option can export a class on an application’s behalf. This package ships the behaviour as two plain cores that the exported class delegates to. A mixin taking the base class would read better but cannot be typed without any — the TypeScript mixin constructor constraint requires it, and the unknown[] form rejects a class whose constructor takes (ctx, env) — so delegation is the design, and it also keeps cloudflare:workers (unresolvable off a Worker toolchain) out of the package.

import { DurableObject } from 'cloudflare:workers';
import { RealtimeBackplaneObjectCore } from '@setu-ts/cloudflare-plugin';

export class RealtimeBackplaneObject extends DurableObject {
  #core = new RealtimeBackplaneObjectCore(this.ctx);
  override fetch(request: Request): Promise<Response> {
    return this.#core.fetch(request);
  }
  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
    this.#core.webSocketMessage(ws, message);
  }
  webSocketClose(ws: WebSocket, code: number, reason: string): void {
    this.#core.webSocketClose(ws, code, reason);
  }
  webSocketError(ws: WebSocket): void {
    this.#core.webSocketError(ws);
  }
}
[[durable_objects.bindings]]
name = "REALTIME"
class_name = "RealtimeBackplaneObject"

[exports.RealtimeBackplaneObject]
type = "durable-object"
storage = "sqlite"

storage = "sqlite" is required on the Workers Free plan. The legacy migrations + new_sqlite_classes flow still works, but a Worker may use only one of the two flows.

Backplane. The durableObject arm registers DurableObjectBackplane under CAPABILITIES.REALTIME_BACKPLANE (or realtime-backplane.<name>), which websocket-plugin and sse-plugin resolve on their own. Register this arm or RealtimeBackplanePlugin, never both — the kernel rejects two providers of one token.

The Durable Object holds zero in-memory state. Sockets are accepted with ctx.acceptWebSocket, the hibernation API, which lets the runtime evict the object and re-run its constructor while connections stay open; getWebSockets() is the only membership that survives, so it is the only membership used. The payload is re-broadcast verbatim and never parsed, which keeps the object schema-ignorant — a future widening of RealtimeFrame needs no redeploy of the application’s class.

The subscription guarantee is narrower than “durable”, deliberately. A Worker isolate is evicted at Cloudflare’s discretion and its outbound WebSockets go with it. That is sound rather than lossy because the members the subscription serves are client sockets held by the same isolate, and an HTTP-triggered Worker stays alive while its clients remain connected — so losing the isolate loses the subscription and its members together. The socket opens lazily on first publish and reopens after any failure.

Lock. DurableObjectLock structurally satisfies scheduler-plugin’s IDistributedLock without importing it (a plugin may not import a plugin), and is app-constructed then handed over, matching KvSessionStore and D1Adapter:

const lock = new DurableObjectLock(env.LOCKS as IDurableObjectNamespace, { runtime });
// `enabled: true` is NOT required — resolveLock consults `lock` before `enabled`.
app.register(SchedulerPlugin({ distributedLock: { lock } }));

One object per lock key. Correctness comes from the platform: a Durable Object processes one event at a time and holds back delivery while a storage operation runs, so the read-compare-write is atomic with no transaction and no quorum. The holder is persisted in ctx.storage, never a field, because an object is evicted after 70–140 seconds of inactivity and a lock TTL routinely outlives that. A non-2xx from the object throws rather than reporting “not acquired”: a 404 means the binding names the wrong class, and folding that into contention would silently disable every scheduled job.

Verified against real workerd (wrangler dev, 12/12 checks) — including a plain Durable Object class without extends DurableObject, a real stub.fetch WebSocket upgrade, real state.acceptWebSocket hibernation, and the real input gate serializing 8 concurrent lock contenders down to one winner. Not verified against a deployed Worker: CI holds no Cloudflare account.

ICloudflareBindings

has(name), names(), vars(), get<T>(name), kv(name), r2(name), d1(name), queue(name), service(name), durableObject(name), waitUntil(promise).

Every accessor throws CloudflareBindingMissingError for an absent name — naming the binding and listing the ones that are present — rather than returning undefined; a missing binding is a deployment error, not an expected case. Use has when absence is expected. kv, r2 and durableObject additionally validate the binding’s shape, so an R2 bucket wired into a KV option fails at register() with a message rather than at first use with a TypeError. (d1 is validated by D1Adapter’s constructor instead, where the adapter is built.)

Notes

  • Registration is opt-in and instance-named. CAPABILITIES.CLOUDFLARE is always registered; CAPABILITIES.CACHE and CAPABILITIES.STORAGE only when their arm is configured. name derives cache.<name> / storage.<name> exactly as CachePlugin does, so a KV cache can sit beside a memory one. Registering an unnamed instance beside CachePlugin() is a startup error, because the kernel rejects two providers of one token.
  • KV’s expirationTtl minimum is 60 seconds, and ICacheStore.set is unbounded. Values carry a { v, e } envelope whose logical deadline is checked against runtime.now() on every read, while the physical expirationTtl is floored at 60 so KV can still reclaim the key. A 5-second entry therefore expires in 5 seconds. The same envelope backs KvSessionStore.
  • clear() requires a prefix. The binding has no bulk delete, so the sweep pages list (1000 keys maximum) and deletes each key. Without a prefix it would delete keys the store does not own, so it throws CloudflareUnsupportedError instead.
  • A read never deletes a key the store does not own. The envelope decoder reports three outcomes, not two — live, this store’s expired entry, and neither — and only the middle one is swept. That is what makes a shared namespace safe, and it is also why a deliberately cached null survives: get answers null for it (the contract has no other way to say so) while has and delete report it as present, and no path removes it.
  • KV is eventually consistent. Suitable for read-heavy caching, not for coordination.
  • R2Storage.getSignedUrl rejects. The R2 Workers binding exposes no presign operation at all. getStream is implemented, so serving through a route is a zero-copy alternative.
  • R2Storage.delete heads first. R2’s delete returns void and reports nothing, so the committed Promise<boolean> costs one extra round trip rather than a constant true.
  • KvSessionStore is constructed by the application, not registered by the plugin: SessionPluginOptions.store is read at plugin construction, before any application exists.
  • No binding I/O at registration. Cloudflare prohibits I/O outside a request context, so the plugin only captures and shape-checks bindings at register(). The cloudflare health indicator performs no binding I/O either — a KV read per probe interval is billable. It reports degraded when runtime.platform() is not cloudflare-workers.
  • waitUntil reports its failures. A rejection handler is attached whether or not a host was injected, so background work never fails silently. With no host the promise still runs: no runtime off Workers cuts work off at the response.
  • Compatibility date. import { waitUntil } from 'cloudflare:workers' shipped 2025-08-08; setu new --runtime cloudflare-workers scaffolds a later date.
  • Unverified against a live Worker. Every binding is exercised against a fake built from the documented signatures — including KV’s 60-second floor and R2’s void delete — but CI holds no Cloudflare account.
  • Queues: addRecurring throws. Cloudflare Queues has no recurring message. The error names Cron Triggers and WorkersCron as the platform’s own mechanism. add and process map directly: AddJobOptions.delayMs is converted to the platform’s whole-second delaySeconds rounded up (so a job is never delivered early) and refused above maxDelaySeconds, while ProcessOptions.concurrency bounds how many of one batch’s messages for that name run at a time — per name, so one processor’s limit never throttles another’s. AddJobOptions.headers travels in the envelope and is delivered as IJob.headers, so a job enqueued on Workers joins a trace exactly as one on the other adapters does; a malformed map is dropped and the job runs untraced rather than being retried until the queue discards it.
  • A job’s id comes from this package, not the platform. producer.send() resolves to void, so the id add returns is minted from runtime.uuid() and travels inside a { v, name, id, data } envelope — which is also what carries the job name, since a Cloudflare message body is arbitrary JSON and IQueue.process dispatches by name. The id the caller receives is therefore the id the processor sees as job.id.
  • An unroutable message is RETRIED, never acked. A body that is not a readable envelope, or a name with no registered processor, is returned for redelivery and reported through the logger — acking it would discard it permanently and silently, which is the failure a queue exists to prevent. A processor that throws is likewise retried, leaving the queue’s own max_retries and dead-letter configuration to decide what happens next. AddJobOptions.maxAttempts is enforced at dispatch, because Cloudflare’s max_retries is queue-wide configuration rather than per message.
  • publish reaches one consumer Worker, not every subscriber in the cluster. Cloudflare allows exactly one active consumer per queue, so two Workers cannot both receive one published message; fan-out happens across the handlers registered inside that consumer. A topology needing cross-service fan-out binds one queue per consuming service. This is a platform property, not a limitation of the adapter, and it is the one place WorkersBroker cannot match a socket broker.
  • subscribe registers; it does not start receiving. A Cloudflare queue consumer is a module-level export, so delivery happens only once the application exports createMessagingHandler(app) as queue AND declares the queue under [[queues.consumers]] — the same split WorkersQueue has between process and dispatch. Within one delivery the selection matches InMemoryBroker: every subscriber that named no queue is called, plus exactly one member of each named group, round-robin.
  • A publish nobody subscribed to is ACKED, not retried — the one place this deliberately departs from WorkersQueue. A job name with no processor is a mistake, but publishing to a topic nobody listens on is ordinary pub/sub, and retrying would burn the queue’s 100-retry budget and dead-letter every fire-and-forget message. A body that is not a readable envelope is still retried, and a subscriber that throws is retried.
  • request/respond need the messaging.rpc arm, and throw CloudflareUnsupportedError without it. A queue reaches its one consumer Worker and never the caller waiting for a reply, so RPC needs a second addressable path: the caller holds a WebSocket to a Durable Object named after its own inbox, and the responder POSTs the reply there. That costs a namespace binding and a DO class the application exports (delegating to ReplyInboxObjectCore), which is why it is opt-in rather than always on. A request whose topic has no responder is answered with a failure rather than left to time out, and a responder that throws is relayed to the caller and acked — never retried, since the caller has already been told and a redelivery would re-run side effects.
  • One queue export serves EVERY queue the Worker consumes. Cloudflare distinguishes them only by batch.queue — the queue name from wrangler.toml, which no plugin option can see. A Worker consuming both a message queue and a job queue must route on it: feeding job batches to createMessagingHandler fails the envelope guard and retries them until the queue dead-letters them, and leaving a produced queue unconsumed discards every IQueue.add() silently. setu new --template microservice --runtime cloudflare-workers emits the routing and a consumer stanza for both queues.
  • A queue carrying RPC MUST set max_batch_timeout = 0. The platform default is 5 seconds and RequestOptions.timeoutMs defaults to 5000, so on a default queue essentially every request() times out. setu new --template microservice --runtime cloudflare-workers emits the correct stanza.
  • The RPC errors are this package’s own classes. CloudflareRequestTimeoutError and CloudflareRemoteHandlerError mirror messaging-plugin’s RequestTimeoutError and RemoteHandlerError but are distinct identities, because AI_GUIDELINES §2.2 forbids a plugin importing another plugin and common carries no error class to promote them into. Which one an application catches is never ambiguous: both providers claim CAPABILITIES.MESSAGING, so the kernel’s duplicate-provider check guarantees exactly one is registered.
  • Cron Triggers do NOT register CAPABILITIES.SCHEDULER, deliberately. Of IScheduler’s eight methods only cron is expressible on Workers: every and delay arm a timer and the isolate is evicted between invocations (the same reason scheduler-plugin cannot run on Workers); pause/resume/remove need state that does not survive between invocations; and getNextRun is owned by the wrangler.toml [triggers] block. An IScheduler where six of eight methods throw would violate Liskov substitution, so WorkersCron is a purpose-built registry reached directly instead — which is why createScheduledHandler takes it while createQueueHandler takes the application.
  • A WorkersCron expression must match wrangler.toml exactly. Nothing in the process can read that file, so an expression registered here but absent from [triggers] crons never fires, and a configured trigger with nothing registered here is reported on every occurrence. expressions() exists so an application can assert its own coverage. Matching is exact — whitespace is not normalized.
  • cacheApiMiddleware is a different layer from cache-plugin’s cacheMiddleware, and the two compose: this one serves from the datacenter the request landed in with no round trip, while cacheMiddleware reads an ICacheStore every colo shares. It therefore reports under X-Cache-Api (HIT/MISS/BYPASS), never X-Cache, so an operator can tell which layer answered. caches.default is per-datacenter: a hit rate measured in one location says nothing about another, and a delete does not evict globally.
  • The default key carries the resolved tenant and locale. The key is a URL string, which the platform matches with no request headers, so Vary cannot separate entries in this cache — and before the tenant was keyed, tenant A’s cached page was served to tenant B for the TTL. When ctx.request.tenant and/or ctx.request.locale is set, the default key is the request URL’s own text with setu-cache-tenant=<encoded id> and then setu-cache-locale=<encoded locale> concatenated after it, in that order. The query is never re-serialized and the parameter never set: set would strip a client-supplied copy from the key while the handler still saw it, and re-serializing would fold encoding variants (?p=%32 and ?p=2) into one entry, either way letting one client choose what another is served. Without a tenant or locale the key is the URL unchanged, except as below. Four kinds of request are served uncached (X-Cache-Api: BYPASS) because no key could keep them apart from another: a URL whose text is not its own WHATWG serialization (Deno, and Node for some targets, deliver the request target as sent, while the Cache API parses a key before matching), a URL carrying a fragment (delivered by Deno, Node and Bun, ignored by the Cache API when matching), a tenant id or locale that is not well-formed UTF-16 (encodeURIComponent cannot encode a lone surrogate), and a request lacking a tenant or locale whose URL already contains setu-cache-tenant= or setu-cache-locale= (its key would equal a tenanted or localized request’s). Workers normalizes the URL and strips the fragment, so the first two do not arise there. The tenant and locale are the ones present when the middleware runs, so the same conditions as cache-plugin apply: the middleware must run after the tenant (40) and locale (45) middleware (globally, at a higher priority number than theirs; per route, listed after them), and a replaceTenant or replaceLocale made inside the handler is not reflected — such a route must not be cached here. A custom key replaces this and must include the tenant and locale itself.
  • The platform’s cache refusals are checked before the write, not discovered by it. caches.default.put throws for a non-GET request, status 206, Vary: *, and an uncleared Set-Cookie; assessCacheability reports each as a CacheRefusal and the middleware skips the write. The 206 and Vary: * checks are unconditional — an operator may legitimately configure cacheableStatuses: [200, 206], at which point only the explicit rule stops the platform throwing. Cache-Control: private=Set-Cookie is the platform’s documented opt-in and clears the set-cookie refusal.
  • Only GET requests touch the edge cache — on the read as well as the write. The cache key is a URL string, which the Cache API resolves as a GET request, so consulting it for a POST would serve the cached GET body and skip the handler entirely: a mutation silently discarded behind a 200. Any non-GET request passes straight through with X-Cache-Api: BYPASS, which matters most when the middleware sits on the global pipeline rather than a single GET route.
  • A failed cache write never fails the request. Cache.put rejects for an oversized response or a quota error, and by then the response already exists — so both write paths report and continue rather than turning a 200 into a 500. With the plugin registered waitUntil carries that reporting; without it the middleware logs through CAPABILITIES.LOGGER when one is present.
  • A cache HIT is replayed as a stream, so a cached response of any size reaches the client without being buffered — which means app.inject() cannot read its body. Drive a cached route with app.fetch and a web Request, the entry point a Worker invokes anyway. A streaming response is never cached: teeing it would double the memory the stream exists to avoid.
  • The cache write rides waitUntil when the plugin is registered AND the application passed a host, so it never delays the response. When CloudflarePlugin received no waitUntil the registry has no sink, so the middleware awaits the write inline — the write lands either way; waitUntil decides only whether it delays the response. With CAPABILITIES.CLOUDFLARE absent the middleware awaits it inline rather than abandoning it, and with no cache handle at all it passes through with BYPASS rather than throwing.
  • Not in this package. D1 as a database backend is Milestone 52c — the seam a backend implements (IDatabaseAdapter) lives inside database-plugin and is not a committed common port, so shipping it means a contract promotion. A Durable-Object realtime backplane and distributed lock are Milestone 52d: both need the application to export a DO class plus a wrangler migration stanza.

GraphQL (@setu-ts/graphql-plugin)

Schema-first and code-first GraphQL support over the kernel router.

Overview

GraphQL plugin providing schema construction, execution, and HTTP transport. Supports both SDL-based schema definition with resolver maps and pre-built schemas. Includes media-type negotiation for application/graphql-response+json, error masking, query-depth limiting, and optional GraphiQL UI.

Capability Token

  • CAPABILITIES.GRAPHQL ('graphql') — the capability token under which GraphqlPlugin registers the IGraphqlService.

Usage

Pass the plugin to createApplication({ plugins: [...] }) — there is no new Application() / app.use() API:

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { GraphqlPlugin } from '@setu-ts/graphql-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    // Schema-first
    GraphqlPlugin({
      typeDefs: `
        type Query {
          hello(name: String!): String
        }
      `,
      resolvers: {
        Query: {
          hello: (_, { name }) => `Hello, ${name}!`,
        },
      },
    }),
  ],
});

await app.start({ port: 3000 });

// Resolve the service AFTER start() — plugins register during start().
const graphql = app.services.get<IGraphqlService>(CAPABILITIES.GRAPHQL);

Code-first differs only in passing a pre-built schema instead of typeDefs + resolvers:

import { buildSchema } from 'graphql';
const schema = buildSchema(`type Query { hello: String }`);

GraphqlPlugin({ schema });

Options

OptionTypeDefaultDescription
typeDefsstring-SDL schema definition (schema-first mode)
resolversResolverMap-Resolver map (schema-first mode)
schemaGraphqlSchemaLike-Pre-built schema (code-first mode)
pathstring/graphqlEndpoint path
graphiqlbooleantrueEnable GraphiQL UI
introspectionbooleantrueEnable schema introspection
maxDepthnumber10Maximum query depth (0 to disable)
validationRulesunknown[]omittedExtra rules, appended after the built-ins, assembled once at registration
maskInternalErrorsbooleantrueMask internal server errors
formatError(error: unknown) => unknownomittedCustom error formatter applied after masking
documentCacheSizenumber1000Max cached documents (0 to disable)
buildContext(input: GraphqlContextInput) => unknownomittedCustom context builder
rootValueunknownomittedRoot value for resolvers
graphqlModuleGraphqlModuleLikeomittedInjected graphql module (for testing or code-first scenarios)
subscriptionsGraphqlSubscriptionsOptionsomittedSubscription transports. Omitted registers no transport route at all.
apqGraphqlApqOptionsomittedAutomatic Persisted Queries. Omitted disables APQ; requires CAPABILITIES.RUNTIME
maxBatchSizenumber00 disables batching and an array body is still refused with 400

subscriptions

OptionTypeDefaultPurpose
websocketGraphqlWsTransportOptions | falseenabledfalse disables the WebSocket transport
websocket.pathstring`${path}/ws`WebSocket endpoint
websocket.connectionInitWaitMsnumber3000Close 4408 when no connection_init arrives
websocket.heartbeatMsnumber0Protocol ping interval; 0 disables
websocket.onConnect(info) => false | voidomittedReturning false closes 4403: Forbidden
sseGraphqlSseTransportOptions | falseenabledfalse disables the SSE transport
sse.pathstring`${path}/stream`SSE endpoint
sse.heartbeatMsnumber0:keep-alive comment interval; 0 disables

apq

OptionTypeDefaultPurpose
ttlSecondsnumber300TTL for entries in a registered ICacheStore
maxEntriesnumber1000Bound on the in-process LRU used when no cache store is registered

Exports

ExportKindPurpose
GraphqlPluginfunctionPlugin factory — registers IGraphqlService under CAPABILITIES.GRAPHQL
GraphqlServiceclassThe IGraphqlService implementation; exported for testing
adaptGraphqlModulefunctionStructural adaptation of graphql module into internal runtime port
graphiqlHtmlfunctionGenerates GraphiQL UI HTML page
createDepthLimitRulefunctionCreates a validation rule for query depth limiting
GraphqlSchemaErrorclassThrown when schema construction or resolver attachment fails
GraphqlRuntimeLoadErrorclassThrown when graphql runtime cannot be loaded
loadGraphqlModulefunctionLoads graphql through a real dynamic import
GraphqlPluginOptionstypeThe factory parameter shape (union of the two arms)
GraphqlSchemaFirstOptionstypeThe schema-first arm of that union
GraphqlCodeFirstOptionstypeThe code-first arm of that union
ResolverMaptypeResolver map for schema-first mode
TypeResolverMaptypeThe resolver entries for one object or interface type
FieldResolvertypeField resolver function type
AnyFieldResolvertypeThe bivariant entry type a TypeResolverMap field holds — accepts a narrowly annotated resolver AND contextually types an unannotated one
AnySubscriptionResolverinterfaceThe bivariant entry type a TypeResolverMap subscription field holds — same rule as AnyFieldResolver, for { subscribe, resolve? } entries
SubscriptionResolvertypeA subscription field’s { subscribe, resolve? } pair
GraphqlScalarResolvertypeCustom scalar serialize/parseValue/parseLiteral methods
GraphqlSubscriptionsOptionstypeThe subscriptions option (WebSocket and SSE arms)
GraphqlWsTransportOptionstypeWebSocket transport options, including onConnect
GraphqlSseTransportOptionstypeSSE transport options
GraphqlApqOptionstypeAutomatic Persisted Queries options
ApqResolverclassVerifies and resolves persisted-query hashes
IApqResolvertypeThe port the transports consume; implemented by ApqResolver
ApqResolveResulttypeThe resolved query, or a refusal carrying its code
extractPersistedQueryfunctionReads { version, sha256Hash } from a request’s extensions
persistedQueryHashfunctionSHA-256 hex of a query, over an injected SubtleCrypto
encodeSseEventfunctionEncodes a next SSE frame
encodeSseCompletefunctionEncodes the complete SSE frame, empty data: field included
encodeSseCommentfunctionEncodes a :keep-alive comment frame
GRAPHQL_TRANSPORT_WSconstThe 'graphql-transport-ws' subprotocol identifier
GraphqlScalarTypeLiketypeStructural constraint for a custom scalar type
GraphqlSchemaLiketypeStructural constraint for pre-built schemas
GraphqlModuleLiketypeStructural constraint for injected graphql modules
DefaultGraphqlContexttypeDefault context shape passed to resolvers
GraphqlContextInputtypeInput type for custom context builder

GraphqlRuntime and the structural graphql facades are not exported. They are an internal port.

Notes

  • Two schema construction arms. Schema-first (typeDefs + resolvers) and code-first (schema) are mutually exclusive; supplying both is a compile error.
  • Resolver context. Without buildContext, resolvers receive a DefaultGraphqlContext whose shape is per-transport. Over HTTP: { services, requestContext, user?, tenant? } — services is the live IServiceRegistry, so a resolver reaches any other capability through it, and user/tenant are whatever the auth and multi-tenancy middleware published on the request. Over WebSocket: { services, connection } — requestContext is absent (not undefined-valued): the runtime closes the upgrade request once the handshake response is returned, so a synthesized one would be dead by the time a resolver runs; that is why the member is typed optional. The upgrade request’s headers and query live on GraphqlConnectionInfo.headers/.query, and identity set by an onConnect hook via info.data.set('user', …) surfaces as ctx.user. Supplying buildContext replaces that object wholesale.
  • Media-type negotiation and the status watershed. Responds with application/graphql-response+json when the client requests it, otherwise application/json — including for failures raised before execution, so a client is never handed a media type it did not ask for. Under graphql-response, a request error (parse, validation, operation resolution) is 400, a subscription over HTTP is 400, and a mutation over GET is 405. Under application/json, every request the endpoint processed as GraphQL answers 200 with the error in the body, because a client predating the newer media type reads a non-200 as a network failure and never reads the errors array. Exactly three cases keep their status under application/json — an unsupported request content type (415), a malformed JSON body (400), and a mutation over GET (405) — because none of them is a GraphQL result. An APQ refusal follows the watershed too: under application/json it answers 200 with PersistedQueryNotFound in the body (it is exactly the error a client must read and retry), while under graphql-response it carries the resolver’s own status. Batching remains refused before per-element resolution, so an APQ miss inside a batch under graphql-response surfaces as 400 BATCHING_NOT_SUPPORTED. The status is decided from the outcome alone and never from the response body, so a formatError hook cannot change it.
  • Two resolver authoring styles, both supported. FieldResolver<TSource, TContext, TArgs> is generic with unknown defaults, so a resolver may be annotated narrowly (FieldResolver<IssueRow, DefaultGraphqlContext, { id: string }>) and still assign to a ResolverMap. A resolver written unannotated — (source, args) => …, the ordinary schema-first shape — takes its parameter types contextually from AnyFieldResolver, so args is Record<string, unknown> rather than never. The map entry is bivariant for exactly this reason: a non-bivariant entry can serve one style or the other, never both. Subscription entries follow the same rule through AnySubscriptionResolver, so a typed { subscribe, resolve: (payload: Book) => … } assigns too.
  • An executed operation is always 200. A field error that nulls data is not a request error, so it does not become a 400 even under graphql-response.
  • 405 carries Allow: POST. A mutation sent over GET is refused with METHOD_NOT_ALLOWED and the endpoint advertises the verb that would work.
  • Ambiguous operations. A document whose operation cannot be resolved — several operations with no operationName, or a name absent from the document — is a 400 with code OPERATION_RESOLUTION_FAILED, decided after parse and before validation.
  • Error masking. Internal errors (resolvers throwing without a code in extensions) are masked by default. The original is logged through the plugin context’s logger when one is registered, so masking never silently discards it.
  • Query depth limiting. Defaults to 10 levels; set to 0 to disable.
  • Document caching. Parse and validation results are cached together, keyed on the raw query string, and the operation guard reads the cached AST — so a repeated query is neither re-parsed nor re-validated. documentCacheSize: 0 disables the cache.
  • GraphiQL UI. Enabled by default, served on GET /graphql when the request has no query parameter and accepts text/html.
  • Platform notes. The graphql package reads process.env.NODE_ENV at import time. Deno requires --allow-env; Cloudflare Workers requires nodejs_compat.
  • Subscriptions are opt-in and never ride the HTTP endpoint. Supply subscriptions to register the transports; omit it and no transport route exists. POST/GET /graphql continues to refuse a subscription operation with 400 SUBSCRIPTIONS_NOT_SUPPORTED_OVER_HTTP, unchanged — a subscription is served only on `${path}/ws` and `${path}/stream`.
  • Declaring a subscription (schema-first). A Subscription field’s resolver entry is a SubscriptionResolver — { subscribe, resolve? } — not a bare function. subscribe returns the async iterable the field streams from and is attached to the schema field’s own subscribe slot; graphql reads the event source from there and nowhere else.
  • Both transports require CAPABILITIES.RUNTIME. They take their timers (the connection-init timeout, the protocol ping, the SSE keep-alive) from IRuntimeServices rather than global timers, so configuring subscriptions without a registered runtime throws at registration naming the requirement — rather than failing at the first connection.
  • The WebSocket transport is optional and self-limiting. It registers only when CAPABILITIES.WEBSOCKET is present AND IWebSocketService.available is true; otherwise the plugin logs a notice and everything else carries on. The route claims heartbeat: false on WebSocketRouteOptions, because websocket-plugin’s shared sweeper sends a raw text frame that a conformant graphql-transport-ws client must answer by closing 4400. Liveness on that route is the protocol’s own ping/pong, configured by subscriptions.websocket.heartbeatMs.
  • Authenticating a subscription. subscriptions.websocket.onConnect runs on connection_init before the ack, receives the GraphqlConnectionInfo (including connectionParams — the protocol’s auth channel — plus the upgrade headers and query), and closes the socket with 4403: Forbidden when it returns false. Writing to conn.data there sets the user/tenant the default resolver context reads back. With no hook configured the socket is accepted. onConnect and a pipeline guard on the socket path are mutually exclusive: connection_init is a message on an already-open socket, and since M70a a guard runs on the upgrade itself, so a guard covering the /graphql prefix answers GET /graphql/ws with 401 and onConnect never runs. Either scope the guard to the HTTP endpoint and authenticate in onConnect, or authenticate on the upgrade with a credential a browser can attach there — a cookie, through auth-plugin’s session strategy. See the package README’s “onConnect and a route guard cannot both authenticate the socket”.
  • Resolver context over a socket. A WebSocket carries no IRequestContext, so IGraphqlService.subscribe takes a GraphqlOperationContext instead. On that path services is the plugin-level IServiceRegistry, so a subscription resolver reaches every capability an HTTP resolver can.
  • Errors inside a live subscription are masked. maskInternalErrors applies identically on every transport: masking happens once, in the service, and the transports put the already-masked payload on the wire.
  • SSE follows the graphql-sse protocol, not the HTTP watershed. In distinct-connections mode a GraphQL request error (parse, validation, operation resolution) is delivered INSIDE the accepted text/event-stream as a next event followed by complete — not as a 400, which would make the user agent fail the connection and give native EventSource nothing to read. A transport failure that happens before any GraphQL request exists (unsupported content type, unparseable body, a missing query parameter) is still an ordinary buffered HTTP error. A persisted-query miss counts as a request error and is therefore delivered in-stream too. The complete frame always carries an empty data: field, without which EventSource never fires the listener.
  • Automatic Persisted Queries verify the hash. A request carrying both a query and a hash is persisted only when sha256(query) matches the submitted sha256Hash; a mismatch answers PERSISTED_QUERY_HASH_MISMATCH. Without that check any client could store a document under a hash another client later executes — a cache-poisoning primitive, and worst when the store is a shared Redis. A hash-only request that misses answers PersistedQueryNotFound / PERSISTED_QUERY_NOT_FOUND, the standard retry signal. Entries live under the apq: namespace in CAPABILITIES.CACHE when one is registered, and in a bounded in-process LRU otherwise. Hashing uses IRuntimeServices.subtle, so configuring apq without CAPABILITIES.RUNTIME throws at registration.
  • Batching is opt-in. maxBatchSize: 0 (the default) keeps refusing an array body with 400. Above 0, an array body executes its elements concurrently and answers a JSON array in request order; over the limit is 400 BATCH_TOO_LARGE. A batch always answers application/json — the application/graphql-response+json media type describes a single result and cannot express an array, so an array body from a client negotiating it is refused with 400 BATCHING_NOT_SUPPORTED.
  • Custom scalars. In the schema-first arm a resolver-map entry for a scalar type supplies any subset of serialize, parseValue, and parseLiteral; omitted members keep graphql’s identity default.

Static Files Plugin (@setu-ts/static-plugin)

Package: @setu-ts/static-plugin

Token: CAPABILITIES.STATIC_FILES = 'static-files'

Registration

import { StaticPlugin } from '@setu-ts/static-plugin';

app.register(
  StaticPlugin({
    root: './public',
    urlPrefix: '/assets',
  }),
);

Options

OptionTypeDefaultDescription
rootstring(required)Directory to serve files from
urlPrefixstring'/'URL prefix for static routes
indexstring'index.html'Index file for directories
fallbackstringundefinedSPA fallback file
cacheControlstring | ((requestPath) => string)Hashed→immutable, else must-revalidateCache-Control header. A callback receives the full leading-slash request path including urlPrefix — for a directory root, never '/' (a directory delivers its resolved index path)
etagbooleantrueEnable ETag generation
rangesbooleantrueEnable Range requests
compressedbooleantrueNegotiate .br/.gz sidecars
maxBufferBytesnumber1048576Threshold for streaming

Exports

ExportKindDescription
StaticPluginfunctionPlugin factory
StaticFilesServiceclassService implementing IStaticFiles
createStaticHandlerfunctionStandalone route handler
IStaticFilesinterfaceService interface
StaticPluginOptionstypePlugin options type

IStaticFiles declares one method:

serve(ctx: IRequestContext): Promise<HandlerResult>;

Notes

  • Mounts routes on both GET and HEAD
  • A root urlPrefix claims GET /* and HEAD /*, which no second plugin can share. The plugin registers <urlPrefix>/*, so urlPrefix: '/' mounts the bare wildcard — and the kernel refuses a duplicate METHOD path, naming the plugin that registered it first (Route 'GET /*' is already registered by plugin 'react-router'.). An application serving SSR at the root therefore cannot also mount static files there: give the static files their own prefix (urlPrefix: '/assets'), which is the arrangement content-hashed assets want anyway
  • Conditional requests: ETag, If-None-Match, If-Modified-Since → 304
  • Range requests: 206 with Content-Range, 416 for unsatisfiable
  • The ETag is strong ("<size>-<mtimeMs>") when the runtime reports an mtime, and degrades to a weak size-only validator when it does not. This matters for resumption: If-Range MUST be ignored for a weak validator (RFC 9110 §13.1.5), so an interrupted download resumes only against the strong form. size+mtime is what nginx and Apache emit as strong for static files
  • Precompressed sidecars: .br preferred over .gz, sent as Content-Encoding: br and Content-Encoding: gzip respectively; ETag and conditional evaluation use the selected sidecar stat rather than the uncompressed source representation
  • Cache-Control is resolved from the full leading-slash request path, INCLUDING urlPrefix — /assets/app-A9acsx54.js for a file under that prefix, /assets/index.html for a directory request against it, and /index.html under a root mount; for a root that is a directory — what root documents — the callback never receives the literal '/' (a directory is served by resolving its index), never the prefix-stripped server path, never the absolute filesystem path, and never the .br/.gz sidecar path — so a content-hashed asset keeps its immutable policy whichever encoding is negotiated. The leading slash is guaranteed for both a file and a resolved index. The prefix inclusion is deliberate (M95c C3): a cache policy is about the URL the client caches under, so the served path is the input, and stripping it would silently change what every existing callback matches.
  • A HEAD opens no body stream, so it cannot leak a file descriptor on a file above maxBufferBytes
  • An explicit Accept-Encoding entry overrides the wildcard, so br;q=0, * refuses brotli
  • Workers degradation: registers capability but mounts no route when fs is absent
  • Health indicator: reports up/down based on root directory accessibility; on a runtime without fs it reports up with data: { fs: false, detail: 'no file system on this runtime' } and registers no static route

View Plugin (@setu-ts/view-plugin)

Server-rendered HTML as a capability, shipped in Milestone 92. The plugin registers an IViewEngine under CAPABILITIES.VIEW; the view is named BY REFERENCE — a component the application already has — so there is no view resolver, no views directory and no filesystem lookup, which makes the capability Workers-portable by construction.

Registration

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { ViewPlugin } from '@setu-ts/view-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    ViewPlugin(), // or ViewPlugin({ engine: 'hono-html' })
  ],
});

Options

ViewPluginOptions is a union discriminated on engine, so a missing per-arm field is a compile error rather than a startup throw:

OptionTypeDefaultBehavior
engine'hono-jsx''hono-jsx'Components are JSX functions; the application manifest declares jsx / jsxImportSource.
engine'hono-html'—Components return an html tagged template; needs no jsxImportSource, works in a plain .ts.
engine'custom'—Requires view; the supplied engine is registered verbatim.
viewIViewEngine—'custom' arm only — the application’s own engine.

'hono-jsx' and 'hono-html' name the authoring mode, not a rendering strategy: both return values funnel through one engine, because escaping belongs to the rendering runtime and there is nothing left to configure per mode. The selected mode is reported by the view health indicator.

Redirecting from a rendered route. A @Render handler may return a HandlerResult from ctx.response instead of the props bag, so the standard form pattern works: return the props to re-render the form carrying validation errors, or ctx.response.redirect(target, 303) once the submission is accepted. The redirect short-circuits before the view runs, so no HTML body is produced. HandlerResult is branded, so widening the return union costs no type safety — a props bag of the wrong shape is still a compile error.

Rendering nothing. A component’s top-level return follows the rendering runtime’s own rules, so (props) => props.show && <Banner /> behaves at the top level exactly as it does nested: null, false, true and '' render as the empty string, while 0 renders as 0. undefined is the one exception — it is refused with ViewRenderError, because it is almost always a missing return rather than a deliberate empty render.

Exports

ExportKindPurpose
ViewPluginfunctionPlugin factory — registers the selected engine under CAPABILITIES.VIEW and a view health indicator reporting it (always up)
renderViewfunctionFunctional entry point — renderView(ctx, component, props) resolves the engine per request and answers IResponse.html(...)
rawfunctionThe escaping opt-out, re-exported from hono/html so an application does not import hono directly
ViewRenderErrorclassA component threw while rendering (original carried as cause), or returned undefined (almost always a missing return)
UnresolvedSuspenseErrorclassThe rendered tree holds a pending <Suspense> boundary; buffered rendering would serve only the fallback, so it is refused
ViewPluginOptionstypeThe discriminated options union above

Behavior notes

  • Two entry points, one implementation. @Render(Component) resolves the engine once at register() (the token joins DecoratorPlugin’s optionalDependencies); renderView resolves it per request. Both answer through the same IResponse.html(...) write — byte-identical bodies and headers, pinned by an integration test under a non-default configuration.
  • Buffered by design. IViewEngine.render answers string | Promise<string> and nothing else. A tree holding a pending <Suspense> boundary is refused with UnresolvedSuspenseError rather than served as its fallback with a 200; streaming resolution is deferred to a follow-up milestone. An async component WITHOUT <Suspense> renders clean, as does an html template with an async interpolation — the refusal has no false positive.
  • Escaping is on for both default arms, and is the RUNTIME’s doing rather than the plugin’s. A plain (props) => string component is a valid Component<P> and its output is returned unchanged, so a hand-written template literal escapes nothing — write views with JSX or the html tag. Both default arms escape interpolations through their rendering runtime; raw() is the documented opt-out. Rendered output is always a primitive string.
  • Layouts are components. A layout is an ordinary component taking children. There is no plugin-level layout option: it would wrap every render, including fragment responses where a full document is wrong.

IdempotencyPlugin() (@setu-ts/idempotency-plugin)

A repeated HTTP request, queue job or broker message is recognised by its key, and a repeat of completed work is answered from its record or skipped instead of running again, over one claim/complete/release state machine and an in-process, Redis or Cloudflare Durable Object store. The guarantee is no duplicate processing within the limits of the store — it is not a single-execution guarantee. Tier C (within, M109b) extends this to plain code: the work and its record commit in ONE database transaction, so a lost race rolls the business writes back with the claim. See packages/idempotency-plugin/README.md for placement, the HTTP check order, failure classification, replay rules, the ingress allow-list, the tier-C backend table and the store guarantees.

within returns the JSON round trip of what fn returned on both the first call and a replay. Dates become ISO strings, undefined object members are absent, and nested toJSON() output determines the stored value. A void result returns undefined on both paths.

Values (runtime exports)

ExportKindPurpose
IdempotencyPluginfunctionRegisters IIdempotencyService under CAPABILITIES.IDEMPOTENCY and the health indicator
idempotentfunctionBuilds the route-level HTTP middleware (list it LAST)
idempotentIngressfunctionBuilds the ingress behaviour over a required topics/jobNames allow-list
derivedIdempotencyKeyfunctionReads the store key the middleware recorded, to forward to a provider
IdempotencyRefusedErrorclassAn ingress refusal, carrying reason, ingress and target
IdempotencyConfigurationErrorclassAn option refusal, carrying the failing option path
IdempotencyWithinErrorclassA tier-C refusal or failure, carrying reason and a status hint (M109b)
IdempotencyVerifyTimeoutErrorclassstart() rejects with it when a store’s verify() outlives storeTimeoutMs (M109b)
IDEMPOTENCY_KEY_HEADERconst'Idempotency-Key'
IDEMPOTENT_REPLAYED_HEADERconst'Idempotent-Replayed'

Types

ExportKindPurpose
IdempotencyPluginOptionstypeThe IdempotencyPlugin options (store, leases, ttl, cap, transactional)
IdempotencyStoreConfigtypeThe store arm (memory, redis built/injected, custom)
IRedisIdempotencyClienttypeThe Redis facade an injected client must satisfy
IdempotencyRefusalReasontypeThe union carried by IdempotencyRefusedError.reason
IdempotencyWithinErrorReasontypeThe union carried by IdempotencyWithinError.reason (M109b)
TransactionalIdempotencyOptionstypeThe transactional option: store, ttlMs, storeTimeoutMs, maxResultBytes, purge (M109b)
TransactionalIdempotencyPurgeOptionstypeThe retention purge’s schedule, intervalMs and batch (M109b)

The queue retry span versus the lease

A crashed holder’s claim blocks redeliveries of its key until the lease lapses, and each redelivery inside the lease consumes a queue attempt. The retry span (the sum of computeBackoffMs(2..maxAttempts)) must EXCEED the lease: with the default backoff and the default 30 s ingress lease that means defaultMaxAttempts ≥ 6. The queue’s retry configuration is not readable from this plugin, so this is documented, not checked.


Localization Plugin (@setu-ts/localization-plugin)

Message catalogues per locale, request locale resolution, and a formatter shared with the browser, shipped in Milestone 103. The plugin registers an ILocalizer under CAPABILITIES.LOCALIZATION, validates every catalogue at register(), and resolves each request’s locale into IRequest.locale. Zero npm dependencies: plurals, numbers and dates come from Intl.

Registration

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { LocalizationPlugin, localizerFor } from '@setu-ts/localization-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    LocalizationPlugin({
      supportedLocales: ['en', 'de'],
      catalogues: { en: { greeting: 'Hello {name}' }, de: { greeting: 'Hallo {name}' } },
    }),
  ],
});

app.router.get('/', (ctx) => ctx.response.text(localizerFor(ctx).t('greeting', { name: 'Ada' })));

Options

LocalizationPluginOptions requires exactly one of catalogues and source — supplying both or neither is a compile error, and a JavaScript caller is refused at construction.

OptionTypeDefaultBehavior
supportedLocalesreadonly string[]—BCP 47 tags, canonicalized; the first is the default. Refused when empty, malformed, duplicated, longer than 35 characters (no client could select it), or unknown to the runtime’s Intl.
cataloguesRecord<string, MessageCatalogue>—Static catalogues keyed by tag, validated at register().
sourceIMessageSource—Loaded once at register() (a rejection fails startup), validated identically; the health indicator reports injected[:name].
allowPartialCataloguesbooleanfalseAccept a locale missing keys the default defines: one warning per locale at register(), the default’s message served for the gap.
onMissing'key' | 'throw''key'A key no catalogue defines answers the key (warned once per key, at most 256 tracked), or throws MissingMessageError.
timeZonestringruntime zoneIANA zone Date values are formatted in; refused at construction when Intl does not recognize it.
tenantLocale(tenant: ITenant) => string | undefined—The tenant’s default, consulted after Accept-Language; matched like any candidate. A throw propagates (application code, not client input).
middleware.enabledbooleantruefalse registers no global middleware; attach localeMiddleware(...) yourself.
middleware.prioritynumber45After tenant resolution (40), before logging (50). Must be an integer.
middleware.querystring | false'locale'The query parameter source; false disables it.
middleware.cookiestring | false'setu_locale'The cookie source; false disables it and drops Cookie from Vary.
middleware.excludereadonly PathPattern[]six probe pathsSkipped entirely — no resolution, no headers. Default /live, /ready, /health, /metrics, /openapi.json, /docs; [] disables.
exposeCatalogues{ basePath; cacheControl? }offRegisters GET <basePath>/:locale. basePath must be one or more plain path segments (letters, digits, ., _, ~, -), with no segment of only . or .. (URL parsing removes those, so the route would be unreachable), and not the root, which would claim every unrouted single-segment GET; cacheControl defaults to private, max-age=3600 (never shared: a session with rolling or idleTimeoutMs refreshes its cookie on every response) and is refused at construction when the platform’s Headers would reject it.

Exports

ExportKindPurpose
LocalizationPluginfunctionPlugin factory — registers the localizer, the resolution middleware, the opt-in catalogue route and a localization indicator
localizerForfunctionlocalizerFor(ctx) — the localizer for ctx.request.locale; the default locale when absent, negotiated when unsupported
localeMiddlewarefunctionThe resolution middleware, for an application registering it per route group under middleware.enabled: false
MissingMessageErrorclasst() under onMissing: 'throw' for a key no catalogue defines; carries key and locale
MissingPluralCountErrorclassA plural message formatted without a finite numeric count; carries key when known
UnsupportedLocaleErrorclassforLocale(tag) for a tag outside the supported set — callers pass configuration there, never client text
IMessageSourcetype{ name?; load(): Promise<Record<string, MessageCatalogue>> }
LocaleMiddlewareOptionstypelocaleMiddleware’s options
LocalizationPluginOptionstypeThe options above

The subpath @setu-ts/localization-plugin/format exports format(message, values, locale, options?), negotiateLocale(candidates, supported, excluded?), parseAcceptLanguage(value) and the types FormatValues, FormatOptions and AcceptLanguage. Its runtime graph is confined to its own modules — a test walks deno info --json to enforce it — so it is safe in a browser bundle.

Behavior notes

  • Resolution order: query parameter, cookie, Accept-Language, tenantLocale, then the default. Every candidate is canonicalized inside a guard (a malformed tag is “no match”) and matched against supportedLocales only, exactly, then with subtags stripped right to left (de-Latn-AT → de-Latn → de). A * range selects the first supported locale no q=0 range excludes; exclusion applies to * only, and the plugin never answers 406.
  • Bounded input: Accept-Language is cut to 1024 characters before splitting, at most 16 ranges are read, and a range over 35 characters is dropped; a query or cookie candidate over 35 characters is ignored before any canonicalization.
  • Headers: Vary: Accept-Language (plus Cookie while the cookie source is on) is appended before the handler on every governed response. Content-Language is written after the handler from the FINAL ctx.request.locale, only when that is a supported tag and the response has none; a rejected next() writes none. Vary: Cookie makes most CDN caching ineffective — the documented cost of the cookie source.
  • Missing keys never fail a request by default; a partial locale falls back to the default locale’s message, formatted in the REQUEST’s locale (numbers, dates and plural rules follow the reader), which is also what a browser formatting the served catalogue does. The catalogue route serves each locale overlaid on the default, so a browser falls back the same way.
  • The formatter escapes nothing; escaping belongs to the renderer, as for IViewEngine.render. In English count: 0 selects the other plural form.
  • Shared code, not identical output across runtimes: Intl data differs between runtimes, and a Date formats in the runtime’s zone unless timeZone is set on both sides.
  • No optionalDependencies on tenancy: the tenant default is read per request, which middleware priority (40 < 45) orders. The plugin declares CAPABILITIES.LOGGER only, so a partial-catalogue warning raised during register() reaches the logger.

Boundary-Type Compatibility

Applications own their third-party dependency resolution. Setu-TS therefore does not freeze a consumer’s dependency graph; it declares the third-party values it accepts at an application boundary and tests those claims in pnpm run check:compat.

Third-party valueOwning packagesSupported versionsBoundary and compatibility rule
Zod schemasvalidation-plugin, openapi-plugin, decorator-pluginzod >=4.6.6 <5Validation duck-types public safeParse and is tested with Zod 4 only; OpenAPI uses public toJSONSchema(). Schemas without that method degrade to {} with the existing unrepresentable diagnostic. Decorators forward schemas without inspecting them. OpenAPI normalizes type unions to anyOf — see Zod Version Support.
Drizzle database and table objectsdatabase-plugin0.45.2 baseline; range pendingThe application creates the database configuration and tables; the plugin loads its own query operators. Its exact lazy-loader import is a separate resolution-policy repair, so no broader application-instance range is claimed until that pin is removed and both endpoints are tested.
Prisma clientdatabase-pluginPending boundary repairPrisma v7 is the current application integration, but no formal range is claimed until the adapter stops its _activeProvider fallback and relies solely on the documented provider option plus public client methods.

GraphQL is intentionally absent: graphqlModule is a package-declared GraphqlModuleLike facade, not an opaque unknown value. Its compile-time shape and adapter tests cover that boundary.

Summary

The Setu-TS public API is designed for developer experience:

  1. Start minimal — Just kernel + runtime, add plugins as needed
  2. Everything is replaceable — Any plugin can be swapped via capability tokens
  3. Full programmatic API — No feature requires decorators or reflection
  4. Optional decorators — Available for those who prefer NestJS-style DX
  5. Type-safe — Full TypeScript support with no any in public APIs
  6. Runtime independent — Runs on Node.js, Deno, Bun, and Cloudflare Workers (future)
  7. Testable — Built-in test utilities, mock plugins, request injection
  8. Enterprise-ready — Auth, secrets, audit, resilience, multi-tenancy, feature flags