Skip to content

start and StartOptions

Reference. The entry point of @btravstack/core: what start accepts, every option with its default, the compile-time gate on the module, and what comes back. For the handle it returns, see RunningApp; for the one-call main.ts, see runMain and exit codes; for the reasoning, see One process, one runtime.

Signature

ts
const start: <X, E, N>(
  module: Module<X, E, N> & StartGate<X, N>,
  options?: StartOptions,
) => RunningApp<E, RuntimeInfoOf<X>>;

start returns synchronously, never throws and never calls process.exit. Every failure lands in RunningApp.exited, an AsyncResult<ExitReport, E | RuntimeStartFailed>.

The module

Module<X, E, N>, with N inferred and then checked by the gate. Three kinds of module pass: one with no needs, one whose acquire/release provider adds Scope (the need Module.scoped discharges by opening the scope itself), and one whose configuration reads Env, which the kernel provides. A module with any other unmet need is rejected at the call site — in di's own words, and naming the port.

The runtime is a service of the module. The module exports a port declared over RuntimePort; start builds the graph, resolves that port and drives what it finds. Every starter ships such a port (HttpRuntime, TemporalRuntime, AmqpRuntime) and a module providing it; a hand-rolled runtime declares its own — see The Runtime contract.

Env wrapping

The kernel wraps the module in one that also provides Envoptions.env, default process.env — so a configuration provider anywhere in the graph reads it. If the module already provides Env itself (directly or through an import), it is booted without the kernel's copy and its own wins; di refuses two providers for one port.

The error channel

E is the module's own error type, unwrapped: a construction failure (a ConfigInvalid, a repository that could not connect) reaches exited still typed. RuntimeStartFailed is the only error the kernel adds — the runtime refused to start, or the probe server could not bind.

StartOptions

OptionTypeDefaultSemantics
envEnvironmentprocess.envThe environment the graph is configured from, provided as the Env port; also where the kernel reads its own PROBE_PORT, PRE_DRAIN_DELAY_MS, DRAIN_TIMEOUT_MS and STOP_TIMEOUT_MS. A test hands in a record.
clockClocksystemClockWhat the drain sleeps against. A test passes createFakeClock().
signalsbooleantrueInstalls the SIGTERM/SIGINT handlers and the uncaughtException/unhandledRejection ones. false disables both together.
probes{ port: number } | falseunsetUnset, the probe port is bound from PROBE_PORT in env (default 9000); false disables the probe server; { port: 0 } lets the OS choose. See Probes.
preDrainDelayMsnumberPRE_DRAIN_DELAY_MS, else 5_000Beat 2 of the drain: how long the kernel waits after readiness flips false before telling the runtime to stop accepting. Measured from the first signal, so a signal that lands mid-build is not paid twice.
drainTimeoutMsnumberDRAIN_TIMEOUT_MS, else 20_000Beat 3: how long in-flight units get to finish once the runtime has been told to stop accepting. Whatever is still open is aborted and reported abandoned. Set it beside terminationGracePeriodSeconds, in the manifest — see Tune the drain for Kubernetes.
stopTimeoutMsnumberSTOP_TIMEOUT_MS, else 5_000The deadline on stopping: how long Serving.stop and the application scope's finalisers get together before the kernel stops waiting and reports ExitReport.abandonedAt: "stop". Beat 3's deadline covers in-flight work; this one covers the teardown, which had none — a release that never settled left the process in stopping with no exit report at all. The three timings are cumulative against terminationGracePeriodSeconds: the defaults sum to 30 s exactly.
onEventEventSinkstderrSinkWhere the ten kernel events go. A throwing sink is swallowed. See Kernel events.

A per-unit scope is no longer a start option: a runtime opens one itself, through UnitHost.fork, from inside its host.run work callback — see The Runtime contract.

The gate: StartGate<X, N>

StartGate is a phantom marker intersected onto the module parameter: no argument ever carries it. It is unknown — and therefore invisible — when the module is boot-able, and a diagnostic otherwise, so a bad composition fails to match the parameter type at the call site.

ts
type StartGate<X, N = never> = [Exclude<N, Scope | Env>] extends [never]
  ? [Extract<X, RuntimeInstance>] extends [never]
    ? "NO RUNTIME — the module exports no port declared over RuntimePort"
    : [InstanceType<RuntimeResolvesOf<X>>] extends [X]
      ? unknown
      : "UNSATISFIED RUNTIME PORTS — the runtime resolves a port the module does not export"
  : { readonly "UNSATISFIED DEPENDENCIES — nothing provides": PortIdOf<Exclude<N, Scope | Env>> };
