@webjsdev/server 0.8.62 → 0.8.64
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 +32 -3
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +1 -1
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/rate-limit.js +50 -5
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- package/src/vendor.js +29 -2371
package/src/form-dispatch.js
CHANGED
|
@@ -561,13 +561,22 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
|
|
|
561
561
|
// the SUCCESS-result path below.
|
|
562
562
|
return new Response(null, { status: e.status || 307, headers: { location: e.url } });
|
|
563
563
|
}
|
|
564
|
+
// `route` and `params` ride along so the boundary renders inside its
|
|
565
|
+
// layouts, exactly as it does on the page-render path (#1298).
|
|
564
566
|
if (isNotFound(err)) {
|
|
565
|
-
|
|
567
|
+
// NEAREST-wins, like the page-render path (#848). `ssrOpts.notFoundFile`
|
|
568
|
+
// is the ROOT one only, so a nested form action throwing notFound()
|
|
569
|
+
// rendered the root boundary while the same throw from the page render
|
|
570
|
+
// rendered the nested one. Two paths, one url, different 404s.
|
|
571
|
+
const nearestNotFound = route.notFounds && route.notFounds.length
|
|
572
|
+
? route.notFounds[route.notFounds.length - 1]
|
|
573
|
+
: (ssrOpts.notFoundFile ?? null);
|
|
574
|
+
return ssrNotFound(nearestNotFound, { ...ssrOpts, req, url, route, params });
|
|
566
575
|
}
|
|
567
576
|
// forbidden()/unauthorized() from a form action render the same 403/401
|
|
568
577
|
// boundary as the page-render path (#848), not a generic 500.
|
|
569
|
-
if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url });
|
|
570
|
-
if (isUnauthorized(err)) return ssrUnauthorized(route, { ...ssrOpts, req, url });
|
|
578
|
+
if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url, route, params });
|
|
579
|
+
if (isUnauthorized(err)) return ssrUnauthorized(route, { ...ssrOpts, req, url, route, params });
|
|
571
580
|
if (typeof onError === 'function') onError(err);
|
|
572
581
|
return await formActionErrorResponse(err, ssrOpts.dev);
|
|
573
582
|
}
|
package/src/importmap.js
CHANGED
|
@@ -6,9 +6,12 @@ import { jsonForScriptTag } from './script-tag-json.js';
|
|
|
6
6
|
import { withBasePath } from './base-path.js';
|
|
7
7
|
import { withAssetHash } from './asset-hash.js';
|
|
8
8
|
|
|
9
|
-
// Local attribute escaper
|
|
10
|
-
// of truth for HTML attribute escaping in this package
|
|
11
|
-
// to avoid a cross-file dependency for one small helper
|
|
9
|
+
// Local attribute escaper, byte-identical to the pair in `ssr/escape.js`,
|
|
10
|
+
// which is the source of truth for HTML attribute escaping in this package.
|
|
11
|
+
// Kept inline to avoid a cross-file dependency for one small helper, so the
|
|
12
|
+
// two must stay in step: this one escapes an attribute in the served head
|
|
13
|
+
// (the importmap script's nonce), so a divergence here moves served bytes
|
|
14
|
+
// exactly as a divergence there would.
|
|
12
15
|
function escapeAttr(s) {
|
|
13
16
|
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
14
17
|
}
|
package/src/listener-core.js
CHANGED
|
@@ -365,8 +365,22 @@ export class SseHub {
|
|
|
365
365
|
}
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
-
/**
|
|
369
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Push a live-reload event to every open tab.
|
|
370
|
+
*
|
|
371
|
+
* The frame carries the change's `{ v, by, why }` classification (#1398) as a
|
|
372
|
+
* single-line JSON payload, matching `devError` below. The browser relay
|
|
373
|
+
* resolves ANY payload it cannot read to `reload`, and an absent or malformed
|
|
374
|
+
* verdict is emitted as `reload` here too, so the fail-safe holds on both ends
|
|
375
|
+
* of the wire and a tab running against a restarted server can never be
|
|
376
|
+
* skewed into a lighter response than the change deserves.
|
|
377
|
+
*
|
|
378
|
+
* @param {{ v?: string, by?: string, why?: string } | null} [verdict]
|
|
379
|
+
*/
|
|
380
|
+
reload(verdict) {
|
|
381
|
+
const v = verdict && typeof verdict.v === 'string' ? verdict : { v: 'reload' };
|
|
382
|
+
this._raw(`event: reload\ndata: ${JSON.stringify(v)}\n\n`);
|
|
383
|
+
}
|
|
370
384
|
|
|
371
385
|
/** Push a dev-error overlay frame (#264) to every open tab. @param {object} frame */
|
|
372
386
|
devError(frame) { this._raw(`event: webjs-error\ndata: ${JSON.stringify(frame)}\n\n`); }
|
package/src/module-graph.js
CHANGED
|
@@ -174,6 +174,28 @@ export function bareImports(graph) {
|
|
|
174
174
|
return BARE_EDGES.get(graph) || new Map();
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Bare (npm vendor) specifiers imported DYNAMICALLY (`await import('dayjs')`),
|
|
179
|
+
* kept apart from `BARE_EDGES` for the same reason `DYNAMIC_EDGES` is kept
|
|
180
|
+
* apart from the static graph: a lazily-imported vendor must RESOLVE when the
|
|
181
|
+
* import finally runs, so it belongs in the importmap, but it must not be
|
|
182
|
+
* preloaded (that would fetch a bundle the author deliberately deferred). So
|
|
183
|
+
* `reachableBareSpecifiers` (the importmap) unions both maps while
|
|
184
|
+
* `bareImports` (the #754 modulepreload source) stays static-only.
|
|
185
|
+
* @type {WeakMap<ModuleGraph, Map<string, Set<string>>>}
|
|
186
|
+
*/
|
|
187
|
+
const BARE_DYNAMIC_EDGES = new WeakMap();
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The dynamically-imported bare specifiers per file for a built graph, or an
|
|
191
|
+
* empty map if none.
|
|
192
|
+
* @param {ModuleGraph} graph
|
|
193
|
+
* @returns {Map<string, Set<string>>}
|
|
194
|
+
*/
|
|
195
|
+
export function dynamicBareImports(graph) {
|
|
196
|
+
return BARE_DYNAMIC_EDGES.get(graph) || new Map();
|
|
197
|
+
}
|
|
198
|
+
|
|
177
199
|
/**
|
|
178
200
|
* Every app source file walked while building a graph (#899). `graph` itself
|
|
179
201
|
* holds only files that HAVE imports, so a leaf (e.g. a no-import `.server.ts`)
|
|
@@ -224,12 +246,16 @@ export async function buildModuleGraph(appDir) {
|
|
|
224
246
|
const dynamic = new Map();
|
|
225
247
|
/** @type {Map<string, Set<string>>} bare vendor specifiers per file (#754) */
|
|
226
248
|
const bare = new Map();
|
|
249
|
+
/** @type {Map<string, Set<string>>} DYNAMICALLY-imported bare vendor specifiers
|
|
250
|
+
* per file: importmap-bound but never preloaded (#1399) */
|
|
251
|
+
const bareDynamic = new Map();
|
|
227
252
|
/** @type {Set<string>} every file walked this build (graph holds only files
|
|
228
253
|
* with deps, so a separate set is needed to know what is still live). */
|
|
229
254
|
const seen = new Set();
|
|
230
|
-
await walk(appDir, appDir, graph, seen, dynamic, bare);
|
|
255
|
+
await walk(appDir, appDir, graph, seen, dynamic, bare, bareDynamic);
|
|
231
256
|
if (dynamic.size) DYNAMIC_EDGES.set(graph, dynamic);
|
|
232
257
|
if (bare.size) BARE_EDGES.set(graph, bare);
|
|
258
|
+
if (bareDynamic.size) BARE_DYNAMIC_EDGES.set(graph, bareDynamic);
|
|
233
259
|
if (seen.size) SEEN_FILES.set(graph, seen);
|
|
234
260
|
// Evict parse-cache entries for files no longer in the tree (a rebuild after
|
|
235
261
|
// a rename or delete), so a long dev session does not accumulate dead
|
|
@@ -374,13 +400,82 @@ export function reachableFromEntries(graph, entryFiles, appDir) {
|
|
|
374
400
|
return visited;
|
|
375
401
|
}
|
|
376
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Bare npm vendor specifiers reachable from `entryFiles`, the importmap
|
|
405
|
+
* analogue of ssr.js's per-page `reachedVendorSpecifiers` (#754).
|
|
406
|
+
*
|
|
407
|
+
* Walks the SAME edges the authorization gate walks (static + dynamic, #751,
|
|
408
|
+
* stopping at `.server.*` boundaries) so the answer is exactly "what a browser
|
|
409
|
+
* could load from these entries", then unions each reached file's recorded
|
|
410
|
+
* bare edges, static AND dynamic. Both dynamic forms are included here even
|
|
411
|
+
* though `transitiveDeps` (preload) ignores them, for the same reason: a
|
|
412
|
+
* lazily imported module is not preloaded, but its bare specifier still has to
|
|
413
|
+
* resolve when the import finally runs, so it belongs in the importmap. That
|
|
414
|
+
* is why a dynamically-imported VENDOR gets its own map (`dynamicBareImports`)
|
|
415
|
+
* rather than joining `bareImports`, which feeds the preload hints.
|
|
416
|
+
*
|
|
417
|
+
* `.server.*` files are reached (the browser fetches a stub at that URL) but
|
|
418
|
+
* their bare imports are NOT collected: the source never ships, so a DB driver
|
|
419
|
+
* imported by a server file must never enter the importmap.
|
|
420
|
+
*
|
|
421
|
+
* `skip` files are neither collected nor traversed into, so a specifier
|
|
422
|
+
* reachable ONLY through an elided component drops out along with the subtree
|
|
423
|
+
* behind it (#197 / #170). Omit `skip` for the un-pruned superset the pin path
|
|
424
|
+
* needs.
|
|
425
|
+
*
|
|
426
|
+
* @param {ModuleGraph} graph
|
|
427
|
+
* @param {string[]} entryFiles absolute paths
|
|
428
|
+
* @param {string} appDir
|
|
429
|
+
* @param {Set<string>} [skip] absolute paths to exclude and not traverse
|
|
430
|
+
* @returns {Set<string>}
|
|
431
|
+
*/
|
|
432
|
+
export function reachableBareSpecifiers(graph, entryFiles, appDir, skip) {
|
|
433
|
+
/** @type {Set<string>} */
|
|
434
|
+
const specs = new Set();
|
|
435
|
+
const bare = BARE_EDGES.get(graph);
|
|
436
|
+
const bareDynamic = BARE_DYNAMIC_EDGES.get(graph);
|
|
437
|
+
if (!bare?.size && !bareDynamic?.size) return specs;
|
|
438
|
+
const dynamic = DYNAMIC_EDGES.get(graph);
|
|
439
|
+
/** @type {Set<string>} */
|
|
440
|
+
const visited = new Set();
|
|
441
|
+
/** @type {string[]} */
|
|
442
|
+
const queue = [];
|
|
443
|
+
for (const entry of entryFiles) {
|
|
444
|
+
if (!entry || !entry.startsWith(appDir)) continue;
|
|
445
|
+
if (skip && skip.has(entry)) continue;
|
|
446
|
+
visited.add(entry);
|
|
447
|
+
queue.push(entry);
|
|
448
|
+
}
|
|
449
|
+
while (queue.length) {
|
|
450
|
+
const file = /** @type {string} */ (queue.shift());
|
|
451
|
+
if (SERVER_FILE_RE.test(file)) continue;
|
|
452
|
+
for (const map of [bare, bareDynamic]) {
|
|
453
|
+
const fileBare = map?.get(file);
|
|
454
|
+
if (fileBare) for (const spec of fileBare) specs.add(spec);
|
|
455
|
+
}
|
|
456
|
+
const staticDeps = graph.get(file);
|
|
457
|
+
const dynDeps = dynamic && dynamic.get(file);
|
|
458
|
+
for (const set of [staticDeps, dynDeps]) {
|
|
459
|
+
if (!set) continue;
|
|
460
|
+
for (const dep of set) {
|
|
461
|
+
if (visited.has(dep)) continue;
|
|
462
|
+
if (!dep.startsWith(appDir)) continue;
|
|
463
|
+
if (skip && skip.has(dep)) continue;
|
|
464
|
+
visited.add(dep);
|
|
465
|
+
queue.push(dep);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return specs;
|
|
470
|
+
}
|
|
471
|
+
|
|
377
472
|
/**
|
|
378
473
|
* Recursively walk a directory, parse imports, and populate the graph.
|
|
379
474
|
* @param {string} dir
|
|
380
475
|
* @param {string} appDir
|
|
381
476
|
* @param {ModuleGraph} graph
|
|
382
477
|
*/
|
|
383
|
-
async function walk(dir, appDir, graph, seen, dynamic, bare) {
|
|
478
|
+
async function walk(dir, appDir, graph, seen, dynamic, bare, bareDynamic) {
|
|
384
479
|
let entries;
|
|
385
480
|
try { entries = await readdir(dir, { withFileTypes: true }); }
|
|
386
481
|
catch { return; }
|
|
@@ -398,9 +493,9 @@ async function walk(dir, appDir, graph, seen, dynamic, bare) {
|
|
|
398
493
|
if (e.name.startsWith('.')) continue;
|
|
399
494
|
const full = join(dir, e.name);
|
|
400
495
|
if (e.isDirectory()) {
|
|
401
|
-
await walk(full, appDir, graph, seen, dynamic, bare);
|
|
496
|
+
await walk(full, appDir, graph, seen, dynamic, bare, bareDynamic);
|
|
402
497
|
} else if (/\.(js|ts|mjs|mts)$/.test(e.name)) {
|
|
403
|
-
await parseFile(full, appDir, graph, seen, dynamic, bare);
|
|
498
|
+
await parseFile(full, appDir, graph, seen, dynamic, bare, bareDynamic);
|
|
404
499
|
}
|
|
405
500
|
}
|
|
406
501
|
}
|
|
@@ -428,7 +523,7 @@ export function _parseCacheHas(file) { return PARSE_CACHE.has(file); }
|
|
|
428
523
|
* @param {string} appDir
|
|
429
524
|
* @param {ModuleGraph} graph
|
|
430
525
|
*/
|
|
431
|
-
async function parseFile(file, appDir, graph, seen, dynamic, bare) {
|
|
526
|
+
async function parseFile(file, appDir, graph, seen, dynamic, bare, bareDynamic) {
|
|
432
527
|
let mtimeMs, size;
|
|
433
528
|
try { const st = await stat(file); mtimeMs = st.mtimeMs; size = st.size; }
|
|
434
529
|
catch { return; }
|
|
@@ -438,6 +533,7 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
|
|
|
438
533
|
if (cached.deps.size) graph.set(file, cached.deps);
|
|
439
534
|
if (cached.dynDeps && cached.dynDeps.size && dynamic) dynamic.set(file, cached.dynDeps);
|
|
440
535
|
if (cached.bareDeps && cached.bareDeps.size && bare) bare.set(file, cached.bareDeps);
|
|
536
|
+
if (cached.bareDynDeps && cached.bareDynDeps.size && bareDynamic) bareDynamic.set(file, cached.bareDynDeps);
|
|
441
537
|
return;
|
|
442
538
|
}
|
|
443
539
|
|
|
@@ -541,19 +637,28 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
|
|
|
541
637
|
// `,`/`)`), preserving the documented limitation.
|
|
542
638
|
const { redacted: holeAware, literals } = redactToPlaceholders(src);
|
|
543
639
|
const dynDeps = new Set();
|
|
640
|
+
const bareDynDeps = new Set();
|
|
544
641
|
for (const m of holeAware.matchAll(DYNAMIC_IMPORT_RE)) {
|
|
545
642
|
const token = m[1];
|
|
546
643
|
const pm = /^__STR_(\d+)__$/.exec(token);
|
|
547
644
|
const spec = pm ? literals[Number(pm[1])] : token;
|
|
548
645
|
if (spec == null) continue;
|
|
549
|
-
if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir))
|
|
646
|
+
if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) {
|
|
647
|
+
// A dynamically-imported VENDOR (`await import('dayjs')`). Not a graph
|
|
648
|
+
// edge (there is no local file to serve) and not a preload (lazy by
|
|
649
|
+
// intent), but it still needs an importmap entry or the import fails to
|
|
650
|
+
// resolve when it runs. Recorded in its own map for that one consumer.
|
|
651
|
+
if (isVendorSpecifier(spec)) bareDynDeps.add(spec);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
550
654
|
const resolved = resolveImport(spec, file, appDir);
|
|
551
655
|
if (resolved && !deps.has(resolved)) dynDeps.add(resolved);
|
|
552
656
|
}
|
|
553
|
-
PARSE_CACHE.set(file, { mtimeMs, size, deps, dynDeps, bareDeps });
|
|
657
|
+
PARSE_CACHE.set(file, { mtimeMs, size, deps, dynDeps, bareDeps, bareDynDeps });
|
|
554
658
|
if (deps.size) graph.set(file, deps);
|
|
555
659
|
if (dynDeps.size && dynamic) dynamic.set(file, dynDeps);
|
|
556
660
|
if (bareDeps.size && bare) bare.set(file, bareDeps);
|
|
661
|
+
if (bareDynDeps.size && bareDynamic) bareDynamic.set(file, bareDynDeps);
|
|
557
662
|
}
|
|
558
663
|
|
|
559
664
|
/**
|
package/src/rate-limit.js
CHANGED
|
@@ -40,6 +40,7 @@ let warnedProxyOverride = false;
|
|
|
40
40
|
* message?: string,
|
|
41
41
|
* store?: import('./cache.js').CacheStore,
|
|
42
42
|
* trustProxy?: boolean,
|
|
43
|
+
* clientIpHeader?: string,
|
|
43
44
|
* }} opts
|
|
44
45
|
* @returns {(req: Request, next: () => Promise<Response>) => Promise<Response>}
|
|
45
46
|
*/
|
|
@@ -50,13 +51,17 @@ export function rateLimit(opts = {}) {
|
|
|
50
51
|
const keyPrefix = typeof opts.key === 'string' ? opts.key : '';
|
|
51
52
|
const message = opts.message ?? 'Too Many Requests';
|
|
52
53
|
const trustProxy = opts.trustProxy === true;
|
|
54
|
+
// The header carrying the visitor, when the app knows which one that is.
|
|
55
|
+
// Inert without `trustProxy: true`, since naming a wire header to trust IS
|
|
56
|
+
// the trust decision and must not be grantable by a second option.
|
|
57
|
+
const header = typeof opts.clientIpHeader === 'string' ? opts.clientIpHeader : undefined;
|
|
53
58
|
// Use the provided store, or fall back to the global cache store.
|
|
54
59
|
// Whatever was set via `setStore()` at app startup (in-memory by default).
|
|
55
60
|
const store = opts.store || null;
|
|
56
61
|
|
|
57
62
|
return async function rateLimitMiddleware(req, next) {
|
|
58
63
|
const s = store || getStore();
|
|
59
|
-
const raw = keyFn ? await keyFn(req) : clientIp(req, { trustProxy });
|
|
64
|
+
const raw = keyFn ? await keyFn(req) : clientIp(req, { trustProxy, header });
|
|
60
65
|
const key = `rl:${keyPrefix}${raw}`;
|
|
61
66
|
|
|
62
67
|
const count = await s.increment(key, windowMs);
|
|
@@ -185,10 +190,42 @@ export function propagateTrustedRemoteIp(src, dst) {
|
|
|
185
190
|
* leaving them in disagreement buckets every visitor behind that proxy onto
|
|
186
191
|
* one key.
|
|
187
192
|
*
|
|
193
|
+
* `header` names the ONE forwarded header to trust, and it is the option a
|
|
194
|
+
* CDN deployment needs (#1389). The default chain reads the leftmost
|
|
195
|
+
* `X-Forwarded-For` entry first, which behind Cloudflare is CLOUDFLARE'S
|
|
196
|
+
* EGRESS address rather than the visitor: Cloudflare pins an egress IP per
|
|
197
|
+
* connection, so a limiter keyed on it gives one bucket per connection, which
|
|
198
|
+
* counts down convincingly and limits nobody. The visitor is in
|
|
199
|
+
* `CF-Connecting-IP`, and naming it here is how the app says so.
|
|
200
|
+
*
|
|
201
|
+
* The framework does NOT reorder the default chain to prefer that header,
|
|
202
|
+
* because which header is trustworthy is a property of the TOPOLOGY, not of
|
|
203
|
+
* the framework. Cloudflare overwrites `CF-Connecting-IP`, so it is
|
|
204
|
+
* unforgeable behind Cloudflare and forgeable everywhere else; preferring it
|
|
205
|
+
* globally would let a client on an nginx or bare-Railway deploy outrank the
|
|
206
|
+
* `X-Forwarded-For` the real proxy set. So the app names its header and owns
|
|
207
|
+
* the claim. When `header` is set it is the only forwarded header consulted,
|
|
208
|
+
* falling back to the stamped peer and then `_anon_`, and a blank value falls
|
|
209
|
+
* through rather than becoming a shared literal key.
|
|
210
|
+
*
|
|
188
211
|
* @param {Request} req
|
|
189
|
-
* @param {{ trustProxy?: boolean }} [opts]
|
|
212
|
+
* @param {{ trustProxy?: boolean, header?: string }} [opts]
|
|
190
213
|
* @returns {string}
|
|
191
214
|
*/
|
|
215
|
+
/**
|
|
216
|
+
* First entry of a forwarded-IP header, trimmed, or `''` when there is nothing
|
|
217
|
+
* usable. A blank value must FALL THROUGH rather than resolve: an empty string
|
|
218
|
+
* as a bucket key is one key shared by every visitor whose proxy sent the
|
|
219
|
+
* header empty, which is a limiter that throttles strangers together.
|
|
220
|
+
*
|
|
221
|
+
* @param {string | null | undefined} raw
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
function firstForwardedEntry(raw) {
|
|
225
|
+
if (!raw) return '';
|
|
226
|
+
return raw.split(',')[0].trim();
|
|
227
|
+
}
|
|
228
|
+
|
|
192
229
|
export function clientIp(req, opts = {}) {
|
|
193
230
|
if (opts.trustProxy === true && !proxyIsTrusted() && !warnedProxyOverride) {
|
|
194
231
|
warnedProxyOverride = true;
|
|
@@ -201,10 +238,18 @@ export function clientIp(req, opts = {}) {
|
|
|
201
238
|
);
|
|
202
239
|
}
|
|
203
240
|
if (opts.trustProxy === true && proxyIsTrusted()) {
|
|
241
|
+
if (opts.header) {
|
|
242
|
+
// One named header, and nothing else from the wire. A chain is still
|
|
243
|
+
// split on the comma so a proxy that appends to the named header cannot
|
|
244
|
+
// turn the key into a growing string, which would mint a fresh bucket per
|
|
245
|
+
// hop and reproduce the very failure this option exists to fix.
|
|
246
|
+
const named = req.headers.get(String(opts.header).toLowerCase());
|
|
247
|
+
return firstForwardedEntry(named) || trustedRemoteIp(req) || '_anon_';
|
|
248
|
+
}
|
|
204
249
|
return (
|
|
205
|
-
req.headers.get('x-forwarded-for')
|
|
206
|
-
req.headers.get('cf-connecting-ip') ||
|
|
207
|
-
req.headers.get('x-real-ip') ||
|
|
250
|
+
firstForwardedEntry(req.headers.get('x-forwarded-for')) ||
|
|
251
|
+
req.headers.get('cf-connecting-ip')?.trim() ||
|
|
252
|
+
req.headers.get('x-real-ip')?.trim() ||
|
|
208
253
|
trustedRemoteIp(req) ||
|
|
209
254
|
'_anon_'
|
|
210
255
|
);
|
package/src/router.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join, relative, sep, posix } from 'node:path';
|
|
2
2
|
import { walk } from './fs-walk.js';
|
|
3
|
+
import { findInstrumentationClient } from './instrumentation.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* @typedef {{
|
|
@@ -35,6 +36,7 @@ import { walk } from './fs-walk.js';
|
|
|
35
36
|
* globalError: string | null,
|
|
36
37
|
* globalNotFound: string | null,
|
|
37
38
|
* metadataRoutes: MetadataRoute[],
|
|
39
|
+
* instrumentationClient: string | null,
|
|
38
40
|
* appDir: string
|
|
39
41
|
* }} RouteTable
|
|
40
42
|
*/
|
|
@@ -185,7 +187,15 @@ export async function buildRouteTable(appDir) {
|
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
pages.sort(compareSpecificity);
|
|
188
|
-
|
|
190
|
+
// `instrumentation-client.*` is an app-ROOT convention file, not a router
|
|
191
|
+
// stem, but it is a browser-bound entry (the boot imports it first), so the
|
|
192
|
+
// table is where every consumer already looks for one. Resolved HERE rather
|
|
193
|
+
// than by each caller: it used to be attached by the dev server alone, and a
|
|
194
|
+
// second consumer that built its own table (the vendor scan) silently lost
|
|
195
|
+
// the entry, which inverted the pin-is-a-superset invariant. Deriving it in
|
|
196
|
+
// the one place that builds the table makes that class of miss impossible.
|
|
197
|
+
const instrumentationClient = await findInstrumentationClient(appDir);
|
|
198
|
+
return { pages, apis, notFound, notFounds, globalError, globalNotFound, metadataRoutes, instrumentationClient, appDir };
|
|
189
199
|
}
|
|
190
200
|
|
|
191
201
|
/**
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app-wide client-router opt-out flag (#629).
|
|
3
|
+
*
|
|
4
|
+
* Default ON (the framework's automatic-nav thesis): the router auto-enables in
|
|
5
|
+
* the browser when `@webjsdev/core` loads. `webjs.clientRouter: false` flips it
|
|
6
|
+
* off app-wide; the dev server reads the config at boot and on each rebuild and
|
|
7
|
+
* calls `setClientRouterEnabled`, and `wrapHead` then emits a
|
|
8
|
+
* `window.__WEBJS_CLIENT_ROUTER__=false` flag BEFORE the deferred boot module
|
|
9
|
+
* so the bundle's module-end auto-enable skips.
|
|
10
|
+
*
|
|
11
|
+
* A module-level switch (mirroring `setBasePath` / `setElisionFingerprint`) so
|
|
12
|
+
* no opt has to thread through every render path. Default true keeps every
|
|
13
|
+
* existing app and test byte-identical.
|
|
14
|
+
*
|
|
15
|
+
* It sits in its own module rather than beside its readers because it has TWO
|
|
16
|
+
* of them, in `head.js` and `render.js`, and parking it in either made the
|
|
17
|
+
* other import across what was otherwise a one-way edge. That was the second
|
|
18
|
+
* half of the ssr import cycle. The rule the pre-split file stated, that module
|
|
19
|
+
* state belongs with the code that uses and writes it, is unchanged for state
|
|
20
|
+
* with a single reader: `_metadataIconRoutes` still lives in `head.js`, whose
|
|
21
|
+
* `wrapHead` is the only thing that reads it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
let _clientRouterEnabled = true;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Set the app-wide client-router flag.
|
|
28
|
+
*
|
|
29
|
+
* @param {boolean} enabled anything but `false` enables
|
|
30
|
+
*/
|
|
31
|
+
export function setClientRouterEnabled(enabled) {
|
|
32
|
+
_clientRouterEnabled = enabled !== false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether the client router is enabled app-wide.
|
|
37
|
+
*
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
export function clientRouterEnabled() {
|
|
41
|
+
return _clientRouterEnabled;
|
|
42
|
+
}
|