Platform features Advanced
Plugins
Let other code extend the Task API without editing it. Write plugins, declare dependencies, run their lifecycle, give each one a scoped context, roll back failed starts, read diagnostics and publish a plugin to npm with @zudojs/plugins.
Why plugins
Your customers keep asking for extras: post to Slack when a task is done, keep an audit log, send reminders. If each extra is a change to the Task API itself, the core grows forever, and every customer runs code for features they do not use. A plugin is a piece of code that adds a feature to an application through a fixed interface, without changing the application. The application that loads plugins is called the host.
You already know modules from the runtime lesson. The difference is who writes the code and who decides to load it:
- A module is part of your application. It is always there.
- A plugin is optional. It may come from another team or from npm, and each installation decides which plugins it loads.
npm install @zudojs/plugins added 1 package, and audited 76 packages in 4s …
The examples use Node.js features, so run them on your computer with npx tsx src/<file>.ts.
Your first plugin
A plugin is an object with metadata (at least a name) and up to five lifecycle hooks, all optional. A PluginManager registers plugins and runs their hooks in the right order:
import { createPluginContext, PluginManager } from "@zudojs/plugins";
import type { Plugin } from "@zudojs/plugins";
const auditLog: Plugin = {
metadata: { name: "audit-log", version: "1.0.0" },
install: () => console.log("install: check settings, register things"),
initialize: () => console.log("initialize: prepare, every plugin is installed now"),
start: (context) => console.log("start: begin work, as", context.plugin.name),
stop: () => console.log("stop: finish work in progress"),
dispose: () => console.log("dispose: release everything"),
};
const manager = new PluginManager();
manager.register(auditLog);
const context = createPluginContext({ name: "task-api", version: "0.1.0" });
await manager.start(context);
console.log("running:", manager.has("audit-log"));
await manager.stop(context);
npx tsx first.tsinstall: check settings, register things initialize: prepare, every plugin is installed now start: begin work, as audit-log running: true stop: finish work in progress dispose: release everything
manager.start runs install, initialize and start. manager.stop runs stop and dispose. Each phase finishes for all plugins before the next phase begins. createPluginContext describes the host (here task-api), and optionally the services it offers to plugins: a logger, config, a container and events. Inside a hook, context.plugin is the plugin's own metadata.
Dependencies and versions
A Slack plugin needs a notifications plugin that knows how to send messages. It declares that with dependencies, and a version range like the ones in package.json: ^1.0.0 means "1.0.0 or any later 1.x". The manager starts dependencies first and stops them last:
import { createPluginContext, PluginDependencyError, PluginDependencyVersionError, PluginManager } from "@zudojs/plugins";
import type { Plugin } from "@zudojs/plugins";
function plugin(name: string, version: string, needs: Plugin["dependencies"] = []): Plugin {
return {
metadata: { name, version },
dependencies: needs,
start: () => console.log("start", name),
stop: () => console.log("stop ", name),
};
}
const manager = new PluginManager();
manager.register(plugin("slack", "1.0.0", [{ name: "notifications", version: "^1.2.0" }]));
manager.register(plugin("notifications", "1.4.0"));
const context = createPluginContext({ name: "task-api" });
await manager.start(context);
await manager.stop(context);
for (const available of [undefined, "2.0.0"]) {
const broken = new PluginManager();
broken.register(plugin("slack", "1.0.0", [{ name: "notifications", version: "^1.2.0" }]));
if (available) broken.register(plugin("notifications", available));
try {
await broken.start(createPluginContext({ name: "task-api" }));
} catch (error) {
if (error instanceof PluginDependencyError || error instanceof PluginDependencyVersionError) {
console.log(error.name, "-", error.message);
}
}
}
npx tsx dependencies.tsstart notifications
start slack
stop slack
stop notifications
PluginDependencyError - Plugin "slack" depends on "notifications" which is not registered.
PluginDependencyVersionError - Plugin "slack" requires "notifications@^1.2.0", but version 2.0.0 is registered. Register a "notifications" that satisfies ^1.2.0, relax the constraint on "slack", or construct the manager with { checkVersions: false }.Slack was registered first, but notifications started first, because Slack depends on it. On shutdown the order is reversed. Both problems are found before any hook runs: a missing plugin, and a notifications 2.0.0 that does not satisfy ^1.2.0, because a new major version may have changed its interface.
Some dependencies are nice to have. Slack can post without the notifications plugin, only less nicely. List those under optionalDependencies: when the plugin is there, it starts first; when it is missing, nothing fails:
import { createPluginContext, PluginManager } from "@zudojs/plugins";
import type { Plugin } from "@zudojs/plugins";
const slack: Plugin = {
metadata: { name: "slack", version: "1.0.0" },
optionalDependencies: [{ name: "notifications", version: "^1.2.0" }],
start: () => console.log("start slack"),
};
const manager = new PluginManager();
manager.register(slack);
await manager.start(createPluginContext({ name: "task-api" }));
npx tsx optional.tsstart slack
optional: true DOES NOT MAKE A DEPENDENCY OPTIONAL
The dependency type also has anoptional field, so TypeScript accepts dependencies: [{ name: "notifications", optional: true }]. The manager ignores that field: a missing notifications still throws PluginDependencyError. Use optionalDependencies.The scoped plugin context
A plugin often creates things that must be cleaned up: timers, connections, subscriptions. The manager gives each plugin its own scoped view of the context:
context.onDispose(fn)andcontext.registerDisposable(obj)add to this plugin's cleanup list, which runs when the plugin is disposed, newest first.context.signalis anAbortSignalthat fires when the plugin system shuts down, to cancel slow work such as afetch.register(plugin, options)hands the options toinstall(context, options). That is how an installation configures a plugin.
import { createPluginContext, PluginManager } from "@zudojs/plugins";
import type { Plugin } from "@zudojs/plugins";
interface ReminderOptions {
readonly everyMs: number;
}
export function createReminderPlugin(): Plugin<ReminderOptions> {
let everyMs = 0;
return {
metadata: { name: "reminders", version: "1.0.0" },
install(_context, options) {
if (!Number.isInteger(options.everyMs) || options.everyMs < 10) throw new Error("everyMs must be an integer >= 10");
everyMs = options.everyMs;
},
start(context) {
const timer = setInterval(() => console.log("reminder: 2 tasks are due today"), everyMs);
context.onDispose(() => {
clearInterval(timer);
console.log("reminders: timer cleared");
});
context.signal.addEventListener("abort", () => console.log("reminders: shutdown signal"));
},
};
}
const manager = new PluginManager();
manager.register(createReminderPlugin(), { everyMs: 40 });
const context = createPluginContext({ name: "task-api" });
await manager.start(context);
await new Promise((resolve) => setTimeout(resolve, 100));
await manager.stop(context);
console.log("host exits cleanly");
npx tsx reminders.tsreminder: 2 tasks are due today reminder: 2 tasks are due today reminders: shutdown signal reminders: timer cleared host exits cleanly
The timer fired twice in 100 ms, and then stop released it. Without the cleanup, the interval would keep the process alive forever and keep printing after the plugin was "stopped". The plugin also checks its options in install: settings from outside are input like any other, and a wrong value should stop the start-up, not cause strange behaviour later.
Failed starts, rollback and diagnostics
What if the Slack plugin cannot reach Slack while the Task API starts? Half-started plugins are dangerous: some timers run, some connections are open, and nothing will ever stop them. So when any hook of manager.start throws, the manager rolls back: it stops and disposes every plugin it had already brought up, then throws the error to you:
import { createPluginContext, PluginManager } from "@zudojs/plugins";
const manager = new PluginManager({ hookTimeout: 1000 });
manager.register({
metadata: { name: "audit-log", version: "1.0.0" },
start: (context) => context.onDispose(() => console.log("audit-log: file closed")),
stop: () => console.log("audit-log: stopped"),
});
manager.register({
metadata: { name: "slack", version: "1.0.0" },
dependencies: [{ name: "audit-log" }],
start: () => {
throw new Error("cannot reach slack.com");
},
});
try {
await manager.start(createPluginContext({ name: "task-api" }));
} catch (error) {
console.log("start failed:", error instanceof Error ? error.message : error);
}
const report = manager.diagnostics();
console.log(`total ${report.total}, healthy ${report.healthy}, failed ${report.failed}`);
for (const entry of report.plugins) {
console.log(entry.plugin.name, entry.state, entry.health.status, entry.health.details ?? "");
}
npx tsx rollback.tsaudit-log: stopped audit-log: file closed start failed: cannot reach slack.com total 2, healthy 0, failed 1 audit-log disposed degraded slack disposed unhealthy cannot reach slack.com
- The audit log had started, so it was stopped and its cleanup ran. Nothing is left running.
diagnostics()reports every plugin's state and health. The failed plugin keeps its error message inhealth.details, so a health endpoint or a log line can say why.hookTimeoutfails a hook that takes longer than the limit, so one hanging plugin cannot freeze the start-up of the whole Task API.
A disposed plugin cannot be started again: calling manager.start a second time throws a PluginStateError. To retry, create a new manager with fresh plugin objects.
Put it together: plugins for the Task API
Plugins need a way to react to what the Task API does. The host offers that through context.events. Pass it the event bus you built in the events lesson. The plugin system adapts it for plugins: a plugin calls on(name, handler) and its handler receives the event's payload. The bus also catches errors, so a broken plugin handler cannot break the request that published the event. (This works since @zudojs/plugins 1.3.0. The adapter is exported as toPluginEvents, if you need it yourself.)
The audit-log plugin subscribes when it starts, and unsubscribes in its cleanup list. It only uses what the context gives it, so it works in any host that offers events and a logger:
import type { Plugin } from "@zudojs/plugins";
export interface TaskCompleted {
readonly taskId: number;
readonly userId: string;
}
export function createAuditLogPlugin(): Plugin {
return {
metadata: { name: "audit-log", version: "1.0.0", capabilities: ["events"] },
start(context) {
const onCompleted = (event: unknown) => {
const { taskId, userId } = event as TaskCompleted;
context.logger?.info("audit: task completed", { taskId, userId });
};
context.events?.on("task.completed", onCompleted);
context.onDispose(() => context.events?.off("task.completed", onCompleted));
},
};
}
The host builds the context with its logger and the bus, loads the plugins, and publishes task.completed whenever a task is done. The Slack plugin needs a secret webhook address, which comes from an environment variable. When it is missing, the host logs a warning and starts without Slack, instead of failing or putting a secret in the code:
import { createEventBus } from "@zudojs/events";
import { createLogger, createTextLoggerFormatter } from "@zudojs/logger";
import { createPluginContext, PluginManager } from "@zudojs/plugins";
import { createAuditLogPlugin } from "./audit-log.plugin.js";
const logger = createLogger({
name: "task-api",
formatter: createTextLoggerFormatter({ includeTimestamp: false }),
transports: [(entry) => console.log(entry.formatted ?? entry.message)],
});
const events = createEventBus();
events.on("plugin:started", (event) => {
const { plugin } = event.payload as { plugin: { name: string; version?: string } };
logger.info("plugin started", { name: plugin.name, version: plugin.version });
});
const manager = new PluginManager({ allowedCapabilities: ["events"], hookTimeout: 5000, logger });
manager.register(createAuditLogPlugin());
const webhook = process.env["SLACK_WEBHOOK_URL"];
if (webhook) {
logger.info("slack plugin would be registered here");
} else {
logger.warn("SLACK_WEBHOOK_URL is not set, running without the slack plugin");
}
const context = createPluginContext({ name: "task-api", version: "0.1.0" }, { logger, events });
await manager.start(context);
const done = await events.publishEvent({ type: "task.completed", payload: { taskId: 7, userId: "ada" } });
console.log("task 7:", done.handlerCount, "handler, failed:", done.failed);
console.log("diagnostics:", manager.diagnostics().healthy, "healthy");
await manager.stop(context);
const after = await events.publishEvent({ type: "task.completed", payload: { taskId: 8, userId: "ada" } });
console.log("task 8 after stop:", after.handlerCount, "handlers");
npx tsx src/plugins/load.ts[WARN] [task-api] SLACK_WEBHOOK_URL is not set, running without the slack plugin [INFO] [task-api] plugin started name=audit-log version=1.0.0 [INFO] [task-api] audit: task completed taskId=7 userId=ada task 7: 1 handler, failed: 0 diagnostics: 1 healthy task 8 after stop: 0 handlers
The plugin manager itself publishes plugin:started (and plugin:stopped, and more) on the same bus, which is where the "plugin started" line comes from. After stop, the audit log's cleanup list unsubscribed its handler, so task 8 reached nobody. If a plugin handler throws, failed counts it and errors holds the error for your log; the other handlers still run.
The host only knows the plugin interface. You could add a Slack, a statistics or a reminder plugin without touching this code, except for the one register line. And allowedCapabilities refuses to register a plugin that asks for more than the host grants.
A PLUGIN RUNS WITH ALL THE POWER OF YOUR APP
Plugins are ordinary JavaScript in your process: a plugin can read every environment variable, open any file and send your data anywhere.capabilities is a declaration that the manager checks against your allow-list. It catches mistakes, but it is not a sandbox, and it cannot stop hostile code. Only install plugins you trust, pin their versions, and review what they do, exactly as with any other npm dependency.Publishing a plugin
To share a plugin, publish it as an npm package. A few conventions make it easy to use:
- Export a factory function, like
createAuditLogPlugin(), not a ready-made object. Every host, and every test, gets fresh state. - Put
@zudojs/pluginsinpeerDependencies, not independencies. A peer dependency says "the host must provide this". If each plugin installed its own copy, the host would end up with several copies, and classes likePluginDependencyErrorfrom one copy would fail aninstanceofcheck against another. Also list it indevDependencies, so you can build and test. - Import only types from
@zudojs/pluginswhere you can (import type). Then the plugin works with whatever copy the host has. - Ship compiled JavaScript and type declarations, and add the keyword
zudojs-pluginso people can find it.
Start a new folder for the package, run npm init -y, then npm install -D @zudojs/plugins typescript, and edit package.json until it looks like this:
{
"name": "@acme/zudo-plugin-audit-log",
"version": "1.0.0",
"description": "Logs every completed task of a ZudoJS Task API.",
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"keywords": [
"zudojs",
"zudojs-plugin",
"audit"
],
"scripts": {
"build": "tsc"
},
"peerDependencies": {
"@zudojs/plugins": "^1.2.0"
},
"devDependencies": {
"@zudojs/plugins": "^1.3.0",
"typescript": "^7.0.2"
}
}
Copy audit-log.plugin.ts to src/index.ts. This tsconfig.json compiles src into dist and writes the .d.ts type files next to the JavaScript:
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"types": []
},
"include": ["src"]
}
Build it. Before you publish, npm pack --dry-run shows exactly which files would be uploaded:
npm run build > @acme/zudo-plugin-audit-log@1.0.0 build > tsc npm pack --dry-run npm notice npm notice package: @acme/zudo-plugin-audit-log@1.0.0 npm notice Tarball Contents npm notice 79B README.md npm notice 196B dist/index.d.ts npm notice 529B dist/index.js npm notice 566B package.json npm notice Tarball Details npm notice name: @acme/zudo-plugin-audit-log npm notice version: 1.0.0 npm notice filename: acme-zudo-plugin-audit-log-1.0.0.tgz npm notice package size: 754 B npm notice unpacked size: 1.4 kB npm notice shasum: d0a9da502d45b919d2af4d873693bef1bb6e3047 npm notice integrity: sha512-IUF9tRPkgZ0cI[...]yFAz/fY6muE6w== npm notice total files: 4 npm notice acme-zudo-plugin-audit-log-1.0.0.tgz
Four files: the compiled code, its types, the package.json and a README. Your src folder, node_modules and any .env file stay at home, because "files": ["dist"] lists what to ship (npm always adds the README and package.json). Then npm publish --access public uploads it (you need an npm account, and @acme must be your scope). A host installs it with npm install @acme/zudo-plugin-audit-log and registers createAuditLogPlugin().
Practice
TRY IT YOURSELF
A statistics plugin
Write createStatsPlugin(): it counts task.completed events per user and, in stop, logs the counts with context.logger. Remember to unsubscribe in the cleanup list.
Show a solution
import { createEventBus } from "@zudojs/events";
import { createPluginContext, PluginManager } from "@zudojs/plugins";
import type { Plugin } from "@zudojs/plugins";
function createStatsPlugin(): Plugin {
const perUser = new Map<string, number>();
return {
metadata: { name: "stats", version: "1.0.0" },
start(context) {
const onCompleted = (event: unknown) => {
const { userId } = event as { userId: string };
perUser.set(userId, (perUser.get(userId) ?? 0) + 1);
};
context.events?.on("task.completed", onCompleted);
context.onDispose(() => context.events?.off("task.completed", onCompleted));
},
stop(context) {
context.logger?.info("tasks completed per user", Object.fromEntries(perUser));
},
};
}
const events = createEventBus();
const logger = { info: console.log, warn: console.log, error: console.log };
const manager = new PluginManager();
manager.register(createStatsPlugin());
const context = createPluginContext({ name: "task-api" }, { events, logger });
await manager.start(context);
for (const userId of ["ada", "linus", "ada"]) {
await events.publishEvent({ type: "task.completed", payload: { taskId: 1, userId } });
}
await manager.stop(context);
npx tsx src/plugins/stats.tstasks completed per user { ada: 2, linus: 1 }TRY IT YOURSELF
Which order?
Plugins: api depends on db and cache, cache depends on db. They are registered as api, cache, db. In which order do they start, and in which order do they stop? What happens if db also declares a dependency on api?
Show a solution
Start: db, cache, api: every plugin starts after everything it depends on. Stop: the reverse, api, cache, db. If db depended on api, there would be a cycle (api → db → api) with no valid order. manager.start throws a PluginDependencyCycleError that names the cycle, before any hook runs.
Recap
- A plugin adds an optional feature to a host through a fixed interface: metadata plus the hooks
install,initialize,start,stopanddispose. PluginManagerchecks dependencies and version ranges before any hook runs, starts in dependency order and stops in reverse. Nice-to-have plugins go inoptionalDependencies.- Pass the event bus from @zudojs/events as
events: plugin handlers receive the payload, and a throwing handler cannot break the publisher. - Each plugin gets a scoped context: its own cleanup list, an abort signal and the host's services. Options passed to
registerreachinstall. Validate them. - A failed start rolls back everything already started.
diagnostics()reports state and health. - Plugins are trusted code. Capabilities catch mistakes but are not a sandbox.
- Publish plugins as factory functions, with
@zudojs/pluginsas a peer dependency.
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.