Context & Lifecycle
You need the request id, the tenant, or the authenticated caller in a function five calls deep. The
naive fix is to thread it through every signature in between — every method grows a parameter it doesn't
use, just to pass it along. Context is request-scoped data without the threading: a transport seeds it
once per request, and any provider reads it by injecting Context, no matter how deep.
import { Context } from "@heximon/runtime";
export class Reporter {
public constructor(private readonly context: Context) {}
public trace() {
return this.context.get("requestId"); // reads the frame this request opened
}
}
Context is backed by Node's AsyncLocalStorage, so the value you read is the one that belongs to this
request — concurrent requests never see each other's data, with nothing passed by hand.
Inject the Context
Context is an ordinary provider — declare it in a constructor, nothing extra to register. There is no
"the context object" to find and pass around; class identity is enough, and a deep collaborator that
resolves its own Context still sees the frame the transport opened.
The HTTP transport opens and seeds a frame for you per request, so a handler or any provider it calls can read straight away. You only open a frame yourself in a test or a standalone script — see Open a context.
Read and write request data
The data bag is typed through the augmentable ContextData interface. Each transport and package declares
the keys it produces — HTTP adds request — and you add your own. Read with get, write with set.
this.context.get("requestId"); // ContextData["requestId"] | undefined
this.context.set("tenantId", tenantId); // available to everything downstream in this request
this.context.isActive(); // is this code running inside a request frame?
To carry your own typed value, augment ContextData once; every get/set for that key is then typed
across the whole app:
declare module "@heximon/runtime" {
interface ContextData {
tenantId: string;
}
}
The bag is typed rather than a free-form Map<string, unknown> on purpose: a misspelled key or a wrong
value type is a compile error, not a undefined you debug at runtime.
Request / correlation id
requestId is a built-in ContextData key core owns so any transport — HTTP, queue drain, WebSocket — can
seed and any provider can read it without an HTTP dependency. The HTTP transport derives it from the incoming
request in precedence order:
- the
x-request-idheader, when present and non-empty (caller-supplied passthrough); - the trace-id segment of a W3C
traceparentheader (32 lowercase-hex chars, not all-zero); - a freshly minted
uuid.v7()(edge-safe — nonode:crypto).
The id is seeded into the context frame for the request and echoed on every response as x-request-id,
so the caller can correlate a response back to its request without any extra work.
A provider reads it anywhere in the request with no threading:
import { Context } from "@heximon/runtime";
export class Reporter {
public constructor(private readonly context: Context) {}
public trace(): string | undefined {
return this.context.get("requestId"); // present for every HTTP request
}
}
set, waitUntil, and onEnd mutate the
active frame, so calling one with no frame open throws Error: No active context — which catches
background code that assumes a request it isn't part of. get and isActive are always safe: get
returns undefined when there is no frame, so a value reader never has to guard. To run outside a request
(a CLI script, a test), open a frame first with run.Authenticate once, read everywhere
This is the pattern in practice. The ladder's L07 — Auth
verifies the bearer token in JWTAuthMiddleware (it reads the cookie, verifies it, and stores the principal
on the request), and a handler further down the chain reads that principal back — nothing threaded through
any signature.
Heximon's AuthContext is a typed wrapper over Context, so the storage is exactly the
request-scoped bag above.
The controller lists JWTAuthMiddleware so it authenticates every request before a handler runs, then reads
the principal straight from its own injected AuthContext — it was never passed in:
import { AuthContext, JWTAuthMiddleware } from "@heximon/auth";
import { Controller, type Get } from "@heximon/http";
export class AdminController implements Controller<{
prefix: "/admin";
middlewares: [JWTAuthMiddleware];
}> {
private static readonly requiredScope: string = "admin:stats:read";
public constructor(private readonly authContext: AuthContext) {}
public async stats(action: Get<"/stats">): Promise<Response> {
if (this.authContext.principal() === null) {
return action.respond(401, { error: "Authentication is required." }); // no principal in the frame
}
if (!this.authContext.isAllowed(AdminController.requiredScope)) {
return action.respond(403, { error: `Requires the '${AdminController.requiredScope}' scope.` });
}
return action.respond(200, { totalRequests: 42, activeSessions: 7 });
}
}
The middleware and the handler never share an argument — they share the request frame. JWTAuthMiddleware
writes the principal once, stats reads it back, and nothing in between has to know the value exists.
Open a context
In a test or a script there is no transport to open the frame, so you open one with run(body, data?, options?). It runs body, and once body settles it fires every onEnd teardown and awaits any deferred
waitUntil work — so the frame cleans up after itself.
import { Context } from "@heximon/runtime";
const context = await app.get(Context);
await context.run(
async () => {
context.set("tenantId", "acme");
return service.report(); // anything it calls now reads tenantId from the frame
},
{ requestId: "req-1" }, // seed the frame's data bag
);
Defer background work past the response
Analytics, an audit write, a cache warm — work the caller shouldn't wait for. waitUntil(promise) hands
it to the frame instead of blocking the handler's return.
this.context.waitUntil(this.analytics.record(event)); // the response returns without awaiting this
Where the promise runs depends on the host. A serverless platform that supplies a waitUntil sink
(Cloudflare Workers does) keeps the worker alive past the response, so the response returns immediately and
the work still completes. With no sink, the frame tracks the promise and awaits it when the frame ends.
Either way you call the same method.
A long-lived producer like an SSE stream can check
hasWaitUntilSink() to learn which world it's in before deciding how to signal completion.
background(promise) is the fire-and-forget counterpart — reach for it when the work must never block the
response and must be safe to call even outside a request (a queue producer's send, a boot self-registration):
this.context.background(this.queue.publish("orders.placed", order)); // never blocks; never throws; never lost
Reach for waitUntil only when the work genuinely must complete before the frame is considered done (a
telemetry flush, a write the response implies); reach for background for everything fire-and-forget. See
the per-host behavior table below for exactly where each one runs.
Register teardown
onEnd(disposer) runs a callback when the frame ends — releasing a connection, closing a transaction,
clearing a lock. Disposers run in reverse registration order by default, so teardown unwinds the way
setup nested (release the connection after the transaction that used it).
this.context.onEnd(() => this.connection.release()); // runs when the request frame settles
Pass independentDisposers: true to run when the disposers don't depend on each other — they then run in
parallel. The default stays ordered because the common case, a transaction over a connection, needs it.
TransactionContext is built on Context: it tracks which transaction is active for the current request
and leaves the concrete handle to your database dialect, so the core runtime never reaches into SQL or ORM
specifics. You inject it the same way, but the Drizzle layer drives it for you and you rarely touch it
directly.
Bindings — host-supplied boot values
Some values aren't declared in a module — a host supplies them once at boot: the validated config, an
in-process client, a transport handle. The host passes them to createApp as bindings, and a
provider that injects one resolves it from that bag.
import { CoreConfig } from "@heximon/runtime";
// createApp is generated per app by the compiler — hosts import it from the wiring output.
import { createApp } from "./.heximon/apps/main.wiring.js";
const app = await createApp({
CoreConfig: new CoreConfig(process.env), // a typed pass-through, seeded for the whole app
});
A provider then injects CoreConfig by class identity, like any dependency:
import { CoreConfig } from "@heximon/runtime";
export class Reporter {
public constructor(private readonly config: CoreConfig) {}
public isVerbose() {
return this.config.debug;
}
}
A missing binding throws at construction, not as a silent undefined discovered deep in a service later —
the failure is loud and early, where you can fix it. InternalClient is supplied the same way: an
in-process HTTP client a host wires up so a packaged module can call the running app with no network hop,
registered only when some module injects it.
Platform bindings
Platform is the seam for deployment bindings — a Cloudflare Workers env, a queue binding, an R2
bucket. Packages and your app declare typed bindings through PlatformBindings, then read them by name.
import { Platform } from "@heximon/runtime";
const queue = Platform.binding("ORDERS_QUEUE"); // typed via the merged PlatformBindings interface
Bindings live on Platform rather than in ContextData because they're per-deployment, not per-request —
the same binding serves every request the worker handles.
Logging
Logger is a consola-backed logger you instantiate directly in a service field — it is not a DI
provider, because the active level and output format are process-wide state, not per-instance dependencies.
The natural pairing with Context is to read the correlation id off the ambient request and bind it onto a
child logger, so every line a handler writes carries the id that ties it to one request:
import { Context, Logger } from "@heximon/runtime";
export class UsersService {
private readonly logger = new Logger("UsersService");
public constructor(private readonly context: Context) {}
public async create(input: CreateUser): Promise<User> {
this.logger.child({ requestId: this.context.get("requestId") }).log("creating user");
// …
}
}
See Logging & Observability for levels, the pretty and JSON reporters,
Logger.configure, and the boot summary.
Run async setup with OnInit
A constructor is synchronous — it can't open a connection pool, run a migration, or await anything.
Implement OnInit and Heximon calls onInit() once, after your class is constructed and before
the app serves its first request. By the time it runs, everything injected into the class is already
built and initialized — so it's the safe place to do work that depends on a collaborator being ready.
import { type OnInit } from "@heximon/runtime";
import { sql } from "drizzle-orm";
import { AppDatabase } from "../database/app-database";
export class TasksRepository implements OnInit {
public constructor(private readonly database: AppDatabase) {}
// Runs once, after construction, before the first request — the async init a constructor cannot do.
public async onInit(): Promise<void> {
await this.database.getOrm().run(sql`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
)
`);
}
}
This is straight from the libsql example: the in-memory database resets on every boot, so the repository creates its table in onInit instead of a migration. The hook fires in dependency order — a class's dependencies are constructed and initialized before it is, so this.database is guaranteed live by the time onInit runs.
Run boot-time work with OnBootstrap
OnInit is lazy — a provider's onInit runs the first time something resolves it, which on the hot path is the request that needs it. That keeps cold starts lean: an app pays only for what a request touches.
But some providers must run at boot regardless of traffic — a queue consumer's poll loop, a scheduler's timers, a service registering itself with a discovery backend. Implement OnBootstrap and createApp force-loads that provider at boot: it constructs the provider and calls onBootstrap() before the app serves, while every other module stays lazy.
import { Context, type OnBootstrap } from "@heximon/runtime";
import { ServiceRegistry } from "./service-registry";
export class FeedRegistration implements OnBootstrap {
public constructor(
private readonly context: Context,
private readonly registry: ServiceRegistry,
) {}
// Runs at boot — createApp force-loads this provider whether or not a request ever arrives.
public onBootstrap(): void {
// Hand boot work to Context.background, the host-shaped fire-and-forget seam: on a freezing/evicting host
// (Cloudflare Workers) it rides the boot frame's waitUntil sink so it survives past the boot invocation; on
// a long-lived host (Node) it just runs in the background. A bare `void` would be severed on the edge.
this.context.background(this.registry.register("feed"));
}
}
Only providers that declare onBootstrap are loaded at boot — everything else stays lazy, so cold starts pay only for the eager-boot work you asked for. createApp runs the hook inside whatever boot frame the host opened, and Context.background routes the work correctly per host — never block boot, never lose it to an eviction.
The framework's own eager-boot providers use this hook: the polling queue consumers (@heximon/queue) start their drain loop in onBootstrap, and the Node schedule driver (@heximon/schedule) arms its cron timers there — so a queue-only or cron-only deploy starts working without waiting for an HTTP request.
OnInit and OnBootstrap are independent: a provider may implement either or both. When a bootstrap provider also has an onInit, its onInit (own-state setup) runs first, then onBootstrap (the standing boot work).
See Eager boot for opaque providers and boot's two phases below for the subtler cases — a useFactory-built provider, and exactly when onBootstrap is guaranteed to see onInit-established state from other modules.
Release resources with OnShutdown
JavaScript has no destructor, so there's no built-in moment to close a pool or flush a buffer. Implement OnShutdown and Heximon calls onShutdown() when the app tears down, in reverse construction order — dependents shut down before the dependencies they rely on, so nothing closes a resource another instance is still using.
import { type OnShutdown } from "@heximon/runtime";
export class Database implements OnShutdown {
public async onShutdown(): Promise<void> {
await this.pool.end(); // close the connection pool as the app stops
}
}
Implement only the hook a constructor can't replace — async initialization (OnInit) and teardown (OnShutdown). Plain synchronous setup belongs in the constructor, where it's clearer and runs eagerly. You never call either hook yourself; the compiler weaves both into the wiring it generates.
| Hook | When it runs | Order |
|---|---|---|
OnInit | After construction (lazy — on first resolution) | Dependency order — dependencies first |
OnBootstrap | At createApp, before the app serves — the provider is force-loaded regardless of traffic | After every onInit has completed (boot phase 2), in registration order |
OnShutdown | On app.shutdown() | Reverse construction order — dependents first |
Resolve a provider
get looks a class up by identity and hands back its single shared instance, constructing it (and its dependency subtree) on first call, then memoizing. It keys on the class object itself — never a name string — so two different classes that happen to share a name never collide.
const tasks = await app.get(TasksRepository); // constructs the repository and its database on first call
get is always async because resolving a token can trigger the construction of a whole subtree, and a constructor's dependency may itself have an OnInit to await. Treating resolution as async keeps that uniform whether the instance already exists or is built on the spot.
Run a task standalone
A Task is an ordinary injectable, discovered by extends Task<…> and wired like any provider. Outside a
request — a migration script, a cron entry — you resolve it through the handle and run it inside a request
context so it can read the same per-operation state your handlers see.
import { Task, type TaskResult } from "@heximon/runtime";
import { MigrationRunner } from "@heximon/persistence";
// `apply()` is idempotent — it reads the bookkeeping table, applies only the pending migrations in name
// order, and returns their names. A second run returns `[]` and leaves the schema untouched.
export class MigrateTask extends Task<string> {
public constructor(private readonly runner: MigrationRunner) {
super();
}
public override async run(): Promise<TaskResult<string>> {
const applied = await this.runner.apply();
return { result: `Applied ${applied.length} migration(s).` };
}
}
const task = await app.get(MigrateTask); // resolved by class identity
const context = await app.get(Context);
const rerun = await context.run(() => task.run()); // run inside a context frame
rerun.result; // "Applied 0 migration(s)." — the boot-time onInit already applied it once
context.run opens the per-operation frame your transports normally open per request — running the task
inside it means the task, and anything it injects, reads the same Context state as a live handler. There's
no container lookup and no global app to reach for: you hold the handle and ask it for what you need.
Warm a server at boot
By default the compiled wiring constructs your modules lazily — each module is built the first time something resolves a provider it owns, so a cold start (an edge isolate, a Workers invocation) pays only for what the first request touches. The exception is OnBootstrap providers, whose modules createApp force-loads at boot (see above).
Call preload() when you want to force a full warm-up explicitly — construct every module and run every not-yet-loaded module's OnInits (open pools, create tables) before a long-lived server starts accepting traffic. The OnBootstrap hooks already ran inside createApp, after every onInit.
await app.preload(); // construct every module and run the remaining OnInit hooks up front
Shut down gracefully
app.shutdown() runs OnShutdown on every constructed instance in reverse construction order. On Node
the generated server entry also registers SIGTERM/SIGINT handlers that drain in-flight requests
first, then shut the app down. The drain waits for all open Context.run frames to settle
(requests that are still running) before tearing down, so no request is cut short mid-flight.
const context = await app.get(Context);
// Wait up to 10 s for in-flight requests, then shut down.
await context.drain({ timeoutMs: 10_000 });
await app.shutdown();
Context.drain() resolves immediately when nothing is in flight, so calling it is always safe.
Without a timeout it waits indefinitely; a timeoutMs budget lets the host proceed to shutdown()
even if a straggler is still running. The generated server entry handles this automatically on
SIGTERM/SIGINT — reach for drain yourself only in tests or custom hosts.
You'll need this later
Eager boot for opaque providers
The compiler detects OnBootstrap by reading a provider's implementation class — so a bare class or a useClass provider is force-loaded automatically. A useValue value and a useFactory result are opaque: the compiler can't see their methods, so it can't know to load them at boot.
Mark such a provider eager: true and Heximon treats it like any other eager-boot provider — force-constructed at createApp, with its onBootstrap dispatched:
import { Context, Module } from "@heximon/runtime";
import { FeedRegistration } from "./feed-registration";
import { ServiceRegistry } from "./service-registry";
export class FeedModule extends Module({
providers: [
ServiceRegistry,
// `FeedRegistration` is built by a factory, so its hooks are opaque — `eager: true` opts it into the
// boot force-load + onBootstrap dispatch. The TYPE system REQUIRES the flag here (the factory result
// implements OnBootstrap), so forgetting it is a compile error, not silently-dropped boot work.
{
provide: FeedRegistration,
useFactory: (context: Context, registry: ServiceRegistry) =>
new FeedRegistration(context, registry),
eager: true,
},
],
}) {}
eager: true is the explicit opposite of a lazy provider — use it on any useValue/useFactory you want constructed at boot. When the produced value implements OnBootstrap, the flag is mandatory (a Module(...) config error names the offending entry otherwise), so eager-boot work can never be silently lost to a missing flag.
A useValue/useFactory provider also runs onInit and onShutdown if its value declares them (duck-typed at runtime) — so a useFactory-built pool is torn down on shutdown without any flag.
Boot is two-phase
Boot is two-phase. Phase 1 constructs each loaded module's providers and runs their onInit — including the module instance's own — at module load. Phase 2 then runs every collected onBootstrap, once, in registration order, still inside createApp, only after every force-loaded module has loaded and every onInit has completed.
So an onBootstrap hook can rely on all onInit-established state across the whole app — a boot DDL in a database module's onInit is guaranteed to finish before an outbox relay's crash-recovery drain reads the table, even when the DDL is asynchronous and lives in a different module.
Background vs waitUntil per host
waitUntil means "this must finish before the frame is done" — it blocks at frame teardown when there
is no sink, and throws if there is no active frame. background never blocks and never throws, but
exactly what "runs in the background" means depends on the host:
| Where it runs | background(p) |
|---|---|
Cloudflare / edge (has a waitUntil sink) | hands to the sink — kept alive past the response |
| Vercel / Netlify / Lambda function (no sink, freezes between invocations) | awaited at frame teardown — completes before the function freezes |
| Node / Bun / Deno (long-lived) | detached — fire-and-forget, never blocks the frame |
| no active frame (boot, a cron tick) | detached — never throws |
A bare void somePromise is severed on a freezing/evicting host when the response returns — background
is what makes detached work edge-safe. Failures are logged, never rethrown.
Selecting a real dependency vs a dev stand-in
A factory often has to choose between a real platform dependency (a hosted queue, a remote database) and
an in-process stand-in that lets vp dev and tests run without the platform. Select on what is
configured, not on which platform you're running:
{
// A real deploy delivers each drained outbox row DURABLY through the platform's own Vercel Queue; dev / CI
// keep the in-process transport — no platform. Both branches share the same TURSO_DATABASE_URL signal the
// database connection reads. `Platform.get` is the portable env accessor.
provide: OutboxRelay,
useFactory: (store: OutboxStore, memoryTarget: MemoryIntegrationEventTransport, context: Context): OutboxRelay =>
new OutboxRelay(
store,
Platform.get("TURSO_DATABASE_URL") !== undefined
? new VercelQueueIntegrationEventTransport()
: memoryTarget,
context,
),
eager: true,
}
Platform.get("KEY") !== undefined asks is this dependency configured? — the question that actually
decides which branch runs. Platform.isVercel() asks what platform am I on? instead, which fails on two
fronts.
vp dev actually runs the Node preset (Platform.current() is "node" in dev, never a synthesized
identity), so an identity check would force you to fake identity just to reach the real branch; and a
misconfigured deploy still takes the "real" branch and throws where the misconfiguration actually is,
instead of silently falling back to a stand-in.
Platform.current() / Platform.isVercel() stay correct for accurate reporting (logs, traces) — just not
for dependency availability. The flagship's apps/vercel
wires its database connection and outbox relay terminal this way, both keyed on TURSO_DATABASE_URL.
See also
- Providers & DI — provider forms and the abstract-class token convention
getresolves against. - Error Handling — error filters receive the
Requestthat triggered the error, so they can map a domain failure to the right problem+json. - Migrations — the production alternative to creating tables in
onInit. - the ladder's L04 — Database — a repository that creates its table in
onInit, plus an end-to-end test that boots the compiled app and drives its CRUD routes. - the ladder's L06 — DDD —
MigrateTaskapplied automatically fromDatabaseModule'sonInit, plus a test that proves a manual re-run is a no-op. - the ladder's L07 — Auth — middleware that authenticates a request once and stores the principal, a handler that reads it back scope-by-scope, and an end-to-end test that proves the 401/403 split.
Logging & Observability
Logger, child fields, Logger.configure, LoggerReporter pretty and json, log levels, HEXIMON_DEBUG, shipping logs to drains, opt-in wide events, and the startup summary lines.
Project Structure
The recommended directory layout for a Heximon app — one folder per feature module, the three config files, naming conventions, and the generated boot entry.