@webjsdev/server 0.8.58 → 0.8.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/actions.js CHANGED
@@ -20,6 +20,7 @@ import { ifNoneMatchSatisfied } from './conditional-get.js';
20
20
  import { getBodyLimits } from './context.js';
21
21
  import { basePath } from './importmap.js';
22
22
  import { withBasePath } from './base-path.js';
23
+ import { FORM_ACTION_ID_KEY } from '@webjsdev/core';
23
24
 
24
25
  /**
25
26
  * The JSON / RPC body cap in effect for the current request: the per-request
@@ -417,11 +418,18 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
417
418
  lines.push(` return __body(fn, key, __METHOD, sig);`);
418
419
  lines.push(`}`);
419
420
  }
421
+ // Each stub carries its own `<hash>/<fn>` identity (#1155), so a client
422
+ // re-render of `<form action=${action}>` can bind the live form
423
+ // synchronously. The server resolves the REAL function's identity through the
424
+ // load-hook registry instead; both produce the same string, which is what
425
+ // makes the SSR'd form and the re-rendered one submit the same value.
426
+ // Non-enumerable so `import * as actions` and a spread stay unchanged.
427
+ lines.push(`const __id = (fn, name) => { try { Object.defineProperty(fn, ${J(FORM_ACTION_ID_KEY)}, { value: __HASH + '/' + name }); } catch {} return fn; };`);
420
428
  for (const name of fnNames) {
421
429
  lines.push(
422
430
  name === 'default'
423
- ? `export default (...args) => __call('default', args);`
424
- : `export const ${name} = (...args) => __call(${J(name)}, args);`,
431
+ ? `export default __id((...args) => __call('default', args), 'default');`
432
+ : `export const ${name} = __id((...args) => __call(${J(name)}, args), ${J(name)});`,
425
433
  );
426
434
  }
427
435
  return lines.join('\n') + '\n';
package/src/base-path.js CHANGED
@@ -11,7 +11,10 @@
11
11
  * hydrates. `createRequestHandler` explicitly targets embedding, where a
12
12
  * sub-path mount is the norm.
13
13
  *
14
- * The model is strip-at-ingress + prefix-on-emit, two seams only:
14
+ * The model is strip-at-ingress + prefix-on-emit. Two seams carry it, plus two
15
+ * further `stripBasePath` consumers on the CACHE path (`dev.js`'s HTML-cache
16
+ * write and `html-cache.js`'s `revalidatePath`), which key on the
17
+ * app-root-relative path so a mounted app hits its own cache:
15
18
  *
16
19
  * 1. STRIP AT INGRESS. At the very start of request handling, when the
17
20
  * request pathname starts with the basePath, strip it so all
package/src/body-limit.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * webjs's prod server reads request bodies on three paths: the server-action
5
5
  * RPC endpoint (actions.js), `route.{js,ts}` handlers via `readBody` (json.js),
6
- * and the no-JS page-action form path (page-action.js). Without a cap, an
6
+ * and the no-JS form-submission path (form-dispatch.js). Without a cap, an
7
7
  * uncapped body is a memory-exhaustion vector. This module is the SINGLE place
8
8
  * that decides the limit and performs a bounded read, so every body-read site
9
9
  * enforces it uniformly.
@@ -31,7 +31,7 @@
31
31
  export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
32
32
 
33
33
  /**
34
- * Default form / multipart body cap: 10 MiB. A form submission (the page-action
34
+ * Default form / multipart body cap: 10 MiB. A form submission (the form-dispatch
35
35
  * path) may legitimately carry more than a JSON RPC call (a textarea, a small
36
36
  * upload), so it gets a separate, higher, still-bounded limit. Large file
37
37
  * uploads are a distinct concern (#247) with their own streaming story.
@@ -40,7 +40,7 @@ export const DEFAULT_MAX_MULTIPART_BYTES = 10 * 1024 * 1024;
40
40
 
41
41
  /**
42
42
  * Thrown by `readBody` (json.js) when a route handler's body exceeds the limit.
43
- * The RPC and page-action paths return a 413 Response inline, but `readBody`
43
+ * The RPC and form-dispatch paths return a 413 Response inline, but `readBody`
44
44
  * runs INSIDE a user route handler and returns parsed data, so it signals the
45
45
  * over-limit case by throwing. The API dispatcher (`handleApi`) catches this and
46
46
  * maps it to a 413, so a handler that just does `await readBody(req)` gets the
@@ -266,7 +266,7 @@ export async function readTextBounded(req, limit) {
266
266
  }
267
267
 
268
268
  /**
269
- * Read a request body as `FormData`, bounded by `limit`. Used by the page-action
269
+ * Read a request body as `FormData`, bounded by `limit`. Used by the form-dispatch
270
270
  * form path. Reconstructs a bounded Request from the already-read bytes and
271
271
  * defers to the platform `formData()` parser, so multipart and
272
272
  * urlencoded bodies are decoded exactly as before, just size-checked first.
package/src/check.js CHANGED
@@ -3,6 +3,7 @@ import { join, relative, sep, basename, dirname } from 'node:path';
3
3
  import { walk } from './fs-walk.js';
4
4
  import {
5
5
  redactStringsAndTemplates,
6
+ redactToPlaceholders,
6
7
  extractWebComponentClassBodies,
7
8
  matchClosingBrace,
8
9
  parsePropEntries,
@@ -134,6 +135,11 @@ export const RULES = [
134
135
  description:
135
136
  'A `\'use server\'` action file that declares HTTP-verb config (any of `method` / `cache` / `tags` / `invalidates` / `validate`, #488) must export exactly ONE callable action function. The config is file-level (it applies to the action in the file), so a second exported function would silently inherit the same verb / cache, which is almost never intended and makes the contract ambiguous. Move the extra function to its own `.server.ts` file (the one-function-per-file convention), or, if it is a private helper, do not export it. Files with no verb config are unaffected.',
136
137
  },
138
+ {
139
+ name: 'form-action-not-a-get-action',
140
+ description:
141
+ 'Flags `<form action=${someAction}>` bound to a `\'use server\'` action whose file declares `export const method = \'GET\'` (#488). A GET action is a READ: it is CSRF-exempt and rides its arguments in the url, while a form submission is a CSRF-checked POST carrying a body, so the two contracts contradict each other and the submission is answered with a 405 at runtime. The rule reads the imported action\'s own file, so it only fires when the binding really does resolve to a GET-declared action. Fix by dropping the `method` export (an action with no `method` is a POST, which is what a form wants) or by binding a different action; if the form really is a read, use a plain `<form method="get" action="/search">` with no bound action.',
142
+ },
137
143
  {
138
144
  name: 'no-redirect-in-api-route',
139
145
  description:
@@ -475,6 +481,36 @@ function enumerableExports(scan) {
475
481
  * @param {string} clause the text between `import` and `from`
476
482
  * @returns {string[]|null}
477
483
  */
