Plugins & encapsulation
Plugins package routes, hooks, and decorators into reusable units. Like Fastify, plugins are encapsulated — what happens inside a plugin stays inside, unless you opt out.
Defining a plugin
A plugin is any object with an optional name and a register(app) function — or a plain function with the same shape. No imports required.
import type { App } from "@daloyjs/core";
export const usersPlugin = {
name: "users",
register(app: App) {
app.use(/* plugin-scoped middleware */);
app.route({
method: "GET",
path: "/me",
operationId: "me",
responses: { 200: { description: "current user" } },
handler: async () => ({ status: 200, body: { user: "alice" } }),
});
},
};Registering a plugin
app.register(usersPlugin, {
prefix: "/users",
tags: ["Users"],
hooks: bearerAuth({ validate: t => t === process.env.TOKEN }),
});
await app.ready();Decorators
Decorate your app to inject shared resources into every handler's state:
app.decorate("db", await openDatabase());
app.decorate("logger", createLogger({ level: "info" }));
app.route({
method: "GET",
path: "/items/:id",
operationId: "getItem",
responses: { 200: { description: "ok" } },
handler: async ({ params, state }) => {
const row = await state.db.findOne("items", { id: params.id });
state.logger.info({ id: params.id }, "item fetched");
return { status: 200, body: row };
},
});Why encapsulation matters
- You can mount the same plugin twice under different prefixes without bleed-through.
- Third-party plugins can't accidentally rewrite your error handler or hooks.
- Plugin-internal middleware doesn't apply to sibling routes — predictable order.
Lifecycle events
Observability plugins often need to know when other plugins finish installing or when the process starts shutting down. DaloyJS exposes two event hooks for this without polluting the route registry:
app.onPluginInstalled(listener)— fires once perregister()call, after sync plugins return and after async plugins resolve. The listener receives{ name?: string, prefix: string }, whereprefixis the effective mounted prefix after nesting. Awaitingapp.ready()drains both async plugins and async listeners.app.onShutdown(listener)— fires at the start ofapp.shutdown(timeoutMs, reason), beforein-flight requests drain. Use this to flush metrics, publish a “draining” signal to a load balancer, or close background pollers. For post-drain cleanup (database pools, file handles), keep usingonClose().
app.onPluginInstalled((info) => {
metrics.counter("plugin.installed", { name: info.name ?? "anonymous", prefix: info.prefix });
});
app.onShutdown(async ({ reason, timeoutMs }) => {
await loadBalancer.drain({ timeoutMs });
metrics.counter("app.shutdown", { reason: reason ?? "unknown" });
});
app.onClose(async () => {
await db.close();
});
app.register(usersPlugin, { prefix: "/users" });
await app.ready();
// later, on SIGTERM
await app.shutdown(10_000, "SIGTERM");Listener errors are caught and logged via the configured logger so a faulty observer can never block plugin registration or graceful shutdown. Both shutdown() and the underlying onClose chain remain idempotent.