Setu-TS — Public API Contract
This document describes how developers use the framework. Implementation details are intentionally omitted.
Table of Contents
- Installation
- Full Stack Application
- Minimal Application
- Kernel diagnostics (
@setu-ts/kernel+@setu-ts/common) - createApplication() (
@setu-ts/kernel) - RuntimePlugin() (
@setu-ts/runtime) - LoggerPlugin() (
@setu-ts/logger-plugin) - ConfigPlugin() (
@setu-ts/config-plugin) - ValidationPlugin() (
@setu-ts/validation-plugin) - DatabasePlugin() (
@setu-ts/database-plugin) - AuthPlugin() (
@setu-ts/auth-plugin) - HttpSecurityPlugin() (
@setu-ts/http-security-plugin) - CachePlugin() (
@setu-ts/cache-plugin) - EventsPlugin() (
@setu-ts/events-plugin) - SsePlugin() (
@setu-ts/sse-plugin) - WebSocketPlugin() (
@setu-ts/websocket-plugin) - RealtimeBackplanePlugin() (
@setu-ts/realtime-backplane-plugin) - SessionPlugin() (
@setu-ts/session-plugin) - ReactRouterPlugin() (
@setu-ts/react-router-plugin) - WorkerPoolPlugin() (
@setu-ts/worker-pool-plugin) - Diagnostics Connector (
@setu-ts/diagnostics-plugin) - SecretsPlugin() (
@setu-ts/secrets-plugin) - AuditPlugin() (
@setu-ts/audit-plugin) - CQRS (
@setu-ts/cqrs-plugin) - Messaging (
@setu-ts/messaging-plugin) - Queue (
@setu-ts/queue-plugin) - Scheduler (
@setu-ts/scheduler-plugin) - Resilience (
@setu-ts/resilience-plugin) - Storage (
@setu-ts/storage-plugin) - MailPlugin() (
@setu-ts/mail-plugin) - Notifications (
@setu-ts/notification-plugin) - Feature Flags (
@setu-ts/feature-flags-plugin) - Multi-Tenancy Plugin (
@setu-ts/multi-tenancy-plugin) - Health (
@setu-ts/health-plugin) - Metrics (
@setu-ts/metrics-plugin) - Telemetry (
@setu-ts/telemetry-plugin) - OpenAPI (
@setu-ts/openapi-plugin) - CLI (
@setu-ts/cli) - REST API Application
- Microservice Application
- CQRS Application
- Plugin Creation
- Custom Middleware
- Custom Decorators
- Service Discovery (
@setu-ts/service-discovery-plugin) - Programmatic vs Decorator API
- Developer Ergonomics
- API Reference: @setu-ts/common
- API Reference: @setu-ts/kernel
- API Reference: @setu-ts/runtime
- API Reference: @setu-ts/exceptions
- API Reference: @setu-ts/di-plugin
- API Reference: @setu-ts/decorator-plugin
- Testing Package (
@setu-ts/testing) - SDK — Client SDK (
@setu-ts/sdk) - API Reference: @setu-ts/grpc-plugin
- API Reference: @setu-ts/cloudflare-plugin
- GraphQL (
@setu-ts/graphql-plugin) - Static Files Plugin (
@setu-ts/static-plugin) - View Plugin (
@setu-ts/view-plugin) - IdempotencyPlugin() (
@setu-ts/idempotency-plugin) - Localization Plugin (
@setu-ts/localization-plugin) - Boundary-Type Compatibility
- 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.
| Member | Node | Deno | Bun | Workers |
|---|---|---|---|---|
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.
| Option | Type | Consumer |
|---|---|---|
LoggerPluginOptions.excludePaths | readonly 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
| Transport | Description |
|---|---|
'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:
| Field | Value |
|---|---|
trace_id | The active span’s 32-hex trace identifier |
span_id | The 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.TELEMETRYinoptionalDependencies.TelemetryPluginalready declaresCAPABILITIES.LOGGERin its own, and the two edges together are a cycle the kernel’s plugin resolver refuses atstart(). The telemetry capability is therefore resolved at CALL time, which is required rather than merely prudent: the resolver ordersLoggerPluginfirst, so telemetry is guaranteed absent when the logger registers.One consequence is visible: the value registered under
CAPABILITIES.LOGGERis an internal decorator, not the transport instance, soinstanceof ConsoleLoggeron the resolved capability no longer holds.ILoggeris 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 receivedatdebug(method, path — the concrete path as the client sent it, not a route template). Setlevel: '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 callsdebug()and lets the logger filter.- One
request completedatinfo(method, path, status, duration in ms), including a request that threw: the status is the oneerrorHandler’s responder predicts throughstatusFor(500without it), so a customrespondhook 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.envfiles. Defaults to no file loading. When supplied, the runtime must providefs(absent on edge platforms).envFileOptional— Whentrue, a path inenvFilePaththat does not exist is skipped instead of throwing. Defaults tofalse, which is the behaviour released in0.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’sparse()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 aftervalidationSchemahas parsed the whole snapshot. Each section reads only its declared prefix-plus-key entries, parses them once at startup, and caches the output forgetConfigSection. Sections validate even wheninstanceis supplied.expandVariables— Whentrue(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 andenvFilePath,envFileOptional,validationSchema, andexpandVariablesare ignored; declaredsectionsstill 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):
- Environment variables (
runtime.env) - Earlier file paths (
.env.localoverrides.env) - 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
| Export | Kind |
|---|---|
ConfigPlugin | function |
ConfigPluginOptions | interface |
loadConfig | function |
StructuralSchema | interface |
defineConfigSection | function |
getConfigSection | function |
ConfigSection | interface |
ConfigDiagnosticsOptions | interface |
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.
| Backend | Tier-C behavior |
|---|---|
| Memory | Supported in one process; a loser is refused at commit and replays. |
| PostgreSQL / Drizzle | Supported; the claim insert blocks the loser until the winner commits, then the loser replays. Each blocked call holds a connection. |
| PostgreSQL / Prisma | As 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 / Drizzle | Supported with a Promise-aware transaction bridge; one writer at a time, with replay or busy/locked conflict depending on timing. |
| D1 | Supported 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 set | Supported with pre-existing collections; the concurrent loser gets conflict, then replays on retry. |
| DynamoDB | Supported 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 standalone | Refused with mongodb-standalone. |
| Cosmos DB | Refused with cosmos-unsupported: the record cannot share the business partition. |
| Bigtable | Refused 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.
| Adapter | Honoured levels |
|---|---|
| Prisma | PostgreSQL, MySQL, and SQL Server: all four; CockroachDB and SQLite: serializable; MongoDB: none |
| Drizzle | all four only when the application wraps its bridge with withIsolationSupport() |
| Memory | serializable (one process only) |
| MongoDB | none |
| D1, DynamoDB, Cosmos DB, Bigtable | none |
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 source | Write mechanism |
|---|---|
| Memory, outside transactions | Synchronous key lookup, predicate check and mutation |
| Prisma | Native update/delete, key conjoined with AND: [where]; P2025 means no match |
| Drizzle | Key and predicate joined with and(...), native RETURNING |
| MongoDB | $and: [idFilter, whereFilter], findOneAndUpdate / deleteOne |
| D1 | Bound UPDATE / DELETE, WHERE key AND predicate, RETURNING |
| DynamoDB | Native ConditionExpression, key existence AND aliased equality fields |
| Cosmos DB | Read and check, then _etag IfMatch guarded replace / delete; retry 412 at most three rounds |
| Bigtable | CheckAndMutateRow 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.
| Symbol | Kind | Purpose |
|---|---|---|
EntityKey | type | A 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. |
PageResult | interface | { rows, nextCursor } returned by IDataSource.findPage. nextCursor is null exactly when the page is the last. |
CursorPayload | interface | The decoded contents of a cursor: orderedValues, keyValues, and a sortFingerprint. |
CursorValue | type | A scalar a cursor can carry: string | number | Date. Dates survive the round trip as Date, not as ISO strings. |
encodeCursor | function | Encodes a CursorPayload as an opaque base64url token. |
decodeCursor | function | Decodes a token, or returns null when it is malformed — it never throws, because the token is untrusted caller input. |
keysetPredicate | function | Builds the “row after this one” comparison as a portable FilterExpression. |
sortFingerprint | function | The stable fingerprint embedded in every cursor; optional scope binds entity and key columns (since 0.11.0). |
mintNextCursor | function | Mints 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. |
resolveKeysetSort | function | The 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.
| Adapter | Translation |
|---|---|
| Memory | walks the document |
| Mongo | the native dotted key (address.city) |
| Prisma | the JSON path filter — an array on PostgreSQL/CockroachDB, $.a.b on MySQL |
| Drizzle | per dialect: PostgreSQL #>>, MySQL JSON_UNQUOTE(JSON_EXTRACT(…)), SQLite json_extract |
| D1 | refused 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:
inis expanded, not translated. Neither Prisma’s JSON path filter nor any SQL dialect offers a path membership operator, soinbecomes anORof 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
numbercasts back. PostgreSQL’s#>>and MySQL’sJSON_UNQUOTEare text-valued while SQLite’sjson_extractpreserves the JSON type, so without normalising, one filter would mean two different things. Casting back forgt/gte/lt/lteon a number is what makesage > 9match30— 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
| Export | Kind |
|---|---|
DatabasePlugin | factory |
DatabaseService | class |
BaseRepository, UnitOfWork | classes |
MemoryAdapter, PrismaAdapter, DrizzleAdapter, MongoAdapter, DynamoAdapter | classes |
PrismaRepository, DrizzleRepository | classes |
UnsupportedFilterOperatorError, UnsupportedIsolationLevelError, UnsupportedMigrationError, UnsupportedRawQueryError, UnsupportedQueryFeatureError | classes |
SerializationConflictError, DatabaseUnavailableError, DatabaseTimeoutError, UnknownPayloadFieldError, MissingKeyColumnError, TenantColumnUnresolvedError | classes |
PageOptions, Page | types |
PrismaCompositeKeyOptions, DrizzleCompositeKeyOptions | types |
EntityKey, PageResult, CursorPayload, CursorValue (re-exported from common) | types |
encodeCursor, decodeCursor, keysetPredicate (re-exported from common) | functions |
MongoTransactionUnavailableError | class |
CosmosAdapter | class |
CosmosTransactionScopeError, CosmosConcurrentModificationError | classes |
CosmosDatabaseOptions, CosmosAdapterOptions, CosmosAdapterOptionsBase, CosmosEntityMapping | types |
ICosmosClient, ICosmosDatabase, ICosmosContainer, ICosmosItems, ICosmosItem, ICosmosQueryIterator | interfaces (Cosmos injection seam) |
CosmosQuerySpec, CosmosQueryParameter, CosmosPartitionKeyValue | types (Cosmos query seam) |
CosmosItemResponse, CosmosFeedResponse, CosmosRequestOptions, CosmosAccessCondition, CosmosPatchOperation, CosmosBatchOperation, CosmosBatchInsertOperation, CosmosBatchReplaceOperation, CosmosBatchPatchOperation, CosmosBatchDeleteOperation, CosmosBatchResponse, CosmosContainerDefinition | interfaces (Cosmos operation seam) |
PrismaSqlProvider | type |
SqlJsonDialect | type |
createPrismaDataSource, createDrizzleDataSource, createDrizzleDatabase, getDrizzleDatabase, getDrizzleTransaction, withIsolationSupport | functions |
DrizzleDatabase, DrizzleDatabaseIdentity, DrizzleTransaction, DrizzleTransactionBridge | types |
IDatabaseService, IRepository, IUnitOfWork | interfaces |
DatabasePluginOptions, BuiltInDatabaseOptions, CustomDatabaseOptions, DatabaseConnectionOptions | types |
MemoryDatabaseOptions, PrismaDatabaseOptions, DrizzleDatabaseOptions, MongoDatabaseOptions, DynamoDatabaseOptions | interfaces |
MongoAdapterOptions (union of two arms), MongoAdapterOptionsBase, MongoEntityMapping | types |
PrismaAdapterOptions, DrizzleAdapterOptions | interfaces |
DatabaseAdapterType, DatabaseAdapterOptions | types |
FindOptions, CountOptions, OrderDirection, FilterOperator, FilterComparison, FilterExpression | types |
IDatabaseAdapter, IAdapterTransaction, IDataSource, NormalizedQuery, TransactionIsolationLevel, TransactionOptions | re-exports from common |
DataSource | deprecated alias of IDataSource |
IMongoClient, IMongoDatabase, IMongoObjectId, IMongoObjectIdCtor, IMongoSession | interfaces (Mongo injection seam) |
IMongoCollection, IMongoCursor, IMongoCollectionFindOneAndUpdateOptions, MongoOptions, MongoWriteOptions | interfaces (Mongo collection seam) |
DynamoAdapterOptions (union of two arms), DynamoAdapterOptionsBase, DynamoEntityMapping, DynamoDateEncoding, DynamoIndexMapping | types |
IDynamoClient, DynamoAttributeValue, DynamoAttributeMap, DynamoExpressionAttributes, DynamoConditionExpression, and the Dynamo*CommandInput/Output/Transact* command-shape closure | interfaces (DynamoDB client seam) |
DynamoClientConfiguration, DynamoClientLoader, DynamoCommandConstructor, DynamoSdkClient, DynamoSdkCommand, DynamoSdkModule | types (DynamoDB SDK seam) |
createInjectedDynamoLoader, createLazyDynamoLoader | functions |
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' });
| Export | Kind | Notes |
|---|---|---|
createDatabaseOutboxStore | function | Resolves CAPABILITIES.DATABASE, or database.<name> for database: '<name>' ('default' is the bare token). Throws TypeError at the call for an empty entity |
DatabaseOutboxStoreOptions | interface | entity? (default 'Outbox'), database? — must be the database the caller’s unit of work belongs to, or the row is never relayed by this store |
OutboxStoreUnavailableError | class | Rejected 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 anotherkindasmissing. - Conditional transitions.
claim(id, { claimVersion, leaseUntil })requireskind,pendingand the read version, increments it by one and sets the lease.markSent/markFailurerequire the held version, includingdeleteNowthroughdeleteWhere; a pending version miss isclaim-lost. Status misses arenot-pendingornot-failed; another kind ismissing.markInvalid(id, now)uses only kind/status, writesfailed/invalid-row, clears the lease and preserves attempts.markFailureandrelease('retry')clear the lease; release preserves the version. Unexplained misses retry at most three rounds. Native conditional writes are required; no fallback remains.sentByis only an operator diagnostic, absent from outcomes. - Columns. The row is the record’s own fields; an absent optional field is written as
nulland read back as absent.failedKeysselects onlytenantIdandorderingKey;purgedeletessentthendiscardedrows withsettledAt < before, at mostlimitper status. verify()runsscanPending(undefined, 1), thentransaction(uow => uow.getRepository(entity).findAll({ where: { kind, status: 'pending' }, limit: 1 })), thenupdateWhere('setu-outbox-claim-probe', { kind, status: 'pending', claimVersion: -1 }, { claimVersion: 0 })anddeleteWherewith the same id and predicate — both, becauseretainSentMs: 0deletes a sent row conditionally after it is published. No row can carry version-1(appendwrites0and a claim only increments), so neither probe writes, even to a row appended under the probe id. The old SQL schema withoutclaim_versionis refused at the first step, the relay query. Aconditional-writerefusal isconditional-writes-unsupported. AnUnsupportedQueryFeatureErrorfrombigtableis'bigtable'; one fromdynamodbfororderByis'dynamodb-index'(a GSI{ partitionKey: 'status', sortKey: 'position' }, projectionALL, is required); a cause chain carryingcode: 20withcodeName: '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' });
| Export | Kind | Notes |
|---|---|---|
createDatabaseInboxStore | function | Resolves CAPABILITIES.DATABASE (or database.<name>) and CAPABILITIES.RUNTIME. Throws TypeError at the call for an empty entity |
DatabaseInboxStoreOptions | interface | entity? (default 'Inbox'), database? — must be the database the handlers write to |
InboxStoreUnavailableError | class | Rejected by verify(); entity, reason ('cosmos-unsupported', 'bigtable-unsupported', 'mongodb-standalone', 'transaction-scope', 'entity-unavailable'), the adapter error as cause when there is one |
runopens 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’sDuplicateKeyError, 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 anotherkindis missing. - Conditional writes.
recordFailurecompareskindand the readattemptsvalue in the write, retrying misses at most five rounds;releaserequireskindandstatus: '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, runspurge(0, 1), then a transaction that creates two probe rows and always rolls back: aBigtableTransactionScopeErrorat the second row is'transaction-scope', a cause chain carryingcode: 20withcodeName: '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' }),
},
});
| Option | Grant source (DatabaseGrantSourceOptions) | Role source (DatabaseRoleSourceOptions) |
|---|---|---|
entity | Required: one row per grant | Required: one row per (scope, role, permission) |
fields | GrantFields — subject / role / scopeType / scopeId (those defaults) | RoleFields — scopeType / scopeId / role / permission (those defaults) |
name | Log name (default 'database-grants') | Log name (default 'database-roles') |
limit | Most matching rows, 1–10 001 (default 10 001); more refuses the question | Most 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:
IJwtServiceunderCAPABILITIES.JWT('jwt') — sign/verify/decode JWTs, only whenjwtis supplied.IAuthServiceunderCAPABILITIES.AUTH('authentication') — passive strategy chain + login.IAuthorizationServiceunderCAPABILITIES.AUTHORIZATION('authorization') — RBAC checks, only whenrbacis supplied.IAuthorizationPolicyServiceunderCAPABILITIES.AUTHORIZATION_POLICIES('authorization-policies') — asynchronous, target-aware policy checks (M110a), ALWAYS registered, holding thepoliciesoption’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):
sessiontakes aSessionAuthOptionswhose single required member,toPrincipal(view), maps the openedSessionViewto the principal it carries — returningnullcontinues the chain. When present, the plugin appends an internalSessionStrategyafter the API-key strategy and requires thesessioncapability (SessionPlugin) to be registered, orregister()throws naming both plugins.strategiesaccepts caller-suppliedIAuthStrategys, appended after every built-in in declaration order; anamecolliding with any other strategy in the assembled chain throws atregister(). 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):
jwtverifies 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 }—audienceandtoPrincipalrequired. The internalissuersstrategy runs immediately after the JWT strategy, reading the same header and scheme; the chain is jwt → issuers → api-key → session → caller-supplied. A token’sissis read unverified only to select the entry whoseissuerequals it exactly.noneandHS*are refused before key lookup; keys are filtered bykty/crv/use/alg/key_ops/kid; RS256, PS256, ES256, ES384 and EdDSA (Ed25519) verify throughruntime.subtlewith zero npm dependencies.issexact,audcontainsaudience,exprequired,exp/nbf/futureiatwithinclockToleranceSec(default 30, max 300). Expiry is refused atnow >= exp + clockToleranceSec; not-before is refused whilenow + clockToleranceSec < nbf. Key sets are cached on the monotonic clock (keySet.ttlMs10 min), an unknownkidrefetches at most once perkeySet.minRefreshIntervalMs(60 s) with concurrent refetches coalesced, the last good set survives fetch failures forkeySet.maxStaleMs(24 h) and is then dropped, and each fetch is bounded bykeySet.fetchTimeoutMs(5 s), 64 KiB and 64 keys. Every refusal — duplicate name or issuer, empty audience, unsupported algorithm, out-of-range tolerance or timing, akeySet.ttlMsabovekeySet.maxStaleMs,httpwithoutissuersorsignIn, non-httpsnon-loopback URL — throwsAuthPluginConfigurationErrorfromAuthPlugin(...).http?: IAuthHttpreplaces the defaultfetchseam. Theauthhealth indicator staysupwithdata.current: booleananddata.issuerscarrying per-issuerstale/expired/unfetchedstates. It performs no I/O.typ: at+jwt(RFC 9068) is not enforced (only a Keycloaktyp: "ID"claim is refused), so the API needs an audience distinct from any sign-in client id; multi-tenant Entra ID is not supported.IJwtServiceremains self-issued only.subis unique only within its issuer, so whenever an issuer is not the only identity source — another issuer, a self-issuedjwt(whose strategy also mapssubto the id), an API key, a session or a custom strategy —toPrincipalshould 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.fetchTimeoutMsmay 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 requiresSessionPlugin(register()throws naming both plugins otherwise), addsCAPABILITIES.AUTH_SESSIONtoprovidesand registers the plugin’sIAuthSessionServicethere, and appends an internalauth-sessionstrategy after thesessionstrategy (chain jwt → issuers → api-key → session → auth-session → caller-supplied) which returns the stored principal withclaims.amrset to the recorded methods, overwriting anyamrthe principal carried.SignInProvideris a union discriminated onkind:OidcProvider(issuer; endpoints and keys from the same issuer-checked discovery document M100b uses;scopesmust containopenid; optionalrpInitiatedLogout: { postLogoutRedirectUri, idTokenHint?: true }) andOAuth2Provider(authorizationEndpoint,tokenEndpoint,userinfoEndpoint). Both sharename(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)andonTokens?(tokens: ProviderTokens). Routes:GET <basePath>/<name>/login,GET <basePath>/<name>/callback, and onePOST <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 neverresponse_mode, and answers503provider-unavailablewhen discovery cannot be read.returnTois 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 with401(or afailureRedirect?error=<code>redirect) and one of the fixed codesprovider-denied,state-invalid(unknown, replayed, expired, another provider’s, or an RFC 9207issnaming another issuer),exchange-failedandprofile-unavailable, throughrespondWithError;toPrincipalreturningnullor throwing answers403principal-refused. The attempt is consumed before the exchange. The ID token is verified by the M100b verifier withaudience = clientId, plusiss === issuer,nonceequality and, for several audiences,azp === clientId. Discovered endpoints must behttps(or loopbackhttp) and printable ASCII — an endpoint with a space, control character or non-ASCII code point answers the login503and drops RP-initiated logout to a local one — and theoauth2userinfo read sends the access token asAuthorization: Bearer. On success the callback callsIAuthSessionService.signIn(ctx, principal, { methods: ['fed'] })(session id regenerated), hands the tokens toonTokens(a throwingonTokensdoes not undo the sign-in), and redirects to the storedreturnTo. Logout callssignOutand redirects to/, or — for the one provider allowed to setrpInitiatedLogout, and only when the session signed in through it (IAuthSessionService.signInclears that marker) — to itsend_session_endpointwithclient_id,post_logout_redirect_uriand, only whenidTokenHint: truestored one,id_token_hint.refreshPrincipal(stored)re-reads the principal per request (nullor a throw → anonymous). WhatsignOutrevokes depends on the session strategy: the store strategy deletes the entry, the default cookie strategy leaves a copied cookie valid until itsmaxAge. AsameSite: 'strict'session cookie is not sent on the provider’s redirect, so every callback fails closed asstate-invalid. Every configuration refusal throwsAuthPluginConfigurationErrorfromAuthPlugin(...).Phasing (M16b, shipped): refresh tokens and rate limiting shipped in M16b as standalone additions —
RefreshTokenService(app-instantiated; NOT anIAuthStrategy, since a refresh token arrives in the request body, not as a passive header credential) andrateLimitMiddleware(a decoupled middleware factory with no capability token). Neither is anAuthPluginoption.IJwtServicestill exposes onlysign/verify/decode— a refresh token is a signed JWT carryingtype: 'refresh'and ajti.
Session and account lifecycle (M124, 0.11.0):
signIn.providersmay be empty with neithermfanorpasskeys— a password-only sign-in that registersCAPABILITIES.AUTH_SESSION, theauth-sessionstrategy and the logout route only; a non-arrayprovidersstill throwsAuthPluginConfigurationError.signIn.sessionStamp?: SessionStamp, whereSessionStamp = (principal: IPrincipal) => string | null | Promise<string | null>, is read once byIAuthSessionService.signInbefore any write (a throw, or a value other than a string ornull, rejectssignInand records nothing); its base64url SHA-256 digest (ornull) is stored asstampin the signed-in record, or in the pending second-factor record and copied on promotion. On every request theauth-sessionstrategy resolves the principal (snapshot orrefreshPrincipal), callssessionStampwith it, and compares digests in constant time; a mismatch, a throw or a malformed value makes the request anonymous. A record withoutstampreads asnull, which matches only a user whose stamp isnull. 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-functionsessionStampthrowsAuthPluginConfigurationErrorfromAuthPlugin(...). No API bumps the stamp: the application changes it on its own user record.requireAuth(options?: RequireAuthOptions),RequireAuthOptions = { readonly signInPage?: string }: without options, unchanged. WithsignInPage(a same-origin absolute path thatsafeReturnTokeeps unchanged, elseAuthPluginConfigurationErrorwhen the guard is built), an unauthenticatedGET/HEADthat is notX-Requested-With: XMLHttpRequestand carriesSec-Fetch-Mode: navigate(or, without that header, anAcceptnamingtext/htmlorapplication/xhtml+xml) is answered302tosignInPagewithreturnTo=safeReturnTo(path + query)added to its query; every other unauthenticated request, and a request forsignInPage’s own path, gets the existing 401. Both unauthenticated answers appendAccept,Sec-Fetch-ModeandX-Requested-WithtoVary.safeReturnTo(value: string | undefined, fallback = '/'): stringis exported unchanged: it returnsvaluewhen 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), elsefallback.
Exports
| Export | File | Description |
|---|---|---|
AuthPlugin | src/plugin/auth-plugin.ts | Plugin factory |
AuthPluginOptions | src/interfaces/index.ts | Plugin factory options (jwt / issuers / signIn / http / apiKey / local / rbac / policies / session / strategies / authorizationDiagnostics) |
AuthMiddlewareOption | src/interfaces/index.ts | Global authentication middleware priority and path exclusions |
AuthPluginConfigurationError | src/errors.ts | Invalid passive-strategy or middleware configuration |
AuthorizationDiagnosticsOptions | src/interfaces/index.ts | M98h opt-in for minimized authorization decision explanations through the diagnostics connector |
IAuthorizationDiagnosticsSource | re-export | From @setu-ts/common — the source AuthPlugin registers under CAPABILITIES.AUTHORIZATION_DIAGNOSTICS |
JwtOptions | src/interfaces/index.ts | JWT config (key material, algorithm, expected aud/iss, header/scheme) |
ApiKeyOptions | src/interfaces/index.ts | API-key strategy config (header + validate callback) |
LocalOptions | src/interfaces/index.ts | Local credential config (verify callback) |
SessionAuthOptions | src/interfaces/index.ts | Session strategy config (required toPrincipal callback) |
TrustedIssuer | src/interfaces/index.ts | M100b outside identity provider: issuer, audience, key source, algorithms, clock tolerance, key-set timings, toPrincipal |
IssuerKeySource | src/interfaces/index.ts | { jwksUri } or { discovery: true } |
IssuerAlgorithm | src/interfaces/index.ts | 'RS256' | 'PS256' | 'ES256' | 'ES384' | 'EdDSA' issuer allowlist entry |
IAuthHttp | src/interfaces/index.ts | Outbound seam: get for issuer key sets and discovery documents, post (form-encoded) for the sign-in token exchange |
SignInConfig | src/interfaces/index.ts | M100c signIn option: basePath, providers, refreshPrincipal, sessionStamp (M124); providers: [] is a password-only sign-in |
SignInProvider | src/interfaces/index.ts | OidcProvider | OAuth2Provider | SamlProvider, discriminated on kind (M100f added the saml arm) |
SignInProviderBase | src/interfaces/index.ts | Fields both provider arms share (name, clientId, credentials, redirectUri, toPrincipal, …) |
OidcProvider | src/interfaces/index.ts | OpenID Connect provider: issuer (discovery), optional rpInitiatedLogout |
OAuth2Provider | src/interfaces/index.ts | Plain OAuth 2.0 provider: authorization, token and userinfo endpoints |
TokenEndpointAuth | src/interfaces/index.ts | 'client_secret_basic' | 'client_secret_post' | 'none' |
ProviderTokens | src/interfaces/index.ts | The provider’s token response, handed to onTokens and stored nowhere else |
RefreshPrincipal | src/interfaces/index.ts | signIn.refreshPrincipal: per-request re-read of the stored principal; null → anonymous |
SessionStamp | src/interfaces/index.ts | M124 signIn.sessionStamp: reads the user’s stamp; its digest is recorded at sign-in and compared per request |
PasswordHasher | src/services/password-hasher.ts | PBKDF2-SHA256 hash/verify utility; 600,000 iterations by default, needsRehash for upgrades |
MalformedPasswordHashError | src/services/password-hasher.ts | Thrown by PasswordHasher.verify when stored is not a well-formed hash |
PasswordHasherOptions | src/services/password-hasher.ts | M113e: { iterations? } — the PBKDF2 count for new hashes, 100,000–10,000,000, default 600,000 |
authMiddleware | src/middleware/auth-middleware.ts | Global middleware: authenticates and populates ctx.request.user |
requireAuth | src/guards/index.ts | Guard: require an authenticated principal (401); M124 { signInPage } redirects a browser navigation (302 with returnTo) |
RequireAuthOptions | src/interfaces/index.ts | M124 requireAuth options: signInPage? |
safeReturnTo | src/sign-in/return-to.ts | M124 export: a same-origin returnTo path, else the fallback |
requireRole | src/guards/index.ts | Guard: require a role (401/403) |
requirePermission | src/guards/index.ts | Guard: require a permission (401/403) |
requireAnyRole | src/guards/index.ts | Guard: require any of the given roles |
requireAllPermissions | src/guards/index.ts | Guard: require all of the given permissions |
publicRoute | src/guards/index.ts | Guard: explicitly allow unauthenticated access |
definePolicy | src/policies/define-policy.ts | M110a: validates a policy and returns a frozen copy typed by its ability names and target; throws AuthPluginConfigurationError when malformed |
requirePolicy | src/policies/policy-guard.ts | M110a route guard over IAuthorizationPolicyService: 501 / 401 / 403 / next(); target value or per-request extractor; refused at startup when its policy ability is not registered |
AuthorizationDeniedError | src/policies/errors.ts | M110a: authorize’s denial rejection — failure / policy / ability, branded with the guards’ 401/403 status hint |
UnknownPolicyError | src/policies/errors.ts | M110a: rejection for an unregistered policy or ability (policy, ability?); unbranded, so a masked 500 |
PolicyDenial | src/policies/errors.ts | M110a: 'authentication-required' | 'insufficient-privileges' |
requireScopedRole | src/scoped/scoped-guards.ts | M110b 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 |
requireScopedPermission | src/scoped/scoped-guards.ts | M110b route guard: all-of catalogue permissions in the request’s scope; otherwise as requireScopedRole |
ScopedGuardOptions | src/scoped/scoped-guards.ts | M110b: { scope?: ScopeSource } for the two scoped guards |
GrantResolutionReason | src/scoped/errors.ts | M110b: 'source-failed' | 'source-timeout' | 'grant-limit' — GrantResolutionError.reason |
GrantResolutionError | src/scoped/errors.ts | M110b: sign-in-timing grant resolution failed (reason, source?); 503 status hint, no caller input in the message |
ScopedRbacOptions | src/interfaces/index.ts | M110b: 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 |
GrantSourceConfig | src/interfaces/index.ts | M110b: static / claims / custom grant source arms |
StaticGrant | src/interfaces/index.ts | M110b: { subject, role, scope } written into configuration |
ClaimsGrantMapper | src/interfaces/index.ts | M110b: (claims, principal) => ScopedGrant[] for the claims arm |
ScopedRoleLimit | src/interfaces/index.ts | M110b: grantableIn entry — scopeTypes, global? |
ScopedRbacTiming | src/interfaces/index.ts | M110b: 'request' / 'sign-in' / { kind: 'cache', ttlMs, maxEntries } |
requireMfa | src/guards/index.ts | M100d step-up guard: 401 anonymous, 403 second-factor-required when claims.amr lacks otp or pop; a non-array amr counts as absent |
TotpService | src/mfa/totp-service.ts | M100d app-instantiated TOTP service: beginEnrolment / confirmEnrolment / verify / verifyRecoveryCode / generateRecoveryCodes / disable / completeSignIn / completeSignInWithRecoveryCode |
TotpServiceOptions | src/mfa/totp-service.ts | TotpService constructor options (store / runtime / issuer) — deliberately NO TTL option |
TotpVerifyResult | src/mfa/totp-service.ts | 'ok' | 'invalid' | 'locked' | 'not-enrolled' |
RecoveryVerifyResult | src/mfa/totp-service.ts | 'ok' | 'invalid' | 'locked' for a recovery-code check |
TotpProofResult | src/mfa/totp-service.ts | 'proof-required' | 'invalid' | 'locked' | 'not-enrolled' — why a factor-changing call was refused |
ConfirmEnrolmentResult | src/mfa/totp-service.ts | confirmEnrolment(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 |
RecoveryCodesResult | src/mfa/totp-service.ts | generateRecoveryCodes(principalId, proof): { status: 'ok', recoveryCodes } replacing the set, or { status: TotpProofResult } — always needs proof of the current factor |
DisableResult | src/mfa/totp-service.ts | disable(principalId, proof?): 'ok' or a TotpProofResult; a confirmed factor needs proof of itself |
TotpCompleteSignInResult | src/mfa/totp-service.ts | 'signed-in' | 'invalid' | 'locked' | 'not-enrolled' | 'no-pending' |
ITotpStore | src/stores/totp-store.ts | M100d 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 |
TotpEnrolment | src/stores/totp-store.ts | Stored factor record: secret, label, confirmed, monotonic lastClaimedStep, and the optional secret/label awaiting confirmation |
ReserveAttemptResult | src/stores/totp-store.ts | { allowed, count } from reserveAttempt — reserved BEFORE the credential is checked; a refused attempt is not recorded |
MemoryTotpStore | src/stores/totp-store.ts | Single-process ITotpStore; takes no constructor arguments (callers pass now to reserveAttempt) |
MfaOptions | src/interfaces/index.ts | M100d 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) |
PasskeyOptions | src/interfaces/index.ts | M100e 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) |
PasskeyRegistrationContext | src/interfaces/index.ts | M100e: what PasskeyOptions.mayRegister is told — the signed-in principal, the session’s recorded methods, and the principal’s existing credentialCount |
IPasskeyStore | src/stores/passkey-store.ts | M100e 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 |
StoredPasskey | src/stores/passkey-store.ts | Stored credential record: id, principal id, base64url user handle, JWK public key, COSE algorithm, counter, backedUp (display only), transports, attestation: 'unverified', created time |
MemoryPasskeyStore | src/stores/passkey-store.ts | Single-process IPasskeyStore; takes no constructor arguments; purges expired challenge claims in an amortized sweep so the claim map stays bounded |
PasskeySaveOptions | src/stores/passkey-store.ts | M100e: what IPasskeyStore.save enforces atomically with the insert — maxPerPrincipal |
PasskeySaveResult | src/stores/passkey-store.ts | M100e: IPasskeyStore.save’s outcome — 'saved', 'duplicate' or 'limit' |
SamlProvider | src/interfaces/index.ts | M100f 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) |
SamlProfile | src/interfaces/index.ts | M100f: the frozen verified identity SamlProvider.toPrincipal receives — issuer, nameID, optional nameIDFormat / sessionIndex, and attributes (string, or array for a multi-valued attribute) |
SamlModule | src/interfaces/index.ts | M100f: structural shape of an injected SAML library ({ SAML }); replaces the lazy @node-saml/node-saml import |
ISamlRequestStore | src/stores/saml-request-store.ts | M100f 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 |
SamlPendingRequest | src/stores/saml-request-store.ts | M100f pending login: requestId, provider, returnTo, binding (the cookie value), issuedAt, expiresAt (ms, 10 minutes after login) |
MemorySamlRequestStore | src/stores/saml-request-store.ts | M100f 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 |
MemorySamlRequestStoreOptions | src/stores/saml-request-store.ts | M100f options for MemorySamlRequestStore: maxPendingRequests? (positive integer; default DEFAULT_MAX_PENDING_SAML_REQUESTS) bounds what an unauthenticated login flood can hold |
DEFAULT_MAX_PENDING_SAML_REQUESTS | src/stores/saml-request-store.ts | M100f default pending-request cap of MemorySamlRequestStore: 10_000 |
SamlRuntimeLoadError | src/errors.ts | M100f: 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 |
RefreshTokenService | src/services/refresh-token-service.ts | Refresh tokens: issue / refresh (rotation) / revoke |
RefreshTokenOptions | src/services/refresh-token-service.ts | RefreshTokenService constructor options |
TokenPair | src/services/refresh-token-service.ts | { accessToken, refreshToken } returned by issue/refresh |
RefreshTokenStore | src/stores/refresh-token-store.ts | Pluggable async store interface for refresh-token records |
RefreshTokenRecord | src/stores/refresh-token-store.ts | Record shape store implementations produce/consume |
IRefreshTokenRotation | src/stores/refresh-token-store.ts | Result of atomically rotating a refresh record |
MemoryRefreshTokenStore | src/stores/refresh-token-store.ts | Default in-memory store with lazy expiry on get and amortised expiry sweeps on save and rotate; unexpired revoked records are retained for reuse detection |
IAccessTokenRevocationStore | src/stores/access-token-revocation-store.ts | Pluggable bounded access-token revocation interface |
MemoryAccessTokenRevocationStore | src/stores/access-token-revocation-store.ts | Single-process access-token revocation store with lazy expiry |
rateLimitMiddleware | src/middleware/rate-limit-middleware.ts | Fixed-window rate limiter middleware factory (429 short-circuit) |
RateLimitOptions | src/middleware/rate-limit-middleware.ts | rateLimitMiddleware(options) parameter, including exclude |
DEFAULT_RATE_LIMIT_EXCLUDED_PATHS | src/middleware/rate-limit-middleware.ts | The six operational paths exclude exempts by default; spread it to extend rather than replace |
RateLimitStore | src/stores/rate-limit-store.ts | Pluggable store interface (increment/reset) |
RateLimitResult | src/stores/rate-limit-store.ts | { count, resetTime } returned by increment |
MemoryRateLimitStore | src/stores/rate-limit-store.ts | Default in-memory fixed-window store |
RedisRateLimitStore | src/stores/redis-rate-limit-store.ts | Redis-backed store (inject-or-lazy ioredis), namespacing keys under keyPrefix |
DEFAULT_RATE_LIMIT_KEY_PREFIX | src/stores/redis-rate-limit-store.ts | 'setu:ratelimit:' — the namespace RedisRateLimitStore applies when no keyPrefix is given |
IAuthService | re-export | From @setu-ts/common |
IJwtService | re-export | From @setu-ts/common |
IAuthorizationPolicyService | re-export | From @setu-ts/common (M110a) |
IAuthorizationService | re-export | From @setu-ts/common |
IAuthStrategy | re-export | From @setu-ts/common |
IPrincipal | re-export | From @setu-ts/common |
JwtSignOptions | re-export | From @setu-ts/common |
RbacConfig | re-export | From @setu-ts/common |
RoleDefinition | re-export | From @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
| Export | Description |
|---|---|
HttpSecurityPlugin | Plugin factory |
corsMiddleware | CORS middleware factory |
securityHeadersMiddleware | Security headers middleware factory |
csrfMiddleware | CSRF middleware factory |
requestSizeMiddleware | Request-size middleware factory |
ipSecurityMiddleware | IP security middleware factory |
HttpSecurityPluginOptions | Plugin factory options (type) |
CorsOptions | CORS middleware options (type) |
CorsOriginMatcher | Dynamic origin matcher function (type) |
SecurityHeadersOptions | Security headers options (type) |
ContentSecurityPolicyOptions | CSP directive options (type) |
StrictTransportSecurityOptions | HSTS options (type) |
CsrfOptions | CSRF middleware options (type) |
RequestSizeOptions | Request-size options (type) |
IpSecurityOptions | IP security options (type) |
Options
| Option | Type | Default | Description |
|---|---|---|---|
cors? | CorsOptions | — | Presence enables CORS (priority 200). |
headers? | SecurityHeadersOptions | default secure set | Omitted → 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
| Export | File | Description |
|---|---|---|
CachePlugin | src/plugin/cache-plugin.ts | Plugin factory |
CacheService | src/services/cache-service.ts | Wrapper applying prefix + defaultTTL |
MemoryStore | src/stores/memory-store.ts | In-memory LRU + TTL store |
RedisStore | src/stores/redis-store.ts | Redis store via ioredis |
NoopStore | src/stores/noop-store.ts | No-op store (dev/test) |
cacheMiddleware | src/middleware/cache-middleware.ts | Transparent response-caching middleware |
CacheStoreType | src/interfaces/index.ts | 'memory' | 'redis' | 'noop' |
CacheStoreOptions | src/interfaces/index.ts | Store-specific options |
CachePluginOptions | src/interfaces/index.ts | Plugin factory options |
IRedisClient | src/interfaces/index.ts | Structural ioredis shape |
CacheMiddlewareOptions | src/interfaces/index.ts | Middleware options |
CachedResponsePayload | src/interfaces/index.ts | Cached response shape |
ICacheStore | src/interfaces/index.ts | Re-export from @setu-ts/common |
CacheDiagnosticsOptions | src/interfaces/index.ts | Opt-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.
| Status | Meaning |
|---|---|
up | The store is connected and reachable, or cannot be probed (reachable is 'unknown'). |
down | The 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
| Option | Type | Default | Behavior |
|---|---|---|---|
ttlSeconds | number | store default | Per-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 |
store | string | CAPABILITIES.CACHE | Capability token for the cache store to use |
cacheableStatuses | number[] | [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 anyhandlerssupplied.EventHandlerRegistration— One{ type, handler }pair for thehandlersoption. 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 implementingIEventBus.DomainEvent— Base class for domain events, generated bydefineDomainEvent.IntegrationEvent— Semantic subclass ofDomainEventfor integration events (no additional fields).defineDomainEvent— Factory that bindsDomainEventandIntegrationEventto a runtime, returning event IDs and timestamps from the runtime’suuidandnowservices.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 emptyIDomainEventsrecorder, intended for an aggregate field rather than a framework base class.IEventHandler— Class-based event handler interface with ahandle(event)method.subscribeHandler— Function that adapts anIEventHandlerinstance to theEventHandlersignature and subscribes it to the bus; returns anUnsubscribefunction.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 theeventsmap 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
| Option | Type | Default | Description |
|---|---|---|---|
heartbeatMs | number | omitted | When set, sends : heartbeat\n\n at this interval. |
retryMs | number | omitted | When set, sends retry: <ms>\n\n as the first frame. |
scalingNotice | boolean | true | Logs 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. |
diagnostics | RealtimeDiagnosticsOptions | omitted | M98l 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 withresult(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 andundefinedotherwise, registering nothing. Use it wherever the name comes from a request. Added in M74; a required member, so a replacement implementation ofISseServicemust supply it.ISseService.connectionCount: number— current open connections.ISseService.channelCount: number— channels with members, reported by the health indicator aschannels. 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, likepeek.ISseConnection.send(msg)— enqueue an encoded SSE frame (id:,event:,data:/ multi-linedata:,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 theLast-Event-IDrequest header (for resume logic).SseChannel.publish(msg)— broadcast to every open member, skipping closed ones.
Exports
| Symbol | Kind | Description |
|---|---|---|
SsePlugin | function | Plugin factory — registers ISseService under CAPABILITIES.SSE |
SseService | class | The ISseService implementation |
SseConnection | class | A live SSE connection over a ReadableStream |
SsePluginOptions | interface | heartbeatMs, retryMs, scalingNotice, diagnostics |
RealtimeDiagnosticsOptions | type (re-export) | From @setu-ts/common — the M98l diagnostics option |
ChannelPublisher | type | Forwards a local publish to other replicas; supplied by a backplane |
ISseConnection, ISseService, SseChannel, SseMessage | type (re-export) | From @setu-ts/common |
CAPABILITIES | const (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
RealtimeBackplanePluginand everypublishalso reaches members on other replicas; with noCAPABILITIES.REALTIME_BACKPLANEprovider the behavior is unchanged.SseChannel.sizekeeps reporting local membership either way. SseMessage.dataisJsonValue— a recursive JSON-safe type exported from@setu-ts/common. Until M74 the union wasstring | number | boolean | null | readonly unknown[] | Record<string, unknown>, whose last two arms admitted valuesJSON.stringifycannot 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: abigint(which throws) and a function or symbol value (which is silently dropped) are compile errors. A property writtenT | undefinedstill assigns, becauseJSON.stringifydrops the key. Three limits remain and no type can close them: a circular structure throws at runtime;NaN,Infinityand-Infinityare members ofnumberthat JSON cannot represent, soJSON.stringifynormalizes each tonulland the value reaches the wire silently changed rather than refused (send it as a string when the distinction matters); and a namedinterfacedoes 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 atypealias or extendRecord<string, JsonValue | undefined>. What M70n’s widening actually bought was arrays, primitives andnull.SseChannelImpl.publishLocalis the local-only delivery path the backplane subscriber uses; applications callpublish.- 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
| Option | Type | Default | Behavior |
|---|---|---|---|
maxConnections | number | 0 | Simultaneous open connections across all routes; 0 is unlimited. At the limit, upgrades get HTTP 503. |
heartbeatMs | number | 0 | Heartbeat interval; 0 disables it and creates no timer. |
heartbeatPayload | string | 'ping' | The text frame sent each tick. Read only when heartbeatMs > 0. |
idleTimeoutMs | number | 0 | Inbound 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. |
maxMessageBytes | number | 0 | Largest inbound frame; 0 is unlimited. A larger frame closes with 1009 and never reaches onMessage. |
scalingNotice | boolean | true | Logs 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. |
routes | readonly WebSocketRouteEntry[] | — | Declarative exact-path route() registrations. An entry is a WebSocketRouteDefinition ({ path, handlers, options? }) or a RegistryFactory resolved at onInit. |
behaviors | readonly (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. |
diagnostics | RealtimeDiagnosticsOptions | — | 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
| Option | Type | Default | Purpose |
|---|---|---|---|
protocols | readonly string[] | omitted | Subprotocol allow-list; the first client match is echoed back |
heartbeat | boolean | true | false excludes this route’s connections from the shared heartbeat sweep — both the payload send AND the idle eviction |
Set
heartbeat: falsewhen the route speaks its own liveness protocol. The sweeper sendsheartbeatPayloadas a raw text frame to every connection on every route, which a protocol client (agraphql-transport-wspeer, 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
| Export | Kind | Purpose |
|---|---|---|
WebSocketPlugin | function | Creates the plugin |
WebSocketService | class | The IWebSocketService implementation registered under the token |
WebSocketConnection | class | The IWebSocketConnection implementation |
Room | class | The WebSocketRoom implementation |
RoomRegistry | class | Owns live rooms, creating on demand and discarding when empty |
WsRouteTable | class | Exact-path route table with subprotocol selection |
HeartbeatSweeper | class | The interval implementing heartbeatMs / idleTimeoutMs |
WebSocketUnavailableError | class | Thrown by route() when the adapter offers no upgrade seam |
resolveOptions | function | Applies option defaults and rejects a contradictory configuration |
frameByteLength | function | Measures a frame in bytes (text by UTF-8 encoding, not string length) |
buildContext | function | Builds the WebSocketConnectionContext from an upgrade request |
parseRequestedProtocols | function | Parses a Sec-WebSocket-Protocol header into tokens |
selectProtocol | function | Picks the subprotocol to echo, or refuses |
WebSocketPluginOptions | type | The options above |
RealtimeDiagnosticsOptions | type (re-export) | From @setu-ts/common — the M98l diagnostics option |
WsRoute, WsRouteMatch | type | Route table entry and match result |
HeartbeatOptions | type | Resolved heartbeat configuration |
RoomMembershipListener | type | Join/leave callbacks a RoomRegistry gives each Room it creates |
RoomPublisher | type | Forwards a local broadcast to other replicas; supplied by a backplane |
LocalBroadcastOptions | type | broadcastLocal options — adds exceptId to exclude by connection ID |
Notes
- Routes match on exact path. Variable data travels in the query string and reaches
onOpenviaWebSocketConnectionContext.query. Pattern parameters (:id) are deliberately not supported: the kernel’s matcher is internal to@setu-ts/kerneland hand-rolling a second one would duplicate logic. - The heartbeat is an application-level frame, not an RFC 6455 ping. The web
WebSocketAPI on Deno and Cloudflare Workers exposes noping(), 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.loadWsModulethrows with the install command when it is absent. The other three runtimes need no dependency. - Upgrades on Node arrive on the raw
upgradeevent, not thefetchpath, so they are only available afterlisten(). On Bun, upgrades likewise only work throughlisten(), sinceserver.upgraderequires the serve-timewebsockethandlers. Deno and Workers upgrade on thefetchpath. - A custom adapter without
setUpgradeRouterdegrades gracefully: the service still registers, the health indicator reportsavailable: false, androute()throwsWebSocketUnavailableError. room(name)is get-or-create;peek(name)is the read that is not.room(callerSupplied).sizecreates 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 | undefinedreturns the room when one exists andundefinedotherwise, 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 ofIWebSocketServicemust supply it. At the registry level,RoomRegistry.get(name)creates on lookup too; useRoomRegistry.peek(name)for reads of client-supplied room names.- Rooms are in-process until a backplane is registered. Register
RealtimeBackplanePluginand everybroadcastalso reaches members on other replicas; with noCAPABILITIES.REALTIME_BACKPLANEprovider the behavior is unchanged.RoomBroadcastOptions.exceptis honored on every replica: connection IDs come fromruntime.uuid()and are globally unique, so the frame carries the excluded ID.Room.sizekeeps reporting local membership either way.Room.broadcastLocalis the local-only delivery path the backplane subscriber uses (itsLocalBroadcastOptionsaddsexceptId); applications callbroadcast. - A
RoomRegistrykeeps a reverseconnection → roomsindex, 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 theRoomMembershipListenerthe registry gives eachRoomit creates; a standalonenew 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’sUpgradeRouterStorebackstop 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
websockethealth indicator reports{ available, connections, rooms, routes }.onClosecloses every live connection with code1001and 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.
| Option | Applies to | Default | Description |
|---|---|---|---|
transport | all | 'memory' | 'memory' | 'messaging' | 'redis' | 'custom' |
topic | all but memory | 'setu-ts.realtime' | Broker topic / Redis channel. Every replica must agree on it |
origin | all | a 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' | 15000 | Per-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-backed | Receives 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' | true | Logs 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 |
diagnostics | all but 'custom' | omitted | M98l 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
transport | Crosses processes | Dependencies | Notes |
|---|---|---|---|
'memory' | No | None | The default, and a real single-process bus rather than a no-op |
'messaging' | Yes | A plugin providing messaging | Reuses any registered messaging broker; adds no dependency |
'redis' | Yes | ioredis (lazy) | Redis pub/sub, over two connections |
'custom' | Depends | None | Any IRealtimeBackplane |
Exports
| Symbol | Kind | Description |
|---|---|---|
RealtimeBackplanePlugin | function | Plugin factory |
createBackplane | function | Transport factory dispatching on the transport discriminant |
MemoryBackplane | class | In-process transport |
MessagingBackplane | class | Transport over CAPABILITIES.MESSAGING |
RedisBackplane | class | Redis pub/sub transport |
isRealtimeFrame | function | Guard narrowing arriving broker traffic to a RealtimeFrame |
adaptRedisModule | function | Narrows an ioredis module to IRedisModule |
loadRedisModule | function | Real lazy import('ioredis') |
RedisModuleError | class | Thrown when ioredis cannot be loaded or recognized |
DEFAULT_TOPIC | const | 'setu-ts.realtime' |
DEFAULT_REDIS_COMMAND_TIMEOUT_MS | const | 15000, the default commandTimeoutMs |
IRedisBackplaneClient | interface | Structural facade for an injected Redis client; optional connect?() (M117) is called at start for an injected client whose status is 'wait' |
IRedisModule | interface | Structural facade for the ioredis module |
RealtimeBackplanePluginOptions | type | Discriminated union of the four transport arms |
BackplaneCommonOptions | interface | topic, origin, localNotice and diagnostics, shared by every arm but 'custom' |
MemoryBackplaneOptions | interface | The 'memory' arm |
MessagingBackplaneOptions | interface | The 'messaging' arm |
RedisBackplaneOptions | interface | The 'redis' arm |
CustomBackplaneOptions | interface | The 'custom' arm |
IRealtimeBackplane, RealtimeFrame, RealtimeFrameHandler, RealtimeFrameKind, EncodedPayload, RealtimeDiagnosticsOptions | type (re-export) | From @setu-ts/common |
encodeFrameData, decodeFrameData, CAPABILITIES | value (re-export) | From @setu-ts/common |
Notes
- Loop prevention is an origin stamp. Every instance owns an
origin(aruntime.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.kindis'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 aclientwithout asubscriberthrows at construction rather than failing at the first publish; the lazy path builds both fromurl. transport: 'messaging'with no messaging capability throws duringregister(), 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/commonbecause three packages need the identical shape). AnSseMessageis 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 defaultenableOfflineQueue: trueholds publishes issued while disconnected and flushes them on reconnect, so frames arrive LATE (measured ~6 s) until themaxRetriesPerRequestbudget (default 20, ~11 s) exhausts and the buffered commands reject with awarnper frame. A connection that stays open while the server answers nothing (paused, or partitioned with no reset) triggers no reconnect; its publish rejects aftercommandTimeoutMs(default 15 s, above that budget) rather than never settling. A publish beforeconnect()or afterclose()rejects rather than resolving. BeyondcommandTimeoutMs, ioredis defaults are not configurable through this plugin; inject aclient/subscriberpair to change them. RoomBroadcastOptions.exceptis honored cluster-wide. It names a live connection object, which means nothing in another process — but connection IDs come fromruntime.uuid()and are therefore globally unique, soRealtimeFrame.exceptIdcarries 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.sizeremain local. A cluster-wide count is inherently asynchronous (a scatter-gather across replicas), so it cannot satisfy the synchronous committedsizegetter; 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
| Option | Type | Default | Behavior |
|---|---|---|---|
secret | string | readonly string[] | resolved | Index 0 seals; every entry can open, so rotation does not log users out |
secretName | string | SESSION_SECRET | Name 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 |
maxAge | number (seconds) | 7200 | Enforced from a stamp inside the payload, not from the cookie’s Max-Age |
rolling | boolean | false | true re-issues on every response, extending expiry |
idleTimeoutMs | number | — | 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 |
maxCookieBytes | number | 4096 | Throws SessionTooLargeError rather than emitting a cookie browsers drop |
cookie.name | string | setu_session | Renamed from hono_session — see the CHANGELOG migration note; pin the old name to preserve in-flight sessions |
cookie.path | string | '/' | |
cookie.domain | string | — | Omitted produces a host-only cookie |
cookie.sameSite | 'strict' | 'lax' | 'none' | 'lax' | 'none' forces Secure |
cookie.secure | boolean | '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.httpOnly | boolean | true | |
csrf | CsrfFormOptions | — | Presence registers csrfFormMiddleware at priority 325 |
csrf.fieldName | string | '_csrf' | Form field carrying the token |
csrf.headerName | string | '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.ignoreMethods | readonly string[] | ['GET','HEAD','OPTIONS'] | Methods that skip verification |
csrf.exclude | readonly (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.exemptBearer | boolean | true | M113e: 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 |
tenantBinding | boolean | true | Seals 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
| Export | Kind | Purpose |
|---|---|---|
SessionPlugin | function | The plugin factory |
SessionService | class | ISessionService implementation registered under the token |
getSession | function | The single accessor: getSession(ctx): ISession |
sessionMiddleware | function | Load/commit middleware (registered at 260; exported for standalone wiring) |
csrfFormMiddleware | function | Synchronizer-token middleware (registered at 325 when csrf is present) |
getCsrfToken | function | Mints-and-stores on first call, then stable within the session |
csrfTokenField | function | Renders 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) |
verifyCsrfToken | function | Standalone verification for handlers and React Router actions |
CSRF_SESSION_KEY | const | Reserved session key holding the token ('__csrf') |
CSRF_CONFIG_STATE_KEY | const | ctx.state key under which csrfFormMiddleware publishes its resolved config for csrfTokenField to read ('session-plugin:csrf-config') |
MemorySessionStore | class | Map-backed store; requires injected clock and timers |
CacheSessionStore | class | Store over any ICacheStore resolved from CAPABILITIES.CACHE |
SessionSecretMissingError | class | Thrown during register() when no adequate secret resolves |
SessionMiddlewareMissingError | class | Thrown by getSession when the middleware did not run |
CsrfTokenMismatchError | class | Thrown by verifyCsrfToken; the middleware converts it to 403 |
SessionTooLargeError | class | Thrown when a committed cookie would exceed maxCookieBytes |
SessionMode | type | 'encrypt' | 'sign' |
SessionPluginOptions | interface | The factory’s parameter |
SessionCookieOptions | interface | The cookie block |
CsrfFormOptions | interface | The csrf block, and verifyCsrfToken’s options |
SessionServiceDeps | interface | Runtime capabilities the service is constructed with |
MemorySessionStoreDeps | interface | The memory store’s required clock and timer injection |
PublishedCsrfConfig | interface | The shape published under CSRF_CONFIG_STATE_KEY — a frozen, request-local { fieldName }, never the middleware’s own shared config |
CacheSessionStoreOptions | interface | The cache store’s key namespacing |
Notes
ISessionService.fromHeaders(headers)is the headers-only read for non-HTTP entry points. It opens the session from aHeadersobject alone — a WebSocketonOpenhandler, an auth strategy reading a cookie — and returns a read-onlySessionView({ id, data }) ornull. It runs the same envelope-open, snapshot-parse, and store-read path as the load behindfrom(ctx), so it inherits real revocation on the store strategy, but it never commits, never advances theseenstamp, and never writes. It is a required member: an application that implementsISessionServiceitself must add it (see the CHANGELOG migration note).- Nothing in application code writes a
Set-Cookie. The middleware commits afternext()returns, and only when the session is dirty, regenerated, destroyed, orrollingis 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, andsnapshot()hands the adapter its liveHeadersrather than a clone — cloning would collapse repeatedSet-Cookievalues 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_KEYin@setu-ts/common), the commit seals the data into the cookie under an internalheld: '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. NoISessionStoremember changes. - Expiry is server-authoritative. A cookie’s
Max-Ageis client-controlled, somaxAgeis enforced from a wall-clock stamp inside the sealed payload. Both stamps come fromruntime.now()rather thanhrtime(), because they are serialized and compared across processes. ISession.set(key, undefined)removes the key rather than storingundefined. Storing it would makehas(key)report a key that serialization drops, so presence would betruebefore a commit andfalseafter the next load; treating it as an unset keepshastruthful across the round-trip.- A commit rejected for size persists nothing. The
maxCookieBytesguard 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.
idleTimeoutMsmeasures 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 asrollingdoes. It does not extend absolute expiry;maxAgestays absolute unlessrollingis also set, and the two compose. - A destroyed session deletes every id it has held. When
regenerate()anddestroy()happen in one request,session.idis 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 owninfolabel) 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’ssubtle.encryptreturns the authentication tag appended to the ciphertext, so unlikenode:cryptothere 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: aclear()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’scsrfMiddleware, not the same feature configured twice. That one is a statelessOrigin/Referercheck; 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
formDataaccessor (or the sameparseFormBodyfallback), 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 afilenameand 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’s415. csrfTokenField(ctx)is the form carrier for the plugin’s configured field name. ThecsrfFormMiddlewarepublishes the field name it will verify intoctx.stateunderCSRF_CONFIG_STATE_KEY— before itsignoreMethods/excludeshort-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-localPublishedCsrfConfig({ fieldName }) and deliberately NOT the middleware’s ownResolvedCsrfConfig: 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 — onectx.state.get(CSRF_CONFIG_STATE_KEY).ignoreMethods.add('POST')turned a403into a200for every later request in the process. An explicitcsrfTokenField(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 useraw(csrfTokenField(ctx))at the application rendering boundary. It does not register or bypass CSRF verification:SessionPlugin({ csrf: {} })still globally checks every unsafe method not inignoreMethods, so a form needs the documented safe-render-then-submit sequence.- The
403body 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
populateLoadContexthook, calling the samegetSession(ctx). No plugin imports another. - A
sessionhealth indicator reports{ strategy, mode, keys, store }and goesdownwhen a configured store reports unhealthy.onClosecloses 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
| Option | Type | Default | Description |
|---|---|---|---|
serverBuildPath | string | one of two | Path 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). |
serverBuild | unknown | omitted | The 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> | omitted | Injectable 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. |
assetsDir | string | omitted | Filesystem root of the built client bundle. Omit to disable the static-asset route. |
assetUrlPrefix | string | /assets/ | URL prefix for the asset route. |
publicFiles | boolean | true | Also 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 |
basename | string | / | Mount prefix for the SSR catch-all. MUST match react-router.config.ts basename for flat routes. |
populateLoadContext | (ctx: IRequestContext, context: RouterLoadContext) => void | omitted | Adds 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 anIPluginwith asyncregister()that mounts the SSR catch-all, optional static-asset route, and areact-routerhealth indicator.createStaticAssetHandler({ fs, assetsDir, assetUrlPrefix })— returns aRouteHandlerfor serving built client assets with immutable caching. Traversal containment: request paths containing..are rejected and every path is resolved underassetsDir; additionally, when the runtime providesIFileSystem.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 insideassetsDirpointing outside it is not served (404). WhenrealPathis absent (edge runtimes), containment degrades to the lexical..guard.assembleHandler(build, createRequestHandler, mode): SsrRequestHandler— assembles an RR request handler from a pre-loadedServerBuildand thecreateRequestHandlerfactory. Pure function; unit-testable without I/O.loadRequestHandler(serverBuildPath, mode, options?): Promise<SsrRuntime>— lazily imports the app-provided server build andreact-router, unwraps theServerBuild(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’sinstanceofcheck tests. The optionaloptionsparameter accepts{ rrImportHook?: () => Promise<Record<string, unknown>> }— a test-seam that replaces thereact-routerimport. Throws when the resolved module exposes noRouterContextProviderexport (i.e. react-router earlier than 8).createLoadContextFactory(rr): () => RouterLoadContext— pure seam building the per-request context factory from a loadedreact-routermodule namespace. @throws when the module has noRouterContextProviderexport.assertSsrRuntime(value): SsrRuntime— validates an injectedloadRequestHandlerresult duringregister(). @throws a message naming what was received whenhandlerorcreateLoadContextis missing — notably for a bare handler function, the pre-populateLoadContextshape. 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 kernelIRequestContextinto a webRequest(omitting the body for GET/HEAD), builds a fresh context provider, invokes the RR handler, and maps the resultingResponseback ontoctx.response. Its web Request usespublicRequestUrl(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, itscreateLoadContextfactory, and the optionalpopulateLoadContexthook; itsrender(ctx)method delegates tobridgeRequestToRRand returns theHandlerResult.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, ornullon 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 tovite.createServer({ plugins }). It resolves each listed package, and any subpath of one, withresolve(passimport.meta.resolvefrom 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’sresolve.builtinsinconfigResolved, appending because a configured list replaces Vite’s default one, which holds the Node built-ins. The patterns arejsr:andnpm:specifiers, plus the directory of each package mapped to afile: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-stackemits adev.tsthat uses it on Deno, Node and Bun; on Node and Bun Vite externalisesnode_modulespackages anyway, so it changes nothing there.interface RouterContextKey<T>—{ readonly defaultValue?: T }. Structurally identical to React Router’sRouterContext<T>, so keys from this package and keys fromcreateContext<T>()are interchangeable.interface RouterLoadContext—get<T>(key)/set<T>(key, value); the per-requestcontextReact 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
contextis a realRouterContextProvider. React Router 8 checksinitialContext instanceof RouterContextProviderinsidecreateRequestHandlerand answers500 Unexpected Server Errorfor 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 samereact-routermodule the handler came from. Breaking change (unreleased): thegetLoadContextoption and theLoadContextFunctiontype are removed, because a function returningRecord<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
loadRequestHandlerthat returns a handler over a build thunk — no plugin change required. See docs/react-router-dev.md for the verified recipe, including thebase-prefix proxy route and the version pins React Router requires. -
@react-router/nodeis excluded. Only corereact-router(createRequestHandler) is lazy imported.@react-router/node’sinstallGlobals()is unnecessary on web-standard runtimes. -
Static-asset serving uses
runtime.fs?.readFile. On edge platforms wherefsis absent, assets degrade to a 404. SSR document rendering still works. -
File-based routing (
flatRoutes) is supported transparently. It is baked into the compiledServerBuildby the React Router Vite plugin at build time — M44 serves it without any plugin surface. -
@react-router/fs-routes(if used) is an app-leveldevDependency, 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
| Option | Type | Default | Description |
|---|---|---|---|
maxWorkers | number | See budget rule below | Total worker slots across all modules; Infinity disables. |
startupTimeoutMs | number | 10000 | Deadline for a spawned worker to signal ready: a positive integer from 1 through 2147483647. 0 cannot disable it; an invalid value throws RangeError. |
defaultPoolSize | number | availableParallelism() | Workers per pool. |
maxQueue | number | 1024 | Pending-task bound per pool; exceeding it throws. |
taskTimeoutMs | number | 30000 | Per-task timeout; 0 disables; at most 2147483647. Timed-out worker dies. |
pools | Record<string, TaskPoolOptions> | {} | Per-module { size?, maxQueue?, taskTimeoutMs? }. |
host | IWorkerHost | runtime.workers | Injected host, wins over the runtime’s; for tests. |
Interface Reference
WorkerPoolService— theIWorkerPoolimplementation the plugin registers underCAPABILITIES.WORKER_POOL; exported for direct construction in tests. Its constructor refuses an invalidmaxWorkers,startupTimeoutMsor task timeout withRangeError, 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 fortaskModulelazily 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’sonClose).
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; carriestaskModule,remoteName, andremoteStack.WorkerTaskTimeoutError— the task exceeded its timeout; the worker was terminated and replaced. CarriestaskModuleandtimeoutMs.WorkerExitError— the worker thread ended while its task was in flight, on a runtime that reports worker exits (Node, Bun; Deno reports none). CarriestaskModuleand the exitcode(nullwhen the runtime reports none).WorkerQueueFullError— the pool’s pending queue is at its bound. CarriestaskModuleandlimit.
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 (webWorker). On Cloudflare Workers there are no threads: the plugin still registers, butrun()rejects withWorkerPoolUnavailableErrorand the health indicator reportsavailable: 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/outputmust be structured-clonable — no functions or class instances. A clone failure surfaces as a rejectedrun()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
WorkerExitErrorand 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 webWorkeremits no host-side event at all when a worker callsself.close(), and where a laterpostMessagestill 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 throughexitDetectionin its health payload, and warns once atregister()whentaskTimeoutMsresolves to0on a runtime that cannot report an exit. - Node
.tstask 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-poolreports{ available, exitDetection, pools, budget };budgetis{ maxWorkers, workers }, with the resolved bound (nullforInfinity) and the sum of pool slots. - Metrics (opt-in by capability). When
CAPABILITIES.METRICSis registered, the plugin publishes six series, all labelledtask_module: gaugesworker_pool_workers,worker_pool_busy_workers,worker_pool_queued_tasks, and countersworker_pool_tasks_completed_total,worker_pool_tasks_failed_total(also labelledreason:handler/timeout/crash/clone/shutdown) andworker_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_totalsummed overreasonequals the health payload’sfailed;..._rejected_totalcounts 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).
| Export | Kind | Since |
|---|---|---|
DiagnosticsPlugin | function | 0.8.0 |
IDiagnosticsPlugin | interface | 0.8.0 |
DiagnosticsPluginOptions | interface | 0.8.0 |
createDiagnosticsClient | function | 0.8.0 |
IDiagnosticsClient | interface | 0.8.0 |
DiagnosticsClientOptions | interface | 0.8.0 |
DiagnosticsSessionLifetime | interface | 0.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, soget/rotatego 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
| Option | Provider | Description |
|---|---|---|
provider | — | 'env' (default), 'aws-kms', 'gcp', 'azure', 'vault'. |
options.cacheTtl | all | Read-cache TTL in seconds; 0 disables. Default 300. |
options.prefix | env | Prefix prepended to the derived env key. |
options.configFallback | env | Default 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 / secretAccessKey | aws-kms | AWS client config (ignored when client injected). |
options.endpoint | aws-kms | LocalStack / emulator / private endpoint for the lazy client (ignored when client injected). |
options.projectId | gcp | GCP project id for resource paths. |
options.endpoint | gcp | Private/regional endpoint for the lazy client as host or host:port, no scheme; TLS only (ignored when client injected). |
options.vaultUrl | azure | Key Vault URL. |
options.address / token / mount | vault | Vault server address, token, KV mount (default secret). |
options.client | aws-kms/gcp/azure | Injected structural client facade (bypasses lazy import). |
options.http | vault | Injected fetch-shaped function (defaults to global fetch). |
options.requestTimeoutMs | vault | Bound 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— theISecretManagerimplementation (provider + read cache). -
EnvProvider,AwsKmsProvider,GcpSecretManagerProvider,AzureKeyVaultProvider,HashiCorpVaultProvider— provider classes. -
SecretProviderUnavailableError— since M101a, a provider that cannot be reached, answered503 Service Unavailablethrough a status hint.HashiCorpVaultProviderrejects with it when a request fails on the network or does not answer insideoptions.requestTimeoutMs— the body read included, so headers followed by silence count as no answer; the transport error is kept ascausefor the log and never reaches the response body. A Vault that answers with an HTTP error is reachable and keeps its handling:404reads asnull, any other error status rejects with a plainError.providernames 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 plainError, with memory held per read bounded by the same 1 MiB; and a name quoted in anysecrets-pluginerror 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, answered501 Not Implemented(X20-2). Thrown (as a rejection — never a synchronous throw) byEnvProvider.set, the provider’s only write method;SecretsService.rotate()reaches it by delegating toset, so both public write paths answer identically. Nothing is wrong with the caller — the configured provider cannot store or rotate secrets, permanently — which is what501states.What
set()means per provider — one portable method, four meanings.rotate()delegates to the provider’sset(), so the table below describes both public write paths. The divergence is documented rather than removed: makingset()mean one thing is the ROADMAP’s X20-3, a contract question deliberately ungrouped.Provider set()on an existing secretset()on a name that does not existVerified against a real backend envrejects rejects yes — readonly-status.test.tspins the501refusalvault(KV v2)writes a new version creates the secret yes — real HashiCorp Vault (X20) aws-kmswrites a new version refuses — PutSecretValueCommandrequires the secret to exist; the provider has no create pathyes — real LocalStack (X20 addendum) gcpadds a new version refuses — addSecretVersionrequires the secret container to pre-existno — no local emulator; stated, not claimed azurewrites a new version creates the secret no — no local emulator; stated, not claimed A bootstrap that assumes
set()creates is correct forvaultandazure, and wrong foraws-kmsandgcp. On those two, create the secret out of band before the firstset(). -
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.
| Status | Meaning |
|---|---|
up | The provider is connected and reachable, or cannot be probed (reachable is 'unknown'). |
down | The provider is not connected, or is connected but unreachable. |
data reports { provider, reachable }, where reachable is true, false, or 'unknown'.
Notes
EnvProvideris read-only:rotate()and providersetreject. It readsIRuntimeServices.envfirst, then the config snapshot for a missing key whenoptions.configFallbackis enabled (defaulttrue). 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? }), whereconfigisPick<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 normaloptions.cacheTtlapplies 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: useConfigPlugin({ 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 andenvFilePathrequires unavailable filesystem support. An injectedConfigPlugin({ instance })or anotherIConfigcan supply keys the environment lacks, including the snapshot used bycreateFullStackAppFromConfig. - Secret names use provider-specific path syntax;
EnvProvidermaps a name to an env key by prependingoptions.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
| Option | Backend | Description |
|---|---|---|
storage | — | 'memory' (default), 'log', 'database', 'file'. Unknown values throw at registration. |
options.level | log | Logger method to emit at: 'info' (default), 'warn', or 'error'. |
options.logger | log | Injected ILogger overriding ctx.logger (throws at registration when neither is present). |
options.client | database | Injected IAuditDbClient; required for 'database' (throws when absent). |
options.table | database | Table name for insert/select; default 'audit_logs'. |
options.path | file | JSONL file path; default './audit.log' (throws at registration when runtime.fs is absent). |
Exports
AuditPlugin(options?)— plugin factory.AuditService— theIAuditLoggerimplementation (stampsid/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’querymembers. Exported so those signatures are nameable by a consumer (the M52cNormalizedQuerylesson); the read path belongs to the storages, not toIAuditLogger.
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’squery()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 overruntime.fs(the committedIFileSystemhas no native append) and serializes concurrent appends; on shutdown the plugin’sonClosedrains any in-flight write. The target file’s parent directory is created recursively on first write, so apathin a not-yet-existing directory does not fail withENOENT. - The
'database'backend delegates equality filtering (action/resource/result/userId/resourceId) to the injected client’sselectWHERE; time-range (from/to), ordering, andlimitare 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:
| Option | Type | Behavior |
|---|---|---|
subscriptions | readonly 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. |
behaviors | readonly (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. |
chainReadyTimeoutMs | number | Bounds 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):
| Error | Thrown when |
|---|---|
RequestTimeoutError | No reply arrived within timeoutMs. |
RemoteHandlerError | The responder threw; .remoteMessage carries the remote message. |
MessagingNotSupportedError | Deprecated — no broker throws this. Retained for instanceof compatibility. |
PublishTimeoutError | The 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:
| Error | Thrown when |
|---|---|
JetStreamUnavailableError | connect() finds no JetStream on the server — start it with the -js flag. The raw 503 the server answered is the cause. |
JetStreamStreamError | The 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:
| Error | Thrown when |
|---|---|
PubSubSubscriptionBoundElsewhereError | The 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. |
NatsConsumerNameCollisionError | The 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. |
KafkaTopicUnavailableError | The 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 injectedIMessageBroker).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 enableauto.create.topics.enable; the same holds for every subscribed topic, whose absence on a non-auto-creating broker rejects withKafkaTopicUnavailableError. 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 ownreplyTopicto bound that fan-out.
RPC and pub/sub are separate channels.
request/respondtravel on a channel derived from the topic, not on the topic itself. A plainsubscribe('orders', …)therefore never observes an RPC request, and a plainpublish('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.
| Broker | orderingKey | deduplicationId |
|---|---|---|
| Kafka | message key → one partition, in order | header only |
| Pub/Sub | native orderingKey (needs an ordering subscription) | header only |
| NATS | header only | Nats-Msg-Id → server de-duplicates within the stream’s dedup window |
| Service Bus | header only | messageId → de-duplicated only on an entity with duplicate detection |
| RabbitMQ | header only | header, and the messageId property |
| Redis Streams | header only | header only |
| In-memory | header only | header only |
WorkersBroker | envelope field, surfaced as the header | envelope 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); atmaxConcurrentCalls: 2the 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.
- A delivered
x-setu-ordering-keyorx-setu-deduplication-idheader 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. - A
deduplicationIdderived from request input lets the caller who chooses it suppress another message with the same id for the broker’s window (NATS’sduplicate_window, 120 s by default; Service Bus’s configured detection window). Derive it from a producer-assigned id — the envelope idpublishIntegrationEventuses by default is one. - An
orderingKeyderived 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:
| Broker | Channel | Notes |
|---|---|---|
memory | in-process metadata | No wire; headers are handed straight to the subscriber. |
redis-streams | extra XADD field/value pairs beside payload | Any non-payload field is read back as a header. |
rabbitmq | AMQP properties.headers | Field-table values are normalized — see below. |
nats | MsgHdrs | Needs a header factory — see the caveat below. |
kafka | record headers | String values; a delivered Buffer value is decoded as UTF-8. |
pubsub | message attributes | Pub/Sub attribute values must be strings. |
service-bus | applicationProperties | SDK 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. AMsgHdrscan only be built by the nats module’sheaders()function, and an injected connection carries no module. PassNatsOptions.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
clientthat predates this release.IPubSubTransport.publishandIServiceBusTransport.sendgained 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 optionalmessageIdandtimestampmembers (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.
| Status | Meaning |
|---|---|
up | The broker is connected and reachable, or cannot be probed (reachable is 'unknown'). |
down | The 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:
deduplicationIddefaults to the envelopeid, so a retried publish of the SAME envelope is de-duplicated where the broker supports it. A caller-supplieddeduplicationIdwins.orderingKeycomes from the caller first, then from the definition’s optionalorderingKey(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:
| Field | Type | Present always? | Meaning |
|---|---|---|---|
id | string | yes | Producer-assigned identity (runtime.uuid()) |
type | string | yes | The contract’s semantic event name |
version | number | yes | The contract version |
occurredAt | string | yes | Publish time, ISO-8601 (new Date(runtime.now()).toISOString()) |
data | payload | yes | The event payload, carried verbatim |
correlationId | string | optional | ID of the causal chain root |
causationId | string | optional | ID of the directly causing event |
aggregateId | string | optional | ID of the aggregate the event concerns |
aggregateVersion | number | optional | Version 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 contractdefineIntegrationEventreturns and both directions read: readonlytype,version,topic,parse: (value: unknown) => T, and the optionalorderingKey?(envelope): string | undefinedselector (M106).parseruns on the consumer side only.IntegrationEventEnvelope<T>(interface) — the wire shape in the table above. The handler receives it withdatarebuilt to the parsed value, soenvelope.data === payloadholds on every delivery.IntegrationEventMetadata(interface) — the optional fifth argument ofpublishIntegrationEvent:correlationId/causationId/aggregateId(string) andaggregateVersion(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.onIntegrationEventoverloads (M108). Withoptions.inboxthe call takes anIntegrationEventInboxHandler<T, S>— the same three arguments plus the inbox transaction’s unit of work,Sinferred from the handler’s annotation — and returns aRegistryFactory<SubscriptionDefinition>(see Consumer inbox). Without it the call is unchanged and returns aSubscriptionDefinition; the inbox overload is declared first, and the legacy options takeinbox?: never, so an options variable carryinginboxis typed as the factory it is.IntegrationEventRejectionReason(type) —'malformed' | 'type-mismatch' | 'version-mismatch' | 'parse', thereasondiscriminant ofIntegrationEventRejectedError(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:
reason | Fault |
|---|---|
malformed | Not an object, or a mandatory field missing or of the wrong primitive type |
type-mismatch | The envelope’s type differs from the definition |
version-mismatch | The envelope’s version differs from the definition |
parse | The 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.
| Export | Kind | Notes |
|---|---|---|
IOutbox | interface | write(scope, definition, payload, input?) → envelope id; dispatch() (never throws); sweep(); purge(); release(id, action, options?) |
OutboxOptions | type | OutboxCommonOptions plus exactly one of store (one store) and stores (per tenant id); supplying both is a compile error |
OutboxCommonOptions | interface | maxEnvelopeBytes, background, relay, health, retainSentMs, purgeBatch, purgeIntervalMs |
OutboxRelayOptions | interface | schedule, intervalMs, pageSize, scanLimit, publishLimit, maxFailedScan, maxAttempts, backoff bounds, the sweep deadline, claimLeaseMs, maxClockSkewMs |
OutboxHealthOptions | interface | degradedAfterMs (default 60 000), overlapWindowMs (default 600 000) |
OutboxStoreEntry | type | an IOutboxStore or a RegistryFactory<IOutboxStore>, resolved in onInit |
OutboxWriteInput | interface | metadata?, options? (PublishOptions, the publishIntegrationEvent precedence), tenantId? |
OutboxSweepResult | interface | origin, scanned, published, failures, poisoned, endedBy |
OutboxEnvelopeTooLargeError | class | write — the serialized envelope exceeds maxEnvelopeBytes (bytes, limit); the caller’s transaction rolls back |
OutboxRelayUnscheduledError | class | start() — the relay is scheduled (the default) but no CAPABILITIES.SCHEDULER is registered |
OutboxUnknownTenantError | class | write / release with per-tenant stores naming no tenant, or one with no store; the tenant id is never quoted |
OutboxRowStateError | class | release — 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 |
OutboxNotReadyError | class | any call before onInit resolved and verified the store |
OutboxStoreVerifyTimeoutError | class | start() — 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. Withrelay.schedule(defaulttrue) and no scheduler registered,start()rejectsOutboxRelayUnscheduledErrorbefore any store I/O. Otherwise each store entry is resolved, each store’sverify()runs — a refusal (OutboxStoreUnavailableErrorfrom the bridge) failsstart()— the outbox is activated, and two jobs are scheduled withIScheduler.every:outbox-relay[.<name>]everyrelay.intervalMs(default 1000) andoutbox-purge[.<name>]everypurgeIntervalMs(default 60 000). Withrelay: { schedule: false }nothing is scheduled; callsweep()andpurge()yourself (on Cloudflare Workers, from a Cron Trigger).- One deadline.
relay.sweepDeadlineMsdefaults 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 ownstoreTimeoutMseven when a pause has exhausted the budget: a sweep that overran by a pause can outlive the deadline by at most onestoreTimeoutMs, and the shutdown drain that waits for it is bounded by the same amount. The strict relation ispublishTimeoutMs + 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
onShutdownhook, 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. AnonClosehook repeats the same drain idempotently, because a failedstart()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.
downwithready: falsebefore the store is verified and once closing;downwithreachable: falsewhenstats()does not answer (cached 5 s, bounded 2 s);upwithdata.reasonsfrom a fixed vocabulary — cluster-wide, from the store:oldest-pending-age(pasthealth.degradedAfterMs),failed-rows,failed-scan-cap(a store’s failed count reachesrelay.maxFailedScan); per instance, from this relay:blocked-key-cap,store-write-failing,relay-overlap(a replica that never sweeps reports none of these).datacarries counts, ages in ms and the last local sweep’s counts — never a tenant id, ordering key, topic or error text. - Metrics. With
CAPABILITIES.METRICSregistered: countersoutbox_published_total,outbox_publish_failures_total,outbox_poisoned_total(labelsoutbox,topic) andoutbox_overlaps_total(outbox,kind); gaugesoutbox_pending_rowsandoutbox_oldest_pending_seconds(outbox), written from each fresh store read the health indicator makes.topiccomes only from a decoded row and is capped at 100 distinct values per instance, thenother; an undecodable poisoned row is labelledinvalid-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/healthpoll. - 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.
positionis taken from each writer’s clock, clamped per process only, so across replicas order also assumes the writers’ clocks agree. - Claims and overlap.
relay.claimLeaseMsdefaults to 30 000, range 1–3 600 000; it must coverpublishTimeoutMs + 2 * storeTimeoutMs + maxClockSkewMs.maxClockSkewMsdefaults to 5 000, range 0–60 000; zero assumes perfectly agreeing clocks and is unsafe across hosts. A held row blocks its key untilnow ≥ leaseUntil + maxClockSkewMs; before publishing the relay requiresnow + publishTimeoutMs + storeTimeoutMs + maxClockSkewMs ≤ leaseUntil. Kindsfenced,claim-lostandduplicatedegrade health asrelay-overlapfor the configured window, for any sweep origin. Claim-time contention has no shipped signal.retainSentMs: 0disables onlyduplicate. 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()deletessentanddiscardedrows whosesettledAtis older thanretainSentMs(default 7 days), at mostpurgeBatchper status per store;failedrows are never purged.retainSentMs: 0deletes at mark-sent. releasehas 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, onestore) 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
failedwithlastError: 'invalid-row', never published; but a VALID row’s topic is published as stored. Protect the outbox table like the broker credentials. - Trace.
writestores the activetraceparent; each row is published inside anoutbox relay <topic>span parented to it, so request → relay → publish → receive is one trace. A row without a validtraceparentgetsSpanOptions.root, never the span of the request that dispatched the sweep. A custom broker without the framework-header channel (WorkersBroker) drops the producer’straceparent. - Cloudflare Workers.
relay: { schedule: false },background: waitUntil, and Cron Triggers onWorkersCroncallingoutbox.sweep()andoutbox.purge(), with the outbox in a D1 table throughD1Adapter. Driven at unit level (D1Adapterover 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.
| Export | Kind | Notes |
|---|---|---|
IInbox | interface | parked(limit?) (never the envelope); release(rowId, 'retry' | 'discard'); purge() |
InboxOptions | interface | store, maxAttempts?, retainMs? (7 days), storeTimeoutMs? (5000), maxParkedEnvelopeBytes? (262 144), purge? |
InboxPurgeOptions | interface | schedule? (true), intervalMs? (60 000), batch? (100) |
InboxStoreEntry | type | an IInboxStore or a RegistryFactory<IInboxStore>, resolved in onInit |
ParkedInboxEntry | interface | rowId, consumer, topic, envelopeId?, attempts, updatedAt, lastError? |
InboxReleaseResult | interface | topic, and for retry the stored envelope (absent when too large or unreadable) |
IntegrationEventInboxHandler | type | (payload, envelope, metadata, scope: S) |
IntegrationEventInboxOptions | interface | consumer (a valid publish id, required), instance? (resolves inbox.<instance>) |
IntegrationEventSubscribeOptions | type | SubscribeOptions & { inbox: IntegrationEventInboxOptions } |
InboxNotConfiguredError | class | resolution — reason: 'unregistered' (no MessagingPlugin({ inbox })) or 'foreign-provider' (a replacement provider of the token) |
InboxConsumerConflictError | class | resolution — a second subscription with the same consumer on the same topic in one inbox |
InboxNotReadyError | class | state: 'not-started' (resolution or a call before the store is verified) or 'closed' |
InboxPurgeUnscheduledError | class | start() — purge.schedule (the default) without a CAPABILITIES.SCHEDULER |
InboxStoreVerifyTimeoutError | class | start() — verify() did not settle within storeTimeoutMs |
InboxRowStateError | class | release — 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
queuedefaults toinbox.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 setqueueexplicitly there and create that subscription. - Retention.
purge()deletes rows of every status older thanretainMs, parked markers and their envelopes included, at mostpurge.batchper 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
errorKindlog field), never its message, which a driver may fill with the statement’s bound parameters. - Failures. Without
maxAttemptsa 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
inboxset the plugin also providesCAPABILITIES.INBOX(inbox.<name>), declaresCAPABILITIES.SCHEDULERas an optional dependency, registers aninboxhealth indicator (downbefore verification and after close,upwithdata.reasons: ['parked-rows'],up/reachable: 'unknown'when the count does not answer within 2 s), verifies the store atonInitbefore any inbox subscription is resolved, schedulesinbox-purge[.<name>], and closes in anonClosehook registered after the broker’s, so a delivery in flight duringstop()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 serviceQueueAdapterType—'memory' | 'redis' | 'rabbitmq' | 'sqs'QueuePluginOptions— Plugin configuration options (includesclient,url,prefix?,sqs?)MemoryQueue— In-memory queue adapter for development/testingRedisQueue— Redis-backed queue adapter for productionRedisQueueOptions— Redis adapter configuration, includingkeyPrefix?(defaultqueue:) andvisibilityTimeoutMs?(default30000)RabbitMqQueue— RabbitMQ queue adapter via amqplib (polling via basicGet, TTL+DLX for delays)RabbitMqQueueOptions— RabbitMQ adapter configuration (includesurl,client,prefix?,persistentMessages?,publishTimeoutMs?,reportWarning?)SqsQueue— AWS SQS queue adapter via@aws-sdk/client-sqs(receipt-handle bookkeeping,ApproximateReceiveCountattempt ladder, visibility-timeout backoff, dead-letter ordering)SqsQueueOptions— SQS adapter configuration (queues,deadLetterQueues?,region?,credentials?,endpoint?,client?)ISqsTransport— Structural SQS transport port (injected viaSqsQueueOptions.client)SqsReceivedMessage— SQS received message shape (body, receiptHandle, approximateReceiveCount)SnsPublisher— SNS publisher for fan-out (SNS→SQS pairing)SnsPublisherOptions— SNS publisher configurationISnsTransport— Structural SNS transport portadaptSqsModule/loadSqsModule— SQS SDK adapter and lazy loaderSqsSdkModule— Shape of the SQS SDK moduleadaptSqsModuleconsumes; exported so a consumer can type a substitute moduleadaptSnsModule/loadSnsModule— SNS SDK adapter and lazy loaderSnsSdkModule— Shape of the SNS SDK moduleadaptSnsModuleconsumesQueueBackendUnavailableError— Thrown when a cloud queue backend is unavailable (e.g., SQS on Cloudflare Workers)SqsDelayTooLongError— Thrown when SQS delay exceeds 900 sSqsQueueNotConfiguredError— Thrown when a job name has no queue URL mappingIQueue— Queue service interface (re-exported from@setu-ts/common)IJob<T>— Job interface (re-exported)JobProcessor<T>— Job processor type (re-exported)AddJobOptions— Options forqueue.add()(re-exported)ProcessOptions— Options forqueue.process()(re-exported)QueueDepths—{ ready, processing, dead }per job name, as the health indicator publishes themRecurringOptions— Options forqueue.addRecurring()(re-exported)QueueLogger— Minimalerror/warnlogger surface the service reports background failures through (structurally compatible withILogger)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.
| Option | Type | Behavior |
|---|---|---|
processors | readonly 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. |
behaviors | readonly (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.
| Adapter | Settlement evidence | Depths |
|---|---|---|
memory | confirmed (in-process) | process-local |
redis | confirmed (awaited server commands) | shared-backend when the client can count |
rabbitmq | unknown — an AMQP ack has no broker confirmation (publishes are confirmed) | unavailable |
sqs | unknown — a lapsed claim or failed dead-letter send is absorbed and logged | unavailable |
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 aenqueue <name>producer span and writes the span’straceparentintoAddJobOptions.headers, merged on top of anything the caller supplied.- Every processor — registered through the
processorsarm, through aprocessorsfactory, or imperatively through the resolved capability — runs inside aprocess <name>consumer span parented fromIJob.headers. - The map is carried end to end by all four adapters (
memory,redis,rabbitmq,sqs) and is exposed to an M86 ingress behaviour asIngressContext.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()).
| Status | Meaning |
|---|---|
up | The adapter is connected and reachable, or cannot be probed (reachable is 'unknown'). |
down | The 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; throwsSchedulerUnavailableErroratregister()on Cloudflare Workers, where its timers cannot fire — useWorkersCronwith[triggers] cronsthere insteadSchedulerUnavailableError— Thrown byregister()when the runtime platform cannot run the scheduler (Cloudflare Workers); catch it by identity to branch on the refusalSchedulerPluginOptions— Plugin configuration options (timezone?,distributedLock?)DistributedLockOptions— Lock configuration (enabled?,storage?,url?,client?,lock?,ttlMs?,acquireTimeoutMs?,commandTimeoutMs?). Since M101a every lock acquire is bounded byacquireTimeoutMs(default5000,0waits indefinitely), whichever lock is in use: an acquire still unsettled at the bound is a skipped fire — logged, countedlockFailedin the execution observations, and re-armed for the next slot — and a token it returns later is released.commandTimeoutMsis the iorediscommandTimeouton the clientRedisLockbuilds (defaultacquireTimeoutMs, or15000when that is0; never applied to an injected client or lock); a timed-outSETmay still apply on the server, soRedisLockreleases that exact token before rethrowing. Either value outside0–2147483647, or acommandTimeoutMsabove a non-zeroacquireTimeoutMs, throwsRangeErrorwhenSchedulerPlugin(...)is calledIDistributedLock— Lock seam (acquire/release) for a custom lock implementation; optionalisHealthy?(): Promise<boolean | undefined>reports reachable, unreachable, or unknownIRedisLockClient— Structural ioredis shape accepted bydistributedLock.client; optionalping?(): Promise<unknown>supports lock reachability probes;eval(...)returnsPromise<unknown>(M117, widened fromPromise<number | string | null>so afromNodeRedisadapter 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 forcron()/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
| Option | Type | Behavior |
|---|---|---|
jobs | readonly 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. |
behaviors | readonly (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:
| Registration | Refused value | Message fragment |
|---|---|---|
delay | delayMs not finite or < 0 | delayMs must be a finite number >= 0 |
every | intervalMs not finite or <= 0 | intervalMs must be a finite number > 0 |
delay, every, cron | retry.delay not finite or < 0 | retry.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 serviceResiliencePluginOptions— Plugin configuration:defaultCircuitBreaker?,defaultRetry?,defaultBulkhead?, each consumed when awrapsets the matching field totrueTimeoutError— Thrown when a call exceeds its per-attempt timeout deadline; answered504 Gateway Timeout— the framework acted as an intermediary to a protected call that did not answer in timeBulkheadFullError— Thrown when a bulkhead sheds a call (concurrency saturated, queue full); answered503 Service Unavailable— shedding is the bulkhead’s purposeCircuitOpenError— Thrown when an open circuit breaker fails fast without invoking the call; answered503 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 forwrap(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— awrap()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.
| Provider | Type | Key options | Auth / SDK | Notes |
|---|---|---|---|---|
MemoryProvider | 'memory' | (none) | N/A (zero-dep) | Default. In-process map. Every runtime incl. Cloudflare. |
LocalStorageProvider | 'local' | rootDir?: string | N/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 + presigner | R2 and MinIO via endpoint. Real presigned GET URLs. |
GcsProvider | 'gcs' | bucket, projectId? | Lazy @google-cloud/storage | Real signed URLs via file.getSignedUrl. |
AzureBlobProvider | 'azure' | containerName, connectionString?, accountName?, accountKey? | Lazy @azure/storage-blob | SAS requires accountKey. No @azure/identity needed. |
| B2 preset | 'b2' | bucket, region, accessKeyId, secretAccessKey | Same 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 type | Mirrors its SDK? | What injecting it means |
|---|---|---|
IGcsClient | yes | A real @google-cloud/storage client fits structurally. |
IAzureBlobClient | yes | A real @azure/storage-blob client fits structurally. |
IS3Backend | no | This 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
| Method | Description |
|---|---|
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
| Provider | Behavior |
|---|---|
MemoryProvider | Returns deterministic synthetic URL memory://<encoded-key>?expires=<epoch-seconds>. Test/process affordance only — never grants real access. |
LocalStorageProvider | Rejects with Error('LocalStorageProvider does not support signed URLs; use the s3, gcs, or azure provider'). |
S3Provider | Real presigned GET URL via getSignedUrl(GetObjectCommand, { expiresIn }). |
GcsProvider | Real signed URL via file.getSignedUrl([{ action: 'read', expires }]). |
AzureBlobProvider | Real 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()).
| Status | Meaning |
|---|---|
up | The provider is connected and reachable, or cannot be probed (reachable is 'unknown'). |
down | The 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
| Option | Provider | Description |
|---|---|---|
provider | — | 'log' (default), 'smtp', 'ses', 'sendgrid'. |
defaults.from | all | Sender applied when a message omits from; else send throws. |
templates | all | Named body templates for sendTemplate: { html?, text? } strings, or { view, text? } view components rendered through CAPABILITIES.VIEW (M102). |
options.host / port / secure / auth | smtp | nodemailer transport config (ignored when transport injected). |
options.transport | smtp | Injected ISmtpTransport facade (bypasses the lazy import). |
options.region / accessKeyId / secretAccessKey | ses | AWS client config (ignored when client injected). |
options.client | ses | Injected ISesClient facade (bypasses the lazy SDK import). |
options.apiKey / endpoint | sendgrid | SendGrid API key (Bearer) and endpoint (default v3 send URL). |
options.http | sendgrid | Injected fetch-shaped function (defaults to global fetch). |
options.sink | log | Called with each sent OutgoingMail — a read-back seam. |
Exports
MailPlugin(options?)— plugin factory.createProvider(type, options, ctx)— provider builder.MailService— theIMailerimplementation (default-fromresolution + template dispatch).TemplateEngine,escapeHtml— the template renderer (both arms;renderis 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 in0.7.0) reports the transport’s REACHABILITY, distinct from whether the mailer was constructed:truereachable,falsecontacted and unreachable,undefinedwhen the question cannot be asked — never readundefinedas healthy.MailServiceimplements it by delegating to the configured provider’s own probe, so a provider with none yieldsundefined. Every built-in provider that ships a probe answers concretely —logis alwaystrue(it touches no network) andsendgridcalls the scopes endpoint — so theundefinedarm is reached by the two providers that DROP their probe when the injected dependency cannot support one:seswith a client exposing noisHealthy, andsmtpwith a transport exposing noverify(). Themailhealth indicator reads reachability through this same method rather than past it to the provider, so the capability and the indicator cannot disagree. It exists onIMailerso a HOLDER of the capability can ask —notification-plugin’s email channel does, and AI_GUIDELINES §2.2 forbids it importingmail-pluginto find a probe.sendTemplate’s envelope isOmit<MailMessage, 'html' | 'text'>—subjectstays REQUIRED; the template provides thehtml/textbodies only, never the subject.MailTemplateis a union of two arms that never mix in one template (a template carrying bothviewandhtmlis a compile error). String arm (MailStringTemplate): in an HTML template, interpolateddatavalues are HTML-escaped (& < > " '); text templates substitute raw. A placeholder whose key is absent fromdata, or an unknown template name, rejects. Component arm (MailComponentTemplate, M102):viewand the optionaltextareComponents rendered through theIViewEngineregistered underCAPABILITIES.VIEW, withdatapassed verbatim as each component’s props —viewintohtml,textintotext(verbatim; write it as a plain(props) => stringfunction, since one written with thehtmltag or JSX is HTML-escaped by its runtime and puts entities such as&into a plain-text body). Escaping is the rendering runtime’s (anhtmltagged 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 asundefinedrather than throwing, because the committedsendTemplatetypesdataasRecord<string, unknown>; the compile-time route isengine.render(Component, props)by hand, thenmailer.send.MailPlugindeclaresCAPABILITIES.VIEWinoptionalDependenciesand resolves the engine ONCE atregister(); a component template configured with no provider fails atregister()naming both remedies (registerViewPluginor any other provider of the token, or remove the component templates) — never on the firstsendTemplate. Only the registry is consulted: a container-supplied engine registered byDecoratorPluginlands after this plugin has run. A rendering failure (ViewRenderError,UnresolvedSuspenseError) propagates unwrapped and the provider is never reached.TemplateEngine.render(name, data)returnsPromise<RenderedTemplate>(asynchronous since M102, becauseIViewEngine.rendermay answer a promise); every refusal is a rejection.LogProvidernever sends real email —.messagesretains the most recent 1,000 messages in send order, whilesinkreceives every message and logging usesctx.logger. Like every provider, it refuses a send outsideconnect()..disconnect()with'LogProvider is not connected', so a send afterapp.stop()rejects.SmtpProviderneeds raw sockets, so it is Node/Deno/Bun only;SendGridProvideris the Cloudflare Workers-portable path.
Health status
Since M70c the indicator reports two signals: the provider’s lifecycle (isReady()) and its
reachability (isHealthy()).
| Status | Meaning |
|---|---|
up | The provider is connected and reachable, or cannot be probed (reachable is 'unknown'). |
down | The 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
| Option | Provider | Description |
|---|---|---|
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 / from | twilio | Twilio credentials and sender number; all three required (construction throws). |
options.projectId | fcm | Firebase project id, addressed by the v1 messages:send URL; required (construction throws). |
options.clientEmail / privateKey | fcm | Service-account email and PEM PKCS#8 key that sign the OAuth2 assertion; required unless tokenSource is supplied. |
options.tokenSource | fcm | Overrides token acquisition (e.g. a GCP metadata server); when set the credential fields above are unused. |
options.webhookUrl | slack | Slack incoming-webhook URL; required (construction throws). |
options.http | twilio, fcm, slack | Injected 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 eachChannelConfigarm returns its own transport port (mail→IMailer,twilio→SmsTransport,fcm→PushTransport,slack→SlackTransport).NotificationService— theINotifierimplementation (parallel fan-out +AggregateError).EmailChannel,SmsChannel,PushChannel,SlackChannel— channel classes (address extraction and payload shaping).TwilioProvider,FcmProvider,SlackProvider— zero-dependency HTTP transports.createDefaultNotificationHttp(fetchImpl?)— thefetch-backed defaultINotificationHttp.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
INotifierhas exactly one method,send. There is nosendEmail/sendSms/sendSlack— single-channel dispatch issend({ channels: ['sms'], … }).- Channels dispatch in parallel via
Promise.allSettled: one failing channel never aborts the others. When any fail,sendthrows anAggregateError(message'One or more notification channels failed') whoseerrorsare alwaysErrorinstances, one per failed channel, including unknown channel names. An emptychannelsarray resolves without dispatching. - Each channel reads its own address key out of
toand throws when it is absent:to.email(email),to.phone(sms),to.token(push). Slack’sto.channelis optional — omitted, the webhook’s default channel applies.subjectbecomes the mail subject (defaulting to'(no subject)') and the push notification title; SMS and Slack ignore it. Built-in channels do not readmetadata— it is there for customNotificationChannelimplementations. - Configuring an
emailchannel without a registeredmailcapability throws duringregister, 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 (SmtpProviderneeds raw sockets).FcmProvidertargets 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 withruntime.subtleand caches the resulting token until shortly before it expires, so a send costs one request in the steady state. Apushchannel using the default signer therefore requiresCAPABILITIES.RUNTIMEand throws duringregisterwithout it; supplyingtokenSourceremoves that requirement. - A
notificationhealth indicator reports'up'with the configured channel names. There is noonClose: 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
| Option | Provider(s) | Required | Description |
|---|---|---|---|
provider | all | yes | Discriminant: 'config', 'memory', 'database', 'launchdarkly', or 'custom' |
options.flags | 'config', 'memory' | config: yes, memory: no | Static Readonly<Record<string, FlagDefinition>> |
options.store | 'database' | yes | Injected IFlagStore providing { loadFlags(): Promise<...> } |
options.refreshIntervalMs | 'database' | no | Poll cadence; defaults to 30000 |
options.sdkKey | 'launchdarkly' | unless client | LaunchDarkly SDK key; a missing key with no client throws at register() |
options.client | 'launchdarkly' | no | Prebuilt ILaunchDarklyClient; suppresses the lazy SDK import |
options.module | 'launchdarkly' | no | SDK module to adapt instead of importing — the test seam |
options.fallbackValue | 'launchdarkly' | no | Value the sync path returns on a cold context; defaults to false |
options.initTimeoutSeconds | 'launchdarkly' | no | Initial-connection budget; defaults to 5. A timeout degrades, never throws |
options.ldOptions | 'launchdarkly' | no | Forwarded verbatim as the SDK init() second argument |
maxCachedContexts | 'launchdarkly' | no | Wrapper 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' | yes | Pre-built FlagProvider instance |
Exports
| Symbol | Kind | Description |
|---|---|---|
FeatureFlagsPlugin | function | Plugin factory — registers IFeatureFlags under CAPABILITIES.FEATURE_FLAGS |
FeatureFlagService | class | Service implementing IFeatureFlags |
createProvider | function | Provider factory dispatching on the provider discriminant |
ConfigProvider | class | Immutable config-backed provider |
MemoryProvider | class | Mutable in-process provider with setFlag/removeFlag/replaceFlags |
DatabaseProvider | class | Polled database-backed provider |
LaunchDarklyProvider | class | LaunchDarkly-backed provider bridging its async SDK to the sync contract |
toLaunchDarklyContext | function | Maps a FlagContext to the SDK’s single-kind user context |
adaptLaunchDarklyModule | function | Narrows an SDK module to ILaunchDarklyModule |
loadLaunchDarklyModule | function | Real lazy import('@launchdarkly/node-server-sdk') |
LaunchDarklyModuleError | class | Thrown when the SDK cannot be loaded or does not expose init() |
ILaunchDarklyClient | interface | Injection facade for the SDK client |
ILaunchDarklyModule | interface | Injection facade for the SDK module |
ILaunchDarklyFlagsState | interface | The snapshot facade — its getFlagValue is the synchronous read |
LaunchDarklyContext | interface | The SDK evaluation context this provider builds |
createFlagGuard | function | Free-function route guard — short-circuits to redirect/404 when flag is off |
FlagProvider | interface | Port implemented by all providers, and by the 'custom' arm’s instance |
FlagProviderStatus | interface | Health status shape ({ healthy, detail? }) |
FlagProviderType | type | 'config' | 'memory' | 'database' | 'launchdarkly' | 'custom' |
FlagDefinition | interface | { enabled, percentage?, users?, tenants? } |
IFlagStore | interface | Structural facade for DatabaseProvider |
FeatureFlagsPluginOptions | type | Discriminated union of the four provider option shapes |
ConfigProviderOptions | interface | The 'config' arm — requires options.flags |
MemoryProviderOptions | interface | The 'memory' arm — options.flags optional |
DatabaseProviderOptions | interface | The 'database' arm — requires options.store |
LaunchDarklyProviderOptions | interface | The 'launchdarkly' arm |
LaunchDarklyProviderConfig | interface | That arm’s configuration shape |
CustomProviderOptions | interface | The 'custom' arm — requires options.instance |
FlagGuardOptions | interface | createFlagGuard options (fallback, statusCode, context) |
IFeatureFlags, FlagContext | type (re-export) | From @setu-ts/common |
Notes
IFeatureFlags.isEnabledis synchronous; providers refresh their state out of band.- Tenant restriction.
FlagDefinition.tenantsis a restriction, not an allowlist: when present and non-empty, the flag isfalsefor any context whosetenantIdis not in the list — including a context with no tenant — and it is evaluated ahead of every other rule, so it cannot be overridden byusersorenabled: true. When absent, evaluation is unchanged.FlagContext.tenantIdis optional, so existing callers and implementors are source-compatible; a context without atenantIdonly matters against a flag that scopes itself.createFlagGuardderivestenantIdfromctx.request.tenant?.id(omitting it when absent) unlessoptions.contextis supplied. - The allowlist (
users) overridesenabled: false—{ enabled: false, users: ['user1'] }evaluates totrueforuserId: 'user1'. - Percentage rollout uses deterministic FNV-1a 32-bit bucketing over
`${flag}:${userId}`. FlagContext.attributesis accepted but not consumed by the built-in evaluators; the'custom'arm is its extension point.IFeatureFlags.isEnabledAsyncis optional and additive. Providers with a purely local snapshot (config, memory, database) have nothing to await, soFeatureFlagServiceresolves 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.LaunchDarklyProviderbridges 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 givenuserId—isEnabledreturns the configuredfallbackValueand schedules a background refill, so every later read for that user is real LaunchDarkly state.start()prewarms the anonymous context and an SDKupdateevent drops the cache. UseisEnabledAsyncwherever a wrong answer on a cold context would matter; it awaitsboolVariationand carries no such caveat. The provider is Node/Deno/Bun only (the SDK usesnode:eventsand 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
| Option | Type | Required | Description |
|---|---|---|---|
resolver | 'subdomain' | 'header' | 'path' | 'jwt' | ITenantResolver | ITenantResolver[] | yes | Discriminant for tenant resolution method, or a custom resolver / array of resolvers |
subdomain.baseDomain? | string | no | Stripped from host to isolate tenant label; absent → first host label is tenant id |
header.name? | string | no | Header to read; default 'x-tenant-id' |
path.segment? | number | no | Path segment index to read; default 0 |
jwt.claim? | string | no | JWT claim to read; default 'tenant_id' |
jwt.headerName? | string | no | Authorization header name; default 'authorization' |
jwt.decode? | (token: string) => Record<string, unknown> | null | no | Custom decoder; falls back to IJwtService.decode from CAPABILITIES.JWT if absent |
database | 'column-per-tenant' | 'schema-per-tenant' | 'database-per-tenant' | ITenantIsolationStrategy | no | Isolation strategy the data store is expected to implement; default 'column-per-tenant' |
dataStore? | ITenantDataStore | RegistryFactory<ITenantDataStore> | no | Injected 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? | boolean | no | When true, stamps resolved prefix into ctx.state via getTenantCachePrefix() |
cache.separator? | string | no | Default ':' — used by prefixCacheKey and the ctx.state stamp |
required? | boolean | no | When true, short-circuits unresolved requests at 400 without calling next() |
rejectionStatus? | number | no | Status code for required-tenant short-circuit; default 400 |
exclude? | readonly (string | RegExp)[] | no | Paths 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? | number | no | Priority for auto-added middleware; default 40 |
Exports
| Symbol | Kind | Description |
|---|---|---|
MultiTenancyPlugin | function | Plugin factory — registers IMultiTenancyService under CAPABILITIES.MULTI_TENANCY |
tenantMiddleware | function | Middleware factory — resolve tenant, set ctx.request.tenant, short-circuit when required |
getTenantCachePrefix | function | Accessor reading the tenant cache prefix from ctx.state |
TENANT_CACHE_PREFIX_STATE_KEY | const | Module-level key constant ('multi-tenancy-plugin:cache-prefix') |
SubdomainResolver | class | Resolves tenant from URL host subdomain |
HeaderResolver | class | Resolves tenant from configurable HTTP header |
PathResolver | class | Resolves tenant from configurable path segment index |
JwtResolver | class | Resolves tenant from JWT claim (uses unverified decode) |
ColumnPerTenant | class | Column-per-tenant isolation strategy (default) |
SchemaPerTenant | class | Schema-per-tenant isolation strategy |
DatabasePerTenant | class | Database-per-tenant isolation strategy |
MemoryTenantDataStore | class | Zero-dependency in-process default store with cross-tenant isolation |
TenantDataStoreNotReadyError | class | Thrown by getRepository/getRepositoryFor before a factory dataStore resolves in onInit |
TenantNotResolvedError | class | Thrown by getRepository when tenant has not been resolved |
MultiTenancyPluginOptions | interface | The argument type of MultiTenancyPlugin(...) |
ResolverConfig | type | The resolver option’s type — discriminant string, custom resolver, or chain |
DatabaseStrategyKind | type | The database option’s discriminant strings |
SubdomainResolverOptions, HeaderResolverOptions, PathResolverOptions, JwtResolverOptions | interfaces | Per-resolver option shapes, also the resolver constructors’ argument types |
TenantCacheOptions | interface | The cache option shape (prefix, separator) |
MemoryTenantDataStoreOptions | interface | MemoryTenantDataStore constructor options (generateId) |
ITenantDataStore | interface | Internal port for tenant-scoped CRUD (app injection seam) |
ITenantIsolationStrategy | type | Discriminated union with 'column' | 'schema' | 'database' arms; narrow on kind |
Re-exported from common: IMultiTenancyService, ITenantRepository, ITenant, ITenantResolver, CAPABILITIES | types/const | Convenience 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.
JwtResolveruses unverified decode. Tenant identity comes from an unsigned token claim. This is acceptable only alongside auth middleware which separately verifies the token.PathResolverreads path segments by index, not router:paramvalues. The resolver receivesIRequest(notIRequestContext), so it cannot accessctx.params.SubdomainResolver’sbaseDomainconstrains resolution, it does not merely strip. WithbaseDomain: '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. WithoutbaseDomain, the first label of any multi-label host is the tenant and a single-label host (localhost) resolves to none.MemoryTenantDataStorehands out detached snapshots. Every entity returned bycreate/findAll/find/findById/updateis a shallow copy, so mutating a returned object never rewrites the stored record. Read paths (findAll/find/findById/delete/a missingupdate) 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 viauseIsolation().MemoryTenantDataStorederives 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 receivesuseIsolationpartitions by raw tenant id. - A strategy NAMES the isolation; a store delivers it. The shipped database bridge,
createDatabaseTenantDataStorefrom@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 throwsTenantStoreStrategyUnsupportedErrorat 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 ownfindById, so a primary key not namedidis addressed as its adapter is configured. Aregister()warning fires when a non-'column'strategy is selected with nodataStoreinjected, and theJwtResolverarm ofresolverwarns 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
| Endpoint | Method | Description | Status Codes |
|---|---|---|---|
/health | GET | Overall health (all indicators) | 200 (up/degraded), 503 (down) |
/live | GET | Liveness (self indicator only) | 200 (always, unless self is down) |
/ready | GET | Readiness (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
| Option | Type | Required | Description |
|---|---|---|---|
serviceName | string | Yes (real mode) | Service name reported to the exporter |
serviceVersion | string | No | Service version (default: '1.0.0') |
exporter | 'otlp' | 'console' | No | Exporter kind; absent = noop mode |
endpoint | string | Yes (otlp) | OTLP HTTP endpoint URL |
headers | Record<string, string> | No | Optional OTLP HTTP headers |
sampling | { type: 'traceidratio'; ratio: number } | No | Sampling config (default ratio: 1.0) |
tracerProviderFactory | () => Promise<TracerHost> | No | Injectable factory to bypass lazy import |
middleware | boolean | No | Register request-span middleware (default: true) |
spanProcessor | 'simple' | 'batch' | No | Span processor ('simple' by default) |
instrumentations | InstrumentationsConfig | No | Auto-instrumentation config (runtime-gated no-op) |
contextPropagation | boolean | No | Activate real OTel spans (default: true) |
contextManagerFactory | () => Promise<{ enable(): unknown; disable(): unknown }> | No | Injectable 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 |
diagnostics | TraceDiagnosticsOptions | No | Opt-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:
- Extracts
traceparent/tracestatefromctx.request.headers(W3C Trace Context) - Starts a server span named
<METHOD> <path> - Stores the span on
ctx.stateunderTELEMETRY_SPAN_KEY('telemetry-plugin:span') - Sets HTTP attributes (
http.method,http.route,http.status_code) - Injects
traceparentinto 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.
| Export | Kind | Shape / description |
|---|---|---|
ITelemetryService | interface | withSpan<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). |
ISpan | interface | setAttribute(key, value): this, setAttributes(attrs): this, setStatus(status): void, recordException(error): void, end(): void, spanContext(): SpanContext. |
SpanContext | interface | { readonly traceId: string; readonly spanId: string; readonly traceFlags: string } — all lowercase hex (32/16/2 chars). Returned by ISpan.spanContext(). |
SpanStatus | union | 'ok' | 'error' | 'unset' — argument to ISpan.setStatus. |
SpanKind | union | 'internal' | 'server' | 'client' | 'producer' | 'consumer' — SpanOptions.kind (default 'internal'). |
SpanAttributeValue | union | string | number | boolean | ReadonlyArray<string | number | boolean>. |
SpanOptions | interface | { 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. |
TelemetryContext | interface | Opaque 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. |
TraceparentSource | interface | Structural traceId?/spanId?/traceFlags? input accepted by contextToTraceparent; both TelemetryContext and SpanContext satisfy it. |
TELEMETRY_CONTEXT_OPAQUE | unique symbol | Brand 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— theAsyncLocalStorageContextManagerfrom the optional@opentelemetry/context-async-hooks— so in real OTel mode awithSpannested 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, undercontextPropagation: 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) onSpanOptionswherever the relationship must hold regardless of activation. The request-span middleware always passes the incomingtraceparentas 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.
| Export | Kind | Contract |
|---|---|---|
OperationIdError | class | Refuses blank or duplicate declared ids; readonly reason: 'duplicate' | 'blank' |
SwaggerOptions | type | { readonly assets?: 'self' | 'cdn' | { readonly baseUrl: string } } |
SwaggerUiOptions | interface | specUrl, 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
schemafield always wins, per field. Declaringschema.bodyand carryingvalidateQuery(...)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 inctx.stateby the time the handler runs. The request must still satisfy EVERY brand, since any of them can short-circuit with a400; the document shows the shape the handler sees, not that conjunction. cookiesderives nothing.RouteSchemahas nocookiesfield, so there is no declared counterpart — and@setu-ts/sdk’s client generator refuses anin: '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 configurederrorFormat, which the generator cannot see. deriveRequestSchemas: falsedisables derivation only. Owner exclusion, the derivedoperationIdformat for routes without a declared id and schema deduplication are unconditional, so this does not restore the whole pre-derivation document; passexcludeOwners: []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.responsealways wins, tested!== undefinedrather 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.
@ResponseHeadercontributes nothing — an OpenAPI response-header entry needs a schema and a description the declaration does not carry, so deriving one would put an under-specifiedheadersobject into every document that used the decorator. - A
@Redirectroute is documented under its3xx. deriveResponseStatus: falserestores the assumed200for every branded route. LikederiveRequestSchemas: falseit disables this derivation only; owner exclusion, the derivedoperationIdformat 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 forauthMiddleware(), 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', sorequireRole('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
bearerAuthorjwt. An undeclared name is refused atregister().
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$schemakey is dropped, formats and constraints pass through verbatim (format,pattern,contentEncoding, …), reused schemas are hoisted intocomponents/schemaswith their pointers rewritten, and a recursive schema’s root-cycle pointer forces that schema intocomponentsso 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 emittedanyOf: [{ "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 atypearray is not legal). Sibling keywords stay beside theanyOfrather 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 thenullarm. Only schema positions are rewritten: adefault,const,enum, example or vendor-extension value is instance DATA and is preserved verbatim, even when it happens to carry its owntypekey. And where a node already assertsanyOf, the normalized union is conjoined throughallOfrather than taking theanyOfkey, since the two are independent assertions over the same instance. A union whose arms carry anything beyond a bare type (a constraint, aformat, aconst, anitems) is emitted asanyOfby 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" } ] }atnames 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 firstgetSpec()//openapi.jsonrequest, not at startup, so inspect it (or test forx-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 existingnot a recognized zod schemadiagnostic throughx-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:
| schema | unknown key at runtime | request body documents | response documents |
|---|---|---|---|
z.object | accepted, stripped | (no additionalProperties) | additionalProperties:false |
z.strictObject | rejected | additionalProperties:false | additionalProperties:false |
z.looseObject | accepted, kept | additionalProperties:{} | 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
RouteSchemaposition the generator reads becomes part of the operation:bodybecomes theapplication/jsonrequest body,responsebecomes the responses map,tags/summary/operationId/descriptionbecome the operation metadata, andparams,query, andheadersbecomeparameterswithin: 'path',in: 'query', andin: 'header'respectively. Path parameters are alwaysrequired: true(they come from the path template); query and header parameters take theirrequiredflag from the schema. Header parameters are emitted verbatim — per OpenAPI 3.1, tooling ignores definitions namedAccept,Content-Type, andAuthorization, so the generator does not filter them out. - A path parameter the
paramsschema 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 emitunknown. A declaredparamsentry always wins, per parameter. - The plugin’s own
specEndpointandendpointare never documented as operations — a spec that lists/openapi.jsonand/docsdescribes 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 inexclude, which matches the fully-resolved router pattern — a route registered insiderouter.group('/internal', …)is matched by its prefixed path, and an entry matching no route is silently ignored. - A
securityrequirement naming a scheme absent fromsecuritySchemesis refused atregister(). 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 answers200. - An unrestricted decorated route marked
@Publicis documented with an emptysecurityarray, so it opts out of a document-level requirement.@Publicnever exempts a route from an enforced@Roles/@Permissionsdeclaration; 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.
| Export | Kind | Description |
|---|---|---|
runMain | function | (runtime: MainRuntime, host: MainProcess) => Promise<number> — runs one invocation and returns its exit code; never exits |
nodeProcess | function | (onInterrupt?) => MainProcess — the Node host: process.argv, process.cwd(), console output, one shared readline reader |
emitInterrupt | function | Delivers an interrupt to this process’s SIGINT handlers as an event, never as a real signal; nodeProcess’s default |
MainRuntime | interface | The runtime services runMain reads (fs, the clock, onSignal) |
MainProcess | interface | The 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
| Export | Kind | Description |
|---|---|---|
createArgs | function | (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
| Option | Commands | Behavior |
|---|---|---|
--runtime deno|node|bun|cloudflare-workers | new, generate | On 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> | new | Downloads 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. |
--workspace | new | Creates 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, adopt | On 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 app | ConfigPlugin-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 app | Repeat 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 view | Place 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-controllers | generate module | The 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. |
--devtool | new, generate app | Opts 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 enable | The 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 --workspace | How 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 --workspace | Replaces 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-based | new, generate app | The 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, -y | new | Take every default and ask nothing. A no-op when no prompter is present, never an error. |
--scope <scope> | generate library | The import scope a shared library is named under, without the leading @. Defaults to the workspace directory name. |
--name <member> | adopt | The member name the converted project takes. Defaults to the project directory name. |
--agents | new | Write 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. |
--check | agents | Render 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, agents | Operate on this directory instead of the working directory. A relative path is resolved against the working directory. |
--dry-run | new, generate, add, agents, workspace ports | Prints 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, -h | both | Prints 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:
| Project | Printed command |
|---|---|
| Deno | deno install --min-dep-age 0 |
| Node | npm install |
| Bun | bun install |
| Cloudflare Workers | npm 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
| Code | Meaning |
|---|---|
0 | Success (including --help and --version). |
1 | Runtime 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. |
2 | Usage 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). |
130 | Interrupted 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:
| Export | Kind | Members |
|---|---|---|
Prompter | interface | Since 0.9.0, select(question, choices): Promise<PromptSelection> — answer, unavailable, or cancelled. |
PromptChoice | interface | { value, label } — the value written into the flag record, and one descriptive line shown above the question. |
PromptSelection | type | Since 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 want | Scaffold with | You get |
|---|---|---|
| Neither | setu new app | The runtime plugin alone. g route, g middleware, g plugin, g service, g job all work and are wired. |
| Functional | setu new app --template rest | Router functions and explicit request context; g module creates a registered route with GET and POST handlers. |
| Class-based | setu new app --template rest --style class-based | DecoratorPlugin + 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 bareSyntaxError: Invalid or unexpected tokenthere — or the constructor parameter propertysetu generate moduleemits (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX). A generated Node project therefore declarestsxindevDependenciesand develops withtsx watch main.ts, which transforms both; itstsconfig.jsonsets 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.wranglerbundlessrc/index.tswith esbuild, which does not read a Deno import map, so a scaffolded Workers project emitspackage.json(@setu-ts/*dependencies from npm,wranglerpinned indevDependencies, anddev/deployscripts), andnpm install && npx wrangler devworks as printed. The Deno target does NOT get one: it resolves through the import map, and apackage.jsonswitches 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 devon 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:
| Path | What it is |
|---|---|
docker/Dockerfile | One 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. |
.dockerignore | At 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.yaml | Every 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.yaml | A 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.
| Refusal | Why |
|---|---|
generate app outside a workspace | No setu.workspace.json in the target directory. Exits 1 naming setu new <name> --workspace. |
| A member name already in use | Exits 1 naming the directory it already has. The managed exemption covers regenerated modules only, never a member’s own source. |
--runtime cloudflare-workers | deno, 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 transport | That 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 port | Two 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 --di | The 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–65535 | Refused 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 left | basePort 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.
--transport | What every member gets | Proven by |
|---|---|---|
http (default) | Nothing extra. Members call each other at discovery.resolveUrl(name). | Three members, one calling both peers, asserting 200 and the body. |
grpc | GrpcPlugin({ 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. |
memory | The messaging plugin’s own in-process default, named rather than implied. Messages never leave the process. | — |
redis | MessagingPlugin({ broker: 'redis-streams', url }) | A real Redis: one service publishes, another receives the payload. Swapping to memory makes that test fail. |
rabbitmq / nats / kafka | The matching MessagingPlugin arm. | Type-checked in the generated project against the plugin’s discriminated union; not run in CI, which holds no such broker. |
pubsub | MessagingPlugin({ 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-bus | MessagingPlugin({ 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 tcpsays 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.
deno | node | bun | |
|---|---|---|---|
| Root manifest | deno.json workspace | package.json workspaces | same as node |
| Install | deno install | npm install | bun install |
| Run every member | deno task dev | npm run dev | npm run dev |
| Environment read in generated source | Deno.env.get(x) | process.env.X | process.env.X |
| Library test runner | @std/testing | node:test | bun test |
| Image | denoland/deno | node:24-alpine | oven/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.
| Refusal | Why |
|---|---|
| A directory that is already a workspace | setu.workspace.json exists. Exits 1 naming generate app. |
A directory with no setu.config.ts | Not a Setu project: there is no application factory to become the member’s. Exits 1. |
A --name that cannot form an identifier | Exits 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.
| Schematic | Emitted at | Registration site |
|---|---|---|
module | src/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) |
controller | src/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) |
service | src/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 |
route | a function controller: src/controllers/<name>.controller.ts; src/modules/<module>/<name>.controller.ts with --module | registerGeneratedRoutes(app.router, app.services) in createApp(), or the module’s index.ts |
schema | src/shared/schemas/<name>.schemas.ts; src/modules/<module>/<name>.schemas.ts with --module | Nothing — the feature that uses it imports it |
sse | src/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 installed | As for controller; the hook, when emitted, remains application-local |
ws-route | src/plugins/<name>.plugin.ts; class-based: src/ingress/<name>-ws-route.ingress.ts | ...GENERATED_PLUGINS in createApplication({ plugins }); class-based: DecoratorPlugin({ ingress }) |
middleware | src/middleware/<name>.middleware.ts | app.middleware.add(...) over GENERATED_MIDDLEWARE |
plugin | src/plugins/<name>.plugin.ts | ...GENERATED_PLUGINS in createApplication({ plugins }) |
health-indicator | src/health/<name>.indicator.ts | HealthPlugin({ indicators: [...HEALTH_INDICATORS] }) |
metric | src/metrics/<name>.metric.ts | MetricsPlugin({ customMetrics: [...CUSTOM_METRICS] }) |
command-handler | src/cqrs/<name>.command-handler.ts; class-based: src/ingress/<name>-command-handler.ingress.ts | CqrsPlugin({ commandHandlers: COMMAND_HANDLERS }); class-based: DecoratorPlugin({ ingress }) |
query-handler | src/cqrs/<name>.query-handler.ts; class-based: src/ingress/<name>-query-handler.ingress.ts | CqrsPlugin({ queryHandlers: QUERY_HANDLERS }); class-based: DecoratorPlugin({ ingress }) |
event-handler | src/events/<name>.event-handler.ts; class-based: src/ingress/<name>-event-handler.ingress.ts | EventsPlugin({ handlers: EVENT_HANDLERS }); class-based: DecoratorPlugin({ ingress }) |
guard | src/guards/<name>.guard.ts | None — per route, by design (see below) |
job | src/jobs/<name>.job.ts; class-based: src/ingress/<name>-job.ingress.ts | Functional + queue: QueuePlugin({ processors: [...QUEUE_PROCESSORS] }); standalone without queue; class-based: DecoratorPlugin({ ingress }) |
migration | src/migrations/<stamp>-<name>.ts | None — the framework ships no migration runner |
Registration details for artifacts with conditional or per-route wiring:
guardis applied per route, withapp.router.get(path, { handler, middleware: [requireX()] })or@UseGuards(requireX())on a controller or handler.auth-pluginpublishes no guard list, and the only barrel-shaped alternative — the global pipeline — would answer401for/health,/metricsand every public route. The emitted guard delegates torequirePermission('<name>'), enforcing the installed authorization provider for anonymous and insufficient-permission requests. A wiring that must not be applied is not a wiring.jobuses the installed queue. Functional projects withqueue-pluginemit a processor factory and the managedsrc/jobs/index.tsbarrel forQueuePlugin({ 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 namessetu add queue. Each processor polls only its own job name; producers enqueue that name explicitly.migrationhas no consumer. No plugin registers a CLI command, so there is nosetu db:migrateand nothing reads migration files. Apply them from your own script or your ORM’s tooling.serviceis shaped on the detected plugin set. With@setu-ts/decorator-plugininstalled it emits@Injectable({ token: '<name>-service' })plus the barrel entry, so the class resolves fromservices.get('<name>-service')(or the DI container, whenDiPluginis 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,controllerandmoduleall mount/<name>. The kernel’s router keys routes by method and path, so a duplicate silently overwrites and one artifact becomes unreachable.serviceandmoduleboth 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;
}
| Template | Plugin set |
|---|---|
| (none) | RuntimePlugin only. |
rest | Runtime, Config, Logger, Validation, HttpSecurity, Health, Metrics, OpenApi + RFC 9457 errorHandler(). |
microservice | rest plus Messaging, Queue, Resilience, Telemetry, ServiceDiscovery ('static' arm), Cqrs, Events. |
class-based | rest plus DecoratorPlugin and DiPlugin, an @Injectable service, and a @Controller. |
full-stack | A 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.setuawaits it during command discovery, andmain.tsawaits 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
assetsDirandenvFilePath: 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 requestenvbinding. - 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.jsonfor Vite and React Router; Node and Bun targets get those dev dependencies merged into thepackage.jsonthey already have. The Denostarttask 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-routerthe 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
| Export | Kind | Purpose |
|---|---|---|
runCli | function | Runs the CLI and RETURNS an exit code; never calls Deno.exit. |
CliDependencies | type | The boundary bundle runCli requires, including optional prompting, interruption and the verified logical working directory. |
deriveNames | function | Produces the five naming forms every schematic uses. |
DerivedNames | type | The result of deriveNames. |
GeneratedFile | type | { path, contents, managed?, bytes? } — one file a schematic asks to create. |
Schematic | type | (names, options) => readonly GeneratedFile[]. |
SchematicOptions | type | The second parameter of every schematic (runtime/plugins/now/modules?, and with --module the optional targetModule? and moduleArtifacts?). |
PROGRAM_NAME | const | 'setu' — interpolated into every usage string. |
TemplateName | type | The --template value union, for callers building argv programmatically. |
ModuleLoader | type | The seam a custom schematic module is loaded through. |
AppLoader | type | The seam setu.config.ts is loaded through (CliDependencies.loadApp). |
PromptChoice | interface | One value and label offered by a Prompter. |
Prompter | interface | Programmatic interactive-question seam. |
PromptSelection | type | Answer, unavailable input, or user cancellation. |
detectPlugins | function | Reads 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.
--templateemits inline wiring. Asetu new --starterpath that scaffolds a project importingcreateRestAppand friends is deferred — the starters themselves shipped in Milestone 36 and can be depended on directly. - Flags for plugin commands.
CliCommandHandlerreceives positionals only; giving handlers a parsed flag record would widen a committedcommoncontract. Forward flags with--instead. - Plugin installation.
setugenerates 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.
| Export | Kind | Package |
|---|---|---|
createRestApp | function | rest-starter |
buildRestPlugins | function | rest-starter |
RestStarterOptions | type | rest-starter |
RealtimeArm | type | all three (re-exported along the tier’s pin chain) |
createMicroserviceApp | function | microservice-starter |
buildMicroservicePlugins | function | microservice-starter |
MicroserviceStarterOptions | type | microservice-starter |
createFullStackApp | function | full-stack-starter |
buildFullStackPlugins | function | full-stack-starter |
createFullStackAppFromConfig | function | full-stack-starter |
fullStackConfigOf | function | Returns the exact config snapshot used by the config-driven factory. |
FullStackConfigUnavailableError | class | Thrown when the accessor receives an app built by another factory. |
FullStackStarterOptions | type | full-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:
| Arm | Gating | Effect |
|---|---|---|
database, auth | gated | Adds DatabasePlugin / AuthPlugin. |
session | gated | Adds SessionPlugin; with csrf, its form-CSRF middleware at priority 325. |
di | gated | Adds DiPlugin. Changes how every decorated service is constructed — see the note below. |
realtime.websocket | gated | Adds WebSocketPlugin. |
realtime.sse | gated | Adds SsePlugin. |
realtime.backplane | gated | Adds RealtimeBackplanePlugin at PLUGIN_PRIORITY.HIGH, so it precedes both consumers. |
| everything else | on | Registered 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 registerMessagingPlugin; the REST tier does not, so on that tier the backplane’s ownregister()throws namingMessagingPlugin. The starter adds no second check of its own.diis opt-in because it is not additive.DecoratorPluginbranches on the presence of a container, so with this arm each@Injectablebecomes a container provider honoring itsscope; without it those classes are constructed directly and registered in theServiceRegistry.- 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
SchedulerPluginout on Cloudflare Workers. The plugin refuses that platform atregister(), so registering it unconditionally made every full-stack Workers application fail to start. The starter reads the platform withdetectRuntime(), as its ownRuntimePlugin()does, and omits the scheduler there unless aschedulerarm is passed; schedule on Workers withcloudflare-plugin’sWorkersCron. sessionis gated because it cannot be defaulted.SessionPluginthrows duringregister()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
DecoratorPluginto 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.
| Arm | Reads | watch() | Runtimes |
|---|---|---|---|
'static' | A literal list in the options | Fires once, then never | All, incl. Workers |
'consul' | GET /v1/health/service/:service | Blocking queries (push) | All, incl. Workers |
'kubernetes' | EndpointSlices from the API server | Watch stream (push) | All, incl. Workers¹ |
'dns' | SRV or A/AAAA records | Polled at watchIntervalMs | Deno, Node, Bun |
'custom' | The application’s own provider | Whatever it implements | Any |
¹ Workers needs an explicit token: it has no file system to read the projected service-account
token from.
Options
| Option | Arms | Default | Behavior |
|---|---|---|---|
provider | all | — | Discriminant; always explicit |
cacheTtlMs | all | 30_000 | 0 disables caching so every resolve hits the backend |
strategy | all | 'round-robin' | Overridable per pick() call via PickOptions.strategy |
ejection | all | see below | false disables outlier ejection entirely |
selfRegistration | consul, custom | — | Throws SelfRegistrationNotSupportedError on the other arms |
watchIntervalMs | static, dns | 30_000 | Absent from the push-based arms rather than silently unread |
services | static | — | Unknown name resolves to [], never a throw |
address | consul | — | Agent base URL |
token | consul | — | Sent as X-Consul-Token; the header is omitted entirely when unset |
datacenter | consul | — | Sent as ?dc= |
waitSeconds | consul | 30 | Blocking-query wait, clamped to Consul’s documented maximum of 600 |
namespace | kubernetes | — | Required |
apiServer | kubernetes | in-cluster env | Absent both, register() throws |
token | kubernetes | projected token file | Used verbatim; otherwise the file is re-read behind a 60 s memo |
portName | kubernetes | — | Unset with one port uses it; unset with several throws |
mode | dns | — | 'srv' honours RFC 2782 priority tiers; 'a' reads address records |
domainTemplate | dns | '{service}.service.consul' | {service} is substituted with the requested name |
port | dns ('a' only) | — | Mandatory on that arm: DNS address records carry no port |
secure | consul, k8s, dns | false | Sets ServiceInstance.secure, which decides the https scheme |
http | consul, kubernetes | fetch | Overrides createDefaultDiscoveryHttp() |
discovery | custom | — | 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
| Export | Kind | Purpose |
|---|---|---|
ServiceDiscoveryPlugin | function | The plugin factory |
ServiceDiscoveryPluginOptions | type | The discriminated option union |
StaticDiscoveryOptions | interface | The 'static' arm |
ConsulDiscoveryOptions | interface | The 'consul' arm |
KubernetesDiscoveryOptions | interface | The 'kubernetes' arm |
DnsDiscoveryOptions | type | The 'dns' arm (SrvDnsDiscoveryOptions | ADnsDiscoveryOptions) |
CustomDiscoveryOptions | interface | The 'custom' arm |
StaticServiceDefinition | interface | One entry of a static service list |
EjectionOptions | interface | Ejection tuning |
SelfRegistration | interface | What this instance advertises |
SelfRegistrationCheck | interface | The mandatory health check |
DiscoveryProvider | interface | The provider port, for the 'custom' arm |
StaticProvider | class | The 'static' provider |
ConsulProvider | class | The 'consul' provider |
KubernetesProvider | class | The 'kubernetes' provider |
DnsProvider | class | The 'dns' provider |
IDiscoveryHttp | interface | The injectable HTTP seam (buffered + streaming) |
DiscoveryHttpResponse | interface | Buffered response shape |
DiscoveryHttpStream | interface | Streaming response shape |
createDefaultDiscoveryHttp | function | The default seam over fetch |
DiscoveryUnavailableError | class | Cold backend failure (branded 503), missing DNS resolver, k8s multi-port |
SelfRegistrationNotSupportedError | class | selfRegistration on an arm that cannot register |
Notes
- Registers a
service-discoveryhealth indicator reportingprovider,cachedServices,watchedServices,ejectedInstances, anddegraded. 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 aDiscoveryUnavailableError. onCloseunsubscribes 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. pickandresolveUrlfunnel 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_CERTSpointed at/var/run/secrets/kubernetes.io/serviceaccount/ca.crt; no code change fixes the cluster CA from inside the process. Thehttpoption 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).
| Status | Meaning |
|---|---|
up | The 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. |
down | The 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 member | Consumer | Behavior |
|---|---|---|
IRuntimeServices.reportError?(record: Readonly<Record<string, unknown>>): void | Kernel unhandled errors and errorHandler 5xx, only without a logger | Last-resort JSON line to stderr (Workers platform log channel); serialization and write failures never throw |
IErrorResponder.statusFor?(error: unknown): number | Request logger | Pure 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:
| Member | Type | Purpose |
|---|---|---|
fields | Readonly<Record<string, DataClassification | FieldRedaction>> | Dot-path patterns and the classification or FieldRedaction each carries |
redactors | Readonly<Record<string, Redactor>> | Redactor selected by the matched classification |
defaultRedactor | Redactor | Fallback 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>]):
| Constant | Value |
|---|---|
DATA_CLASSIFICATIONS.PII | 'pii' |
DATA_CLASSIFICATIONS.PHI | 'phi' |
DATA_CLASSIFICATIONS.PCI | 'pci' |
DATA_CLASSIFICATIONS.SECRET | 'secret' |
Values (runtime exports)
| Export | Kind | Purpose |
|---|---|---|
CAPABILITIES | const | Standard 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) | function | Validates and creates a custom (optionally dot-namespaced) token; throws TypeError on invalid names |
isConnectionFault(error) | function | Recognises connection outages through a bounded, cycle-safe cause walk, including measured ioredis rejection shapes. |
fromNodeRedis(client, options?) | function | M117: wraps a node-redis client in the ioredis-shaped adapter every Redis-backed plugin’s client option accepts |
NodeRedisClientFactory | type | M117: creates the node-redis client on the adapter’s first connect, so redis can load after TelemetryPlugin registers its instrumentation |
NodeRedisClientLike | type | M117: the structural node-redis client fromNodeRedis takes |
NodeRedisAdapterOptions | type | M117: commandTimeoutMs, onError |
NodeRedisAdapter | type | M117: the adapter fromNodeRedis returns |
isConnectionFaultCode(code) | function | Returns true only for a string in the private eight-code node/net connection-fault set. |
encodeFrameData(data) | function | Encodes a WebSocket payload for a realtime backplane; binary becomes base64 |
decodeFrameData(payload) | function | Decodes a backplane payload back to string or Uint8Array |
createCachedProbe(options) | function | Builds 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) | function | Since 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) | function | Since 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 |
DeadlineOptions | type | Since M101a. { timeoutMs, onTimeout, timing? } for withDeadline; timing is the setTimer/clearTimer surface, typically resolveProbeTiming(ctx.runtime) |
resolveProbeTiming(runtime) | function | Resolves 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) | function | Builds 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?) | function | Attaches 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) | function | Parses 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?) | function | Serializes 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) | function | Guard: narrows a worker message to a WorkerReadySignal |
isWorkerTaskRequest(m) | function | Guard: narrows a worker message to a WorkerTaskRequest |
isWorkerTaskReply(m) | function | Guard: narrows a worker message to a WorkerTaskReply |
PLUGIN_PRIORITY | const | Well-known plugin priority bands (HIGHEST…LOWEST) |
createToken<T>(name) | function | Creates 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) | function | Result constructors |
isOk(r) / isErr(r) | function | Result type guards |
unwrap(r) | function | Returns the Ok value or throws the Err error |
some(value) / none() | function | Option constructors (none() returns a frozen singleton) |
isSome(o) / isNone(o) | function | Option type guards |
fromNullable(v) | function | Converts T | null | undefined to Option<T> |
serializeError(value) | function | Serializes 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) | function | Renders 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) | function | Extracts 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) | function | Parses 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) |
DuplicateKeyError | class | A 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 |
assertPrimaryKeyUnchanged | function | Since 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 |
PrimaryKeyChangeError | class | Since 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 |
InvalidCursorError | class | Since 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 |
OptionalDependencyUnavailableError | class | Thrown 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 |
MalformedRequestBodyError | class | The 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) | function | Parses 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) | function | Classifies 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 / FormEncoding | types | The 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) |
UnsupportedFormEncodingError | class | The 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) | function | Returns 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) | function | Writes 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) | function | Writes 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) | function | Since 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_KEY | const | exceptions:error-responder, the ctx.state key under which errorHandler publishes its IErrorResponder |
brandErrorResponder(fn, r) | function | Attaches 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) | function | Reads 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_BRAND | const | A 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) | function | Brands 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) | function | Reads 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_HINT | const | Symbol.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) | function | Returns `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_KEY | const | http-security-plugin:request-origin; the trusted { protocol, host } IP security publishes for the common accessor (M113n) |
RequestOrigin | type | { readonly protocol: 'http' | 'https'; readonly host: string; readonly origin: string } returned by requestOriginOf (M113n) |
requestOriginOf(ctx) | function | Returns the published public origin or one derived from ctx.request.url; makes no proxy trust decision (M113n) |
publicRequestUrl(ctx) | function | Substitutes the published origin while keeping path and query; returns the original URL byte for byte without publication (M113n) |
CLIENT_IP_STATE_KEY | const | http-security-plugin:client-ip, the cross-package key ipSecurityMiddleware writes and rateLimitMiddleware reads |
SESSION_STATE_KEY | const | session-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_KEY | const 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_KEY | const | __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_KEY | const | session-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) | function | The 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) | function | Builds a path-exclusion predicate from readonly PathPattern[]; strings match exactly and regular expressions are tested against the path. |
installRequestIdentityGuard(prototype) | function | Installs 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) | function | Installs the one-implicit-write request identity guard for user, tenant and locale |
replacePrincipal(request, principal) | function | Deliberately replaces request.user after it has been guarded |
replaceTenant(request, tenant) | function | Deliberately replaces request.tenant after it has been guarded |
replaceLocale(request, locale) | function | Deliberately 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) | function | Duck-typed thenable test (M87) — see the note below the Types table |
Types
| Group | Exports |
|---|---|
| Tokens | CapabilityToken, StandardCapability |
| Shared types | HttpMethod, 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 |
| Utilities | Result<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 contract | IPlugin, IPluginContext, IApplication, StartOptions |
| Plugin context APIs | IMiddlewareApi, MiddlewareOptions, IRouterApi, IEnvironmentApi, EnvVarSpec, IHealthApi, IMetricsApi, IOpenApiApi, IDecoratorApi, DecoratorHandler, ICliApi, CliCommandHandler, ILifecycleApi, IMetadataStore |
| Service registry | IServiceRegistry, RegisterOptions, ServiceFactory<T>, RegistryFactory<T>, resolveRegistryEntry |
| HTTP | IRequest, 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 |
| Runtime | IRuntimeServices, 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 |
| Logging | ILogger, LogMetadata |
| Config | IConfig |
| Validation | IValidationService, ValidationTarget, ValidationIssue |
| Health | IHealthIndicator, HealthIndicatorFn, HealthIndicatorOptions, HealthCheckResult, IHealthService, HealthReport, HealthStatus, CachedProbeOptions, ProbeTiming, ConnectionErrorReporter, ConnectionErrorReporterOptions, ConnectionErrorLogger |
| Metrics | IMetric, MetricConfig, IMetricsService, ICounter, IGauge, IHistogram, ISummary, MetricOptions |
| Auth | IPrincipal, IJwtService, JwtSignOptions — IPrincipal is { id: string; roles?: readonly string[]; permissions?: readonly string[]; claims?: Readonly<Record<string, unknown>> } |
| Database | IOrmAdapter, 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) |
| Cache | ICacheStore |
| Events | IEventBus, IDomainEvent<T>, EventHandler<T>, Unsubscribe |
| Messaging | IMessageBroker, 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 |
| Ingress | IngressKind, IngressContext<TPayload>, BehaviorLike<TWork, TResult>, IIngressBehavior, composeBehaviorChain |
| Queue | IQueue, IJob<T>, JobProcessor<T>, AddJobOptions, ProcessOptions, RecurringOptions |
| Scheduler | IScheduler, ScheduledJob<T>, SchedulerJobHandler<T>, ScheduleOptions<T>, RetryOptions, SchedulerBackoff |
| Secrets | ISecretManager |
| Audit | IAuditLogger, AuditEntry |
| Resilience | ICircuitBreaker, CircuitState, IResilienceService, WrapOptions, CircuitBreakerPolicy, RetryPolicy, BulkheadPolicy, BackoffStrategy, ResilientCall, HardenedCall |
| Storage | IStorage, SignedUrlOptions |
IMailer, MailMessage | |
| Notifications | INotifier (with optional sendSettled?), NotificationMessage, ChannelSendResult |
| Errors | IErrorResponder, ErrorResponseInit, ErrorResponderTarget, SerializedError, AuthorizationFailure — the request-scoped error responder seam, standard authorization refusals, and the pure error serializer (M70f) |
| Feature flags | IFeatureFlags, FlagContext |
| Multi-tenancy | IMultiTenancyService, ITenantRepository, ITenantResolver, ITenant |
| SSR | ISsrService |
| SSE | ISseService, ISseConnection, SseChannel, SseMessage |
| Realtime backplane | IRealtimeBackplane, RealtimeFrame, RealtimeFrameHandler, RealtimeFrameKind, EncodedPayload |
| WebSocket | IWebSocketService, IWebSocketConnection, IWebSocketTransport, WebSocketRoom, RoomBroadcastOptions, WebSocketHandlers, WebSocketRouteOptions, WebSocketConnectionContext, WebSocketCloseEvent, WebSocketReadyState, WebSocketEventSink, WebSocketUpgradeDecision, WebSocketUpgradeRouter, WebSocketUpgradeGuard, WebSocketGuardDecision |
| Worker pool | IWorkerPool, WorkerRunOptions, TaskPoolStats, WorkerReadySignal, WorkerTaskRequest, WorkerTaskReply, WorkerErrorShape |
| Session | ISessionService, ISession, ISessionStore, SessionData, SessionView, CookieAttributes |
| Auth session | IAuthSessionService, SignInOptions, SignInOutcome, AuthMethod |
| Authorization policies | IAuthorizationPolicyService, 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 RBAC | SCOPED_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 discovery | IServiceDiscovery, ServiceInstance, PickOptions, LoadBalanceStrategy, ServiceOutcome |
| DNS | IDnsResolver, SrvRecord |
| gRPC | IGrpcService, GrpcServiceDefinition, GrpcServingStatus, RpcFetchHandler |
| Cloudflare | splitWorkerEnv, SplitWorkerEnv |
| View rendering | IViewEngine, Component — the view port (render(component, props): string | Promise<string>) and the structural component type it renders, named by @Render and renderView (M92) |
| Localization | ILocalizer, 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) |
| Outbox | IOutboxStore, 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) |
| Inbox | IInboxStore, 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) |
| Idempotency | IIdempotencyStore, 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
IContainermethod takes aContainerToken<T>: a string, anInjectionToken<T>fromcreateToken<T>(name), or a class (Constructor<T>). Object tokens are matched by identity.resolveon anInjectionToken<T>or a class returnsTwithout a type argument;resolve<T>(token: string)is unchanged. - Visible dependencies.
ClassProvider.injectand the newFactoryProvider.injectarereadonly InjectEntry[].FactoryProvider.useFactoryis(...deps: never[]) => Tand is called with the resolved dependencies ininjectorder; an absent optional dependency arrives asundefined. A factory withoutinjectis opaque to boot validation. ProviderOptions<T>(generic, defaultunknown) addsdispose?: (instance: T) => void | Promise<void>, called bydispose()for each instance the container created.forRequest(ctx: IRequestContext): IContainerreturns the request’s scope, created on first call and disposed throughctx.deferwhen the request finishes. Withoutctx.deferit 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) oneAggregateError. 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.scopedmeans one instance per scope; a request reaches its scope throughforRequest. Resolving ascopedtoken on the root container is refused, and so is atransientthere whose graph reaches ascopedprovider.- Breaking for implementors:
forRequestanddisposeare 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.runtimeis 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 ofstop(), 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 inonShutdownleaves 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 fromstop(), but only after the drain, the socket close, and theonShutdown/onClosephases have run: a failing hook must not be able to prevent the application from stopping. With no hook registered the phase is skipped andstop()behaves exactly as before. -
Schema positions (
RouteSchema,IValidationService,IOpenApiApi) are typedunknownsocommoncarries no validator dependency; the validation plugin narrows them (Zod by default). -
RouteSchema.operationId?: stringandRouteSchema.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;OperationIdErrorfrom@setu-ts/openapi-pluginrefuses invalid declarations at bootstrap and document generation. -
RouteSchema.securityis 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.
withSecurityMetadatabrands aMiddlewareFunctionwith aRouteSecurityMetadata({ authenticated: boolean }), andsecurityMetadataOfreads it back — the channel by which@setu-ts/openapi-pluginderives an operation’s requirement from the guards@setu-ts/auth-pluginproduced, without either package importing the other.SECURITY_METADATAis created withSymbol.for, so two copies ofcommonin 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, sorequireRole('admin')brands{ authenticated: true }and nothing more. -
withHttpStatusHintbrands anErrorrather than a middleware function, and the reason it exists is thatrespondWithErrorcannot reach every site: a data source throwing from deep inside an adapter holds noIRequestContext, so its error arrives aterrorHandleras a plainError, is normalized to a500, and is masked. That is right for a driver fault and wrong for a refusal the caller caused.HttpStatusHintis anErrorResponseInitwhosedetailis required, anderrorHandlerbuilds the response from the hint’s ownstatus/title/detail— never from the error’smessage, which is the operator-facing diagnostic and stays log-only. Both entry points construct the response through one implementation, so a hinted throw and arespondWithErrorcall carrying the same values answer byte-identically under every configured format. A hinted error is exempt frommaskInternalErrors, 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 thrownHttpErroris ignored — that error already states its own status.statusmust be an integer in400–599, and a hint outside that range is treated as ABSENT so the error takes the ordinary masked-500path. Two reasons, and the second is why the floor is400rather than200: the webResponseconstructor throwsRangeErroroutside[200, 599], so an unserveable status would make the error handler itself the fault — a throw escaping the onecatchthat 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 theErrorResponseInitit extends, whosestatusis a plainnumberthat an application authors (FlagGuardOptions.statusCodeis the shipped example) and so may legitimately be any serveable status, not only an error one.ErrorResponseInit.statusis guarded separately, byresolveResponseStatus, against the serveability bound[200, 599]rather than this error-ness one. -
withValidationMetadatabrands aMiddlewareFunctionwith aRouteValidationMetadata({ target: ValidationTarget; schema: unknown }), andvalidationMetadataOfreads it back — the same mechanism, for the same reason, applied to request shape rather than authentication. Every helper@setu-ts/validation-pluginships carries it, as doesIValidationService.middleware(schema, target), and@setu-ts/openapi-pluginreads it to fill an operation’srequestBodyandparameters.VALIDATION_METADATAis created withSymbol.forfor the same cross-copy reason, the brand is symbol-keyed and non-enumerable, and theschematravels 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. -
withResponseMetadatabrands aRouteHandlerwith aRouteResponseMetadata({ status: number }), andresponseMetadataOfreads 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@HttpCodeor@Redirectproduces carries it, and@setu-ts/openapi-pluginreads it (deriveResponseStatus, on by default) to document the operation under that status instead of the assumed200.RESPONSE_METADATAis created withSymbol.forfor 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.
statusmust be an integer in200–599, the range the webResponseconstructor 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 withMalformedRequestBodyError— branded400— when the body is not valid JSON (X37-1). The read is lazy (M87) and happens inside the handler, so the rejection reacheserrorHandlerthrough the ordinary throw path and is answered400in the configured format; a handler that CATCHES its ownjson()rejection keeps full control of what is served. The same rejection comes from all three producers, so an in-processinject()observes exactly what a served request observes. Added in Milestone 90f. -
IRequest.formData?()resolves aFormBodyfor both form encodings through the sharedparseFormBody, memoized exactly likejson()— the in-flight promise is cached, a rejection with it. It throwsUnsupportedFormEncodingError, branded415, 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-processinject()observes exactly what a served request observes; a request WITHOUT the optional accessor (an out-of-repo implementor) falls back to the sameparseFormBody(await request.bytes(), contentType)— the fallback is the same function, not a second implementation. Optional on thesignal?/fs?/raw?precedent. Added in Milestone 94b. -
IRequest.raw?: RequestandIRequestContext.raw?: Requestcarry the undisturbed webRequestthe 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 M42signal?/ M44fs?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 the404— it never throws.inject()populates it, so an injected request can exercise both paths. Added in Milestone 70a. -
setUpgradeIntent(request, intent)brands anIRequestwith aWebSocketUpgradeIntent({ sink, protocol? }) andupgradeIntentOf(request)reads it back, under theUPGRADE_INTENTsymbol. This is how the kernel tells the HTTP adapter “the pipeline ran, nothing short-circuited, perform the handshake”. TheIRequestis the channel rather thanIRequestContext.statebecause the adapter holds the former and never sees the context — the kernel builds and discards it internally, so nothing written toctx.statecould reach the adapter.UPGRADE_INTENTusesSymbol.for, so two copies ofcommonin one process agree. Added in Milestone 70a. -
isWebSocketUpgradeRequest(headers)is the shared RFC 6455 §4.2.1 predicate:Upgrademust equalwebsocketcase-insensitively andConnectionmust contain theupgradetoken, matched after splitting on commas — a substring test would also claimConnection: no-upgrade. It lives incommonbecause 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. -
HandlerResultis an opaque brand only the kernel constructs; handlers obtain it fromIResponseterminal methods (json,text,send,redirect,stream). -
IResponsehas two header setters with distinct semantics:header(name, value)replaces any existing value forname(Headers.set), whileappendHeader(name, value)adds a value without removing existing ones (Headers.append).appendHeaderis the correct way to emit multiple headers of the same name — most notably severalSet-Cookieheaders (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 asheader(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’sreply.headers(obj), Express’sres.set(obj)and Koa’sctx.set(obj)are the precedent for the batch call; the template cache is Setu’s. Breaking forIResponseimplementors, including test fakes typed asIResponse: add the member as a loop overheader(), or use@setu-ts/testing’screateTestContext. -
IResponse.html(body: string): HandlerResult— sends an HTML response withcontent-type: text/html; charset=utf-8. The charset is not optional: a baretext/htmllets a browser sniff the encoding. Added because every CSRF-protected form example was setting the header by hand. Breaking for out-of-repoIResponseimplementors (both in-repo implementors — the kernel’sResponseBuilderandtesting’sMockResponse— implement it). -
IResponse.stream(body: ReadableStream<Uint8Array>): HandlerResult— sends a streaming response body. The runtime maps this tonew 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 onstreaming: whenfalse,bodyisUint8Array | string | null(buffered); whentrue,bodyisReadableStream<Uint8Array>(live stream). This allows middleware to safely inspect the response without draining a live stream — middleware that reads the body must checkstreamingfirst. 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 webResponse, and a stream body iscancel()led so its source is released. TheResponseconstructor rejects a body at these statuses, and the mapping runs in the HTTP adapter after the pipeline, so passing one through raised aTypeErrorno middleware orerrorHandlercould 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 readCLIENT_IP_STATE_KEYfor 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 nativeRequest.signal; optional because injected / test requests may not carry one. Added in Milestone 42. -
IRequest.user,IRequest.tenantandIRequest.localeeach allow one implicit assignment per request; a later assignment throws.replacePrincipal,replaceTenantandreplaceLocaleare 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, aninject()request and acreateTestContextrequest — carries this guard on its class prototype (installRequestIdentityGuard, 0.11.0), so on those the three fields are inherited accessors:Object.keys(request)andJSON.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 thetenantprecedent. Three things to keep in mind: a reader running BELOW priority 45 seesundefined(the priority table orders it, exactly as fortenantat 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(). Itsregister,registerFactory, andunregistermethods then throw; request-scoped child registries remain mutable. Startup-timeoverride: truemutations log atinfo, and successful unregisters log atwarnthrough the logger capability. -
IRequestContext.signal: AbortSignal— required abort signal (always present). Populated bycreateRequestContextfrom the nativeRequest.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 theISseService. The service provides real-time, one-way server-to-client messaging over an SSE stream built onIResponse.stream(). Added in Milestone 43. -
CAPABILITIES.SSR('ssr') — the capability token under which the React Router plugin registers theISsrService. The service provides server-side rendering by delegating to React Router’s request handler and writing back the result viaIResponse. Added in Milestone 44. -
CAPABILITIES.REALTIME_BACKPLANE('realtime-backplane') — the capability token under whichRealtimeBackplanePluginregisters anIRealtimeBackplane. 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 theIWebSocketService. The service provides full-duplex, bidirectional real-time messaging: exact-path routes with lifecycle handlers, named broadcast rooms, and an application-level heartbeat. Distinct fromSSE, which is one-way. Added in Milestone 46. -
CAPABILITIES.SESSION('session') — the capability token under whichSessionPluginregisters theISessionService. Provides cookie-backed sessions for server-rendered applications: an encrypted self-contained cookie by default, or an opaque id over anISessionStore. Distinct fromAUTHENTICATION, 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 whichGrpcPluginregisters theIGrpcService. The service provides gRPC/Connect co-serving on the same port as ordinary Hono routes through the kernel terminal handler. The deprecated optionalIHttpAdapter.setRpcHandler?seam is no longer consulted. Added in Milestone 49. -
CAPABILITIES.CLOUDFLARE('cloudflare') — the capability token under whichCloudflarePluginregistersICloudflareBindings: typed access to a Worker’s KV, R2, D1, Queues, service and Durable Object bindings, its string variables, andwaitUntil. Added in Milestone 52. -
CAPABILITIES.GRAPHQL('graphql') — the capability token under whichGraphqlPluginregisters theIGraphqlService. 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_ADAPTERand 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 viaservices.getAll(); the corresponding first-party plugins aggregate and expose them.HTTP_ADAPTERis single-provider — the runtime plugin registers itsIHttpAdapterthere. -
METADATA_STORE('metadata-store') is the single-provider capability backingIPluginContext.metadata; the DecoratorPlugin registers itsIMetadataStorethere. It is distinct fromOPENAPIso an OpenAPI plugin registering underOPENAPIdoes not populatectx.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)
| Export | Kind | Purpose |
|---|---|---|
createApplication | function | Creates a kernel application with optional pre-registered plugins |
Types
| Export | Kind | Purpose |
|---|---|---|
ApplicationOptions | type | Options for createApplication ({ plugins?: IPlugin[]; diagnostics?: KernelDiagnosticsOptions }) |
IKernelApplication | type | IApplication extended with inject() for serverless request injection, and unregister(name) |
InjectRequest | type | Synthetic 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) |
InjectResponse | type | Response shape returned by inject() ({ statusCode, headers, body, json<T>() }) |
KernelDiagnosticsOptions | type | Kernel-diagnostics activation passed as ApplicationOptions.diagnostics ({ labels?: KernelDiagnosticsLabelOptions }); its PRESENCE is the activation, and an omitted option allocates nothing |
KernelDiagnosticsLabelOptions | type | The 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): booleanremoves every pending plugin carrying the name beforestart(), returningtruewhen one or more matched. Two pending plugins may share a name — the kernel refuses duplicates atstart(), not atregister()— so removing only the first would leave one running while the caller was told the name was gone. Plugins do not run untilstart(), so removing one means itsregister()— and any eager side effect inside it, such as a database adapter’sconnect()— 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, mirroringregister(), becausestart()has already read the plugin list and a silentfalsewould report success for an operation that can have had no effect. It throws once any plugin has REGISTERED, not merely oncestart()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 leaveunregisteravailable. Removing a plugin another declares independenciesis 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-pluginprovidesdatabase), so a failure after awithoutreads as a missing capability rather than as the exclusion that caused it.@setu-ts/testing’screateTestApp({ 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 boundaryunregisteruses. Correct the plugin list and callstart()again. Once any plugin’sregister()has run, a secondstart()throwsCannot 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’sonClosehooks have already run. Restart after a successfulstart()+stop()is not supported either —start()throwsApplication has already been started. hasPlugin(name: string): booleanreports 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:unregistermutates 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.createTestAppchecks everywithoutentry 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 beforestart()— startup does not clear it, so afterwards the member still reportstruefor a plugin that has already run.- Listening requires
CAPABILITIES.HTTP_ADAPTER(registered by the runtime plugin) and aportoption. 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 whilestop()is draining →503) are written through the error responder seam (respondWithErrorin@setu-ts/common). WitherrorHandlerregistered they answer in the application’s configured format; with noerrorHandlerregistered they fall back to the no-handler shape{ error, detail? }— which is not the same as thedefaultformatter’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-error500additionally logs the error throughCAPABILITIES.LOGGERwhen a logger is registered. inject()body semantics.InjectResponse.bodyis text: a byte body written withresponse.send(bytes)is UTF-8 decoded rather than reported asnull, andjson()parses it. A streaming response cannot be presented as text without draining the live stream, soinject()throws and points atapp.fetch()with a webRequestinstead.app.fetch()rejects rather than throwing synchronously when nohttp-adaptercapability is registered, so the Workersexport default { fetch: app.fetch }entry point sees a failed promise instead of an unhandled exception.app.fetchis bound to its application, so it works passed around as a bare function. That entry point needs it: Cloudflare callsexported.fetch(request, …), which madethisthe 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(), andctx.decorators.register()funnel contributions into multi-provider services under the Step-1 tokens; consumers retrieve them withservices.getAll(). ctx.runtimeis a lazy getter that resolvesCAPABILITIES.RUNTIMEon 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 callingnext()short-circuits, and the handler does not run. As defense-in-depth, a stage that responds AND callsnext()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
IHttpAdaptercontract (setHandler/fetch/listen/close) and added the Cloudflare Workers adapter.
M87 widened two of those members to accept and return sync-or-async —
setHandlernow takes(request: IRequest) => IResponse | Promise<IResponse>andfetchreturnsResponse | 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 narrowerPromise-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, sinceawaitworks on both.The widening exists because a request the kernel can answer without awaiting must not be wrapped back into a promise:
@hono/node-serverserves 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)
| Export | Kind | Purpose |
|---|---|---|
RuntimePlugin | function | Creates the runtime plugin (registers CAPABILITIES.RUNTIME) |
detectRuntime | function | Detects the current runtime platform ('node' | 'deno' | 'bun' | 'cloudflare-workers') |
createRuntimeServices | function | Creates IRuntimeServices for the detected platform, without an application |
buildNodeHost | function | Builds a NodeHost from injected NodeModules (defaults to real node: built-ins) |
buildBunHost | function | Builds a BunHost from injected BunModules (defaults to node: built-ins) |
createDenoRuntimeServices | function | Creates IRuntimeServices backed by Deno APIs |
createNodeRuntimeServices | function | Creates IRuntimeServices backed by Node.js APIs |
createBunRuntimeServices | function | Creates IRuntimeServices backed by Bun APIs |
createCloudflareRuntimeServices | function | Creates IRuntimeServices backed by Cloudflare Workers APIs (edge-compatible) |
DenoHttpAdapter | class | Deno HTTP server adapter implementing IHttpAdapter |
NodeHttpAdapter | class | Node.js HTTP server adapter implementing IHttpAdapter |
BunHttpAdapter | class | Bun HTTP server adapter implementing IHttpAdapter |
CloudflareWorkersHttpAdapter | class | Cloudflare Workers HTTP adapter implementing IHttpAdapter (fetch-only, no listen) |
createWebWorkerHost | function | Creates an IWorkerHost over the web Worker API (Deno/Bun); throws if Worker absent |
createNodeWorkerHost | function | Creates an IWorkerHost over node:worker_threads |
defineWorkerTask | function | @setu-ts/runtime/worker subpath. Registers a worker module’s task handler |
isDenoHttpServerHandle | function | Type guard for DenoHttpServerHandle |
isNodeHttpServerHandle | function | Type guard for NodeHttpServerHandle |
isBunHttpServerHandle | function | Type guard for BunHttpServerHandle |
WebSocket upgrade support (Milestone 46) — shared primitives:
| Export | Kind | Purpose |
|---|---|---|
isWebSocketUpgradeRequest | function | Whether request headers describe an RFC 6455 upgrade (Upgrade + Connection) |
createWebSocketTransport | function | Wraps a web-API socket as an IWebSocketTransport |
normalizeFrame | function | Normalizes an inbound frame payload to string | Uint8Array |
toReadyState | function | Maps the web WebSocket numeric readyState to WebSocketReadyState |
toTransportError | function | Coerces an error-event payload into a real Error |
Per-runtime upgrade seams:
| Export | Kind | Purpose |
|---|---|---|
bindDenoSocketToSink | function | Binds a Deno.upgradeWebSocket socket’s on* handlers to a WebSocketEventSink |
createBunWebSocketHandlers | function | Builds the serve-time handler object for Bun.serve’s websocket option |
bindCloudflareSocketToSink | function | Accepts a Workers WebSocketPair server half and binds it to a sink |
createDefaultCloudflareWebSocketHost | function | Builds the Workers host from the real WebSocketPair global |
buildUpgradeResponseInit | function | Builds the Workers 101 ResponseInit carrying the client socket |
NodeUpgradeCoordinator | class | Owns the ws server for one Node adapter and performs the raw handshake |
adaptWsModule | function | Narrows an imported ws module to WsModuleLike |
loadWsModule | function | Lazily imports ws; throws with the install command if absent |
bindWsSocketToSink | function | Binds a ws socket’s events to a WebSocketEventSink |
createWsTransport | function | Wraps a ws socket as an IWebSocketTransport |
toWsReadyState | function | Maps a ws numeric ready state to WebSocketReadyState |
createUpgradeRequest | function | Rebuilds a web Request from Node’s upgrade event arguments |
asUpgradeEmitter | function | Probes a Node server handle for the raw upgrade event |
rejectRawUpgrade | function | Writes an HTTP status line to a raw socket and destroys it |
Types
| Export | Kind | Purpose |
|---|---|---|
RuntimeOptions | type | Options for RuntimePlugin (platform, env) |
CreateRuntimeServicesOptions | type | Options for createRuntimeServices (platform, adapters, env) |
RuntimeAdapterFactories | type | Platform → runtime adapter factory map |
GlobalScope | type | Injectable global scope shape for detectRuntime |
DenoHost | type | Host interface for the Deno adapter (extension point) |
DenoFileInfo | type | File info returned by DenoHost.stat() |
DenoDirEntry | type | Directory entry yielded by DenoHost.readDir() (an AsyncIterable) |
NodeHost | type | Host interface for the Node adapter (extension point) |
NodeFsInfo | type | File info returned by NodeHost.stat() |
NodeModules | type | Injectable Node built-ins for buildNodeHost (testing seam) |
BunHost | type | Host interface for the Bun adapter (extension point) |
BunFileInfo | type | File info returned by BunHost.stat() |
BunModules | type | Injectable built-ins for buildBunHost (testing seam) |
DenoHttpServerHandle | type | Internal server handle for DenoHttpAdapter |
NodeHttpServerHandle | type | Internal server handle for NodeHttpAdapter |
BunHttpServerHandle | type | Internal server handle for BunHttpAdapter |
CloudflareWorkersHttpServerHandle | type | Internal server handle for CloudflareWorkersHttpAdapter |
DenoServeHost | type | Injectable host interface for DenoHttpAdapter (extension point) |
NodeServeHost | type | Injectable host interface for NodeHttpAdapter (extension point) |
BunServeHost | type | Injectable host interface for BunHttpAdapter (extension point) |
BunServer | type | Bun server handle returned by Bun.serve |
HttpAdapterFactories | type | Platform→adapter factory map for RuntimePlugin |
WebWorkerGlobals | type | Injectable seam for createWebWorkerHost (Worker + concurrency) |
WebWorkerLike | type | Minimal web Worker shape the host consumes |
NodeWorkerModules | type | Injectable seam for createNodeWorkerHost (Worker + availableParallelism) |
NodeWorkerLike | type | Minimal worker_threads.Worker shape the host consumes |
WebSocketLike | type | Minimal web WebSocket shape the adapters drive |
DenoWebSocketLike | type | A web socket exposing the on* handler properties (Deno) |
DenoWebSocketUpgrade | type | Result shape of Deno.upgradeWebSocket ({ socket, response }) |
BunServerWebSocket | type | Minimal Bun ServerWebSocket shape, carrying data |
BunSocketData | type | Per-socket data Bun carries from upgrade() to its handlers ({ sink }) |
BunWebSocketHandlers | type | The serve-time handler object for Bun.serve’s websocket option |
CloudflareServerSocket | type | Workers server-half socket (accept() + addEventListener) |
CloudflareWebSocketPair | type | A created Workers pair ({ client, server }) |
CloudflareWebSocketHost | type | Injectable seam for the Workers upgrader (extension point) |
CloudflareUpgradeResponseInit | type | The Workers 101 response init, including the webSocket member |
WsModuleLike | type | Structural facade over the ws module ({ WebSocketServer }) |
WsServerLike | type | Structural facade over a noServer ws WebSocketServer |
WsSocketLike | type | Structural facade over a ws socket |
NodeIncomingMessage | type | Minimal node:http.IncomingMessage shape for building an upgrade Request |
RawUpgradeSocket | type | Minimal raw duplex socket shape a refusal writes to |
UpgradeEmitter | type | A 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 defaultBunHostfromnode:fs(sync),node:os, andnode:process— which Bun implements — NOT from members of theBunglobal: Bun’s file API isBun.file()/Bun.write(), and it has noreadFile/stat/readdir/mkdir/rm/realPath/hostname/exit. OnlyBun.versionis read from the global. On the Deno side,DenoHost.readDir()is named and shaped after the realDeno.readDir— anAsyncIterable, consumed withfor await. Both defaults are exercised against their real APIs by the test suite, not only through injected fakes. -
IRuntimeServices.envis 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 ofprocess.envon Node and Bun. Nothing in the framework mutates the environment at runtime. -
M23 replaced M39’s HTTP server adapters. The
IHttpAdaptercontract now exposes the web-standardfetchentry:setHandlerinstalls the framework handler,fetchis the universal entry point callable withoutlisten(Cloudflare Workers),listenbinds a real TCP socket, andclosetears it down. Adapters (NodeHttpAdapter,DenoHttpAdapter,BunHttpAdapter,CloudflareWorkersHttpAdapter) are registered underCAPABILITIES.HTTP_ADAPTERviaRuntimePlugin. -
M46 added a fifth, OPTIONAL member:
setUpgradeRouter?(router). It installs aWebSocketUpgradeRouterconsulted for every inbound WebSocket upgrade before the request is mapped to anIRequestand enters the middleware pipeline. That ordering is a correctness requirement, not an optimization: the shared mapping pre-reads the body viaarrayBuffer(), andDeno.upgradeWebSocketfails once the request body has been disturbed. The router answers accept (with theWebSocketEventSinkthe adapter binds its native socket into), reject (with a status), ornullto 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 (seeWebSocketPlugin, which reportsavailable: falseand failsroute()with a typed error). All four first-party adapters implement it. -
M49 added a sixth, OPTIONAL member:
setRpcHandler?(handler). It installs anRpcFetchHandlerconsulted for every inbound request after the WebSocket upgrade short-circuit (where one exists) and before the request is mapped to anIRequest. The ordering is again a correctness requirement: a gRPC exchange needs the raw streaming body and emits trailers, neither of whichIRequest/IResponsecan express, and the shared mapping pre-reads the body. The handler answers aResponse(handled as RPC) ornullto fall through, so installing one never changes the behavior of non-RPC traffic. The handler is consulted exactly once per request, and one returningnullMUST leave the body unread — the adapter maps that sameRequestafterwards, 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 readrequest.clone(). Because the member is optional, adapters written before this seam remain valid implementations; consumers must degrade gracefully when it is absent (seeGrpcPlugin, which reportsavailable: falseand throwsGrpcUnavailableErrorfromhandleRequest). All four first-party adapters implement it. -
Socket peer (M113n).
IRequest.ip?: stringis the socket peer on Node, Deno and Bun; the client behind a proxy isCLIENT_IP_STATE_KEY. IPv4-mapped peers are normalized to dotted IPv4. Workers, plainadapter.fetch()and the local diagnostics listener supply no peer.NodeServeHost.serve’s fetch environment addsincoming?: { socket?: { remoteAddress?: string | undefined } }(matching Node’s native member).DenoServeHost.serve’s callback accepts optionalinfo?: { remoteAddr?: { hostname?: string } }.BunServeradds optionalrequestIP?(request: Request): { address: string } | null. Older injected hosts that supply none of these still compile and produce no peer. -
The
RuntimePluginis mandatory in every application. It registers atPLUGIN_PRIORITY.HIGHESTso its services are available to all other plugins during registration. -
Each adapter factory accepts an injectable
*Hostinterface (the documented extension point for custom runtimes). The default host binds to the real runtime global via a single sanctionedascast; no other casts are used. -
detectRuntime()accepts an injectableglobalsparameter (defaultglobalThis) 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 whatcreateFullStackAppFromConfigdoes.RuntimePlugin.registercalls this same function, so detection, the platform → adapter lookup, and theNo 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 —
envis a snapshot taken at construction, not a live view, so a variable set between two constructions is visible only to the later instance. -
envon Cloudflare Workers. There is no ambient environment on the edge: bindings and variables arrive as theenvargument of thefetchhandler. BothRuntimePluginandcreateRuntimeServicestherefore take anenvoption, and without itruntime.envis empty on Workers, soConfigPluginand the secretsEnvProviderread 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 asReadonly<Record<string, string | undefined>>; object bindings are filtered out by the puresplitWorkerEnvincommonand reached throughCAPABILITIES.CLOUDFLAREinstead. Passing them through unfiltered would handConfigPlugina[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)
| Export | Kind | Purpose |
|---|---|---|
HttpError | class | The single HTTP error type (extends Error, carries statusCode) |
badRequest | function | Factory → 400 HttpError |
unauthorized | function | Factory → 401 HttpError |
forbidden | function | Factory → 403 HttpError |
notFound | function | Factory → 404 HttpError |
conflict | function | Factory → 409 HttpError |
validationError | function | Factory → 422 HttpError wrapping ValidationError[] |
tooManyRequests | function | Factory → 429 HttpError |
internalServerError | function | Factory → 500 HttpError (accepts cause for error chaining) |
notImplemented | function | Factory → 501 HttpError |
serviceUnavailable | function | Factory → 503 HttpError |
statusTitle | function | Resolves a status code to a human-readable title |
STATUS_TITLES | const | Readonly record of well-known status-code → title mappings |
errorHandler | function | Creates the global error-handler MiddlewareFunction |
defaultFormatter | const | Framework-standard error body formatter ({ statusCode, message }) |
rfc9457Formatter | const | RFC 9457 Problem Details formatter |
rfc7807Formatter | const | Deprecated. RFC 7807 Problem Details formatter |
selectFormatter | function | Resolves an ErrorFormat or custom function to a formatter |
ERROR_TYPE_BASE | const | Base URI for framework problem types (https://setu-ts.dev/errors) |
Types
| Export | Kind | Purpose |
|---|---|---|
ValidationError | type | A single validation failure ({ field, message, code? }) |
HttpErrorInit | type | Options object for HttpError.from() |
ErrorHandlerOptions | type | Options for errorHandler() ({ format?, includeStackTrace?, logErrors?, maskInternalErrors?, respond? }); respond(error: HttpError, ctx: IRequestContext) returns or resolves a HandlerResult or undefined fallback. |
ErrorHandlerFormatter | type | (error: Error, ctx?) => Record<string, unknown> |
ErrorFormat | type | 'default' | 'rfc9457' | 'rfc7807' (this package’s union, no 'nestjs') |
DefaultErrorBody | type | Framework-standard error body shape |
ProblemDetails | type | RFC 9457 Problem Details body shape |
Contract notes:
-
Composition over inheritance: there is exactly one
HttpErrorclass. Every factory function returns anHttpErrorwith a pre-setstatusCode— noBadRequestError extends HttpErrorhierarchy. -
causechaining:internalServerError(message, cause)forwardscauseto the ES2022Errorcause chain. The error handler logs it when a logger is registered (the loggedcauseis serialized throughserializeError, so a nestedErrornever renders as{}). -
Error format is the framework’s error-body contract:
errorHandleris the single place an application configures how every error body is written. It publishes a request-scopedIErrorResponder(viarespondWithErrorin@setu-ts/common) beforenext(), 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 witherrorHandler({ format: 'rfc9457' })registered, every error those sites produce is RFC 9457.validation-pluginis the deliberate exception: it formats validation failures itself and takes its ownerrorFormatoption, so an application configures the two to agree — the CLI templates andrest-starterpairValidationPlugin({ errorFormat: 'rfc9457' })with the handler for exactly that reason. With noerrorHandlerregistered, 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 thedefaultformatter’s{ statusCode, message, details? }: it is the framework’s pre-formatter shape, written directly byrespondWithError, and it is what an application withouterrorHandlerkeeps receiving byte-for-byte. -
Application-owned caught-error response:
ErrorHandlerOptions.respondruns after a caught value has been normalized, status-hinted or masked, and resolved to a serveable status. It receives thatHttpErrorand the liveIRequestContext; returning or resolving aHandlerResultbuilt throughctx.responsebypasses framework formatting, so the application owns the returned status, headers, and body (for example, an HTML error page). Returning or resolvingundefinedis 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 toerrorHandler’s caught-error path: the M70f responder still formats unmatched-path404, malformed-request400, shutdown-drain503, and other responder-based terminals, including when no full request context exists. The status ownership is the part easiest to lose —renderViewtakes no status, so the natural spelling serves200for 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 carriestype,title,status,detail(andinstancefrom the request path) withContent-Type: application/problem+json. Themessagefield is absent in this mode (Problem Details usesdetail). The media type follows the RESOLVED formatter, so passing the exportedrfc9457Formatterfunction asformatproduces the same body and the same content type as the'rfc9457'alias. -
typeisabout:blankfor status-only problems. RFC 9457 §4.2 registersabout:blankfor a problem carrying “no semantics beyond the HTTP status code”, which is every error this package produces except the one fromvalidationError(). That one defines anerrorsextension member, so it is a distinct problem type identified byhttps://setu-ts.dev/errors/validation— the same URI@setu-ts/validation-pluginemits for it. Clients that need to distinguish errors should readstatus, 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 therfc7807Formatterexport are retained through a deprecation period (AI_GUIDELINES §9.2) and still emit the status-derivedtype(https://setu-ts.dev/errors/404) they always did — a deprecated symbol must not silently change behavior (§9.4). Both spellings carryapplication/problem+json. Removal is scheduled for v1.0.0. -
Logger is optional:
errorHandlerlogs viaILoggerresolved fromctx.services.get(CAPABILITIES.LOGGER)only when a logger is registered; otherwise logging is silently skipped. -
maskInternalErrors(defaulttrue): an unhandled error that is not anHttpErrorand resolves to status ≥ 500 is masked to a genericdetailbefore 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. AnHttpError(a message the developer chose for the caller) and every 4xx pass through unmasked, andmaskInternalErrors: falserestores the previous body verbatim. This is a behaviour change from the previous release — see the CHANGELOG. -
includeStackTraceis config-supplied: passconfig.get('NODE_ENV') === 'development'— never readprocess.envdirectly. The stack trace is secondary to the message, which is what carries the failing statement and its bound parameters;maskInternalErrors(defaulttrue) is what guards that message, notincludeStackTrace. Masking wins over this option: a masked error carries nostackin the body even when this istrue, because a stack begins with the very message that was masked. SetmaskInternalErrors: falseto see an internal error’s stack. -
Short-circuit: when
next()throws,errorHandlerproduces a response (HandlerResult) without re-invokingnext().
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)
| Export | Kind | Purpose |
|---|---|---|
DiPlugin | function | Plugin factory — registers IContainer under CAPABILITIES.DI_CONTAINER, closes it at the end of bootstrap |
ContainerBuilder | class | Fluent builder for configuring and creating a DiContainer |
createContainer | function | Convenience factory for a standalone IContainer |
DiContainer | class | The IContainer implementation; adds close(), which ends registration and validates the graph |
CircularDetector | class | Cycle detector used by the graph compiler’s walk (exported for testing and advanced use) |
ProviderRegistry | class | Token-keyed provider store with hierarchical lookups |
ContainerValidationError | class | Thrown when a compile or close() finds missing, cyclic or captive dependencies; carries findings |
ScopeRequiredError | class | Thrown when a scoped token, or a transient reaching one, is resolved on the root container |
ScopeDisposedError | class | Thrown by resolve on a disposed scope |
ContainerClosedError | class | Thrown by register on a closed root container |
RequestScopeUnavailableError | class | Thrown 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
| Export | Kind | Purpose |
|---|---|---|
DiPluginOptions | type | Options for DiPlugin() ({ defaultScope?, autoRegister? }) |
ContainerConfig | type | Configuration for DiContainer constructor |
ExternalResolver | type | Subset of IServiceRegistry for auto-registration fallback |
ProviderEntry | type | A provider paired with its resolved scope (internal building block) |
Contract notes:
- Optional: no plugin depends on the DI container. When
DiPluginis not registered,ctx.containerisundefinedand services resolve directly from theServiceRegistry. - Three provider forms:
ClassProvider(constructor injection viainject),FactoryProvider(a factory called with itsinjectdependencies),ValueProvider(pre-built value) — all defined in@setu-ts/common, with the token andinjectrules in “DI container contract (M125)”. - Three lifetimes:
singleton(one instance, shared with every scope),scoped(one instance per scope — a request’s scope comes fromforRequest(ctx)),transient(new instance every resolve, tracked by the scope that built it). Default issingleton. - Open, then closed: a container accepts registrations and resolves while open; a resolve
compiles that token’s subgraph on demand.
DiPlugincloses the container after the last bootstrap hook, the moment the service registry seals; aregisteron the closed root then throwsContainerClosedError. A standalone container stays open until its owner callsclose(). - 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 asingletonreaching ascopedprovider directly or throughtransients — throwing oneContainerValidationErrorthat lists every finding. Validation instantiates nothing. A factory withoutinjectis opaque;DiPluginlists such tokens in onedebugline. - Compiled resolution: no resolve runs the cycle detector or looks up a dependency by token; a
top-level
resolve(token)reads oneMap. Singletons are still created lazily, on first resolve. - Scopes:
createScope()andforRequest(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 (asingletonlifetime there is stored asscoped); 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 inctx.slotunder a key private to the container (a context withoutslotfalls back to a per-containerWeakMap), and registers its disposal withctx.defer. A request that never calls it pays oneundefinedcheck 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.disposeruns fromdispose()— for a request scope when the request finishes, for the root inDiPlugin’sonClosehook. 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 theAggregateErrorand the rest still run, soapp.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, throughDiPlugin’s logger atwarn(ContainerConfig.reportDisposeError), whenever it settles — also after the kernel’s 5000 ms cleanup bound has already reported its own timeout. - Auto-registration (
autoRegister, defaulttruesince 0.9.0): a token not in the container falls back to the kernel’sServiceRegistry, checked withhas()during validation and resolved lazily once, then cached as a singleton;falseconfines 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.*, orprocess.*— 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, noemitDecoratorMetadata, and nocompilerOptionsentry 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 existingcompilerOptionsblock 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.@Paramsis checked against the handler’s own signature, which the legacy parameter decorators never were.
Importing the package installs
Symbol.metadatawhen the runtime lacks it. Node 24 and Bun 1.4 do not define the well-known symbol, so TypeScript’s decorator emit hands every decoratorcontext.metadata === undefined. Evaluating@setu-ts/decorator-pluginsetsSymbol.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 onetsx, 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 namedDecoratorMetadataUnavailableErrorwhose message gives both remedies, rather than a bareTypeError; the package manifest declares nosideEffects: false, and the Node manifest generator refuses one that would let a bundler drop the installing module.
Values (decorator-plugin exports)
| Export | Kind | Purpose |
|---|---|---|
DecoratorPlugin | function | Plugin factory — registers MetadataStore, routes/services, explicitly activated @Module trees, and classes explicitly listed in ingress during onInit |
MetadataStore | class | IMetadataStore implementation (the concrete store) |
metadataStore | value | The process-wide singleton decorators write to and the plugin reads |
Controller | function | Class decorator — base path prefix |
Version | function | Class decorator — API version prefix |
Get/Post/Put/Patch/Delete/Head/Options | function | HTTP method decorators |
Processor | function | Method decorator — registers a bound handler with IQueue.process(name, handler, options) during onInit |
Cron/Every | function | Method decorators — schedule the bound handler by its method name through IScheduler.cron() / every() during onInit |
OnEvent | function | Method decorator — subscribes the bound handler to an IEventBus event type during onInit |
Subscribe | function | Method decorator — subscribes the bound handler to an IMessageBroker topic during onInit |
Gateway with OnOpen/OnMessage/OnClose | function | Class and method decorators — assemble one IWebSocketService.route() declaration from a gateway class during onInit |
CommandHandler/QueryHandler | function | Method decorators — register bound ICommandHandler / IQueryHandler objects during onInit |
UseIngressBehaviors/UsePipelineBehaviors | function | Method decorators — scope IIngressBehavior to one supported non-HTTP ingress handler, or IPipelineBehavior to one command/query handler; wrong decorator category is refused at startup |
Params | function | Method decorator binding handler arguments to sources, positionally; type-checked against the handler’s signature |
Service | function | Parameter 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 |
Injectable | function | Class decorator — marks a class for DI registration |
Inject | function | Class decorator declaring constructor injection tokens, one per argument in argument order |
Optional | function | Wraps a token inside @Inject(...); that argument receives undefined when the token has no provider |
Roles/Permissions | function | Class/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) |
Public | function | Method 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/UseFilters | function | Class/method pipeline decorators |
ValidateBody/ValidateQuery/ValidateParams | function | Method 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 |
Render | function | Method 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) |
HttpCode | function | Method 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) |
ResponseHeader | function | Method 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) |
Redirect | function | Method 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) |
Idempotent | function | Method 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 / RequirePolicy | function | Since 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 / ScopedPermissions | function | Since 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 |
RenderDecorator | type | The type Render returns: a standard method decorator whose value parameter is narrowed to (...args: never[]) => P | HandlerResult | Promise<P | HandlerResult> |
ApiTags | function | Class decorator — OpenAPI tags |
ApiOperation/ApiResponse | function | Method decorators — OpenAPI operation metadata |
Module | function | Class decorator grouping controllers, providers, and imported modules for DecoratorPlugin({ modules }) |
createDecorator | function | Custom class/method decorator factory |
Body/Query/Param/Header/Cookie/CurrentUser/Ctx | function | Built-in parameter SOURCES, declared inside @Params(...); Ctx yields the active IRequestContext without reserving the application custom type name context |
Custom | function | Declares a parameter source resolved by a resolver registered under the same name |
resolveParameters | function | Resolves an ordered argument array from parameter metadata |
resolveParameter | function | Resolves a single parameter value |
registerParameterResolver | function | Registers a resolver for a custom parameter type |
getParameterResolver | function | Looks up a custom parameter resolver |
clearParameterResolvers | function | Clears the custom resolver registry (tests) |
parseCookies | function | Parses a Cookie header into a name→value record |
discoverControllers | function | Auto-discovers decorated classes from a directory |
Types
| Export | Kind | Purpose |
|---|---|---|
DecoratorPluginOptions | type | Options for DecoratorPlugin() (autoDiscover?, controllersPath?, controllers?, services?, ingress?, modules?, enforceSchemas?, enforceRoles?, policies?) |
AbilityOptions / PolicyClassAbility / PolicyClassTarget | type | Since 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) |
InjectableOptions | type | Options for @Injectable() (scope?, token?) |
ModuleOptions | type | Options for @Module() (controllers?, providers?, imports?; no exports) |
ApiOperationConfig | type | Config for @ApiOperation() (operationId?, summary?, description?); all three fields reach RouteSchema; OpenAPI uses a declared id verbatim and emits the description |
ApiResponseConfig | type | Config for @ApiResponse() (status, description?, schema?) |
HttpMethodDecorator | type | (path?: string) => SetuMethodDecorator |
ParamSource | type | One entry in a @Params(...) declaration; carries the resolved value type |
SourceValues | type | Maps a source tuple onto the handler parameter tuple it binds |
InjectToken | type | string | OptionalToken — one entry in an @Inject(...) list |
OptionalToken | type | A token wrapped by Optional(...), marking that argument absent-tolerant |
SetuClassDecorator | type | A standard class decorator that records metadata and leaves the class unchanged |
SetuMethodDecorator | type | A standard method decorator that records metadata and leaves the method unchanged |
SetuClassOrMethodDecorator | type | A standard decorator valid in either position, discriminating on context.kind |
MiddlewareLike | type | MiddlewareFunction | (new () => IMiddleware) — accepted by pipeline decorators |
CustomParameterResolver | type | (ctx, metadata?) => unknown | Promise<unknown> |
ParameterMetadata | type | Parameter metadata captured by a @Params(...) source |
ParameterType | type | 'body' | 'query' | 'param' | 'header' | 'cookie' | 'custom' |
DiscoveryOptions | type | Config for discoverControllers() (path, extensions?, exclude?) |
DiscoveryResult | type | Result of discovery (controllers, services, errors) |
ModuleImporter | type | (specifier: string) => Promise<unknown> — injectable module loader |
Contract notes:
-
Inert without the plugin: decorators write to the
metadataStoresingleton at class-definition time regardless of whether the plugin is registered. OnlyDecoratorPlugin.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 })flattensimportsdepth-first, registers imported providers before the importing module’s controllers, and deduplicates classes by identity. A class passed as a module without@Modulemetadata logs a warning and contributes nothing. There is noexportsoption because application service visibility is not module-scoped. -
Validation schemas are enforced, not just described (
enforceSchemas, defaulttrue): for each ofschema.body/query/paramspresent on a route,registerControllerresolvesCAPABILITIES.VALIDATIONand 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 told400with 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 andValidationPlugin; nothing throws.enforceSchemas: falsekeeps schemas description-only and silences the warning. -
@Roles/@Permissionsare enforced, not just described (enforceRoles, defaulttrue):registerControllerappends 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 resolvesCAPABILITIES.AUTHORIZATIONPER REQUEST, so a provider registered afterregister()is honoured; with none, the route answers501 Not Implemented / "Authorization is not configured"andregister()warns once per affected route naming the controller, handler, restriction and both remedies (register a provider, orenforceRoles: false).enforceRoles: falsekeeps the metadata description-only and silences the warning — the pre-M89a behaviour. The appended middleware is M57-branded, soderiveSecuritydocuments decorated routes. -
@RequirePolicyis validated atregister()and always enforced (M110a). Each@RequirePolicyappends one middleware AFTER@Roles/@Permissionsand BEFORE interceptors, ordinary middleware, filters and validation — so a target extractor reading the body sees the UNVALIDATED body.register()throws, naming the route, when noCAPABILITIES.AUTHORIZATION_POLICIESprovider 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’sanonymousflag (a different policy sharing the registered one’s name — the same refusalrequirePolicymeets at startup). An anonymous request to a non-anonymous ability is refused before the target extractor runs.enforceRolesdoes not govern it. Listingpolicieswith 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@PublicOpenAPI marker is omitted when any@RequirePolicyrequires a principal. The two packages may not import each other, so@RequirePolicyandrequirePolicyare two thin middlewares over one service, pinned together by a parity test. -
@ScopedRoles/@ScopedPermissionsare validated atregister()and always enforced (M110b).@ScopedRoles(roles, scope?)(any-of) and@ScopedPermissions(permissions, scope?)(all-of) are the class form of AuthPlugin’srequireScopedRole/requireScopedPermission;scopeis aScopeSourcedefaulting toscopeFromTenant(). Class level sets a default, method level overrides it, per decorator. Each appends one middleware — roles, then permissions — AFTER@Roles/@Permissionsand@RequirePolicy. An empty list or a non-string name throws at decoration time;register()throws, naming the route, when noCAPABILITIES.AUTHORIZATION_POLICIESprovider is registered or a name is outside the scoped RBAC catalogue (soAuthPlugin({ rbac, scopedRbac })is required, and a per-scope custom role cannot be named). Per request:501with no policy service,401for an anonymous request before the scope source runs,403on a deny; an unresolved scope denies. The middleware is branded authenticated, and the route’s@PublicOpenAPI marker is omitted. Refusal bodies are byte-identical with the guards’, pinned by a parity test undererrorHandler({ format: 'rfc9457' }). -
Body()/Query()/Param()read the VALIDATED value when one exists. Each checksctx.stateundervalidatedStateKey(target)first — presence-tested withhas, so a validatednullor0is honoured — and falls back to the raw source when absent. A Zodtransformordefaulttherefore 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()andCookie()deliberately read their raw sources: headers resolve case-insensitively throughheaders.get(name), which the validated record would break, and no schema key exists for cookies. An absent header or cookie resolves toundefined, matching theirstring | undefinedtype. -
No reflection: metadata is stored in plain
Maps keyed by class reference, not viaReflect.getMetadata(). Noreflect-metadatadependency. -
Decorator composition: cross-cutting decorators (
@Params,@ValidateBody,@Roles, …) run before the HTTP-verb decorator; the store accumulates per-method and derives oneRouteMetadataper (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/@Permissionsoverride class-level;@Publicsets theisPublicflag (OpenAPIsecurity: []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
HandlerResultfromctx.response.*. -
Discovery:
discoverControllerswalks viaIRuntimeServices.fs(absent on edge platforms → empty result with a warning) and loads modules withawait import()(norequire/eval). Snapshot-diff against the store attributes newly-decorated classes to each file. Discovery failures never crash the application. -
Ctx()response control:Ctxresolves the liveIRequestContext, so a decorated handler can configurectx.response(status, headers, or a stream) and return itsHandlerResult; it is a built-in custom parameter type and needs no resolver registration. It is recognised by a marker registered withSymbol.for, so it keeps working if two copies of the package share a process, and an application’s ownCustom('context')still reaches its own resolver. -
Controllers and the DI container: controllers are built once, during
register(), and are registered in the container assingletonwhatever itsdefaultScope(M125). Ascopedservice reaches a handler through@Params(Service(token)); an@Injectableclass is also registered under the class itself, soresolve(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 incontrollersthat carries no@Controllermetadata 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 asundefined; the warning names the controller, handler, and parameter index. The parameter check reflects the resolvers registered when the plugin registers, so callregisterParameterResolverbeforeapp.start(). -
Custom decorators:
createDecoratorrecords class/method metadata replayed againstDecoratorHandlers registered viactx.decorators.register()(collected underCAPABILITIES.DECORATOR_HANDLER).Custom(name, metadata?)declares a parameter source resolved byresolveParametersviaregisterParameterResolver; thecontextandcurrent-userbuilt-ins resolve directly toctxandctx.request.user, respectively. -
@Injectis 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 receiveundefinedwhen the token has no provider.Optionalmeans 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 readsdesign:paramtypes. A list shorter than the constructor leaves the trailing argumentsundefined— a positional list cannot have gaps, so the legacy “parameter N has no@Injecttoken” and “declares both@Injectforms” 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@Controllercarries no@Injectable, so a constructor-injected controller in aDiPluginapplication 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 sameemitDecoratorMetadatareason 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.Optionalcannot 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 atregister(), since it would otherwise passundefinedfor 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
@Optionalregisters as auseClassprovider whoseinjectentries 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, orfs— all file/time operations go throughIRuntimeServices.
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
| Export | File | Description |
|---|---|---|
createTestApp | src/test-app.ts | Test application factory |
TestAppOptions | src/test-app.ts | Factory options (union of the two arms below) |
TestAppFromPlugins | src/test-app.ts | Hand-assembled arm |
TestAppFromApp | src/test-app.ts | Composition-root arm |
overrideCapability | src/override-capability.ts | Capability replacement plugin builder |
overrideProvider | src/override-provider.ts | DI container provider replacement builder |
createMockPlugin | src/mock-plugin.ts | Mock plugin builder |
MockPluginOptions | src/mock-plugin.ts | Builder options |
collectStream | src/inject.ts | Collect streaming response body |
inject | src/inject.ts | Inject HTTP requests into test app |
StreamingBody | src/inject.ts | Stream collector result shape |
createTestContext | src/mock-context.ts | Create a mock IRequestContext |
TestContextOptions | src/mock-context.ts | Context builder options |
MockResponse | src/mock-context.ts | Fake IResponse double |
MockServiceRegistry | src/mock-registry.ts | Fake IServiceRegistry double |
FixtureManager | src/fixtures/fixture-manager.ts | Assemble 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):
| Option | Type | Default | Behavior |
|---|---|---|---|
plugins | IPlugin[] | [] | 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. |
autoStart | boolean | true | true 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:
| Option | Type | Default | Behavior |
|---|---|---|---|
app | IKernelApplication | required | An already-constructed, not yet started application — typically a scaffolded project’s createApp() from setu.config.ts, or a starter factory’s return value. |
without | readonly 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. |
overrides | readonly 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. |
autoStart | boolean | true | As 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.
overrideCapabilityreplaces;createMockPluginprovides.createMockPlugindeclares the token inprovides, which satisfies a dependent plugin’sdependenciescheck 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):
| Option | Type | Default | Behavior |
|---|---|---|---|
name | string | required | Plugin name, and the capability token when provides is omitted. |
service | object | required | The mock service registered under the token. |
provides | string | name | Overrides the token when the plugin name and capability token differ. |
priority | number | omitted | Registration priority passed to the kernel resolver. |
register | (ctx: IPluginContext) => void | Promise<void> | omitted | Extra registration (middleware, routes, hooks) run after the service is registered. |
TestContextOptions — createTestContext(options?):
| Option | Type | Default | Behavior |
|---|---|---|---|
request | Partial<IRequest> | GET http://localhost/ | Overrides on the mock request (method, url, path, headers, ip, user, tenant, signal). |
body | unknown | none | Backs json(), text() and bytes(). A non-string body is JSON-stringified once, so all three readers agree. |
runtime | IRuntimeServices | internal monotonic fake | Supplies ctx.id (uuid()) and ctx.startTime (hrtime()). |
startTime | number | runtime.hrtime() (0) | Highest-precedence monotonic origin — never pass Date.now(). |
services | IServiceRegistry | new MockServiceRegistry() | Any implementation, including a real kernel registry from a started app. |
response | IResponse | new MockResponse() | The response builder on ctx.response. |
params | Record<string, string> | {} | Path parameters. |
query | Record<string, string> | parsed from the request URL | Query parameters. |
state | Map<string, unknown> | new Map() | Request-scoped state. |
signal | AbortSignal | live, never-aborting | ctx.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;
createTestAppreturns a started kernel application that can be exercised viainject()orfetch(). pluginsmust include a runtime provider. The package depends only oncommonandkernel, so it cannot importRuntimePluginto supply one for you, and the kernel treats theruntimecapability as mandatory atstart().await createTestApp()with no plugins therefore rejects withNo plugin provides the mandatory 'runtime' capability. PassRuntimePlugin(), or for a dependency-free unit testcreateMockPlugin({ name: 'runtime', service: fakeRuntime, provides: CAPABILITIES.RUNTIME }).- Adding global middleware requires
autoStart: false.start()compiles the pipeline, after whichapp.middleware.add(...)throwsCannot add middleware after the pipeline has been compiled.Routes are unaffected —app.router.get(...)works on a started app. - A mock providing only
runtimecannot servefetch(). The realRuntimePluginalso provideshttp-adapter; without itapp.fetch()throwsNo HTTP adapter registered.inject()needs onlyruntime, so unit tests useinject()andfetch()assertions belong to integration/e2e tests on the realRuntimePlugin(). - The free-function
inject()accepts a webRequest, and reading its body consumes it. The sameRequestcannot be injected twice, nor injected and then passed toapp.fetch()— the second call throws and names the cause instead of silently sending no body. Build a separateRequestper call. inject()returnsbody: string | null, where a byte body fromresponse.send(bytes)is UTF-8 decoded andnullmeans the response genuinely had no body. A streaming response cannot be rendered as a string without draining the live stream, soinject()throws for one — read those throughapp.fetch()pluscollectStreaminstead.createTestContextuses a built-in monotonic fake runtime (hrtime: () => 0) by default — inject your ownIRuntimeServiceswhen you need controllable timing. Its timers are inert no-ops, so a fixture can never leak a callback past the test that created it.MockServiceRegistryandMockResponsereproduce their kernel counterparts’ observable semantics (factory caching, single-vs-multi registration andgetAllmerge order, override policy, the verbatim not-registered message,content-typedefaults, and thesnapshot()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;
};
| Option | Consumer | Behavior |
|---|---|---|
baseUrl | HttpClient URL resolver | Required base for every relative ClientRequest.path |
headers | HttpClient request builder | Cloned into each request; request-specific values win |
fetch | HttpClient transport | Called after policy gates; defaults to global fetch |
timing | retry, breaker, limiter | Optional; defaults to createDefaultClientTiming() |
retry | retry strategy | limit must be a positive safe integer; every configured or derived delay must be finite, non-negative, and at most 2_147_483_647 ms |
maxRetryAfterMs | retry strategy | ClientRetryPolicy member; same timer-safe bound. A larger Retry-After surfaces the response error immediately, without sleeping or retrying |
circuitBreaker | origin breaker map | threshold < 1 throws at construction |
rateLimit | origin limiter map | Non-positive maxRequests/windowMs throws |
requestInterceptors | request pipeline | Run once in array order before resilient execution |
responseInterceptors | response pipeline | Run 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
| Error | When thrown | Fields |
|---|---|---|
HttpClientError | Non-2xx HTTP response | status, headers, body |
ClientCircuitOpenError | Circuit breaker is open for the origin | (none) |
OpenApiCodegenError | Invalid OpenAPI document during codegen | path, 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;
}
| Option | Default | Description |
|---|---|---|
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 symbol | Derivation |
|---|---|
| Operation method | lower-camelCase from operationId, split on non-alphanumeric runs, interior casing preserved (listUsers → listUsers) |
| Component type | PascalCase from the component name (User → export type User) |
| Argument interface | PascalCase from operationId plus Args (listUsers → ListUsersArgs) |
| Client interface | apiTypeName, PascalCase-sanitized (default Api); the factory’s written-out return type |
| Error union | PascalCase 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 alias | PascalCase from operationId plus Error<status>Body, emitted only when the rendered body spans lines |
| Request body alias | PascalCase from operationId plus Body, emitted only when the body schema is inline and spans lines |
| Response alias | PascalCase from operationId plus Response<status>, emitted only when a 2xx schema is inline and spans lines |
| Parameter alias | PascalCase from operationId plus the parameter name plus Param, emitted only when the parameter schema is inline and spans lines |
| Leading digit / reserved | digit run prefixed n; reserved word prefixed _; a name that sanitizes to nothing becomes operation |
| Local-identifier rename | a 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 name | a 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 nonew Application()/app.use()API. Plugins register duringapp.start(), so do not resolveCAPABILITIES.GRPCbeforestart()resolves — the capability does not exist yet and the lookup throws. Pass services through theservicesoption (below) or calladdServiceafterstart().
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
| Option | Type | Default | Description |
|---|---|---|---|
basePath | string | / | 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. |
reflection | boolean | true | Register grpc.reflection.v1.ServerReflection. Bidi streaming — requires HTTP/2 or in-process fetch. |
health | boolean | true | Register grpc.health.v1.Health (Check only), bridged to the M20 health plugin. |
services | Array<{ definition, implementation? }> | [] | Initial services to register at startup. The recommended registration path: the capability does not exist before start(). |
connectModule | ConnectRuntime | omitted | Injected Connect runtime for tests; omitted triggers lazy import() of four npm specifiers. |
interceptors | readonly 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-OnlyUNIMPLEMENTED(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
| Export | Kind | Purpose |
|---|---|---|
GrpcPlugin | function | Plugin factory — registers IGrpcService under CAPABILITIES.GRPC |
GrpcService | class | The IGrpcService implementation; exported so tests can compose it without subclassing |
adaptConnectModule | function | Structural adaptation of raw Connect/Protobuf modules into the internal ConnectRuntime port |
GrpcUnavailableError | class | Deprecated — nothing throws it since M70a; handleRequest needs no adapter capability |
GrpcRuntimeLoadError | class | Thrown by loadConnectModule when any of the four npm specifiers cannot be imported |
GrpcDescriptorError | class | Thrown when an embedded descriptor set cannot be decoded or lacks its expected service |
GrpcPluginOptions | type | The factory parameter shape |
ConnectModuleLike | type | The four-module bundle adaptConnectModule accepts |
ConnectRuntimeand 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 includeapplication/jsonandapplication/proto. A path outsidebasePathreturnsnulland 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.GrpcPluginno longer callsadapter.setRpcHandler, which is deprecated and consulted by nothing. IGrpcService.claims(request)is what keeps ordinary 404s intact.handleRequestreturnsPromise<Response>and nevernull, so it answers404both for a path outsidebasePathand for a claimed path with no such procedure — the two are indistinguishable once dispatched. The kernel therefore asksclaims()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’sNot Found(text/plain).claimsis optional on the contract for source compatibility, and the kernel treats an implementor that lacks it as claiming nothing. A rootbasePathnormalizes to'', which contains every path, so thereclaimsreports only registered procedures rather than the whole application — otherwise a root-mounted service consulted before route matching would 404 every ordinary route.IGrpcService.availableis now alwaystrue. It used to report whether the resolved HTTP adapter implementedsetRpcHandler; dispatch no longer depends on any adapter capability.- The request handed to
handleRequestis reconstructed from the mappedIRequest(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, andinject()attaches the undisturbed webRequestasIRequest.rawbefore running the pipeline, so an injected request reaches RPC dispatch exactly as a socket request does — the integration suite drives gRPC throughinject(). The retired adapter interceptor is not involved: nosetRpcHandlerseam is consulted on any path.- Bidi streaming requires HTTP/2.
grpc.reflection.v1.ServerReflectionis 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 reportingSERVINGhere 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 onSERVINGwhile a process is degraded now seesNOT_SERVING(see the CHANGELOG). Checkhonors theservicefield. The empty string means “the whole server” and returns the mapped aggregate health. A name the server does not serve returnsSERVICE_UNKNOWN (3), regardless of overall health.ListandWatchare unimplemented. Connect auto-responsesunimplementedfor 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), andall_extension_numbers_of_type.file_containing_extensionanswersUNIMPLEMENTED— the framework registers no extensions. An unknown filename, symbol or type answersNOT_FOUND (5). - Reflection sees the app’s own protos. A registered service’s
DescFileand its transitivedependenciesare indexed, sofile_containing_symbolresolves 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’sDescFile.namedrops the.protosuffix whileproto.nameretains 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 firstregister(). Absence throwsGrpcRuntimeLoadErrorwith 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.availableis alwaystrue. The retiredsetRpcHandler?member is consulted by nothing, andGrpcUnavailableErrorremains 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 (specificallygrpc-status) for proper status signaling, and no fetch-based server runtime exposes them to aResponse— including Deno’sDeno.serve, Node.js, and Bun. Every native request is therefore answered with a Trailers-OnlyUNIMPLEMENTED(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. Since0.7.0the refusal is decided from the request HEADERS through the optionalIGrpcService.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
| Option | Type | Default | Consumer / behavior |
|---|---|---|---|
env | CloudflareWorkerEnv | required | BindingRegistry — the record every accessor reads |
waitUntil | WaitUntilHost | — | resolveWaitUntil — delegated to; omit off Workers |
requireBindings | readonly string[] | [] | register() — throws naming every absent entry |
cache.binding | string | — | KvCacheStore — the KV namespace serving CAPABILITIES.CACHE |
cache.name | string | 'default' | Plugin factory — derives cache.<name> when not 'default' |
cache.prefix | string | — | KvCacheStore — key prefix; required to call clear() |
cache.defaultTtlSeconds | number | — | KvCacheStore.set — applied when ttlSeconds is omitted |
storage.binding | string | — | R2Storage — the R2 bucket serving CAPABILITIES.STORAGE |
storage.name | string | 'default' | Plugin factory — derives storage.<name> when not 'default' |
storage.prefix | string | — | R2Storage — object-key prefix |
queue.binding | string | — | WorkersQueue — the producer binding serving CAPABILITIES.QUEUE |
queue.name | string | 'default' | Plugin factory — derives queue.<name> when not 'default' |
queue.maxDelaySeconds | number | 86400 | WorkersQueue.add — a larger delayMs throws rather than being truncated |
messaging.binding | string | — | WorkersBroker — the producer binding serving CAPABILITIES.MESSAGING; validated at register() |
messaging.name | string | 'default' | Plugin factory — derives messaging.<name> when not 'default' |
messaging.rpc.binding | string | — | WorkersBroker — the Durable Object namespace serving reply inboxes; absent, request/respond throw |
messaging.rpc.defaultTimeoutMs | number | 5000 | RequestCorrelation — reply budget when RequestOptions.timeoutMs is omitted |
durableObject.binding | string | — | DurableObjectBackplane — the namespace serving CAPABILITIES.REALTIME_BACKPLANE; validated at register() |
durableObject.name | string | 'default' | Plugin factory — derives realtime-backplane.<name> when not 'default' |
durableObject.topic | string | 'realtime' | DurableObjectBackplane — the idFromName value every replica shares; two apps sharing a namespace must differ |
Exports
| Export | Kind |
|---|---|
CloudflarePlugin | factory |
ICloudflareBindings | interface |
CloudflarePluginOptions, KvCacheOptions, R2StorageArm, WorkersQueueArm, WorkersMessagingArm, WorkersMessagingRpcArm, DurableObjectArm | types |
KvCacheStore, KvCacheStoreOptions, CacheClock | class + types |
KvSessionStore, KvSessionStoreOptions | class + types |
R2Storage, R2StorageOptions | class + types |
D1Adapter, D1AdapterOptions, D1EntityMapping | class + types |
WaitUntilHost, LoggerSource | types |
WorkersQueue, WorkersQueueOptions, JobIdSource | class + types |
createQueueHandler, QueueHandler, QueueHandlerOptions | fn + types |
WorkersBroker, WorkersBrokerOptions, ReplyInboxBinding, BrokerRuntime | class + types |
createMessagingHandler, MessagingHandler, MessagingHandlerOptions | fn + types |
ReplyInboxObjectCore, ReplyInboxObjectCoreOptions | class + type |
WorkersCron, WorkersCronOptions, CronHandler | class + types |
createScheduledHandler, ScheduledHandler | fn + type |
cacheApiMiddleware, CacheApiMiddlewareOptions, ICacheApi | fn + types |
assessCacheability, CacheabilityInput, CacheRefusal | fn + types |
RealtimeBackplaneObjectCore, RealtimeBackplaneObjectCoreOptions | class + type |
DistributedLockObjectCore, DistributedLockObjectCoreOptions | class + type |
DurableObjectBackplane, DurableObjectBackplaneOptions | class + type |
DurableObjectLock, DurableObjectLockOptions | class + type |
IdempotencyObjectCore, IdempotencyObjectCoreOptions | class + type |
DurableObjectIdempotencyStore, DurableObjectIdempotencyStoreOptions | class + type |
IIdempotencyObjectState | type |
asUpgradeResponse, DurableObjectUpgradeResponse | fn + type |
createDefaultDurableObjectWebSocketHost, DurableObjectWebSocketHost, DurableObjectWebSocketPair | fn + types |
IDurableObjectState, IDurableObjectStorage, IDurableObjectWebSocket, IDurableObjectClientSocket, DurableObjectMessageEvent | types |
IKvNamespace, IR2Bucket, IR2Object, IR2ObjectBody, ID1Database, ID1PreparedStatement, D1Result, IQueueProducer, IQueueMessage, IQueueMessageBatch, IScheduledController, IServiceBinding, IDurableObjectNamespace, CloudflareWorkerEnv, KvPutOptions, KvListOptions, KvListResult, QueueSendOptions | types |
isKvNamespace, isR2Bucket, isD1Database, isQueueProducer, isDurableObjectNamespace | guards |
CloudflareBindingMissingError, CloudflareUnsupportedError, CloudflareObjectNotFoundError, CloudflareRequestTimeoutError, CloudflareRemoteHandlerError | errors |
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' } },
}),
}),
);
| Option | Default | Behavior |
|---|---|---|
tables | {} | Per-entity { table, primaryKey } overrides |
D1EntityMapping.table | the entity name | Physical 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 throwsCloudflareUnsupportedErrornaming the constraint when one is absent — a deferredINSERTcannot report a generated key to a caller that awaitscreate()before the flush. Outside a transactioncreate()usesRETURNING *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.CLOUDFLAREis always registered;CAPABILITIES.CACHEandCAPABILITIES.STORAGEonly when their arm is configured.namederivescache.<name>/storage.<name>exactly asCachePlugindoes, so a KV cache can sit beside a memory one. Registering an unnamed instance besideCachePlugin()is a startup error, because the kernel rejects two providers of one token. - KV’s
expirationTtlminimum is 60 seconds, andICacheStore.setis unbounded. Values carry a{ v, e }envelope whose logical deadline is checked againstruntime.now()on every read, while the physicalexpirationTtlis floored at 60 so KV can still reclaim the key. A 5-second entry therefore expires in 5 seconds. The same envelope backsKvSessionStore. clear()requires a prefix. The binding has no bulk delete, so the sweep pageslist(1000 keys maximum) and deletes each key. Without a prefix it would delete keys the store does not own, so it throwsCloudflareUnsupportedErrorinstead.- 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
nullsurvives:getanswersnullfor it (the contract has no other way to say so) whilehasanddeletereport it as present, and no path removes it. - KV is eventually consistent. Suitable for read-heavy caching, not for coordination.
R2Storage.getSignedUrlrejects. The R2 Workers binding exposes no presign operation at all.getStreamis implemented, so serving through a route is a zero-copy alternative.R2Storage.deleteheads first. R2’sdeletereturns void and reports nothing, so the committedPromise<boolean>costs one extra round trip rather than a constanttrue.KvSessionStoreis constructed by the application, not registered by the plugin:SessionPluginOptions.storeis 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(). Thecloudflarehealth indicator performs no binding I/O either — a KV read per probe interval is billable. It reportsdegradedwhenruntime.platform()is notcloudflare-workers. waitUntilreports 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-workersscaffolds 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:
addRecurringthrows. Cloudflare Queues has no recurring message. The error names Cron Triggers andWorkersCronas the platform’s own mechanism.addandprocessmap directly:AddJobOptions.delayMsis converted to the platform’s whole-seconddelaySecondsrounded up (so a job is never delivered early) and refused abovemaxDelaySeconds, whileProcessOptions.concurrencybounds 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.headerstravels in the envelope and is delivered asIJob.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 tovoid, so the idaddreturns is minted fromruntime.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 andIQueue.processdispatches by name. The id the caller receives is therefore the id the processor sees asjob.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_retriesand dead-letter configuration to decide what happens next.AddJobOptions.maxAttemptsis enforced at dispatch, because Cloudflare’smax_retriesis queue-wide configuration rather than per message. publishreaches 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 placeWorkersBrokercannot match a socket broker.subscriberegisters; it does not start receiving. A Cloudflare queue consumer is a module-level export, so delivery happens only once the application exportscreateMessagingHandler(app)asqueueAND declares the queue under[[queues.consumers]]— the same splitWorkersQueuehas betweenprocessanddispatch. Within one delivery the selection matchesInMemoryBroker: every subscriber that named noqueueis 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/respondneed themessaging.rpcarm, and throwCloudflareUnsupportedErrorwithout 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 responderPOSTs the reply there. That costs a namespace binding and a DO class the application exports (delegating toReplyInboxObjectCore), 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
queueexport serves EVERY queue the Worker consumes. Cloudflare distinguishes them only bybatch.queue— the queue name fromwrangler.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 tocreateMessagingHandlerfails the envelope guard and retries them until the queue dead-letters them, and leaving a produced queue unconsumed discards everyIQueue.add()silently.setu new --template microservice --runtime cloudflare-workersemits 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 andRequestOptions.timeoutMsdefaults to 5000, so on a default queue essentially everyrequest()times out.setu new --template microservice --runtime cloudflare-workersemits the correct stanza. - The RPC errors are this package’s own classes.
CloudflareRequestTimeoutErrorandCloudflareRemoteHandlerErrormirrormessaging-plugin’sRequestTimeoutErrorandRemoteHandlerErrorbut are distinct identities, because AI_GUIDELINES §2.2 forbids a plugin importing another plugin andcommoncarries no error class to promote them into. Which one an application catches is never ambiguous: both providers claimCAPABILITIES.MESSAGING, so the kernel’s duplicate-provider check guarantees exactly one is registered. - Cron Triggers do NOT register
CAPABILITIES.SCHEDULER, deliberately. OfIScheduler’s eight methods onlycronis expressible on Workers:everyanddelayarm a timer and the isolate is evicted between invocations (the same reasonscheduler-plugincannot run on Workers);pause/resume/removeneed state that does not survive between invocations; andgetNextRunis owned by thewrangler.toml[triggers]block. AnISchedulerwhere six of eight methods throw would violate Liskov substitution, soWorkersCronis a purpose-built registry reached directly instead — which is whycreateScheduledHandlertakes it whilecreateQueueHandlertakes the application. - A
WorkersCronexpression must matchwrangler.tomlexactly. Nothing in the process can read that file, so an expression registered here but absent from[triggers] cronsnever 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. cacheApiMiddlewareis a different layer fromcache-plugin’scacheMiddleware, and the two compose: this one serves from the datacenter the request landed in with no round trip, whilecacheMiddlewarereads anICacheStoreevery colo shares. It therefore reports underX-Cache-Api(HIT/MISS/BYPASS), neverX-Cache, so an operator can tell which layer answered.caches.defaultis per-datacenter: a hit rate measured in one location says nothing about another, and adeletedoes 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
Varycannot separate entries in this cache — and before the tenant was keyed, tenant A’s cached page was served to tenant B for the TTL. Whenctx.request.tenantand/orctx.request.localeis set, the default key is the request URL’s own text withsetu-cache-tenant=<encoded id>and thensetu-cache-locale=<encoded locale>concatenated after it, in that order. The query is never re-serialized and the parameter neverset:setwould strip a client-supplied copy from the key while the handler still saw it, and re-serializing would fold encoding variants (?p=%32and?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 (encodeURIComponentcannot encode a lone surrogate), and a request lacking a tenant or locale whose URL already containssetu-cache-tenant=orsetu-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 ascache-pluginapply: 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 areplaceTenantorreplaceLocalemade inside the handler is not reflected — such a route must not be cached here. A customkeyreplaces 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.putthrows for a non-GET request, status 206,Vary: *, and an unclearedSet-Cookie;assessCacheabilityreports each as aCacheRefusaland the middleware skips the write. The 206 andVary: *checks are unconditional — an operator may legitimately configurecacheableStatuses: [200, 206], at which point only the explicit rule stops the platform throwing.Cache-Control: private=Set-Cookieis the platform’s documented opt-in and clears theset-cookierefusal. - 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
POSTwould serve the cached GET body and skip the handler entirely: a mutation silently discarded behind a 200. Any non-GET request passes straight through withX-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.putrejects 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 registeredwaitUntilcarries that reporting; without it the middleware logs throughCAPABILITIES.LOGGERwhen 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 withapp.fetchand a webRequest, 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
waitUntilwhen the plugin is registered AND the application passed a host, so it never delays the response. WhenCloudflarePluginreceived nowaitUntilthe registry has no sink, so the middleware awaits the write inline — the write lands either way;waitUntildecides only whether it delays the response. WithCAPABILITIES.CLOUDFLAREabsent the middleware awaits it inline rather than abandoning it, and with no cache handle at all it passes through withBYPASSrather than throwing. - Not in this package. D1 as a database backend is Milestone 52c — the seam a backend implements
(
IDatabaseAdapter) lives insidedatabase-pluginand is not a committedcommonport, 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 whichGraphqlPluginregisters theIGraphqlService.
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
| Option | Type | Default | Description |
|---|---|---|---|
typeDefs | string | - | SDL schema definition (schema-first mode) |
resolvers | ResolverMap | - | Resolver map (schema-first mode) |
schema | GraphqlSchemaLike | - | Pre-built schema (code-first mode) |
path | string | /graphql | Endpoint path |
graphiql | boolean | true | Enable GraphiQL UI |
introspection | boolean | true | Enable schema introspection |
maxDepth | number | 10 | Maximum query depth (0 to disable) |
validationRules | unknown[] | omitted | Extra rules, appended after the built-ins, assembled once at registration |
maskInternalErrors | boolean | true | Mask internal server errors |
formatError | (error: unknown) => unknown | omitted | Custom error formatter applied after masking |
documentCacheSize | number | 1000 | Max cached documents (0 to disable) |
buildContext | (input: GraphqlContextInput) => unknown | omitted | Custom context builder |
rootValue | unknown | omitted | Root value for resolvers |
graphqlModule | GraphqlModuleLike | omitted | Injected graphql module (for testing or code-first scenarios) |
subscriptions | GraphqlSubscriptionsOptions | omitted | Subscription transports. Omitted registers no transport route at all. |
apq | GraphqlApqOptions | omitted | Automatic Persisted Queries. Omitted disables APQ; requires CAPABILITIES.RUNTIME |
maxBatchSize | number | 0 | 0 disables batching and an array body is still refused with 400 |
subscriptions
| Option | Type | Default | Purpose |
|---|---|---|---|
websocket | GraphqlWsTransportOptions | false | enabled | false disables the WebSocket transport |
websocket.path | string | `${path}/ws` | WebSocket endpoint |
websocket.connectionInitWaitMs | number | 3000 | Close 4408 when no connection_init arrives |
websocket.heartbeatMs | number | 0 | Protocol ping interval; 0 disables |
websocket.onConnect | (info) => false | void | omitted | Returning false closes 4403: Forbidden |
sse | GraphqlSseTransportOptions | false | enabled | false disables the SSE transport |
sse.path | string | `${path}/stream` | SSE endpoint |
sse.heartbeatMs | number | 0 | :keep-alive comment interval; 0 disables |
apq
| Option | Type | Default | Purpose |
|---|---|---|---|
ttlSeconds | number | 300 | TTL for entries in a registered ICacheStore |
maxEntries | number | 1000 | Bound on the in-process LRU used when no cache store is registered |
Exports
| Export | Kind | Purpose |
|---|---|---|
GraphqlPlugin | function | Plugin factory — registers IGraphqlService under CAPABILITIES.GRAPHQL |
GraphqlService | class | The IGraphqlService implementation; exported for testing |
adaptGraphqlModule | function | Structural adaptation of graphql module into internal runtime port |
graphiqlHtml | function | Generates GraphiQL UI HTML page |
createDepthLimitRule | function | Creates a validation rule for query depth limiting |
GraphqlSchemaError | class | Thrown when schema construction or resolver attachment fails |
GraphqlRuntimeLoadError | class | Thrown when graphql runtime cannot be loaded |
loadGraphqlModule | function | Loads graphql through a real dynamic import |
GraphqlPluginOptions | type | The factory parameter shape (union of the two arms) |
GraphqlSchemaFirstOptions | type | The schema-first arm of that union |
GraphqlCodeFirstOptions | type | The code-first arm of that union |
ResolverMap | type | Resolver map for schema-first mode |
TypeResolverMap | type | The resolver entries for one object or interface type |
FieldResolver | type | Field resolver function type |
AnyFieldResolver | type | The bivariant entry type a TypeResolverMap field holds — accepts a narrowly annotated resolver AND contextually types an unannotated one |
AnySubscriptionResolver | interface | The bivariant entry type a TypeResolverMap subscription field holds — same rule as AnyFieldResolver, for { subscribe, resolve? } entries |
SubscriptionResolver | type | A subscription field’s { subscribe, resolve? } pair |
GraphqlScalarResolver | type | Custom scalar serialize/parseValue/parseLiteral methods |
GraphqlSubscriptionsOptions | type | The subscriptions option (WebSocket and SSE arms) |
GraphqlWsTransportOptions | type | WebSocket transport options, including onConnect |
GraphqlSseTransportOptions | type | SSE transport options |
GraphqlApqOptions | type | Automatic Persisted Queries options |
ApqResolver | class | Verifies and resolves persisted-query hashes |
IApqResolver | type | The port the transports consume; implemented by ApqResolver |
ApqResolveResult | type | The resolved query, or a refusal carrying its code |
extractPersistedQuery | function | Reads { version, sha256Hash } from a request’s extensions |
persistedQueryHash | function | SHA-256 hex of a query, over an injected SubtleCrypto |
encodeSseEvent | function | Encodes a next SSE frame |
encodeSseComplete | function | Encodes the complete SSE frame, empty data: field included |
encodeSseComment | function | Encodes a :keep-alive comment frame |
GRAPHQL_TRANSPORT_WS | const | The 'graphql-transport-ws' subprotocol identifier |
GraphqlScalarTypeLike | type | Structural constraint for a custom scalar type |
GraphqlSchemaLike | type | Structural constraint for pre-built schemas |
GraphqlModuleLike | type | Structural constraint for injected graphql modules |
DefaultGraphqlContext | type | Default context shape passed to resolvers |
GraphqlContextInput | type | Input type for custom context builder |
GraphqlRuntimeand 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 aDefaultGraphqlContextwhose shape is per-transport. Over HTTP:{ services, requestContext, user?, tenant? }—servicesis the liveIServiceRegistry, so a resolver reaches any other capability through it, anduser/tenantare whatever the auth and multi-tenancy middleware published on the request. Over WebSocket:{ services, connection }—requestContextis absent (notundefined-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 onGraphqlConnectionInfo.headers/.query, and identity set by anonConnecthook viainfo.data.set('user', …)surfaces asctx.user. SupplyingbuildContextreplaces that object wholesale. - Media-type negotiation and the status watershed. Responds with
application/graphql-response+jsonwhen the client requests it, otherwiseapplication/json— including for failures raised before execution, so a client is never handed a media type it did not ask for. Undergraphql-response, a request error (parse, validation, operation resolution) is400, a subscription over HTTP is400, and a mutation overGETis405. Underapplication/json, every request the endpoint processed as GraphQL answers200with the error in the body, because a client predating the newer media type reads a non-200 as a network failure and never reads theerrorsarray. Exactly three cases keep their status underapplication/json— an unsupported request content type (415), a malformed JSON body (400), and a mutation overGET(405) — because none of them is a GraphQL result. An APQ refusal follows the watershed too: underapplication/jsonit answers200withPersistedQueryNotFoundin the body (it is exactly the error a client must read and retry), while undergraphql-responseit carries the resolver’s own status. Batching remains refused before per-element resolution, so an APQ miss inside a batch undergraphql-responsesurfaces as400 BATCHING_NOT_SUPPORTED. The status is decided from the outcome alone and never from the response body, so aformatErrorhook cannot change it. - Two resolver authoring styles, both supported.
FieldResolver<TSource, TContext, TArgs>is generic withunknowndefaults, so a resolver may be annotated narrowly (FieldResolver<IssueRow, DefaultGraphqlContext, { id: string }>) and still assign to aResolverMap. A resolver written unannotated —(source, args) => …, the ordinary schema-first shape — takes its parameter types contextually fromAnyFieldResolver, soargsisRecord<string, unknown>rather thannever. 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 throughAnySubscriptionResolver, so a typed{ subscribe, resolve: (payload: Book) => … }assigns too. - An executed operation is always
200. A field error that nullsdatais not a request error, so it does not become a400even undergraphql-response. 405carriesAllow: POST. A mutation sent overGETis refused withMETHOD_NOT_ALLOWEDand 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 a400with codeOPERATION_RESOLUTION_FAILED, decided after parse and before validation. - Error masking. Internal errors (resolvers throwing without a
codein extensions) are masked by default. The original is logged through the plugin context’sloggerwhen 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: 0disables the cache. - GraphiQL UI. Enabled by default, served on
GET /graphqlwhen the request has noqueryparameter and acceptstext/html. - Platform notes. The
graphqlpackage readsprocess.env.NODE_ENVat import time. Deno requires--allow-env; Cloudflare Workers requiresnodejs_compat. - Subscriptions are opt-in and never ride the HTTP endpoint. Supply
subscriptionsto register the transports; omit it and no transport route exists.POST/GET /graphqlcontinues to refuse a subscription operation with400 SUBSCRIPTIONS_NOT_SUPPORTED_OVER_HTTP, unchanged — a subscription is served only on`${path}/ws`and`${path}/stream`. - Declaring a subscription (schema-first). A
Subscriptionfield’s resolver entry is aSubscriptionResolver—{ subscribe, resolve? }— not a bare function.subscribereturns the async iterable the field streams from and is attached to the schema field’s ownsubscribeslot; 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) fromIRuntimeServicesrather than global timers, so configuringsubscriptionswithout 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.WEBSOCKETis present ANDIWebSocketService.availableistrue; otherwise the plugin logs a notice and everything else carries on. The route claimsheartbeat: falseonWebSocketRouteOptions, becausewebsocket-plugin’s shared sweeper sends a raw text frame that a conformantgraphql-transport-wsclient must answer by closing4400. Liveness on that route is the protocol’s ownping/pong, configured bysubscriptions.websocket.heartbeatMs. - Authenticating a subscription.
subscriptions.websocket.onConnectruns onconnection_initbefore the ack, receives theGraphqlConnectionInfo(includingconnectionParams— the protocol’s auth channel — plus the upgrade headers and query), and closes the socket with4403: Forbiddenwhen it returnsfalse. Writing toconn.datathere sets theuser/tenantthe default resolver context reads back. With no hook configured the socket is accepted.onConnectand a pipeline guard on the socket path are mutually exclusive:connection_initis a message on an already-open socket, and since M70a a guard runs on the upgrade itself, so a guard covering the/graphqlprefix answersGET /graphql/wswith401andonConnectnever runs. Either scope the guard to the HTTP endpoint and authenticate inonConnect, or authenticate on the upgrade with a credential a browser can attach there — a cookie, throughauth-plugin’s session strategy. See the package README’s “onConnectand a route guard cannot both authenticate the socket”. - Resolver context over a socket. A WebSocket carries no
IRequestContext, soIGraphqlService.subscribetakes aGraphqlOperationContextinstead. On that pathservicesis the plugin-levelIServiceRegistry, so a subscription resolver reaches every capability an HTTP resolver can. - Errors inside a live subscription are masked.
maskInternalErrorsapplies 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-streamas anextevent followed bycomplete— not as a400, which would make the user agent fail the connection and give nativeEventSourcenothing to read. A transport failure that happens before any GraphQL request exists (unsupported content type, unparseable body, a missingqueryparameter) is still an ordinary buffered HTTP error. A persisted-query miss counts as a request error and is therefore delivered in-stream too. Thecompleteframe always carries an emptydata:field, without whichEventSourcenever 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 submittedsha256Hash; a mismatch answersPERSISTED_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 answersPersistedQueryNotFound/PERSISTED_QUERY_NOT_FOUND, the standard retry signal. Entries live under theapq:namespace inCAPABILITIES.CACHEwhen one is registered, and in a bounded in-process LRU otherwise. Hashing usesIRuntimeServices.subtle, so configuringapqwithoutCAPABILITIES.RUNTIMEthrows at registration. - Batching is opt-in.
maxBatchSize: 0(the default) keeps refusing an array body with400. Above0, an array body executes its elements concurrently and answers a JSON array in request order; over the limit is400 BATCH_TOO_LARGE. A batch always answersapplication/json— theapplication/graphql-response+jsonmedia type describes a single result and cannot express an array, so an array body from a client negotiating it is refused with400 BATCHING_NOT_SUPPORTED. - Custom scalars. In the schema-first arm a resolver-map entry for a scalar type supplies any
subset of
serialize,parseValue, andparseLiteral; 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
| Option | Type | Default | Description |
|---|---|---|---|
root | string | (required) | Directory to serve files from |
urlPrefix | string | '/' | URL prefix for static routes |
index | string | 'index.html' | Index file for directories |
fallback | string | undefined | SPA fallback file |
cacheControl | string | ((requestPath) => string) | Hashed→immutable, else must-revalidate | Cache-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) |
etag | boolean | true | Enable ETag generation |
ranges | boolean | true | Enable Range requests |
compressed | boolean | true | Negotiate .br/.gz sidecars |
maxBufferBytes | number | 1048576 | Threshold for streaming |
Exports
| Export | Kind | Description |
|---|---|---|
StaticPlugin | function | Plugin factory |
StaticFilesService | class | Service implementing IStaticFiles |
createStaticHandler | function | Standalone route handler |
IStaticFiles | interface | Service interface |
StaticPluginOptions | type | Plugin options type |
IStaticFiles declares one method:
serve(ctx: IRequestContext): Promise<HandlerResult>;
Notes
- Mounts routes on both
GETandHEAD - A root
urlPrefixclaimsGET /*andHEAD /*, which no second plugin can share. The plugin registers<urlPrefix>/*, sourlPrefix: '/'mounts the bare wildcard — and the kernel refuses a duplicateMETHOD 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:
206withContent-Range,416for unsatisfiable - The
ETagis strong ("<size>-<mtimeMs>") when the runtime reports anmtime, and degrades to a weak size-only validator when it does not. This matters for resumption:If-RangeMUST be ignored for a weak validator (RFC 9110 §13.1.5), so an interrupted download resumes only against the strong form.size+mtimeis what nginx and Apache emit as strong for static files - Precompressed sidecars:
.brpreferred over.gz, sent asContent-Encoding: brandContent-Encoding: gziprespectively; ETag and conditional evaluation use the selected sidecar stat rather than the uncompressed source representation Cache-Controlis resolved from the full leading-slash request path, INCLUDINGurlPrefix—/assets/app-A9acsx54.jsfor a file under that prefix,/assets/index.htmlfor a directory request against it, and/index.htmlunder a root mount; for arootthat is a directory — whatrootdocuments — 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/.gzsidecar path — so a content-hashed asset keeps itsimmutablepolicy 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
HEADopens no body stream, so it cannot leak a file descriptor on a file abovemaxBufferBytes - An explicit
Accept-Encodingentry overrides the wildcard, sobr;q=0, *refuses brotli - Workers degradation: registers capability but mounts no route when
fsis absent - Health indicator: reports
up/downbased on root directory accessibility; on a runtime without fs it reportsupwithdata: { 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:
| Option | Type | Default | Behavior |
|---|---|---|---|
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. |
view | IViewEngine | — | '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
| Export | Kind | Purpose |
|---|---|---|
ViewPlugin | function | Plugin factory — registers the selected engine under CAPABILITIES.VIEW and a view health indicator reporting it (always up) |
renderView | function | Functional entry point — renderView(ctx, component, props) resolves the engine per request and answers IResponse.html(...) |
raw | function | The escaping opt-out, re-exported from hono/html so an application does not import hono directly |
ViewRenderError | class | A component threw while rendering (original carried as cause), or returned undefined (almost always a missing return) |
UnresolvedSuspenseError | class | The rendered tree holds a pending <Suspense> boundary; buffered rendering would serve only the fallback, so it is refused |
ViewPluginOptions | type | The discriminated options union above |
Behavior notes
- Two entry points, one implementation.
@Render(Component)resolves the engine once atregister()(the token joinsDecoratorPlugin’soptionalDependencies);renderViewresolves it per request. Both answer through the sameIResponse.html(...)write — byte-identical bodies and headers, pinned by an integration test under a non-default configuration. - Buffered by design.
IViewEngine.renderanswersstring | Promise<string>and nothing else. A tree holding a pending<Suspense>boundary is refused withUnresolvedSuspenseErrorrather than served as its fallback with a200; streaming resolution is deferred to a follow-up milestone. An async component WITHOUT<Suspense>renders clean, as does anhtmltemplate 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) => stringcomponent is a validComponent<P>and its output is returned unchanged, so a hand-written template literal escapes nothing — write views with JSX or thehtmltag. 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-levellayoutoption: 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)
| Export | Kind | Purpose |
|---|---|---|
IdempotencyPlugin | function | Registers IIdempotencyService under CAPABILITIES.IDEMPOTENCY and the health indicator |
idempotent | function | Builds the route-level HTTP middleware (list it LAST) |
idempotentIngress | function | Builds the ingress behaviour over a required topics/jobNames allow-list |
derivedIdempotencyKey | function | Reads the store key the middleware recorded, to forward to a provider |
IdempotencyRefusedError | class | An ingress refusal, carrying reason, ingress and target |
IdempotencyConfigurationError | class | An option refusal, carrying the failing option path |
IdempotencyWithinError | class | A tier-C refusal or failure, carrying reason and a status hint (M109b) |
IdempotencyVerifyTimeoutError | class | start() rejects with it when a store’s verify() outlives storeTimeoutMs (M109b) |
IDEMPOTENCY_KEY_HEADER | const | 'Idempotency-Key' |
IDEMPOTENT_REPLAYED_HEADER | const | 'Idempotent-Replayed' |
Types
| Export | Kind | Purpose |
|---|---|---|
IdempotencyPluginOptions | type | The IdempotencyPlugin options (store, leases, ttl, cap, transactional) |
IdempotencyStoreConfig | type | The store arm (memory, redis built/injected, custom) |
IRedisIdempotencyClient | type | The Redis facade an injected client must satisfy |
IdempotencyRefusalReason | type | The union carried by IdempotencyRefusedError.reason |
IdempotencyWithinErrorReason | type | The union carried by IdempotencyWithinError.reason (M109b) |
TransactionalIdempotencyOptions | type | The transactional option: store, ttlMs, storeTimeoutMs, maxResultBytes, purge (M109b) |
TransactionalIdempotencyPurgeOptions | type | The 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.
| Option | Type | Default | Behavior |
|---|---|---|---|
supportedLocales | readonly 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. |
catalogues | Record<string, MessageCatalogue> | — | Static catalogues keyed by tag, validated at register(). |
source | IMessageSource | — | Loaded once at register() (a rejection fails startup), validated identically; the health indicator reports injected[:name]. |
allowPartialCatalogues | boolean | false | Accept 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. |
timeZone | string | runtime zone | IANA 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.enabled | boolean | true | false registers no global middleware; attach localeMiddleware(...) yourself. |
middleware.priority | number | 45 | After tenant resolution (40), before logging (50). Must be an integer. |
middleware.query | string | false | 'locale' | The query parameter source; false disables it. |
middleware.cookie | string | false | 'setu_locale' | The cookie source; false disables it and drops Cookie from Vary. |
middleware.exclude | readonly PathPattern[] | six probe paths | Skipped entirely — no resolution, no headers. Default /live, /ready, /health, /metrics, /openapi.json, /docs; [] disables. |
exposeCatalogues | { basePath; cacheControl? } | off | Registers 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
| Export | Kind | Purpose |
|---|---|---|
LocalizationPlugin | function | Plugin factory — registers the localizer, the resolution middleware, the opt-in catalogue route and a localization indicator |
localizerFor | function | localizerFor(ctx) — the localizer for ctx.request.locale; the default locale when absent, negotiated when unsupported |
localeMiddleware | function | The resolution middleware, for an application registering it per route group under middleware.enabled: false |
MissingMessageError | class | t() under onMissing: 'throw' for a key no catalogue defines; carries key and locale |
MissingPluralCountError | class | A plural message formatted without a finite numeric count; carries key when known |
UnsupportedLocaleError | class | forLocale(tag) for a tag outside the supported set — callers pass configuration there, never client text |
IMessageSource | type | { name?; load(): Promise<Record<string, MessageCatalogue>> } |
LocaleMiddlewareOptions | type | localeMiddleware’s options |
LocalizationPluginOptions | type | The 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 againstsupportedLocalesonly, exactly, then with subtags stripped right to left (de-Latn-AT→de-Latn→de). A*range selects the first supported locale noq=0range excludes; exclusion applies to*only, and the plugin never answers406. - Bounded input:
Accept-Languageis 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(plusCookiewhile the cookie source is on) is appended before the handler on every governed response.Content-Languageis written after the handler from the FINALctx.request.locale, only when that is a supported tag and the response has none; a rejectednext()writes none.Vary: Cookiemakes 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 Englishcount: 0selects theotherplural form. - Shared code, not identical output across runtimes:
Intldata differs between runtimes, and aDateformats in the runtime’s zone unlesstimeZoneis set on both sides. - No
optionalDependencieson tenancy: the tenant default is read per request, which middleware priority (40 < 45) orders. The plugin declaresCAPABILITIES.LOGGERonly, so a partial-catalogue warning raised duringregister()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 value | Owning packages | Supported versions | Boundary and compatibility rule |
|---|---|---|---|
| Zod schemas | validation-plugin, openapi-plugin, decorator-plugin | zod >=4.6.6 <5 | Validation 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 objects | database-plugin | 0.45.2 baseline; range pending | The 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 client | database-plugin | Pending boundary repair | Prisma 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:
- Start minimal — Just kernel + runtime, add plugins as needed
- Everything is replaceable — Any plugin can be swapped via capability tokens
- Full programmatic API — No feature requires decorators or reflection
- Optional decorators — Available for those who prefer NestJS-style DX
- Type-safe — Full TypeScript support with no
anyin public APIs - Runtime independent — Runs on Node.js, Deno, Bun, and Cloudflare Workers (future)
- Testable — Built-in test utilities, mock plugins, request injection
- Enterprise-ready — Auth, secrets, audit, resilience, multi-tenancy, feature flags