Skip to content

Commit fc2fce3

Browse files
committed
fix: close three gaps opened by shipping the boundary boot set
Making the 403 / 401 / 404 boundaries render their layout chain also made their modules ship, and three things that were harmless while they never shipped are not harmless now. webjs check's always-ship set listed errors, loadings and the not-found pages but not forbidden, unauthorized or global-not-found. Those reach the browser now, so a forbidden.ts importing a server-only module is the exact throw-at-load crash the rule exists to catch, and it passed a green check. instrumentation-client was missing from every boundary boot set, so the error and 403 pages were the only pages running app modules without the app's client error reporting installed, which is where it matters most. A layout throwing while wrapping a 403 / 401 / 404 was swallowed by a bare catch. Those paths execute layout modules for the first time, so a real crash degraded to a chrome-less page with nothing in dev saying why and nothing reaching the APM sink. It reports through the same sinks a page render error does, then degrades as before.
1 parent f9b062c commit fc2fce3

4 files changed

Lines changed: 174 additions & 4 deletions

File tree

packages/server/src/check/runner-support.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,30 @@ export async function checkServerImportInBrowserModule(appDir, violations) {
159159
for (const page of routeTable.pages || []) {
160160
for (const f of page.errors || []) alwaysShipRouteModules.set(f, 'error boundary');
161161
for (const f of page.loadings || []) alwaysShipRouteModules.set(f, 'loading boundary');
162+
// forbidden / unauthorized joined this set with #1298. They used to render
163+
// with an empty boot set, so their modules never reached the browser and
164+
// omitting them here was harmless. They now render inside their layout
165+
// chain AND ship, so a `forbidden.ts` doing `import { auth } from
166+
// '#lib/auth.server.ts'` (a natural thing on a 403: "signed in as X, but
167+
// not permitted") is a real throw-at-load crash that takes every sibling
168+
// registration in the same module script with it.
169+
for (const f of page.forbiddens || []) alwaysShipRouteModules.set(f, 'forbidden boundary');
170+
for (const f of page.unauthorizeds || []) alwaysShipRouteModules.set(f, 'unauthorized boundary');
162171
}
163172
if (routeTable.notFound) alwaysShipRouteModules.set(routeTable.notFound, 'not-found page');
164173
if (routeTable.notFounds) {
165174
for (const f of routeTable.notFounds.values()) {
166175
alwaysShipRouteModules.set(f, 'not-found page');
167176
}
168177
}
178+
// global-not-found ships for the same reason: a thrown notFound() with no
179+
// nearer boundary renders it against the matched route, so it wraps and
180+
// boots. global-ERROR is deliberately absent: it returns its own document
181+
// verbatim with no importmap and no boot script, so nothing of it ever
182+
// reaches the browser.
183+
if (routeTable.globalNotFound) {
184+
alwaysShipRouteModules.set(routeTable.globalNotFound, 'global-not-found page');
185+
}
169186

170187
// The elision flag mirrors `dev.js`: respect `webjs.elide === false` and the
171188
// WEBJS_ELIDE override. When elision is OFF, the build ships EVERY component

packages/server/src/ssr/render.js

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,25 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
414414
return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: chain.reduced };
415415
}
416416

417+
/**
418+
* Report a throw from a layout wrapped around a 403 / 401 / 404 boundary
419+
* (#1298) to the same sinks a page-render error reaches, then let the caller
420+
* degrade to the standalone render. Best-effort on both sinks: a throwing sink
421+
* must never affect the response.
422+
*
423+
* @param {unknown} err
424+
* @param {{ onError?: (e: unknown) => void, onDevError?: (e: unknown) => void }} opts
425+
*/
426+
function reportBoundaryLayoutError(err, opts) {
427+
if (typeof opts.onError === 'function') {
428+
try { opts.onError(err); } catch { /* a throwing sink must not affect the response */ }
429+
}
430+
if (typeof opts.onDevError === 'function') {
431+
try { opts.onDevError(err); } catch { /* a throwing sink must not affect the response */ }
432+
}
433+
console.error('[webjs] a layout threw while wrapping a boundary page:', err);
434+
}
435+
417436
/**
418437
* The boot module set for a boundary response: what actually RENDERED, the
419438
* boundary module plus the layouts wrapping it (#1298).
@@ -434,7 +453,8 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
434453
* @param {string} boundaryFile
435454
* @param {string[]} wrapLayouts
436455
* @param {{ appDir: string, inertRouteModules?: Set<string>,
437-
* importOnlyRouteModules?: Map<string, string[]> }} opts
456+
* importOnlyRouteModules?: Map<string, string[]>,
457+
* instrumentationClient?: string }} opts
438458
* @returns {string[]}
439459
*/
440460
function boundaryModuleUrls(boundaryFile, wrapLayouts, opts) {
@@ -451,6 +471,18 @@ function boundaryModuleUrls(boundaryFile, wrapLayouts, opts) {
451471
if (emit) emit.forEach(push);
452472
else push(f);
453473
}
474+
// instrumentation-client.{js,ts} (#848) rides the SAME first-import contract
475+
// it has on the happy path: it runs before app modules so the app's client
476+
// error reporting is installed before anything can throw. A boundary page is
477+
// where that matters most, so it must not be the one place it is missing.
478+
// Only when the set is non-empty: an empty set means no boot script at all,
479+
// and instrumentation alone is not a reason to start emitting one.
480+
if (opts.instrumentationClient && urls.length) {
481+
const u = toUrlPath(opts.instrumentationClient, opts.appDir);
482+
const i = urls.indexOf(u);
483+
if (i !== -1) urls.splice(i, 1);
484+
urls.unshift(u);
485+
}
454486
return urls;
455487
}
456488

