@zudojs/runtime
Application runtime orchestrator for the Zudojs framework. Manages the complete lifecycle from creation through shutdown with deterministic dependency ordering, rollback, signal handling, readiness tracking, and health checks.
OVERVIEW
A real application is made of several pieces: a database connection, a queue, an HTTP server. They have to come up in the right order — the server should not accept traffic before the database is connected — and go down in the opposite order, so nothing is torn out from under something still using it.
A runtime is the thing that does that for you. You hand it your pieces, it works out the order from the dependencies you declared, starts them one by one, and when the process is asked to shut down it stops them in reverse.
Each piece you hand it is a module: a plain object with an id, a name, an optional list of module ids it depends on, and up to four hook functions the runtime calls at the right moments.
When you need it
- → Your process owns resources that must be opened before use and closed on exit.
- → One piece of your app must be up before another one starts.
- → You deploy somewhere that sends SIGTERM and expects a clean exit (Kubernetes, Docker, systemd).
- → You need a readiness endpoint that only says "yes" once your dependencies really answer.
When you don't
- → A script that runs top to bottom and exits. There is no lifecycle to manage.
- → A library. Libraries should not start processes; leave that to the application that uses them.
- → You already use
createApplication()from @zudojs/core, which wires a runtime for you.
How this fits with core and lifecycle
@zudojs/core owns the Module contract and the application object. It defines what a module is; this package defines when each of its hooks is called. Reach for core when you are writing modules, and for this package when you are booting a process yourself.
@zudojs/lifecycle is the same idea one level down: it orders and starts arbitrary components, not Zudo modules, and knows nothing about runtime status, readiness or health. Use it to sequence a handful of objects inside a larger program; use this package to run the whole process.
INSTALLATION
Install the package together with the services it orchestrates. All of them ship at the same version.
The package also pulls in @zudojs/errors, @zudojs/constants and @zudojs/config as regular dependencies, so you do not install those yourself unless you import from them directly. Node 24 or newer is required.
QUICK START
This is a complete program. It defines one module, builds a runtime around it, starts it and stops it.
What you should see, in this order (interleaved with the runtime's own log lines):
environment and applicationName are the only required options.
RUNTIME STATES
A lifecycle state is one word describing where the runtime is right now: not started yet, coming up, up, going down, down, or broken. Read it from runtime.state.
The runtime only moves between states along fixed paths. Ask for an illegal move and you get a RuntimeStateError instead of a half-started process.
| From | Can move to |
|---|---|
| created | initializing, stopped, failed |
| initializing | initialized, failed |
| initialized | starting, failed |
| starting | running, failed |
| running | stopping, failed |
| stopping | stopped, failed |
| stopped | nothing — this is the end |
| failed | stopping, stopped |
start() passes through every state in order: created → initializing → initialized → starting → running. onInitialize hooks see initializing and onReady hooks see starting, and the runtime publishes runtime.initialized and runtime.starting between the two phases. RUNTIME_STATE_TRANSITIONS no longer allows initializing → running or initialized → running. In 1.2.1 the two middle states were declared but never entered, and onReady ran while the state still read initializing.
failed is not the end. A failed start only rolls back the modules that actually started, so you can still call stop() to release everything else. Since v1.3.0 the state helpers agree: TERMINAL_STATES is ["stopped"] and isTerminalState("failed") is false.
The same rules are available as functions, if you want to check a state without touching a runtime.
For more than the bare state, read runtime.status. It is recomputed every time you read it, so it never goes stale.
state === "stopped" with a non-empty shutdownFailures means the runtime finished shutting down but some modules did not release their resources. Log that list; do not treat it as a clean exit.
MODULES AND ORDER
A module declares what it needs through dependencies — a list of other module ids. The runtime sorts modules so that a module's dependencies are always brought up first, and torn down last.
Each module can implement any of four hooks. The runtime calls them with a ModuleContext, and skips any hook a module does not define.
| Hook | When it runs | Order |
|---|---|---|
| onInitialize | Bringing the module up: open connections, read config. | Dependencies first |
| onReady | After every module has initialized: start accepting work. runtime.state is starting here (it was still initializing before v1.3.0). | Dependencies first |
| onShutdown | Shutting down: stop accepting work, drain. | Reverse |
| onDestroy | Last call: release everything the module still holds. | Reverse |
This example has three modules in a chain. Watch the printed order.
Even though the map lists api first, this prints:
You can compute that order yourself, without a runtime. The dependency functions take a map of module id to its dependency ids.
RuntimeDependencyError, and a dependency cycle throws RuntimeCircularDependencyError. Both surface at startup, not later at runtime.
STARTUP AND SHUTDOWN
start() initializes every module in order, then readies them. If any module throws, the runtime rolls back: modules that already started are stopped and destroyed in reverse, and the state becomes failed.
start() does not re-throw the error your module threw. It rejects with a RuntimeStartError whose cause is your original error, whose phase is "initialize" (an onInitialize threw) or "start" (an onReady threw), and whose failedModuleId names the module. So error instanceof MyDbError is always false in the catch block; test error.cause instead. Since v1.3.0 two subclasses narrow it down, and instanceof RuntimeStartError still matches both:
RuntimeInitializationError(phase: "initialize") when anonInitializehook fails, or when the configuration manager fails to load (then with nofailedModuleId).RuntimeRollbackErrorwhen startup fails and the rollback that follows also fails.phase,failedModuleIdandcausedescribe the startup failure,originalErrorholds the errorstart()would otherwise have thrown,rollbackErrorwhat failed during rollback (anAggregateErrorwhen several modules failed), and the message names both, for exampleModule "api" failed during initialization. Rollback also failed: close failed. Before v1.3.0 a rollback failure was only logged.
A startup that outlives startupTimeout rejects with RuntimeTimeoutError instead, which has no cause.
stop() goes the other way, and is safe to call more than once — stopping an already-stopped runtime does nothing.
Handling a failed start, and getting at the original error:
With a module whose onInitialize throws new Error("connect ECONNREFUSED"), this prints module database failed in initialize: Error: connect ECONNREFUSED.
Who disposes the container
The container is passed in, so by default the caller owns it: stop() calls every module's onShutdown and onDestroy but leaves the container alone, and any SINGLETON it created (a pool, a file handle, a client with open sockets) is still open after runtime.stop() resolves. Since v1.3.0 you can hand ownership to the runtime with disposeContainerOnStop: true: stop() then disposes the container after every module has shut down and been destroyed, including a signal-driven stop and stop() on a runtime that never started. A disposal failure is recorded in status.shutdownFailures under the id "(container)" (exported as CONTAINER_SHUTDOWN_ID) and does not fail the stop.
Without the option, dispose the container yourself after the runtime has stopped (modules may still resolve services while they shut down, and a disposed container throws on every use): await runtime.stop(); await container.dispose();.
The fatal-error path (uncaughtException, unhandledRejection) calls process.exit(1) as soon as the stop settles, so an asynchronous dispose() you start yourself from a runtime.stopped handler may not finish there. disposeContainerOnStop: true avoids that, because the disposal is part of stop() itself.
Two options bound how long either direction may take. A module whose hook never settles no longer hangs the process forever.
| Option | Default | What it bounds |
|---|---|---|
| startupTimeout | 60000 | The whole startup sequence. On expiry, startup fails with a timeout error and rolls back; no further module hook starts, and a module whose hook finishes late is shut down and destroyed. |
| shutdownTimeout | 30000 | The whole shutdown sequence. On expiry stop() throws RuntimeStopError and the state becomes failed. |
READINESS AND HEALTH
Liveness asks: is this process still worth keeping alive? If the answer is no, the right fix is to restart it.
Readiness asks something narrower: should this process be sent traffic right now? A process can be perfectly alive and still not ready — it is starting up, or its database went away for a minute. Restarting it would not help; you just stop sending it requests until it recovers.
A readiness check is a named function returning a boolean (or a promise of one). Register as many as you have dependencies worth checking.
Registering a check and re-running it:
runReadinessChecks() passes it. That is deliberate — a check nobody has evaluated is not evidence of anything.
runtime.health is derived, never stored. While the runtime is running it is healthy if every check passes and degraded if any fails; every other lifecycle state maps straight onto a health state.
| Lifecycle state | health.state |
|---|---|
| created, stopped | unknown |
| initializing, initialized, starting | starting |
| running | healthy or degraded, from the checks |
| stopping | stopping |
| failed | unhealthy |
Wiring the two ideas to two probe endpoints, as a fragment inside your HTTP handler:
A check that throws counts as failing, and its message is recorded on the check. A check that does not settle within 5 seconds is recorded as failed too, so one hanging probe cannot wedge your readiness endpoint.
SIGNALS
When your platform wants your process gone, it sends a signal — a small message from the operating system. The runtime listens for these and calls stop() for you, so you rarely write signal code yourself.
| Signal or event | What the runtime does |
|---|---|
| SIGTERM | Graceful shutdown. The process stays alive until every onShutdown has finished, then exits on its own: code 0 when the shutdown succeeded, code 1 when it failed. A signal during startup waits for startup to settle, then stops. A second SIGTERM during shutdown exits immediately. |
| SIGINT | Same as SIGTERM. This is Ctrl-C. |
| uncaughtException | Logs the error, shuts down, then exits with code 1. |
| unhandledRejection | Same as an uncaught exception. |
Handlers are attached before modules start, so a signal arriving mid-boot is still honoured. Turn them off — in tests, or when your host owns process signals — with two options:
process.on("SIGTERM", ...) that also calls stop() while handleSignals is on. You get two shutdowns racing; the second signal is then read as "exit now".
How a signal-driven shutdown ends
With handleSignals on, the signal handler holds the event loop open for the whole graceful shutdown, so onShutdown runs to the end even under plain node, after your server has closed and nothing else keeps the process alive. The runtime never calls process.exit() here. It sets process.exitCode and lets the process exit on its own:
| What happened during shutdown | Final state | Exit code | Events and log |
|---|---|---|---|
| Every hook finished | stopped | 0 (left alone) | runtime.stopped |
An onShutdown threw | stopped | 1 | runtime.module.failed, then runtime.stopped; logs Shutdown handler failed. with a RuntimeSignalError |
The shutdown outlived shutdownTimeout | failed | 1 | runtime.failed (phase: "stop") once; logs Shutdown handler failed. with a RuntimeSignalError |
Tested under plain node with a module whose onShutdown clears the last interval and then waits on an unref'd 300 ms timer, and shutdownTimeout: 500:
An orchestrator such as Kubernetes or systemd sees the non-zero exit without any code of yours. You no longer need to set process.exitCode from a runtime.failed handler; subscribe to runtime.failed and runtime.module.failed only to report the failure.
SIGTERM/SIGINT under plain node could end the process at once with code 0, with the runtime still running or stopping and onShutdown unfinished, whenever nothing else kept the event loop alive (tsx hid this by keeping the loop alive). A failed signal-driven shutdown was only logged, as Shutdown failed., and the exit code stayed 0, so this page advised setting process.exitCode = 1 in a runtime.failed handler; that workaround can go. A failed stop also published runtime.failed twice, and a module failure during startup did too; each now publishes it once.
EVENTS
The runtime announces what it is doing on the event bus you passed in. Subscribe if you want to log boot progress, export metrics, or react to a health change without polling.
Listening for health changes:
Eighteen event types are emitted, in four families:
- → Runtime —
runtime.initializing,.initialized,.starting,.running,.stopping,.stopped,.failed..initializedand.startingare published since v1.3.0, and since v1.3.1 theRuntimeEventTypeunion includes them too. There is noruntime.createdevent. - → Module —
runtime.module.initializing,.initialized,.starting,.started,.stopping,.stopped,.failed - → Shutdown —
runtime.shutdown.drain,runtime.shutdown.complete - → Derived state —
runtime.health.changed,runtime.readiness.changed
Every payload carries runtimeId, timestamp and state. Module events add moduleId and moduleName; failure events add error and phase. Set emitEvents: false to silence all of them.
RUNTIME CONTEXT
The context is one object holding everything about the running application: who it is, how it is doing, and the shared services. Read it from runtime.context. Like status, it is rebuilt on every read, so the values are always current.
Anything you put in metadata at creation shows up here — handy for a region, a build number, or a pod name.
Modules get a different object, a ModuleContext from @zudojs/core, giving them a logger, the container, and configuration. Configuration is backed by a manager you supply; without one, modules see an empty configuration rather than a crash.
context.application is only available if you pass an application in the same dependencies object. Reading it without one throws a clear error instead of handing back an empty stub.
MULTIPLE RUNTIMES
One process sometimes runs more than one thing — an API and a background worker, say. RuntimeRegistry keeps them together so you can start and stop them as a unit. You register each one under an id of your choosing.
Runtimes start in registration order and stop in reverse. If one fails to start, the ones already started are stopped again before the error reaches you, so you never keep a half-booted process.
unregister(id) only drops the reference and returns whether there was one — it does not stop the runtime. Use removeAndStop(id) when you want both.
TESTING
The test helpers live behind their own entry point, @zudojs/runtime/testing, so a test-only module never ends up in your production bundle. They are not exported from the package root.
withTestRuntime builds a runtime with signals off, starts it, runs your callback, and stops it — even if the callback throws.
When you need the runtime object itself, build one with createTestRuntime and drive it by hand. It creates its own container and, since v1.3.0, disposes it on stop() (disposeContainerOnStop: true); pass disposeContainerOnStop: false to keep it.
createMockModule records its own calls in calls and callOrder. They are plain counters, not vi.fn() spies, so do not call toHaveBeenCalled() on them.
API REFERENCE
The Runtime object
| Member | What it does | Notes |
|---|---|---|
| start() | Brings every module up in dependency order. | Rolls back and throws RuntimeStartError (original on cause; RuntimeInitializationError or RuntimeRollbackError where they apply) on failure. Calling it twice is a no-op. |
| stop() | Tears every module down in reverse order. | Works from failed; safe to call repeatedly. Disposes the container only with disposeContainerOnStop: true. |
| state | The current lifecycle state. | One RuntimeState string. |
| status | State plus timestamps, error and shutdown failures. | Recomputed on every read. |
| context | Identity, services and live state in one object. | Recomputed on every read. |
| health | Derived health, with per-check results. | Always unknown when trackHealth is off. |
| ready | Whether every readiness check passes. | Boolean. |
| readiness | Readiness state plus a map of check results. | Each result carries ready, message, durationMs. |
| registerReadinessCheck(name, check) | Adds a named check. | Starts out failing until first run. |
| removeReadinessCheck(name) | Removes a check. | Returns whether one existed. |
| runReadinessChecks() | Re-evaluates every check. | Each check is bounded by a 5-second timeout. |
Options
| Option | Default | What it does |
|---|---|---|
| environment | required | "development" | "test" | "staging" | "production". |
| applicationName | required | Name used in logs and events. |
| applicationVersion | "0.1.0" | Version reported on the context. |
| runtimeId | generated | Identifier for this runtime instance. |
| handleSignals | true | Shut down on SIGTERM and SIGINT. |
| handleFatalErrors | true | Shut down and exit 1 on an uncaught error. |
| exitOnFatalError | true | Exit 1 after a fatal-error shutdown. |
| fatalExitTimeout | 10000 | Exit anyway if that shutdown hangs. |
| forceExitOnSecondSignal | true | A second SIGTERM/SIGINT exits immediately. |
| startupTimeout | 60000 | Milliseconds allowed for startup. |
| shutdownTimeout | 30000 | Milliseconds allowed for shutdown. |
| emitEvents | true | Publish lifecycle events on the bus. |
| trackReadiness | true | Mark ready automatically once all checks pass. |
| trackHealth | true | Derive health and emit health events. |
| readinessCheckTimeout | 5000 | Milliseconds one readiness check may take before it counts as failed. |
| parallelInitialization | false | Initialize modules at the same dependency depth together instead of one by one. |
| disposeContainerOnStop | false | Dispose the container after every module has shut down. New in v1.3.0; createTestRuntime sets it to true. |
| metadata | {} | Free-form values surfaced on the context. |
Functions
| Name | What it does | Notes |
|---|---|---|
| createRuntime(deps, options) | Builds a runtime. | The normal entry point. |
| resolveDependencies(map) | Returns order and parallelGroups. | Throws on a missing dependency or a cycle. |
| buildDependencyGraph(map) | Returns nodes, order and any cycles. | Does not throw on a cycle; it reports one. |
| validateDependencies(map) | Throws if a module names a dependency that is not in the map. | Returns nothing; resolveDependencies calls it for you. |
| computeRuntimeHealth(state, readiness) | Derives health from a state and check results. | What runtime.health uses. |
| createRuntimeOptions(options) | Applies defaults, then validates. | resolveRuntimeOptions and validateRuntimeOptions do each half. |
| createRuntimeContext(deps) | Builds a context object. | The runtime does this for you. |
| createRuntimeIdentity(id, env, name, version) | Adds hostname and process id to the identity. | Useful in log metadata. |
| createRuntimeId() | Generates an rt_… identifier. | createCorrelationId and createRequestId match it. |
| createStatus(state) | Builds a bare status from a state. | No timestamps. |
| canTransition(from, to) | Whether a state move is legal. | assertTransition throws instead. |
| isTerminalState / isRunning / hasFailed / canStart / canStop | One-line state questions. | All take a RuntimeState. |
| executeStartup / rollbackStartup / executeShutdown | The sequences the runtime runs internally. | Only needed if you build your own runtime. |
| createRuntimeEventPayload and friends | Build event payloads. | Module, failure, health and readiness variants exist. |
| publishRuntimeEvent(bus, logger, event) | Publishes an event without letting a subscriber break the caller. | Used by the runtime. |
Classes
| Name | What it does | Notes |
|---|---|---|
| DefaultRuntime | The runtime implementation. | Prefer createRuntime(). |
| RuntimeRegistry | Holds several runtimes in one process. | register(id, runtime), startAll, stopAll, removeAndStop. |
| ReadinessTracker | Stores and evaluates readiness checks. | Usable on its own: registerCheck, runChecks, updateCheck, removeCheck, getState. |
| LifecycleManager | Calls module hooks in order. | initialize, start, stop, destroy, rollback. |
| SignalHandler | Attaches and removes process handlers. | The runtime owns one already. |
Errors
| Name | Thrown when | Notes |
|---|---|---|
| RuntimeStateError | An illegal state move is attempted. | E.g. starting a stopped runtime. |
| RuntimeStartError | A module hook, or the configuration manager, fails during startup. | Carries phase ("initialize" or "start") and failedModuleId; the error your module threw is on cause. |
| RuntimeStopError | Shutdown fails. | Carries phase. |
| RuntimeInitializationError | An onInitialize hook, or the configuration manager, fails during startup. | Extends RuntimeStartError with phase: "initialize"; accepts a failedModuleId. Thrown since v1.3.0. |
| RuntimeTimeoutError | Startup, shutdown or a check runs too long. | Carries operation and timeoutMs. |
| RuntimeRollbackError | Startup fails and the rollback that follows also fails. | Extends RuntimeStartError. originalError is the startup error, rollbackError what failed during rollback (an AggregateError for several). Thrown since v1.3.0. |
| RuntimeCircularDependencyError | Modules depend on each other in a loop. | Carries the cycle. |
| RuntimeDependencyError | A module depends on an unregistered id. | Carries moduleId and dependencyId. |
| RuntimeSignalError | Logged by SignalHandler when the shutdown it triggered rejects; never thrown, since a signal handler has no caller. | Carries signal and, since v1.3.0, an optional cause. See the note under Signals: a runtime from createRuntime catches its own stop failure first and logs Shutdown failed. instead. |
| toRuntimeError(error, phase) | Wraps anything thrown into a RuntimeError. | Passes an existing one through unchanged. |
Constants and types
| Name | What it is | Notes |
|---|---|---|
| DEFAULT_RUNTIME_OPTIONS | The default option values. | Frozen object. |
| RUNTIME_STATE_TRANSITIONS | The legal moves per state. | Backs canTransition. |
| TERMINAL_STATES / STARTABLE_STATES / STOPPABLE_STATES | State groupings. | Frozen arrays. TERMINAL_STATES is ["stopped"] since v1.3.0. |
| CONTAINER_SHUTDOWN_ID | "(container)" | The shutdownFailures id under which a failed container disposal is recorded. |
| Runtime, RuntimeDependencies | The runtime interface and its inputs. | Type-only exports. |
| RuntimeOptions, ResolvedRuntimeOptions | Options before and after defaults. | Type-only. |
| RuntimeState, RuntimeStatus, RuntimeHealth, RuntimeHealthState | State and health shapes. | Type-only. |
| ReadinessCheckFn, ReadinessCheck, ReadinessTrackerState | Readiness shapes. | Type-only. |
| LifecyclePhase, LifecycleResult, LifecycleFailure | Per-phase results from the lifecycle manager. | Type-only. |
| RuntimeEventType, RuntimeModuleEventType, RuntimeEventMap and payload types | Event names and payload shapes. | Type-only. Since v1.3.1 RuntimeEventType is keyof RuntimeEventMap (all 18 names, including runtime.initialized and runtime.starting), and RuntimeModuleEventType is its runtime.module.* subset. |
COMMON MISTAKES
- → Importing test helpers from the package root.
import { withTestRuntime } from "@zudojs/runtime"fails — nothing by that name is exported there. Import from@zudojs/runtime/testinginstead. - → Registering a runtime without an id.
registry.register(apiRuntime)does not type-check. The signature isregister(id, runtime), and registering the same id twice throws. - → Expecting a new readiness check to pass. It is failing until
runReadinessChecks()runs it, soruntime.readyflips to false the moment you register one. Run the checks before reading readiness. - → Treating
failedas the end. Startup rollback shuts down started modules and destroys every module whoseonInitializeran (including one that threw); a module that finishes after a startup timeout is torn down when it settles, andstop()waits for that. Callstop()on a failed runtime to release it fully. - → Caching
runtime.statusin a variable and reading it later. Each read builds a fresh snapshot; the one you held onto is frozen in the past. Readruntime.statusat the moment you need it. - → Ignoring
status.shutdownFailures. The state reachesstoppedeither way. Only that list tells you a module failed to release its resources. - → Checking the startup error's type directly.
start()wraps a module's failure inRuntimeStartError, soerror instanceof YourErroris false. Look aterror.cause. - → Assuming
stop()closes container services. By default it does not callcontainer.dispose(). PassdisposeContainerOnStop: true, or call it yourself after the runtime has stopped. - → Checking
state === "initializing"insideonReady. Since v1.3.0onReadyruns instarting. Test for the state you actually mean, or subscribe toruntime.starting/runtime.running.
COMPLETE EXPORT INDEX
Every name @zudojs/runtime exports from its package root at v1.2.1 — 90 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.
Show all 90 exports
DefaultRuntime LifecycleManager ReadinessTracker RuntimeCircularDependencyError RuntimeDependencyError RuntimeInitializationError RuntimeRegistry RuntimeRollbackError RuntimeSignalError RuntimeStartError RuntimeStateError RuntimeStopError RuntimeTimeoutError SignalHandlerassertTransition buildDependencyGraph canStart canStop canTransition computeRuntimeHealth createCorrelationId createFailureEventPayload createHealthEventPayload createModuleEventPayload createReadinessEventPayload createRequestId createRuntime createRuntimeContext createRuntimeEventPayload createRuntimeId createRuntimeOptions createStatus executeShutdown executeStartup hasFailed isRunning isTerminalState publishRuntimeEvent resolveDependencies resolveRuntimeOptions rollbackStartup toRuntimeError validateDependencies validateRuntimeOptions withRuntimeContextStateCircularDependencyInfo DependencyGraph DependencyNode DependencyResolutionResult LifecycleContext LifecycleFailure LifecycleManagerOptions LifecycleResult ManagedModule ModuleContextServices ReadinessCheck ReadinessOptions ReadinessTrackerState ResolvedRuntimeOptions Runtime RuntimeContext RuntimeContextDependencies RuntimeContextState RuntimeDependencies RuntimeEventMap RuntimeEventPayload RuntimeFailureEventPayload RuntimeHealth RuntimeHealthCheck RuntimeHealthEventPayload RuntimeModuleEventPayload RuntimeOptions RuntimeReadinessEventPayload RuntimeShutdownFailure RuntimeStatusLifecyclePhase ModuleEventListener ReadinessCheckFn ReadinessState RuntimeEventType RuntimeFailureState RuntimeHealthState RuntimeId RuntimeModuleEventType RuntimeStateDEFAULT_RUNTIME_OPTIONS RUNTIME_STATE_TRANSITIONS STARTABLE_STATES STOPPABLE_STATES TERMINAL_STATES