@setu-ts/runtime

Each adapter supplies IRuntimeServices.reportError: one JSON line to stderr on Node, Bun and Deno, or the platform log channel on Workers. Serialization and write failures never escape. The kernel and errorHandler use it only without a registered logger, for unhandled errors and handled 5xx respectively; handled 4xx stays silent without a logger.

RuntimePlugin and runtime adapters providing IRuntimeServices for Node.js, Deno, Bun, and Cloudflare Workers.

This package implements the runtime-independence seam: every runtime-specific operation the framework needs is abstracted behind IRuntimeServices (defined in @setu-ts/common) and provided here under the CAPABILITIES.RUNTIME token by the RuntimePlugin. No other package ever touches process, Deno, Bun, or node:/deno:/bun: modules directly.

Installation

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

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

Local diagnostics listener

RuntimePlugin provides CAPABILITIES.LOCAL_DIAGNOSTICS_LISTENER for an explicitly activated local diagnostics connector on Deno and Node. It binds one additional 127.0.0.1 port (1024–65535), keeps the application listener separate, and exposes only an idempotent close() handle. Bun and Workers refuse before binding.

Node uses node:http directly, preserves raw request-target spellings, checks duplicate singleton header lines, and applies explicit parser/time limits and a 32-connection cap. Parser errors answer a fixed bare 400 with no body. A port conflict rejects startup; shutdown destroys open connections. See the protocol for the refusal families and trust limits.

What’s Inside

AreaExports
PluginRuntimePlugin, RuntimeOptions, HttpAdapterOptions, RequestBodyTooLargeError
DetectiondetectRuntime, GlobalScope
Deno adaptercreateDenoRuntimeServices, DenoHost, DenoFileInfo, DenoDirEntry
Node adaptercreateNodeRuntimeServices, NodeHost, NodeFsInfo
Bun adaptercreateBunRuntimeServices, BunHost, BunFileInfo
Workers adaptercreateCloudflareRuntimeServices, CloudflareRuntimeOptions
HTTP adaptersDenoHttpAdapter, NodeHttpAdapter, BunHttpAdapter, CloudflareWorkersHttpAdapter

Usage

Register the plugin in every application — it is mandatory:

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

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

app.router.get('/info', (ctx) => {
  const runtime = ctx.services.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
  return ctx.response.json({
    platform: runtime.platform(),
    requestId: runtime.uuid(),
  });
});

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

Force a specific platform (useful for testing):

RuntimePlugin({ platform: 'node' });

Options

OptionTypeDefaultDescription
platformRuntimePlatformdetectRuntime()Force a platform instead of auto-detecting.
envRecord<string, unknown>—Cloudflare Workers env. Only its string entries populate runtime.env.
adaptersRuntimeAdapterFactories—Internal: override runtime adapter factories.
httpAdaptersHttpAdapterFactoriesbuilt-in fourInternal: override HTTP adapter factories.
maxBodyBytesnumber— (unbounded)Cap on the request-body read, enforced where the body is consumed.

Bounding the request body

RuntimePlugin({ maxBodyBytes: 10 * 1024 * 1024 });

Omitted, the read is unbounded — the released behaviour, byte for byte.

This is the layer no request header can switch off. HttpSecurityPlugin({ requestSize: { maxBodySize } }) refuses on a declared Content-Length before anything is read, which is cheaper and reports earlier; a chunked request declares no length, and since the body read became lazy it happens inside the handler, after every middleware has returned. So the middleware bounds declared lengths and this option bounds the read itself. There are two knobs because the request mapping runs before any plugin and there is no channel between them: mapWebRequestToFrameworkRequest receives a Request and nothing else. Set both, and set maxBodyBytes to the same value as maxBodySize or higher.

A body past the cap rejects with RequestBodyTooLargeError, branded with a 413 HTTP status hint, so an application running errorHandler answers 413 Payload Too Large in its configured format rather than a masked 500. With no errorHandler registered the brand has no reader and the kernel’s opaque 500 answers instead.

