@webjsdev/server 0.9.0 → 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.
- package/README.md +4 -1
- package/index.d.ts +427 -109
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +96 -14
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +177 -129
- package/src/dev/server.js +92 -33
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +2 -2
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +140 -65
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +80 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- 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
|
+
}
|