@setu-ts/kernel
Plugin kernel for the Setu-TS framework: plugin registry, service registry, middleware pipeline, router, and application lifecycle.
This package is the framework’s orchestration layer. It resolves plugin dependencies, builds the
middleware pipeline and router, validates environment variables, and dispatches requests through the
pipeline to route handlers. It owns no runtime-specific behavior — every runtime operation goes
through IRuntimeServices provided by the runtime plugin.
Installation
# npm (pnpm add / yarn add / bun add work the same way)
npm install @setu-ts/kernel
# Deno
deno add npm:@setu-ts/kernel
What’s Inside
| Area | Exports |
|---|---|
| Application | createApplication(), ApplicationOptions, IKernelApplication, InjectRequest, InjectResponse, app.diagnostics reader (opt-in) |
| Plugin resolution | resolvePluginOrder() (internal), dependency topological sort, cycle detection |
| Service registry | ServiceRegistry (internal), single/multi/lazy-factory registrations, request-scoped children |
| Middleware | MiddlewarePipeline (internal), priority-ordered execution, short-circuit, double-next guard |
| Router | Router (internal), 7 verbs, route groups, static-over-param matching preference |
| Lifecycle | LifecycleManager (internal), init/bootstrap/shutdown (LIFO)/close + request/response/error |
Only the seven public exports listed above are part of the public API; all concrete classes are internal.
Kernel diagnostics (optional, since 0.8.0)
Pass diagnostics to createApplication to enable an optional, read-only view of application
composition and kernel execution, exposed as the pull-only app.diagnostics reader. An omitted
option allocates nothing — no collector, ring, or timer — and the property is absent.
const app = createApplication({
plugins: [RuntimePlugin()],
// labels is the disclosure decision: a name appears ONLY when it exactly
// matches an allowlist entry; everything else is projected as opaque ids.
diagnostics: { labels: { plugins: ['catalog'], routes: ['/items'] } },
});
await app.start();
const snapshot = app.diagnostics!.snapshot(); // bounded composition view
let cursor = 0;
const poll = setInterval(() => {
// Poll non-destructively: each read returns the events after `cursor`
// (up to 128) and reports evicted records — and any discarded by a failed
// start — as `lost`. Other readers polling
// at their own cursors are unaffected — reads never consume.
const batch = app.diagnostics!.read(cursor, 128);
cursor = batch.next;
}, 1000);
A plugin node’s version is the one exception to the allowlist: it is emitted whenever it passes a
bounded semver grammar, so enabling diagnostics discloses every registered plugin’s version.
snapshot() and read() never resolve a lazy factory, invoke application code, or mutate state,
and everything they return is frozen. Timing is monotonic from runtime registration; records before
that carry null timings. Startup failure and final shutdown clear retained metadata — a reader
then sees only the coarse state, the failure code, and drop counters. Bounded by construction: 1,024
nodes / 4,096 edges / 256 KiB per snapshot, 1,024 events / 1,024 bytes per event, with
droppedEvents and lost reporting what did not fit. See
PUBLIC_API.md
for the full contract; scripts/inspect-kernel.ts in the repository is a runnable consumer.
Usage
Create an application, register plugins, and start it:
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
const app = createApplication({
plugins: [RuntimePlugin()],
});
app.router.get('/health', (ctx) => ctx.response.json({ status: 'ok' }));
await app.start({ port: 3000 });
Test without a server using inject():
const res = await app.inject({ method: 'GET', url: 'http://localhost/health' });
console.log(res.statusCode, res.json());
Options
createApplication(options?) takes two options:
| Option | Type | Default | Description |
|---|---|---|---|
plugins | IPlugin[] | [] | Plugins registered before start() is called. |
diagnostics | KernelDiagnosticsOptions | disabled | Opt-in kernel diagnostics; see the diagnostics section above. |
For plugin options needed before registration, see Configuration a plugin needs at construction.
Everything else is configured on the application itself: app.register(plugin) adds a plugin after
construction, app.middleware.add() installs global middleware, and app.start({ port }) binds the
socket. start() with no port runs the full lifecycle without listening, which is what inject()
and the CLI’s command discovery rely on.
Rules
- No runtime-specific APIs — all timers, UUIDs, and clocks go through
IRuntimeServices. - No
console.*— the kernel writes no output of its own. It reports through the registered logger, or throughruntime.reportErrorwhen none is registered (see the unhandled-error rule below). - Listening requires both
CAPABILITIES.HTTP_ADAPTERand aportoption; otherwisestart()skips server creation (soinject()and tests need no server). - The kernel emits only bare 404/500 JSON; error formatting belongs to the exceptions package.
- An unhandled HTTP error is reported through the registered logger, or
runtime.reportErrorwhen no logger is registered. That last-resort runtime sink receives the serialized error, request id, method, and path; the client still receives an opaque 500 response. An injected runtime may omit the sink. - A runtime provider is mandatory —
start()fails fast if no plugin providesCAPABILITIES.RUNTIME. - A failed
start()can be corrected and retried only when it failed before any plugin registered (plugin resolution). Once a plugin’sregister()has run, a secondstart()throws by name — create a new application instead.
See the repository’s
PUBLIC_API.md
for the full API contract and
ARCHITECTURE.md for how this
package fits the plugin architecture.
Exports
| Export | Kind |
|---|---|
createApplication | function |
ApplicationOptions | interface |
IKernelApplication | interface |
InjectRequest | interface |
InjectResponse | interface |
KernelDiagnosticsLabelOptions | interface |
KernelDiagnosticsOptions | interface |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
API reference
Kernel diagnostics (@setu-ts/kernel + @setu-ts/common)
An optional, read-only view of application composition and kernel execution. Enable it explicitly
per application; an omitted diagnostics option creates no collector, metadata mirror, ring, or
timer anywhere in the kernel, and IApplication.diagnostics is absent.
import { createApplication } from '@setu-ts/kernel';
const app = createApplication({
plugins: [RuntimePlugin()],
// Explicit activation. `labels` is the disclosure decision: a name leaves
// the process ONLY when it exactly matches an allowlist entry.
diagnostics: {
labels: { plugins: ['catalog'], routes: ['/items'] },
},
});
await app.start();
const snapshot = app.diagnostics!.snapshot(); // DiagnosticsSnapshot
const batch = app.diagnostics!.read(0, 128); // DiagnosticsBatch
The reader is pull-only. snapshot() and read() are the whole surface: neither registers a
callback, resolves a service, invokes application code, or mutates state. Returned data is deeply
frozen; a slow or stopped reader cannot backpressure requests. DTOs (DiagnosticsSnapshot,
DiagnosticsNode, DiagnosticsEdge, DiagnosticsEvent, DiagnosticsBatch) and
IDiagnosticsSource are exported from @setu-ts/common; KernelDiagnosticsOptions and the
ApplicationOptions.diagnostics field from @setu-ts/kernel.
Capture minimization. No dynamic request path, query string, header, body, cookie, token,
session value, database value, or raw error text is ever recorded. labels is an exact allowlist
(≤256 entries per list, ≤160 UTF-8 bytes per entry, no control characters); un-allowlisted names are
projected as opaque sequential ids (p1, c1, r1, m1), never truncated into new names.
Approving a label is a disclosure decision, not a secret-detection guarantee.
One field is not allowlist-gated. A plugin node’s version is emitted whenever it passes a
bounded major.minor.patch semver grammar, independently of labels.plugins — so an application
that enables diagnostics with no labels at all still discloses the version of every registered
plugin, including the framework’s own through RuntimePlugin. A version that fails the grammar (a
git sha, a sentence, any string over 64 characters) is omitted rather than projected.
Bounds. Snapshots are capped at 1,024 nodes, 4,096 edges, and 256 KiB of compact JSON
(truncated reports omission). Events are capped at 1,024 retained records and 1,024 bytes each;
eviction is reported as lost sequence numbers, and drops as droppedEvents. Startup failure and
final shutdown clear retained metadata while preserving the original application error — a reader
then sees only the coarse failed/closed state, the failure code, and counters. A retried
start() — possible only after a failure before any plugin registered — continues the event
numbering, so a later read reports the discarded range as lost. Timing is monotonic from runtime
registration; atMs/durationMs are null before that, never fabricated.
The runnable consumer is scripts/inspect-kernel.ts; the paired throughput/latency harness is
scripts/benchmark-kernel-diagnostics.ts --mode=disabled|enabled. Network authentication and
transport for an external devtool belong to M98b, which consumes this same contract.
createApplication() (@setu-ts/kernel)
The entry point to the framework.
A capability is not resolvable from app.services until start() has run.
Signature
function createApplication(options?: ApplicationOptions): Application;
interface ApplicationOptions {
plugins?: IPlugin[];
onError?: (error: Error, ctx: RequestContext) => void;
onRequest?: (ctx: RequestContext) => void | Promise<void>;
onResponse?: (ctx: RequestContext) => void | Promise<void>;
gracefulShutdown?: boolean;
shutdownTimeout?: number;
}
interface Application {
register(plugin: IPlugin): Application;
router: RouterApi;
middleware: MiddlewareApi;
services: ServiceRegistry;
start(options?: StartOptions): Promise<void>;
stop(): Promise<void>;
inject(request: InjectRequest): Promise<InjectResponse>;
}
interface StartOptions {
port?: number;
hostname?: string;
}
Usage
const app = createApplication();
// Register plugins programmatically
app.register(RuntimePlugin());
app.register(LoggerPlugin({ level: 'info' }));
// Register an inline plugin
app.register({
name: 'hello-plugin',
version: '1.0.0',
register(ctx) {
ctx.router.get('/hello', (ctx) => ctx.response.json({ hello: 'world' }));
},
});
await app.start({ port: 3000 });
Startup logging
After adapter.listen succeeds, the kernel logs one info record through CAPABILITIES.LOGGER
when one is registered: Server listening with the port, and hostname only when one was
supplied. A port: 0 start logs Server listening on an ephemeral port with no port field. With no
logger, nothing is logged; a logger that throws never fails start(). Deno also keeps its own
native listening banner, so a Deno start shows both lines.
Route Introspection
app.router.listRoutes() returns RouteInfo entries. Each entry has an optional owner: it is the
name of the plugin whose register() call created the route, and is absent for a route added
directly through app.router. Registering the same method and path twice throws instead of silently
replacing the first handler.
Testing Without a Server
const app = createApplication({ plugins: [RuntimePlugin()] });
app.router.get('/users', (ctx) => ctx.response.json([{ id: 1 }]));
await app.start();
const response = await app.inject({ method: 'GET', url: '/users' });
console.log(response.statusCode); // 200
console.log(response.json()); // [{ id: 1 }]
For testing, prefer createTestApp() — it calls start() automatically (without binding a socket),
so you can call inject() or fetch() directly. See
Testing Package for the full API.
InjectRequest.ip?: string optionally supplies the socket peer as IRequest.ip (M113n). Omitting
it leaves the injected request without a peer.
inject() carries the body shapes a request actually has (M95c): a Uint8Array and an
ArrayBuffer pass through VERBATIM with no content-type default, since only the caller knows
whether those bytes are multipart, JSON, or an image; a Blob also passes through verbatim and
defaults content-type to its own type when that is non-empty — exactly what the platform does
for new Request(url, { body: blob }) — contributing nothing when the blob has no type; a
URLSearchParams is serialised with its own toString() and defaults content-type to
application/x-www-form-urlencoded; a plain object is JSON-serialised and a bare string is
carried as-is, both defaulting to application/json. An explicitly supplied content type always
wins, and the default is written onto a COPY of headers — a Headers instance the caller reuses
across two injected requests is never mutated, so the second request still gets its own shape’s
default. Any other shape — an array, a Date, a class instance, a number — is refused with a
TypeError naming the received type, not silently JSON-stringified: the previous behaviour turned a
Uint8Array into {"0":97,…} and every other non-string into {} while answering 200, which made
an injected multipart upload parse as an empty form.
API Reference: @setu-ts/kernel
The plugin kernel: resolves plugin dependencies, builds the middleware pipeline and router,
validates environment variables, and dispatches requests. Implemented in Milestone 2; route
matching was delegated to Hono in Milestone 22 (behind the unchanged IRouterApi contract).
This section is the authoritative export list (AI_GUIDELINES §10.5). All exports carry full JSDoc.
Route patterns are static segments, :name parameters (a word, \w+, other than __proto__), a
final optional :name?, * segments and a final prefix*. Since 0.11.0 anything else is refused
at registration with an error naming the unsupported part — a Hono regular-expression parameter
(:name{…}, whose /-spanning form TrieRouter matches in time quadratic in the path length), an
empty segment, a * inside a non-final segment, an optional parameter that is not last.
router.group() joins its prefix and each path with exactly one /. A request may carry one extra
trailing slash (/users/ reaches /users); a static route registered with a trailing slash answers
only that spelling, while a parameter route’s trailing slash is not significant (/users/:id/
answers /users/42). A pattern written without a leading slash is the same route as its
slash-prefixed spelling.
Values (runtime exports)
| Export | Kind | Purpose |
|---|---|---|
createApplication | function | Creates a kernel application with optional pre-registered plugins |
Types
| Export | Kind | Purpose |
|---|---|---|
ApplicationOptions | type | Options for createApplication ({ plugins?: IPlugin[]; diagnostics?: KernelDiagnosticsOptions }) |
IKernelApplication | type | IApplication extended with inject() for serverless request injection, and unregister(name) |
InjectRequest | type | Synthetic request shape for inject() ({ method, url, headers?, body? } — bytes verbatim with no content-type default, a Blob defaulted from its own non-empty type, URLSearchParams urlencoded-defaulted, plain object and string JSON-defaulted, anything else refused) |
InjectResponse | type | Response shape returned by inject() ({ statusCode, headers, body, json<T>() }) |
KernelDiagnosticsOptions | type | Kernel-diagnostics activation passed as ApplicationOptions.diagnostics ({ labels?: KernelDiagnosticsLabelOptions }); its PRESENCE is the activation, and an omitted option allocates nothing |
KernelDiagnosticsLabelOptions | type | The four exact-match label allowlists (plugins/capabilities/routes/middleware), each at most 256 entries of at most 160 UTF-8 bytes with no control characters |
Contract notes:
unregister(name: string): booleanremoves every pending plugin carrying the name beforestart(), returningtruewhen one or more matched. Two pending plugins may share a name — the kernel refuses duplicates atstart(), not atregister()— so removing only the first would leave one running while the caller was told the name was gone. Plugins do not run untilstart(), so removing one means itsregister()— and any eager side effect inside it, such as a database adapter’sconnect()— never happens. That is the one thing overriding a capability cannot do: an override replaces what consumers resolve, after the real plugin has already run. It throws once the application has started, mirroringregister(), becausestart()has already read the plugin list and a silentfalsewould report success for an operation that can have had no effect. It throws once any plugin has REGISTERED, not merely oncestart()was called: plugin resolution can fail before anything runs — an unsatisfied dependency, a cycle, no runtime provider — and removing the offending plugin is exactly how that is corrected, so those failures leaveunregisteravailable. Removing a plugin another declares independenciesis not refused here —start()reports it, naming the dependent plugin and the unsatisfied capability. It does not name the removed plugin when its name differs from the token it provided, which is the usual case (database-pluginprovidesdatabase), so a failure after awithoutreads as a missing capability rather than as the exclusion that caused it.@setu-ts/testing’screateTestApp({ app, without })is the intended caller.- A failed
start()is retryable only if it failed before any plugin registered — plugin resolution (an unsatisfied dependency, a cycle, no runtime provider), which is the same boundaryunregisteruses. Correct the plugin list and callstart()again. Once any plugin’sregister()has run, a secondstart()throwsCannot retry start() after plugins have registered … Create a new application instead.: the plugins that ran left their services (in a registry that may already be sealed), lifecycle hooks, routes and middleware behind, and possibly resources outside the kernel, none of which can be un-run. The failed attempt’sonClosehooks have already run. Restart after a successfulstart()+stop()is not supported either —start()throwsApplication has already been started. hasPlugin(name: string): booleanreports whether a plugin carrying that name is pending. A pure read — it resolves and constructs nothing. It exists so a caller applying several exclusions can validate the whole set before removing any of them:unregistermutates immediately, so removing as it goes would leave earlier exclusions applied when a later name is misspelled, and the throw is recoverable, so a caller that catches it and reuses that composition root would be handed a silently altered one.createTestAppchecks everywithoutentry through this member first and names all unmatched entries in one error. It answers against the registered plugin list, which is the PENDING list only beforestart()— startup does not clear it, so afterwards the member still reportstruefor a plugin that has already run.- Listening requires
CAPABILITIES.HTTP_ADAPTER(registered by the runtime plugin) and aportoption. Without either,start()skips server creation —inject()and tests need no server. - The kernel’s own error responses (malformed request URL or malformed percent-escape in the path →
400; unmatched path →404; unhandled error →500; a request arriving whilestop()is draining →503) are written through the error responder seam (respondWithErrorin@setu-ts/common). WitherrorHandlerregistered they answer in the application’s configured format; with noerrorHandlerregistered they fall back to the no-handler shape{ error, detail? }— which is not the same as thedefaultformatter’s{ statusCode, message, details? }body (see the exceptions contract notes). Error formatting belongs to the exceptions package, not the kernel; the kernel only supplies the status and message. The unhandled-error500additionally logs the error throughCAPABILITIES.LOGGERwhen a logger is registered. inject()body semantics.InjectResponse.bodyis text: a byte body written withresponse.send(bytes)is UTF-8 decoded rather than reported asnull, andjson()parses it. A streaming response cannot be presented as text without draining the live stream, soinject()throws and points atapp.fetch()with a webRequestinstead.app.fetch()rejects rather than throwing synchronously when nohttp-adaptercapability is registered, so the Workersexport default { fetch: app.fetch }entry point sees a failed promise instead of an unhandled exception.app.fetchis bound to its application, so it works passed around as a bare function. That entry point needs it: Cloudflare callsexported.fetch(request, …), which madethisthe exported object, and before the binding every such request answered 500.- Contribution-token pattern:
ctx.health.register(),ctx.metrics.register(),ctx.openapi.addSchema(),ctx.cli.register(), andctx.decorators.register()funnel contributions into multi-provider services under the Step-1 tokens; consumers retrieve them withservices.getAll(). ctx.runtimeis a lazy getter that resolvesCAPABILITIES.RUNTIMEon access, so the runtime plugin itself does not trip over it during its own registration.- Route middleware uses the same
next()-chaining semantics as the global pipeline: a stage that responds without callingnext()short-circuits, and the handler does not run. As defense-in-depth, a stage that responds AND callsnext()still does not let downstream stages overwrite the response (the chain stops once the response is ended).