0 refuses every request that carries a body — it does NOT disable the check, which is what 0 means for maxDepth/maxNodes/maxBatchSize elsewhere in this framework. Omitting the option is the one way to say unbounded. A value that is not a non-negative integer throws at RuntimePlugin(...); NaN in particular would make every comparison against the cap false and silently disable the bound, and Number() of an unset environment variable is exactly NaN.

For in-process oversized-upload tests on Node 24 or Node 26 (up to 26.12.0), build the multipart request body from a Blob or use inject() instead of app.fetch(new Request(..., { body: formData })) with a file. Those releases bundle undici 7.20.0–8.11.2, whose FormData request body throws an uncaught ERR_INVALID_STATE when the runtime cancels it after the cap is exceeded; undici 8.12.0 fixes it, and no released Node bundles that yet. Node 22, Bun and Deno are unaffected, as are real-socket uploads and inject(). The runtime still cancels oversized bodies to enforce the read bound.

Reporting a failed WebSocket handshake

HttpAdapterOptions accepts reportError?: (record: Readonly<Record<string, unknown>>) => void (@since 0.10.0). RuntimePlugin supplies one resolved at CALL time: the registered logger’s error, else IRuntimeServices.reportError, and a throwing logger never fails the report. The Node adapter calls it at most once per distinct message per handle when a server-side handshake fails — ws is missing or throws — before answering 500; Deno, Workers and Bun ignore the option. Omitted, the refusal is silent as before.

adapters and httpAdapters are marked @internal — they exist so unit tests can run without OS permissions or real runtime globals, not as application configuration.

env matters only on Workers: the edge has no ambient environment, so without it runtime.env is empty and ConfigPlugin reads nothing. Object bindings (KV, R2, D1, …) are filtered out here and published separately by CloudflarePlugin.

Architecture

Cross-runtime operations (UUID, random bytes, SubtleCrypto, now, hrtime, timers) are identical across Node 22+, Deno, and Bun because they rely on web-standard APIs on globalThis (the framework’s floor is Node 22; globalThis.crypto alone would have put it at 19). They are implemented once in src/services/cross-runtime.ts.

Divergent operations (platform, version, hostname, env, exit, fs, workers, dns) are implemented per-adapter via dependency injection: each factory accepts a *Host interface describing only what it needs, defaulting to the real runtime global via a single boundary cast. This makes every adapter fully unit-testable on Deno by passing a fake host — no real Node/Bun, no OS permissions.

Scope

This package provides the runtime services abstraction and the HTTP server adapters. Every adapter implements IHttpAdapter and is registered under CAPABILITIES.HTTP_ADAPTER by RuntimePlugin; app.start({ port }) throws when none is registered for the detected platform.

Platform coverage is not uniform, and the gaps are deliberate rather than pending: on Cloudflare Workers fs is undefined (no edge filesystem), workers and dns are omitted, and exit() throws — there is no process to end.

See the repository’s PUBLIC_API.md for the full API contract and ARCHITECTURE.md for how this package fits the plugin architecture.

Exports

@setu-ts/runtime