484
+ /**
485
+ * The `{ local, imported }` pairs a named import clause binds, for a rule that
486
+ * has to match the LOCAL name against code (`action=${reader}`) while reporting
487
+ * the IMPORTED one (`readIt`, which is what the target module calls it).
488
+ *
489
+ * `importedValueNames` cannot serve here: it returns only the imported side,
490
+ * which is the wrong name to match against a template hole under `as`.
491
+ *
492
+ * @param {string} clause the text between `import` and `from`
493
+ * @returns {{ local: string, imported: string }[]}
494
+ */
495
+ function importedLocalNames(clause) {
496
+ if (/^\s*type\b/.test(clause)) return [];
497
+ const brace = clause.match(/\{([^}]*)\}/);
498
+ if (!brace) return [];
499
+ /** @type {{ local: string, imported: string }[]} */
500
+ const out = [];
501
+ for (const part of brace[1].split(',')) {
502
+ const seg = part.trim().replace(/^type\s+/, '');
503
+ if (!seg) continue;
504
+ const as = seg.split(/\s+as\s+/);
505
+ const imported = as[0].trim();
506
+ const local = (as[1] || as[0]).trim();
507
+ if (/^[A-Za-z_$][\w$]*$/.test(local) && /^[A-Za-z_$][\w$]*$/.test(imported)) {
508
+ out.push({ local, imported });
509
+ }
510
+ }
511
+ return out;
512
+ }
513
+
478
514
  function importedValueNames(clause) {
479
515
  if (/^\s*type\b/.test(clause)) return null; // import type { ... }
480
516
  const brace = clause.match(/\{([^}]*)\}/);
