@webjsdev/server 0.8.91 → 0.10.0

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.
Files changed (105) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +461 -102
  3. package/index.js +9 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-input.js +208 -0
  7. package/src/action-params.js +156 -0
  8. package/src/action-route.js +8 -9
  9. package/src/action-seed.js +44 -5
  10. package/src/actions.js +112 -15
  11. package/src/api.js +67 -10
  12. package/src/app-layout.js +133 -0
  13. package/src/asset-hash.js +82 -49
  14. package/src/auth.js +612 -108
  15. package/src/browser-entries.js +2 -17
  16. package/src/build-epoch.js +1 -1
  17. package/src/cache-tags.js +5 -1
  18. package/src/check/helpers.js +85 -0
  19. package/src/check/rules-actions.js +37 -1
  20. package/src/check/rules-components.js +133 -28
  21. package/src/check/rules-imports.js +175 -4
  22. package/src/check/rules-route-modules.js +200 -0
  23. package/src/check/rules-routes.js +55 -0
  24. package/src/check/rules-routing.js +143 -95
  25. package/src/check/rules-typescript.js +24 -2
  26. package/src/check/rules.js +109 -9
  27. package/src/check/runner-support.js +53 -171
  28. package/src/check/runner.js +24 -6
  29. package/src/client-signals.js +171 -0
  30. package/src/component-scanner.js +22 -16
  31. package/src/context.js +152 -16
  32. package/src/cookie-store.js +262 -0
  33. package/src/core-exports.js +190 -0
  34. package/src/dev/bun-app-source.js +95 -5
  35. package/src/dev/config.js +7 -51
  36. package/src/dev/dev-service-worker.js +48 -0
  37. package/src/dev/dev-status.js +122 -0
  38. package/src/dev/esm-hot-hooks.js +373 -0
  39. package/src/dev/handler.js +632 -155
  40. package/src/dev/helpers.js +127 -96
  41. package/src/dev/hot-host.js +33 -6
  42. package/src/dev/poke-state.js +110 -0
  43. package/src/dev/serve.js +191 -133
  44. package/src/dev/server.js +109 -37
  45. package/src/dev/specifier-versions.js +125 -0
  46. package/src/dev/watch-ignore.js +49 -4
  47. package/src/dev/watch-poll.js +78 -0
  48. package/src/dev/watch-tree.js +59 -22
  49. package/src/dev-classify.js +59 -26
  50. package/src/dev-error.js +104 -3
  51. package/src/dev-hot-classify.js +291 -0
  52. package/src/dev-hot-client.js +245 -0
  53. package/src/dev-import.js +22 -5
  54. package/src/dev-overlay.js +26 -0
  55. package/src/dev-ready-probe.js +69 -0
  56. package/src/dev-regenerate.js +322 -78
  57. package/src/dev-reload-hold.js +49 -20
  58. package/src/dev-reload-reason.js +61 -0
  59. package/src/dev-reload-worker.js +62 -13
  60. package/src/dev-secret.js +73 -0
  61. package/src/dev-source-locations-hook.js +4 -1
  62. package/src/dev-source-locations.js +33 -2
  63. package/src/dev-styles.js +64 -6
  64. package/src/dev.js +0 -1
  65. package/src/env-files.js +124 -0
  66. package/src/file-storage.js +2 -2
  67. package/src/foreign-imports.js +228 -0
  68. package/src/form-action-identity.js +52 -25
  69. package/src/form-dispatch.js +150 -69
  70. package/src/html-cache.js +35 -4
  71. package/src/importmap.js +2 -2
  72. package/src/instrumentation.js +59 -3
  73. package/src/js-scan.js +5 -4
  74. package/src/jwe.js +155 -0
  75. package/src/lazy-components.js +166 -0
  76. package/src/listener-core.js +4 -2
  77. package/src/lit-hints.js +172 -0
  78. package/src/middleware-load.js +248 -0
  79. package/src/module-graph.js +73 -47
  80. package/src/module-side-effect.js +284 -0
  81. package/src/public-files.js +73 -0
  82. package/src/redirect-location.js +19 -0
  83. package/src/redirects.js +9 -1
  84. package/src/route-frontier.js +151 -0
  85. package/src/route-report.js +130 -0
  86. package/src/router.js +120 -13
  87. package/src/session.js +32 -10
  88. package/src/ssr/client-router-flag.js +1 -1
  89. package/src/ssr/document.js +49 -17
  90. package/src/ssr/head.js +228 -25
  91. package/src/ssr/preloads.js +31 -45
  92. package/src/ssr/render.js +243 -55
  93. package/src/ssr/responses.js +51 -4
  94. package/src/ssr/shipped-modules.js +52 -16
  95. package/src/testing.d.ts +31 -2
  96. package/src/testing.js +85 -17
  97. package/src/thenable-params.js +26 -0
  98. package/src/vendor/jspm.js +12 -1
  99. package/src/vendor/pins.js +9 -8
  100. package/src/vendor/scanner.js +14 -9
  101. package/src/vendor.js +5 -3
  102. package/webjs-config.schema.json +3 -8
  103. package/src/component-elision.js +0 -1852
  104. package/src/elision-differential.js +0 -101
  105. package/src/elision-report.js +0 -193
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Whether a module runs code when it loads (#1689), read lexically from its
3
+ * source. The dev hot swap (`dev-hot-classify.js`, through `hotVerdict` in
4
+ * `dev/handler.js`) walks an edit up the shipped importer graph to the
5
+ * component modules that re-import it, and a module passed through on the way
6
+ * must have no module-scope side effect, since re-evaluating it at a new
7
+ * version would repeat that effect in the open tab. Lifted from the component
8
+ * elision analyser this check used to live in, which #1686 removed.
9
+ *
10
+ * @module module-side-effect
11
+ */
12
+ import { redactToPlaceholders } from './js-scan.js';
13
+
14
+ /**
15
+ * Constructors that produce inert DATA with no side effect, so a module-scope
16
+ * `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
17
+ * parsed URL) does no work at load (#623). Any constructor NOT in this set
18
+ * (`new WebSocket()`, `new Worker()`, `new EventSource()`, `new Audio()`) IS a
19
+ * side effect.
20
+ */
21
+ const PURE_DATA_CONSTRUCTORS = new Set([
22
+ 'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object',
23
+ 'Number', 'String', 'Boolean', 'BigInt', 'Symbol',
24
+ 'Error', 'TypeError', 'RangeError', 'SyntaxError',
25
+ 'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView',
26
+ 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
27
+ 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
28
+ 'BigInt64Array', 'BigUint64Array',
29
+ ]);
30
+
31
+ /**
32
+ * Marks where `hasModuleScopeSideEffect` dropped a top-level `{...}` body from
33
+ * its depth-0 frame, so an arrow followed by a block body is not mistaken for
34
+ * an expression body that runs on into the next statement. Removed again
35
+ * before the call scan.
36
+ */
37
+ const BLOCK = '\x01';
38
+
39
+ /** `function`, `function*`, `async function name` up to its parameter list's `(`. */
40
+ const FUNCTION_PARAMS_RE = /(?<![\w$.])function\s*\*?\s*(?:[A-Za-z_$][\w$]*)?\s*\(/g;
41
+
42
+ /**
43
+ * The text before an arrow's `=>` when that arrow is a declarator initializer
44
+ * (`const usd = (m) =>`, `let f = async x =>`, `, g = (a) =>`). The parameter
45
+ * list must hold no parens, so a call in a parameter default
46
+ * (`(a = init()) =>`) keeps its arrow unrecognised and still counts.
47
+ */
48
+ const ARROW_DECLARATOR_RE =
49
+ /(?:(?<![\w$.])(?:const|let|var)\s+|,\s*)[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?(?:\([^()]*\)|[A-Za-z_$][\w$]*)\s*$/;
50
+
51
+ /**
52
+ * An expression body that ends this way cannot stop at the newline (a binary
53
+ * operator, `?`, `:`, or a curried `=>`). Postfix `++` / `--` and a regex
54
+ * literal's closing `/` end a statement, so neither is listed.
55
+ */
56
+ const BODY_CONTINUES_RE = /(?:=>|&&|\|\||\?\?|[?:=*%|&^<>]|(?<![+])\+|(?<![-])-)\s*$/;
57
+
58
+ /** A line starting this way continues the previous one: no statement starts with it. */
59
+ const LINE_CONTINUES_RE = /^\s*(?:\?|:|&&|\|\||\.(?![.\d])|\*|%|=(?!>)|\|)/;
60
+
61
+ /**
62
+ * Removes the two depth-0 shapes that look like calls and run nothing at module
63
+ * load: a `function` declaration's parameter list (a default such as
64
+ * `now = Date.now()` runs per call) and an arrow's expression body when the
65
+ * arrow initializes a declarator (`const usd = (m) => Math.round(m) / MICROS`).
66
+ *
67
+ * The arrow body ends at a `,` or `;` at its own paren/bracket depth 0, at a
68
+ * `)` / `]` closing something outside it, or at a newline once the body has
69
+ * content, unless `BODY_CONTINUES_RE` / `LINE_CONTINUES_RE` prove the
70
+ * expression carries on. Ending early is always safe, because whatever is left
71
+ * stays in the frame and is still scanned; ending late could swallow a real
72
+ * statement, so every rule errs towards ending.
73
+ *
74
+ * @param {string} frame depth-0 text, with `BLOCK` where a `{...}` was dropped
75
+ * @returns {string | null} the frame without those parts, or null when a
76
+ * parameter list does not close (the caller then reports a side effect)
77
+ */
78
+ function blankDeclarationOnlyCode(frame) {
79
+ let out = '';
80
+ let last = 0;
81
+ for (const m of frame.matchAll(FUNCTION_PARAMS_RE)) {
82
+ if (m.index < last) continue;
83
+ const open = m.index + m[0].length - 1;
84
+ let depth = 0;
85
+ let close = -1;
86
+ for (let j = open; j < frame.length; j++) {
87
+ if (frame[j] === '(') depth++;
88
+ else if (frame[j] === ')' && --depth === 0) { close = j; break; }
89
+ }
90
+ if (close < 0) return null;
91
+ out += frame.slice(last, open + 1);
92
+ last = close;
93
+ }
94
+ frame = out + frame.slice(last);
95
+
96
+ out = '';
97
+ let depth = 0;
98
+ for (let i = 0; i < frame.length; i++) {
99
+ const c = frame[i];
100
+ if (c === '(' || c === '[') depth++;
101
+ else if ((c === ')' || c === ']') && depth > 0) depth--;
102
+ if (depth === 0 && c === '=' && frame[i + 1] === '>' && ARROW_DECLARATOR_RE.test(out)) {
103
+ out += '=>';
104
+ i = arrowBodyEnd(frame, i + 2) - 1;
105
+ continue;
106
+ }
107
+ out += c;
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * Index where an arrow's expression body starting at `start` ends (see
114
+ * `blankDeclarationOnlyCode`).
115
+ *
116
+ * @param {string} frame
117
+ * @param {number} start index just past the `=>`
118
+ * @returns {number}
119
+ */
120
+ function arrowBodyEnd(frame, start) {
121
+ let depth = 0;
122
+ let i = start;
123
+ for (; i < frame.length; i++) {
124
+ const c = frame[i];
125
+ if (c === '(' || c === '[') { depth++; continue; }
126
+ if (c === ')' || c === ']') {
127
+ if (depth === 0) return i;
128
+ depth--;
129
+ continue;
130
+ }
131
+ if (depth > 0) continue;
132
+ if (c === ',' || c === ';') return i;
133
+ if (c === '\n') {
134
+ const body = frame.slice(start, i);
135
+ if (!body.trim() || BODY_CONTINUES_RE.test(body)) continue;
136
+ if (LINE_CONTINUES_RE.test(frame.slice(i + 1))) continue;
137
+ return i;
138
+ }
139
+ }
140
+ return i;
141
+ }
142
+
143
+ /**
144
+ * Module-scope work, detected by an ALLOWLIST of safe top-level forms rather
145
+ * than a denylist of browser globals. A module that runs ANY code when it
146
+ * loads (other than registering a component) has a side effect that a second
147
+ * evaluation would repeat, so the dev hot swap (#1689) refuses to pass an edit
148
+ * through it and reloads instead (`side-effect-module` in
149
+ * `dev-hot-classify.js`). Unlike a global
150
+ * denylist this does not rot as browsers add APIs: a brand-new global trips it
151
+ * automatically, because it is the CALL (or `new`, or dynamic `import()`), not
152
+ * the global's name, that is recognised.
153
+ *
154
+ * Rule: at brace depth 0 (so code inside function / class / method bodies and
155
+ * template holes, which do not run at module load, is ignored), the only
156
+ * permitted forms are declarations (`import` / `export` / `function` / `class`
157
+ * / `const` / `let` / `var`) and the component registration call
158
+ * (`X.register(...)` / `customElements.define(...)`). Any other call, any
159
+ * `new`, any dynamic `import(...)`, or top-level `await` is a side effect.
160
+ *
161
+ * Scans the redacted copy (strings / templates / comments blanked, regex
162
+ * literals and nested `${...}` interpolation tracked by the lexer) so template
163
+ * prose and JSDoc cannot trip it; quoted-string bodies, which redaction keeps
164
+ * verbatim for other rules, are blanked here too so a string like `"foo()"` or
165
+ * `"{"` is not read as a call and cannot unbalance the brace scan. The
166
+ * unbalanced-brace and unterminated-string fallbacks below are defense in
167
+ * depth: with the lexer tracking regex literals, neither should trigger on
168
+ * valid code, but if either does the module counts as having a side effect
169
+ * rather than risk hiding one.
170
+ *
171
+ * PASS TYPE-ERASED SOURCE for a TypeScript module. Redaction blanks comments
172
+ * and literals, NOT type syntax, and TS annotations are call-shaped often
173
+ * enough to matter: `readonly (readonly [number, number, number])[]` is an
174
+ * identifier immediately followed by `(`, so a module holding nothing but a
175
+ * typed data literal reads as running code at module scope (#1423). The dev
176
+ * handler strips types before it calls this; a caller handing it `.ts` source
177
+ * as authored is opting into that false positive.
178
+ *
179
+ * A `function` declaration's parameter list and the expression body of an
180
+ * arrow that initializes a `const` / `let` / `var` declarator are dropped from
181
+ * the frame first (`blankDeclarationOnlyCode`): `now = Date.now()` and
182
+ * `(m) => Math.round(m) / MICROS` declare code, they do not run it.
183
+ *
184
+ * Over-detection is safe (a pure top-level helper call, or an arrow outside a
185
+ * declarator whose body calls something, only costs a reload). The accepted
186
+ * residual misses, all
187
+ * contrived and structural (so they do not rot), are a call buried inside a
188
+ * top-level object / array initializer or a destructuring default, and a
189
+ * side-effecting tagged-template hole evaluated at module scope.
190
+ *
191
+ * @param {string} src module source, type-erased if it came from a TypeScript file
192
+ * @param {string[]} [literals] the redaction's literal bodies, when already computed
193
+ * @returns {boolean}
194
+ */
195
+ export function hasModuleScopeSideEffect(src, literals) {
196
+ let redacted = src;
197
+ if (!literals) {
198
+ const r = redactToPlaceholders(src);
199
+ redacted = r.redacted;
200
+ literals = r.literals;
201
+ }
202
+ // Keep only depth-0 text (outside every `{}`). Skip quoted-string bodies so
203
+ // braces/parens inside a string neither desync the depth nor read as a call.
204
+ let depth = 0;
205
+ let frame = '';
206
+ for (let i = 0; i < redacted.length; i++) {
207
+ const c = redacted[i];
208
+ if (c === "'" || c === '"') {
209
+ i++;
210
+ let closed = false;
211
+ while (i < redacted.length) {
212
+ const d = redacted[i];
213
+ if (d === '\\') { i += 2; continue; }
214
+ if (d === '\n') break; // a real quoted string never spans a newline
215
+ if (d === c) { closed = true; break; }
216
+ i++;
217
+ }
218
+ // Not closed on its line is an unterminated string OR a regex literal
219
+ // containing a quote that desynced the upstream redaction (regex bodies
220
+ // are not tracked, so a stray quote shifts quote pairing). Either way the
221
+ // lexical state is unreliable below here, so answer conservatively.
222
+ if (!closed) return true;
223
+ if (depth === 0) frame += "''";
224
+ continue;
225
+ }
226
+ if (c === '{') { if (depth === 0) frame += BLOCK; depth++; continue; }
227
+ if (c === '}') { if (depth > 0) depth--; continue; }
228
+ if (depth === 0) frame += c;
229
+ }
230
+ // Unbalanced braces mean a construct we could not lexically resolve (a regex
231
+ // literal with a stray brace is the common case). Report a side effect
232
+ // rather than risk a hidden top-level statement.
233
+ if (depth !== 0) return true;
234
+ // Parameter lists and declarator arrow bodies are declarations, not code
235
+ // that runs at load.
236
+ const declared = blankDeclarationOnlyCode(frame);
237
+ if (declared === null) return true;
238
+ frame = declared.replaceAll(BLOCK, '');
239
+ // Optional-chaining call/index: `foo?.()`, `x?.[i]()` (the `?.` defeats the
240
+ // identifier-before-paren match below).
241
+ if (/\?\.\s*[([]/.test(frame)) return true;
242
+ // Top-level `new X`: a pure-data builtin constructor (Set / Map / Date /
243
+ // RegExp / typed array / URL / ...) is inert module data, not a side effect, so
244
+ // it is no side effect (#623); any other constructor is one. Catches
245
+ // both `new X` and `new X(...)` (the constructor's own `(`, if present, is
246
+ // then skipped in the call scan below).
247
+ for (const nm of frame.matchAll(/(?<![.\w])new\s+([A-Za-z_$][\w$]*)/g)) {
248
+ if (!PURE_DATA_CONSTRUCTORS.has(nm[1])) return true;
249
+ }
250
+ // Top-level `await`.
251
+ if (/(?<![.\w])await\s/.test(frame)) return true;
252
+ // A call: `(` preceded (ignoring whitespace) by an identifier, `)`, or `]`.
253
+ const CALL_RE = /(?:([A-Za-z_$][\w$]*)|[)\]])\s*\(/g;
254
+ // Identifiers that precede a `(` WITHOUT it being a call (keywords + a
255
+ // `function` declaration's parameter list).
256
+ const NOT_A_CALL = new Set([
257
+ 'if', 'for', 'while', 'switch', 'catch', 'with', 'return', 'typeof',
258
+ 'instanceof', 'void', 'delete', 'in', 'of', 'yield', 'do', 'else',
259
+ 'case', 'default', 'function', 'await', 'new', 'async',
260
+ ]);
261
+ let m;
262
+ while ((m = CALL_RE.exec(frame)) !== null) {
263
+ const ident = m[1];
264
+ if (ident === 'import') return true; // dynamic import()
265
+ if (ident && NOT_A_CALL.has(ident)) continue;
266
+ // A `function`-declaration parameter list (`function name(` or
267
+ // `async function name(`) is not a call.
268
+ if (ident && /\bfunction\s*\*?\s*$/.test(frame.slice(0, m.index))) continue;
269
+ // The component registration call is the one permitted top-level call.
270
+ if (ident === 'register' || ident === 'define') continue;
271
+ // `extends WebComponent(...)` is the reactive-prop factory (#597 /
272
+ // #599): a class-declaration construct, not an independent side-effecting
273
+ // statement. Scoped to the `extends` position, so a top-level
274
+ // `WebComponent(...)` call used anywhere else still counts as a side
275
+ // effect (#604).
276
+ if (ident === 'WebComponent' && /\bextends\s+$/.test(frame.slice(0, m.index))) continue;
277
+ // A `new X(...)` constructor call: the `new`-prefixed cases were already
278
+ // vetted above (a non-pure constructor returned true; a pure-data one is
279
+ // inert), so its `(` here is not an independent side-effecting call.
280
+ if (ident && /(?<![.\w])new\s+$/.test(frame.slice(0, m.index))) continue;
281
+ return true; // any other top-level call
282
+ }
283
+ return false;
284
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `public/` served at the URL root, as in Next.js: `public/logo.png` is
3
+ * `/logo.png`, `public/robots.txt` is `/robots.txt`, `public/sw.js` is
4
+ * `/sw.js`. There is no `/public/` URL prefix.
5
+ *
6
+ * Resolution order matches Next: the framework's own assets, then the app's
7
+ * source modules, then a public file, then the routes. So a public file beats
8
+ * a DYNAMIC route at the same URL, and a public file at the same URL as a
9
+ * static page, a `route.ts` or a metadata route is a boot error
10
+ * (`no-public-route-conflict`, see app-layout.js), since one would silently
11
+ * shadow the other.
12
+ *
13
+ * @module public-files
14
+ */
15
+ import { readdirSync, statSync } from 'node:fs';
16
+ import { join, sep } from 'node:path';
17
+
18
+ /**
19
+ * Every file under `<appDir>/public`, as the URL path it is served at
20
+ * (`/images/a.png`). Symlinks are not followed. Empty when there is no
21
+ * `public/` directory.
22
+ * @param {string} appDir
23
+ * @returns {Set<string>}
24
+ */
25
+ export function buildPublicIndex(appDir) {
26
+ /** @type {Set<string>} */
27
+ const out = new Set();
28
+ const root = join(appDir, 'public');
29
+ /** @param {string} dir @param {string} prefix */
30
+ const walk = (dir, prefix) => {
31
+ let entries;
32
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
33
+ for (const e of entries) {
34
+ if (e.isSymbolicLink()) continue;
35
+ const url = `${prefix}/${e.name}`;
36
+ if (e.isDirectory()) walk(join(dir, e.name), url);
37
+ else if (e.isFile()) out.add(url);
38
+ }
39
+ };
40
+ walk(root, '');
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * The file a URL path names under `<appDir>/public`, or null when the path
46
+ * escapes the directory (a `..%2F` that survived URL parsing) or is a
47
+ * directory path. Does not check existence.
48
+ * @param {string} appDir
49
+ * @param {string} urlPath decoded pathname
50
+ * @returns {string | null}
51
+ */
52
+ export function publicFilePath(appDir, urlPath) {
53
+ if (!urlPath || urlPath === '/' || urlPath.endsWith('/')) return null;
54
+ const root = join(appDir, 'public') + sep;
55
+ const abs = join(root, urlPath);
56
+ return abs.startsWith(root) ? abs : null;
57
+ }
58
+
59
+ /**
60
+ * Whether a URL path is a public file. Production answers from the boot-time
61
+ * index; dev checks the disk so a file added while the server runs is served
62
+ * at once.
63
+ * @param {string} appDir
64
+ * @param {string} urlPath
65
+ * @param {{ dev: boolean, index?: Set<string> | null }} opts
66
+ * @returns {string | null} the absolute file, or null
67
+ */
68
+ export function resolvePublicFile(appDir, urlPath, { dev, index }) {
69
+ const abs = publicFilePath(appDir, urlPath);
70
+ if (!abs) return null;
71
+ if (!dev && index) return index.has(urlPath) ? abs : null;
72
+ try { return statSync(abs).isFile() ? abs : null; } catch { return null; }
73
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A redirect target as an HTTP header value.
3
+ *
4
+ * A header value must be a ByteString, so `new Response(null, { headers: {
5
+ * location: '/日本' } })` throws ("Cannot convert argument to a ByteString")
6
+ * inside the very catch block that is answering a thrown `redirect()`, which
7
+ * turns a navigation into a 500. Next.js percent-encodes the target, so this
8
+ * does the same for every run of characters outside visible ASCII (a space
9
+ * included) and leaves everything else, an existing `%xx` escape among it,
10
+ * untouched. Applied at every site that answers a thrown `redirect()`. A lone
11
+ * surrogate (half an emoji, from truncated user input) is replaced with U+FFFD
12
+ * first, since encodeURIComponent throws URIError on one.
13
+ *
14
+ * @param {unknown} url
15
+ * @returns {string}
16
+ */
17
+ export function headerSafeLocation(url) {
18
+ return String(url).replace(/[^\x21-\x7e]+/gu, (run) => encodeURIComponent(run.toWellFormed()));
19
+ }
package/src/redirects.js CHANGED
@@ -135,9 +135,10 @@ export function readTrailingSlashPolicy(pkg) {
135
135
  *
136
136
  * @param {Request} req
137
137
  * @param {'never' | 'always' | 'ignore'} policy
138
+ * @param {(path: string) => boolean} [isPublicFile] a url that is a `public/` file is left alone
138
139
  * @returns {Response | null}
139
140
  */
140
- export function applyTrailingSlash(req, policy) {
141
+ export function applyTrailingSlash(req, policy, isPublicFile) {
141
142
  if (policy !== 'never' && policy !== 'always') return null;
142
143
  let url;
143
144
  try {
@@ -153,6 +154,13 @@ export function applyTrailingSlash(req, policy) {
153
154
  // `//host`): canonicalizing it would emit a protocol-relative, cross-origin
154
155
  // Location (an open redirect). Let the router handle the weird path instead.
155
156
  if (!isSameOriginPath(path)) return null;
157
+ // A `public/` file is served at exactly its own url (a file is a leaf, even
158
+ // one with no extension such as `/LICENSE`), so it is never canonicalized.
159
+ if (typeof isPublicFile === 'function') {
160
+ let decoded = path;
161
+ try { decoded = decodeURIComponent(path); } catch { /* keep raw */ }
162
+ if (isPublicFile(decoded)) return null;
163
+ }
156
164
 
157
165
  /** @type {string | null} */
158
166
  let canonical = null;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Route modules never ship to the browser (#1686, Plan v2 workstream 1).
3
+ *
4
+ * A page, layout or boundary module (error, not-found, forbidden, unauthorized,
5
+ * global-not-found, loading) runs ONLY on the server, the way a Next.js server
6
+ * component does. Nothing in the browser ever read a page export: the boot
7
+ * script is a list of bare `import "<url>"` lines and the client router never
8
+ * imports a route module. So the boot imports each route module's component
9
+ * FRONTIER instead: the shipping components reachable from it on a path that
10
+ * does not pass through another component (a component's own subtree loads
11
+ * with it).
12
+ *
13
+ * This replaced a lexical guess about whether a page did "client work" at
14
+ * module scope, which decided whether the page itself shipped. The guess had
15
+ * false positives (`new Intl.DateTimeFormat()`, `Array.from(...)`, a class-name
16
+ * helper call at module scope), and a page wrongly judged to ship had its
17
+ * server-only import refused by `no-server-import-in-browser-module`. With no
18
+ * route module ever shipping, there is nothing to guess.
19
+ *
20
+ * Pure and graph-only: it reads no file.
21
+ *
22
+ * @module
23
+ */
24
+
25
+ const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
26
+
27
+ /**
28
+ * The component frontier of every route module.
29
+ *
30
+ * @param {Iterable<string>} routeFiles absolute route-module paths (`collectBootRouteModules`)
31
+ * @param {Map<string, Set<string>>} moduleGraph file -> its static imports
32
+ * @param {Set<string>} componentFiles every registered component's file
33
+ * @param {Set<string>} skip files that never load in the browser (route-table files)
34
+ * @returns {Map<string, string[]>} route file -> component files to import in its place (possibly empty)
35
+ */
36
+ export function routeFrontiers(routeFiles, moduleGraph, componentFiles, skip) {
37
+ /** @type {Map<string, string[]>} */
38
+ const out = new Map();
39
+ for (const file of routeFiles) {
40
+ /** @type {string[]} */
41
+ const frontier = [];
42
+ const visited = new Set([file]);
43
+ const queue = [file];
44
+ while (queue.length) {
45
+ const cur = /** @type {string} */ (queue.shift());
46
+ const deps = moduleGraph.get(cur);
47
+ if (!deps) continue;
48
+ for (const dep of deps) {
49
+ if (visited.has(dep)) continue;
50
+ visited.add(dep);
51
+ // A skipped file never loads; a `.server.*` module is a stub in the
52
+ // browser, so nothing behind it can load either (inside or outside appDir).
53
+ if (skip.has(dep) || SERVER_FILE_RE.test(dep)) continue;
54
+ // A component is imported in the route module's place, and its own
55
+ // subtree loads with it, so the walk stops there.
56
+ if (componentFiles.has(dep)) { frontier.push(dep); continue; }
57
+ // Anything else (a helper, a barrel, core) is only a carrier on the way
58
+ // to components: walked, never emitted.
59
+ queue.push(dep);
60
+ }
61
+ }
62
+ out.set(file, frontier);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Every route module the client boot could be asked to stand in for, with its
69
+ * kind: pages, layouts and every boundary (error, loading, not-found,
70
+ * forbidden, unauthorized, global-not-found). None of them ships; each is
71
+ * replaced in the boot by its component frontier. `global-error` is left out:
72
+ * it renders its own document with no boot script.
73
+ *
74
+ * Route-ness comes from the route table, never from a file name (a plain
75
+ * helper may be called `page.ts`). A file used as more than one kind keeps
76
+ * the first kind found, pages first.
77
+ *
78
+ * @param {any} routeTable the router's table (`buildRouteTable`)
79
+ * @returns {Map<string, 'page'|'layout'|'error'|'loading'|'not-found'|'forbidden'|'unauthorized'|'global-not-found'>}
80
+ */
81
+ export function routeModuleKinds(routeTable) {
82
+ /** @type {Map<string, any>} */
83
+ const kinds = new Map();
84
+ const add = (/** @type {string|undefined} */ f, /** @type {string} */ kind) => { if (f && !kinds.has(f)) kinds.set(f, kind); };
85
+ const pages = routeTable.pages || [];
86
+ for (const page of pages) add(page.file, 'page');
87
+ for (const [key, kind] of /** @type {const} */ ([
88
+ ['layouts', 'layout'], ['errors', 'error'], ['loadings', 'loading'], ['notFounds', 'not-found'],
89
+ ['forbiddens', 'forbidden'], ['unauthorizeds', 'unauthorized'],
90
+ ])) {
91
+ for (const page of pages) for (const f of page[key] || []) add(f, kind);
92
+ }
93
+ add(routeTable.notFound, 'not-found');
94
+ if (routeTable.notFounds) for (const f of routeTable.notFounds.values()) add(f, 'not-found');
95
+ add(routeTable.globalNotFound, 'global-not-found');
96
+ return kinds;
97
+ }
98
+
99
+ /**
100
+ * The route modules `routeModuleKinds` lists, as an array.
101
+ * @param {any} routeTable
102
+ * @returns {string[]}
103
+ */
104
+ export function collectBootRouteModules(routeTable) {
105
+ return [...routeModuleKinds(routeTable).keys()];
106
+ }
107
+
108
+ /**
109
+ * Every route-table file the browser must never load: the boot route modules
110
+ * plus `global-error`. The serve gate refuses these (#1686), and the gate and
111
+ * vendor walks stop at them, so a page-only import is neither servable nor
112
+ * vendored.
113
+ * @param {any} routeTable
114
+ * @returns {Set<string>}
115
+ */
116
+ export function routeTableFiles(routeTable) {
117
+ const files = new Set(routeModuleKinds(routeTable).keys());
118
+ if (routeTable.globalError) files.add(routeTable.globalError);
119
+ return files;
120
+ }
121
+
122
+ /**
123
+ * A memoized "does this module lead to a component" test over the static
124
+ * graph, stopping at `.server` files. A side-effect import of a barrel of
125
+ * component imports is how a route module registers components, and the boot
126
+ * already loads those in its place, so route-module hints skip it.
127
+ * @param {Map<string, Set<string>>} moduleGraph
128
+ * @param {Set<string>} componentFiles
129
+ * @returns {(abs: string) => boolean}
130
+ */
131
+ export function leadsToComponent(moduleGraph, componentFiles) {
132
+ /** @type {Map<string, boolean>} */
133
+ const memo = new Map();
134
+ return (abs) => {
135
+ const hit = memo.get(abs);
136
+ if (hit !== undefined) return hit;
137
+ const seen = new Set([abs]);
138
+ const work = [abs];
139
+ let found = false;
140
+ while (work.length && !found) {
141
+ for (const dep of moduleGraph.get(/** @type {string} */ (work.pop())) || []) {
142
+ if (seen.has(dep) || SERVER_FILE_RE.test(dep)) continue;
143
+ if (componentFiles.has(dep)) { found = true; break; }
144
+ seen.add(dep);
145
+ work.push(dep);
146
+ }
147
+ }
148
+ memo.set(abs, found);
149
+ return found;
150
+ };
151
+ }