@webjsdev/server 0.8.39 → 0.8.41
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/index.d.ts +9 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/action-error.js +56 -0
- package/src/action-stream.js +20 -4
- package/src/actions.js +26 -14
- package/src/cache-fn.js +28 -5
- package/src/cache-tags.js +43 -19
- package/src/cache.js +53 -0
- package/src/check.js +100 -19
- package/src/component-elision.js +67 -18
- package/src/dev.js +48 -4
- package/src/importmap.js +36 -0
- package/src/listener-bun.js +78 -33
- package/src/listener-core.js +175 -1
- package/src/module-graph.js +161 -17
- package/src/page-action.js +9 -0
- package/src/rate-limit.js +60 -5
- package/src/router.js +63 -6
- package/src/ssr.js +100 -2
- package/src/testing.d.ts +10 -0
- package/src/testing.js +40 -1
package/src/module-graph.js
CHANGED
|
@@ -101,10 +101,87 @@ const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
|
101
101
|
*/
|
|
102
102
|
const EXPORT_FROM_RE = /\bexport\b[^'";]+?\sfrom\s+['"]([^'"]+)['"]/g;
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* @type {RegExp} match a dynamic `import('…')` with a STRING-LITERAL specifier
|
|
106
|
+
* only (`import('./x.ts')`, `await import("#lib/y.ts")`). A computed specifier
|
|
107
|
+
* (`import(expr)`) cannot be captured and is intentionally left out: it stays a
|
|
108
|
+
* documented limitation (and `webjs check` warns on it), not a phantom edge.
|
|
109
|
+
*
|
|
110
|
+
* Unlike the static `IMPORT_RE` (which requires `\s+` after `import`), this
|
|
111
|
+
* matches `import` immediately or with whitespace before the `(`, so the two
|
|
112
|
+
* never overlap. `m.index` is the `import` keyword start, so the same
|
|
113
|
+
* redaction-mask guard (keyword-in-code-position) applies.
|
|
114
|
+
*
|
|
115
|
+
* The specifier may be followed by `)` OR `,` so the import-attributes form
|
|
116
|
+
* `import('./data.json', { with: { type: 'json' } })` (a JSON / CSS module
|
|
117
|
+
* import) is captured too. A computed specifier still falls out: in
|
|
118
|
+
* `import('./pages/' + name)` the char after the closing quote is `+`, neither
|
|
119
|
+
* `,` nor `)`, so it does not match.
|
|
120
|
+
*/
|
|
121
|
+
const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*[,)]/g;
|
|
122
|
+
|
|
104
123
|
/**
|
|
105
124
|
* @typedef {Map<string, Set<string>>} ModuleGraph
|
|
106
|
-
* A map of absolute file path → Set of absolute file paths it imports.
|
|
125
|
+
* A map of absolute file path → Set of absolute file paths it STATICALLY imports.
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Dynamic-import edges (string-literal `import('…')` only), kept SEPARATE from
|
|
130
|
+
* the static graph and keyed by it in a WeakMap. The authorization gate
|
|
131
|
+
* (`reachableFromEntries`) unions these in so a lazily-imported app module is
|
|
132
|
+
* servable instead of 404ing, but the preload walk (`transitiveDeps`) and the
|
|
133
|
+
* elision analysis stay on the static graph ONLY: a dynamic import is lazy by
|
|
134
|
+
* author intent, so eagerly preloading its target would over-fetch on every
|
|
135
|
+
* page load, and feeding dynamic edges into elision could flip a verdict. The
|
|
136
|
+
* module is fetched at call time, now served correctly by the gate.
|
|
137
|
+
* @type {WeakMap<ModuleGraph, ModuleGraph>}
|
|
138
|
+
*/
|
|
139
|
+
const DYNAMIC_EDGES = new WeakMap();
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The dynamic-import edges discovered for a built graph (string-literal targets
|
|
143
|
+
* only), or an empty map if none. Exposed for tests/ops.
|
|
144
|
+
* @param {ModuleGraph} graph
|
|
145
|
+
* @returns {ModuleGraph}
|
|
146
|
+
*/
|
|
147
|
+
export function dynamicEdges(graph) {
|
|
148
|
+
return DYNAMIC_EDGES.get(graph) || new Map();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bare (npm vendor) import specifiers per file, kept SEPARATE from the static
|
|
153
|
+
* app graph (which only tracks relative + `#`-alias edges) and keyed by the
|
|
154
|
+
* graph in a WeakMap. Records the EXACT specifier as written (`dayjs`,
|
|
155
|
+
* `dayjs/plugin/utc`) so SSR can look it up in the vendor importmap and emit a
|
|
156
|
+
* `modulepreload` for the reached vendor URL (#754), flattening the vendor CDN
|
|
157
|
+
* waterfall. Excludes `node:` builtins and protocol specifiers.
|
|
158
|
+
* @type {WeakMap<ModuleGraph, Map<string, Set<string>>>}
|
|
159
|
+
*/
|
|
160
|
+
const BARE_EDGES = new WeakMap();
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The bare (npm vendor) import specifiers per file for a built graph, or an
|
|
164
|
+
* empty map if none. The values are specifiers (not resolved URLs); the caller
|
|
165
|
+
* maps them through the vendor importmap.
|
|
166
|
+
* @param {ModuleGraph} graph
|
|
167
|
+
* @returns {Map<string, Set<string>>}
|
|
107
168
|
*/
|
|
169
|
+
export function bareImports(graph) {
|
|
170
|
+
return BARE_EDGES.get(graph) || new Map();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* True for a bare npm vendor specifier (`dayjs`, `@scope/pkg/sub`), excluding
|
|
175
|
+
* relative / absolute / `#`-alias paths and `node:` / protocol specifiers.
|
|
176
|
+
* Inlined here (not imported from vendor.js) to avoid a module cycle.
|
|
177
|
+
* @param {string} spec
|
|
178
|
+
* @returns {boolean}
|
|
179
|
+
*/
|
|
180
|
+
function isVendorSpecifier(spec) {
|
|
181
|
+
if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('#') || spec.startsWith('__')) return false;
|
|
182
|
+
if (spec.startsWith('node:') || /^[a-z][a-z0-9+.-]*:/.test(spec)) return false;
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
108
185
|
|
|
109
186
|
/**
|
|
110
187
|
* Build the module graph for all source files under `appDir`.
|
|
@@ -118,10 +195,16 @@ export async function buildModuleGraph(appDir) {
|
|
|
118
195
|
IMPORTS_CACHE.delete(appDir);
|
|
119
196
|
/** @type {ModuleGraph} */
|
|
120
197
|
const graph = new Map();
|
|
198
|
+
/** @type {ModuleGraph} dynamic-import edges, keyed to `graph` below */
|
|
199
|
+
const dynamic = new Map();
|
|
200
|
+
/** @type {Map<string, Set<string>>} bare vendor specifiers per file (#754) */
|
|
201
|
+
const bare = new Map();
|
|
121
202
|
/** @type {Set<string>} every file walked this build (graph holds only files
|
|
122
203
|
* with deps, so a separate set is needed to know what is still live). */
|
|
123
204
|
const seen = new Set();
|
|
124
|
-
await walk(appDir, appDir, graph, seen);
|
|
205
|
+
await walk(appDir, appDir, graph, seen, dynamic, bare);
|
|
206
|
+
if (dynamic.size) DYNAMIC_EDGES.set(graph, dynamic);
|
|
207
|
+
if (bare.size) BARE_EDGES.set(graph, bare);
|
|
125
208
|
// Evict parse-cache entries for files no longer in the tree (a rebuild after
|
|
126
209
|
// a rename or delete), so a long dev session does not accumulate dead
|
|
127
210
|
// entries. Scoped to appDir so a multi-app process (tests, dogfood smoke)
|
|
@@ -226,6 +309,13 @@ const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
|
|
|
226
309
|
* @returns {Set<string>}
|
|
227
310
|
*/
|
|
228
311
|
export function reachableFromEntries(graph, entryFiles, appDir) {
|
|
312
|
+
// The gate unions in dynamic-import edges (#751) so a lazily-imported app
|
|
313
|
+
// module (`await import('./widget.ts')`) is servable instead of 404ing. A
|
|
314
|
+
// dynamically-imported module's OWN static imports are then walked normally
|
|
315
|
+
// (it joins the queue), so its subtree is servable too. The `.server.*`
|
|
316
|
+
// boundary still holds: a dynamic `import('./x.server.ts')` is admitted (a
|
|
317
|
+
// stub is served) but, like any server file, not traversed into.
|
|
318
|
+
const dynamic = DYNAMIC_EDGES.get(graph);
|
|
229
319
|
/** @type {Set<string>} */
|
|
230
320
|
const visited = new Set();
|
|
231
321
|
/** @type {string[]} */
|
|
@@ -242,13 +332,17 @@ export function reachableFromEntries(graph, entryFiles, appDir) {
|
|
|
242
332
|
// time), but we don't add its imports because the browser never
|
|
243
333
|
// sees them.
|
|
244
334
|
if (SERVER_FILE_RE.test(file)) continue;
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (!
|
|
250
|
-
|
|
251
|
-
|
|
335
|
+
const staticDeps = graph.get(file);
|
|
336
|
+
const dynDeps = dynamic && dynamic.get(file);
|
|
337
|
+
if (!staticDeps && !dynDeps) continue;
|
|
338
|
+
for (const set of [staticDeps, dynDeps]) {
|
|
339
|
+
if (!set) continue;
|
|
340
|
+
for (const dep of set) {
|
|
341
|
+
if (visited.has(dep)) continue;
|
|
342
|
+
if (!dep.startsWith(appDir)) continue;
|
|
343
|
+
visited.add(dep);
|
|
344
|
+
queue.push(dep);
|
|
345
|
+
}
|
|
252
346
|
}
|
|
253
347
|
}
|
|
254
348
|
return visited;
|
|
@@ -260,7 +354,7 @@ export function reachableFromEntries(graph, entryFiles, appDir) {
|
|
|
260
354
|
* @param {string} appDir
|
|
261
355
|
* @param {ModuleGraph} graph
|
|
262
356
|
*/
|
|
263
|
-
async function walk(dir, appDir, graph, seen) {
|
|
357
|
+
async function walk(dir, appDir, graph, seen, dynamic, bare) {
|
|
264
358
|
let entries;
|
|
265
359
|
try { entries = await readdir(dir, { withFileTypes: true }); }
|
|
266
360
|
catch { return; }
|
|
@@ -278,9 +372,9 @@ async function walk(dir, appDir, graph, seen) {
|
|
|
278
372
|
if (e.name.startsWith('.')) continue;
|
|
279
373
|
const full = join(dir, e.name);
|
|
280
374
|
if (e.isDirectory()) {
|
|
281
|
-
await walk(full, appDir, graph, seen);
|
|
375
|
+
await walk(full, appDir, graph, seen, dynamic, bare);
|
|
282
376
|
} else if (/\.(js|ts|mjs|mts)$/.test(e.name)) {
|
|
283
|
-
await parseFile(full, appDir, graph, seen);
|
|
377
|
+
await parseFile(full, appDir, graph, seen, dynamic, bare);
|
|
284
378
|
}
|
|
285
379
|
}
|
|
286
380
|
}
|
|
@@ -308,7 +402,7 @@ export function _parseCacheHas(file) { return PARSE_CACHE.has(file); }
|
|
|
308
402
|
* @param {string} appDir
|
|
309
403
|
* @param {ModuleGraph} graph
|
|
310
404
|
*/
|
|
311
|
-
async function parseFile(file, appDir, graph, seen) {
|
|
405
|
+
async function parseFile(file, appDir, graph, seen, dynamic, bare) {
|
|
312
406
|
let mtimeMs, size;
|
|
313
407
|
try { const st = await stat(file); mtimeMs = st.mtimeMs; size = st.size; }
|
|
314
408
|
catch { return; }
|
|
@@ -316,6 +410,8 @@ async function parseFile(file, appDir, graph, seen) {
|
|
|
316
410
|
const cached = PARSE_CACHE.get(file);
|
|
317
411
|
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
|
|
318
412
|
if (cached.deps.size) graph.set(file, cached.deps);
|
|
413
|
+
if (cached.dynDeps && cached.dynDeps.size && dynamic) dynamic.set(file, cached.dynDeps);
|
|
414
|
+
if (cached.bareDeps && cached.bareDeps.size && bare) bare.set(file, cached.bareDeps);
|
|
319
415
|
return;
|
|
320
416
|
}
|
|
321
417
|
|
|
@@ -343,24 +439,72 @@ async function parseFile(file, appDir, graph, seen) {
|
|
|
343
439
|
// code-example `import` string never becomes an edge.
|
|
344
440
|
const masked = redactStringsAndTemplates(src, true);
|
|
345
441
|
const deps = new Set();
|
|
442
|
+
/** @type {Set<string>} bare npm vendor specifiers imported by this file (#754) */
|
|
443
|
+
const bareDeps = new Set();
|
|
346
444
|
for (const re of [IMPORT_RE, EXPORT_FROM_RE]) {
|
|
347
445
|
for (const m of src.matchAll(re)) {
|
|
348
446
|
// m.index is the keyword start (`\bimport` / `\bexport`). If that
|
|
349
447
|
// position is blanked in the mask, the match lives inside a literal
|
|
350
448
|
// and is not a real import edge.
|
|
351
449
|
if (masked[m.index] === ' ') continue;
|
|
450
|
+
// A statement-leading `import type { X }` / `import type * as X` /
|
|
451
|
+
// `import type Foo` (default type import) / `export type { X }` /
|
|
452
|
+
// `export type *` is fully erased by the TS stripper, so it never becomes
|
|
453
|
+
// a browser fetch or a runtime edge. Skip it, so a type-only import of a
|
|
454
|
+
// `.server.ts` (a common, correct way to share a derived row type) is not
|
|
455
|
+
// mistaken for a runtime dependency by the gate / elision / the
|
|
456
|
+
// no-server-import check. Two carve-outs keep this from over-dropping:
|
|
457
|
+
// - `import type from '...'` is a VALUE default import whose binding is
|
|
458
|
+
// literally named `type` (the stripper keeps it), so `type` directly
|
|
459
|
+
// followed by `from` is NOT type-only.
|
|
460
|
+
// - a MIXED `import { type A, b }` does NOT lead with `type`, so it is
|
|
461
|
+
// kept (its `b` binding is a real runtime edge).
|
|
462
|
+
// #805
|
|
463
|
+
const lead = m[0];
|
|
464
|
+
const typeOnly =
|
|
465
|
+
/^(?:import|export)\s+type\s*[{*]/.test(lead) ||
|
|
466
|
+
(/^import\s+type\s+[A-Za-z_$]/.test(lead) && !/^import\s+type\s+from\b/.test(lead));
|
|
467
|
+
if (typeOnly) continue;
|
|
352
468
|
const spec = m[1];
|
|
469
|
+
// Guard a match whose `from '<spec>'` tail reaches INTO a blanked literal:
|
|
470
|
+
// EXPORT_FROM_RE's lazy `[^'";]+?` can span a template body to a `from`
|
|
471
|
+
// written inside example code (`export const t = html\`...import x from
|
|
472
|
+
// 'left-pad'\``), so the KEYWORD is real but the SPECIFIER is not. A real
|
|
473
|
+
// string keeps its delimiters in the mask (only the body blanks), while a
|
|
474
|
+
// template body blanks whole, so a blanked opening quote means the
|
|
475
|
+
// specifier is inside a literal and the match is spurious.
|
|
476
|
+
const quoteAt = m.index + m[0].length - spec.length - 2;
|
|
477
|
+
if (masked[quoteAt] === ' ') continue;
|
|
353
478
|
// Only resolve relative imports + `#`-style subpath aliases (#555)
|
|
354
479
|
// within the project. A bare npm specifier (dayjs) has no alias match
|
|
355
|
-
// and is skipped
|
|
356
|
-
//
|
|
357
|
-
|
|
480
|
+
// and is skipped FROM THE GRAPH, but recorded as a vendor edge so SSR
|
|
481
|
+
// can emit a modulepreload for the reached vendor URL (#754); an aliased
|
|
482
|
+
// `#lib/x.server.ts` IS followed so the graph / auth gate / elision see
|
|
483
|
+
// the real path through the alias.
|
|
484
|
+
if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) {
|
|
485
|
+
if (isVendorSpecifier(spec)) bareDeps.add(spec);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
358
488
|
const resolved = resolveImport(spec, file, appDir);
|
|
359
489
|
if (resolved) deps.add(resolved);
|
|
360
490
|
}
|
|
361
491
|
}
|
|
362
|
-
|
|
492
|
+
// Dynamic `import('…')` with a string-literal specifier (#751): a separate
|
|
493
|
+
// edge class so the gate admits the lazily-loaded module (no 404) without
|
|
494
|
+
// preloading it. Same redaction-mask + alias rules as the static scan. A
|
|
495
|
+
// target already statically imported is not duplicated as a dynamic edge.
|
|
496
|
+
const dynDeps = new Set();
|
|
497
|
+
for (const m of src.matchAll(DYNAMIC_IMPORT_RE)) {
|
|
498
|
+
if (masked[m.index] === ' ') continue;
|
|
499
|
+
const spec = m[1];
|
|
500
|
+
if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) continue;
|
|
501
|
+
const resolved = resolveImport(spec, file, appDir);
|
|
502
|
+
if (resolved && !deps.has(resolved)) dynDeps.add(resolved);
|
|
503
|
+
}
|
|
504
|
+
PARSE_CACHE.set(file, { mtimeMs, size, deps, dynDeps, bareDeps });
|
|
363
505
|
if (deps.size) graph.set(file, deps);
|
|
506
|
+
if (dynDeps.size && dynamic) dynamic.set(file, dynDeps);
|
|
507
|
+
if (bareDeps.size && bare) bare.set(file, bareDeps);
|
|
364
508
|
}
|
|
365
509
|
|
|
366
510
|
/**
|
package/src/page-action.js
CHANGED
|
@@ -2,6 +2,7 @@ import { isNotFound, isRedirect } from '@webjsdev/core';
|
|
|
2
2
|
import { ssrPage, ssrNotFound, loadModule } from './ssr.js';
|
|
3
3
|
import { readBytesBounded, payloadTooLarge, DEFAULT_MAX_MULTIPART_BYTES } from './body-limit.js';
|
|
4
4
|
import { getBodyLimits } from './context.js';
|
|
5
|
+
import { propagateTrustedRemoteIp } from './rate-limit.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Page server actions: a `page.{js,ts}` may export an `action` function that
|
|
@@ -133,12 +134,20 @@ async function parseFormBody(req) {
|
|
|
133
134
|
if (tooLarge) return { tooLarge: true, formData: new FormData(), request: req };
|
|
134
135
|
|
|
135
136
|
// Rebuild a fresh Request from the bytes so the action can re-read the body.
|
|
137
|
+
// SECURITY (#756): strip any inbound `x-webjs-remote-ip` the copy carried so a
|
|
138
|
+
// client cannot spoof it through the rebuild, and carry the FRAMEWORK-trusted
|
|
139
|
+
// remote IP forward out of band (the rebuild is a new object, so the listener's
|
|
140
|
+
// WeakMap stamp on `req` does not follow it). Without this, `clientIp` inside a
|
|
141
|
+
// page `action` (the no-JS form write path, e.g. login throttling) would read
|
|
142
|
+
// the spoofable header on Bun.
|
|
136
143
|
const headers = new Headers(req.headers);
|
|
144
|
+
headers.delete('x-webjs-remote-ip');
|
|
137
145
|
const rebuilt = new Request(req.url, {
|
|
138
146
|
method: req.method,
|
|
139
147
|
headers,
|
|
140
148
|
body: bytes && bytes.byteLength ? bytes : undefined,
|
|
141
149
|
});
|
|
150
|
+
propagateTrustedRemoteIp(req, rebuilt);
|
|
142
151
|
|
|
143
152
|
const isForm = /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
|
|
144
153
|
let formData = new FormData();
|
package/src/rate-limit.js
CHANGED
|
@@ -89,13 +89,68 @@ export function rateLimit(opts = {}) {
|
|
|
89
89
|
*/
|
|
90
90
|
const REMOTE_IP_HEADER = 'x-webjs-remote-ip';
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Out-of-band trusted remote IP (#756). A listener shell that already holds the
|
|
94
|
+
* socket IP can stamp it here instead of reallocating a whole `Request` just to
|
|
95
|
+
* set the `x-webjs-remote-ip` header (the Bun hot path). When an entry exists
|
|
96
|
+
* for a request it is AUTHORITATIVE: `trustedRemoteIp` returns it and ignores
|
|
97
|
+
* the inbound header entirely, so a client-spoofed `x-webjs-remote-ip` is never
|
|
98
|
+
* trusted even though the original (unmodified) request still carries it.
|
|
99
|
+
* @type {WeakMap<Request, string>}
|
|
100
|
+
*/
|
|
101
|
+
const trustedIpMap = new WeakMap();
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Stamp the trusted remote IP for a request out-of-band (no Request clone).
|
|
105
|
+
* Pass `''` (or a falsy value) to mark the request as listener-stamped with no
|
|
106
|
+
* known IP, which still makes the inbound header untrusted (resolves to the
|
|
107
|
+
* anon fallback rather than a spoofable header value).
|
|
108
|
+
* @param {Request} req
|
|
109
|
+
* @param {string | null | undefined} ip
|
|
110
|
+
*/
|
|
111
|
+
export function setTrustedRemoteIp(req, ip) {
|
|
112
|
+
trustedIpMap.set(req, ip || '');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The framework-trusted remote IP for a request: the out-of-band WeakMap value
|
|
117
|
+
* when a listener stamped it (authoritative, header ignored), else the
|
|
118
|
+
* framework-stamped `x-webjs-remote-ip` header (the node / embedded path).
|
|
119
|
+
* @param {Request} req
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
function trustedRemoteIp(req) {
|
|
123
|
+
if (trustedIpMap.has(req)) return trustedIpMap.get(req) || '';
|
|
124
|
+
return req.headers.get(REMOTE_IP_HEADER) || '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Carry the trusted remote IP from `src` to a freshly-rebuilt `dst` Request
|
|
129
|
+
* (#756). When the framework re-wraps a request (the page-action body rebuild in
|
|
130
|
+
* `page-action.js`), the out-of-band WeakMap key is a new object, so the trusted
|
|
131
|
+
* IP would be lost and `clientIp(dst)` would fall back to the (spoofable) header
|
|
132
|
+
* the rebuild copied over. Propagating the trusted value via the WeakMap keeps
|
|
133
|
+
* `dst` authoritative on BOTH runtimes (it reads `trustedRemoteIp(src)`, which is
|
|
134
|
+
* the WeakMap value on Bun and the framework-stamped header on Node). The caller
|
|
135
|
+
* must ALSO strip the inbound `x-webjs-remote-ip` header from `dst` so a client
|
|
136
|
+
* copy can never win. Idempotent and safe to call on any rebuild.
|
|
137
|
+
* @param {Request} src
|
|
138
|
+
* @param {Request} dst
|
|
139
|
+
*/
|
|
140
|
+
export function propagateTrustedRemoteIp(src, dst) {
|
|
141
|
+
setTrustedRemoteIp(dst, trustedRemoteIp(src));
|
|
142
|
+
}
|
|
143
|
+
|
|
92
144
|
/**
|
|
93
145
|
* Resolve the client IP for rate-limit bucket keying.
|
|
94
146
|
*
|
|
95
147
|
* `trustProxy: false` (default, safe everywhere): read ONLY the
|
|
96
|
-
* framework-
|
|
97
|
-
* the
|
|
98
|
-
* any inbound copy via `toWebRequest
|
|
148
|
+
* framework-trusted remote IP. Under `startServer` the framework derives it from
|
|
149
|
+
* the actual TCP socket: the node shell stamps the `x-webjs-remote-ip` header
|
|
150
|
+
* (stripping any inbound copy via `toWebRequest`), and the Bun shell stamps it
|
|
151
|
+
* OUT OF BAND via `setTrustedRemoteIp` (a WeakMap, no per-request Request clone,
|
|
152
|
+
* #756) which `trustedRemoteIp` reads in preference to (and to the exclusion of)
|
|
153
|
+
* the header, so a client cannot spoof it either way.
|
|
99
154
|
* Under `createRequestHandler` (embedded use) the host adapter MUST
|
|
100
155
|
* call `stampRemoteIp(req, remoteAddress)` first, otherwise the
|
|
101
156
|
* adapter may pass forged inbound headers straight through and the
|
|
@@ -122,11 +177,11 @@ export function clientIp(req, opts = {}) {
|
|
|
122
177
|
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
|
123
178
|
req.headers.get('cf-connecting-ip') ||
|
|
124
179
|
req.headers.get('x-real-ip') ||
|
|
125
|
-
req
|
|
180
|
+
trustedRemoteIp(req) ||
|
|
126
181
|
'_anon_'
|
|
127
182
|
);
|
|
128
183
|
}
|
|
129
|
-
return req
|
|
184
|
+
return trustedRemoteIp(req) || '_anon_';
|
|
130
185
|
}
|
|
131
186
|
|
|
132
187
|
/**
|
package/src/router.js
CHANGED
|
@@ -158,7 +158,7 @@ export async function buildRouteTable(appDir) {
|
|
|
158
158
|
a.middlewares = chainDirs.map((d) => middlewares.get(d)).filter(Boolean);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
pages.sort(
|
|
161
|
+
pages.sort(compareSpecificity);
|
|
162
162
|
return { pages, apis, notFound, notFounds, metadataRoutes, appDir };
|
|
163
163
|
}
|
|
164
164
|
|
|
@@ -191,11 +191,68 @@ function isUrlSegment(seg) {
|
|
|
191
191
|
return true;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Per-URL-segment specificity kind: 0 = static literal, 1 = dynamic `[x]`,
|
|
196
|
+
* 2 = catch-all `[...x]` / `[[...x]]`. Lower is MORE specific. Checks the
|
|
197
|
+
* catch-all forms before the bare `[` (a `[[...x]]` also starts with `[`).
|
|
198
|
+
* @param {string} seg
|
|
199
|
+
* @returns {0 | 1 | 2}
|
|
200
|
+
*/
|
|
201
|
+
function segKind(seg) {
|
|
202
|
+
if (seg.startsWith('[[...') || seg.startsWith('[...')) return 2;
|
|
203
|
+
if (seg.startsWith('[') && seg.endsWith(']')) return 1;
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The ordered URL segments of a route (route groups + private folders removed),
|
|
209
|
+
* which is the basis for positional specificity.
|
|
210
|
+
* @param {string} routeDir
|
|
211
|
+
* @returns {string[]}
|
|
212
|
+
*/
|
|
213
|
+
function urlSegmentsOf(routeDir) {
|
|
214
|
+
return (routeDir === '.' ? [] : routeDir.split('/')).filter(isUrlSegment);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Deterministic route specificity ordering (#750). Replaces the old coarse
|
|
219
|
+
* 3-bucket score (static=1 / dynamic=2 / catch-all=3) whose same-bucket ties
|
|
220
|
+
* resolved by filesystem walk order, so two overlapping dynamic routes
|
|
221
|
+
* (`/[org]/[repo]` vs `/[user]/settings`) could match the WRONG page depending
|
|
222
|
+
* on traversal order. Specificity is fully POSITIONAL, like Next.js's router:
|
|
223
|
+
* compare segment by segment, and the catch-all kind is just the lowest-priority
|
|
224
|
+
* kind AT ITS POSITION, NOT a global "always last" bucket. The contract, most
|
|
225
|
+
* specific first:
|
|
226
|
+
* 1. Compare segment by segment: at the same position a static literal (kind 0)
|
|
227
|
+
* outranks a dynamic `[x]` (kind 1), which outranks a catch-all
|
|
228
|
+
* `[...x]` / `[[...x]]` (kind 2). So `/[user]/settings` beats `/[org]/[repo]`
|
|
229
|
+
* (static tail wins), AND `/docs/[[...slug]]` beats `/[org]/[repo]` (a
|
|
230
|
+
* literal first segment outranks a dynamic one, even though the former ends
|
|
231
|
+
* in a catch-all). A global catch-all-last rule would wrongly shadow the
|
|
232
|
+
* literal-prefixed catch-all behind every all-dynamic route.
|
|
233
|
+
* 2. With an identical kind prefix, the SHORTER (prefix) route ranks first, so
|
|
234
|
+
* an explicit `/docs` beats the optional-catch-all base `/docs/[[...slug]]`
|
|
235
|
+
* for `/docs` (the optional catch-all also matches zero trailing segments,
|
|
236
|
+
* so the shorter exact route is the more specific match). Standard
|
|
237
|
+
* lexicographic order on the kind arrays.
|
|
238
|
+
* 3. A genuine tie (identical kinds + length, e.g. `/[a]/[b]` vs `/[c]/[d]`)
|
|
239
|
+
* resolves by an alphabetical `routeDir` key, NOT walk order, so the match
|
|
240
|
+
* is deterministic across environments.
|
|
241
|
+
*
|
|
242
|
+
* The result is a valid total order (lexicographic on kind arrays with a unique
|
|
243
|
+
* final tiebreak), so `Array.sort` is correct and deterministic regardless of
|
|
244
|
+
* the filesystem traversal order.
|
|
245
|
+
* @param {PageRoute} a
|
|
246
|
+
* @param {PageRoute} b
|
|
247
|
+
* @returns {number}
|
|
248
|
+
*/
|
|
249
|
+
export function compareSpecificity(a, b) {
|
|
250
|
+
const sa = urlSegmentsOf(a.routeDir).map(segKind);
|
|
251
|
+
const sb = urlSegmentsOf(b.routeDir).map(segKind);
|
|
252
|
+
const n = Math.min(sa.length, sb.length);
|
|
253
|
+
for (let i = 0; i < n; i++) if (sa[i] !== sb[i]) return sa[i] - sb[i];
|
|
254
|
+
if (sa.length !== sb.length) return sa.length - sb.length; // shorter (prefix) first
|
|
255
|
+
return a.routeDir < b.routeDir ? -1 : a.routeDir > b.routeDir ? 1 : 0;
|
|
199
256
|
}
|
|
200
257
|
|
|
201
258
|
/**
|
package/src/ssr.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
|
|
4
|
-
import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath, vendorPreconnectOrigins } from './importmap.js';
|
|
4
|
+
import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath, vendorPreconnectOrigins, vendorPreloadTargets } from './importmap.js';
|
|
5
5
|
import { withBasePath } from './base-path.js';
|
|
6
6
|
import { withAssetHash } from './asset-hash.js';
|
|
7
7
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
8
|
-
import { transitiveDeps } from './module-graph.js';
|
|
8
|
+
import { transitiveDeps, bareImports } from './module-graph.js';
|
|
9
9
|
import { seedingEnabled, collectSeeds, buildSeedScript } from './action-seed.js';
|
|
10
10
|
import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
|
|
11
11
|
import {
|
|
@@ -189,6 +189,26 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
189
189
|
opts.serverFiles,
|
|
190
190
|
opts.elidableComponents,
|
|
191
191
|
);
|
|
192
|
+
// Vendor modulepreload (#754): flatten the npm CDN waterfall by hinting the
|
|
193
|
+
// vendor URLs the page's SHIPPED modules actually import, fetched in parallel
|
|
194
|
+
// instead of discovered level by level. The walk roots are the BOOT's shipped
|
|
195
|
+
// module set (`moduleUrls`, which already drops inert page/layout modules and
|
|
196
|
+
// substitutes an import-only page with its components) plus the rendered
|
|
197
|
+
// components, so a vendor reached ONLY through a dropped module (a page's
|
|
198
|
+
// SSR-only direct import OR its SSR-only relative helper) is never preloaded
|
|
199
|
+
// (no over-fetch).
|
|
200
|
+
const shippedRoots = moduleUrls.map((u) =>
|
|
201
|
+
resolve(opts.appDir, u.startsWith('/') ? u.slice(1) : u));
|
|
202
|
+
const vendorPreloads = vendorPreloadTargets(
|
|
203
|
+
reachedVendorSpecifiers(
|
|
204
|
+
opts.moduleGraph,
|
|
205
|
+
shippedRoots,
|
|
206
|
+
eagerComponents,
|
|
207
|
+
opts.appDir,
|
|
208
|
+
opts.elidableComponents,
|
|
209
|
+
opts.serverFiles,
|
|
210
|
+
),
|
|
211
|
+
);
|
|
192
212
|
// Extract CSP nonce from request headers (if present).
|
|
193
213
|
const nonce = opts.req ? getNonce(opts.req) : undefined;
|
|
194
214
|
const wrapOpts = {
|
|
@@ -197,6 +217,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
197
217
|
dev: opts.dev,
|
|
198
218
|
streaming: suspenseCtx.pending.length > 0,
|
|
199
219
|
preloads,
|
|
220
|
+
vendorPreloads,
|
|
200
221
|
lazyComponents,
|
|
201
222
|
nonce,
|
|
202
223
|
};
|
|
@@ -1232,6 +1253,26 @@ function wrapHead(opts) {
|
|
|
1232
1253
|
`${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
|
|
1233
1254
|
);
|
|
1234
1255
|
}
|
|
1256
|
+
// Vendor modulepreload (#754): the npm CDN dependencies the page reaches,
|
|
1257
|
+
// hinted up front to flatten the cross-origin waterfall. The href comes
|
|
1258
|
+
// straight from the importmap target (byte-identical, no `fp()` rewrite, so
|
|
1259
|
+
// the browser does not double-fetch) and carries the importmap's `integrity`.
|
|
1260
|
+
// `preloadCrossOriginAttr` adds `crossorigin` for a cross-origin CDN url (a
|
|
1261
|
+
// same-origin pinned `/__webjs/vendor/*` url gets none). Deduped against the
|
|
1262
|
+
// app module/component preloads already emitted above.
|
|
1263
|
+
const emittedPreloadHrefs = new Set([
|
|
1264
|
+
...opts.moduleUrls.map((u) => fp(u)),
|
|
1265
|
+
...(opts.preloads || []).map((u) => fp(u)),
|
|
1266
|
+
]);
|
|
1267
|
+
for (const v of opts.vendorPreloads || []) {
|
|
1268
|
+
if (emittedPreloadHrefs.has(v.href)) continue;
|
|
1269
|
+
emittedPreloadHrefs.add(v.href);
|
|
1270
|
+
const integrity = v.integrity ? ` integrity="${escapeAttr(v.integrity)}"` : '';
|
|
1271
|
+
linkTags.push(
|
|
1272
|
+
`<link rel="modulepreload" href="${escapeAttr(v.href)}"` +
|
|
1273
|
+
`${preloadCrossOriginAttr(v.href)}${integrity}${noncePreload}>`,
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1235
1276
|
if (Array.isArray(m.preload)) {
|
|
1236
1277
|
for (const p of m.preload) {
|
|
1237
1278
|
if (!p || !p.href) continue;
|
|
@@ -1493,6 +1534,63 @@ function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appD
|
|
|
1493
1534
|
return result;
|
|
1494
1535
|
}
|
|
1495
1536
|
|
|
1537
|
+
/**
|
|
1538
|
+
* Collect the bare npm vendor specifiers the page's SHIPPED modules import
|
|
1539
|
+
* (#754). The walk ROOTS are the boot's actually-shipped module set: the caller
|
|
1540
|
+
* passes `entryFiles` = the absolute paths of `moduleUrls` (which already drops
|
|
1541
|
+
* inert page/layout modules and substitutes an import-only page with its
|
|
1542
|
+
* components), plus `componentUrls` = the rendered components. From those roots
|
|
1543
|
+
* it walks the transitive app-graph closure (elidable components and the subtree
|
|
1544
|
+
* reachable only through them excluded), and collects each reached file's bare
|
|
1545
|
+
* imports, excluding server files. The specifiers are resolved to `modulepreload`
|
|
1546
|
+
* targets via the vendor importmap; a specifier not in the map (unpinned /
|
|
1547
|
+
* unreached) drops out there.
|
|
1548
|
+
*
|
|
1549
|
+
* Because the roots are the SHIPPED set, a vendor reached ONLY through a module
|
|
1550
|
+
* dropped from the boot, whether a dropped page's SSR-only DIRECT vendor import
|
|
1551
|
+
* or its SSR-only RELATIVE HELPER's vendor, is never collected: the dropped
|
|
1552
|
+
* module is not a root, and nothing that ships imports it (pages/layouts are not
|
|
1553
|
+
* importable). So the canonical SSR-only-dependency pattern (which elision keeps
|
|
1554
|
+
* off the client) is never preloaded (no over-fetch).
|
|
1555
|
+
*
|
|
1556
|
+
* @param {import('./module-graph.js').ModuleGraph | undefined} graph
|
|
1557
|
+
* @param {string[]} entryFiles absolute paths of the SHIPPED page/layout modules (from `moduleUrls`)
|
|
1558
|
+
* @param {string[]} componentUrls rendered eager component URL paths
|
|
1559
|
+
* @param {string} appDir
|
|
1560
|
+
* @param {Set<string>} [elidableComponents]
|
|
1561
|
+
* @param {Set<string>} [serverFiles] the action / server-file index (`'use server'`, incl. no-`.server.` files)
|
|
1562
|
+
* @returns {Set<string>}
|
|
1563
|
+
*/
|
|
1564
|
+
function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir, elidableComponents, serverFiles) {
|
|
1565
|
+
/** @type {Set<string>} */
|
|
1566
|
+
const specs = new Set();
|
|
1567
|
+
if (!graph) return specs;
|
|
1568
|
+
const bare = bareImports(graph);
|
|
1569
|
+
if (!bare.size) return specs;
|
|
1570
|
+
// Roots = the SHIPPED page/layout modules (already inert-dropped + import-only
|
|
1571
|
+
// expanded by the caller) + the rendered components, keyed by the graph's own
|
|
1572
|
+
// absolute paths. Walk their non-elided transitive closure.
|
|
1573
|
+
const allEntries = [...entryFiles];
|
|
1574
|
+
for (const url of componentUrls) {
|
|
1575
|
+
allEntries.push(resolve(appDir, url.startsWith('/') ? url.slice(1) : url));
|
|
1576
|
+
}
|
|
1577
|
+
const files = new Set(allEntries);
|
|
1578
|
+
for (const dep of transitiveDeps(graph, allEntries, appDir, elidableComponents)) files.add(dep);
|
|
1579
|
+
for (const file of files) {
|
|
1580
|
+
// A server file is never served to the browser (its source is an RPC /
|
|
1581
|
+
// throw-at-load stub), so a vendor it imports never ships and must NOT be
|
|
1582
|
+
// preloaded. `transitiveDeps` stops AT a server-file boundary but still
|
|
1583
|
+
// returns the boundary file itself, so filter it: the `.server.*` suffix
|
|
1584
|
+
// AND the action index (a `'use server'` file without the suffix), matching
|
|
1585
|
+
// `deduplicatedPreloads`' `byIndex` filter.
|
|
1586
|
+
if (/\.server\.m?[jt]s$/.test(file)) continue;
|
|
1587
|
+
if (serverFiles && serverFiles.has && serverFiles.has(file)) continue;
|
|
1588
|
+
const fileBare = bare.get(file);
|
|
1589
|
+
if (fileBare) for (const spec of fileBare) specs.add(spec);
|
|
1590
|
+
}
|
|
1591
|
+
return specs;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1496
1594
|
/**
|
|
1497
1595
|
* Build a streaming Response. Degrades to a single-flush response when
|
|
1498
1596
|
* there are no pending Suspense boundaries.
|
package/src/testing.d.ts
CHANGED
|
@@ -103,3 +103,13 @@ export declare function rawActionRequest(
|
|
|
103
103
|
contentType?: string;
|
|
104
104
|
},
|
|
105
105
|
): Promise<Response>;
|
|
106
|
+
|
|
107
|
+
/** Browser-test harness (#806): a webjs handler a web-test-runner config
|
|
108
|
+
* proxies module requests to, so a browser test can import a real `.ts`
|
|
109
|
+
* component that imports a `'use server'` action. `importmapHtml()` is the
|
|
110
|
+
* `<script type="importmap">` to inject into the test page (call after warmup). */
|
|
111
|
+
export declare function createBrowserTestHandler(appDir: string): Promise<{
|
|
112
|
+
handle: Handle;
|
|
113
|
+
warmup: () => Promise<void>;
|
|
114
|
+
importmapHtml: () => string;
|
|
115
|
+
}>;
|
package/src/testing.js
CHANGED
|
@@ -295,9 +295,13 @@ export async function invokeActionForTest(app, serverFilePath, fnName, args = []
|
|
|
295
295
|
|
|
296
296
|
if (!res.ok && throwOnError) {
|
|
297
297
|
const msg = (parsed && parsed.error) || `webjs action ${fnName} -> ${res.status}`;
|
|
298
|
-
const err = /** @type {Error & { status?: number, response?: Response }} */ (new Error(msg));
|
|
298
|
+
const err = /** @type {Error & { status?: number, response?: Response, digest?: string }} */ (new Error(msg));
|
|
299
299
|
err.status = res.status;
|
|
300
300
|
err.response = res;
|
|
301
|
+
// Prod sanitization (#749) returns a generic message + a correlation digest;
|
|
302
|
+
// surface the digest on the thrown error so a test (or a caller) can map it
|
|
303
|
+
// to the server log line, mirroring the generated client stub.
|
|
304
|
+
if (parsed && parsed.digest) err.digest = parsed.digest;
|
|
301
305
|
throw err;
|
|
302
306
|
}
|
|
303
307
|
return parsed;
|
|
@@ -344,3 +348,38 @@ export async function rawActionRequest(app, serverFilePath, fnName, args = [], o
|
|
|
344
348
|
|
|
345
349
|
return testRequest(handle, endpoint, { method: 'POST', headers, body });
|
|
346
350
|
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Browser-test harness (#806): build a webjs handler that a `web-test-runner`
|
|
354
|
+
* config can proxy module requests to, so a `test/**/browser/*.test.js` file
|
|
355
|
+
* can import a real `.ts` component that imports a `'use server'` action and it
|
|
356
|
+
* loads + hydrates in a real browser. Plain WTR serves raw TS with no webjs
|
|
357
|
+
* transform, so the component (and its `#`-alias / `.server.ts` imports) never
|
|
358
|
+
* resolves; this handler runs the SAME dev pipeline (TS strip, `.server.ts` ->
|
|
359
|
+
* RPC stub, `#` alias, `/__webjs/core/*`, the importmap) as `webjs dev`.
|
|
360
|
+
*
|
|
361
|
+
* Uses `testMode` so ANY app file under appDir is servable (a component a test
|
|
362
|
+
* imports is not route-reachable, so it is absent from the browser-bound graph
|
|
363
|
+
* the normal serve gate requires). The `.server.*` source guardrail is
|
|
364
|
+
* unchanged, so no server source is exposed. `importmapHtml()` returns the
|
|
365
|
+
* `<script type="importmap">` to inject into the test page (call after warmup,
|
|
366
|
+
* so the map is final).
|
|
367
|
+
*
|
|
368
|
+
* The dev handler + importmap builder are LAZILY imported so importing the rest
|
|
369
|
+
* of `@webjsdev/server/testing` stays light (they pull the full server + the
|
|
370
|
+
* `ws` WebSocket subsystem).
|
|
371
|
+
*
|
|
372
|
+
* @param {string} appDir the webjs app root (usually `process.cwd()`)
|
|
373
|
+
* @returns {Promise<{ handle: Handle, warmup: () => Promise<void>, importmapHtml: () => string }>}
|
|
374
|
+
*/
|
|
375
|
+
export async function createBrowserTestHandler(appDir) {
|
|
376
|
+
const { createRequestHandler } = await import('./dev.js');
|
|
377
|
+
const { importMapTag } = await import('./importmap.js');
|
|
378
|
+
const app = await createRequestHandler({ appDir, dev: true, testMode: true });
|
|
379
|
+
if (app.warmup) await app.warmup();
|
|
380
|
+
return {
|
|
381
|
+
handle: app.handle,
|
|
382
|
+
warmup: app.warmup || (async () => {}),
|
|
383
|
+
importmapHtml: () => importMapTag(),
|
|
384
|
+
};
|
|
385
|
+
}
|