Address whole-project review (todo §2); wire plugin hooks (onBoot/onRequest/onResponse), document template trust boundary, tidy discovery

This commit is contained in:
2026-06-16 16:23:08 +02:00
parent ff7b55be4c
commit a8ebf81588
8 changed files with 150 additions and 12 deletions

View File

@@ -176,6 +176,29 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per
assert.equal((await fetch(url + "/demo/nope")).status, 404);
});
test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
const seen: string[] = [];
const hooked: Plugin = {
apiVersion: "1.0.0",
hooks: {
onRequest: (c) => (c.url.pathname === "/hooked/blocked" ? { html: "blocked by hook", status: 403 } : undefined),
onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`),
},
id: "hooked",
routes: [{ handler: () => ({ html: "handler ran" }), method: "GET", path: "/ok" }],
};
const url = await startApp(t, [hooked]);
// onRequest short-circuits before routing — handler never runs.
const blocked = await fetch(url + "/hooked/blocked");
assert.equal(blocked.status, 403);
assert.match(await blocked.text(), /blocked by hook/);
// A normal route runs the handler; onResponse observed its result.
assert.match(await (await fetch(url + "/hooked/ok")).text(), /handler ran/);
assert.ok(seen.includes("/hooked/ok:handler ran"));
});
test("rejects unsafe static request paths (encoded traversal, NUL) with 403", async () => {
assert.equal((await fetch(base + "/public/..%2f..%2fapp.ts")).status, 403);
assert.equal((await fetch(base + "/public/%00")).status, 403);

View File

@@ -5,6 +5,7 @@ import * as ejs from "ejs";
import { buildContext } from "./context.ts";
import { buildDashboardModel } from "./dashboard.ts";
import { PLUGINS_DIR } from "./discovery.ts";
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
@@ -29,6 +30,9 @@ export function createApp(options: AppOptions = {}): Server {
const menu = options.menu ?? DEFAULT_MENU;
const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id));
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
const publicDir = options.publicDir ?? join(rootDir, "public");
const viewsDir = options.viewsDir ?? join(rootDir, "views");
@@ -48,8 +52,8 @@ export function createApp(options: AppOptions = {}): Server {
return createServer(async (req, res) => {
try {
const method = req.method ?? "GET";
const { url } = buildContext(req, res);
const pathname = url.pathname;
const ctx = buildContext(req, res); // base context (no route params yet); reused for onRequest
const pathname = ctx.url.pathname;
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
// /public/<id>/… serves a plugin's public/; everything else the core public/.
@@ -58,22 +62,32 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) {
const short = await runRequestHooks(plugins, ctx);
if (short) {
await sendResult(res, short.result, (view, data) => renderView(short.plugin.id, view, data));
return;
}
}
// Plugin routes (any method): gate on the route's permission, then run the handler.
const match = matchRoute(plugins, method, pathname);
if (match) {
const ctx = buildContext(req, res, { params: match.params });
if (!isAuthorized(match.route, ctx.roles)) {
const routeCtx = buildContext(req, res, { params: match.params });
if (!isAuthorized(match.route, routeCtx.roles)) {
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
const result = await match.route.handler(ctx);
await sendResult(res, result ?? null, (view, data) => renderView(match.plugin.id, view, data));
const result = (await match.route.handler(routeCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
await sendResult(res, result, (view, data) => renderView(match.plugin.id, view, data));
return;
}
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// Mock data + no roles until auth (§4) lands; branding/override come from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel(url, [], menu) }));
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, [], menu) }));
return;
}

View File

@@ -29,7 +29,7 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
const plugins: Plugin[] = [];
for (const id of pluginFolders(dir)) {
const fail = (msg: string): number => errors.push(`plugins/${id}: ${msg}`);
const fail = (msg: string): void => void errors.push(`plugins/${id}: ${msg}`);
if (!isValidPluginId(id)) {
errors.push(`"${id}" is not a valid plugin folder name (lowercase az, digits, dashes)`);

50
src/hooks.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { RequestContext } from "./context.ts";
import type { Plugin, PluginHooks } from "./plugin.ts";
import { runBootHooks, runRequestHooks, runResponseHooks } from "./hooks.ts";
const ctx = {} as RequestContext; // the hooks only thread ctx through; they never read it
function plugin(id: string, hooks: PluginHooks): Plugin {
return { apiVersion: "1.0.0", hooks, id };
}
test("runBootHooks runs each onBoot in order, skips plugins without one, and a throw aborts", async () => {
const calls: string[] = [];
await runBootHooks([
plugin("a", { onBoot: () => void calls.push("a") }),
plugin("b", {}), // no onBoot → skipped
plugin("c", { onBoot: async () => void calls.push("c") }),
]);
assert.deepEqual(calls, ["a", "c"]);
await assert.rejects(runBootHooks([plugin("x", { onBoot: () => { throw new Error("boom"); } })]), /boom/);
});
test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => {
const calls: string[] = [];
const short = await runRequestHooks([
plugin("a", { onRequest: () => void calls.push("a") }), // returns void → continue
plugin("b", { onRequest: () => { calls.push("b"); return { html: "stop" }; } }),
plugin("c", { onRequest: () => void calls.push("c") }), // never reached
], ctx);
assert.deepEqual(short?.result, { html: "stop" });
assert.equal(short?.plugin.id, "b"); // the owning plugin (so a `view` result resolves correctly)
assert.deepEqual(calls, ["a", "b"]);
// No hook short-circuits → null (proceed with normal routing).
assert.equal(await runRequestHooks([plugin("a", { onRequest: () => {} })], ctx), null);
});
test("runResponseHooks runs every onResponse as an observer with the result; a throw fails", async () => {
const seen: unknown[] = [];
await runResponseHooks([
plugin("a", { onResponse: (_c, r) => void seen.push(r) }),
plugin("b", {}), // no onResponse → skipped
], ctx, { html: "ok" });
assert.deepEqual(seen, [{ html: "ok" }]);
await assert.rejects(runResponseHooks([plugin("x", { onResponse: () => { throw new Error("boom"); } })], ctx, null), /boom/);
});

29
src/hooks.ts Normal file
View File

@@ -0,0 +1,29 @@
// Plugin lifecycle hooks (todo §2): the host invokes the optional PluginHooks a plugin may declare
// (docs/plugin-contract.md → Hooks). No sandbox — a throwing hook fails loud (boot for onBoot, the
// request for the others). Hooks run in discovery order (plugins sorted by id). app.ts skips these
// entirely when no plugin declares the hook, so the no-hooks hot path stays free.
import type { RequestContext } from "./context.ts";
import type { Plugin, RouteResult } from "./plugin.ts";
// After discovery, before the server listens. A throw aborts boot.
export async function runBootHooks(plugins: Plugin[]): Promise<void> {
for (const plugin of plugins) await plugin.hooks?.onBoot?.();
}
// Before route matching. The first hook to return a RouteResult short-circuits the request — its
// result becomes the response and later hooks + the route handler are skipped. Returns that result
// with its owning plugin (so a `view` result resolves against that plugin's views), or null to proceed.
export async function runRequestHooks(plugins: Plugin[], ctx: RequestContext): Promise<{ plugin: Plugin; result: RouteResult } | null> {
for (const plugin of plugins) {
const result = await plugin.hooks?.onRequest?.(ctx);
if (result != null) return { plugin, result };
}
return null;
}
// After a route handler produces its result. Observers only — the return value is ignored, so a
// hook cannot change the response; a throw fails the request.
export async function runResponseHooks(plugins: Plugin[], ctx: RequestContext, result: RouteResult | null): Promise<void> {
for (const plugin of plugins) await plugin.hooks?.onResponse?.(ctx, result);
}

View File

@@ -1,6 +1,7 @@
import { createApp } from "./app.ts";
import { loadConfig } from "./config.ts";
import { discoverPlugins } from "./discovery.ts";
import { runBootHooks } from "./hooks.ts";
import { loadMenuConfig } from "./menu-config.ts";
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
@@ -8,6 +9,7 @@ const menu = await loadMenuConfig(); // config/menu.ts override + branding — f
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
const server = createApp({ cache: config.cacheTemplates, menu, plugins }).listen(config.port, () => {
console.log(`Listening on http://localhost:${config.port}`);