@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

AreaExports
ApplicationcreateApplication(), ApplicationOptions, IKernelApplication, InjectRequest, InjectResponse, app.diagnostics reader (opt-in)
Plugin resolutionresolvePluginOrder() (internal), dependency topological sort, cycle detection
Service registryServiceRegistry (internal), single/multi/lazy-factory registrations, request-scoped children
MiddlewareMiddlewarePipeline (internal), priority-ordered execution, short-circuit, double-next guard
RouterRouter (internal), 7 verbs, route groups, static-over-param matching preference
LifecycleLifecycleManager (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:

OptionTypeDefaultDescription
pluginsIPlugin[][]Plugins registered before start() is called.
diagnosticsKernelDiagnosticsOptionsdisabledOpt-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 through runtime.reportError when none is registered (see the unhandled-error rule below).
  • Listening requires both CAPABILITIES.HTTP_ADAPTER and a port option; otherwise start() skips server creation (so inject() 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.reportError when 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 provides CAPABILITIES.RUNTIME.
  • A failed start() can be corrected and retried only when it failed before any plugin registered (plugin resolution). Once a plugin’s register() has run, a second start() 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

ExportKind
createApplicationfunction
ApplicationOptionsinterface
IKernelApplicationinterface
InjectRequestinterface
InjectResponseinterface
KernelDiagnosticsLabelOptionsinterface
KernelDiagnosticsOptionsinterface

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)

ExportKindPurpose
createApplicationfunctionCreates a kernel application with optional pre-registered plugins

Types

ExportKindPurpose
ApplicationOptionstypeOptions for createApplication ({ plugins?: IPlugin[]; diagnostics?: KernelDiagnosticsOptions })
IKernelApplicationtypeIApplication extended with inject() for serverless request injection, and unregister(name)
InjectRequesttypeSynthetic request shape for inject() ({ method, url, headers?, body? } — bytes verbatim with no content-type default, a Blob defaulted from its own non-empty type, URLSearchParams urlencoded-defaulted, plain object and string JSON-defaulted, anything else refused)
InjectResponsetypeResponse shape returned by inject() ({ statusCode, headers, body, json<T>() })
KernelDiagnosticsOptionstypeKernel-diagnostics activation passed as ApplicationOptions.diagnostics ({ labels?: KernelDiagnosticsLabelOptions }); its PRESENCE is the activation, and an omitted option allocates nothing
KernelDiagnosticsLabelOptionstypeThe four exact-match label allowlists (plugins/capabilities/routes/middleware), each at most 256 entries of at most 160 UTF-8 bytes with no control characters

Contract notes:

  • unregister(name: string): boolean removes every pending plugin carrying the name before start(), returning true when one or more matched. Two pending plugins may share a name — the kernel refuses duplicates at start(), not at register() — so removing only the first would leave one running while the caller was told the name was gone. Plugins do not run until start(), so removing one means its register() — and any eager side effect inside it, such as a database adapter’s connect() — never happens. That is the one thing overriding a capability cannot do: an override replaces what consumers resolve, after the real plugin has already run. It throws once the application has started, mirroring register(), because start() has already read the plugin list and a silent false would report success for an operation that can have had no effect. It throws once any plugin has REGISTERED, not merely once start() was called: plugin resolution can fail before anything runs — an unsatisfied dependency, a cycle, no runtime provider — and removing the offending plugin is exactly how that is corrected, so those failures leave unregister available. Removing a plugin another declares in dependencies is not refused here — start() reports it, naming the dependent plugin and the unsatisfied capability. It does not name the removed plugin when its name differs from the token it provided, which is the usual case (database-plugin provides database), so a failure after a without reads as a missing capability rather than as the exclusion that caused it. @setu-ts/testing’s createTestApp({ app, without }) is the intended caller.
  • A failed start() is retryable only if it failed before any plugin registered — plugin resolution (an unsatisfied dependency, a cycle, no runtime provider), which is the same boundary unregister uses. Correct the plugin list and call start() again. Once any plugin’s register() has run, a second start() throws Cannot retry start() after plugins have registered … Create a new application instead.: the plugins that ran left their services (in a registry that may already be sealed), lifecycle hooks, routes and middleware behind, and possibly resources outside the kernel, none of which can be un-run. The failed attempt’s onClose hooks have already run. Restart after a successful start() + stop() is not supported either — start() throws Application has already been started.
  • hasPlugin(name: string): boolean reports whether a plugin carrying that name is pending. A pure read — it resolves and constructs nothing. It exists so a caller applying several exclusions can validate the whole set before removing any of them: unregister mutates immediately, so removing as it goes would leave earlier exclusions applied when a later name is misspelled, and the throw is recoverable, so a caller that catches it and reuses that composition root would be handed a silently altered one. createTestApp checks every without entry through this member first and names all unmatched entries in one error. It answers against the registered plugin list, which is the PENDING list only before start() — startup does not clear it, so afterwards the member still reports true for a plugin that has already run.
  • Listening requires CAPABILITIES.HTTP_ADAPTER (registered by the runtime plugin) and a port option. Without either, start() skips server creation — inject() and tests need no server.
  • The kernel’s own error responses (malformed request URL or malformed percent-escape in the path → 400; unmatched path → 404; unhandled error → 500; a request arriving while stop() is draining → 503) are written through the error responder seam (respondWithError in @setu-ts/common). With errorHandler registered they answer in the application’s configured format; with no errorHandler registered they fall back to the no-handler shape { error, detail? } — which is not the same as the default formatter’s { statusCode, message, details? } body (see the exceptions contract notes). Error formatting belongs to the exceptions package, not the kernel; the kernel only supplies the status and message. The unhandled-error 500 additionally logs the error through CAPABILITIES.LOGGER when a logger is registered.
  • inject() body semantics. InjectResponse.body is text: a byte body written with response.send(bytes) is UTF-8 decoded rather than reported as null, and json() parses it. A streaming response cannot be presented as text without draining the live stream, so inject() throws and points at app.fetch() with a web Request instead.
  • app.fetch() rejects rather than throwing synchronously when no http-adapter capability is registered, so the Workers export default { fetch: app.fetch } entry point sees a failed promise instead of an unhandled exception.
  • app.fetch is bound to its application, so it works passed around as a bare function. That entry point needs it: Cloudflare calls exported.fetch(request, …), which made this the exported object, and before the binding every such request answered 500.
  • Contribution-token pattern: ctx.health.register(), ctx.metrics.register(), ctx.openapi.addSchema(), ctx.cli.register(), and ctx.decorators.register() funnel contributions into multi-provider services under the Step-1 tokens; consumers retrieve them with services.getAll().
  • ctx.runtime is a lazy getter that resolves CAPABILITIES.RUNTIME on access, so the runtime plugin itself does not trip over it during its own registration.
  • Route middleware uses the same next()-chaining semantics as the global pipeline: a stage that responds without calling next() short-circuits, and the handler does not run. As defense-in-depth, a stage that responds AND calls next() still does not let downstream stages overwrite the response (the chain stops once the response is ended).

Agent card · TypeDoc API reference