Heximon Logo
Essentials

Context & Lifecycle

Request-scoped data with Context and the augmentable ContextData, deferred work via waitUntil and background, plus the OnInit, OnBootstrap, and OnShutdown lifecycle hooks, createApp, get, preload, and graceful shutdown with drain.

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.

reporter.ts
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:

context.d.ts
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:

  1. the x-request-id header, when present and non-empty (caller-supplied passthrough);
  2. the trace-id segment of a W3C traceparent header (32 lowercase-hex chars, not all-zero);
  3. a freshly minted uuid.v7() (edge-safe — no node: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:

src/reporter.ts
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
  }
}
Writing to the context only works inside a request frame.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:

src/admin/admin.controller.ts
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.

auth.test.ts
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.

host boot
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:

reporter.ts
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:

users.service.ts
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.

src/tasks/tasks.repository.ts
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.

src/feed/feed-registration.ts
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.

src/database/database.ts
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.

HookWhen it runsOrder
OnInitAfter construction (lazy — on first resolution)Dependency order — dependencies first
OnBootstrapAt createApp, before the app serves — the provider is force-loaded regardless of trafficAfter every onInit has completed (boot phase 2), in registration order
OnShutdownOn 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.

resolve a provider
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.

src/database/migrate.task.ts
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).` };
  }
}
a manual re-run
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.

warm the app
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.

manual graceful teardown
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:

src/feed/feed.module.ts
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 runsbackground(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:

database.module.ts
{
  // 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 get resolves against.
  • Error Handling — error filters receive the Request that 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 — DDDMigrateTask applied automatically from DatabaseModule's onInit, 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.
Copyright © 2026