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, capabilities, lifecycle, scoped context, events, diagnostics, rollback and tests.

  • 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: "receipts@shopflow.ng" } 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. "receipts@shopflow.ng". */
  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 } 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(): { readonly status: string; readonly details?: unknown };
}

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 not part of the Plugin interface. The manager's diagnostics only know a plugin's lifecycle state; whether receipts are actually going out is something only the plugin knows, so it offers a method for the host to call.

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": "receipts@shopflow.ng" });
events.on("plugin.started", (event) => {
  console.log("event: started", (event.payload as { plugin: { name: string } }).plugin.name);
});

const { transport, outbox } = createMemoryTransport({ failFor: "bounce@example.com" });
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", "ada@example.com");
await pay("ORD-1002", "bounce@example.com");
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", "tunde@example.com");
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 from=receipts@shopflow.ng
event: started @mycompany/zudo-plugin-receipts
two payments published
[WARN] [shopflow] receipt not sent reference=ORD-1002 reason="ORD-1002: mailbox unavailable"
manager: 2 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
[
  'ada@example.com <- Receipt for ORD-1001',
  'tunde@example.com <- 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 says both plugins are healthy, because both are started. The plugin knows better: its last receipt bounced. A health endpoint should report both views.
  • 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 { 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: "receipts@shopflow.ng" });
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();
typo.register(createReceiptsPlugin(), { form: "receipts@shopflow.ng" });
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. TypeError - 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: the second argument of register is typed unknown, whatever the plugin declares. Only the plugin's own validation caught it. 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

Because register does not check options against the plugin's type, a plugin package can offer a typed helper instead: for example createReceiptsPlugin(options) that keeps the options in the closure. Then the compiler checks them in the host's code, and install still validates them at run time.

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": "receipts@shopflow.ng" });
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: "ada@example.com", 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 from=receipts@shopflow.ng
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": "receipts@shopflow.ng" });
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).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: "ada@example.com", 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 from=receipts@shopflow.ng
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: SMS gateway rejected the API key
healthy 0, degraded 2, unhealthy 1
  sms-alerts disposed unhealthy SMS gateway rejected the API key
  receipts disposed degraded
  mailer disposed degraded
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. The other two are disposed, reported as degraded.
  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?

Notice what the catch received: the plugin's own Error, unwrapped. It does not say which plugin threw it. @zudojs/plugins exports PluginStartError and PluginInitializationError, but the published manager never throws them. A host that logs "SMS gateway rejected the API key" at start-up leaves the operator to guess which of twelve plugins has an SMS gateway. Wrap it yourself, using the diagnostics:

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

/** Starts the plugins; when one fails, throws a PluginStartError that names it. */
async function startPlugins(manager: PluginManager, context: PluginContext): Promise<void> {
  try {
    await manager.start(context);
  } catch (error) {
    const failed = manager.diagnostics().plugins.find((p) => p.failed);
    if (!failed) throw error;
    const reason = error instanceof Error ? error.message : String(error);
    throw new PluginStartError(`Plugin "${failed.plugin.name}" failed to start: ${reason}`, failed.plugin.name, { cause: error });
  }
}

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 startPlugins(manager, 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

Now the log line names the plugin, the error has the stable code ERR_PLUGIN_START, and the original error is kept as the cause. Put startPlugins in the host once, and every plugin failure is reported the same way.

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: 13
stdout: ""
stderr: Warning: Detected unsettled top-level await at /home/you/project/hang.mjs:15

No PluginTimeoutError, no rollback, no "connection closed", not even "start-up finished": Node simply exited with code 13 while start was still waiting. The reason is in the manager's dist code: the timeout timer is created with timer.unref(), which tells Node "do not stay alive just for this timer". The hanging promise holds nothing either, so Node saw no more work and quit. In a server that is already listening, the open socket keeps the process alive and the timeout fires. During start-up, before anything listens, nothing does.

So the host keeps the process alive itself while plugins start. One interval, cleared in finally, is enough:

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>(() => {}),
});

const keepAlive = setInterval(() => {}, 60_000);
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"));
} finally {
  clearInterval(keepAlive);
}
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: degraded, fx-rates: unhealthy

Now the timeout fires, the mailer is stopped and its connection closed, and the diagnostics blame the right plugin. Put the guard in the host's startPlugins helper from the previous section, so every start-up has it. 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

This failure only appears 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: (token: unknown) => (token === MAILER ? mailer : undefined) };
    const { events, handlers } = fakeEvents();
    const owned = createOwnedPluginContext({ name: "receipts" }, { events, container });
    const plugin = createReceiptsPlugin();

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

    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: "receipts@shopflow.ng" })).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": "receipts@shopflow.ng" });
    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.

Show a solution
health-rule.tsNode.js only
import { createDegradedHealth, createHealthyHealth } from "@zudojs/plugins";

function createHealthTracker(limit = 3) {
  let failuresInARow = 0;
  return {
    success: () => void (failuresInARow = 0),
    failure: () => void failuresInARow++,
    health: () => (failuresInARow >= limit ? createDegradedHealth(`${failuresInARow} receipts failed in a row`) : createHealthyHealth()),
  };
}

const tracker = createHealthTracker();
const outcomes = ["fail", "fail", "ok", "fail", "fail", "fail"];
for (const outcome of outcomes) {
  if (outcome === "ok") tracker.success();
  else tracker.failure();
  console.log(outcome.padEnd(4), JSON.stringify(tracker.health()));
}
Output of npx tsx health-rule.ts
fail {"status":"healthy"}
fail {"status":"healthy"}
ok   {"status":"healthy"}
fail {"status":"healthy"}
fail {"status":"healthy"}
fail {"status":"degraded","details":"3 receipts failed in a row"}

In the plugin, call success() where sent++ is and failure() in the catch, and return tracker.health(). A rule based on consecutive failures separates "one address is bad" from "the mail system is down".

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.

Show a solution
orders.tsNode.js only
import { createPluginContext, PluginManager } from "@zudojs/plugins";
import type { PluginDependency } from "@zudojs/plugins";

function plugin(name: string, dependencies: PluginDependency[] = [], optional: PluginDependency[] = [], fails = false) {
  return {
    metadata: { name },
    dependencies,
    optionalDependencies: optional,
    start: () => {
      if (fails) throw new Error(`${name} failed`);
      console.log("start", name);
    },
    stop: () => console.log("stop ", name),
    dispose: () => console.log("dispose", name),
  };
}

const manager = new PluginManager();
manager.register(plugin("api", [{ name: "receipts" }], [], true));
manager.register(plugin("audit"));
manager.register(plugin("receipts", [{ name: "mailer" }], [{ name: "audit" }]));
manager.register(plugin("mailer"));
await manager.start(createPluginContext({ name: "shopflow" })).catch((error: Error) => console.log(error.message));
Output of npx tsx orders.ts
start mailer
start audit
start receipts
stop  receipts
stop  audit
stop  mailer
dispose api
dispose receipts
dispose audit
dispose mailer
api failed

Start: mailer, audit, receipts, then api, which fails. Every plugin starts after everything it depends on, optional ones included when present. The rollback stops the started plugins in reverse order (receipts, audit, mailer) and then disposes all four in reverse start order, the failed api first. A normal shutdown would use the same reverse order.

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. It does not check register options against the plugin's type, and it rethrows a hook's error without naming the plugin: validate options in install, and wrap failures in PluginStartError using the diagnostics.
  • Diagnostics reflect lifecycle state only; give plugins their own health().
  • 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.