Keep the chrome lazy for error pages, guard the guard-error render, split the recovery link
CI / full-gate (push) Successful in 2m38s
CI / full-gate (push) Successful in 2m38s
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
Reference in New Issue
Block a user