The ZudoJS core Core
The application runtime and lifecycle
Learn how the ZudoJS runtime starts the parts of your application in dependency order, rolls back when startup fails, reports readiness, and shuts everything down gracefully.
Why an application needs a runtime
A real backend is not one thing. The Task API will soon have a database connection, a service with the task rules, and an HTTP server. These parts depend on each other:
- The tasks service needs the database, so the database must be ready first.
- The HTTP server sends requests to the tasks service, so it must start last.
- When the program stops, the order is reversed: stop taking requests first, close the database last.
Doing this by hand works for two parts and becomes fragile at ten. The runtime (@zudojs/runtime) does it for you. You describe each part and what it depends on, and the runtime works out the order, starts everything, notices failures and shuts down cleanly.
You already have one. In Create the Task API project, the CLI generated src/app.ts, which builds a runtime with createRuntime, and src/server.ts, which calls runtime.start(). This lesson explains what happens inside those two calls. Every package used here is already in the project's package.json, so there is nothing to install.
Modules and their four hooks
The runtime manages modules. A module is an object with an id, a name, an optional list of dependencies (the ids of other modules), and up to four hooks. A hook is a function the runtime calls at a fixed moment:
| Hook | When the runtime calls it | Typical work |
|---|---|---|
onInitialize | During start(), dependencies first | Open a connection, load data |
onReady | During start(), after every module has initialized | Start accepting work |
onShutdown | During stop(), in reverse order | Stop accepting work, finish what is running |
onDestroy | During stop(), last, in reverse order | Release everything the module still holds |
To see the order, this helper builds a module whose hooks only print what happens. It also creates a runtime. createRuntime takes two arguments: the services the runtime works with (the modules, a logger, a dependency container and an event bus, exactly like src/app.ts) and the options that describe the application:
import { createContainer } from "@zudojs/container";
import type { Module } from "@zudojs/core";
import { createEventBus } from "@zudojs/events";
import { createLogger, LoggerLevel } from "@zudojs/logger";
import { createRuntime } from "@zudojs/runtime";
import type { Runtime } from "@zudojs/runtime";
export function traceModule(id: string, dependencies: string[] = []): Module {
return {
id,
name: id,
dependencies,
onInitialize: () => console.log(`initialize ${id}`),
onReady: () => console.log(`ready ${id}`),
onShutdown: () => console.log(`shutdown ${id}`),
onDestroy: () => console.log(`destroy ${id}`),
};
}
export function buildRuntime(modules: Module[]): Runtime {
return createRuntime(
{
modules: new Map(modules.map((m) => [m.id, m])),
logger: createLogger({ name: "task-api", level: LoggerLevel.FATAL }),
container: createContainer(),
eventBus: createEventBus(),
},
{ applicationName: "task-api", environment: "development", handleSignals: false },
);
}
The logger level FATAL hides the runtime's own log lines, so the output below only shows the hooks. In your project, keep the default level: those log lines are useful there. handleSignals: false is explained in Signals and graceful shutdown.
Now three modules for the Task API: a store that holds the data, a tasks module that needs the store, and an http module that needs the tasks. They are listed in the wrong order on purpose:
import { buildRuntime, traceModule } from "./trace.js";
const runtime = buildRuntime([
traceModule("http", ["tasks"]),
traceModule("tasks", ["store"]),
traceModule("store"),
]);
console.log("state:", runtime.state);
await runtime.start();
console.log("state:", runtime.state);
await runtime.stop();
console.log("state:", runtime.state);
npx tsx start-stop.tsstate: created initialize store initialize tasks initialize http ready store ready tasks ready http state: running shutdown http shutdown tasks shutdown store destroy http destroy tasks destroy store state: stopped
Read it from top to bottom:
- The runtime ignored the order of the list and followed the
dependencies:store, thentasks, thenhttp. - Startup has two rounds. Every module is initialized before any module becomes ready. So when
httpbecomes ready, it knows every other module has at least been initialized. - Stopping runs the same order backwards, again in two rounds: all
onShutdownhooks, then allonDestroyhooks. runtime.statewent fromcreatedtorunningtostopped.
Lifecycle states and events
The runtime is a state machine: at any moment it is in exactly one state, and it only moves between states along fixed paths. You read it from runtime.state:
| State | Meaning |
|---|---|
created | Built, nothing has run yet. |
initializing | Running the onInitialize hooks. |
initialized | Every module has initialized. The runtime passes through this state on its way to starting. |
starting | Running the onReady hooks. |
running | Everything is up. |
stopping | Running the onShutdown and onDestroy hooks. |
stopped | Everything is down. A stopped runtime cannot be started again; create a new one. |
failed | Something went wrong on the way up or down. This is not the end: you can still call stop() to clean up. |
Each change is also announced as an event on the event bus you passed in. Other parts of your program can listen without the runtime knowing about them. Here a listener prints the runtime events and the state at that moment:
import { createContainer } from "@zudojs/container";
import { createEventBus } from "@zudojs/events";
import { createLogger, LoggerLevel } from "@zudojs/logger";
import { createRuntime } from "@zudojs/runtime";
import { traceModule } from "./trace.js";
const eventBus = createEventBus();
const runtime = createRuntime(
{
modules: new Map([["store", traceModule("store")]]),
logger: createLogger({ name: "task-api", level: LoggerLevel.FATAL }),
container: createContainer(),
eventBus,
},
{ applicationName: "task-api", environment: "development", handleSignals: false },
);
const types = [
"runtime.initializing",
"runtime.initialized",
"runtime.starting",
"runtime.running",
"runtime.stopping",
"runtime.stopped",
] as const;
for (const type of types) {
eventBus.on(type, (event) => console.log(`event ${event.type} (state: ${runtime.state})`));
}
await runtime.start();
await runtime.stop();
npx tsx events.tsevent runtime.initializing (state: initializing) initialize store event runtime.initialized (state: initialized) event runtime.starting (state: starting) ready store event runtime.running (state: running) event runtime.stopping (state: stopping) shutdown store destroy store event runtime.stopped (state: stopped)
The two rounds of startup show up as states: every onInitialize runs while the state is initializing, and every onReady while it is starting.
There are also events for every module (runtime.module.initializing, runtime.module.failed, …) and for health changes. In later lessons, logging and monitoring code listens to them.
The dependency graph
The dependencies lists form a dependency graph: an arrow from each module to every module it needs. The runtime sorts that graph so that every module comes after the modules it points to. That sorting is called a topological sort, and it is why the order in your list did not matter.
Two mistakes make sorting impossible, and the runtime refuses to start in both cases:
import { buildRuntime, traceModule } from "./trace.js";
const cycle = buildRuntime([
traceModule("tasks", ["store"]),
traceModule("store", ["tasks"]),
]);
try {
await cycle.start();
} catch (error) {
console.log((error as Error).name);
console.log((error as Error).message);
}
const missing = buildRuntime([traceModule("tasks", ["database"])]);
try {
await missing.start();
} catch (error) {
console.log((error as Error).name);
console.log((error as Error).message);
}
npx tsx bad-graph.tsRuntimeCircularDependencyError Circular module dependency detected: tasks -> store -> tasks. RuntimeDependencyError Module "tasks" depends on "database" which is not registered.
- A cycle (
tasksneedsstore, which needstasks) has no valid first module. The error shows the whole loop, so you can see which link to remove. - A missing dependency is usually a typo or a module you forgot to register.
No hook ran in either case: the runtime checks the whole graph before it touches any module. A mistake in the wiring shows up the first time you start the app, not hours later.
Rollback when startup fails
Suppose the HTTP module cannot start because another program already uses its port. The store and the tasks module are already up. If the runtime simply gave up, the store's connection would stay open. Instead, it rolls back: it undoes what already happened, in reverse order.
import { RuntimeStartError } from "@zudojs/runtime";
import { buildRuntime, traceModule } from "./trace.js";
const http = {
...traceModule("http", ["tasks"]),
onReady: () => {
console.log("ready http");
throw new Error("Port 3000 is already in use");
},
};
const runtime = buildRuntime([traceModule("store"), traceModule("tasks", ["store"]), http]);
try {
await runtime.start();
} catch (error) {
if (error instanceof RuntimeStartError) {
console.log(error.message);
console.log("phase:", error.phase);
console.log("cause:", (error.cause as Error).message);
}
}
console.log("state:", runtime.state);
npx tsx rollback.tsinitialize store initialize tasks initialize http ready store ready tasks ready http shutdown tasks shutdown store destroy http destroy tasks destroy store Module "http" failed during startup. phase: start cause: Port 3000 is already in use state: failed
Look at which hooks ran after the failure:
onShutdownran only fortasksandstore, the modules that had become ready.httpnever started, so there was nothing to shut down.onDestroyran for all three, because all three had initialized and may hold resources.start()rejected with aRuntimeStartError. It says whichphasefailed (initializeorstart), and the original error is incause.
The state is now failed. A failed runtime can still be stopped: await runtime.stop() releases anything the rollback did not reach, and it is safe to call even when there is nothing left. In src/server.ts this means: if runtime.start() throws, log the error, call runtime.stop(), and exit with code 1 so the platform running your app knows it failed.
Readiness and health
Hosting platforms ask a running app two questions:
- Is it ready? Can it take requests right now? If not, send traffic elsewhere for a while.
- Is it healthy? Is everything working as it should?
A readiness check is a function that returns true or false. You register it on the runtime, and runtime.ready and runtime.health are worked out from all the checks:
import { buildRuntime, traceModule } from "./trace.js";
let storeConnected = true;
const runtime = buildRuntime([traceModule("store")]);
runtime.registerReadinessCheck("store", () => storeConnected);
await runtime.start();
await runtime.runReadinessChecks();
console.log("ready:", runtime.ready, "health:", runtime.health.state);
storeConnected = false;
await runtime.runReadinessChecks();
console.log("ready:", runtime.ready, "health:", runtime.health.state);
await runtime.stop();
npx tsx readiness.tsinitialize store ready store ready: true health: healthy ready: false health: degraded shutdown store destroy store
When the store lost its connection, the app kept running but reported itself degraded: still alive, not fully working. The generated src/app.ts registers one check, modules, and the generated /health route answers 503 when the runtime is not running. At the end of this lesson, /health reports every readiness check.
Signals and graceful shutdown
When a hosting platform wants your app to stop, for example to deploy a new version, it sends the process a signal called SIGTERM. Pressing Ctrl + C in a terminal sends SIGINT. By default, Node.js ends the process immediately. Any request being answered is cut off, and any data not yet saved is lost.
A graceful shutdown does better: stop accepting new work, finish the work in progress, close connections, then exit. With handleSignals: true (the default), the runtime listens for both signals and runs stop() for you. This example sends SIGTERM to its own process to show it:
import { createContainer } from "@zudojs/container";
import type { Module } from "@zudojs/core";
import { createEventBus } from "@zudojs/events";
import { createLogger, LoggerLevel } from "@zudojs/logger";
import { createRuntime } from "@zudojs/runtime";
let listener: NodeJS.Timeout | undefined;
const http: Module = {
id: "http",
name: "http",
onReady: () => {
listener = setInterval(() => {}, 1000);
console.log("http: accepting requests");
},
onShutdown: async () => {
console.log("http: finishing requests in progress...");
await new Promise((resolve) => setTimeout(resolve, 100));
clearInterval(listener);
console.log("http: closed");
},
};
const runtime = createRuntime(
{
modules: new Map([["http", http]]),
logger: createLogger({ name: "task-api", level: LoggerLevel.FATAL }),
container: createContainer(),
eventBus: createEventBus(),
},
{ applicationName: "task-api", environment: "development", handleSignals: true },
);
process.on("exit", (code) => console.log(`process exits with code ${code}, state: ${runtime.state}`));
await runtime.start();
process.kill(process.pid, "SIGTERM");
npx tsx signal.tshttp: accepting requests http: finishing requests in progress... http: closed process exits with code 0, state: stopped
The setInterval stands in for a real server's open network socket. As long as something like that is open, Node.js keeps the process running. onShutdown closes it, the way a real HTTP module closes its server.
The signal did not kill the process. The runtime caught it, waited for the onShutdown hook to finish its 100 ms of work, and ran stop() to the end. The runtime does not call process.exit() itself: once the last open handle was closed, Node.js ended the process on its own, with code 0. A second SIGTERM during shutdown exits at once, in case shutdown hangs, and shutdownTimeout (30 seconds by default) puts an upper limit on the whole thing.
Why does the generated src/app.ts pass handleSignals: false? Because the HTTP server in src/server.ts is not a module. server.ts handles the signals itself so it can stop things in the right order: first server.stop() (no new requests, finish the current ones), then runtime.stop() (close everything the requests used). If both listened, they would race each other. You will see the server side of this in Middleware, CORS, security headers and graceful shutdown.
Application options and module context
The second argument of createRuntime describes the application. These are the options you will use most:
| Option | Default | What it does |
|---|---|---|
applicationName | required | Name used in logs and events. |
environment | required | "development", "test" or "production". The generated app reads it from NODE_ENV with resolveEnvironment(). |
applicationVersion | "0.1.0" | Your app's version. |
handleSignals | true | Stop gracefully on SIGTERM and SIGINT. |
startupTimeout / shutdownTimeout | 60000 / 30000 | Longest time, in milliseconds, that starting or stopping may take. |
disposeContainerOnStop | false | You created the container, so by default you own it and stop() leaves it alone. Set true to let stop() also clean up (dispose) the container after every module has stopped. The next lesson is about the container. |
Everything about the running application is available as runtime.context. And each hook receives a module context (ModuleContext) with the module's own information and the runtime's logger. Modules are usually classes that extend BaseModule from @zudojs/core, like the generated AppModule. The options you give BaseModule come back in context.options:
import { createContainer } from "@zudojs/container";
import { BaseModule } from "@zudojs/core";
import type { ModuleContext } from "@zudojs/core";
import { createEventBus } from "@zudojs/events";
import { createLogger, LoggerLevel } from "@zudojs/logger";
import { createRuntime } from "@zudojs/runtime";
class StoreModule extends BaseModule {
public readonly id = "store";
public readonly name = "Task store";
public constructor() {
super({ version: "0.1.0", options: { maxTasks: 500 } });
}
public override async onInitialize(context: ModuleContext): Promise<void> {
console.log(`${context.name} v${context.version}, options:`, context.options);
}
}
const runtime = createRuntime(
{
modules: new Map([["store", new StoreModule()]]),
logger: createLogger({ name: "task-api", level: LoggerLevel.FATAL }),
container: createContainer(),
eventBus: createEventBus(),
},
{ applicationName: "task-api", applicationVersion: "0.1.0", environment: "development", handleSignals: false },
);
await runtime.start();
const { applicationName, applicationVersion, environment, runtimeId } = runtime.context;
console.log(applicationName, applicationVersion, environment, runtimeId);
await runtime.stop();
npx tsx context.tsTask store v0.1.0, options: { maxTasks: 500 }
task-api 0.1.0 development rt_c8a25a1c1f0e4d7ab4a6d1a9e8a0c3b2The runtimeId is new on every run. It lets you tell apart two copies of the same app in logs.
TIP
@zudojs/core also has createApplication, which builds the container, logger and runtime for you in one call. The CLI uses @zudojs/runtime directly instead, so you can see and change every piece in src/app.ts.Smaller parts: @zudojs/lifecycle
The runtime manages the big pieces of your app. Sometimes one module owns several smaller parts, such as a connection pool, a cache and a mail sender. @zudojs/lifecycle applies the same idea one level down, to plain objects called components. Each component has optional initialize, start, ready, stop and dispose methods. You declare its dependencies with dependsOn, and you can mark a component that the app can live without as critical: false:
import { createLifecycleManager } from "@zudojs/lifecycle";
function component(name: string, failOnStart = false) {
return {
name,
start: async () => {
console.log(`start ${name}`);
if (failOnStart) throw new Error(`${name} is unreachable`);
},
stop: async () => console.log(`stop ${name}`),
};
}
const manager = createLifecycleManager({ handleSignals: false });
manager.register(component("pool"), { id: "pool" });
manager.register(component("cache"), { id: "cache", dependsOn: ["pool"] });
manager.register(component("mailer", true), { id: "mailer", critical: false });
await manager.start();
for (const [id, status] of manager.getStatus()) console.log(id, status.state);
await manager.shutdown();
npx tsx components.tsstart pool start mailer start cache pool ready cache ready mailer failed stop cache stop mailer stop pool
The mailer failed, but because it is not critical, startup carried on and the mailer is simply marked failed. Had the pool failed, start() would have rolled back and rejected, just like the runtime. Notice that pool and mailer started together: components that do not depend on each other run in parallel.
Put it in the Task API
Open src/app.ts. The runtime it builds already has two modules: integrations, which starts the outside connections listed in src/integrations/ (none yet), and app, a placeholder AppModule that only logs "app module initialized". You will replace the placeholder with two real modules.
First, somewhere to keep tasks. For now it is a Map in memory (the database lesson replaces it with PostgreSQL). Code that stores data goes in src/repositories/, next to the example resource's examples.repository.ts. Create src/repositories/tasks.store.ts:
export type Priority = "low" | "normal" | "high";
export interface StoredTask {
readonly id: number;
readonly title: string;
readonly done: boolean;
readonly priority: Priority;
readonly createdAt: string;
}
export class TaskStore {
public readonly tasks = new Map<number, StoredTask>();
public connected = false;
private lastId = 0;
public nextId(): number {
this.lastId += 1;
return this.lastId;
}
}
The store module "connects" it when the app starts and clears it when the app stops. Create src/modules/store.module.ts, next to the generated app.module.ts:
import { BaseModule } from "@zudojs/core";
import type { ModuleContext } from "@zudojs/core";
import type { TaskStore } from "../repositories/tasks.store.js";
export class StoreModule extends BaseModule {
public readonly id = "store";
public readonly name = "store";
public constructor(private readonly store: TaskStore) {
super({ version: "0.1.0" });
}
public override async onInitialize(context: ModuleContext): Promise<void> {
this.store.connected = true;
context.logger.info("store connected");
}
public override async onDestroy(context: ModuleContext): Promise<void> {
this.store.tasks.clear();
this.store.connected = false;
context.logger.info("store closed");
}
}
Then src/modules/tasks.module.ts. It adds one example task at startup. Its dependencies list is what guarantees the store is connected before onInitialize runs:
import { BaseModule } from "@zudojs/core";
import type { ModuleContext } from "@zudojs/core";
import type { TaskStore } from "../repositories/tasks.store.js";
export class TasksModule extends BaseModule {
public readonly id = "tasks";
public readonly name = "tasks";
public constructor(private readonly store: TaskStore) {
super({ version: "0.1.0", dependencies: ["store"] });
}
public override async onInitialize(context: ModuleContext): Promise<void> {
if (!this.store.connected) {
throw new Error("The task store is not connected");
}
const id = this.store.nextId();
this.store.tasks.set(id, {
id,
title: "Read the runtime lesson",
done: true,
priority: "normal",
createdAt: "2026-09-23T09:00:00.000Z",
});
context.logger.info(`tasks ready, ${this.store.tasks.size} in store`);
}
}
The two new modules replace the placeholder. Delete src/modules/app.module.ts and src/services/app.service.ts, empty src/services/index.ts (the next lesson puts the task service in that folder), and make src/modules/index.ts export the new modules instead:
export { StoreModule } from "./store.module.js";
export { TasksModule } from "./tasks.module.js";
Now change src/app.ts. It needs four edits: import the new modules and TaskStore instead of AppModule, create one TaskStore, put the two modules in the list where new AppModule() was, and register a readiness check for the store. Both modules receive the same store object, and the order in the list no longer matters. This is the whole file afterwards:
import type { Server } from "node:http";
import { resolveEnvironment } from "@zudojs/constants";
import { createContainer } from "@zudojs/container";
import type { Module } from "@zudojs/core";
import { createEventBus } from "@zudojs/events";
import { createLogger } from "@zudojs/logger";
import { createRuntime, type Runtime } from "@zudojs/runtime";
import type { AppConfig } from "./configs/index.js";
import { IntegrationsModule, integrations } from "./integrations/index.js";
import { StoreModule, TasksModule } from "./modules/index.js";
import { TaskStore } from "./repositories/tasks.store.js";
/** What {@link createApp} needs. */
export interface AppOptions {
readonly config: AppConfig;
/** The Node HTTP server, for integrations that attach to it. */
readonly httpServer?: Server;
}
/**
* Assembles the application runtime.
*
* `createRuntime` takes two arguments: the dependencies the runtime and its
* modules share, and the options describing this application. Integrations
* (`src/integrations`) are registered first so they start before, and stop
* after, every other module.
*/
export function createApp(options: AppOptions): Runtime {
const logger = createLogger({ name: "task-api" });
const container = createContainer();
const eventBus = createEventBus();
const store = new TaskStore();
const modules = new Map<string, Module>();
const integrationsModule = new IntegrationsModule(integrations, {
config: options.config,
...(options.httpServer === undefined ? {} : { httpServer: options.httpServer }),
});
modules.set(integrationsModule.id, integrationsModule);
for (const module of [
new TasksModule(store),
new StoreModule(store),
] as Module[]) {
modules.set(module.id, module);
}
const runtime = createRuntime(
{ modules, logger, container, eventBus },
{
applicationName: "task-api",
applicationVersion: "0.1.0",
// NODE_ENV is read the same way the framework reads it: `prod` and
// `Production` are production, an unknown value warns once.
environment: resolveEnvironment(),
// Signals are handled explicitly in server.ts.
handleSignals: false,
},
);
runtime.registerReadinessCheck("modules", () => runtime.state === "running");
runtime.registerReadinessCheck("store", () => store.connected);
return runtime;
}
The readiness check does nothing yet, because nothing asks for it. The generated /health route gets its answer from a health function in src/server.ts, which only looks at runtime.state and the integrations. Change that function so it also runs the readiness checks and reports each one. runtime.readiness.checks holds the result of every check by name:
const router = createRouter();
registerRoutes(
router,
createDependencies({
health: async () => {
const checks = await checkIntegrations(integrations);
await runtime.runReadinessChecks();
for (const [name, check] of runtime.readiness.checks) {
checks[name] = check.ready ? "up" : "down";
}
const ready = runtime.ready && Object.values(checks).every((check) => check === "up");
return { ready, checks };
},
}),
);
runtime.ready is false as soon as one check fails, and the modules check already covers "the runtime is running". So /health answers 503 while the app starts or stops, and also if the store ever loses its connection. Check the types, then start the server:
npx tsc --noEmit npm run dev > task-api@0.1.0 dev > tsx watch src/server.ts 2026-09-23T19:44:12.201Z [INFO] [task-api] store connected 2026-09-23T19:44:12.203Z [INFO] [task-api] tasks ready, 1 in store 2026-09-23T19:44:12.204Z [INFO] [task-api] All modules initialized. modules=["integrations","store","tasks"] durationMs=5 2026-09-23T19:44:12.206Z [INFO] [task-api] All modules started. modules=["integrations","store","tasks"] durationMs=0 2026-09-23T19:44:12.206Z [INFO] [task-api] Runtime is ready. runtimeId=rt_e953a0efe9854de8b4c3beae6745ee88 environment=development Listening on http://0.0.0.0:3000
tsc printed nothing, so the project still type-checks. Your times and runtimeId will differ. integrations comes first because src/app.ts registers it first and nothing depends on it; then the store, then the tasks module, because of the dependency. In a second terminal, ask for the health report:
curl http://localhost:3000/health {"status":"ok","checks":{"modules":"up","store":"up"},"timestamp":"2026-09-23T19:44:12.351Z"}
Both readiness checks are listed. Now press Ctrl + C in the server's terminal and read the order on the way out:
^C2026-09-23T19:44:12.361Z [INFO] [task-api] Initiating graceful shutdown. timeoutMs=30000 2026-09-23T19:44:12.363Z [INFO] [task-api] All modules stopped. modules=["tasks","store","integrations"] durationMs=1 2026-09-23T19:44:12.363Z [INFO] [task-api] store closed 2026-09-23T19:44:12.363Z [INFO] [task-api] All modules destroyed. durationMs=0 2026-09-23T19:44:12.364Z [INFO] [task-api] Graceful shutdown complete. 2026-09-23T19:44:12.364Z [INFO] [task-api] Runtime stopped. runtimeId=rt_e953a0efe9854de8b4c3beae6745ee88
The modules stopped in reverse order. "store closed" comes after "All modules stopped", because the store module does its work in onDestroy, the second round of stopping.
Practice
TRY IT YOURSELF
Add a mailer module
Using traceModule and buildRuntime from this lesson, add a mailer module that needs tasks, next to http, which also needs tasks. Before running it, write down the order you expect for all four rounds of hooks. Then run it.
Show a solution
import { buildRuntime, traceModule } from "./trace.js";
const runtime = buildRuntime([
traceModule("mailer", ["tasks"]),
traceModule("http", ["tasks"]),
traceModule("tasks", ["store"]),
traceModule("store"),
]);
await runtime.start();
await runtime.stop();
npx tsx mailer.tsinitialize store initialize tasks initialize mailer initialize http ready store ready tasks ready mailer ready http shutdown http shutdown mailer shutdown tasks shutdown store destroy http destroy mailer destroy tasks destroy store
mailer and http do not depend on each other, so either may come first. The runtime keeps the order you listed them in, and stops them in exactly the reverse order.
TRY IT YOURSELF
A failing initialization
Make the tasks module throw new Error("Schema is out of date") in onInitialize. Which hooks run, what is the error's phase, and why does onShutdown run for no module at all?
Show a solution
import { RuntimeStartError } from "@zudojs/runtime";
import { buildRuntime, traceModule } from "./trace.js";
const tasks = {
...traceModule("tasks", ["store"]),
onInitialize: () => {
console.log("initialize tasks");
throw new Error("Schema is out of date");
},
};
const runtime = buildRuntime([traceModule("store"), tasks, traceModule("http", ["tasks"])]);
try {
await runtime.start();
} catch (error) {
if (error instanceof RuntimeStartError) {
console.log(error.phase, "-", (error.cause as Error).message);
}
}
npx tsx init-fail.tsinitialize store initialize tasks destroy tasks destroy store initialize - Schema is out of date
The failure happened in the first round, so no module ever became ready and there is nothing to shut down. http was never initialized, so it is not destroyed either. tasks is destroyed even though its onInitialize failed halfway: it may have opened something before throwing, so its onDestroy must cope with a half-initialized state.
The error itself is a RuntimeInitializationError, a kind of RuntimeStartError, so the instanceof RuntimeStartError check matches it too.
TRY IT YOURSELF
Stop on failure
In the Task API, src/server.ts calls await runtime.start() without a try, just before it creates the HTTP server. Change it so that a failed start logs the error, calls runtime.stop() and exits with code 1.
Show a solution
try {
await runtime.start();
} catch (error) {
console.error("Startup failed:", error);
await runtime.stop();
process.exit(1);
}
Exit code 1 tells Docker, systemd or your hosting platform that the app did not start, so it can restart it or alert you. Exiting with 0 would report success.
Recap
- The runtime starts modules in dependency order, in two rounds (
onInitialize, thenonReady), and stops them in reverse (onShutdown, thenonDestroy). runtime.statemoves fromcreatedtorunningtostopped, or tofailed. Every change is also an event on the event bus.- Cycles and missing dependencies are refused before any hook runs.
- A failed start rolls back what already ran and rejects with
RuntimeStartError, with the original error ascause. - Readiness checks decide
runtime.readyandruntime.health. - Graceful shutdown turns
SIGTERMinto an orderly stop. In the Task API,server.tsstops the HTTP server first, then the runtime.
The modules in this lesson passed the TaskStore to each other through their constructors. With two modules that is fine. The next lesson introduces the dependency container, which builds and shares objects like it for you.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.