LEVEL 18 · LESSON 3 OF 3

Framework engineering Advanced

Building a complete ZudoJS plugin

Build a production-grade receipts plugin with @zudojs/plugins: metadata, dependencies, lifecycle, scoped context, events, diagnostics and rollback.

  • 60 min to read and try
  • You need: Plugins, Creating a ZudoJS package, Events, and Testing a ZudoJS app
  • You build: A receipts plugin that depends on a mailer plugin, reacts to payment events, reports its own health, drains on shutdown and is covered by Vitest tests, plus a failing plugin that triggers a full rollback
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Design a plugin's contract with its host: metadata, capabilities, dependencies and the services it needs from the context
  • Implement all five lifecycle hooks so that each one does only its own job, and cleanup is registered next to what it cleans up
  • Share a service between plugins through the container and react to host events without coupling
  • Predict what the manager checks before any hook runs, and what it rolls back when a hook fails or hangs
  • Report health beyond the manager's diagnostics and turn an unnamed startup failure into a PluginStartError
  • Test a plugin's hooks in isolation and inside a real manager

A feature three apps can switch on

ShopFlow wants an email receipt for every successful payment. So does the invoicing service. The bookings app does not: its customers get a booking confirmation instead. If receipts are written into ShopFlow, the invoicing team copies them, and you are back to the pasted-code problem of the previous lesson. If receipts become a plugin, each app decides with one line whether it runs them.

You met plugins in the plugins lesson: metadata, five hooks, a scoped context, rollback. That lesson showed each feature on its own, with small plugins. This one builds a plugin you could ship: @mycompany/zudo-plugin-receipts. It needs a mail service that another plugin provides, validates its settings before anything starts, listens to the host's payment events, sends mail without blocking payments, reports its own health, finishes the receipts in progress when the app shuts down, and cleans up everything it created. Then you add a plugin that fails and watch the manager undo the whole start-up.

NOTE

The plugin, the mailer and the host are files in one project; each example is a script that uses them. They use Node.js, so run them on your computer with npx tsx <file>.ts.

Before you write a hook

REASON IT OUT

What can go wrong for a receipts plugin?

Think these through first. What does the plugin need from the host, and what if the host does not offer it? The mail service belongs to another plugin: what if that plugin is missing, too old, or starts after receipts? An installation passes { form: "[email protected]" } with a typo: when should that fail? One customer's mailbox bounces: should the payment fail, or the plugin, or neither? The app shuts down while three receipts are being sent. And the plugin after receipts fails to start: what must happen to the mail connection receipts' dependency already opened?

Show the reasoning
  • Needs from the host: events (to hear about payments) and a container (to find the mailer). Check for them in install, the first hook, so a host that lacks them fails at start-up with a clear message, not at the first payment.
  • The mailer plugin: declare it as a dependency with a version range. The manager then refuses to start without it, refuses an incompatible major, and always starts it first and stops it last.
  • Bad settings: validate options in install. The start fails and rolls back; a typo never turns into receipts with no sender at 2 a.m.
  • A bounce: neither the payment nor the plugin fails. Sending happens after the payment succeeded, off the request path; a failed receipt is logged and shown in the plugin's health.
  • Shutdown: stop stops accepting new work and waits for the receipts in flight; the manager's hook timeout bounds that wait.
  • A later plugin fails: the manager rolls back: it stops and disposes every plugin it started, in reverse order, so the mail connection is closed. That works only if every plugin registers its cleanup when it creates something.

The parts of a complete plugin

Every concern from the reasoning maps to one part of the plugin API:

ConcernPlugin APIChecked or run
Who am I, which versionmetadata.name, version, description, keywordsRegistration; version ranges of other plugins
What I am allowed to usemetadata.capabilitiesregister, against the host's allowedCapabilities
What must run before medependencies, optionalDependenciesstart, before any hook runs
Settings, host servicesinstall(context, options)Phase 1, for every plugin
Wiring to other pluginsinitialize(context)Phase 2, when every plugin is installed
Doing the workstart(context), context.eventsPhase 3
Finishing work in progressstop(context)Shutdown and rollback, reverse order
Releasing resourcescontext.onDispose, registerDisposable, dispose, context.signalAfter stop, newest first
Am I healthy?manager.diagnostics(), plus your own health()Whenever the host asks

The split between install, initialize and start matters when plugins cooperate. The manager runs each phase for all plugins before the next phase begins. So in install a plugin registers what it offers, in initialize it can rely on everything every other plugin offers, and in start it begins work knowing the whole system is wired.

The dependency: a mailer plugin

Receipts need a way to send mail, and several plugins will want one. So mail gets its own plugin, which offers a Mailer service to the others. The contract (the interface and the token under which it is shared) lives in its own file, which in real life is exported by the mailer package:

src/mailer/mailer.contract.ts
/** What the mailer plugin offers other plugins. Shared through the container under MAILER. */
export interface Mailer {
  send(message: { readonly to: string; readonly subject: string; readonly text: string }, signal?: AbortSignal): Promise<void>;
}

/**
 * The container token for the mailer. Symbol.for, not Symbol(): every copy of
 * this module gets the same token, as with the @zudojs/errors brand.
 */
export const MAILER = Symbol.for("@mycompany/zudo-plugin-mailer:Mailer");

The mailer plugin registers the service in install, so it exists before any plugin's initialize looks for it. It opens its connection in start and, in the same breath, registers how to close it:

src/mailer/mailer.plugin.ts
import type { Plugin } from "@zudojs/plugins";
import { MAILER } from "./mailer.contract.js";
import type { Mailer } from "./mailer.contract.js";

/** Where mail really goes: SMTP, an email API, or an array in tests. */
export interface MailTransport {
  open(): Promise<void>;
  deliver(to: string, subject: string, text: string): Promise<void>;
  close(): Promise<void>;
}

export function createMailerPlugin(transport: MailTransport, version = "2.1.0"): Plugin {
  return {
    metadata: { name: "@mycompany/zudo-plugin-mailer", version, capabilities: ["container"] },
    install(context) {
      const mailer: Mailer = {
        async send(message, signal) {
          signal?.throwIfAborted();
          await transport.deliver(message.to, message.subject, message.text);
        },
      };
      context.container?.register(MAILER, { useValue: mailer });
    },
    async start(context) {
      await transport.open();
      context.registerDisposable({ dispose: () => transport.close() });
    },
  };
}

Registering cleanup right after acquiring the resource is the most important habit in plugin code. If start opened the connection and dispose closed it, a plugin that failed between the two lines would leave the connection open; and a plugin that was never started would try to close a connection that was never opened. The disposables list only ever contains what really exists.

The receipts plugin

First the settings. They come from outside (the register call and the host's configuration), so they are input like any request body, checked by one function:

src/receipts/receipts.options.ts
/** Settings an installation passes to the receipts plugin. */
export interface ReceiptsOptions {
  /** The From address of every receipt, e.g. "[email protected]". */
  readonly from: string;
  /** Log a summary every `summaryEveryMs` milliseconds. 0 turns it off. */
  readonly summaryEveryMs?: number;
}

/** Options are input from outside: check them before anything starts. */
export function parseReceiptsOptions(value: unknown): Required<ReceiptsOptions> {
  const input = (value ?? {}) as Partial<ReceiptsOptions>;
  const problems: string[] = [];
  if (typeof input.from !== "string" || !/^[^@\s]+@[^@\s]+$/.test(input.from)) problems.push("from must be an email address");
  const every = input.summaryEveryMs ?? 0;
  if (!Number.isInteger(every) || every < 0) problems.push("summaryEveryMs must be a whole number >= 0");
  if (problems.length > 0) throw new TypeError(`receipts: invalid options: ${problems.join("; ")}`);
  return { from: input.from!, summaryEveryMs: every };
}

Now the plugin. Read it hook by hook; each one does one job:

src/receipts/receipts.plugin.ts
import { createDegradedHealth, createHealthyHealth } from "@zudojs/plugins";
import type { Plugin, PluginContainer, PluginContext, PluginHealth } from "@zudojs/plugins";
import { MAILER } from "../mailer/mailer.contract.js";
import type { Mailer } from "../mailer/mailer.contract.js";
import { parseReceiptsOptions } from "./receipts.options.js";
import type { ReceiptsOptions } from "./receipts.options.js";

/** The event this plugin reacts to. The host publishes it after a successful charge. */
export interface PaymentSucceeded {
  readonly reference: string;
  readonly email: string;
  readonly amountKobo: number;
}

/** The plugin object, plus a health check the host can call. */
export interface ReceiptsPlugin extends Plugin<Partial<ReceiptsOptions> | undefined> {
  health(): PluginHealth;
}

type ResolvingContainer = PluginContainer & { resolve(token: unknown): unknown };
const canResolve = (c: PluginContainer | undefined): c is ResolvingContainer =>
  typeof (c as { resolve?: unknown } | undefined)?.resolve === "function";

const naira = (kobo: number) => `₦${(kobo / 100).toLocaleString("en-US", { minimumFractionDigits: 2 })}`;

export function createReceiptsPlugin(): ReceiptsPlugin {
  let settings: Required<ReceiptsOptions> | undefined;
  let mailer: Mailer | undefined;
  let accepting = false;
  let sent = 0;
  let lastError: string | undefined;
  const inFlight = new Set<Promise<void>>();

  async function sendReceipt(context: PluginContext, payment: PaymentSucceeded): Promise<void> {
    const text = `Thank you. We received ${naira(payment.amountKobo)} for order ${payment.reference}.`;
    try {
      await mailer!.send({ to: payment.email, subject: `Receipt for ${payment.reference}`, text }, context.signal);
      sent++;
      lastError = undefined;
      await context.events?.emit("receipt.sent", { reference: payment.reference });
    } catch (error) {
      lastError = `${payment.reference}: ${error instanceof Error ? error.message : String(error)}`;
      context.logger?.warn("receipt not sent", { reference: payment.reference, reason: lastError });
    }
  }

  return {
    metadata: {
      name: "@mycompany/zudo-plugin-receipts",
      version: "1.0.0",
      description: "Emails a receipt for every successful payment.",
      keywords: ["zudojs-plugin", "payments", "email"],
      capabilities: ["events", "container"],
    },
    dependencies: [{ name: "@mycompany/zudo-plugin-mailer", version: "^2.0.0" }],
    optionalDependencies: [{ name: "@mycompany/zudo-plugin-audit", version: "^1.0.0" }],

    install(context, options) {
      settings = parseReceiptsOptions({ from: context.config?.get("receipts.from"), ...(options ?? {}) });
      if (!context.events) throw new Error("receipts: the host must provide events");
      if (!canResolve(context.container)) throw new Error("receipts: the host must provide a container that can resolve");
    },

    initialize(context) {
      mailer = (context.container as ResolvingContainer).resolve(MAILER) as Mailer;
    },

    start(context) {
      accepting = true;
      const onPayment = (payload: unknown) => {
        if (!accepting) return;
        const job = sendReceipt(context, payload as PaymentSucceeded).finally(() => inFlight.delete(job));
        inFlight.add(job);
      };
      context.events!.on("payment.succeeded", onPayment);
      context.onDispose(() => context.events!.off("payment.succeeded", onPayment));
      if (settings!.summaryEveryMs > 0) {
        const timer = setInterval(() => context.logger?.info("receipts summary", { sent }), settings!.summaryEveryMs);
        context.onDispose(() => clearInterval(timer));
      }
      context.logger?.info("receipts started", { from: settings!.from });
    },

    async stop(context) {
      accepting = false;
      const waiting = inFlight.size;
      await Promise.allSettled(inFlight);
      context.logger?.info("receipts stopped", { drained: waiting, sent });
    },

    health() {
      return lastError ? createDegradedHealth(`last receipt failed: ${lastError}`) : createHealthyHealth();
    },
  };
}
  • A factory, not an object. createReceiptsPlugin() keeps all state in a closure, so every host and every test gets a fresh plugin. A disposed plugin can never be started again, so a retry needs a new one anyway.
  • install merges the host's configuration (receipts.from) with the options passed to register, validates them, and checks that the host offers what the plugin needs. The context's container type only promises register, because plugins are meant to offer services; resolving one needs a container that can, so the plugin checks at run time instead of assuming.
  • initialize resolves the mailer. It can, because every plugin's install, including the mailer's, has finished.
  • start subscribes to payment.succeeded and registers the unsubscribe on the very next line. The handler does not await the mail: the host's publish returns at once, so a slow mail server never slows a payment. Each send is tracked in inFlight.
  • stop refuses new work, then waits for the sends in flight. It does not release anything: that is disposal's job, and it happens next.
  • health() is an optional part of the Plugin interface, and manager.diagnostics() now calls it itself for every started plugin, folding the result straight into the report (a plugin with no health() is just reported healthy while it runs). Receipts still defines its own, because whether mail is actually going out is something only the plugin knows, and the type it returns, PluginHealth, is exactly what diagnostics() expects back.

The host, and a first run

The host is ShopFlow. It creates the services it offers plugins (a logger, an event bus, a container and its configuration) and one context that carries them. A memory transport stands in for SMTP; it can be told to bounce one address:

src/host/host.ts
import { createContainer } from "@zudojs/container";
import { createEventBus } from "@zudojs/events";
import { createLogger, createTextLoggerFormatter } from "@zudojs/logger";
import { createPluginContext } from "@zudojs/plugins";
import type { MailTransport } from "../mailer/mailer.plugin.js";

/** Everything a ShopFlow host offers its plugins. */
export function createHost(settings: Record<string, unknown> = {}) {
  const logger = createLogger({
    name: "shopflow",
    formatter: createTextLoggerFormatter({ includeTimestamp: false }),
    transports: [(entry) => console.log(entry.formatted ?? entry.message)],
  });
  const events = createEventBus();
  const container = createContainer();
  const config = { get: (key: string) => settings[key] };
  const context = createPluginContext({ name: "shopflow", version: "3.2.0" }, { logger, events, container, config });
  return { logger, events, container, context };
}

/** A transport that keeps mail in memory, and can be told to fail for one address. */
export function createMemoryTransport(options: { readonly failFor?: string } = {}) {
  const outbox: string[] = [];
  const transport: MailTransport = {
    async open() {
      console.log("smtp: connected");
    },
    async deliver(to, subject) {
      await new Promise((resolve) => setTimeout(resolve, 10));
      if (options.failFor && to === options.failFor) throw new Error("mailbox unavailable");
      outbox.push(`${to} <- ${subject}`);
    },
    async close() {
      console.log("smtp: connection closed");
    },
  };
  return { transport, outbox };
}

One detail of createPluginContext surprises people: its first argument, the host's own metadata (shopflow 3.2.0), never reaches a plugin. The manager gives each plugin its own view of the context, and in that view context.plugin is the plugin's metadata. Only the services (logger, events, container, config) are shared. A plugin that must know the host's version has to get it from config.

Now run the whole life of the plugin: start, two payments (the second bounces), a health check, a third payment, and a shutdown while its receipt is still being sent. The host also listens to the manager's own lifecycle events, published on the same bus:

run.tsNode.js only
import { PluginManager } from "@zudojs/plugins";
import { createHost, createMemoryTransport } from "./src/host/host.js";
import { createMailerPlugin } from "./src/mailer/mailer.plugin.js";
import { createReceiptsPlugin } from "./src/receipts/receipts.plugin.js";

const { events, context } = createHost({ "receipts.from": "[email protected]" });
events.on("plugin.started", (event) => {
  console.log("event: started", (event.payload as { plugin: { name: string } }).plugin.name);
});

const { transport, outbox } = createMemoryTransport({ failFor: "[email protected]" });
const receipts = createReceiptsPlugin();
const manager = new PluginManager({ allowedCapabilities: ["events", "container"], hookTimeout: 5_000 });
manager.register(receipts);
manager.register(createMailerPlugin(transport));
await manager.start(context);

const pay = (reference: string, email: string) =>
  events.publishEvent({ type: "payment.succeeded", payload: { reference, email, amountKobo: 1_250_000 } });

await pay("ORD-1001", "[email protected]");
await pay("ORD-1002", "[email protected]");
console.log("two payments published");
await new Promise((resolve) => setTimeout(resolve, 50));
const report = manager.diagnostics();
console.log(`manager: ${report.healthy} of ${report.total} healthy | receipts says:`, receipts.health());

await pay("ORD-1003", "[email protected]");
console.log("ORD-1003 published, stopping while its receipt is in flight");
await manager.stop(context);
console.log(outbox);
Output of npx tsx run.ts
smtp: connected
event: started @mycompany/zudo-plugin-mailer
[INFO] [shopflow] receipts started [email protected]
event: started @mycompany/zudo-plugin-receipts
two payments published
[WARN] [shopflow] receipt not sent reference=ORD-1002 reason="ORD-1002: mailbox unavailable"
manager: 1 of 2 healthy | receipts says: {
  status: 'degraded',
  details: 'last receipt failed: ORD-1002: mailbox unavailable'
}
ORD-1003 published, stopping while its receipt is in flight
[INFO] [shopflow] receipts stopped drained=1 sent=2
smtp: connection closed
[
  '[email protected] <- Receipt for ORD-1001',
  '[email protected] <- Receipt for ORD-1003'
]

Follow the order in the output:

  • Receipts was registered first, but the mailer started first: it is a dependency. The lifecycle event plugin:started arrives as plugin.started, because the event bus normalizes : to . (you traced that in Reading ZudoJS internals). A host can subscribe to plugin.* to see every lifecycle event.
  • "two payments published" appears before the bounce warning: publishing returned before any mail was sent, so receipts never slow the payment path.
  • The manager already agrees with the plugin: diagnostics() calls receipts.health() itself while it is started, so the bounce shows up as 1 of 2 healthy without receipts having to push the news anywhere. Calling receipts.health() directly, as the host does here, still has its place: it is the one place that can also carry extra detail (counts, timings) that a generic diagnostics report has no room for.
  • The third receipt was still being sent when stop began. stop waited for it (drained=1), so it reached the outbox. Then receipts was disposed before the mailer, and the SMTP connection closed last.

What the manager refuses before any hook runs

Three mistakes an installation can make, and when each one is caught: a host that does not grant a capability, a mailer from an incompatible major version, and a typo in the options. The first is caught by register, the second by start before any hook runs, and the third by the plugin's own install:

checks.tsNode.js only
import { PluginManager } from "@zudojs/plugins";
import type { Plugin } from "@zudojs/plugins";
import { createHost, createMemoryTransport } from "./src/host/host.js";
import { createMailerPlugin } from "./src/mailer/mailer.plugin.js";
import { createReceiptsPlugin } from "./src/receipts/receipts.plugin.js";

const { context } = createHost();
const { transport } = createMemoryTransport();

try {
  new PluginManager({ allowedCapabilities: ["events"] }).register(createReceiptsPlugin());
} catch (error) {
  console.log("1.", (error as Error).name, "-", (error as Error).message);
}

const tooNew = new PluginManager();
tooNew.register(createReceiptsPlugin(), { from: "[email protected]" });
tooNew.register(createMailerPlugin(transport, "3.0.0"));
try {
  await tooNew.start(context);
} catch (error) {
  console.log("2.", (error as Error).name, "-", (error as Error).message);
}

const typo = new PluginManager();
const asPlugin: Plugin = createReceiptsPlugin(); // erased to the base type: see below
typo.register(asPlugin, { form: "[email protected]" });
typo.register(createMailerPlugin(transport));
try {
  await typo.start(context);
} catch (error) {
  console.log("3.", (error as Error).name, "-", (error as Error).message);
}
console.log("3. states:", typo.diagnostics().plugins.map((p) => `${p.plugin.name.slice(23)}=${p.state}`).join(" "));
Output of npx tsx checks.ts
1. PluginRegistrationError - Plugin "@mycompany/zudo-plugin-receipts" requests capabilities that are not granted: container.
2. PluginDependencyVersionError - Plugin "@mycompany/zudo-plugin-receipts" requires "@mycompany/zudo-plugin-mailer@^2.0.0", but version 3.0.0 is registered. Register a "@mycompany/zudo-plugin-mailer" that satisfies ^2.0.0, relax the constraint on "@mycompany/zudo-plugin-receipts", or construct the manager with { checkVersions: false }.
3. PluginInitializationError - Plugin "@mycompany/zudo-plugin-receipts" failed to install: receipts: invalid options: from must be an email address
3. states: receipts=disposed mailer=disposed
  • Capabilities are compared at register: the host grants events only, receipts asks for container too. Remember from the plugins lesson that this is a declaration check, not a sandbox.
  • The version range ^2.0.0 refuses mailer 3.0.0 before any plugin was installed: nothing to roll back. The error class, PluginDependencyVersionError, extends PluginDependencyError, so a handler for missing plugins still catches it.
  • The typo form reached install unnoticed by TypeScript, but only because of the asPlugin: Plugin line just above it. register<TOptions>(plugin: Plugin<TOptions>, options?: TOptions) infers TOptions from whatever type the plugin is held as, so a createReceiptsPlugin() passed straight to register would make this exact typo a compile error today. Holding the plugin as the bare Plugin interface first, the way a value coming out of a plugin registry or a dynamic import usually would, erases that connection, and TOptions falls back to unknown: anything compiles again. Either way, the runtime backstop is what actually caught it: the plugin's own validation, throwing a plain TypeError. The manager wraps whatever a hook throws in a named error before it reaches you, here PluginInitializationError, with the plugin's name, the phase, and the original error kept as .cause. The mailer had already been installed, so the manager disposed it: both end disposed. (The smtp lines are missing because the mailer never reached start, where the connection opens.)

TIP

register checks options against the plugin's type only while the compiler can still see it as the concrete plugin, which a plain list of installed plugins or a dynamically loaded one usually cannot. A plugin package can make the safe path the only path by offering a typed factory instead: createReceiptsPlugin(options) that keeps the options in its closure rather than accepting them through register. Then the compiler checks them where they are written, in the host's code, and install still validates them at run time for whatever slips past that — options built dynamically, read from a config file, or a plugin the compiler only ever sees as Plugin.

Optional dependencies and plugins that talk through events

Some teams want an audit trail of every receipt. An audit plugin should not need changes to receipts, and receipts should not need the audit plugin. The two meet on the event bus: receipts publishes receipt.sent, and the audit plugin, if installed, listens. Receipts lists it under optionalDependencies for one reason only: when it is installed, it must start first, so it is listening before the first receipt goes out.

optional.tsNode.js only
import { PluginManager } from "@zudojs/plugins";
import type { Plugin } from "@zudojs/plugins";
import { createHost, createMemoryTransport } from "./src/host/host.js";
import { createMailerPlugin } from "./src/mailer/mailer.plugin.js";
import { createReceiptsPlugin } from "./src/receipts/receipts.plugin.js";

function createAuditPlugin(): Plugin {
  return {
    metadata: { name: "@mycompany/zudo-plugin-audit", version: "1.4.0", capabilities: ["events"] },
    start(context) {
      const record = (payload: unknown) => context.logger?.info("audit", payload as Record<string, unknown>);
      context.events?.on("receipt.sent", record);
      context.onDispose(() => context.events?.off("receipt.sent", record));
    },
  };
}

const { events, context } = createHost({ "receipts.from": "[email protected]" });
const order: string[] = [];
events.on("plugin.started", (event) => order.push((event.payload as { plugin: { name: string } }).plugin.name.slice(23)));

const manager = new PluginManager();
manager.register(createReceiptsPlugin());
manager.register(createMailerPlugin(createMemoryTransport().transport));
manager.register(createAuditPlugin());
await manager.start(context);
console.log("start order:", order.join(" -> "));

await events.publishEvent({ type: "payment.succeeded", payload: { reference: "ORD-2001", email: "[email protected]", amountKobo: 500_000 } });
await new Promise((resolve) => setTimeout(resolve, 30));
await manager.stop(context);
Output of npx tsx optional.ts
smtp: connected
[INFO] [shopflow] receipts started [email protected]
start order: mailer -> audit -> receipts
[INFO] [shopflow] audit reference=ORD-2001
[INFO] [shopflow] receipts stopped drained=0 sent=1
smtp: connection closed

The manager put audit before receipts because of the optional dependency, and the audit line appeared without receipts knowing who listened. Without the audit plugin, the same receipts plugin starts exactly as in the first run. That is the shape to aim for between plugins: hard dependencies for services you call, events for things others may want to know.

A failing plugin and the rollback

The invoicing team adds an SMS alerts plugin that depends on receipts. Its API key is wrong, so its start throws. By then the mailer and receipts are running: the connection is open and receipts is subscribed to payments. Watch the manager undo all of it:

rollback.tsNode.js only
import { PluginManager } from "@zudojs/plugins";
import type { Plugin } from "@zudojs/plugins";
import { createHost, createMemoryTransport } from "./src/host/host.js";
import { createMailerPlugin } from "./src/mailer/mailer.plugin.js";
import { createReceiptsPlugin } from "./src/receipts/receipts.plugin.js";

function createSmsAlertsPlugin(): Plugin {
  return {
    metadata: { name: "@mycompany/zudo-plugin-sms-alerts", version: "0.3.0", capabilities: ["events"] },
    dependencies: [{ name: "@mycompany/zudo-plugin-receipts", version: "^1.0.0" }],
    async start() {
      throw new Error("SMS gateway rejected the API key");
    },
  };
}

const { events, context } = createHost({ "receipts.from": "[email protected]" });
events.on("plugin.*", (event) => {
  const { plugin, state } = event.payload as { plugin: { name: string }; state: string };
  if (["started", "failed", "stopped", "disposed"].includes(state)) console.log(`event: ${plugin.name.slice(23)} ${state}`);
});

const manager = new PluginManager({ allowedCapabilities: ["events", "container"] });
manager.register(createSmsAlertsPlugin());
manager.register(createReceiptsPlugin());
manager.register(createMailerPlugin(createMemoryTransport().transport));
try {
  await manager.start(context);
} catch (error) {
  console.log("start failed:", (error as Error).name, "-", (error as Error).message);
}

const report = manager.diagnostics();
console.log(`healthy ${report.healthy}, degraded ${report.degraded}, unhealthy ${report.unhealthy}`);
for (const entry of report.plugins) console.log(" ", entry.plugin.name.slice(23), entry.state, entry.health.status, entry.health.details ?? "");

const after = await events.publishEvent({ type: "payment.succeeded", payload: { reference: "ORD-9", email: "[email protected]", amountKobo: 100 } });
console.log("handlers still subscribed to payments:", after.handlerCount);
Output of npx tsx rollback.ts
smtp: connected
event: mailer started
[INFO] [shopflow] receipts started [email protected]
event: receipts started
event: sms-alerts failed
[INFO] [shopflow] receipts stopped drained=0 sent=0
event: receipts stopped
event: mailer stopped
event: sms-alerts disposed
event: receipts disposed
smtp: connection closed
event: mailer disposed
start failed: PluginStartError - Plugin "@mycompany/zudo-plugin-sms-alerts" failed to start: SMS gateway rejected the API key
healthy 2, degraded 0, unhealthy 1
  sms-alerts disposed unhealthy SMS gateway rejected the API key
  receipts disposed healthy
  mailer disposed healthy
handlers still subscribed to payments: 0

Read the output as the rollback's checklist:

  1. SMS alerts failed in start. Receipts was started, so it was stopped (its drain found nothing in flight), then the mailer.
  2. Every plugin that still held anything was disposed, dependents first: the SMS plugin, receipts (whose disposables unsubscribed it from payments), then the mailer (whose disposable closed the connection).
  3. diagnostics() names the culprit and its error, unhealthy. The other two are cleanly disposed, reported as healthy: an idle plugin (registered, stopped or disposed without failing) reads healthy, so a shutdown report is not full of false alarms.
  4. No handler is left on payment.succeeded. Without the onDispose line in receipts' start, a disposed plugin would still be sending mail through a closed connection.

Which plugin failed?

Look again at what the rollback's catch received above: not the SMS plugin's bare Error, but a PluginStartError that already names it. The manager wraps whatever a hook throws before it reaches you: an install or initialize failure becomes PluginInitializationError, a start failure becomes PluginStartError, each with the plugin's name, a stable code, and the original error kept as .cause. Confirm the shape directly:

named-failure.tsNode.js only
import { PluginManager, PluginStartError } from "@zudojs/plugins";
import { createHost } from "./src/host/host.js";

const { context } = createHost();
const manager = new PluginManager();
manager.register({
  metadata: { name: "@mycompany/zudo-plugin-fx-rates", version: "1.0.0" },
  start: () => {
    throw new Error("rates API returned 401");
  },
});

try {
  await manager.start(context);
} catch (error) {
  if (error instanceof PluginStartError) {
    console.log(String(error));
    console.log("plugin:", error.pluginName, "| status:", error.statusCode, "| cause:", (error.cause as Error).message);
  }
}
Output of npx tsx named-failure.ts
PluginStartError [ERR_PLUGIN_START]: Plugin "@mycompany/zudo-plugin-fx-rates" failed to start: rates API returned 401
plugin: @mycompany/zudo-plugin-fx-rates | status: 500 | cause: rates API returned 401

No wrapper of your own needed any more: even a host that only logs error.message at start-up already gets the plugin's name in the text, and code that needs the structured fields catches by type (instanceof PluginStartError) and reads .pluginName, .statusCode and .cause directly. A short "before @zudojs/plugins 1.3, this reached you unwrapped, and hosts wrapped it themselves using diagnostics().plugins.find(p => p.failed)" is worth knowing only if you are reading code written before then.

A ROLLED-BACK MANAGER CANNOT START AGAIN

After a rollback every plugin is disposed, and a second manager.start throws PluginStateError. To retry (for example, without the failing plugin), create a new manager and new plugin objects from the factories. That is one more reason plugins are factories.

A plugin that never answers

A failing plugin is the easy case. Worse is a plugin whose start waits for a service that never answers, such as a rates API behind a firewall that drops packets. Without a limit, the whole application waits with it. hookTimeout is meant to fail any hook that takes longer, and the failure should roll back like any other. Try it the way many hosts start: plugins first, before the HTTP server listens.

hang.mjs
import { createPluginContext, PluginManager } from "@zudojs/plugins";

const manager = new PluginManager({ hookTimeout: 200 });
manager.register({
  metadata: { name: "mailer" },
  start: (context) => context.onDispose(() => console.log("mailer: connection closed")),
});
manager.register({
  metadata: { name: "fx-rates" },
  dependencies: [{ name: "mailer" }],
  start: () => new Promise(() => {}),
});

try {
  await manager.start(createPluginContext({ name: "shopflow" }));
} catch (error) {
  console.log("caught:", error.name);
}
console.log("start-up finished");

Run it the way production runs compiled code, with plain node, and look at everything the process reports:

run-hang.tsNode.js only
import { spawnSync } from "node:child_process";

const run = spawnSync(process.execPath, ["hang.mjs"], { encoding: "utf8" });
console.log("exit code:", run.status);
console.log("stdout:", JSON.stringify(run.stdout.trim()));
console.log("stderr:", run.stderr.trim().split("\n")[0]);
Output of npx tsx run-hang.ts
exit code: 0
stdout: "mailer: connection closed\ncaught: PluginTimeoutError\nstart-up finished"
stderr:

The timeout fires, the rollback stops the mailer (its connection is closed), the error reaches your catch, and start-up carries on to report it. That works even here, where nothing else holds the event loop, because the manager's own timers do. hookTimeout's timer is ref'd, so a hanging hook cannot let Node exit before it fires. After it fires, the manager waits one more hookTimeout to see whether the abandoned start() settles after all. Promise.race cannot cancel the losing call, and a plugin that does finish starting late must still be stopped. That grace wait is ref'd too, and it is cleared the moment it is decided.

Before @zudojs/plugins 1.4.1 the grace timer was unref'd. A start() that never settled let Node exit with code 13 ("unsettled top-level await") one hookTimeout after the timeout, with no error, no rollback and no log line. Hosts guarded against that with a setInterval around manager.start(). You no longer need that, but the lesson behind it still holds: a timeout you have never seen fire in a fresh process is a timeout you have not tested.

The same failure inside the plugin project, reading the diagnostics afterwards:

hang-guarded.tsNode.js only
import { PluginManager } from "@zudojs/plugins";
import { createHost, createMemoryTransport } from "./src/host/host.js";
import { createMailerPlugin } from "./src/mailer/mailer.plugin.js";

const { context } = createHost();
const manager = new PluginManager({ hookTimeout: 200 });
manager.register(createMailerPlugin(createMemoryTransport().transport));
manager.register({
  metadata: { name: "@mycompany/zudo-plugin-fx-rates", version: "1.0.0" },
  dependencies: [{ name: "@mycompany/zudo-plugin-mailer" }],
  start: () => new Promise<void>(() => {}),
});

try {
  await manager.start(context);
} catch (error) {
  const timeout = error as Error & { getMetadata(key: string): unknown };
  console.log(timeout.name, "-", timeout.message, "| phase:", timeout.getMetadata("phase"));
}
console.log(manager.diagnostics().plugins.map((p) => `${p.plugin.name.slice(23)}: ${p.health.status}`).join(", "));
Output of npx tsx hang-guarded.ts
smtp: connected
smtp: connection closed
PluginTimeoutError - Plugin "@mycompany/zudo-plugin-fx-rates" timed out after 200ms. | phase: starting
mailer: healthy, fx-rates: unhealthy

The mailer is stopped and its connection closed, and the diagnostics blame the right plugin. Choose the timeout from the slowest honest start-up of your plugins (a database migration may need a minute; a mailer needs seconds), and remember it bounds stop too, which is what limits the receipts plugin's drain on shutdown.

TEST START-UP THE WAY PRODUCTION RUNS IT

Start-up failures behave differently when nothing else holds the event loop, which is exactly the situation in a fresh process at start-up, and rarely the situation in a test runner or a development server. A start-up test that runs your real entry file in a child process, with a plugin that hangs, is the only reliable way to know that your timeout works.

Testing the plugin

Two kinds of tests. Unit tests call the hooks directly with a context you control. createOwnedPluginContext returns the context together with its disposables list and its abort function, which is exactly what a test needs to play the manager's part. Integration tests run the plugin in a real PluginManager with its real dependency, and check the promises from the reasoning section: ordering, rollback, cleanup.

The same Vitest helper as in Creating a ZudoJS package prints one line per test:

vitest-run.ts
import { startVitest } from "vitest/node";

/** Runs test files with Vitest and prints one line per test. */
export async function runTests(...files: string[]): Promise<void> {
  const vitest = await startVitest("test", files, { watch: false, reporters: [] });
  for (const file of vitest.state.getTestModules()) {
    for (const error of file.errors()) console.log(`× ${file.relativeModuleId}: ${error.message}`);
    for (const test of file.children.allTests()) {
      const { state, errors = [] } = test.result();
      console.log(`${state === "passed" ? "✓" : "×"} ${test.fullName}`);
      for (const error of errors) console.log(`    ${error.message}`);
    }
  }
  await vitest.close();
}
tests/receipts.plugin.test.ts
import { describe, expect, it } from "vitest";
import { PluginManager, createOwnedPluginContext } from "@zudojs/plugins";
import type { PluginEvents } from "@zudojs/plugins";
import { MAILER } from "../src/mailer/mailer.contract.js";
import type { Mailer } from "../src/mailer/mailer.contract.js";
import { createHost, createMemoryTransport } from "../src/host/host.js";
import { createMailerPlugin } from "../src/mailer/mailer.plugin.js";
import { createReceiptsPlugin } from "../src/receipts/receipts.plugin.js";

function fakeEvents() {
  const handlers = new Map<string, (payload: unknown) => void>();
  const events: PluginEvents = {
    on: (name, handler) => void handlers.set(name, handler),
    off: (name) => void handlers.delete(name),
    emit: () => undefined,
  };
  return { events, handlers };
}

describe("receipts plugin, hooks in isolation", () => {
  it("subscribes on start and unsubscribes when disposed", async () => {
    const sentTo: string[] = [];
    const mailer: Mailer = { send: async (message) => void sentTo.push(message.to) };
    const container = { register: () => undefined, resolve: <T,>(token: unknown): T => (token === MAILER ? mailer : undefined) as T };
    const { events, handlers } = fakeEvents();
    const owned = createOwnedPluginContext({ name: "receipts" }, { events, container });
    const plugin = createReceiptsPlugin();

    await plugin.install?.(owned.context, { from: "[email protected]" });
    await plugin.initialize?.(owned.context);
    await plugin.start?.(owned.context);
    handlers.get("payment.succeeded")!({ reference: "ORD-1", email: "[email protected]", amountKobo: 100 });
    await plugin.stop?.(owned.context);
    expect(sentTo).toEqual(["[email protected]"]);

    for (const disposable of owned.disposables.reverse()) await disposable.dispose();
    expect(handlers.has("payment.succeeded")).toBe(false);
  });

  it("refuses a host without events", async () => {
    const owned = createOwnedPluginContext({ name: "receipts" });
    expect(() => createReceiptsPlugin().install?.(owned.context, { from: "[email protected]" })).toThrow("must provide events");
  });
});

describe("receipts plugin, inside a manager", () => {
  it("closes the mail connection when a later plugin fails", async () => {
    const { context } = createHost({ "receipts.from": "[email protected]" });
    const log: string[] = [];
    const { transport } = createMemoryTransport();
    const manager = new PluginManager();
    manager.register(createMailerPlugin({ ...transport, close: async () => void log.push("closed") }));
    manager.register(createReceiptsPlugin());
    manager.register({
      metadata: { name: "broken" },
      dependencies: [{ name: "@mycompany/zudo-plugin-receipts" }],
      start: () => Promise.reject(new Error("boom")),
    });
    await expect(manager.start(context)).rejects.toThrow("boom");
    expect(log).toEqual(["closed"]);
    expect(manager.diagnostics().plugins.every((p) => p.state === "disposed")).toBe(true);
  });
});
test-plugin.tsNode.js only
import { runTests } from "./vitest-run.js";

await runTests("tests/receipts.plugin.test.ts");
Output of npx tsx test-plugin.ts
✓ receipts plugin, hooks in isolation > subscribes on start and unsubscribes when disposed
✓ receipts plugin, hooks in isolation > refuses a host without events
✓ receipts plugin, inside a manager > closes the mail connection when a later plugin fails

The first test plays the manager by hand: it calls the hooks in order and then runs the disposables newest first, as the manager does. That makes the cleanup contract testable without a manager. The last test protects the promise that matters most in production: a failure anywhere later in the start-up never leaves the mail connection open. (The memory transport prints its "connected" line to the test's output, which the helper does not show.)

Shipping the plugin

Package the plugin exactly like @mycompany/zudo-payments: src compiled to dist, an exports map, changesets. Three plugin-specific points go into its manifest:

package.json
{
  "name": "@mycompany/zudo-plugin-receipts",
  "version": "1.0.0",
  "description": "Emails a receipt for every successful payment. A ZudoJS plugin.",
  "license": "MIT",
  "type": "module",
  "exports": {
    ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
  },
  "files": ["dist"],
  "keywords": ["zudojs", "zudojs-plugin", "payments", "email"],
  "peerDependencies": {
    "@zudojs/plugins": "^1.3.0",
    "@mycompany/zudo-plugin-mailer": "^2.0.0"
  },
  "devDependencies": {
    "@zudojs/plugins": "^1.3.3",
    "@mycompany/zudo-plugin-mailer": "^2.1.0",
    "@zudojs/container": "^1.2.3",
    "@zudojs/events": "^1.3.3",
    "typescript": "^7.0.2",
    "vitest": "^5.0.1"
  }
}
  • @zudojs/plugins is a peer dependency, for the reason the plugins lesson gave: the host's copy runs the lifecycle, and your plugin must use its types and helpers.
  • The mailer is a peer dependency too. The version range appears twice, and must agree: in peerDependencies, so npm installs a compatible mailer package; and in the plugin's dependencies, so the manager checks the registered plugin. The first is about files on disk, the second about what the host actually registered.
  • Capabilities are part of the public API. A host allow-lists them; adding a capability in a new version makes register fail for every host that did not grant it. Treat a new capability as a major change, and list the capabilities in your README.

In production

  • Trust: a plugin runs with all the power of the process. Review third-party plugins like any dependency, pin their versions, and grant only the capabilities they need.
  • Idempotency: in-process events arrive once. If the host later moves payment.succeeded to a queue, the same payment can arrive twice; keep a record of sent receipts by reference and skip duplicates.
  • Observability: log the lifecycle events (plugin.*) at start-up and shutdown, and expose both manager.diagnostics() and each plugin's own health() on your health endpoint.
  • Bounded work: set hookTimeout, keep start fast (open connections, subscribe, schedule; no long jobs), and make stop finish or give up within the timeout.
  • Secrets: read keys through context.config from the environment, never from plugin options committed to the repository, and never log them.

Practice

TRY IT YOURSELF

Degrade only after repeated failures

One bounced mailbox is the customer's problem, not the mail system's. Change the health rule: degraded only after three failures in a row, reset by any success. Show it with a small stand-in for the plugin's counters.

Write it in the editor, run it on your computer, then press Check and paste what it printed. Hints and the solution open up once you have checked your output.

TRY IT YOURSELF

Predict the orders

Plugins: api depends on receipts; receipts depends on mailer and optionally on audit; audit is registered. They are registered as api, audit, receipts, mailer. Predict the start order, the stop order, and what happens to each if api's start throws. Then check with plugins that only log.

Write it in the editor, run it on your computer, then press Check and paste what it printed. Hints and the solution open up once you have checked your output.

Recap

  • A complete plugin is a factory returning metadata (name, version, capabilities), dependencies with version ranges, and hooks that each do one job: install validates and offers, initialize wires, start works, stop finishes, disposal releases.
  • Register cleanup with onDispose or registerDisposable on the line after you create the thing it cleans up. Rollback and shutdown then release exactly what exists.
  • Share services between plugins through the container under a Symbol.for token; tell others what happened through events. Optional dependencies only order plugins that are present.
  • The manager checks capabilities at register, dependencies and versions before any hook, and rolls back everything on a failed or timed-out hook. register checks options against the plugin's type when the compiler can still see it as that concrete plugin, and a hook's error reaches you already named and typed (PluginInitializationError, PluginStartError, PluginTimeoutError, …) with the original kept as .cause — validate options in install too, for whatever the type system cannot see.
  • health() is an optional part of the Plugin interface; diagnostics consult it for every started plugin automatically, and treat an idle plugin (registered, cleanly stopped or disposed) as healthy. Give plugins their own health() when lifecycle state alone would not tell the whole story.
  • Test hooks in isolation with createOwnedPluginContext, and the rollback promises inside a real manager.

You can now read ZudoJS, extend it with packages and plugins, and prove each piece works. Next: the real-world projects, starting with a users, products and orders API built from everything in the course.

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.