ExportKind
adaptWsModulefunction
asUpgradeEmitterfunction
bindCloudflareSocketToSinkfunction
bindDenoSocketToSinkfunction
bindWsSocketToSinkfunction
buildBunHostfunction
buildNodeHostfunction
createBunRuntimeServicesfunction
createBunWebSocketHandlersfunction
createCloudflareRuntimeServicesfunction
createDefaultCloudflareWebSocketHostfunction
createDenoDnsResolverfunction
createDenoRuntimeServicesfunction
createNodeDnsResolverfunction
createNodeRuntimeServicesfunction
createNodeWorkerHostfunction
createRuntimeServicesfunction
createUpgradeRequestfunction
createWebSocketTransportfunction
createWebWorkerHostfunction
createWsTransportfunction
detectRuntimefunction
isWebSocketUpgradeRequestfunction
loadWsModulefunction
normalizeFramefunction
rejectRawUpgradefunction
RuntimePluginfunction
toReadyStatefunction
toTransportErrorfunction
toWsReadyStatefunction
BunHttpAdapterclass
CloudflareWorkersHttpAdapterclass
DenoHttpAdapterclass
NodeHttpAdapterclass
NodeUpgradeCoordinatorclass
RequestBodyTooLargeErrorclass
RpcInterceptorStoreclass
BunFileInfointerface
BunHostinterface
BunModulesinterface
BunServeHostinterface
BunServerinterface
BunServerWebSocketinterface
BunSocketDatainterface
BunWebSocketHandlersinterface
CloudflareEnvinterface
CloudflareRuntimeOptionsinterface
CloudflareServerSocketinterface
CloudflareWebSocketHostinterface
CloudflareWebSocketPairinterface
CreateRuntimeServicesOptionsinterface
DenoDirEntryinterface
DenoDnsHostinterface
DenoFileInfointerface
DenoHostinterface
DenoServeHostinterface
DenoServerinterface
DenoSrvRecordinterface
DenoWebSocketLikeinterface
DenoWebSocketUpgradeinterface
GlobalScopeinterface
HttpAdapterFactoriesinterface
HttpAdapterOptionsinterface
NodeDnsModuleinterface
NodeFsInfointerface
NodeHostinterface
NodeIncomingMessageinterface
NodeModulesinterface
NodeServeHostinterface
NodeServerinterface
NodeWorkerLikeinterface
NodeWorkerModulesinterface
RawUpgradeSocketinterface
RuntimeAdapterFactoriesinterface
RuntimeOptionsinterface
UpgradeEmitterinterface
WebSocketLikeinterface
WebWorkerGlobalsinterface
WebWorkerHostOptionsinterface
WebWorkerLikeinterface
WsModuleLikeinterface
WsServerLikeinterface
WsSocketLikeinterface

@setu-ts/runtime/worker

ExportKind
defineWorkerTaskfunction

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

API reference

RuntimePlugin() (@setu-ts/runtime)

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

Registration

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

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

Deno shutdown

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

Bounding the request body

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

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

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

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

Reporting a failed WebSocket handshake

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

Accessing Runtime Services

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

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

Available Runtime Services

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

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

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

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

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

type RuntimeSignal = 'SIGTERM' | 'SIGINT';

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

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

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

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

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

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

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

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

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

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

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

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

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


API Reference: @setu-ts/runtime

RuntimePlugin and runtime adapters providing IRuntimeServices for Node.js, Deno, Bun, and Cloudflare Workers.

M23 replaced the old HTTP server adapters with the new IHttpAdapter contract (setHandler/fetch/listen/close) and added the Cloudflare Workers adapter.

M87 widened two of those members to accept and return sync-or-async — setHandler now takes (request: IRequest) => IResponse | Promise<IResponse> and fetch returns Response | Promise<Response>. This is breaking for an out-of-repo adapter: the handler’s return type sits in a contravariant position, so an implementation declaring the narrower Promise-only shape is no longer a faithful implementor (TypeScript’s bivariant method parameters mean it still compiles — the break surfaces when a handler that answers synchronously reaches it). Callers are unaffected, since await works on both.

The widening exists because a request the kernel can answer without awaiting must not be wrapped back into a promise: @hono/node-server serves a response through its synchronous fast path only when the handler returns a non-promise, so a single eagerly-async link foreclosed that path for every request. An adapter implementing this contract should branch on promise-ness rather than awaiting:

const result = this.handler(frameworkRequest);
return isPromiseLike(result) ? Promise.resolve(result).then(finish) : finish(result);

Values (runtime exports)