ArmFires when
UNSATISFIED DEPENDENCIESSomething in the graph needs a port nothing provides — checked first, and answered in di's own words, ending on the port's id ("OrpcRouter"). Scope and Env are the two the kernel itself discharges.
NO RUNTIMEX contains no port declared over RuntimePort. Every starter's module sugar exports one; a hand-rolled root must export its runtime port.
UNSATISFIED RUNTIME PORTSThe runtime's declared resolves are not all among the module's exports — the module's alone, never a fork's, because RuntimeHost.ctx is the application context.

runMain, and @btravstack/testing's Boot, carry the same marker. There is no arm for a UnitHost.fork module's own needs: a fork module is forked over the application context, so its needs are exactly what a starter's own needs channel already asks the composition root to supply, and UNSATISFIED DEPENDENCIES — di's own gate above, not a fourth arm here — is what refuses a root that does not.

What a failing arm prints, measured — a root exporting a Greeter and no runtime port:

text
error TS2345: Argument of type 'Module<Greeter, never, never>' is not assignable to parameter of type 'Module<Greeter, never, never> & "NO RUNTIME — the module exports no port declared over RuntimePort"'.
  Type 'Module<Greeter, never, never>' is not assignable to type '"NO RUNTIME — the module exports no port declared over RuntimePort"'.

And the unmet-need arm, on a root that imports http() and forgets provides: [router] — the mistake a hand-rolled root makes most:

text
error TS2345: Argument of type 'Module<any, ConfigInvalid, Env | OrpcRouterPort>' is not assignable to parameter of type 'Module<any, ConfigInvalid, Env | OrpcRouterPort> & { readonly "UNSATISFIED DEPENDENCIES — nothing provides": "OrpcRouter"; }'.
  Property '"UNSATISFIED DEPENDENCIES — nothing provides"' is missing in type 'Module<any, ConfigInvalid, Env | OrpcRouterPort>' but required in type '{ readonly "UNSATISFIED DEPENDENCIES — nothing provides": "OrpcRouter"; }'.

The port is named by its id, not by its type: AmqpHandlers as a type is its contract expanded — hundreds of characters of the caller's own schema, truncated long before a name is reached — where "AmqpHandlers" is what the application wrote in Port("…") and always fits.

The sentence prints because the marker rides the module parameter — an argument that fails a parameter type makes TypeScript name that type. This was a trailing ...gate rest tuple until it was not: a rest tuple leaves inference alone, but fails as an arity error, and an arity error never prints a type, so the arm's name never reached a reader. X still infers from the Module<X, …> half of the intersection — measured, and the reason the swap was free. Each arm's sentence is asserted by an expectTypeOf<StartGate<…>> in start.test-d.ts, since @ts-expect-error accepts any error.

The gate is bypassable by a cast (start(App as never)) — the ordinary TypeScript escape. Spelling phantom arguments out by hand went with the tuple.

Reading the runtime back: RuntimePort and RuntimeInfoOf

RuntimePort is Port("Runtime"), exported generic — no fixed service — so a runtime package declares its own concrete port over it and every runtime port is one id at runtime while each carries its own Resolves/Info in the type. RuntimeInfoOf<X> reads the Info back out of a module's exports, which is how RunningApp<E, RuntimeInfoOf<X>> types runtimeInfo().

ts
import { RuntimePort, start, type Runtime } from "@btravstack/core";
import { Module, Provider } from "@btravstack/di";
import { OkAsync } from "unthrown";

type HttpInfo = { readonly port: number };

const httpish: Runtime<never, HttpInfo> = {
  name: "httpish",
  resolves: [],
  start: () =>
    OkAsync({
      drain: () => OkAsync(),
      stop: () => OkAsync(),
      info: { port: 8080 },
    }),
};

class Httpish extends RuntimePort<Runtime<never, HttpInfo>> {}

const HttpishApp = Module("HttpishApp")({
  provides: [Provider(Httpish)({ inject: {}, value: httpish })],
  exports: [Httpish],
});

const app = start(HttpishApp, { env: {}, probes: false });
const info = await app.runtimeInfo(); // Result<HttpInfo | undefined, never>

Drop Httpish from exports and the call to start fails to compile against "NO RUNTIME — the module exports no port declared over RuntimePort".

Lifecycle, in order

  1. building is emitted and the probe server binds — before the graph exists, so /livez answers while it is still building.
  2. Module.scoped builds the graph; the phase moves to starting; the runtime is resolved from RuntimePort and runtime.start(host) is called.
  3. On Ok(serving), the phase moves to serving, Serving.info settles runtimeInfo(), and the kernel waits for a shutdown request.
  4. A signal drains; stop() and an uncaught exception go straight to stopping. Serving.stop() runs, the scope closes, and exited settles with an ExitReport.

Any failure before step 3 — a construction Err, a runtime that refused, a probe bind failure — emits startFailed, moves the phase to stopping then exited, and lands in exited's error channel.

Released under the MIT License.