Setu-TS — Architecture Guide
This document is the definitive technical architecture guide for framework contributors. It explains how the framework works internally and why it was designed this way.
Audience: Framework contributors and maintainers, not application developers.
Companion documents:
PUBLIC_API.md— How developers use the frameworkROADMAP.md— Implementation phasesAI_GUIDELINES.md— Engineering rules
Table of Contents
- Vision
- Design Principles
- High-Level Architecture
- Request Lifecycle
- Plugin Architecture
- Service Registry
- Runtime Layer
- Package Architecture
- Dependency Rules
- Middleware Pipeline
- Dependency Injection
- Decorator System
- Error Handling
- Security Model
- Performance Philosophy
- Testing Strategy
- Extending the Framework
- Future Evolution
- Deployment
- Conclusion
1. Vision
Why This Framework Exists
Enterprise backend development faces a recurring tension: frameworks that are powerful tend to be heavy and opinionated, while frameworks that are lightweight tend to lack enterprise features.
NestJS provides excellent developer experience with decorators, DI, and modules, but it is tightly coupled to Node.js, requires reflection, and has a steep learning curve. Fastify is fast and plugin-oriented but lacks the enterprise abstractions (repository pattern, CQRS, multi-tenancy, audit logging) that large organizations need. Hono delivers exceptional performance and runtime portability but is intentionally minimal — it provides routing, not architecture.
Setu-TS exists to bridge this gap. It combines:
| Source | What We Take |
|---|---|
| Hono | Performance, runtime portability, routing engine |
| Spring Boot | Plugin auto-configuration, conditional registration, starter bundles |
| ASP.NET Core | Middleware pipeline, request/response abstractions |
| Fastify | Plugin encapsulation, decoration pattern, lifecycle hooks |
Problems It Solves
-
Runtime lock-in — NestJS, Express, and Fastify are Node.js-first. Setu-TS runs on Node.js, Deno, and Bun with zero code changes, and is designed for Cloudflare Workers extensibility.
-
Opinionated heaviness — NestJS requires DI, decorators, and reflection. Setu-TS makes all of these optional. Start with just a router and add capabilities as needed.
-
Monolithic frameworks — Most frameworks bundle all features. Setu-TS is plugin-first: every capability (logging, database, auth, validation) is a plugin that can be added, removed, or replaced.
-
Poor replaceability — In most frameworks, swapping the database layer or logger requires touching application code. Setu-TS uses capability tokens, so any plugin can be replaced without modifying business logic.
-
Enterprise feature gaps — Lightweight frameworks lack secrets management, audit logging, circuit breakers, feature flags, and multi-tenancy. Setu-TS provides these as plugins.
Why It Is Not a NestJS Clone
NestJS is built around three mandatory pillars: modules, dependency injection, and decorators. Every NestJS application must use all three. This creates a steep learning curve and tight coupling to the framework’s opinions.
Setu-TS inverts this:
| Aspect | NestJS | Setu-TS |
|---|---|---|
| DI | Required | Optional plugin |
| Decorators | Required | Optional plugin |
| Reflection | Required | Optional |
| Modules | Required | Plugins (more granular) |
| Programmatic API | Limited | Full API for everything |
| Replaceability | Difficult | Any plugin swappable |
| Runtime | Node.js only | Node, Deno, Bun, CF Workers |
| Bundle size | Large | Pay only for what you use |
Runtime vs build toolchain. The Runtime row describes where the framework runs. The React Router SSR plugin (M44) is web-standard at runtime — it runs wherever
Request/Responsedo — but its frontend build uses the Node/npm toolchain (Vite) outside the Deno workspace, and is never a dependency of a JSR-published package (see AI_GUIDELINES §12.2). CF Workers support for React Router framework mode specifically carries additional constraints and is not implied by this row.
A Setu-TS application can be as minimal as:
const app = createApplication({ plugins: [RuntimePlugin()] });
app.router.get('/', (ctx) => ctx.response.json({ hello: 'world' }));
await app.start();
No decorators, no DI, no modules — just a router and a runtime. Capabilities are added incrementally via plugins.
Why It Uses Hono
As of Milestone 22, Hono is used as the internal routing engine inside the kernel Router
class. Route matching is delegated to a new Hono({ strict: false }) instance constructed with
Hono’s TrieRouter (M22 chose LinearRouter; TrieRouter replaced it in 0.11.0 because it returns
the same full candidate set without re-parsing every pattern per request). The kernel calls the
router directly, so it applies LinearRouter’s trailing-slash rule itself: a request may carry one
extra trailing slash (/users/ reaches /users), a static route registered with a trailing slash
answers only that spelling, and a parameter pattern is registered without its trailing slash, which
was never significant. For a slash-terminated request it matches both spellings and ranks the
candidates together. The kernel’s custom (ctx, next) middleware pipeline runs inside the Hono
dispatch, and the application’s #handleRequest method calls router.match() which maps Hono’s
matched route back to { definition, params }. The kernel maintains its own RouteEntry map so
getAll()/listRoutes() introspection is unchanged, and the kernel applies its own deterministic
tie-break on top of Hono’s match result: more static segments first, then FEWER * wildcard
segments, then earliest registration order. The wildcard key is what stops an application catch-all
(GET /*) from owning every single-segment route registered after it — before M70g a * counted as
a static segment, so /* tied with /openapi.json and won merely by registering first. The rule
compares counts rather than positions, so /a/* loses to /:x/b on /a/b; that limit is
documented in Router.match and pinned by a test.
listRoutes() and the kernel-diagnostics route projection are two DIFFERENT surfaces and neither
replaces the other. RouteInfo.definition carries the live handler, its middleware functions and
its schema objects: it is an in-process contract for plugins that must call or wrap a route
(openapi-plugin reads the Zod schemas; M70g reads owner for the duplicate-route refusal), and
it is deliberately NOT wire-safe. The diagnostics projection added in Milestone 98a selects its own
bounded primitives — an opaque id, the HTTP verb, and the registered pattern only when the
application allowlisted that exact pattern — and can therefore leave the process. M98a widened
neither IRouterApi.listRoutes() nor RouteInfo; the projection is captured at the router’s own
registration boundary through an internal sink.
Hono is chosen for several reasons:
- Performance — Hono is one of the fastest TypeScript routers, competitive with Fastify.
- Runtime portability — Hono already runs on Node.js, Deno, Bun, and Cloudflare Workers.
- Minimal — Hono does not impose an architecture; it provides routing and lets the framework build on top.
- Standards-based — Hono uses the standard
Request/ResponseWeb APIs.
Setu-TS does not expose Hono directly to application developers. Instead, it wraps Hono in the kernel router; the runtime adapter (M41 today, M23 next) handles only socket serving. This allows the framework to swap Hono for another router in the future without breaking applications.
Why It Is Plugin-First
A plugin-first architecture provides three critical benefits:
-
Pay only for what you use — If you do not need a database, the database plugin is never loaded. No dead code, no unused dependencies, no startup overhead.
-
Replace anything — Every capability is identified by a capability token (a string). If you do not like the default logger, you register a different plugin that provides the same
loggertoken. Application code does not change. -
Extensible without forking — Need a feature the framework does not provide? Write a plugin. No need to fork the framework or monkey-patch internals.
Why Runtime Independence Matters
The JavaScript ecosystem is fragmenting across runtimes:
- Node.js — The incumbent, with the largest ecosystem.
- Deno — Secure by default, native TypeScript, web-standard APIs.
- Bun — Extremely fast, Node-compatible, native TypeScript.
- Cloudflare Workers — Edge computing, V8 isolates, no Node.js APIs.
Applications written today on Node.js may need to run on Bun tomorrow for performance, or on
Cloudflare Workers for edge deployment. Setu-TS ensures that business logic never depends on
runtime-specific APIs. All runtime-specific operations (UUID generation, timers, crypto, file
system, environment variables) are abstracted behind IRuntimeServices and provided by the
RuntimePlugin.
2. Design Principles
Plugin-First Architecture
Every capability is a plugin. The kernel ships with zero features beyond plugin orchestration. This means:
- The kernel is small and stable.
- Features are independently versioned.
- Applications include only what they need.
- Any feature can be replaced or removed.
Composition Over Inheritance
The framework avoids deep inheritance hierarchies. Instead:
- Exceptions are created via factory functions, not class inheritance.
- Plugins compose via capability tokens, not abstract base classes.
- Middleware composes via pipeline execution, not middleware classes inheriting from a base.
- Services are composed via the service registry, not via class hierarchies.
Dependency Inversion
All dependencies point toward abstractions:
- Plugins depend on interfaces from
@setu-ts/common. - Services are resolved by capability token, not by class reference.
- Adapters implement interfaces defined in
common. - No package depends on a concrete implementation from another package.
Runtime Abstraction
No package except @setu-ts/runtime may use runtime-specific APIs. This rule is enforced by:
- Lint rules (custom
deno lintplugin rule / CI gate) that forbidnode:andbun:imports andDeno.*globals outside the runtime package. - Code review.
- CI tests that run on all three runtimes.
Capability Tokens
Plugins communicate via capability tokens — strings that identify a capability, not a concrete type:
// Plugin A provides the 'database' capability
ctx.services.register('database', new PrismaDatabaseService());
// Plugin B consumes the 'database' capability
const db = ctx.services.get<IDatabaseService>('database');
This decouples plugins from each other. Plugin B does not know that Plugin A provides the database
service — it only knows that something provides the database capability. This enables:
- Replaceability — Swap Prisma for Drizzle by registering a different plugin.
- Testing — Register a mock service with the same token.
- Decoupling — Plugins never import from each other.
Programmatic APIs
Every feature has a complete programmatic API. No feature requires decorators, DI, or reflection. This ensures:
- The framework works in environments that do not support decorators (e.g., some bundlers strip them).
- Developers who prefer functional style are not forced into OOP.
- Testing is easier — no need to bootstrap a decorator system.
Optional Decorators
Decorators are provided by the DecoratorPlugin. When registered, it reads metadata stored by
decorators and registers routes, services, and middleware with the kernel. When not registered,
decorators are inert — they store metadata that is never read.
Optional Dependency Injection
DI is provided by the DiPlugin. When registered, it provides a container for resolving services by
token. When not registered, services are resolved directly from the ServiceRegistry. The
ServiceRegistry is the primary service resolution mechanism; the DI container is a convenience
layer on top.
Tree Shaking
Every package uses:
- ES module exports only.
- Subpath exports for granular imports.
- No top-level side effects.
This ensures bundlers can eliminate unused code.
SOLID
- S — Each plugin has one responsibility.
- O — Plugins extend behavior via registration, not modification.
- L — Any adapter implementing an interface is substitutable.
- I — Interfaces are segregated (e.g.,
IReadRepositoryvsIWriteRepository). - D — All dependencies point to interfaces in
common.
Clean Architecture
Dependency direction always points inward:
Plugins (Infrastructure) → Application Code → Domain Code
↑
depends on interfaces only
Hexagonal Architecture
- Ports — Interfaces in
@setu-ts/common(e.g.,IDatabaseService,ICacheStore,IMessageBroker). - Adapters — Implementations in plugins (e.g.,
PrismaAdapter,RedisStore,RabbitMqBroker). - Application Core — Business logic depends only on ports.
3. High-Level Architecture
graph TB
subgraph Application Layer
App[Application Instance]
end
subgraph Kernel
Kernel[Plugin Kernel]
Registry[Plugin Registry]
Services[Service Registry]
Pipeline[Middleware Pipeline]
Router[Router]
end
subgraph Plugins
Runtime[RuntimePlugin]
Logger[LoggerPlugin]
Config[ConfigPlugin]
Validation[ValidationPlugin]
Database[DatabasePlugin]
Auth[AuthPlugin]
Other[...Other Plugins]
end
subgraph Runtime Adapters
Node[Node Adapter]
Deno[Deno Adapter]
Bun[Bun Adapter]
end
App --> Kernel
Kernel --> Registry
Kernel --> Services
Kernel --> Pipeline
Kernel --> Router
Registry --> Runtime
Registry --> Logger
Registry --> Config
Registry --> Validation
Registry --> Database
Registry --> Auth
Registry --> Other
Runtime --> Node
Runtime --> Deno
Runtime --> Bun
Pipeline -.-> Router
Router -.-> Pipeline
Component Responsibilities
| Component | Responsibility |
|---|---|
| Application | Entry point for developers. Holds plugin list, starts/stops server, provides inject() for testing. |
| Plugin Kernel | Orchestrates plugin registration, lifecycle, and shutdown. The only component that knows about all plugins. |
| Plugin Registry | Stores registered plugins, resolves dependencies, detects cycles, orders by priority. |
| Service Registry | Maps capability tokens to service instances. The primary service resolution mechanism. |
| Middleware Pipeline | Executes middleware in order. Created by the kernel, populated by plugins. |
| Router | Registers and matches routes. Populated by plugins and application code. |
| Plugins | Provide capabilities (logging, database, auth, etc.) by registering services, middleware, routes, and hooks. |
| Runtime Adapters | Abstract runtime-specific APIs (HTTP server, UUID, timers, crypto, fs). |
Key Insight
The kernel knows nothing about what the application does. It only knows how to:
- Register plugins.
- Resolve plugin dependencies.
- Build the middleware pipeline.
- Build the router.
- Start and stop the HTTP server (delegated to the runtime adapter).
All domain knowledge lives in plugins. This is what makes the framework replaceable and extensible.
4. Request Lifecycle
sequenceDiagram
participant Client
participant Runtime as Runtime Adapter
participant Kernel
participant Pipeline as Middleware Pipeline
participant Router
participant Handler as Route Handler
participant Services as Service Registry
Client->>Runtime: HTTP Request
Runtime->>Kernel: Normalized Request
Kernel->>Pipeline: Create RequestContext
Pipeline->>Pipeline: Execute middleware chain
Note over Pipeline: 1. LoggingMiddleware
Note over Pipeline: 2. RequestIdMiddleware
Note over Pipeline: 3. CorsMiddleware
Note over Pipeline: 4. SecurityHeadersMiddleware
Note over Pipeline: 5. AuthMiddleware
Note over Pipeline: 6. ValidationMiddleware
Pipeline->>Router: Match route
Router->>Router: Extract params
Router->>Handler: Call handler with context
Handler->>Services: Resolve services
Services-->>Handler: Return service instances
Handler->>Handler: Execute business logic
Handler-->>Router: Return response
Router-->>Pipeline: Response
Pipeline->>Pipeline: Execute response middleware
Note over Pipeline: 7. ResponseInterceptors
Note over Pipeline: 8. MetricsMiddleware
Note over Pipeline: 9. ErrorHandler (if error)
Pipeline-->>Kernel: Final response
Kernel-->>Runtime: Send response
Runtime-->>Client: HTTP Response
Detailed Flow
-
Incoming Request — A client sends an HTTP request to the server.
-
Runtime Adapter — The runtime adapter (Node, Deno, or Bun) receives the raw HTTP request and converts it to the framework’s
IRequestabstraction. This normalizes the request across runtimes. -
Kernel — The kernel receives the normalized request and creates a
RequestContext. The context provides access to:- The request and response objects.
- The service registry (for resolving services).
- A state map (for passing data between middleware).
- The request ID and correlation ID.
-
Middleware Pipeline — The kernel passes the context through the middleware pipeline. Middleware executes in priority order:
- Logging — Logs the incoming request.
- Request ID — Generates or propagates a unique request ID.
- CORS — Handles preflight requests.
- Security Headers — Adds security headers to the response.
- Authentication — Extracts and validates credentials, populates
ctx.request.user. - Authorization — Checks roles and permissions.
- Validation — Validates the request body, query, params, headers against Zod schemas.
-
Router — After middleware, the pipeline calls the router to match the request path and method to a registered route. The router extracts path parameters.
-
Route Handler — The matched route’s handler is called with the context. The handler:
- Resolves services from the service registry via
ctx.services.get<T>(token). - Executes business logic (calling services, repositories, etc.).
- Returns a response via
ctx.response.json(),ctx.response.send(), etc.
- Resolves services from the service registry via
-
Response Middleware — After the handler returns, the pipeline executes response-phase middleware:
- Response Interceptors — Transform the response (e.g., wrap in a standard envelope).
- Metrics — Record request duration and status code.
- Error Handler — If any middleware or handler threw an error, the error handler catches it and formats a standardized error response.
-
Response — The kernel sends the final response back through the runtime adapter to the client.
Short-Circuiting
Any middleware can short-circuit the pipeline by sending a response without calling next(). For
example, the auth middleware can return a 401 response if authentication fails, and the pipeline
stops — the router and handler are never called.
Error Propagation
If any middleware or handler throws an error, the pipeline catches it and passes it to the error handler middleware. The error handler:
- Logs the error (if a logger is available).
- Formats the error as a standardized response (RFC 9457 Problem Details when configured).
- Sends the error response to the client.
5. Plugin Architecture
Plugin Lifecycle
stateDiagram-v2
[*] --> Discovered
Discovered --> Sorted: Dependencies resolved
Sorted --> Registered: register() called
Registered --> Initialized: onInit hooks
Initialized --> Bootstrapped: onBootstrap hooks
Bootstrapped --> Active: Server listening
Active --> ShuttingDown: Shutdown signal
ShuttingDown --> Shutdown: onShutdown hooks
Shutdown --> Closed: onClose hooks
Closed --> [*]
Plugin Registration Flow
sequenceDiagram
participant App
participant Kernel
participant Registry as Plugin Registry
participant Services as Service Registry
participant Pipeline
participant Router
App->>Kernel: register(plugin)
Kernel->>Registry: add(plugin)
Registry->>Registry: Store plugin metadata
Note over Kernel: When app.start() is called:
Kernel->>Registry: Resolve dependencies
Registry->>Registry: Topological sort
Registry->>Registry: Detect cycles
Registry-->>Kernel: Ordered plugin list
loop For each plugin in order
Kernel->>Registry: plugin.register(ctx)
Registry->>Services: Register services
Registry->>Pipeline: Add middleware
Registry->>Router: Add routes
Registry->>Services: Register health checks
Registry->>Services: Register metrics
Registry->>Services: Register CLI commands
end
Kernel->>Pipeline: Build pipeline
Kernel->>App: Ready
Dependency Resolution
Plugins declare dependencies:
{
name: 'database-plugin',
dependencies: ['logger', 'config'],
optionalDependencies: ['metrics'],
provides: ['database'],
consumes: ['logger', 'config'],
}
The plugin registry:
- Builds a dependency graph from all registered plugins.
- Performs topological sort to determine registration order.
- Detects circular dependencies and throws an error.
- Validates that all required dependencies are present.
- Optional dependencies that are absent do not cause an error.
Capability Tokens
Capability tokens are strings that identify a capability, not a concrete type:
// Standard tokens defined in @setu-ts/common
const CAPABILITIES = {
LOGGER: 'logger',
DATABASE: 'database',
CACHE: 'cache',
AUTH: 'authentication',
// ...
};
// Plugin A provides a capability
ctx.services.register(CAPABILITIES.DATABASE, new DatabaseService());
// Plugin B consumes the capability
const db = ctx.services.get<IDatabaseService>(CAPABILITIES.DATABASE);
Why tokens instead of class references?
- Decoupling — Plugin B does not import Plugin A’s code.
- Replaceability — Any plugin can provide the same token.
- Testing — Tests register a mock with the same token.
- Tree-shaking — No import dependencies between plugins.
- Multi-provider — Multiple plugins can provide the same token (e.g., multiple database connections).
Plugin Ordering
Plugins are ordered by:
- Dependency order — A plugin’s dependencies must be registered first.
- Priority — Within the same dependency level, lower priority numbers register first.
- Registration order — Within the same priority, registration order is preserved.
Plugin Replacement
Any plugin can be replaced by registering a new plugin with the same name and provides tokens:
// Replace the default database plugin
app.register({
name: 'database', // Same name
version: '1.0.0',
provides: ['database'],
register(ctx) {
ctx.services.register('database', new MyCustomDatabase(), { override: true });
},
});
Optional Plugins
Plugins can declare optional dependencies:
{
name: 'audit-plugin',
dependencies: ['logger'],
optionalDependencies: ['database', 'messaging'],
register(ctx) {
const logger = ctx.services.get('logger');
// Check if optional dependencies are available
if (ctx.services.has('database')) {
const db = ctx.services.get('database');
// Use database for audit storage
}
if (ctx.services.has('messaging')) {
const broker = ctx.services.get('messaging');
// Publish audit events to message broker
}
},
}
Plugin Communication
Plugins never communicate directly. They communicate through:
| Mechanism | Use Case |
|---|---|
| Service Registry | Resolve a capability provided by another plugin |
| Middleware | Observe or modify requests/responses |
| Event Bus | Publish/subscribe to domain events |
| Message Broker | Publish/subscribe to integration events |
| Lifecycle Hooks | React to application lifecycle events |
Lifecycle Hooks
Plugins register lifecycle hooks via ctx.lifecycle:
| Hook | When | Use Case |
|---|---|---|
onRegister | Plugin registration | Initialize plugin state |
onInit | All plugins registered | Validate cross-plugin state |
onBootstrap | Server about to start | Final preparation |
onRequest | Every request | Per-request setup |
onResponse | Every response | Per-response cleanup |
onError | Unhandled error | Error logging, alerting |
onStopping | stop() begins, still serving | Deregister from a service registry |
onShutdown | Application shutting down | Close connections, flush buffers |
onClose | Application closed | Final cleanup |
onStopping is the only hook that runs while the application is still answering requests normally:
it fires at the very start of stop(), before the application begins refusing new requests with
a 503 and before the socket closes. That ordering is what makes it the right home for telling the
outside world to stop routing here — deregistering from service discovery, for instance.
Deregistering in onShutdown instead means the socket is already closed by the time the registry
hears about it, so callers keep being routed at a dead port for up to one health-check interval on
every rolling deploy. Hooks run LIFO and are awaited, so a slow hook delays shutdown and a rejecting
one surfaces from stop() — but only after the rest of the shutdown has run, so a failing hook
cannot leave an application that keeps serving and can never be stopped; with no hook registered the
phase is skipped entirely and stop() behaves exactly as it did before.
Extension Points
Every plugin must expose extension points so other plugins and application code can extend its behavior:
| Extension Point | API | Example |
|---|---|---|
| Custom service | ctx.services.register(token, service, { override: true }) | Replace the default logger |
| Custom middleware | ctx.middleware.add(fn, { priority }) | Add a request timing middleware |
| Custom route | ctx.router.get(path, handler) | Add a health check route |
| Custom health check | ctx.health.register(name, indicator) | Add a custom health indicator |
| Custom metric | ctx.metrics.register(name, config) | Add a custom Prometheus metric |
| Custom CLI command | ctx.cli.register(name, handler) | Add a custom CLI command |
| Custom OpenAPI | ctx.openapi.addSchema(name, schema) | Add a schema to the OpenAPI spec |
| Custom decorator | ctx.decorators.register(name, handler) | Add a custom decorator |
| Custom lifecycle | ctx.lifecycle.onX(fn) | React to lifecycle events |
| Custom env validation | ctx.environment.validate(spec) | Validate environment variables |
6. Service Registry
The Service Registry is the primary service resolution mechanism in the framework. It maps capability tokens to service instances.
Registration
import { CAPABILITIES } from '@setu-ts/common';
// Register a service under a standard capability token
ctx.services.register(CAPABILITIES.DATABASE, new DatabaseService());
// Register with options (RegisterOptions has only `override` and `multi`)
ctx.services.register(CAPABILITIES.DATABASE, new DatabaseService(), {
override: false, // Throw if already registered (default)
multi: false, // Single provider (default)
});
// Lazy construction: the factory runs only on the first get()
ctx.services.registerFactory(CAPABILITIES.DATABASE, () => new DatabaseService());
Capability Lookup
// Get a service (throws if not found)
const db = ctx.services.get<IDatabaseService>(CAPABILITIES.DATABASE);
// Check if a capability is available
if (ctx.services.has(CAPABILITIES.CACHE)) {
const cache = ctx.services.get<ICacheStore>(CAPABILITIES.CACHE);
}
// Get all services for a multi-provider token
const allNotifiers = ctx.services.getAll<INotifier>(CAPABILITIES.NOTIFICATION);
Multi-Provider Support
Some capabilities can have multiple providers:
// Plugin A
ctx.services.register(CAPABILITIES.NOTIFICATION, new EmailNotifier(), { multi: true });
// Plugin B
ctx.services.register(CAPABILITIES.NOTIFICATION, new SmsNotifier(), { multi: true });
// Consumer
const notifiers = ctx.services.getAll<INotifier>(CAPABILITIES.NOTIFICATION);
// Returns [EmailNotifier, SmsNotifier]
Override Rules
// First registration
ctx.services.register(CAPABILITIES.LOGGER, new PinoLogger());
// Override (explicit)
ctx.services.register(CAPABILITIES.LOGGER, new ConsoleLogger(), { override: true });
// Without override flag, throws an error
ctx.services.register(CAPABILITIES.LOGGER, new ConsoleLogger());
// Error: Capability 'logger' is already registered. Use { override: true } to replace.
Lazy Services
// Factory is called only on first get()
ctx.services.registerFactory(CAPABILITIES.DATABASE, () => {
const config = ctx.services.get<IConfig>(CAPABILITIES.CONFIG);
return new DatabaseService({ url: config.get('DATABASE_URL') });
});
// Factory is not called yet
// ...
// First get() triggers factory
const db = ctx.services.get<IDatabaseService>(CAPABILITIES.DATABASE); // Factory called here
// Subsequent get() returns cached instance
const db2 = ctx.services.get<IDatabaseService>(CAPABILITIES.DATABASE); // Same instance
Service Scopes
The Service Registry supports two scopes:
| Scope | Behavior |
|---|---|
| Application | Service is created once and shared across all requests. Default. |
| Request | Service is created per request via ctx.services.register() in request middleware. |
Request-scoped services are registered on the request context, not the global registry:
app.middleware.add(async (ctx, next) => {
// Register a request-scoped service
ctx.services.register('request-logger', logger.child({ requestId: ctx.id }));
await next();
});
Registry Sealing
The application registry is sealed immediately after runBootstrap(). Its register,
registerFactory, and unregister methods throw after that point; request contexts retain an
unsealed child registry for request-scoped services. An override: true and a successful
unregister during startup are reported through the logger capability.
Thread Safety Considerations
The Service Registry is not thread-safe by design. JavaScript is single-threaded (event loop), so concurrent access is not an issue. However:
- Registration is enforced during bootstrap: the application registry seals after
runBootstrap(), so post-bootstrap mutations throw. - Request-scoped registration is safe because each request has its own context.
- Lazy factories are safe because JavaScript is single-threaded — the factory is called at most once.
7. Runtime Layer
Why Runtime Abstraction Exists
Different JavaScript runtimes have different APIs for the same operations:
| Operation | Node.js | Deno | Bun |
|---|---|---|---|
| UUID | crypto.randomUUID() | crypto.randomUUID() | crypto.randomUUID() |
| Random bytes | crypto.randomBytes() | crypto.getRandomValues() | crypto.randomBytes() |
| High-res time | process.hrtime() | performance.now() | performance.now() |
| Environment | process.env | Deno.env | Bun.env |
| File system | fs module | Deno.readFile() | Bun.file() |
| HTTP server | http.createServer() | Deno.serve() | Bun.serve() |
Without abstraction, every plugin would need runtime-specific code paths. The Runtime Layer eliminates this by providing a single interface.
IRuntimeServices
All runtime-specific operations are abstracted behind IRuntimeServices:
interface IRuntimeServices {
platform(): RuntimePlatform;
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;
}
The optional members are capabilities a runtime may not have. Cloudflare Workers omits all three —
dns in particular because its network access is fetch, which resolves names internally and
exposes no lookup surface. An adapter omits an absent key entirely rather than assigning
undefined to it, which exactOptionalPropertyTypes requires and which lets a consumer distinguish
“not supported here” with a plain in check.
Runtime Adapters
graph LR
subgraph RuntimePlugin
Detector[Runtime Detector]
Services[IRuntimeServices]
HttpAdapter[HTTP Adapter]
end
subgraph Adapters
Node[NodeRuntimeServices]
Deno[DenoRuntimeServices]
Bun[BunRuntimeServices]
CF[CloudflareRuntimeServices]
end
Detector --> Node
Detector --> Deno
Detector --> Bun
Detector --> CF
Node --> Services
Deno --> Services
Bun --> Services
CF --> Services
Services --> HttpAdapter
Runtime Is Mandatory
A runtime provider is the one plugin every application must register. createApplication() fails
fast at startup if no registered plugin provides the runtime capability, and the kernel always
registers the runtime-providing plugin first, regardless of declared priority. This is why
ctx.runtime on IPluginContext is non-optional — every other plugin can rely on it being present
during its own register() call.
Auto-Detection
The RuntimePlugin also provides CAPABILITIES.LOCAL_DIAGNOSTICS_LISTENER (M98b): a runtime-owned
factory that binds exactly one additional IPv4-loopback listener for the local diagnostics
connector. The application’s own IHttpAdapter is stateful and cannot serve a second handler, so
this factory owns a private server through an injectable Deno serve or Node HTTP seam — and exposes
no adapter, host, or handle to its consumer, which supplies only a normalized-request protocol
handler. Bun, Workers and unknown platforms refuse every listen before any bind, and the plugin
registers a close hook so shutdown and failed-startup paths release the port.
The RuntimePlugin auto-detects the runtime at startup:
function detectRuntime(): RuntimePlatform {
if (typeof Deno !== 'undefined') return 'deno';
if (typeof Bun !== 'undefined') return 'bun';
if (typeof caches !== 'undefined' && navigator.userAgent?.includes('cloudflare')) {
return 'cloudflare-workers';
}
return 'node';
}
HTTP Server Abstraction
The Runtime Plugin also provides HTTP server adapters implementing the IHttpAdapter interface:
interface IHttpAdapter {
setHandler(handler: (request: IRequest) => Promise<IResponse>): void;
fetch(request: Request): Promise<Response>;
listen(port: number, hostname?: string): Promise<ServerHandle>;
close(handle: ServerHandle): Promise<void>;
/** Optional: gRPC/Connect RPC interceptor. Consulted after WebSocket upgrade short-circuit and before body mapping. */
setRpcHandler?(handler: RpcFetchHandler): void;
/** Optional: WebSocket upgrade router. Consulted before body mapping. */
setUpgradeRouter?(router: WebSocketUpgradeRouter): void;
}
The contract is web-fetch-centric: setHandler installs the framework handler, fetch is the
universal entry point callable without listen (Cloudflare Workers), listen binds a real TCP
socket, and close tears it down.
Each runtime has its own implementation:
- NodeHttpAdapter — Uses
@hono/node-serverserve(). - DenoHttpAdapter — Uses
Deno.serve(). - BunHttpAdapter — Uses
Bun.serve(). - CloudflareWorkersHttpAdapter — Exports
fetchonly;listenthrows.
Node, Deno and Bun supply the socket peer to the shared mapping as IRequest.ip (M113n),
normalizing IPv4-mapped IPv6. Workers and plain fetch calls have no peer. Behind a proxy the peer is
the proxy; IP security checks it against trustedProxies before walking the forwarded chain, then
publishes the client IP and public origin. Session cookies, CSRF and the React Router bridge read
that origin through the common accessor.
Why Only Runtime May Use Runtime-Specific APIs
This rule is enforced because:
- Portability — If a plugin uses
process.envdirectly, it breaks on Deno and Bun. - Testability — Runtime services can be mocked in tests.
- Consistency — All plugins use the same API, regardless of runtime.
- Future-proofing — New runtimes only need a new adapter, not changes to every plugin.
Future Runtimes
Adding a new runtime requires:
- Implement
IRuntimeServicesfor the new runtime. - Implement
IHttpAdapterfor the new runtime. - Add the runtime to the detector.
- No changes to any other package.
This is the power of the adapter pattern — the framework is open for extension but closed for modification.
8. Package Architecture
Toolchain and Distribution
The monorepo is developed with the Deno toolchain (Deno 2 workspaces;
deno check/test/lint/fmt). Packages are published to JSR under the @setu-ts scope with
no build step — TypeScript sources are published directly. Node and Bun consumers install through
JSR’s npm compatibility layer, which transforms packages to standard ESM npm artifacts.
Cross-runtime support is therefore a distribution property (JSR) plus a code property (runtime
abstraction, §7) — CI verifies both by running the test suite under Deno and a compat suite under
Node and Bun.
Package Overview
graph TB
subgraph Foundation
common[common]
kernel[kernel]
runtime[runtime]
end
subgraph Optional Core
di[di-plugin]
decorator[decorator-plugin]
end
subgraph Infrastructure Plugins
database[database-plugin]
cache[cache-plugin]
messaging[messaging-plugin]
queue[queue-plugin]
storage[storage-plugin]
service-discovery[service-discovery-plugin]
cloudflare[cloudflare-plugin]
end
subgraph Security Plugins
auth[auth-plugin]
http-sec[http-security-plugin]
secrets[secrets-plugin]
end
subgraph Application Plugins
events[events-plugin]
cqrs[cqrs-plugin]
scheduler[scheduler-plugin]
end
subgraph Observability Plugins
logger[logger-plugin]
metrics[metrics-plugin]
health[health-plugin]
telemetry[telemetry-plugin]
audit[audit-plugin]
end
subgraph Communication Plugins
mail[mail-plugin]
notification[notification-plugin]
end
subgraph Feature Plugins
feature-flags[feature-flags-plugin]
idempotency[idempotency-plugin]
multi-tenancy[multi-tenancy-plugin]
resilience[resilience-plugin]
session[session-plugin]
end
subgraph Realtime Plugins
sse[sse-plugin]
websocket[websocket-plugin]
realtime[realtime-backplane-plugin]
end
subgraph Platform Plugins
graphql[graphql-plugin]
grpc[grpc-plugin]
react-router[react-router-plugin]
static[static-plugin]
view[view-plugin]
localization[localization-plugin]
worker-pool[worker-pool-plugin]
end
subgraph Developer Tools
validation[validation-plugin]
config[config-plugin]
openapi[openapi-plugin]
cli[cli]
testing[testing]
sdk[sdk]
exceptions[exceptions]
end
subgraph Starters
rest-starter[rest-starter]
microservice-starter[microservice-starter]
full-stack-starter[full-stack-starter]
end
common --> kernel
common --> runtime
kernel --> runtime
common --> di
kernel --> di
common --> decorator
kernel --> decorator
common --> logger
kernel --> logger
common --> config
kernel --> config
common --> validation
kernel --> validation
common --> database
kernel --> database
common --> cache
kernel --> cache
common --> events
kernel --> events
common --> cqrs
kernel --> cqrs
common --> messaging
kernel --> messaging
common --> queue
kernel --> queue
common --> auth
kernel --> auth
common --> http-sec
kernel --> http-sec
common --> scheduler
kernel --> scheduler
common --> metrics
kernel --> metrics
common --> health
kernel --> health
common --> openapi
kernel --> openapi
common --> telemetry
kernel --> telemetry
common --> secrets
kernel --> secrets
common --> audit
kernel --> audit
common --> resilience
kernel --> resilience
common --> storage
kernel --> storage
common --> mail
kernel --> mail
common --> notification
kernel --> notification
common --> feature-flags
kernel --> feature-flags
common --> multi-tenancy
kernel --> multi-tenancy
common --> idempotency
common --> service-discovery
common --> cloudflare
common --> session
kernel --> session
common --> graphql
kernel --> graphql
common --> grpc
kernel --> grpc
common --> sse
kernel --> sse
common --> websocket
kernel --> websocket
common --> realtime
kernel --> realtime
common --> react-router
kernel --> react-router
common --> static
kernel --> static
common --> view
kernel --> view
common --> localization
kernel --> localization
common --> worker-pool
kernel --> worker-pool
common --> rest-starter
kernel --> rest-starter
common --> microservice-starter
kernel --> microservice-starter
common --> full-stack-starter
kernel --> full-stack-starter
```
### Package Details
#### @setu-ts/common
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| **Purpose** | Shared types, interfaces, and capability tokens |
| **Responsibilities** | Define all public interfaces; define capability token constants; provide shared utility types |
| **Dependencies** | None |
| **Public API** | All interfaces (`IPlugin`, `IPluginContext`, `ILogger`, `IConfig`, `IDatabaseService`, etc.); `CAPABILITIES` constant; utility types |
| **Extension Points** | N/A (types only) |
| **Rules** | Zero dependencies; no runtime behavior beyond constants and pure type utilities |
#### @setu-ts/kernel
| Aspect | Detail |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Plugin orchestration and request processing |
| **Responsibilities** | Plugin registry; service registry; middleware pipeline; router; application lifecycle; request context |
| **Dependencies** | `common` only |
| **Public API** | `createApplication()`; `Application`; `ServiceRegistry`; `RouterApi`; `MiddlewareApi` (consumes `IPlugin`/`IPluginContext` from `common`) |
| **Extension Points** | Custom plugins; custom middleware; custom routes; service override |
| **Rules** | No runtime-specific APIs; no decorator support; no DI; zero features beyond orchestration |
#### @setu-ts/runtime
| Aspect | Detail |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| **Purpose** | Runtime abstraction layer |
| **Responsibilities** | Provide `IRuntimeServices` for Node, Deno, Bun; provide HTTP server adapters; runtime auto-detection |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `RuntimePlugin()`; `IRuntimeServices`; `IHttpAdapter`; `detectRuntime()` |
| **Extension Points** | Custom runtime adapter (for new runtimes); custom HTTP adapter |
| **Rules** | The only package allowed to use runtime-specific APIs |
#### @setu-ts/di-plugin
| Aspect | Detail |
| -------------------- | ----------------------------------------------------------------------------------------------------------- |
| **Purpose** | Optional dependency injection container |
| **Responsibilities** | Provide DI container with singleton, scoped, transient lifecycles; constructor injection; factory providers |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `DiPlugin()`; `IContainer`; `ContainerBuilder` |
| **Extension Points** | Custom provider types; custom scopes |
| **Rules** | Optional; no other plugin depends on it; services resolve from ServiceRegistry, not container |
#### @setu-ts/decorator-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Optional decorator and metadata system |
| **Responsibilities** | Store decorator metadata in plain objects; read metadata and register routes, services, middleware, and explicit non-HTTP ingress classes through public capabilities |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `DecoratorPlugin()`; `@Controller`, `@Get`, `@Post`, etc.; `@Injectable`, `@Inject`; `@Params(...)` with the `Body`/`Query`/`Param` sources; `createDecorator()` |
| **Extension Points** | Custom decorators via `createDecorator()`; custom parameter sources via `Custom()` |
| **Rules** | Optional; no reflection required; metadata stored in plain objects; decorators are syntactic sugar over programmatic API |
#### @setu-ts/logger-plugin
| Aspect | Detail |
| -------------------- | ----------------------------------------------------------------------------------------------------------- |
| **Purpose** | Structured logging |
| **Responsibilities** | Provide `ILogger` implementations (Pino, Console, Noop); request logging middleware; slow request detection; redaction of log metadata |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `LoggerPlugin()`; `ILogger` |
| **Extension Points** | Custom logger implementation (override `logger` token); custom log formatters |
| **Rules** | Pino is optional (injected or lazy-loaded via a bare specifier); ConsoleLogger works on all runtimes |
#### @setu-ts/config-plugin
| Aspect | Detail |
| -------------------- | -------------------------------------------------------------------------------------------------- |
| **Purpose** | Configuration management |
| **Responsibilities** | Load environment variables; parse `.env` files; validate config with Zod; provide type-safe access |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `ConfigPlugin()`; `IConfig` |
| **Extension Points** | Custom config sources (override `config` token); custom env file parsers |
| **Rules** | Environment access via `runtime.env`, not `process.env` |
#### @setu-ts/validation-plugin
| Aspect | Detail |
| -------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Request validation with Zod |
| **Responsibilities** | Validate body, query, params, headers, cookies; sanitize input; format validation errors (RFC 9457, default, custom) |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `ValidationPlugin()`; `IValidationService`; `validateBody()`, `validateQuery()`, `validateParams()` |
| **Extension Points** | Custom error formatters; custom sanitization rules |
| **Rules** | Zod is the source of truth for validation; schemas are shared with OpenAPI plugin |
#### @setu-ts/exceptions
| Aspect | Detail |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Exception types and global error handling |
| **Responsibilities** | `HttpError` type; factory functions (`badRequest()`, `notFound()`, etc.); error handler middleware; RFC 9457 formatting |
| **Dependencies** | `common` only |
| **Public API** | `HttpError`; exception factory functions; `errorHandler()` |
| **Extension Points** | Custom error formatters |
| **Rules** | Plain package, not a plugin; composition (factories) over inheritance; registered by the application, not the kernel |
#### @setu-ts/database-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Database access with repository pattern |
| **Responsibilities** | Provide `IDatabaseService`; repository pattern including portable expression filters and `findOne`; unit of work (transactions); backend adapters (Memory, Prisma, Drizzle, MongoDB, DynamoDB, Cosmos DB, Cloud Bigtable) |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `DatabasePlugin()`; `IDatabaseService`; `IRepository`; `IUnitOfWork`; Drizzle-specific `createDrizzleDatabase()`, `getDrizzleDatabase()`, and `getDrizzleTransaction()` |
| **Extension Points** | Custom ORM adapter; custom repository; custom transaction strategy |
| **Rules** | Applications inject Prisma clients and opaque configured Drizzle identities with explicit Promise-aware transaction bridges; only Drizzle operators are lazy-loaded from bare specifiers. Portable repository operations stay backend-neutral, while released raw `query()` and distinct typed outer/transaction Drizzle accessors are explicit backend-specific escape hatches. Memory remains the zero-dependency default. Cloud Bigtable is the wide-column arm: it has no secondary index, so its row key — one lexicographically-sorted string composed from logical fields by the per-entity mapping — is the only ordering, and every non-key predicate is evaluated by the same portable evaluator the memory reference uses. |
#### @setu-ts/cache-plugin
| Aspect | Detail |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Caching abstraction with transparent response-caching middleware |
| **Responsibilities** | Provide `ICacheStore`; Memory, Redis, Noop stores; `CacheService`; `cacheMiddleware`; TTL management |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `CachePlugin()`, `CacheService`, `MemoryStore`, `RedisStore`, `NoopStore`, `cacheMiddleware`, types in `src/interfaces/index` |
| **Extension Points** | Custom cache store (override `cache` token); custom key generators; named multi-cache instances |
| **Rules** | Redis client is optional (injected or lazy-loaded via a bare specifier); Memory store default; `ICacheStore` has 5 methods |
#### @setu-ts/events-plugin
| Aspect | Detail |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | In-memory event bus for domain events |
| **Responsibilities** | Publish/subscribe domain events; event handler registration; error handling |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `EventsPlugin()`; `IEventBus`; `DomainEvent`; `IntegrationEvent`; `defineDomainEvent`; `IDomainEvents`; `createDomainEvents`; `IEventHandler`; `subscribeHandler` |
| **Extension Points** | Custom event bus (override `events` token); custom event handlers |
| **Rules** | In-memory only; `IDomainEvents` records aggregate-local facts without publishing; for distributed events, use messaging plugin |
#### @setu-ts/cqrs-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Purpose** | Command Query Responsibility Segregation |
| **Responsibilities** | Command bus; query bus; pipeline behaviors; handler registration |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `CqrsPlugin()`; `ICommandBus`; `IQueryBus`; `ICqrsFacade`; `ICommandHandler`; `IQueryHandler`; `IPipelineBehavior`; `CqrsRequest`; `CqrsCommand`; `CqrsQuery`; `CommandBus`; `QueryBus`; `HandlerNotFoundError` (contracts owned by `common`, re-exported by the plugin) |
| **Extension Points** | Custom pipeline behaviors; custom bus implementations |
| **Rules** | Optional; consumes the `events` capability via token for event sourcing (optional) |
#### @setu-ts/messaging-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Message broker abstraction for cross-service integration events |
| **Responsibilities** | Provide `IMessageBroker`; in-memory, Redis Streams, RabbitMQ, NATS, Kafka, GCP Pub/Sub, Azure Service Bus, and custom-injected broker adapters; serializer interface (`ISerializer`); `EventsMessagingBridge`; request-reply via `RequestReplyCore` |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `MessagingPlugin()`; `EventsMessagingBridge()`; `InMemoryBroker`; `RedisStreamsBroker`; `RabbitMqBroker`; `NatsBroker`; `KafkaBroker`; `GcpPubSubBroker`; `ServiceBusBroker`; `JsonSerializer`; `IMessageBroker` (re-exported from `common`) |
| **Extension Points** | Custom broker adapter; custom serializers; custom SDK client injection; custom transport adapters |
| **Rules** | Cloud SDKs are optional (injected or lazy-loaded via a bare specifier); in-memory broker default for testing; decoupled from events plugin; named instances via `name` option; cloud brokers require provider credentials at runtime |
#### @setu-ts/queue-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Purpose** | Background job queue |
| **Responsibilities** | Add/process jobs; retry strategies; recurring jobs; concurrency control; Memory/Redis/RabbitMQ/SQS adapters; AWS SNS fan-out publisher; cloud DLQ promotion |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `QueuePlugin()`; `MemoryQueue`; `RedisQueue`; `RabbitMqQueue`; `SqsQueue`; `SnsPublisher`; `IQueue` (re-exported from `common`) |
| **Extension Points** | Custom queue adapter via internal `QueueAdapter` seam; custom retry strategies; custom SQS transport injection |
| **Rules** | Redis client is optional (injected or lazy-loaded via a bare specifier); Memory queue for testing; RabbitMQ adapter (polling via basicGet, TTL+DLX for delays); SQS adapter via AWS SDK `@aws-sdk/client-sqs` (injected or lazy-loaded); SNS publisher via AWS SDK `@aws-sdk/client-sns` (injected or lazy-loaded); DLQ forwards original body then deletes source |
**Architecture Notes:** The `QueuePlugin` factory registers a `QueueService` implementing the
`IQueue` contract under the `queue` capability token (or `queue.<name>` for named instances). The
service owns the backend-agnostic machinery (worker poll loop, retry with exponential backoff,
cron-driven recurring scheduling) and delegates storage to an internal `QueueAdapter` transport
seam. `MemoryQueue` and `RedisQueue` (ioredis-based delayed queue using Redis sorted sets) implement
this seam in M15. `RabbitMqQueue` (implemented in M15b) uses polling via `basicGet` over a ready
queue and per-message TTL + dead-letter-exchange for delayed enqueue/requeue. `SqsQueue` (M54) uses
AWS SQS with visibility timeouts, envelope-based retry tracking, and DLQ promotion when
`maxAttempts` is reached — the DLQ adapter forwards the original message body to the DLQ queue URL
then deletes the source message, with separate diagnostics for send vs delete failures. The
`SnsPublisher` export is a separate AWS SNS fan-out helper (not a `QueueAdapter`): it publishes to a
single topic and reuses the same inject-or-lazy `@aws-sdk/client-sns` SDK seam as `SqsQueue`.
#### @setu-ts/idempotency-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Purpose** | One idempotency core that answers a repeat of completed work from its record (or skips it), reachable from HTTP, ingress and plain code. It deduplicates; it cannot make a handler's external side effects atomic with the record |
| **Responsibilities** | The `claim`/`complete`/`release` state machine; memory, Redis (three Lua scripts) and Cloudflare Durable Object stores; the HTTP `idempotent()` middleware; the `idempotentIngress()` behaviour; the `idempotency` health indicator |
| **Dependencies** | `common` only — ioredis is injected or lazy-loaded, and the Durable Object store lives in `cloudflare-plugin` because no plugin imports a plugin |
| **Public API** | `IdempotencyPlugin()`; `idempotent()`; `derivedIdempotencyKey()`; `idempotentIngress()`; `IdempotencyRefusedError`; `IdempotencyConfigurationError`; `IDEMPOTENCY_KEY_HEADER`; `IDEMPOTENT_REPLAYED_HEADER`; and the types `IdempotencyPluginOptions`, `IdempotencyStoreConfig`, `IRedisIdempotencyClient`, `IdempotencyRefusalReason`. The stores and `IIdempotencyService`/`IIdempotencyStore` are internal or owned by `common`, not barrel exports |
| **Extension Points** | A custom `IIdempotencyStore` through the `store` option; the `scope`, `key` and `fingerprint` callbacks; `@Idempotent()` in `decorator-plugin` resolves the same capability token |
| **Rules** | `idempotent()` and `@Idempotent()` are appended AFTER guards and validation, so a refused request consumes no key; the Redis tier owns its client rather than widening `ICacheStore`; tier A is per process, so Workers HTTP uses the Durable Object store |
#### @setu-ts/auth-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Authentication and authorization (JWT, API key, local credentials; RBAC; route guards) |
| **Responsibilities** | JWT service (HS256/RS256) for self-issued tokens; an `issuers` strategy verifying outside-provider access tokens against their published, rotating key sets (RS256/PS256/ES256/ES384/EdDSA) — a strategy, not a service, so `IJwtService` stays self-issued only; API-key + local credential strategies; RBAC with role hierarchy; permission checks; short-circuiting guards; PBKDF2 password hashing |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `AuthPlugin()`; `IAuthService`; `IJwtService`; `IAuthorizationService`; `authMiddleware()`; `PasswordHasher`; guards `requireAuth()`, `requireRole()`, `requirePermission()`, `requireAnyRole()`, `requireAllPermissions()`, `publicRoute()` |
| **Extension Points** | Custom `IAuthStrategy` strategies; RBAC role/permission models; app-supplied `apiKey.validate` / `local.verify` |
| **Rules** | Zero npm deps — JWT (HS256/RS256) and PBKDF2-SHA256 via Web Crypto (`runtime.subtle`/`runtime.randomBytes`); no JWT library. `jwt` and `authentication` always register; `authorization` registers only when `rbac` is supplied. `ctx.request.user` is the writable principal field; guards short-circuit (no `next()`) on 401/403. Refresh tokens (`RefreshTokenService`) + rate limiting (`rateLimitMiddleware`) shipped in M16b as standalone additions — no new tokens |
#### @setu-ts/http-security-plugin
| Aspect | Detail |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | HTTP transport security |
| **Responsibilities** | CORS; security headers; CSRF; request size limiting; IP security |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `HttpSecurityPlugin()`; `corsMiddleware()`; `securityHeadersMiddleware()`; `csrfMiddleware()`; `requestSizeMiddleware()`; `ipSecurityMiddleware()` |
| **Extension Points** | Custom security headers; custom CORS policies; custom CSRF trusted origins |
| **Rules** | Secure defaults: security headers on by default; CORS/CSRF/request-size/IP-security are opt-in via their option blocks, each secure-by-default when enabled |
#### @setu-ts/scheduler-plugin
| Aspect | Detail |
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Job scheduling |
| **Responsibilities** | Cron jobs; delayed jobs; recurring jobs; retry with backoff; distributed locking |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `SchedulerPlugin()`; `IScheduler` |
| **Extension Points** | Custom distributed lock implementation; custom cron parser |
| **Rules** | Distributed locking for multi-instance deployments; Redis for lock storage (optional, injected or lazy-loaded) |
#### @setu-ts/metrics-plugin
| Aspect | Detail |
| -------------------- | ----------------------------------------------------------------------------------------------- |
| **Purpose** | Prometheus metrics |
| **Responsibilities** | Counter, gauge, histogram, summary; metrics registry; Prometheus rendering; built-in collectors |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `MetricsPlugin()`; `IMetricsService` |
| **Extension Points** | Custom metric collectors; custom renderers |
| **Rules** | Prometheus format only; OpenMetrics support in future |
#### @setu-ts/health-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------ |
| **Purpose** | Health check endpoints |
| **Responsibilities** | `/health`, `/live`, `/ready` endpoints; health indicator registration; built-in indicators |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `HealthPlugin()`; `IHealthService`; `IHealthIndicator` |
| **Extension Points** | Custom health indicators |
| **Rules** | Health indicators depend on capability tokens, not concrete packages |
#### @setu-ts/openapi-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------- |
| **Purpose** | OpenAPI documentation generation |
| **Responsibilities** | Generate OpenAPI spec from route schemas; Zod to OpenAPI conversion; Swagger UI |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `OpenApiPlugin()`; `IOpenApiService` |
| **Extension Points** | Custom schema transformers; custom UI |
| **Rules** | Zod is the source of truth; no duplicated schemas; spec deduplication |
#### @setu-ts/telemetry-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | OpenTelemetry distributed tracing |
| **Responsibilities** | Tracer provider; span management; context propagation; request-span middleware; safe request URL attributes |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `TelemetryPlugin()`; `telemetryMiddleware`; `TELEMETRY_SPAN_KEY`; `NoopTelemetryService`; `InstrumentationsConfig`/`InstrumentationConfig`/`InstrumentationKind`/`SpanProcessorKind` |
| **Extension Points** | Custom exporters; injectable `tracerProviderFactory`; injectable per-instrumentation instances (`InstrumentationConfig.instrumentation`) |
| **Rules** | OpenTelemetry SDK is optional (injected or lazy-loaded via a bare specifier); noop default when no exporter; middleware priority 30; `TracerProviderFactory` injection seam for tests; auto-instrumentation (M24b) is runtime-gated (Node-only kinds `http`/`fetch`/`ioredis`/`amqplib`/`kafkajs`) and degrades to a documented no-op elsewhere; `spanProcessor: 'simple'` (default) or `'batch'` |
#### @setu-ts/secrets-plugin
| Aspect | Detail |
| -------------------- | ----------------------------------------------------------------------------------------------------- |
| **Purpose** | Secret management |
| **Responsibilities** | Retrieve secrets from KMS, Vault, env; secret rotation; caching |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `SecretsPlugin()`; `ISecretManager` |
| **Extension Points** | Custom secret provider |
| **Rules** | Cloud SDKs are optional (injected or lazy-loaded via bare specifiers); env provider for development |
#### @setu-ts/audit-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------ |
| **Purpose** | Audit trail logging |
| **Responsibilities** | Log audit events; redact entry bodies; store in database, file, or log; audit trail retrieval |
| **Dependencies** | `common`, `kernel` (consumes `logger` capability via token) |
| **Public API** | `AuditPlugin()`; `IAuditLogger` |
| **Extension Points** | Custom audit storage |
| **Rules** | Audit logs are immutable; storage is pluggable |
#### @setu-ts/resilience-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------- |
| **Purpose** | Resilience patterns |
| **Responsibilities** | Circuit breaker; retry with backoff; timeout; bulkhead |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `ResiliencePlugin()`; `IResilienceService` |
| **Extension Points** | Custom resilience patterns; custom circuit breaker strategies |
| **Rules** | Patterns are composable; no external dependencies |
#### @setu-ts/storage-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------- |
| **Purpose** | File storage abstraction |
| **Responsibilities** | S3, GCS, local, memory providers; upload middleware; signed URLs |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `StoragePlugin()`; `IStorage` |
| **Extension Points** | Custom storage provider |
| **Rules** | Cloud SDKs are optional (injected or lazy-loaded via bare specifiers); local and memory providers for testing |
#### @setu-ts/mail-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------------- |
| **Purpose** | Email sending |
| **Responsibilities** | SMTP, SES, SendGrid providers; template engine (`{{ variable }}` strings and view components) |
| **Dependencies** | `common`, `kernel`; optionally the `view` capability (component templates, M102) |
| **Public API** | `MailPlugin()`; `IMailer` |
| **Extension Points** | Custom mail provider; component templates through `CAPABILITIES.VIEW` |
| **Rules** | Email SDKs are optional (injected or lazy-loaded via bare specifiers); log provider for testing |
#### @setu-ts/notification-plugin
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------ |
| **Purpose** | Multi-channel notifications |
| **Responsibilities** | Email, SMS, push, Slack channels; multi-channel dispatch |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `NotificationPlugin()`; `INotifier` |
| **Extension Points** | Custom notification channels |
| **Rules** | Channel providers are optional (injected or lazy-loaded via bare specifiers) |
#### @setu-ts/feature-flags-plugin
| Aspect | Detail |
| -------------------- | ----------------------------------------------------------------------------------------- |
| **Purpose** | Feature flag management |
| **Responsibilities** | Flag evaluation; percentage rollout; user targeting; multiple providers |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `FeatureFlagsPlugin()`; `IFeatureFlags` |
| **Extension Points** | Custom flag provider |
| **Rules** | Config provider for simple cases; DatabaseProvider or LaunchDarklyProvider for production |
#### @setu-ts/realtime-backplane-plugin
| Aspect | Detail |
| -------------------- | ---------------------------------------------------------------------------------------- |
| **Purpose** | Cross-replica fan-out for WebSocket rooms and SSE channels |
| **Responsibilities** | Publish/subscribe transport; origin stamping and echo suppression; frame encoding |
| **Dependencies** | `common` |
| **Public API** | `RealtimeBackplanePlugin()`; `IRealtimeBackplane` |
| **Extension Points** | Custom transport via the `'custom'` arm |
| **Rules** | Consumed OPTIONALLY — absent, rooms and channels stay in-process. Never imports a plugin |
`cloudflare-plugin` is a **second provider** of `CAPABILITIES.REALTIME_BACKPLANE` (M52d, over a
Durable Object). An application registers exactly one of the two: the kernel rejects two providers
of a single capability token at startup. It is likewise a second provider of
`CAPABILITIES.MESSAGING` (M59, over Cloudflare Queues plus a Durable Object reply inbox) — an
application registers `MessagingPlugin` **or** the Cloudflare `messaging` arm, never both.
#### @setu-ts/multi-tenancy-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------- |
| **Purpose** | Multi-tenancy support |
| **Responsibilities** | Tenant resolution; tenant context; database isolation strategies; cache isolation |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `MultiTenancyPlugin()`; `IMultiTenancyService` |
| **Extension Points** | Custom tenant resolver; custom database strategy |
| **Rules** | Tenant resolution via middleware; tenant context via request context |
#### @setu-ts/testing
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Purpose** | Testing utilities |
| **Responsibilities** | Test app factory; mock plugin; request injection; mock context |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `createTestApp()`; `createMockPlugin()`; `inject()`; `collectStream()`; `createTestContext()`; `MockResponse`; `MockServiceRegistry`; `FixtureManager` |
| **Extension Points** | N/A (testing utility) |
| **Rules** | No real external dependencies; in-memory adapters only |
#### @setu-ts/cli
| Aspect | Detail |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | CLI tool with generators |
| **Responsibilities** | Project scaffolding; monorepo workspaces; code generation; plugin-aware generators |
| **Dependencies** | `common`, `runtime` (NOT `kernel` — the CLI emits source text and loads the user's app through `IApplication`, never constructing one itself) |
| **Public API** | The `setu` command (`new`, `new --workspace`, `generate`, `generate app`, `commands`); `runCli`, `deriveNames`, `detectPlugins`, `PROGRAM_NAME` |
| **Extension Points** | Custom schematics from `.setu-ts/schematics/`; plugin commands via `ICliApi`, read by loading the project's `setu.config.ts` |
| **Rules** | Generation reads the target project's manifest and its generated-artifact directories, and never boots it; schematics are pure functions returning files, so `--dry-run` writes nothing. A generate refuses to overwrite any existing file, except the CLI-owned seam barrels a schematic declares `managed` (one `index.ts` per generated family). It also refuses a name that would collide with an existing artifact's HTTP path or injection token. Generation has two coherent styles, selected by the template and persisted in the generated manifest: the default is FUNCTIONAL — no `DecoratorPlugin`, no `DiPlugin`, `ctx`-first handlers and plain-function services — while `--template class-based` installs both plugins together and switches every style-sensitive schematic to decorated classes. There is no independent `--di` axis; a project's installed package set IS the selector, so a later `generate` cannot silently revert to the other style. Every host — including the no-template one — wires the seams whose plugins it registers, so `setu generate route` is a first-class decorator-free path. Plugin-command discovery is the one path that DOES boot the app — via `setu.config.ts`, with no port and guaranteed teardown. A **workspace** is a Deno workspace by default, or an npm one under `--runtime node|bun` (Cloudflare Workers is refused — each Worker is its own deploy unit), whose root declares members by GLOB (`"workspace": ["./apps/*"]`, or `"workspaces"` on the npm arm), so `setu generate app` creates a member directory and rewrites no manifest; framework pins live in each member's own manifest, because plugin detection reads one directory and never walks up. Cross-member service discovery is a CLI-owned `src/discovery/services.ts` per member, rendered from `setu.workspace.json` — the LOCAL development topology only; Compose and Kubernetes objects per member belong to M39. A template that a runtime serves through a DIFFERENT plugin declares a `RuntimeSwap` rather than refusing the pairing: `microservice` on `cloudflare-workers` swaps `MessagingPlugin`/`QueuePlugin` for `CloudflarePlugin`, which serves both capabilities from Cloudflare Queues, and contributes the module-level `queue` export, the Durable Object class the platform requires the application to own, and the wrangler stanzas it needs. A standalone `setu new` may select its OWN message broker and job queue with `--broker`/`--queue`, derived from the SAME transport registry the workspace `--transport` flag reads; each flag is REFUSED, never silently ignored, wherever it would be a no-op (Cloudflare Workers, starter-composed templates, templates registering no matching wiring), and the selected arm's Compose service ships with the scaffold so it can boot. Prompting is an OPTIONAL `ask` seam on `CliDependencies` that fails closed in three layers — absent by default, TTY-gated in the executable entry point, refused by Deno on a non-terminal — so every gate and script is non-interactive by construction |
#### @setu-ts/sdk
| Aspect | Detail |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Portable, zero-npm-dependency client SDK for consuming Setu-TS APIs from browsers and servers |
| **Responsibilities** | Injectable HTTP client (`IHttpClient`); bearer/API-key auth interceptors; per-origin retry with backoff, circuit breaker, sliding-window rate limiting; pure OpenAPI 3.1 → TypeScript codegen |
| **Dependencies** | `common` only (type-level imports of `RetryPolicy`, `CircuitBreakerPolicy`, `BackoffStrategy`; no kernel, no runtime, no plugins, no npm dependencies) |
| **Public API** | Interfaces + factory functions + error classes (`IHttpClient`, `createClient()`, interceptor types, `IClientTiming`, `createDefaultClientTiming()`, `ClientRateLimitPolicy`, re-exported policy types, auth factories, errors, `generateOpenApiClient`, `SdkOpenApi*` types, `OpenApiCodegenOptions`) — NOT the concrete `HttpClient` class |
| **Extension Points** | Custom request/response interceptors (`ClientRequestInterceptor`, `ClientResponseInterceptor`); injected `fetch` and `IClientTiming` seams |
| **Rules** | Not a plugin; does not register capabilities or resolve tokens; runtime-independent (web `URL`, `Headers`, `AbortSignal`, `performance.now()` only); zero npm dependencies |
#### @setu-ts/cloudflare-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Cloudflare Workers platform bindings: KV, R2, D1, Queues, Messaging, Cron, Cache API, Durable Objects |
| **Responsibilities** | Bind platform-specific services to the service registry; split env bindings; throw on missing bindings |
| **Dependencies** | `common`, `kernel`, `storage-plugin`, `cache-plugin`, `database-plugin` |
| **Public API** | `CloudflarePlugin()`; `ICloudflareBindings` |
| **Extension Points** | N/A — platform-specific, not replaceable |
| **Rules** | Workers-only; zero npm dependencies; bindings injected at app construction, never imported from `cloudflare:workers` |
#### @setu-ts/graphql-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | GraphQL server with schema-first and code-first arms, over HTTP, WebSocket, and SSE |
| **Responsibilities** | Schema parsing; query execution; subscription management; GraphiQL interface; depth limiting; APQ |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `GraphqlPlugin()`; `IGraphqlService` |
| **Extension Points** | Custom schema; custom resolvers; custom scalar resolvers; custom document cache |
| **Rules** | `graphql` is inject-or-lazy; subscriptions require `CAPABILITIES.WEBSOCKET` or `CAPABILITIES.SSE` OPTIONALLY |
#### @setu-ts/grpc-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Co-serve gRPC, Connect, and gRPC-Web on the same port as ordinary Hono routes |
| **Responsibilities** | Connect-ES core + Protobuf-ES; reflection and health over embedded descriptors; RPC dispatch through the kernel pipeline |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `GrpcPlugin()`; `IGrpcService` |
| **Extension Points** | Custom service definitions via embedded `FileDescriptorSet` constants |
| **Rules** | Connect-ES + Protobuf-ES from bare specifiers; detection is prefix-only; RPC dispatch runs in the kernel terminal handler after the middleware pipeline |
#### @setu-ts/react-router-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | React Router 8 framework-mode SSR embedded as a plugin |
| **Responsibilities** | File-based routing; SSR via `loadRequestHandler`; static asset serving over `runtime.fs` with symlink-safe containment |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `ReactRouterPlugin()`; `SsrService` |
| **Extension Points** | Custom `loadRequestHandler` seam; custom `loadContext` |
| **Rules** | Vite is an app-level build tool, never imported by the plugin; `IFileSystem.realPath` is the symlink containment seam |
#### @setu-ts/sse-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Server-Sent Events with named channels, heartbeat, and Last-Event-ID replay |
| **Responsibilities** | Frame encoding; channel management; heartbeat interval; client disconnect handling |
| **Dependencies** | `common`, `kernel` |
| **Public API** | `SsePlugin()`; `ISseService` |
| **Extension Points** | Custom frame encoder; custom channel store |
| **Rules** | One-way broadcast only; uses `IResponse.stream()` for the SSE body |
#### @setu-ts/websocket-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Full-duplex WebSocket on all four runtimes, with rooms, heartbeat, and admission control |
| **Responsibilities** | RFC 6455 handshake; room broadcast with `except`; idle sweeper; max connections / max message bytes |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `WebSocketPlugin()`; `IWebSocketService` |
| **Extension Points** | Custom room implementation; custom heartbeat; custom admission control |
| **Rules** | `setUpgradeRouter?` on `IHttpAdapter` is the interception seam; real-socket e2e on Deno is required |
#### @setu-ts/worker-pool-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | CPU-bound work on real worker threads, off the event loop |
| **Responsibilities** | Task handlers addressed by module specifier; structured clone I/O; per-specifier lazy task pools |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `WorkerPoolPlugin()`; `IWorkerPool` |
| **Extension Points** | Custom `IWorkerHost` implementation; custom task handler modules |
| **Rules** | `IRuntimeServices.workers?` is the seam; omitted on Workers (throws `WorkerPoolUnavailableError`) |
#### @setu-ts/session-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Cookie sessions with pluggable stores and synchronizer-token form CSRF |
| **Responsibilities** | AES-256-GCM / HKDF-SHA256 cookie sealing; rolling / idle timeout; store strategy (memory / cache / custom) |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `SessionPlugin()`; `ISessionService`; `csrfTokenField()` |
| **Extension Points** | Custom `ISessionStore`; custom cookie signing (`mode: 'sign'`) |
| **Rules** | Zero npm dependencies; `runtime.subtle` for crypto; commit-on-response is sound via `snapshot()` live `Headers` |
#### @setu-ts/static-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Static file serving as a capability rather than a side effect of SSR |
| **Responsibilities** | Conditional requests; single-range `206`; `.br`/`.gz` sidecar negotiation; SPA fallback |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `StaticPlugin()`; `IStaticFiles` |
| **Extension Points** | Custom `IFileSystem` implementation; custom content-type map |
| **Rules** | `IRuntimeServices.fs?` is the seam; strong ETag when `mtime` present, weak when size-only |
#### @setu-ts/view-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Server-rendered HTML as a capability — views named by reference, never by path |
| **Responsibilities** | Register an `IViewEngine` under `CAPABILITIES.VIEW`; `'hono-jsx'` / `'hono-html'` / `'custom'` arms; `view` health indicator; the `Suspense` refusal |
| **Dependencies** | `common`, `hono` (both default arms add no new third-party package to the resolution set) |
| **Public API** | `ViewPlugin()`; `renderView()`; `raw`; `ViewRenderError`; `UnresolvedSuspenseError`; `ViewPluginOptions` |
| **Extension Points** | The `'custom'` engine arm; `@Render(Component)` in `decorator-plugin` resolves the same token |
| **Rules** | No filesystem lookup, so Workers-portable by construction; output is a buffered primitive string, never a stream; rendering is stateless, so no `onClose` |
#### @setu-ts/localization-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Message catalogues per locale, request locale resolution, and a formatter shared with the browser |
| **Responsibilities** | Register an `ILocalizer` under `CAPABILITIES.LOCALIZATION`; validate catalogues at `register()`; resolve `IRequest.locale` in middleware at 45; write `Vary` and `Content-Language`; the opt-in catalogue route; `localization` health indicator |
| **Dependencies** | `common` only; no npm package — plurals, numbers and dates come from the platform's `Intl` |
| **Public API** | `LocalizationPlugin()`; `localizerFor()`; `localeMiddleware()`; three error classes; the `/format` subpath (`format`, `negotiateLocale`, `parseAcceptLanguage`) |
| **Extension Points** | `IMessageSource` for catalogues kept outside the code; `tenantLocale` for a per-tenant default; other plugins resolve `ILocalizer` from `common` |
| **Rules** | Client text only ever selects a CONFIGURED locale and never reaches a header; the `/format` subpath has no runtime dependency outside itself (enforced by a `deno info` gate); the formatter escapes nothing; `cache-plugin` keys on the resolved locale |
#### @setu-ts/service-discovery-plugin
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Service discovery over static config, Consul, Kubernetes, or DNS-SRV, with balancing and outlier ejection |
| **Responsibilities** | Read-through cache with per-service in-flight coalescing; stale-on-failure; watch-based invalidation |
| **Dependencies** | `common`, `kernel`, `runtime` |
| **Public API** | `ServiceDiscoveryPlugin()`; `IServiceDiscovery` |
| **Extension Points** | Custom `DiscoveryProvider` arm; custom `IDnsResolver` |
| **Rules** | Zero npm dependencies; `IRuntimeServices.dns?` is the optional DNS seam; `ILifecycleApi.onStopping` for deregistration |
#### @setu-ts/rest-starter
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Opinionated REST plugin composition library: `createRestApp` with pre-wired plugin set |
| **Responsibilities** | Compose `RuntimePlugin`, `LoggerPlugin`, `ExceptionsPlugin`, `ValidationPlugin`, `HealthPlugin`, `MetricsPlugin`, `OpenApiPlugin` |
| **Dependencies** | `common`, `kernel`, `runtime`, `logger-plugin`, `exceptions`, `validation-plugin`, `health-plugin`, `metrics-plugin`, `openapi-plugin` |
| **Public API** | `createRestApp()`; `RestStarterOptions` |
| **Extension Points** | Per-plugin option arms; optional `realtime` / `di` gates |
| **Rules** | Emits inline wiring, never imports a starter; `rest` = 9 plugins + `errorHandler()` middleware |
#### @setu-ts/microservice-starter
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Opinionated microservice composition: REST set plus messaging, queues, resilience, telemetry |
| **Responsibilities** | Compose `rest` starter plus `MessagingPlugin`, `QueuePlugin`, `ResiliencePlugin`, `TelemetryPlugin` |
| **Dependencies** | `rest-starter`, `messaging-plugin`, `queue-plugin`, `resilience-plugin`, `telemetry-plugin` |
| **Public API** | `createMicroserviceApp()`; `MicroserviceStarterOptions` |
| **Extension Points** | `serviceDiscovery` arm; `realtime` / `di` gates |
| **Rules** | Refused on `cloudflare-workers` because brokers need raw sockets |
#### @setu-ts/full-stack-starter
| Aspect | Detail |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Purpose** | Opinionated full-stack composition: microservice set plus caching, CQRS, storage, and React Router SSR |
| **Responsibilities** | Compose `microservice` starter plus `CachePlugin`, `CqrsPlugin`, `StoragePlugin`, `ReactRouterPlugin` |
| **Dependencies** | `microservice-starter`, `cache-plugin`, `cqrs-plugin`, `storage-plugin`, `react-router-plugin` |
| **Public API** | `createFullStackApp()`; `createFullStackAppFromConfig()`; `FullStackStarterOptions` |
| **Extension Points** | `reactRouter`, `session`, `database` arms; config-driven composition via `ConfigPlugin` |
| **Rules** | Vite is an app-level build tool; `tsconfig.base.json` enables `exactOptionalPropertyTypes` |
---
9. Dependency Rules
Allowed Dependency Direction
graph TB
common[common]
kernel[kernel]
runtime[runtime]
plugins[All Plugins]
common --> kernel
common --> runtime
common --> plugins
kernel --> runtime
kernel --> plugins
style common fill:#e1f5fe
style kernel fill:#e8f5e9
style runtime fill:#fff3e0
style plugins fill:#fce4ec
Rules:
commondepends on nothing.kerneldepends oncommononly.runtimedepends oncommonandkernel.- All plugins depend on
commonand optionallykernel. - No plugin depends on another plugin at the package level.
- Plugins communicate via capability tokens at runtime.
Why Circular Dependencies Are Forbidden
Circular dependencies cause:
- Unpredictable initialization — If A depends on B and B depends on A, which initializes first?
- Bundle size — Bundlers cannot tree-shake circular dependencies.
- Testing difficulty — Cannot test one package without loading the other.
- Maintenance — Changes to one package force changes to the other.
How Capability Tokens Prevent Circular Dependencies
graph LR
subgraph Without Tokens
A1[Plugin A] --> B1[Plugin B]
B1 --> A1
end
subgraph With Tokens
A2[Plugin A] -->|register token| R[Service Registry]
B2[Plugin B] -->|get by token| R
end
With capability tokens:
- Plugin A registers a service with token
'database'. - Plugin B resolves the service by token
'database'. - Plugin B never imports Plugin A’s code.
- No circular dependency.
Build-Time vs Runtime Dependencies
| Type | Direction | Example |
|---|---|---|
| Build-time | Package imports types from another package | import type { IDatabaseService } from '@setu-ts/common' |
| Runtime | Plugin resolves service by token | ctx.services.get('database') |
Build-time dependencies are for type definitions only. Runtime dependencies are resolved through the service registry. This separation ensures packages can be type-checked independently and bundled without pulling in unnecessary code.
10. Middleware Pipeline
Pipeline Architecture
graph LR
Req[Request] --> M1[Middleware 1]
M1 --> M2[Middleware 2]
M2 --> M3[Middleware 3]
M3 --> Handler[Route Handler]
Handler --> M3
M3 --> M2
M2 --> M1
M1 --> Resp[Response]
The middleware pipeline is a chain of functions that execute in order. Each middleware receives the
request context and a next() function. Calling next() passes control to the next middleware. Not
calling next() short-circuits the pipeline.
The pipeline runs for ALL inbound traffic, not only ordinary HTTP routes. WebSocket upgrades and
gRPC/Connect requests go through the same chain before anything protocol-specific happens, so auth,
metrics and the shutdown drain apply to them exactly as they do to a GET /users. The handshake or
RPC dispatch happens only in the kernel’s terminal handler, after the pipeline has run without
short-circuiting — a guard that answers 401 therefore refuses the upgrade, and a draining
application answers 503 on every protocol.
Protocol dispatch runs before route matching, not after. A WebSocket upgrade is a protocol
switch rather than an HTTP route, and a path inside the gRPC basePath belongs to gRPC — so an
application catch-all (the one ReactRouterPlugin mounts for SSR, a SPA fallback, a hand-written
404 route) cannot shadow either.
One caveat, because it is easy to assume otherwise: on an accepted upgrade the adapter answers
with the runtime’s own 101, so response headers a middleware wrote on ctx.response — security
headers, Set-Cookie — are not carried onto the handshake response. The pipeline still runs,
which is what lets a guard refuse; it just has no response to decorate once the socket is taken
over. A refused upgrade is an ordinary HTTP response and carries everything.
This was not always so. Until M70a the HTTP adapter consulted setUpgradeRouter and setRpcHandler
before mapping the request, so no middleware applied to either: an unauthenticated WebSocket wrote
through a guarded endpoint and an unauthenticated gRPC client read and wrote through one. The
adapter now stores the router and acts only on an upgrade intent the kernel records after the
pipeline; setRpcHandler is deprecated and consulted by nothing.
Ordering
Middleware is ordered by priority (lower numbers execute first). The pipeline is an onion:
pre-next() code runs in ascending priority order, then the route handler (terminal), then
post-next() code unwinds in descending order. A LOW priority number is outermost (first inbound,
last outbound).
| Priority | Middleware | Purpose |
|---|---|---|
| 0 | ErrorHandler | Outermost error handler |
| 20 | MetricsMiddleware | Record metrics |
| 30 | TelemetryMiddleware | Propagate trace context |
| 40 | TenantMiddleware | Resolve request tenant |
| 45 | LocaleMiddleware | Resolve request locale |
| 50 | LoggingMiddleware | Log incoming request |
| 100 | RequestIdMiddleware | Generate request ID |
| 150 | CorrelationIdMiddleware | Propagate correlation ID |
| 200 | CorsMiddleware | Handle CORS |
| 250 | SecurityHeadersMiddleware | Add security headers |
| 260 | SessionMiddleware | Load/commit the session |
| 270 | CsrfMiddleware | Origin/Referer check |
| 300 | AuthMiddleware | Authenticate request |
| 325 | CsrfFormMiddleware | Synchronizer-token check |
| 350 | AuthorizationMiddleware | Check permissions |
| 400 | ValidationMiddleware | Validate request |
These priorities are conventional bands. Metrics (20), telemetry (30), tenant resolution (40),
locale resolution (45), HTTP security (120–270), session (260), authentication (300), and form CSRF
(325) are self-registered by their own plugins. The application (or a starter) adds the error
handler and any application middleware. AuthPluginOptions.middleware can move authentication,
exclude selected paths, or disable its global registration when the application attaches
authMiddleware() per route.
The session sits below authentication so an auth strategy can read it — SessionStrategy
(configured by AuthPluginOptions.session) is that reader. It opens the cookie through
ISessionService.fromHeaders, a headers-only read, so it needs only the session capability
(SessionPlugin) to be registered; it does not itself require the session middleware to have run
first. The session’s commit phase (which runs after next() returns) therefore wraps everything
inner. The two CSRF middlewares are different mechanisms rather than one feature configured twice:
the stateless Origin/Referer check at 270 is cheap and runs first, so a request failing it never
reaches token comparison at 325. The form check sits after authentication (300) because its bearer
exemption reads the credential source the authentication middleware records — only a request
authenticated by a header-credential strategy is exempt. Anything reading the session must be
registered above 260, or the session accessor throws.
Registration
// Global middleware
app.middleware.add(loggingMiddleware(), { priority: 50 });
// Route-level middleware
app.router.get('/users', {
middleware: [requireAuth(), validationMiddleware()],
handler: async (ctx) => {
/* ... */
},
});
Execution
sequenceDiagram
participant Pipeline
participant MW1 as Middleware 1
participant MW2 as Middleware 2
participant Handler
Pipeline->>MW1: ctx, next()
MW1->>MW1: Pre-processing
MW1->>MW2: next()
MW2->>MW2: Pre-processing
MW2->>Handler: next()
Handler-->>MW2: Response
MW2->>MW2: Post-processing
MW2-->>MW1: Response
MW1->>MW1: Post-processing
MW1-->>Pipeline: Final response
Short-Circuiting
A middleware can short-circuit by sending a response without calling next():
const authMiddleware = () => async (ctx, next) => {
const token = ctx.request.headers.get('authorization');
if (!token) {
return ctx.response.status(401).json({ error: 'Unauthorized' });
// next() is not called — pipeline stops
}
await next();
};
Non-HTTP ingress behaviour chain
Queue processors, scheduler jobs, messaging subscriptions, and WebSocket frames use a second,
transport-neutral behaviour chain. Each work item is presented to an IIngressBehavior as an
IngressContext; calling next() advances to the next declared behaviour and a behaviour that does
not call it short-circuits the handler.
This ingress chain is separate from both the HTTP middleware pipeline and CQRS’s IPipelineBehavior
chain. They do not share middleware or behaviour instances: HTTP middleware wraps requests and
upgrades, CQRS behaviours wrap commands and queries, and ingress behaviours wrap the four non-HTTP
handler kinds above.
Error Propagation
If a middleware or handler throws an error:
- The error propagates up the middleware chain.
- Each middleware’s post-processing is skipped.
- The error reaches the error handler middleware.
- The error handler formats the response and sends it.
graph LR
Handler -->|throws Error| MW2
MW2 -->|skips post-processing| MW1
MW1 -->|skips post-processing| ErrorHandler
ErrorHandler -->|formats response| Client
Context Creation
Each request gets a fresh RequestContext:
interface RequestContext {
request: IRequest;
response: IResponse;
services: ServiceRegistry;
state: Map<string, unknown>;
params: Record<string, string>;
query: Record<string, string>;
id: string;
startTime: number;
}
The state map allows middleware to pass data to downstream middleware and handlers:
// Validation middleware
ctx.state.set(validatedStateKey('body'), result.data);
// Handler
const body = ctx.state.get(validatedStateKey('body'));
Request-Scoped Data
Request-scoped data is stored on the context, not on global state. This ensures:
- No data leaks between requests.
- No race conditions.
- Easy testing (each test gets a fresh context).
11. Dependency Injection
Why DI Is Optional
DI is a pattern for managing service dependencies. In Setu-TS, the Service Registry is the primary service resolution mechanism. DI is an optional layer that provides:
- Constructor injection.
- Lifecycle management (singleton, scoped, transient).
- Circular dependency detection at the DI level.
Applications that prefer manual wiring or factory functions can skip the DI plugin entirely.
Three Approaches to Service Management
graph TB
subgraph Approach 1: Service Registry Only
SR1[ctx.services.get token] --> S1[Service Instance]
end
subgraph Approach 2: Manual Wiring
MW[Constructor Injection] --> S2[Service Instance]
end
subgraph Approach 3: DI Plugin
DI[DI Container] --> SR2[Service Registry]
SR2 --> S3[Service Instance]
end
| Approach | When to Use |
|---|---|
| Service Registry only | Simple applications; functional style; no DI needed |
| Manual wiring | When you want explicit control; pass dependencies via constructors |
| DI Plugin | When you want automatic resolution; when you have complex dependency graphs |
Service Registry (Default)
// Register
ctx.services.register('userService', new UserService(db, logger));
// Resolve
const userService = ctx.services.get<UserService>('userService');
Manual Wiring
class UserService {
constructor(
private db: IDatabaseService,
private logger: ILogger,
) {}
}
// In a plugin
register(ctx) {
const db = ctx.services.get('database');
const logger = ctx.services.get('logger');
ctx.services.register('userService', new UserService(db, logger));
}
DI Plugin
// Register DI plugin
app.register(DiPlugin({ defaultScope: 'singleton' }));
// In a plugin
register(ctx) {
const container = ctx.services.get<IContainer>('di-container');
container.register('UserService', {
useClass: UserService,
inject: ['database', 'logger'],
});
// Resolve with constructor injection
const userService = container.resolve<UserService>('UserService');
}
When to Use Each
| Scenario | Recommended Approach |
|---|---|
| Simple app with few services | Service Registry |
| Functional programming style | Service Registry |
| OOP with explicit dependencies | Manual Wiring |
| Complex dependency graphs | DI Plugin |
| NestJS migration | DI Plugin + Decorator Plugin |
| Testing | Service Registry (mock by overriding token) |
12. Decorator System
Why Decorators Are Optional
Decorators are a language feature not every environment or bundler supports, and some developers prefer a functional style over OOP decorators. Setu-TS uses TC39 standard decorators, so no compiler option is required — but a runtime whose toolchain does not transform them (Node, for instance, where V8 has not shipped them) still needs one that does.
Setu-TS makes decorators optional by:
- Providing a complete programmatic API for every feature.
- Implementing decorators as a thin layer over the programmatic API.
- Storing decorator metadata in plain objects, not via reflection.
How Decorators Work
graph LR
D[Decorator] -->|stores metadata| M[Metadata Store]
M -->|read by| DP[DecoratorPlugin]
DP -->|registers| K[Kernel]
K -->|creates| R[Routes, Services, Middleware]
-
Decorator execution — When a class is decorated, the decorator function stores metadata in a plain object (
MetadataStore). -
Metadata storage — Metadata is stored in a
Mapkeyed by class reference. No reflection is used. -
DecoratorPlugin registration — When the
DecoratorPluginis registered, it scans theMetadataStorefor decorated classes. -
Kernel registration — The
DecoratorPlugincalls the kernel’s programmatic API to register routes, services, and middleware from the metadata.
Metadata Storage
// MetadataStore is a plain object, not a WeakMap
interface MetadataStore {
controllers: Map<Function, ControllerMetadata>;
services: Map<Function, ServiceMetadata>;
routes: Map<Function, RouteMetadata[]>;
}
Why not WeakMap? WeakMaps require the key to be an object reference. In some bundlers and
runtimes, class references can be lost during tree-shaking. A plain Map with explicit registration
is more predictable.
Programmatic API Equivalents
Every decorator has a programmatic equivalent:
| Decorator | Programmatic Equivalent |
|---|---|
@Controller('/users') | app.router.group('/users', ...) |
@Get('/') | app.router.get('/', handler) |
@Params(Body()) | ctx.request.body |
@Params(Query('name')) | ctx.query.name |
@Params(Param('id')) | ctx.params.id |
@UseGuards(requireAuth()) | middleware: [requireAuth()] |
@Injectable() | ctx.services.register(token, new Service()) |
The three parameter rows show the raw equivalent. Body(), Query() and Param() prefer the
value validation middleware wrote for the request (validatedStateKey(...)) and fall back to the
raw request only when there is none — so on a route carrying @ValidateBody(schema) they yield the
parsed-and-transformed value, where ctx.request.body yields what the client sent. A schema with a
coercion or a default therefore makes the two differ.
Reflection
Reflection is not required. The DecoratorPlugin reads metadata from the MetadataStore, not
via Reflect.getMetadata(). This ensures:
- No dependency on
reflect-metadatapolyfill. - Works in environments that do not support reflection.
- Metadata is explicit and inspectable.
Discovery
The DecoratorPlugin can auto-discover decorated classes:
app.register(
DecoratorPlugin({
autoDiscover: true,
controllersPath: './src/controllers',
}),
);
Or classes can be registered explicitly:
app.register(
DecoratorPlugin({
controllers: [UserController, OrderController],
}),
);
13. Error Handling
Exception Flow
graph TB
Handler[Route Handler] -->|throws Error| Pipeline
Pipeline -->|catches| ErrorHandler[Error Handler Middleware]
ErrorHandler -->|logs| Logger[Logger]
ErrorHandler -->|formats| Formatter[Error Formatter]
Formatter -->|RFC 9457| Response[Error Response]
Response --> Client[Client]
Exception Types
The framework uses factory functions instead of class inheritance for exceptions:
// Factory function (composition)
function badRequest(message: string, details?: unknown): HttpError;
function unauthorized(message: string): HttpError;
function notFound(message: string): HttpError;
Why factory functions?
- No rigid inheritance hierarchy to maintain.
- Custom error types without modifying the base class.
- Easier serialization/deserialization.
- Runtime composition of error properties.
Problem Details (RFC 9457)
The standards-compliant error format follows RFC 9457, which obsoleted RFC 7807 in July 2023:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "User with id 123 not found",
"instance": "/users/123"
}
type is about:blank because an HttpError carries no problem-type identity beyond its status
code, and RFC 9457 §4.2 registers about:blank for exactly that case — a URI minted from the status
would identify nothing status does not already convey. The one error that does define an
extension member, validationError(), gets a concrete type URI
(https://setu-ts.dev/errors/validation).
The 'rfc7807' format alias is retained, deprecated, and unchanged; it still emits the
status-derived type. Removal is scheduled for v1.0.0.
Global Error Handler
The error handler middleware comes from the @setu-ts/exceptions package and is registered by the
application (or by a starter bundle) — the kernel ships zero features, including error formatting:
import { errorHandler } from '@setu-ts/exceptions';
app.middleware.add(
errorHandler({
format: 'rfc9457',
includeStackTrace: config.get('NODE_ENV') === 'development', // via ConfigPlugin, never process.env
logErrors: true,
}),
);
For an application-owned response to a caught error, respond can return or resolve a result built
with the live context’s response writer (for example,
ctx.response.status(error.statusCode).html(page)). It runs after normalization, masking, and
served-status resolution; its returned result owns the response, while undefined falls through to
the configured formatter. Logging retains the unmasked diagnostic and records the selected
response’s status. This is an explicit application branch, not content negotiation, and it does not
intercept the responder terminals below.
Error Responder Seam
The kernel ships zero error formatting, but it does produce error responses of its own (a malformed
request URL → 400, an unmatched path → 404, an unhandled error → 500, a request during a
shutdown drain → 503), and so does every short-circuiting middleware. Before M70f each of those
sites wrote its own ad-hoc body, so an application that configured
errorHandler({ format: 'rfc9457' }) still saw a mix of formats.
The fix is a request-scoped seam in @setu-ts/common — not exceptions, because no plugin may
import exceptions (where every formatter lives), and the kernel and every middleware that answers
an error all need the same contract. errorHandler builds an IErrorResponder from the formatter
and content type it already resolved at factory time and publishes it in ctx.state before
next(); any site inside the pipeline then calls
respondWithError(ctx, { status, title, detail?, details? }), which delegates to the published
responder and answers in the application’s configured format. The one first-party site deliberately
outside the seam is validation-plugin, which owns its own errorFormat option and formats
validation failures itself; an application sets the two to the same format, as the CLI templates and
rest-starter do. The responder builds a real HttpError from the init (so buildProblemDetails
sees a genuine statusCode and the validation errors extension, and maskInternalErrors never
masks a deliberate 4xx), runs the resolved formatter over it, and writes status, content-type, and
the serialized body — the same three-step tail errorHandler’s catch path performs. Three kernel
sites run before the pipeline — the shutdown-drain 503, the malformed-URL / undecodable-path
400, and an onRequest lifecycle hook that throws (answered as the unhandled 500) — so they
cannot see errorHandler’s ctx.state publication; the kernel seeds the same resolved responder
into their state from a cache it reads off the pipeline’s errorHandler at startup, and the
fallback below applies when no errorHandler is registered.
With no errorHandler registered, respondWithError falls back to the no-handler shape
{ error, detail? } — the framework’s pre-formatter shape, written directly by the seam and not
the same as the default formatter’s { statusCode, message, details? } body — so a site can never
answer in a shape the application did not ask for. The unhandled-error 500 additionally logs the
error through CAPABILITIES.LOGGER when a logger is registered (X11-2); the body stays opaque.
serializeError (also in common) is the pure serializer the logger plugin uses to normalize a raw
Error out of log metadata before redaction (X2-5) and the kernel, exceptions, grpc-plugin, and
notification-plugin use to serialize a cause without importing one another.
Plugin Error Handling
Plugins can register custom error handlers:
ctx.lifecycle.onError((error, ctx) => {
const logger = ctx.services.get('logger');
logger.error('Unhandled error', { error: error.message, stack: error.stack });
});
Logging and Observability
Telemetry spans nest through the telemetry host’s activation seam. In a real OTel configuration the
plugin registers an async-local context manager and executes withSpan callbacks with their span
active. This is what makes spans produced by downstream work, including broker publishing, children
of the request span; without activation they are unrelated siblings.
Activation is conditional, not guaranteed: it requires real OTel mode, contextPropagation left
enabled, and a context manager that registered successfully. Noop and fallback modes, an explicit
contextPropagation: false, and a failed registration all leave spans flat — recorded, but
unparented. The outcome is reported through the plugin’s logger rather than thrown, so a component
that needs a specific parent passes parentContext explicitly instead of relying on ambient
activation.
Errors are:
- Logged — Via the logger plugin (if registered).
- Traced — Via the telemetry plugin (if registered), as span exceptions.
- Metriced — Via the metrics plugin (if registered), as error counters.
- Audited — Via the audit plugin (if registered), for security-related errors.
14. Security Model
Security Layers
graph TB
subgraph Transport Security
CORS[CORS]
Headers[Security Headers]
CSRF[CSRF]
Size[Request Size Limiting]
IP[IP Security]
end
subgraph Authentication
JWT[JWT]
APIKey[API Key]
Local[Local Strategy]
Refresh[Refresh Token]
end
subgraph Authorization
RBAC[RBAC]
Perms[Permissions]
Guards[Guards]
end
subgraph Secrets
KMS[KMS/Vault]
Env[Env Secrets]
Rotation[Secret Rotation]
end
subgraph Rate Limiting
RL[Rate Limiter]
Storage[Redis/Memory Storage]
end
Authentication
Authentication is handled by the AuthPlugin, which provides:
- JWT — Sign and verify tokens.
- API Key — Validate API keys via custom function.
- Local — Username/password authentication.
- Refresh Token — Issue new access tokens via the app-instantiated
RefreshTokenService(M16b, shipped) — a service with rotation + revocation over a pluggable store, not anIAuthStrategy(a refresh token arrives in the request body, not as a passive header).
Authentication middleware extracts credentials and populates ctx.request.user.
Authorization
Authorization is handled via free MiddlewareFunction factories (imported from the plugin, not
methods on IAuthService). The role/permission guards resolve IAuthorizationService from the
'authorization' token and short-circuit — they return a 401/403 response and do not call
next(); authMiddleware always calls next() so an unauthenticated request still reaches the
guard.
requireAuth()— Require any authenticated user (401).requireRole(role)— Require a specific role, honoring the configured hierarchy (401/403).requirePermission(permission)— Require a specific permission (401/403).requireAnyRole(roles)— Require any of the specified roles.requireAllPermissions(permissions)— Require all specified permissions.publicRoute()— Explicitly allow unauthenticated access (namedpublicRoute, notpublic).
Policies (Milestone 110a)
Roles answer “does this principal hold this role, anywhere”; a policy answers “may this
principal do this, to this target”. IAuthorizationService is synchronous and takes a principal
plus a role or permission string, so a target cannot reach it — which is why policies are a second
capability, IAuthorizationPolicyService under CAPABILITIES.AUTHORIZATION_POLICIES, beside RBAC
rather than a widening of it. AuthPlugin always registers it.
Why one evaluator. The route guard (requirePolicy), the decorator (@RequirePolicy), and the
imperative can/authorize all reach the same service, so the four entry points cannot disagree
about an outcome; a parity test drives all four under one non-default configuration.
@RequirePolicy and requirePolicy are two thin middlewares rather than one because
decorator-plugin may not import auth-plugin (§2.2); both call the public contract, so an
application’s replacement provider serves them too.
Why the rules are fixed. Only a literal true allows; a throw denies (and is logged without the
target); an anonymous principal is refused 401 unless the ability opted in. Configurable
fail-open-ish semantics are exactly the knobs an authorization layer should not have.
Why unknown names fail at startup. A functional guard is a value the application builds, so no
register() ever sees it. AuthPlugin’s onBootstrap hook scans every registered route for the
policy-guard brand and fails start() before the server listens, then seals the registry.
@RequirePolicy is validated in DecoratorPlugin’s own register(). A route added after start()
is not scanned and fails closed per request.
Where attribute rules go. Into policies. The framework ships no attribute language — ASP.NET
Core, NestJS and Spring make the same choice. An external engine (OpenFGA, Casbin) is wrapped as a
policy. Scoped grants (a role held in one tenant) are built on this layer — see Scoped Roles below.
Non-HTTP ingress has no policy behaviour yet: IngressContext carries no principal, and a check
that silently evaluated as anonymous would be worse than none; the imperative calls work there
because the principal is an argument.
Scoped Roles (Milestone 110b)
The RBAC layer below holds roles globally: “Ann is an approver”. Scoped RBAC holds them in a scope — “Ann is an approver in the Acme tenant”, or in one organisation, team or region. It extends the global model rather than contradicting it: the role catalogue is unchanged and still the only source of role and permission names; what a grant adds is where it applies.
Why a built-in policy, not a capability. scopedRbac makes AuthPlugin define one reserved
policy, scoped-rbac, on the M110a evaluator: each catalogue permission is an ability
perm:<name>, each role role:<name>, and the target is the scope. So the guards
(requireScopedRole/requireScopedPermission), the decorators
(@ScopedRoles/@ScopedPermissions) and the imperative can reach the same evaluator and inherit
its fixed rules, and the M110a startup scan refuses a typo in a scoped guard for free — a typo is a
configuration error, not a 403.
One mechanism for hierarchy and delegation. inheritsFrom(scope) names the scopes whose grants
also apply; a parent is one hop and a delegating tenant another. It is walked transitively,
depth-first, with cycle refusal and depth/node bounds, so a malformed hierarchy denies rather than
loops. Grants flow down only: a child request inherits a parent’s grants, never the reverse.
Why the request tenant bounds the scope. A scope taken from a route parameter is
caller-controlled. A check whose own scope is a tenant other than the resolved request tenant
denies, so a parameter cannot reach outside the caller’s tenant; a parent reached through
inheritsFrom still applies.
Why one failing source denies. Grant sources are unioned. Answering from the sources that did reply would allow on a partial view — the one grant that says “no longer” may be the one that failed. Every source call has a deadline, every result is validated and bounded, and the deny is logged with the source name, the scope type and the error class — never an identifier.
Why custom roles bundle permissions. A tenant may define its own roles at runtime, but only from catalogue permissions, resolved against the defining scope only, and checked through permissions. The permission catalogue stays static, so the startup check stays total.
Why timing is a choice. Resolving per request makes a revocation immediate; a TTL cache or sign-in resolution trades revocation latency for fewer reads. Sign-in grants live under a private session key, never in principal claims, so a federated token carrying a same-named claim grants nothing.
RBAC
The RBAC system supports role hierarchy:
rbac: {
roles: {
admin: { permissions: ['*'], inherits: ['manager'] },
manager: { permissions: ['users:read', 'users:write'], inherits: ['user'] },
user: { permissions: ['profile:read', 'profile:write'] },
},
}
Secrets
The SecretsPlugin provides secret management:
- AWS KMS — Retrieve secrets from AWS KMS.
- GCP Secret Manager — Retrieve secrets from GCP.
- Azure Key Vault — Retrieve secrets from Azure.
- HashiCorp Vault — Retrieve secrets from Vault.
- Environment — Retrieve secrets from environment variables (development).
Plugin Responsibilities
| Plugin | Security Responsibility |
|---|---|
http-security-plugin | CORS, security headers, CSRF, request size, IP security |
auth-plugin | Authentication, authorization, RBAC, route guards |
secrets-plugin | Secret management and rotation |
audit-plugin | Audit trail for security events; policy redaction |
validation-plugin | Input validation and sanitization |
logger-plugin | Redaction of structured log metadata |
telemetry-plugin | Query-safe span URL attributes; optional value redaction |
Secure Defaults
All security plugins default to the most secure configuration:
- CORS: no origins allowed by default.
- JWT: strong algorithm (HS256 or RS256).
- Rate limiting: enabled by default.
- Security headers: enabled by default.
- Input validation: enabled by default.
- Secret redaction in logs: enabled by default.
Diagnostics Capture Boundary (Milestone 98a)
Kernel diagnostics are OFF unless createApplication({ diagnostics }) is passed explicitly, and an
omitted option allocates no collector, ring, or timer. When enabled, the security boundary is
STRUCTURAL rather than a filter over captured data: the kernel selects bounded primitives at its own
registration and execution boundaries, so no dynamic request path, query string, header, body,
cookie, token, session value, database value, or raw error text is ever collected in the first
place. There is nothing to redact afterwards, and snapshot()/read() cannot resolve a lazy
factory, invoke application code, or mutate state.
Registration names are a separate decision: plugin names, capability tokens, route patterns and
middleware names appear only when the application listed that exact string in diagnostics.labels,
which makes approving one a deliberate disclosure rather than a secret-detection guarantee. A
plugin’s version is the one field NOT gated that way — it is emitted whenever it passes a bounded
semver grammar. Two trust limits are explicit: the projection is not a sandbox against malicious
same-process code, and it cannot isolate deliberately blocking synchronous application code. Network
authentication and transport for an external devtool are a separate milestone (M98b); this boundary
is in-process only and registers no listener.
Health Observation Boundary (Milestone 98d)
Health observations extend the M98a/M98b pattern to the health plugin.
HealthPlugin({ diagnostics }) is OFF unless the option is passed, and an omitted option registers
only an inert, disabled source under CAPABILITIES.HEALTH_DIAGNOSTICS — no capture, no timer. When
enabled, the boundary is again STRUCTURAL, not a filter over captured data: the collector is handed
the already-produced HealthCheckResult from the health service’s single evaluation (one callback
per normal check — the report and the observation are the same evaluation), and it retains only a
fixed field set per explicitly allowlisted indicator alias. An indicator’s data, the thrown value
of a rejection, and any absolute time are never admitted; the projected observation carries the
approved alias, the framework’s own status (present only when reported), the outcome state, and
monotonic latencyMs/ageMs. The source’s snapshot(instanceId) is synchronous and frozen: it
never invokes an indicator, resolves a lazy factory, or mutates state, so a slow or hostile consumer
cannot backpressure the health endpoint.
The optional scheduled block is the one part that performs work: a bounded scheduler (at most 16
approved indicators, concurrency 1–4, a per-check reporting timeoutMs that is a bound — not a
cancellation — so a hung check stays in-flight until it settles and no replacement starts for it;
the hung callback holds its own concurrency slot and nothing more, cycles wait only for reporting,
and a rotating cursor keeps every scheduled indicator reachable) is started from the plugin’s
onBootstrap hook and torn down from onClose, including the failed-startup path. The M98b
connector consumes the source to serve GET /v1/health, projecting the DTO field-by-field and
bounding it by the same 256 KiB response ceiling; an absent source answers unsupported, a throwing
source — or one whose projected DTO fails the exact validator — a value-free collection-failed,
and neither changes the application’s readiness.
Configuration Provenance Boundary (Milestone 98e)
Configuration provenance extends the same pattern to the config plugin.
ConfigPlugin({ diagnostics }) / loadConfig(runtime, { diagnostics }) is OFF unless the option is
passed; an omitted option registers only an inert, disabled source under the EAGER
CAPABILITIES.CONFIG_DIAGNOSTICS token, so the connector can distinguish “no config plugin”
(unsupported) from “present but off” (disabled). When enabled, the boundary is again STRUCTURAL:
the provenance builder observes only framework-owned structure at the resolution steps the loader
already performs — the merge displacement chain, the existing ${NAME} grammar on an already-loaded
string, and schema input/output property PRESENCE — and it never compares, hashes, serializes,
measures, or retains a value. Only explicitly approved key aliases and file-path aliases are
retained; unapproved keys are dropped without a count (a count would disclose that they exist), and
an unapproved file path contributes only its category. The record travels with the exact
configuration object through a module-private WeakMap, so the standalone loadConfig →
ConfigPlugin({ instance }) composition stays ONE snapshot with ONE metadata pass, and GC owns the
lifetime. An opaque injected instance is answered with unknown entries — no presence flag, no read
of it; configured sections perform exactly the same IConfig.get calls with diagnostics disabled
and enabled. The connector consumes the source to serve GET /v1/config with the identical
projection, validation, bounding, and failure isolation as GET /v1/health.
Queue Observation Boundary (Milestone 98f)
Queue observations apply the same pattern to background work, with two structural differences. The
first is multiplicity: a queue plugin can be registered several times under derived names, and the
kernel refuses two plugins claiming one capability in provides, so a singleton inspector source is
impossible. Every QueuePlugin instance therefore contributes one IQueueDiagnosticsSource under
CAPABILITIES.QUEUE_DIAGNOSTICS with { multi: true } and never claims the token — the
contribution-token pattern HEALTH_INDICATOR already uses — and the connector reads every source it
finds at bootstrap (at most 16), identifying each only by a q<N> position so registry names and
tokens never reach the wire.
The second is the settlement boundary. The queue’s existing outcome notification (the metrics sink)
fires BEFORE the adapter’s settlement call is awaited, so it is not proof that a job was durably
acknowledged, requeued or dead-lettered. The runner therefore has two hooks: the metrics hook keeps
its timing, and the diagnostics hook fires only after the settlement promise settled — with the
requested settlement on resolution, failed on rejection (which is then rethrown unchanged) — and
the collector records unknown in place of a completed call on an adapter that cannot confirm its
settlement calls (RabbitMQ’s unconfirmed channel operations; SQS, which absorbs a lapsed claim or a
failed dead-letter send). Minimization is again structural: the observer is handed a job name (for
the exact allowlist lookup), a raw job id (for the bounded LRU alias lookup) and the attempt number
at dispatch, and fixed outcome/settlement primitives afterwards — its signatures cannot accept a
payload, a header, a claim token, the attempt limit or a thrown value. Everything retained is
bounded (1,024 attempts, 4,096 aliases, 2,048 observed in flight, 64 latest depths).
Depths are counted only by a separately opted-in scheduler (non-overlapping cycles, concurrency
1–4, a reporting timeoutMs that never cancels — a hung count keeps its slot until it settles), and
only on adapters that can count cheaply; an adapter that cannot reports unavailable, never zero,
and each depth carries its scope (process-local or shared-backend) so a consumer never sums
one backend’s inventory across replicas. A diagnostic read never reserves, settles or counts a job.
The connector keeps one internal cursor per source (M98b permits one session), drains each source’s
new attempts into a bounded merge ring on every authenticated read, and pages that ring under M98a’s
cursor contract. Two rings mean two losses, reported separately: merge-ring eviction in the batch’s
lost, and a source ring that wrapped between two reads on that source’s status, so an unbroken
merge sequence never reads as complete coverage. A source is untrusted input — any installed plugin
can contribute one — so its batch is validated key-by-key before it is merged, and a failing source
is isolated as collection-failed without breaking the read.
Trace Observation Boundary (Milestone 98g)
Trace observations apply the pattern to the telemetry plugin, whose structural difference is the
observation SEAM: spans are produced by OpenTelemetry, and the framework does not own a completed
-span feed. Rather than a competing tracer or a wrapper around the exporter, the plugin appends one
internal SpanProcessor AFTER the exporter processor in the SAME BasicTracerProvider constructor
— the exporter path is untouched (measured: it still receives every span), the diagnostic processor
never exports, forceFlush resolves without touching the exporter, and shutdown closes the
collector only when the provider shuts down, after the connector session’s onStopping revocation.
Minimization happens IN the processor, before retention: onEnd reads only name, kind, span
context, parent context, link contexts, status code and duration; the exact raw-name allowlist
replaces the name with its approved alias before anything is buffered; attributes, events,
resources, tracestate, baggage, exceptions and status messages are structurally unreachable. Any
failure — a hostile getter, an unmappable kind or status, an invalid identifier — is one saturating
drop; the processor never throws into OTel.
Kinds follow the @opentelemetry/api SpanKind enum a readable span carries — INTERNAL = 0,
SERVER = 1, CLIENT = 2, PRODUCER = 3, CONSUMER = 4 — the numbering both auto-instrumentation
and the framework’s TelemetryService write (a default span arrives as 0, measured on the locked
SDK); the OTLP wire enum is this plus one and never reaches the processor. An unmappable value drops
the record rather than improvising. The collector is the single source under
CAPABILITIES.TRACE_DIAGNOSTICS (one telemetry plugin per application, so no merge ring is needed),
always registered: disabled without the option, unsupported with the fixed coverage reason under
a custom provider factory or in noop mode. Parent and link relationships are identifier
relationships only — parentVisibility names whether the parent is locally observed, unobserved, a
root, or unknown — and no global clock is implied: ageMs is arrival age at one process. Sampling
stays authoritative: unsampled spans never complete through a processor, and the batch reports the
configured sampler so partial traces stay visible as partial.
Cache observations (M98i) follow the queue pattern’s multi-provider registration without its merge
ring: each CachePlugin instance attaches a bounded collector to its OWN CacheService through a
private field set by an internal, non-barrel attach function (the exported constructor is unchanged,
and an unattached service does one field read beyond the pre-M98i path) and registers an
ICacheDiagnosticsSource under CAPABILITIES.CACHE_DIAGNOSTICS with { multi: true }. Only a
fixed operation name, a primitive outcome code and at most two monotonic readings cross into the
collector — the wrapper classifies a result (=== null for get, === true for has/delete)
before calling it, and a rejection is recorded without the error being read. The connector resolves
the sources once at bootstrap (refusing more than 16), reads each synchronously only after
authentication, and copies only own data properties of plain objects, so a hostile replacement
source cannot run a getter or smuggle a field. Coverage is owned-instance: direct store calls and
replacement services are not represented, and no eviction is inferred from misses. The caller
receives a promise derived from the backend’s, which re-rejects with the ORIGINAL reason, so an
unhandled backend rejection stays unhandled with diagnostics on (a side branch on the caller’s own
promise would have marked it handled and hidden it). Only one call in eight per operation is timed
with a start reading — the per-call cost is one clock read plus one promise reaction.
Scheduler execution observations (M98k) use the same multi-provider shape, attached to the plugin’s
OWN SchedulerService and threaded to the executor per dispatched fire. The service observes each
approved job’s timer fire at its entry — lateness measured once against the intended epoch fire time
(max(0, actualStart - intendedFire), never an absolute schedule) — and settles it at the
skip-or-dispatch decision: contended (fire slot held elsewhere, a delay whose registration slot
belonged to another replica, or the overlap mutex held), lock-failed (a lock operation rejected), or
dispatched with its settlement and monotonic duration. The executor observes handler attempts —
started, succeeded/failed, the retry count — reading clocks only when an observer was supplied, so
an unapproved job allocates nothing and a lock loser produces NO attempt record. A skipped local
fire is never a globally missed execution: no missed counter exists, and coverage is always
owned-instance, never cluster completeness. Storage observations (M98m) reuse the cache pattern
without change of shape: each StoragePlugin instance attaches a bounded collector to its OWN
StorageService through a module-private WeakMap written by an internal, non-barrel attach
function (the exported constructor is unchanged, and an unattached service does one WeakMap read
beyond the pre-M98m path) and registers an IStorageDiagnosticsSource under
CAPABILITIES.STORAGE_DIAGNOSTICS with { multi: true }. Only a fixed operation name, a primitive
outcome code, at most two monotonic readings and a byte length cross into the collector — the
wrapper classifies a result (a null get is impossible because absent objects throw; a false
exists/delete is a success) before calling it, and a rejection is recorded without the error
being read. getSignedUrl mints a URL rather than transferring bytes, so the collector carries
null for its duration and byte length by construction; getStream counts the open, not the drain.
The collector is NOT shared in common (unlike the M98l realtime collector) because a single plugin
owns it; the option and collector types live in @setu-ts/storage-plugin. The connector resolves
the sources once at bootstrap (refusing more than 16), reads each synchronously only after
authentication, and copies only own data properties of plain objects, so a hostile replacement
source cannot run a getter or smuggle a field. Coverage is owned-instance: direct provider calls
and a service constructed without the collector are not represented. Object paths, stored bytes,
content types, signed URLs (the synthetic memory:// URL encodes the path) and error text never
reach the collector, the wire, or the client. The caller receives a promise derived from the
provider’s, which re-rejects with the ORIGINAL reason, so an unhandled provider rejection stays
unhandled with diagnostics on.
Authorization Decision Explanation Boundary (Milestone 98h)
Authorization decision explanations apply the pattern to the first-party RBAC evaluator, with two structural differences that make the boundary stricter than every inspector before it.
The first is the observation SEAM. The RbacService is framework-owned, but its public surface is
four synchronous boolean methods (IAuthorizationService) that every guard, decorator middleware
and direct consumer calls — and the boolean IS the answer. There is no side channel to hook: the
evaluation must be refactored so that the SAME code path that produces the boolean also emits one
guarded decision observation, and the refactor is proven by the guards’ byte-identical status/body
and short-circuit behaviour. The observer is stored in a package-private WeakMap keyed by the
RbacService instance and attached only when the authorizationDiagnostics option is passed, so a
JWT-only registration and a registration with RBAC but no option are both unobserved: the evaluator
builds no step list and invokes nothing when no observer is attached. Minimization is structural:
the evaluator hands the observer only the requested rule names, the fixed reason, the granting role
name and the step it just ran — its signatures cannot accept a principal, a request, a credential or
an error — and the collector replaces every name with its approved alias before anything is
retained, so a raw rule name never reaches the ring. A compound that short-circuits emits exactly
the steps it evaluated (at most 16 retained, the true count in stepsEvaluated, stepsTruncated
true when more ran), never a fabricated full sweep; a decision whose requested rule lacks an
approved alias is dropped BEFORE buffering and counted in a saturating droppedUnapproved counter,
so partial rule lists are never emitted; an unapproved granting role is omitted from viaRoleAlias
rather than dropping the decision.
The second is the TRUST boundary. The CAPABILITIES.AUTHORIZATION provider is replaceable — an
application may register its own IAuthorizationService after the AuthPlugin, and the guards will
enforce through it. The observer can therefore see a decision it did not produce, and a decision it
did produce may no longer be authoritative. The source never guesses: before buffering it checks,
and before every read it re-checks, that the current provider is still the exact RbacService
instance it created, through the new OPTIONAL IServiceRegistry.isCurrent?(token, instance)
identity predicate — non-resolving (it never instantiates a lazy factory), non-enumerating, and
returning a boolean only. A registry without the predicate latches the source to
unsupported/provider-identity-unavailable; a false result latches custom-provider. Both
latches are terminal: the source detaches, clears retained state, and never resumes even if startup
code later restores the old registration. Keeping the predicate optional means third-party
registry-shaped implementations keep compiling, and a consumer that needs a current-provider check
must treat its absence as “cannot verify” rather than falling back to a resolving get.
The connector consumes the source to serve GET /v1/authorization with the identical projection,
validation, bounding, and failure isolation as every other inspector; the full wire shape is in
docs/diagnostics-protocol.md.
Event Observation Boundary (Milestone 98j)
Event observations apply the pattern to the events plugin, whose structural differences are the
observation SEAM and the hot path. The seam is the existing dispatch: a module-private WeakMap
attaches the collector to the bus during plugin registration — no extra subscription (which would
change dispatch and count itself), no wrapper around the bus, and no exported attachment helper. The
collector accepts only the fixed operation (publish/handler), an already-approved alias, and
primitive outcomes with measured durations; event payloads, event ids, aggregate ids and handler
identities are structurally unreachable from its signature. Publication and each existing handler’s
await are counted separately — async completion is not handler completion — publishBatch counts
its constituent publishes, and a thrown errorHandler keeps its exact propagation while the
observation still lands.
The hot path pays one failing WeakMap probe when detached; when attached it derives nothing per
event: the exact-type lookup happens first (an unapproved type costs one probe and no clock read),
then the alias replacement happens before anything is retained. Retention is the shared shape — 64
slots keyed (alias, operation), 60-second expiry checked during update and read, 30-second stale
threshold, saturating counters — and a clock or observer failure latches collection-failed and
stops capture without changing the application’s error. Every EventsPlugin instance contributes one
IEventDiagnosticsSource under CAPABILITIES.EVENTS_DIAGNOSTICS with { multi: true } and never
claims the token (the M98f contribution pattern); close detaches the observer FIRST, then clears
the bus, so a late observation after shutdown is discarded rather than resurrecting state. The
connector reads every source at bootstrap (more than 16 refuses startup; s<N> position ids; an
over-budget body collapses to collection-failed), isolates a failing source as
collection-failed, and answers duplicate aliases with a fixed no-source response.
Realtime observations (M98l) apply the pattern to three packages at once — the WebSocket, SSE and
realtime-backplane plugins — which is what put their collector in @setu-ts/common: the three need
the identical collector and none may import another (the M47 frame-codec precedent), so
createRealtimeObservationCollector exists once instead of three times. Each plugin attaches it to
its own component through a package-private WeakMap and registers only its frozen, snapshot-only
source facade — registering the collector would hand every registry reader its observe and
close. Capture sits where the outcome is already known: a WebSocket send at
WebSocketConnection.send (so a room broadcast or heartbeat is counted once, at the connection), an
SSE send at the connection’s one enqueue path with its backlog guard reported as a fixed
backpressure category, a backplane publish around each transport’s publish(), and a receive
after the transport’s own shape and origin filters. Only fixed operations and booleans cross the
seam. The one structural addition is the gauge: current connection and group counts are not
operation records and must not expire with them, so each websocket and sse source captures a reader
over its OWN service’s size getters at registration and calls it only inside an authenticated read.
Nothing resolves the capability on a read, so a replacement provider is never invoked, and close
releases the reader.
Outbound HTTP observations (M98n) are the one letter whose producer is not a plugin: the SDK is a
browser-portable library whose only common imports are types. createObservedFetch therefore
returns a fetch wrapper together with a small registration plugin that puts the helper’s frozen,
snapshot-only source under the multi token CAPABILITIES.OUTBOUND_HTTP_DIAGNOSTICS (written in the
SDK as a literal, pinned to CAPABILITIES by a test), and its collector is a deliberate local copy
rather than an import from common. The wrapper forwards the caller’s own arguments and receiver to
exactly one delegation and reads only the response’s status. The documented composition builds the
helper only when the application factory receives its devtool composition, so a production entry
carries no wrapper at all.
15. Performance Philosophy
Minimal Allocations
The framework minimizes object allocations:
- Request context is reused where possible (pooled in high-throughput scenarios).
- Middleware chain is pre-compiled, not rebuilt per request.
- Route matching uses Hono’s TrieRouter, compiled once at registration, with candidate ranking.
- Service instances are cached (singleton scope by default).
Lazy Loading
graph LR
App[App Start] --> Kernel[Kernel Init]
Kernel --> Plugins[Plugin Registration]
Plugins -->|lazy| Services[Service Factories]
Services -->|on first get| Instance[Service Instance]
- Plugins register factories, not instances.
- Service instances are created on first
get(). - Heavy services (database, message broker) are only initialized when first used.
Plugin Loading
Plugin loading is optimized:
- Topological sort — O(V + E) where V is plugins and E is dependencies.
- Parallel registration — Plugins with no dependencies between them can register in parallel (future optimization).
- No reflection — Metadata is stored in plain objects, no runtime reflection overhead.
Caching
- Route matching results are cached per request.
- Service instances are cached (singleton scope).
- Config values are cached after first read.
- OpenAPI spec is generated once and cached.
Tree Shaking
Every package is tree-shakeable:
- ES module exports only.
- Subpath exports for granular imports.
- No top-level side effects.
Startup Optimization
- Plugins register factories, not instances.
- The middleware pipeline is compiled once at startup.
- The route table is built once at startup.
- Config validation happens once at startup.
Runtime Performance
- Hono router for fast route matching.
- Minimal middleware overhead (each middleware is a function call).
- No reflection in the hot path.
- Async/await throughout (no callback overhead).
Memory Efficiency
- No global state.
- Request context is garbage-collected after response.
- Service singletons are the only long-lived objects.
- Event listeners are cleaned up on shutdown.
16. Testing Strategy
Test Pyramid
graph TB
E2E[E2E Tests - Few]
Integration[Integration Tests - Some]
Unit[Unit Tests - Many]
E2E --> Integration
Integration --> Unit
Unit Tests
- Test individual functions and classes in isolation.
- Mock all dependencies.
- Fast execution (< 100ms per test).
- 90%+ coverage required.
Integration Tests
- Test plugin registration and service resolution.
- Test middleware pipeline execution.
- Test route matching and handler execution.
- Use in-memory adapters (no real databases, Redis, etc.).
Plugin Tests
Each plugin must test:
- Registration — Plugin registers correctly with the kernel.
- Service resolution — Services are resolvable by capability token.
- Middleware — Plugin middleware executes correctly.
- Routes — Plugin routes are registered and matchable.
- Lifecycle — Lifecycle hooks are called correctly.
- Error handling — Plugin handles errors gracefully.
- Optional dependencies — Plugin works when optional dependencies are absent.
Runtime Compatibility Tests
- Deno runs the full workspace test suite against the working tree (HEAD).
- Node and Bun run the published-artifact compatibility suite in
compat/, which consumes the packages through JSR’s npm compatibility layer — it exercises module loading, runtime detection, and the per-runtime HTTP adapters, not the current HEAD source. - Runtime-specific tests are guarded with
runtime.platform()checks.
Contract Tests
- OpenAPI spec is validated against the actual API.
- Route schemas match the actual request/response shapes.
- Plugin interfaces are validated against their implementations.
End-to-End Tests
- Test full application scenarios.
- Use
app.inject()for HTTP testing without a server. - Test the complete request lifecycle.
- Test graceful shutdown.
Test Utilities
The @setu-ts/testing package provides:
createTestApp()— Create a test application with specified plugins.createMockPlugin()— Mock a plugin’s service.inject()— Send HTTP requests without a server.createTestContext()— Create a mock request context.MockServiceRegistry— Mock service registry implementation.MockResponse— Mock response builder.FixtureManager— Manage fixture setup and reset.collectStream()— Read streaming response bodies incrementally.
17. Extending the Framework
Creating a Plugin
import type { IPlugin, IPluginContext } from '@setu-ts/common';
export function MyPlugin(options: MyPluginOptions): IPlugin {
return {
name: 'my-plugin',
version: '1.0.0',
dependencies: ['logger'],
provides: ['my-capability'],
register(ctx: IPluginContext) {
const logger = ctx.services.get('logger');
// 1. Register a service
ctx.services.register('my-capability', new MyService(options));
// 2. Add middleware
ctx.middleware.add(myMiddleware(), { priority: 300 });
// 3. Register routes
ctx.router.get('/my-route', (ctx) => {
const service = ctx.services.get<MyService>('my-capability');
return ctx.response.json(service.getData());
});
// 4. Register health check
ctx.health.register('my-service', async () => ({
status: 'up',
data: { version: options.version },
}));
// 5. Register metric
ctx.metrics.register('my_operations_total', {
type: 'counter',
help: 'Total my operations',
});
// 6. Register CLI command
ctx.cli.register('my:command', () => {
console.log('Running my command');
});
// 7. Register lifecycle hook
ctx.lifecycle.onShutdown(() => {
logger.info('My plugin shutting down');
});
},
};
}
Creating a Runtime Adapter
import { IRuntimeServices, RuntimePlatform } from '@setu-ts/common';
export class MyRuntimeServices implements IRuntimeServices {
platform(): RuntimePlatform {
return 'my-runtime';
}
version(): string {
return '1.0.0';
}
uuid(): string {
// Implement UUID generation for your runtime
}
// ... implement all IRuntimeServices methods
}
Creating Custom Middleware
function myMiddleware(options?: MyOptions): MiddlewareFunction {
return async (ctx, next) => {
// Pre-processing
const start = ctx.services.get<IRuntimeServices>('runtime').now();
await next();
// Post-processing
const duration = ctx.services.get<IRuntimeServices>('runtime').now() - start;
ctx.response.header('X-Duration', duration.toString());
};
}
Creating a Custom Decorator
import { Controller, createDecorator, Get } from '@setu-ts/decorator-plugin';
export const Cacheable = (ttl: number) => createDecorator('cacheable', { ttl });
// Usage
@Controller('/api')
class ApiController {
@Get('/data')
@Cacheable(3600)
async getData() {
return this.service.getData();
}
}
Creating a Database Adapter
IDatabaseAdapter is the full backend port — lifecycle plus data access. It lives in common
(promoted there in M52c) precisely so a backend can be implemented in any package, including one
outside this repository, without importing database-plugin. Register it through the plugin’s
'custom' arm.
There is no migrate() on the contract: programmatic migration is not honestly implementable across
the supported backends, and each one owns migrations through its own CLI.
import type { IAdapterTransaction, IDatabaseAdapter, IDataSource } from '@setu-ts/common';
export class MyAdapter implements IDatabaseAdapter {
async connect(): Promise<void> {
/* ... */
}
async disconnect(): Promise<void> {
/* ... */
}
isReady(): boolean {
/* ... */
}
// One data source per entity; it owns query evaluation end to end.
createDataSource(entity: string): IDataSource {
/* ... */
}
// The handle also opens transaction-scoped data sources, which is what makes
// a Unit of Work spanning several entities atomic.
async beginTransaction(): Promise<IAdapterTransaction> {
/* ... */
}
async rawQuery<T>(sql: string, params?: unknown[]): Promise<T[]> {
/* ... */
}
}
app.register(DatabasePlugin({ type: 'custom', adapter: new MyAdapter() }));
@setu-ts/cloudflare-plugin’s D1Adapter is the worked example.
Creating a Cache Adapter
import { ICacheStore } from '@setu-ts/common';
export class MyCacheStore implements ICacheStore {
async get<T>(key: string): Promise<T | null> {
/* ... */
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
/* ... */
}
async delete(key: string): Promise<boolean> {
/* ... */
}
// ... implement all ICacheStore methods
}
Creating a Transport Adapter
Transport adapters (message brokers, queue systems) follow the same pattern:
import { IMessageBroker } from '@setu-ts/common';
export class MyMessageBroker implements IMessageBroker {
async connect(): Promise<void> {
/* ... */
}
async disconnect(): Promise<void> {
/* ... */
}
async publish<T>(topic: string, message: T): Promise<void> {
/* ... */
}
async subscribe<T>(topic: string, handler: MessageHandler<T>): Promise<Subscription> {
/* ... */
}
}
18. Future Evolution
How the Architecture Supports Evolution
graph TB
subgraph Stable Core
Kernel[Kernel]
Common[Common]
Runtime[Runtime]
end
subgraph Evolving Plugins
Existing[Existing Plugins]
New[New Plugins]
Replaced[Replaced Plugins]
end
subgraph Future
end
Kernel --> Existing
Kernel --> New
Kernel --> Replaced
The WebSocket Plugin shipped in Milestone 46 and is no longer a future extension point. The plugin does not hang off the kernel: it depends on
commonand thehttp-adaptercapability, because an RFC 6455 handshake needs the nativeRequestand answers with a 101 carrying a socket — neither of which the kernel’sIRequest/IResponseabstraction can express, and the handshake itself stays an HTTP-adapter concern reached through the optionalIHttpAdapter.setUpgradeRouterseam. Deciding the upgrade does not, though, and has not since M70a: the kernel’s terminal handler resolvesIWebSocketServicefrom the registry and callsrouteUpgradeafter the §10 pipeline has run and before route matching, then records an upgrade intent for the adapter to act on. The pipeline is therefore not bypassed for upgrade requests — that was the pre-M70a behaviour, and it is what let an unauthenticated socket through a guarded endpoint.The gRPC Plugin shipped in Milestone 49 and is likewise no longer a future extension point. It depends on
commonalone — it needed thehttp-adaptercapability when it installed a handler there, and since M70a it installs nothing and resolves no adapter. A gRPC exchange cannot travel throughIRequest/IResponse— the former exposes no raw streaming body and the latter no trailing headers. RPC is dispatched from the kernel’s terminal handler rather than intercepted in the adapter: the kernel resolvesIGrpcService, asksclaims()whether the path belongs to gRPC, and callshandleRequestwith aRequestrebuilt from the already-mapped body — a path gRPC does not claim falls through to route matching untouched.IHttpAdapter.setRpcHandleris deprecated and consulted by nothing. Only the server shipped: gRPC/Connect clients are generated by the consuming application’s own toolchain, and the browser client story belongs to@setu-ts/sdk(Milestone 35).
The GraphQL Plugin shipped in Milestone 51 and is no longer a future extension point. Unlike its two neighbours above it needs no adapter seam at all, and that negative result is the point: a GraphQL-over-HTTP exchange is a
POSTofapplication/jsonanswered with JSON, so every byte of it fitsIRequest.json()andIResponse.send()as committed. The plugin registers two ordinary kernel routes (POSTandGETatpath, default/graphql), rides the normal middleware pipeline, and widens neitherIHttpAdapternor any runtime. Do not copy thesetUpgradeRouter/setRpcHandlerpattern by analogy — reach for an adapter seam only when the exchange genuinely cannot be expressed asIRequest/IResponse.Milestone 51b added the two long-lived transports, and neither needed an adapter seam either. Subscriptions ride the OPTIONAL
CAPABILITIES.WEBSOCKET— the plugin registers agraphql-transport-wsroute throughIWebSocketService.route(), so M46 owns the upgrade and the GraphQL plugin owns only the protocol state machine — and GraphQL-over-SSE in distinct-connections mode, which is an ordinary kernel route returningIResponse.stream()and needs no other plugin at all. Both are opt-in: with nosubscriptionsoption neither route exists, andPOST/GET /graphqlstill answers a coded400for a subscription operation. The one widening the transports did force isWebSocketRouteOptions.heartbeat, becausewebsocket-plugin’s heartbeat sweeper is global across routes and its raw text frame is not a protocol message: a route that speaks its own liveness protocol has to be able to leave the sweep.cloudflare-pluginandgraphql-pluginare therefore both examples of the same rule — widen a committed option when a second, legitimate consumer needs it, rather than duplicating the mechanism.
Versioning
- Kernel — Extremely stable. Breaking changes only in major versions.
- Common — Extremely stable. Interface additions are minor versions; removals are major.
- Plugins — Independently versioned. Each plugin follows semver.
- Starters — Versioned as bundles. A starter version pins compatible plugin versions.
Backward Compatibility
The architecture ensures backward compatibility through:
- Capability tokens — Tokens are strings. New tokens can be added without breaking existing ones.
- Optional plugins — New plugins do not affect applications that do not use them.
- Interface additions — New interface methods can be added with default implementations.
- Plugin replacement — A new plugin can replace an old one by registering the same token.
Plugin Evolution
Plugins can evolve independently:
- A plugin can add new features in minor versions.
- A plugin can deprecate old APIs with
@deprecatedJSDoc. - A plugin can remove deprecated APIs in major versions.
- A plugin can be replaced by a new plugin with the same capability token.
Capability Evolution
New capabilities can be added without affecting existing ones:
// v1.0: Existing capability
ctx.services.register('database', new DatabaseService());
// v2.0: New capability added
ctx.services.register('vector-database', new VectorDatabaseService());
// Existing applications are unaffected
Public API Stability
The public API (defined in PUBLIC_API.md) is stable:
- No breaking changes in minor or patch versions.
- Deprecation period for removed APIs.
- Major version bumps for breaking changes.
PUBLIC_API.mdis the source of truth and is updated with every API change.
Future Additions
The architecture supports future additions without breaking existing applications:
| Future Addition | How It Fits |
|---|---|
| New Database Adapter | New plugin that provides the database token with a new ORM |
| New Message Broker | New plugin that provides the messaging token with a new broker |
| New Auth Strategy | New plugin that extends the auth-plugin with a new strategy |
| New Health Indicator | New plugin that registers a health check |
| New Metric Collector | New plugin that registers a metric collector |
Migration Paths
When breaking changes are necessary:
- Announce — Document the breaking change in a release notes and
PUBLIC_API.md. - Deprecate — Mark the old API as
@deprecatedwith a migration path. - Provide replacement — Offer a new API that replaces the old one.
- Wait — Allow at least one minor version for migration.
- Remove — Remove the deprecated API in the next major version.
19. Deployment
The framework’s deployment model follows directly from the runtime abstraction in §4: a Setu-TS
application is an ordinary process that binds a socket, so it containerizes like any other, and
Cloudflare Workers — which has no listen() — deploys through its own platform instead.
Operator-facing detail lives in docs/deployment.md; this section records
only the architectural constraints.
19.1 Images build from the repository root
An application is a member of the pnpm workspace and depends on @setu-ts/* as workspace:^. Only
the root pnpm-workspace.yaml and pnpm-lock.yaml let a frozen install link those members, so a
container image must carry both alongside packages/; without the lock,
pnpm install --frozen-lockfile refuses to run. This is why the build context is the repository
root rather than the application directory. The image then compiles the members it needs and copies
the app out with pnpm deploy --prod, so it runs the packed framework (dist/) rather than
workspace source.
The no-shell variant is a Node single executable application on distroless/cc-debian12. It is 211
MB — 4.7× the 44.9 MB Deno-compiled image it replaced — because it embeds the Node runtime; its
reason to exist is the removed shell and package manager, not size. The binary links against glibc,
so distroless/cc is the smallest base it runs on.
19.2 Health endpoints are the orchestration contract
HealthPlugin registers /health, /live and /ready. The latter two are what a Kubernetes
livenessProbe and readinessProbe consume, which makes them part of the deployment contract
rather than a convenience: an application that does not register the plugin cannot express readiness
to an orchestrator at all, and will never pass a rollout.
19.3 Graceful shutdown is the application’s responsibility
ILifecycleApi.onStopping runs at the start of stop(), before the socket closes, so a service can
deregister while it is still reachable. Nothing calls stop() on SIGTERM automatically. The
framework installs no signal handler: doing so would be a side effect at import time (§11.5 of the
guidelines) and signal APIs are runtime-specific (§4.1), so it belongs to application code, which is
why stop() is a public programmatic API. Applications that skip it are killed outright and run no
shutdown hooks.
19.4 Boundary with service discovery
This layer owns the platform objects — the Kubernetes Service, the EndpointSlices it
generates, and the RBAC a pod needs to read them. Resolving a name to instances, balancing across
them, watching for changes and ejecting outliers belong to @setu-ts/service-discovery-plugin. The
split matters because the plugin cannot grant itself the permission it needs; that grant is a
deployment artifact.
Conclusion
The Setu-TS architecture is designed for longevity, replaceability, and runtime portability. By making everything a plugin, using capability tokens for communication, and abstracting runtime-specific APIs, the framework can evolve without breaking existing applications.
Contributors should internalize these principles:
- Everything is a plugin — If you are adding a feature, it should be a plugin.
- Communicate via tokens — Never import from another plugin; use the service registry.
- Abstract the runtime — Never use runtime-specific APIs outside the runtime package.
- Prefer composition — Use factory functions, not inheritance hierarchies.
- Document everything — JSDoc on every public API; update
PUBLIC_API.mdfor API changes. - Test everything — 90%+ coverage; tests on all runtimes.
- Never break backward compatibility — Deprecate, do not remove.
For implementation phases, refer to ROADMAP.md. For developer-facing API, refer to
PUBLIC_API.md. For engineering rules, refer to
AI_GUIDELINES.md.