ExportKindPurpose
RuntimePluginfunctionCreates the runtime plugin (registers CAPABILITIES.RUNTIME)
detectRuntimefunctionDetects the current runtime platform ('node' | 'deno' | 'bun' | 'cloudflare-workers')
createRuntimeServicesfunctionCreates IRuntimeServices for the detected platform, without an application
buildNodeHostfunctionBuilds a NodeHost from injected NodeModules (defaults to real node: built-ins)
buildBunHostfunctionBuilds a BunHost from injected BunModules (defaults to node: built-ins)
createDenoRuntimeServicesfunctionCreates IRuntimeServices backed by Deno APIs
createNodeRuntimeServicesfunctionCreates IRuntimeServices backed by Node.js APIs
createBunRuntimeServicesfunctionCreates IRuntimeServices backed by Bun APIs
createCloudflareRuntimeServicesfunctionCreates IRuntimeServices backed by Cloudflare Workers APIs (edge-compatible)
DenoHttpAdapterclassDeno HTTP server adapter implementing IHttpAdapter
NodeHttpAdapterclassNode.js HTTP server adapter implementing IHttpAdapter
BunHttpAdapterclassBun HTTP server adapter implementing IHttpAdapter
CloudflareWorkersHttpAdapterclassCloudflare Workers HTTP adapter implementing IHttpAdapter (fetch-only, no listen)
createWebWorkerHostfunctionCreates an IWorkerHost over the web Worker API (Deno/Bun); throws if Worker absent
createNodeWorkerHostfunctionCreates an IWorkerHost over node:worker_threads
defineWorkerTaskfunction@setu-ts/runtime/worker subpath. Registers a worker module’s task handler
isDenoHttpServerHandlefunctionType guard for DenoHttpServerHandle
isNodeHttpServerHandlefunctionType guard for NodeHttpServerHandle
isBunHttpServerHandlefunctionType guard for BunHttpServerHandle

WebSocket upgrade support (Milestone 46) — shared primitives:

ExportKindPurpose
isWebSocketUpgradeRequestfunctionWhether request headers describe an RFC 6455 upgrade (Upgrade + Connection)
createWebSocketTransportfunctionWraps a web-API socket as an IWebSocketTransport
normalizeFramefunctionNormalizes an inbound frame payload to string | Uint8Array
toReadyStatefunctionMaps the web WebSocket numeric readyState to WebSocketReadyState
toTransportErrorfunctionCoerces an error-event payload into a real Error

Per-runtime upgrade seams:

ExportKindPurpose
bindDenoSocketToSinkfunctionBinds a Deno.upgradeWebSocket socket’s on* handlers to a WebSocketEventSink
createBunWebSocketHandlersfunctionBuilds the serve-time handler object for Bun.serve’s websocket option
bindCloudflareSocketToSinkfunctionAccepts a Workers WebSocketPair server half and binds it to a sink
createDefaultCloudflareWebSocketHostfunctionBuilds the Workers host from the real WebSocketPair global
buildUpgradeResponseInitfunctionBuilds the Workers 101 ResponseInit carrying the client socket
NodeUpgradeCoordinatorclassOwns the ws server for one Node adapter and performs the raw handshake
adaptWsModulefunctionNarrows an imported ws module to WsModuleLike
loadWsModulefunctionLazily imports ws; throws with the install command if absent
bindWsSocketToSinkfunctionBinds a ws socket’s events to a WebSocketEventSink
createWsTransportfunctionWraps a ws socket as an IWebSocketTransport
toWsReadyStatefunctionMaps a ws numeric ready state to WebSocketReadyState
createUpgradeRequestfunctionRebuilds a web Request from Node’s upgrade event arguments
asUpgradeEmitterfunctionProbes a Node server handle for the raw upgrade event
rejectRawUpgradefunctionWrites an HTTP status line to a raw socket and destroys it

Types

