Keep the chrome lazy for error pages, guard the guard-error render, split the recovery link
CI / full-gate (push) Successful in 2m38s

This commit is contained in:
2026-08-04 00:31:19 +02:00
parent 93139ea058
commit 18e1a8d29d
11 changed files with 58 additions and 13 deletions
+17
View File
@@ -23,6 +23,7 @@ import type { Plugin } from "../plugin-host/plugin.ts";
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
import adminManifest from "../../examples/plugins/admin/plugin.ts";
import { createI18n } from "../i18n/runtime.ts";
import type { MenuConfig } from "../ui/menu-config.ts";
import { loadI18n } from "../i18n/load.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
@@ -1486,3 +1487,19 @@ test("a plugin that owns a landing page, or short-circuits a hook, translates fr
assert.equal(await (await fetch(`${url}/hooked`)).text(), "Hello from the plugin");
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
});
test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => {
// The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a
// broken menu takes the error pages down with it.
let built = 0;
const menu: MenuConfig = { branding: { get name() { built++; return "Plainpages"; } }, override: {} };
const app = createApp({ jwks: staticJwks([ecJwk]), menu });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const res = await fetch(`${url}/no-such-page`);
assert.equal(res.status, 404);
assert.match(await res.text(), /Page not found/);
assert.equal(built, 0);
});
+22 -3
View File
@@ -120,7 +120,17 @@ export function createApp(options: AppOptions = {}): Server {
// it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
// happens to use one loses that key rather than breaking the shell that renders around it.
const localsOf = (ctx: RequestContext): I18nRequest => ({ ...ctx, method: ctx.req.method ?? "GET" });
// Named field by field on purpose: spreading the context would trigger its lazy `chrome` getter,
// composing the menu for every render — including the standalone error pages, which exist to
// render when the shell's own data is what failed.
const localsOf = (ctx: RequestContext): I18nRequest => ({
locale: ctx.locale,
localeHref: ctx.localeHref,
locales: ctx.locales,
method: ctx.req.method ?? "GET",
t: ctx.t,
url: ctx.url,
});
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
@@ -307,7 +317,9 @@ export function createApp(options: AppOptions = {}): Server {
}
csrfMint.setCookie();
const result = (await match.route.handler(routeCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result); // observers; a throw → 500
// The responding plugin observes its own route, params and all; the others get a plain
// context for their own id (never another plugin's params).
if (anyResponseHooks) await runResponseHooks(plugins, (id) => (id === match.plugin.id ? routeCtx : contextFor(id)), result);
await sendResult(res, result, pluginViewsFor(routeCtx, match.plugin.id), carryLocale);
return;
}
@@ -334,7 +346,14 @@ export function createApp(options: AppOptions = {}): Server {
if (err instanceof GuardError) {
if (res.headersSent) return void res.end();
if (err.location) return void res.writeHead(303, { location: err.location }).end();
return void sendHtml(res, err.status, await renderPage("403", {}));
try {
return void sendHtml(res, err.status, await renderPage("403", {}));
} catch (renderErr) {
// Same last resort as the 500 branch below: a throw here would leave the socket open
// (this catch is the one that would have handled it), so end the response ourselves.
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
return void res.writeHead(err.status, { "content-type": "text/plain; charset=utf-8" }).end("Forbidden");
}
}
reqLog.error("unhandled request error", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
if (res.headersSent) return void res.end(); // a partial body is already on the wire
+1 -1
View File
@@ -141,7 +141,7 @@ const messages = {
"pagination.go": "Go",
"pagination.label": "Pagination",
"pagination.next": "Next page",
"pagination.of": "of",
"pagination.summary": "{{from}}{{to}} of <b>{{total}}</b>",
"pagination.previous": "Previous page",
"pagination.rows": "Rows",
+1 -1
View File
@@ -127,7 +127,7 @@ const messages: CoreMessages = {
"pagination.go": "Visa",
"pagination.label": "Sidnavigering",
"pagination.next": "Nästa sida",
"pagination.of": "av",
"pagination.summary": "{{from}}{{to}} av <b>{{total}}</b>",
"pagination.previous": "Föregående sida",
"pagination.rows": "Rader",