Address whole-project review (todo §2); wire plugin hooks (onBoot/onRequest/onResponse), document template trust boundary, tidy discovery
This commit is contained in:
@@ -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);
|
||||
|
||||
28
src/app.ts
28
src/app.ts
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 a–z, digits, dashes)`);
|
||||
|
||||
50
src/hooks.test.ts
Normal file
50
src/hooks.test.ts
Normal 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
29
src/hooks.ts
Normal 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);
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user