ExportKindPurpose
RuntimeOptionstypeOptions for RuntimePlugin (platform, env)
CreateRuntimeServicesOptionstypeOptions for createRuntimeServices (platform, adapters, env)
RuntimeAdapterFactoriestypePlatform → runtime adapter factory map
GlobalScopetypeInjectable global scope shape for detectRuntime
DenoHosttypeHost interface for the Deno adapter (extension point)
DenoFileInfotypeFile info returned by DenoHost.stat()
DenoDirEntrytypeDirectory entry yielded by DenoHost.readDir() (an AsyncIterable)
NodeHosttypeHost interface for the Node adapter (extension point)
NodeFsInfotypeFile info returned by NodeHost.stat()
NodeModulestypeInjectable Node built-ins for buildNodeHost (testing seam)
BunHosttypeHost interface for the Bun adapter (extension point)
BunFileInfotypeFile info returned by BunHost.stat()
BunModulestypeInjectable built-ins for buildBunHost (testing seam)
DenoHttpServerHandletypeInternal server handle for DenoHttpAdapter
NodeHttpServerHandletypeInternal server handle for NodeHttpAdapter
BunHttpServerHandletypeInternal server handle for BunHttpAdapter
CloudflareWorkersHttpServerHandletypeInternal server handle for CloudflareWorkersHttpAdapter
DenoServeHosttypeInjectable host interface for DenoHttpAdapter (extension point)
NodeServeHosttypeInjectable host interface for NodeHttpAdapter (extension point)
BunServeHosttypeInjectable host interface for BunHttpAdapter (extension point)
BunServertypeBun server handle returned by Bun.serve
HttpAdapterFactoriestypePlatform→adapter factory map for RuntimePlugin
WebWorkerGlobalstypeInjectable seam for createWebWorkerHost (Worker + concurrency)
WebWorkerLiketypeMinimal web Worker shape the host consumes
NodeWorkerModulestypeInjectable seam for createNodeWorkerHost (Worker + availableParallelism)
NodeWorkerLiketypeMinimal worker_threads.Worker shape the host consumes
WebSocketLiketypeMinimal web WebSocket shape the adapters drive
DenoWebSocketLiketypeA web socket exposing the on* handler properties (Deno)
DenoWebSocketUpgradetypeResult shape of Deno.upgradeWebSocket ({ socket, response })
BunServerWebSockettypeMinimal Bun ServerWebSocket shape, carrying data
BunSocketDatatypePer-socket data Bun carries from upgrade() to its handlers ({ sink })
BunWebSocketHandlerstypeThe serve-time handler object for Bun.serve’s websocket option
CloudflareServerSockettypeWorkers server-half socket (accept() + addEventListener)
CloudflareWebSocketPairtypeA created Workers pair ({ client, server })
CloudflareWebSocketHosttypeInjectable seam for the Workers upgrader (extension point)
CloudflareUpgradeResponseInittypeThe Workers 101 response init, including the webSocket member
WsModuleLiketypeStructural facade over the ws module ({ WebSocketServer })
WsServerLiketypeStructural facade over a noServer ws WebSocketServer
WsSocketLiketypeStructural facade over a ws socket
NodeIncomingMessagetypeMinimal node:http.IncomingMessage shape for building an upgrade Request
RawUpgradeSockettypeMinimal raw duplex socket shape a refusal writes to
UpgradeEmittertypeA server handle that emits the raw upgrade event