@@ -512,9 +544,14 @@ async function ssrBoundaryHtml(file, heading, opts) {
512544
} else {
513545
body = await renderToString(tree, { ssr: true, dev: opts.dev });
514546
}
515-
} catch {
547+
} catch (layoutErr) {
516548
// A wrapped layout threw. Degrade to the standalone render this has
517-
// always produced, and to its empty boot set with it.
549+
// always produced, and to its empty boot set with it. REPORT it
550+
// first: these paths execute layout modules for the first time since
551+
// #1298, so a genuine layout crash would otherwise vanish, leaving a
552+
// developer looking at a chrome-less boundary page with nothing
553+
// saying why, and an APM sink that never heard about it.
554+
reportBoundaryLayoutError(layoutErr, opts);
518555
body = await renderToString(tree, { ssr: true, dev: opts.dev });
519556
moduleUrls = [];
520557
}
@@ -564,7 +601,9 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
564601
} else {
565602
body = await renderToString(tree, { ssr: true, dev: opts.dev });
566603
}
567-
} catch {
604+
} catch (layoutErr) {
605+
// Same degradation, and the same reporting, as ssrBoundaryHtml above.
606+
reportBoundaryLayoutError(layoutErr, opts);
568607
body = await renderToString(tree, { ssr: true, dev: opts.dev });
569608
moduleUrls = [];
570609
}

packages/server/test/check/no-server-import-in-browser-module.test.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,76 @@ export default async function NotFound() {
378378
}
379379
});
380380

