Reference: Plugins

Reference

Plugins

A plugin is a plain object with a name and whichever lifecycle hooks you need. Register them in sigil.config.ts:

sigil.config.ts
import { defineConfig } from "@sigil-dev/grimoire";
import { myPlugin } from "./plugins/my-plugin";

export default defineConfig({ plugins: [myPlugin] });

Only name is required. Everything else is optional, so a plugin can be one function or thirty.

The hooks

HookRunsFor
onPluginsResolved(ctx)All plugins collectedReading the full set
onStart(server)Server bootOpening connections
onStop(reason)Server shutdownCleanup
onRequest(req, next)Every requestMiddleware, headers, auth
onRouteLoad(route, ctx)A route's loadTracking, timing
onRouteRender(html, ctx)After HTML rendersHTML transforms
onBuildStart()Before a buildCleaning output
onBuildEnd(result)After a buildReporting
transform(code, id)After compilation, on JSImport rewriting
config(config)When config loadsMutating config
cli(program, config)When the CLI startsRegistering sigil subcommands
onCoordinatorStart(ctx)All workers readyShared state
onWorkerSpawn(worker)Before each workerInjecting env
routeRequest(req, workers, routes)Choosing a workerSticky routing

The one that matters

Implementing onRouteRender disables streaming.

interface GrimoirePlugin {
	onRouteRender?(html: string, context: RenderContext): string | Promise<string>;
}

Streaming works by flushing the response shell before the data has finished. To rewrite the finished HTML, the framework has to wait for all of it, so the response is buffered into a single string. Routes still render correctly — they just arrive all at once, and any loading export on those routes is skipped entirely.

Grimoire warns about this once at server start, naming the plugin. If you are not sure which plugin cost you streaming, that warning is the answer.

If you only need to touch headers, use onRequest instead — it works at the Response level and leaves the body alone.

Registering CLI commands

A plugin can add its own subcommands to sigil:

my-plugin.ts
import type { GrimoirePlugin } from "@sigil-dev/grimoire";

export const plugin: GrimoirePlugin = {
	name: "my-plugin",
	cli: (program, config) => {
		program
			.command("thing")
			.description("do the thing")
			.action(() => run(config.something));
	},
};
sigil thing

The cli hook runs after the project config is loaded and after every plugin's config transform has been applied, so the config you receive is the same one the server sees. Commands are registered before parsing, so they appear in sigil --help.

Plugins without a cli hook are skipped. If a plugin throws while registering, the CLI prints a warning and continues — a broken plugin cannot take sigil dev or sigil build down with it.

The program argument is a structural subset of commander's Command, typed as CliCommand, so grimoire does not depend on commander.

Middleware

onRequest wraps the whole pipeline:

export const requestId = () => ({
	name: "request-id",
	async onRequest(req: Request, next: () => Promise<Response>) {
		const id = crypto.randomUUID();
		const res = await next();
		// Copy into a new Response rather than mutating res.headers: a
		// response from next() may be immutable, and .set() would throw.
		const out = new Response(res.body, {
			status: res.status,
			statusText: res.statusText,
			headers: res.headers,
		});
		out.headers.set("x-request-id", id);
		return out;
	},
});

Copying into a fresh Response matters for two reasons beyond immutability: it also avoids mutating a response another plugin holds a reference to. If you must mutate in place, wrap the set in a try and fall back to rebuilding.

To answer a request without calling next(), return your own Response — the rest of the pipeline is skipped, the same way returning instead of calling resolve works in hooks.server.ts.

Transforming compiled code

transform runs after Sigil has compiled, so it receives JavaScript rather than TSX:

const plugin = {
	transform(code: string, id: string) {
		if (!id.includes("/generated/")) return;
		return code.replace("__BUILD_ID__", buildId);
	},
};

Return null or undefined to pass the code through unchanged. Because it sees compiled output, string-level transforms are the right tool here — an import rewrite, a comment injection — and anything that needs to understand the component tree belongs in the compiler, not here.

Injecting worker environment

onWorkerSpawn runs for each worker before it starts. All plugins are called and the results merged:

const plugin = {
	onWorkerSpawn(worker) {
		return { env: { WORKER_ID: worker.name } };
	},
};

Next

Edit this page