Contract notes:

  • The Bun and Deno host defaults are backed by real APIs (retro review, Part 2). buildBunHost() builds the default BunHost from node:fs (sync), node:os, and node:process — which Bun implements — NOT from members of the Bun global: Bun’s file API is Bun.file() / Bun.write(), and it has no readFile/stat/readdir/mkdir/rm/realPath/hostname/exit. Only Bun.version is read from the global. On the Deno side, DenoHost.readDir() is named and shaped after the real Deno.readDir — an AsyncIterable, consumed with for await. Both defaults are exercised against their real APIs by the test suite, not only through injected fakes.

  • IRuntimeServices.env is a snapshot on Deno and Workers, taken when the services were created (Deno.env.toObject(), the per-invocation Workers bindings object), and a live pass-through of process.env on Node and Bun. Nothing in the framework mutates the environment at runtime.

  • M23 replaced M39’s HTTP server adapters. The IHttpAdapter contract now exposes the web-standard fetch entry: setHandler installs the framework handler, fetch is the universal entry point callable without listen (Cloudflare Workers), listen binds a real TCP socket, and close tears it down. Adapters (NodeHttpAdapter, DenoHttpAdapter, BunHttpAdapter, CloudflareWorkersHttpAdapter) are registered under CAPABILITIES.HTTP_ADAPTER via RuntimePlugin.

  • M46 added a fifth, OPTIONAL member: setUpgradeRouter?(router). It installs a WebSocketUpgradeRouter consulted for every inbound WebSocket upgrade before the request is mapped to an IRequest and enters the middleware pipeline. That ordering is a correctness requirement, not an optimization: the shared mapping pre-reads the body via arrayBuffer(), and Deno.upgradeWebSocket fails once the request body has been disturbed. The router answers accept (with the WebSocketEventSink the adapter binds its native socket into), reject (with a status), or null to fall through — so installing a router never changes the behavior of non-WebSocket traffic. Because the member is optional, adapters written before this seam remain valid implementations; consumers must degrade gracefully when it is absent (see WebSocketPlugin, which reports available: false and fails route() with a typed error). All four first-party adapters implement it.

  • M49 added a sixth, OPTIONAL member: setRpcHandler?(handler). It installs an RpcFetchHandler consulted for every inbound request after the WebSocket upgrade short-circuit (where one exists) and before the request is mapped to an IRequest. The ordering is again a correctness requirement: a gRPC exchange needs the raw streaming body and emits trailers, neither of which IRequest/IResponse can express, and the shared mapping pre-reads the body. The handler answers a Response (handled as RPC) or null to fall through, so installing one never changes the behavior of non-RPC traffic. The handler is consulted exactly once per request, and one returning null MUST leave the body unread — the adapter maps that same Request afterwards, so a consumed body makes the fall-through fail with “Body already consumed”. Decide from method, URL and headers (the gRPC plugin matches on a path prefix alone); a handler that must inspect the body has to read request.clone(). Because the member is optional, adapters written before this seam remain valid implementations; consumers must degrade gracefully when it is absent (see GrpcPlugin, which reports available: false and throws GrpcUnavailableError from handleRequest). All four first-party adapters implement it.

  • Socket peer (M113n). IRequest.ip?: string is the socket peer on Node, Deno and Bun; the client behind a proxy is CLIENT_IP_STATE_KEY. IPv4-mapped peers are normalized to dotted IPv4. Workers, plain adapter.fetch() and the local diagnostics listener supply no peer. NodeServeHost.serve’s fetch environment adds incoming?: { socket?: { remoteAddress?: string | undefined } } (matching Node’s native member). DenoServeHost.serve’s callback accepts optional info?: { remoteAddr?: { hostname?: string } }. BunServer adds optional requestIP?(request: Request): { address: string } | null. Older injected hosts that supply none of these still compile and produce no peer.

  • The RuntimePlugin is mandatory in every application. It registers at PLUGIN_PRIORITY.HIGHEST so its services are available to all other plugins during registration.

  • Each adapter factory accepts an injectable *Host interface (the documented extension point for custom runtimes). The default host binds to the real runtime global via a single sanctioned as cast; no other casts are used.

  • detectRuntime() accepts an injectable globals parameter (default globalThis) so all detection branches are testable without real runtimes.

  • createRuntimeServices(options?) builds services without an application. Use it when the runtime is needed before any plugin exists — reading the environment to decide which plugins to register, which is what createFullStackAppFromConfig does. RuntimePlugin.register calls this same function, so detection, the platform → adapter lookup, and the No runtime adapter factory for platform: <p> error exist once rather than twice.

    import { createRuntimeServices } from '@setu-ts/runtime';
    import { loadConfig } from '@setu-ts/config-plugin';
    
    const config = await loadConfig(createRuntimeServices());

    Building a second instance alongside the application’s own is safe: the adapters are stateless facades over platform globals, holding no connection, cache, or handle registry, and nothing compares them by identity. One caveat — env is a snapshot taken at construction, not a live view, so a variable set between two constructions is visible only to the later instance.

  • env on Cloudflare Workers. There is no ambient environment on the edge: bindings and variables arrive as the env argument of the fetch handler. Both RuntimePlugin and createRuntimeServices therefore take an env option, and without it runtime.env is empty on Workers, so ConfigPlugin and the secrets EnvProvider read nothing. Pass what the platform provides:

    import { env } from 'cloudflare:workers';
    
    const app = createApplication({ plugins: [RuntimePlugin({ env })] });

    Only the record’s string entries populate runtime.env, which is contracted as Readonly<Record<string, string | undefined>>; object bindings are filtered out by the pure splitWorkerEnv in common and reached through CAPABILITIES.CLOUDFLARE instead. Passing them through unfiltered would hand ConfigPlugin a [object Object] for every KV namespace. The option is ignored on Deno, Node, and Bun, which read their own ambient environment.


Agent card · TypeDoc API reference