381+
// forbidden.ts / unauthorized.ts / global-not-found.ts joined the shipping set
382+
// with #1298: they render inside their layout chain now, and ship with it. The
383+
// rule has to see them, or the exact #963 crash it exists to catch (a
384+
// throw-at-load stub taking every sibling registration with it) passes a green
385+
// `webjs check`. A 403 that greets the signed-in user by name is the natural
386+
// shape, so this is not a hypothetical import.
387+
test('a forbidden boundary importing a server module IS flagged (#1298)', async () => {
388+
const appDir = await makeApp({
389+
'lib/auth.server.ts': AUTH_SERVER,
390+
'app/admin/page.ts': `export default function Admin() { return '<h1>admin</h1>'; }\n`,
391+
'app/admin/forbidden.ts': `import { auth } from '../../lib/auth.server.ts';
392+
export default async function Forbidden() {
393+
const session = await auth();
394+
return \`<h1>No access for \${session.user ?? 'guest'}</h1>\`;
395+
}
396+
`,
397+
});
398+
try {
399+
const violations = await checkConventions(appDir);
400+
const hits = find(violations, 'forbidden.ts');
401+
assert.equal(hits.length, 1, 'a forbidden boundary that ships and imports a server module must be flagged');
402+
assert.ok(hits[0].message.includes('auth.server.ts'), 'names the offending server import');
403+
assert.ok(/forbidden boundary/.test(hits[0].message), 'identifies it as a forbidden boundary');
404+
} finally {
405+
await rm(appDir, { recursive: true, force: true });
406+
}
407+
});
408+
409+
test('an unauthorized boundary importing a server module IS flagged (#1298)', async () => {
410+
const appDir = await makeApp({
411+
'lib/auth.server.ts': AUTH_SERVER,
412+
'app/private/page.ts': `export default function P() { return '<h1>private</h1>'; }\n`,
413+
'app/private/unauthorized.ts': `import { auth } from '../../lib/auth.server.ts';
414+
export default async function Unauthorized() {
415+
const session = await auth();
416+
return \`<h1>Sign in (\${session.user ?? 'guest'})</h1>\`;
417+
}
418+
`,
419+
});
420+
try {
421+
const violations = await checkConventions(appDir);
422+
const hits = find(violations, 'unauthorized.ts');
423+
assert.equal(hits.length, 1, 'an unauthorized boundary that ships and imports a server module must be flagged');
424+
assert.ok(/unauthorized boundary/.test(hits[0].message), 'identifies it as an unauthorized boundary');
425+
} finally {
426+
await rm(appDir, { recursive: true, force: true });
427+
}
428+
});
429+
430+
test('a global-not-found page importing a server module IS flagged (#1298)', async () => {
431+
const appDir = await makeApp({
432+
'lib/auth.server.ts': AUTH_SERVER,
433+
'app/page.ts': `export default function Home() { return '<h1>home</h1>'; }\n`,
434+
'app/global-not-found.ts': `import { auth } from '../lib/auth.server.ts';
435+
export default async function GlobalNotFound() {
436+
const session = await auth();
437+
return \`<h1>404 for \${session.user ?? 'guest'}</h1>\`;
438+
}
439+
`,
440+
});
441+
try {
442+
const violations = await checkConventions(appDir);
443+
const hits = find(violations, 'global-not-found.ts');
444+
assert.equal(hits.length, 1, 'a global-not-found page that ships and imports a server module must be flagged');
445+
assert.ok(/global-not-found page/.test(hits[0].message), 'identifies it as a global-not-found page');
446+
} finally {
447+
await rm(appDir, { recursive: true, force: true });
448+
}
449+
});
450+
381451
test('a loading boundary importing a server module IS flagged', async () => {
382452
const appDir = await makeApp({
383453
'lib/auth.server.ts': AUTH_SERVER,

test/ssr/ssr.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2083,6 +2083,50 @@ test('boundary: a 404 for a URL that matched no route stays a bare document', as
20832083
assert.ok(!body.includes('<script type="module">'), 'and no boot script, since no chain rendered');
20842084
});
20852085

2086+
test('boundary: instrumentation-client boots FIRST on a boundary page too (#848)', async () => {
2087+
// The boundary page is where the app's client error reporting matters most,
2088+
// so it must not be the one page that runs app modules without it.
2089+
const { route, appDir } = crashApp({
2090+
files: { 'instrumentation-client.js': `console.log('boot');\n` },
2091+
});
2092+
const resp = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/docs/crash'), {
2093+
dev: false, appDir, instrumentationClient: join(appDir, 'instrumentation-client.js'),
2094+
}));
2095+
const body = await resp.text();
2096+
const boot = body.match(/<script type="module">([\s\S]*?)<\/script>/);
2097+
assert.ok(boot, 'the boundary page has a boot script');
2098+
const imports = [...boot[1].matchAll(/import\s+"([^"]+)"/g)].map((m) => m[1]);
2099+
assert.equal(imports[0], '/instrumentation-client.js', 'and instrumentation is its FIRST import');
2100+
assert.ok(imports.includes('/docs/error.js'), 'the boundary module still boots');
2101+
});
2102+
2103+
test('boundary: a layout that throws while wrapping a 403 is REPORTED, not swallowed', async () => {
2104+
// These paths execute layout modules for the first time since #1298, so a
2105+
// genuine layout crash would otherwise vanish: a chrome-less boundary page
2106+
// with nothing saying why, and an APM sink that never heard about it.
2107+
const { route, appDir } = makeBoundaryApp({
2108+
files: {
2109+
'layout.js': `export default function Root() { throw new Error('layout-boom-403'); }\n`,
2110+
'admin/forbidden.js': HTML_IMPORT + `export default function F() { return html\`<p id="fb">no</p>\`; }\n`,
2111+
'admin/page.js': `import { forbidden } from ${JSON.stringify(WEBJS_MODULE_URL)};\nexport default function Page() { forbidden(); }\n`,
2112+
},
2113+
page: 'admin/page.js',
2114+
layouts: ['layout.js'],
2115+
forbiddens: ['admin/forbidden.js'],
2116+
});
2117+
const seen = [];
2118+
const resp = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/admin'), {
2119+
dev: false, appDir, onError: (e) => seen.push(e),
2120+
}));
2121+
assert.equal(resp.status, 403, 'it still degrades to the standalone 403 rather than failing');
2122+
const body = await resp.text();
2123+
assert.ok(body.includes('id="fb"'), 'the boundary itself still rendered');
2124+
assert.equal(markersOf(body).length, 0, 'with no markers, since the chain could not render');
2125+
assert.ok(!body.includes('<script type="module">'), 'and no boot set for a chain that did not render');
2126+
assert.equal(seen.length, 1, 'the layout throw reached the onError sink');
2127+
assert.match(String(seen[0].message), /layout-boom-403/);
2128+
});
2129+
20862130
test('boundary: a boundary response is never storable and never reduced', async () => {
20872131
// Two independent guarantees. The HTML cache refuses a non-200 outright, and
20882132
// the reduced X-Webjs-Have path is structurally unreachable: the boundary

0 commit comments

Comments
 (0)