@@ -489,8 +525,9 @@ function importedValueNames(clause) {
489
525
  /**
490
526
  * Scan a WebJs app directory and report convention violations.
491
527
  *
492
- * Every rule is a correctness check (a crash, a security leak, or a
493
- * build/type-strip failure), so they all run unconditionally. There is no
528
+ * Every rule is a correctness check (a crash, a security leak, a reactive
529
+ * prop that silently stops re-rendering, or a build/type-strip failure), so
530
+ * they all run unconditionally. There is no
494
531
  * per-project disabling: project conventions (layout, style, process) live in
495
532
  * CONVENTIONS.md as guidance, not in this tool.
496
533
  *
@@ -1333,6 +1370,90 @@ export async function checkConventions(appDir) {
1333
1370
  }
1334
1371
  }
1335
1372
 
1373
+ // --- Rule: form-action-not-a-get-action (#1155) ---
1374
+ // `<form action=${fn}>` binds a server action to a POST submission. An action
1375
+ // declaring `method = 'GET'` cannot serve one: it is CSRF-exempt and takes its
1376
+ // args in the url, so the dispatcher answers 405. Catch it at edit time.
1377
+ {
1378
+ // A `'use server'` file's declared method, or absent when it declares none.
1379
+ const methodByAbs = new Map();
1380
+ for (const f of files) {
1381
+ if (!/\.server\.m?[jt]s$/.test(f.rel)) continue;
1382
+ if (!hasUseServerDirective(f.content)) continue;
1383
+ const m = /\bexport\s+const\s+method\s*(?::[^=]*)?=\s*['"`]([A-Za-z]+)['"`]/.exec(f.scan);
1384
+ if (m) methodByAbs.set(f.abs, m[1].toUpperCase());
1385
+ }
1386
+ if (methodByAbs.size) {
1387
+ const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
1388
+ for (const { abs, rel, content } of files) {
1389
+ // PLACEHOLDER redaction, not either mask, and the choice is forced.
1390
+ // The default mask blanks a tagged template outright, so a real
1391
+ // `html`<form action=${fn}>`` disappears and the rule could never fire;
1392
+ // the fully-blanked mask does the same. Placeholder mode keeps `${...}`
1393
+ // holes as REAL code while turning each literal BODY into one opaque
1394
+ // `__STR_n__` token, so a live binding is readable and a `<form
1395
+ // action=${x}>` shown as text inside a docs sample is a single token
1396
+ // with no hole in it. That distinction is the whole carve-out: the
1397
+ // framework's own website renders this exact shape as a code sample.
1398
+ const { redacted, literals } = redactToPlaceholders(content);
1399
+ // A hole binds an action when the literal segment IMMEDIATELY before it
1400
+ // ends inside a start tag at the attribute that tag can really bind:
1401
+ // `action=` on a `<form>` (#1155), or `formaction=` on a `<button>` /
1402
+ // `<input>` submitter (#1207). Reading the segment rather than the whole
1403
+ // template is what keeps `<div action=${x}>` out. The tag and the
1404
+ // attribute are matched as a PAIR, so `<form formaction=${x}>` and
1405
+ // `<button action=${x}>` stay out too: both are refused at render time,
1406
+ // and neither is this rule's failure mode.
1407
+ const bound = new Set();
1408
+ for (const m of redacted.matchAll(/__STR_(\d+)__\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) {
1409
+ const before = literals[Number(m[1])] || '';
1410
+ // The last `<` opens the tag the hole sits in. Requiring no `>`
1411
+ // after it keeps the match inside that one start tag.
1412
+ const tagAt = before.lastIndexOf('<');
1413
+ if (tagAt < 0) continue;
1414
+ const tag = before.slice(tagAt);
1415
+ if (tag.includes('>')) continue;
1416
+ const bindsForm = /^<form\b/i.test(tag) && /\saction=$/i.test(tag);
1417
+ const bindsSubmitter = /^<(?:button|input)\b/i.test(tag) && /\sformaction=$/i.test(tag);
1418
+ if (!bindsForm && !bindsSubmitter) continue;
1419
+ bound.add(m[2]);
1420
+ }
1421
+ if (!bound.size) continue;
1422
+ reImport.lastIndex = 0;
1423
+ let im;
1424
+ while ((im = reImport.exec(redacted))) {
1425
+ // The specifier is a placeholder token in this view, so read the real
1426
+ // one back out of `literals`.
1427
+ const tok = /__STR_(\d+)__/.exec(redacted.slice(reImport.lastIndex, reImport.lastIndex + 24));
1428
+ if (!tok) continue;
1429
+ const spec = literals[Number(tok[1])] || '';
1430
+ if (!/^(?:\.|#)/.test(spec)) continue;
1431
+ const names = importedLocalNames(im[1]);
1432
+ if (!names.length) continue;
1433
+ const target = resolveImport(spec, abs, appDir);
1434
+ // ONLY a GET is contradictory, and the rule must say exactly what the
1435
+ // dispatcher does or it is a false positive. A browser form always
1436
+ // submits as a POST, and the dispatcher runs a PUT / PATCH / DELETE
1437
+ // action on that POST: the declared verb governs the RPC transport,
1438
+ // not whether the function can serve a form. A GET is different in
1439
+ // kind (CSRF-exempt, args in the url, no body), which is why it is
1440
+ // the one the dispatcher answers with a 405.
1441
+ const method = target ? methodByAbs.get(target) : undefined;
1442
+ if (method !== 'GET') continue;
1443
+ for (const { local, imported } of names) {
1444
+ if (!bound.has(local)) continue;
1445
+ violations.push({
1446
+ rule: 'form-action-not-a-get-action',
1447
+ file: rel,
1448
+ message: `Binds \`${imported}\` to a form, but \`${spec}\` declares \`method = 'GET'\`. A GET action rides its arguments in the url and skips the CSRF check, so it cannot answer a form POST; the submission is a 405.`,
1449
+ fix: `Drop the \`method\` export from \`${spec}\` (an action with no method is a POST, which is what a form submits), or bind an action that takes a POST.`,
1450
+ });
1451
+ }
1452
+ }
1453
+ }
1454
+ }
1455
+ }
1456
+
1336
1457
  return violations;
1337
1458
  }
1338
1459
 
package/src/context.js CHANGED
@@ -17,7 +17,7 @@ import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
17
17
  *
18
18
  * `bodyLimits` holds the resolved request body-size caps (issue #237) so
19
19
  * `readBody` (used inside `route.{js,ts}` handlers, which have no handle to the
20
- * server state) can enforce the same limit the RPC and page-action paths do. The
20
+ * server state) can enforce the same limit the RPC and form-dispatch paths do. The
21
21
  * handler writes it per request via `setBodyLimits`.
22
22
  *
23
23
  * `requestId` holds the per-request correlation id (issue #239). The handler
package/src/csp.js CHANGED
@@ -50,8 +50,9 @@ const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/;
50
50
  * with webjs's own output, which is: nonce-signed inline `<script>` tags
51
51
  * (the boot script, the public-env shim, the importmap), nonce-signed
52
52
  * `<link rel="modulepreload">`, ES modules fetched same-origin and from
53
- * the configured vendor CDN, and Tailwind's runtime which injects a
54
- * `<style>` element (so inline styles must be allowed).
53
+ * the configured vendor CDN, and one inline `<style>` in the head. The
54
+ * nonce covers scripts only, so that rule and every app-authored style
55
+ * alike are admitted by `'unsafe-inline'` (see the `style-src` note).
55
56
  *
56
57
  * `script-src` uses `'strict-dynamic'` so a nonce-loaded module can pull
57
58
  * in its own dependencies (the importmap-driven per-file ESM graph)
@@ -67,10 +68,25 @@ const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/;
67
68
  const DEFAULT_DIRECTIVES = {
68
69
  'default-src': "'self'",
69
70
  'script-src': "'nonce-__NONCE__' 'strict-dynamic' 'self' https:",
70
- // Tailwind's browser runtime injects a <style> element, and webjs
71
- // emits an inline <style> for adopted component styles, so inline
72
- // styles must be permitted. Style elements are not a script-injection
73
- // vector, so this does not weaken the script protection.
71
+ // NOTE this directive carries no nonce source, unlike `script-src`, so a
72
+ // nonce attribute on a <style> is INERT here and every inline style is
73
+ // admitted by 'unsafe-inline' alone. That includes WebJs's own head rule
74
+ // (the @layer webjs-host block), which SSR does stamp with the nonce but
75
+ // which this policy never checks it against. Dropping 'unsafe-inline'
76
+ // would block that rule too, not only app style.
77
+ //
78
+ // What keeps the allowance necessary is inline style with no realistic
79
+ // nonce path, and it arrives in four shapes. A page or layout may write
80
+ // an inline <style>, which an author could nonce by hand but does not by
81
+ // default. A light-DOM component's <style> and a shadow component's
82
+ // `static styles`, serialized into its declarative shadow root, are both
83
+ // emitted by the core renderer with no nonce and no way to supply one.
84
+ // And every style="..." attribute is unfixable in principle, since a
85
+ // nonce applies to elements and never to attributes. No styling library
86
+ // is involved in any of them.
87
+ //
88
+ // Style elements are not a script-injection vector, so this does not
89
+ // weaken the script protection.
74
90
  'style-src': "'self' 'unsafe-inline'",
75
91
  'img-src': "'self' data: https:",
76
92
  'font-src': "'self' data: https:",
package/src/csrf.js CHANGED
@@ -19,7 +19,10 @@
19
19
  * 2. When `Sec-Fetch-Site` is absent (an older browser), fall back to
20
20
  * comparing the `Origin` host to the request host; an absent `Origin`
21
21
  * can't be checked so it passes (a handcrafted / non-browser request
22
- * can't carry a victim's SameSite cookies cross-site anyway).
22
+ * can't carry a victim's SameSite cookies cross-site anyway). That
23
+ * request host comes from `requestHost` below, which resolves
24
+ * `x-forwarded-host` through the shared `trustProxy()` seam in
25
+ * `forwarded.js` (#1104), so this file holds no trust posture of its own.
23
26
  *
24
27
  * Scope:
25
28
  * - Internal RPC only. A `route.ts` REST endpoint (hand-written or via the
@@ -28,6 +31,8 @@
28
31
  * - Session / auth cookies stay `SameSite=Lax` as defense-in-depth.
29
32
  */
30
33
 
34
+ import { trustProxy } from './forwarded.js';
35
+
31
36
  /**
32
37
  * Parse cookies off a standard Request. Retained as a general cookie reader
33
38
  * (used by `context.js` for `cookies()`), independent of CSRF.
@@ -56,21 +61,37 @@ function hostOf(value) {
56
61
  }
57
62
 
58
63
  /**
59
- * The host the request was addressed to. Honors `x-forwarded-host` first
60
- * (set by a reverse proxy / CDN like the Cloudflare-in-front-of-Railway
61
- * setup), then the `Host` header, then the request URL.
64
+ * The host the request was addressed to: `x-forwarded-host` when a proxy is
65
+ * trusted to speak for the client (the Cloudflare-in-front-of-Railway setup),
66
+ * then the `Host` header, then the request URL.
67
+ *
68
+ * Trust note, preserved from before this read went through `trustProxy()`
69
+ * (#1104), because it is correct and non-obvious: reading `x-forwarded-host`
70
+ * here was never a CSRF weakness. A CSRF attack is browser-driven and a
71
+ * browser cannot set `x-forwarded-host` (or `Origin`); only a direct
72
+ * non-browser client can, and such a client carries no victim SameSite
73
+ * cookies to abuse. The primary `Sec-Fetch-Site` path does not reach this
74
+ * function at all, only the legacy no-`Sec-Fetch-Site` fallback in
75
+ * `verifyOrigin` does. The value CAN still be attacker-chosen, since a proxy
76
+ * of the kind above forwards a client-supplied one rather than overwriting
77
+ * it; see the threat model in `forwarded.js` for what that does reach.
78
+ *
79
+ * What changed is the POSTURE, not a hole: the read now honors
80
+ * `WEBJS_NO_TRUST_PROXY=1` like every other forwarded-header read, so an
81
+ * operator who sets the flag because their container is directly reachable
82
+ * gets one answer from the whole package instead of two. Behind a proxy
83
+ * WITHOUT the flag nothing changes. With the flag set on a genuinely proxied
84
+ * deploy (a misconfiguration) the fallback compares `Origin` against the raw
85
+ * `Host`, so a legitimate cross-host request can now be rejected. That is the
86
+ * flag's intended meaning.
62
87
  *
63
- * Trust note: `x-forwarded-host` is only meaningful behind a proxy that
64
- * overwrites it. This is NOT a CSRF weakness, because a CSRF attack is
65
- * browser-driven and a browser cannot set `x-forwarded-host` (or `Origin`);
66
- * only a direct non-browser client can, and such a client carries no victim
67
- * SameSite cookies to abuse. The primary `Sec-Fetch-Site` path does not use
68
- * this at all.
69
88
  * @param {Request} req
70
89
  */
71
90
  export function requestHost(req) {
72
- const xfh = req.headers.get('x-forwarded-host');
73
- if (xfh) return xfh.split(',')[0].trim().toLowerCase();
91
+ if (trustProxy()) {
92
+ const xfh = req.headers.get('x-forwarded-host');
93
+ if (xfh) return xfh.split(',')[0].trim().toLowerCase();
94
+ }
74
95
  const host = req.headers.get('host');
75
96
  if (host) return host.toLowerCase();
76
97
  return hostOf(req.url);
package/src/dev-error.js CHANGED
@@ -91,9 +91,19 @@ export function readCodeFrame(file, line, column, context = 3) {
91
91
  /**
92
92
  * Build a dev error frame from an error. DEV-ONLY (the caller gates on `dev`).
93
93
  *
94
+ * `opts.url` stamps the frame with the request URL that produced it (#1047).
95
+ * Only a `render` frame carries one, and the browser overlay uses it to refuse a
96
+ * frame belonging to a page the tab is not looking at (another tab's crash, a
97
+ * background render of some other url, a page navigated away from). A frame with
98
+ * no url renders unconditionally, which is what a `rebuild` / `ts-strip` frame
99
+ * wants: those describe a still-broken build, not one URL.
100
+ *
101
+ * A speculative link prefetch is NOT in that list, and cannot be: `dev.js` drops
102
+ * the report hook for one, so no frame is ever built for it to refuse.
103
+ *
94
104
  * @param {unknown} error
95
- * @param {{ kind?: 'render' | 'ts-strip' | 'rebuild', appDir?: string, file?: string, line?: number, column?: number, hint?: string }} [opts]
96
- * @returns {{ kind: string, message: string, stack: string|null, file: string|null, line: number|null, column: number|null, codeFrame: string|null, hint: string|null }}
105
+ * @param {{ kind?: 'render' | 'ts-strip' | 'rebuild', appDir?: string, file?: string, line?: number, column?: number, hint?: string, url?: string }} [opts]
106
+ * @returns {{ kind: string, message: string, stack: string|null, file: string|null, line: number|null, column: number|null, codeFrame: string|null, hint: string|null, url: string|null }}
97
107
  */
98
108
  export function buildDevErrorFrame(error, opts = {}) {
99
109
  const err = error instanceof Error ? error : new Error(String(error));
@@ -125,5 +135,6 @@ export function buildDevErrorFrame(error, opts = {}) {
125
135
  column,
126
136
  codeFrame,
127
137
  hint: opts.hint || null,
138
+ url: typeof opts.url === 'string' && opts.url ? opts.url : null,
128
139
  };
129
140
  }
@@ -9,14 +9,83 @@
9
9
  * Security: the overlay is built with `createElement` + `textContent` only,
10
10
  * NEVER `innerHTML`, so a hostile error message / file path / code frame is
11
11
  * rendered as inert text and can never inject markup or script.
12
+ *
13
+ * Scope (#1047): a `render` frame is stamped server-side with the URL that
14
+ * produced it, and this module is the single gate deciding whether a frame
15
+ * belongs on the page currently being viewed. Without it, a body-level overlay
16
+ * outlives every client-router swap (which works strictly inside the keyed
17
+ * boundary comment ranges), and any render of a page this tab is NOT looking at
18
+ * raises an overlay over the one it is, in every open tab, since the frame fans
19
+ * out over the shared SSE channel. (The other half of #1047, a link prefetch
20
+ * reporting a frame at all, is cut server-side in `dev.js`, not here.)
21
+ *
22
+ * The gate cannot be a plain refuse-and-drop, because the SSE frame is pushed
23
+ * DURING the render, before the navigation response is even sent, so it reaches
24
+ * the browser while `location` is still the old page. So there are two slots: a
25
+ * RENDERED frame and a PENDING one the gate refused. A refused frame is held,
26
+ * and the nav sync re-evaluates it once the URL advances.
27
+ *
28
+ * A held frame is only ever rendered for the navigation it actually belongs to,
29
+ * which is what `__wjNavSeq` is for. A frame that arrives while a navigation is
30
+ * IN FLIGHT is that navigation's; one that arrived while the tab sat idle is
31
+ * not, and must never paint on a later visit to that url, because by then the
32
+ * page may well render fine. An idle-time frame comes from a render this tab
33
+ * did not navigate for: another tab's page, or a background fetch of some other
34
+ * url. NOT from a link prefetch, which reports no frame at all. Both look
35
+ * identical once held, so the seq records which navigation was in flight when
36
+ * the frame landed, and the sync renders it only if that is still the one
37
+ * finishing. `webjs:before-cache` is the nav-START signal (the router snapshots
38
+ * the page it is leaving before it fetches); a navigation that does not emit one
39
+ * leaves the seq unchanged, which fails toward SHOWING the frame, the same way
40
+ * the rest of the gate fails open.
12
41
  */
13
42
 
14
43
  /** The single live overlay element, or null. */
15
44
  let __wjOverlay = null;
45
+ /** The frame the live overlay was built from, or null. */
46
+ let __wjFrame = null;
47
+ /** The most recent frame the scope gate refused, awaiting a URL that matches. */
48
+ let __wjPending = null;
49
+ /** The navigation that was in flight when `__wjPending` arrived. */
50
+ let __wjPendingSeq = -1;
51
+ /** Bumped at the start of each client-router navigation. */
52
+ let __wjNavSeq = 0;
16
53
 
17
- /** Remove the overlay if one is showing. */
18
- export function dismissDevOverlay() {
54
+ /** The path the gate compares a frame's url against. */
55
+ function __wjCurrentPath() {
56
+ return typeof location !== 'undefined' ? location.pathname + location.search : '';
57
+ }
58
+
59
+ /**
60
+ * Compare a frame url against a browser path. Encoding-tolerant on purpose: a
61
+ * mismatch fails CLOSED (no overlay), so a percent-encoding difference on a
62
+ * dynamic segment would otherwise hide a genuine error.
63
+ */
64
+ function __wjSamePath(a, b) {
65
+ if (a === b) return true;
66
+ try { return decodeURI(a) === decodeURI(b); } catch (_) { return false; }
67
+ }
68
+
69
+ /** True when `f` is a frame the scope gate applies to (a url-stamped render). */
70
+ function __wjScoped(f) {
71
+ return !!f && f.kind === 'render' && typeof f.url === 'string' && f.url !== '';
72
+ }
73
+
74
+ /** Take the overlay off the page. Leaves the pending slot alone. */
75
+ function __wjRemoveOverlay() {
19
76
  if (__wjOverlay) { __wjOverlay.remove(); __wjOverlay = null; }
77
+ __wjFrame = null;
78
+ }
79
+
80
+ /**
81
+ * Remove the overlay if one is showing, and forget any pending frame. This is
82
+ * what the Dismiss button calls, so a frame the user dismissed by hand is never
83
+ * resurrected by a later navigation.
84
+ */
85
+ export function dismissDevOverlay() {
86
+ __wjRemoveOverlay();
87
+ __wjPending = null;
88
+ __wjPendingSeq = -1;
20
89
  }
21
90
 
22
91
  /** Append a styled text row to `parent`. */
@@ -30,11 +99,27 @@ function __wjRow(parent, css, text) {
30
99
 
31
100
  /**
32
101
  * Render the dev error overlay for a frame, replacing any prior one.
33
- * @param {{ kind?: string, message?: string, file?: string|null, line?: number|null, column?: number|null, codeFrame?: string|null, hint?: string|null }} f
102
+ *
103
+ * A url-stamped `render` frame for a DIFFERENT path than `currentPath` is not
104
+ * rendered; it is held in the pending slot for `syncDevOverlayToLocation` to
105
+ * re-evaluate once the URL advances (#1047). The gate runs BEFORE any removal,
106
+ * so a refused frame never wipes what is on screen (a live `rebuild` /
107
+ * `ts-strip` overlay describes a still-broken build and must survive it) and
108
+ * never paints even briefly. A frame with no `url`, or any other kind, renders
109
+ * exactly as it always did.
110
+ *
111
+ * @param {{ kind?: string, message?: string, file?: string|null, line?: number|null, column?: number|null, codeFrame?: string|null, hint?: string|null, url?: string|null }} f
112
+ * @param {string} [currentPath] defaults to `location.pathname + location.search`
34
113
  */
35
- export function renderDevOverlay(f) {
36
- dismissDevOverlay();
37
- if (!f) return;
114
+ export function renderDevOverlay(f, currentPath) {
115
+ if (!f) { dismissDevOverlay(); return; }
116
+ const here = currentPath === undefined ? __wjCurrentPath() : currentPath;
117
+ if (__wjScoped(f) && !__wjSamePath(f.url, here)) {
118
+ __wjPending = f;
119
+ __wjPendingSeq = __wjNavSeq;
120
+ return;
121
+ }
122
+ __wjRemoveOverlay();
38
123
  const o = document.createElement('div');
39
124
  o.setAttribute('data-webjs-error-overlay', '');
40
125
  o.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:rgba(10,10,12,.92);color:#e6e6e6;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;padding:32px;overflow:auto';
@@ -72,4 +157,131 @@ export function renderDevOverlay(f) {
72
157
  o.appendChild(card);
73
158
  (document.body || document.documentElement).appendChild(o);
74
159
  __wjOverlay = o;
160
+ __wjFrame = f;
161
+ }
162
+
163
+ /**
164
+ * Re-evaluate the scope gate against the page now being viewed (#1047). Called
165
+ * on every client-router navigation, so an overlay the navigation just made
166
+ * stale comes down, and a frame that arrived before the URL advanced (the usual
167
+ * ordering, since the SSE frame is pushed during the render that produces the
168
+ * navigation response) goes up.
169
+ *
170
+ * @param {string} [currentPath] defaults to `location.pathname + location.search`
171
+ */
172
+ export function syncDevOverlayToLocation(currentPath) {
173
+ const here = currentPath === undefined ? __wjCurrentPath() : currentPath;
174
+ // The BACKSTOP for an overlay element this module does not own. Any such node
175
+ // is unremovable and its Dismiss button carries no listener, so it would sit
176
+ // there forever: a `document.body.replaceChildren` from cached HTML is the way
177
+ // that happens. The router's OWN back/forward snapshot is already handled
178
+ // upstream, since `before-cache` detaches the overlay across its `outerHTML`
179
+ // read, so what is left here is everything else (a snapshot cached before this
180
+ // client installed its listeners, or any other wholesale body replacement).
181
+ // Also notice when the node we did own was replaced out from under us: it is
182
+ // detached, so `.remove()` would no-op and the slot would lie about what is
183
+ // on screen.
184
+ if (typeof document !== 'undefined') {
185
+ const live = __wjOverlay && __wjOverlay.isConnected ? __wjOverlay : null;
186
+ __wjOverlay = live;
187
+ document.querySelectorAll('[data-webjs-error-overlay]').forEach((el) => {
188
+ if (el !== live) el.remove();
189
+ });
190
+ }
191
+ // A live overlay whose page we have navigated away from comes down. An
192
+ // unscoped overlay (rebuild / ts-strip) is untouched: it describes the build,
193
+ // not one URL, and only the next successful rebuild clears it.
194
+ if (__wjScoped(__wjFrame) && !__wjSamePath(__wjFrame.url, here)) __wjRemoveOverlay();
195
+ // Still the right frame for this page, but its node was swept away by the
196
+ // restore above: put a REAL one back, with a working Dismiss button.
197
+ if (__wjFrame && !__wjOverlay) renderDevOverlay(__wjFrame, here);
198
+ // The pending frame is consumed by this navigation either way. It renders
199
+ // only if this is BOTH the page it names and the navigation it belongs to;
200
+ // a frame held from an idle-time render (another tab's page, a background
201
+ // fetch of some other url) is dropped, because by the time you actually
202
+ // visit that url the page may render perfectly well, and an overlay over it
203
+ // would be the very bug this whole gate exists to stop.
204
+ const p = __wjPending;
205
+ const seq = __wjPendingSeq;
206
+ __wjPending = null;
207
+ __wjPendingSeq = -1;
208
+ if (p && seq === __wjNavSeq && __wjSamePath(p.url, here)) renderDevOverlay(p, here);
209
+ }
210
+
211
+ /**
212
+ * Mark the start of a client-router navigation (#1047), so a frame arriving
213
+ * from here on is known to belong to it. Exported for the browser test; the
214
+ * shipping wiring is `installDevOverlayNavSync`.
215
+ */
216
+ export function markDevOverlayNavStart() {
217
+ __wjNavSeq++;
218
+ }
219
+
220
+ /**
221
+ * Wire the scope gate to the client router's navigation events (#1047), so the
222
+ * overlay tracks the page actually on screen. Browser-safe and dev-only: the
223
+ * reload client that calls this is served only in dev.
224
+ *
225
+ * Three events, each for a distinct reason:
226
+ * - `webjs:navigate` on `document`, the applied-navigation signal, dispatched
227
+ * after `history.pushState` so `location` is already current.
228
+ * - `popstate` on `window`, because a back/forward restore served from the
229
+ * router's snapshot cache returns before it dispatches `webjs:navigate`.
230
+ * - `webjs:before-cache` on `document`, which does two things. It is the
231
+ * nav-START marker (the router dispatches it from `snapshotCurrent`, at the
232
+ * top of every navigation and form submission, before the fetch), the only
233
+ * signal separating a frame belonging to the navigation in flight from one
234
+ * held since the tab was idle. And it detaches the overlay across the
235
+ * snapshot read, re-attaching it a microtask later, so the cached HTML
236
+ * carries no copy. What it must NOT do is strip the overlay for good, which
237
+ * the event's own contract invites: it fires on EVERY navigation, so that
238
+ * would tear a `rebuild` / `ts-strip` overlay off the page the moment you
239
+ * clicked any link, while the build was still broken.
240
+ *
241
+ * @param {{ document?: any, window?: any, getPath?: () => string }} [opts] injection seam for the browser test
242
+ * @returns {() => void} an uninstall thunk
243
+ */
244
+ export function installDevOverlayNavSync(opts) {
245
+ const o = opts || {};
246
+ const doc = o.document || (typeof document !== 'undefined' ? document : null);
247
+ const win = o.window || (typeof window !== 'undefined' ? window : null);
248
+ const getPath = o.getPath || __wjCurrentPath;
249
+ const onNav = () => syncDevOverlayToLocation(getPath());
250
+ const onBeforeCache = () => {
251
+ markDevOverlayNavStart();
252
+ // Keep the overlay out of the snapshot the router is about to take. It
253
+ // reads `outerHTML` SYNCHRONOUSLY right after this event, so detaching here
254
+ // and re-attaching in a microtask (which runs once that read has returned,
255
+ // and long before any paint) means the cached HTML never carries a copy,
256
+ // while the overlay on screen never flickers.
257
+ //
258
+ // This is not the strip that used to live here. That one dropped the frame
259
+ // permanently, so any link click tore down a `rebuild` / `ts-strip`
260
+ // overlay while the build was still broken; this puts the SAME node back.
261
+ // The sweep in `syncDevOverlayToLocation` stays as the backstop, because
262
+ // it cannot cover every ordering on its own: under an opt-in view
263
+ // transition the router defers the body swap past the sync, so a copy
264
+ // parsed in afterwards would otherwise sit there undismissable.
265
+ const el = __wjOverlay;
266
+ if (!el || !el.isConnected) return;
267
+ const parent = el.parentNode;
268
+ el.remove();
269
+ queueMicrotask(() => {
270
+ // Only if it is still the live overlay: a frame arriving meanwhile may
271
+ // have replaced it, or the user may have dismissed it.
272
+ if (__wjOverlay === el && !el.isConnected && parent) parent.appendChild(el);
273
+ });
274
+ };
275
+ if (doc) {
276
+ doc.addEventListener('webjs:navigate', onNav);
277
+ doc.addEventListener('webjs:before-cache', onBeforeCache);
278
+ }
279
+ if (win) win.addEventListener('popstate', onNav);
280
+ return () => {
281
+ if (doc) {
282
+ doc.removeEventListener('webjs:navigate', onNav);
283
+ doc.removeEventListener('webjs:before-cache', onBeforeCache);
284
+ }
285
+ if (win) win.removeEventListener('popstate', onNav);
286
+ };
75
287
  }