@webjsdev/server 0.8.88 → 0.8.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/importmap.js CHANGED
@@ -4,7 +4,8 @@ import { createHash } from 'node:crypto';
4
4
  import { digestHex } from './crypto-utils.js';
5
5
  import { jsonForScriptTag } from './script-tag-json.js';
6
6
  import { withBasePath } from './base-path.js';
7
- import { withAssetHash } from './asset-hash.js';
7
+ import { withAssetHash, assetHashMisses } from './asset-hash.js';
8
+ import { buildEpoch, bumpBuildEpoch } from './build-epoch.js';
8
9
 
9
10
  // Local attribute escaper, byte-identical to the pair in `ssr/escape.js`,
10
11
  // which is the source of truth for HTML attribute escaping in this package.
@@ -32,6 +33,18 @@ function escapeAttr(s) {
32
33
  /** @type {Record<string, string>} */
33
34
  let _extraEntries = {};
34
35
 
36
+ /**
37
+ * Per-build memos (#1644), each `{ epoch, value }` keyed on `buildEpoch()`.
38
+ * Every setter in this module bumps the epoch, as do the asset-hash roots,
39
+ * the elision fingerprint, the hash-cache clear, and the dev rebuild.
40
+ * @type {{ epoch: number, value: { imports: Record<string, string>, integrity?: Record<string, string> } }}
41
+ */
42
+ let _mapMemo = { epoch: 0, value: { imports: {} } };
43
+ /** @type {{ epoch: number, value: string }} */
44
+ let _tagJsonMemo = { epoch: 0, value: '' };
45
+ /** @type {{ epoch: number, max: number, value: string[] }} */
46
+ let _preconnectMemo = { epoch: 0, max: -1, value: [] };
47
+
35
48
  /**
36
49
  * Browser importmap entries for the app's `package.json "imports"` subpath
37
50
  * aliases (#555), e.g. `{ "#lib/": "/lib/" }` for `"#lib/*": "./lib/*"`. Kept in lockstep
@@ -68,6 +81,9 @@ let _basePath = '';
68
81
  * @returns {Promise<void>}
69
82
  */
70
83
  export async function setBasePath(basePath) {
84
+ // Bumped BEFORE the await below so a render racing this setter cannot
85
+ // refill the importmap memo with the old base path (#1644).
86
+ bumpBuildEpoch();
71
87
  _basePath = basePath || '';
72
88
  _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
73
89
  }
@@ -137,6 +153,7 @@ export function importAliasBrowserEntries(importsMap, topLevelDirs = []) {
137
153
  * @returns {Promise<void>}
138
154
  */
139
155
  export async function setImportAliasEntries(entries) {
156
+ bumpBuildEpoch();
140
157
  _aliasEntries = entries && typeof entries === 'object' ? entries : {};
141
158
  _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
142
159
  }
@@ -160,6 +177,7 @@ let _vendorIntegrity = {};
160
177
  * @returns {Promise<void>}
161
178
  */
162
179
  export async function setVendorEntries(entries, integrity) {
180
+ bumpBuildEpoch();
163
181
  _extraEntries = entries;
164
182
  _vendorIntegrity = integrity || {};
165
183
  _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
@@ -296,11 +314,9 @@ export function vendorIntegrityFor(url) {
296
314
  * Initialized to the two minimum-safe defaults (the bare specifier
297
315
  * pointing at the browser source-mode entry and the catch-all prefix
298
316
  * pointing at `src/`) so any consumer that calls `buildImportMap()`
299
- * before `setCoreInstall` runs still gets a usable map. Pre-#118 the
300
- * legacy `coreMappings` were always derived from a boolean and so
301
- * could not be empty; this keeps that fail-open posture for embedded
302
- * SSR test helpers and one-shot tooling that imports `importmap.js`
303
- * without booting `dev.js`.
317
+ * before `setCoreInstall` runs still gets a usable map: a fail-open
318
+ * posture for embedded SSR test helpers and one-shot tooling that
319
+ * imports `importmap.js` without booting `dev.js`.
304
320
  *
305
321
  * @type {Record<string, string>}
306
322
  */
@@ -329,9 +345,7 @@ let _coreEntries = {
329
345
  * rebuilds today; if `@webjsdev/core/package.json` is edited in a
330
346
  * long-running dev session (e.g. workspace dev that runs a fresh
331
347
  * `npm run build:dist`), the derivation is refreshed on next server
332
- * restart, not on the watcher tick. Pre-#118 the legacy
333
- * `setCoreDistMode` had the same behaviour: only the dist-presence
334
- * boolean was watched, not the package.json itself.
348
+ * restart, not on the watcher tick.
335
349
  *
336
350
  * Like `setVendorEntries`, the importmap-hash is recomputed eagerly
337
351
  * so `importMapHash()` stays synchronous on the per-request SSR
@@ -342,6 +356,7 @@ let _coreEntries = {
342
356
  * @returns {Promise<void>}
343
357
  */
344
358
  export async function setCoreInstall(coreDir, distMode) {
359
+ bumpBuildEpoch();
345
360
  _coreEntries = buildCoreEntries(coreDir, !!distMode);
346
361
  // Capture the installed @webjsdev/core version (#899). The importmap targets
347
362
  // core at a version-independent URL, so a core RELEASE (new implementation,
@@ -458,6 +473,18 @@ export function buildCoreEntries(coreDir, distMode) {
458
473
  */
459
474
  export function buildImportMap(opts = {}) {
460
475
  const fingerprint = opts.fingerprint !== false;
476
+ // The SERVED (fingerprinted) map is request-independent within a build and
477
+ // was rebuilt two or three times per page render, each pass running
478
+ // `withAssetHash` over every target (#1644). Memoized by build epoch; every
479
+ // setter above bumps it. The `fingerprint: false` form the setters use for
480
+ // the build-id hash stays live, since it is what the memo is derived from.
481
+ // Callers only READ the returned object.
482
+ if (fingerprint && _mapMemo.epoch === buildEpoch()) return _mapMemo.value;
483
+ // A transient hash miss during this build (`assetHashMisses` moved) means a
484
+ // target fell back to its un-fingerprinted url; that map is served but NOT
485
+ // memoized, so the next render re-attempts the read as `asset-hash.js`
486
+ // promises, instead of pinning the fallback for the rest of the build.
487
+ const missesBefore = assetHashMisses();
461
488
  const merged = {
462
489
  ..._coreEntries,
463
490
  ..._extraEntries,
@@ -509,6 +536,7 @@ export function buildImportMap(opts = {}) {
509
536
  for (const k of intKeys) integrity[withBasePath(k, _basePath)] = _vendorIntegrity[k];
510
537
  out.integrity = integrity;
511
538
  }
539
+ if (fingerprint && assetHashMisses() === missesBefore) _mapMemo = { epoch: buildEpoch(), value: out };
512
540
  return out;
513
541
  }
514
542
 
@@ -567,6 +595,10 @@ export function vendorPreloadTargets(specifiers) {
567
595
  * @returns {string[]} e.g. `['https://ga.jspm.io']`
568
596
  */
569
597
  export function vendorPreconnectOrigins(max = 2) {
598
+ // Request-independent within a build (it reads only `_extraEntries`), and
599
+ // it re-parsed every vendor url with `new URL` per render (#1644). Keyed by
600
+ // epoch AND `max`, since a caller can ask for a different cap.
601
+ if (_preconnectMemo.epoch === buildEpoch() && _preconnectMemo.max === max) return _preconnectMemo.value;
570
602
  /** @type {Map<string, number>} */
571
603
  const counts = new Map();
572
604
  for (const target of Object.values(_extraEntries)) {
@@ -579,10 +611,12 @@ export function vendorPreconnectOrigins(max = 2) {
579
611
  try { origin = new URL(target).origin; } catch { continue; }
580
612
  counts.set(origin, (counts.get(origin) || 0) + 1);
581
613
  }
582
- return [...counts.entries()]
614
+ const origins = [...counts.entries()]
583
615
  .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
584
616
  .slice(0, Math.max(0, max))
585
617
  .map(([origin]) => origin);
618
+ _preconnectMemo = { epoch: buildEpoch(), max, value: origins };
619
+ return origins;
586
620
  }
587
621
 
588
622
  /**
@@ -619,5 +653,15 @@ export function importMapTag(opts = {}) {
619
653
  // change here evicts the client caches softly (see appSourceId()); empty until
620
654
  // the analysis is ready, which the client reads as "unknown".
621
655
  const s = ` data-webjs-src="${appSourceId()}"`;
622
- return `<script type="importmap"${n}${b}${s}>${jsonForScriptTag(buildImportMap())}</script>`;
656
+ // The JSON body is memoized by build epoch (#1644); the nonce and the two
657
+ // deploy signals above are assembled per call, which is why `publishBuildId`
658
+ // and `setAppSourceId` need no epoch bump.
659
+ let json = _tagJsonMemo.epoch === buildEpoch() ? _tagJsonMemo.value : null;
660
+ if (json === null) {
661
+ json = jsonForScriptTag(buildImportMap());
662
+ // Stored only when the map itself memoized: a transient hash miss leaves
663
+ // both live for the next render (see `buildImportMap`).
664
+ if (_mapMemo.epoch === buildEpoch()) _tagJsonMemo = { epoch: buildEpoch(), value: json };
665
+ }
666
+ return `<script type="importmap"${n}${b}${s}>${json}</script>`;
623
667
  }
@@ -49,6 +49,7 @@ import { registerClient } from './broadcast.js';
49
49
  import { setTrustedRemoteIp, propagateTrustedRemoteIp } from './rate-limit.js';
50
50
  import { applyForwarded } from './forwarded.js';
51
51
  import { isAddrInUse, portInUseError, reusePortRequested } from './port-in-use.js';
52
+ import { isBufferedResponse } from './conditional-get.js';
52
53
  import {
53
54
  isCompressible,
54
55
  isEventsPath,
@@ -59,6 +60,7 @@ import {
59
60
  createCompressor,
60
61
  compressBufferSync,
61
62
  MAX_SYNC_COMPRESS_BYTES,
63
+ MIN_COMPRESS_BYTES,
62
64
  varyWithAcceptEncoding,
63
65
  readBufferedOrStream,
64
66
  } from './listener-core.js';
@@ -462,8 +464,10 @@ async function maybeCompress(resp, req) {
462
464
  const encoding = negotiateEncoding(req.headers.get('accept-encoding'));
463
465
  if (!encoding) return resp;
464
466
 
467
+ // `encoding` decides WHETHER the response is compressed and is the coding a
468
+ // streamed body gets; a buffered body re-asks with its size (the small-body
469
+ // gzip preference, #1646). The header is set once, where the coding is final.
465
470
  const headers = new Headers(resp.headers);
466
- headers.set('content-encoding', encoding);
467
471
  headers.set('vary', varyWithAcceptEncoding(headers.get('vary')));
468
472
 
469
473
  // Buffered-body fast path (#756): peek the body to tell a single bounded
@@ -475,9 +479,24 @@ async function maybeCompress(resp, req) {
475
479
  // runtime a buffered body and a streamed one compress identically (the wire is
476
480
  // self-describing via content-encoding either way). A streamed / oversized body
477
481
  // falls through to the bridge below.
478
- const peeked = await readBufferedOrStream(resp.body, MAX_SYNC_COMPRESS_BYTES);
479
- if (peeked.buffered !== undefined) {
480
- const out = compressBufferSync(encoding, peeked.buffered);
482
+ // A response the framework built from a string or bytes is read whole
483
+ // (`isBufferedResponse`, #1644: the reader peek costs about 45 µs per
484
+ // response on Bun against 3 µs for `arrayBuffer()`); anything else is peeked
485
+ // without reading ahead of the client.
486
+ const peeked = isBufferedResponse(resp)
487
+ ? { buffered: new Uint8Array(await resp.arrayBuffer()) }
488
+ : await readBufferedOrStream(resp.body, MAX_SYNC_COMPRESS_BYTES);
489
+ if (peeked.buffered !== undefined && peeked.buffered.byteLength <= MAX_SYNC_COMPRESS_BYTES) {
490
+ // Under the shared floor (#1644) the body goes out uncompressed, with the
491
+ // ORIGINAL headers (the vary written above is not applied).
492
+ // The peek consumed the body, so rebuild the Response from the bytes.
493
+ if (peeked.buffered.byteLength < MIN_COMPRESS_BYTES) {
494
+ return new Response(peeked.buffered, { status: resp.status, statusText: resp.statusText, headers: resp.headers });
495
+ }
496
+ // The size is known now, so the small-body gzip preference applies (#1646).
497
+ const sized = negotiateEncoding(req.headers.get('accept-encoding'), peeked.buffered.byteLength);
498
+ headers.set('content-encoding', sized);
499
+ const out = compressBufferSync(sized, peeked.buffered);
481
500
  headers.set('content-length', String(out.length));
482
501
  return new Response(out, { status: resp.status, statusText: resp.statusText, headers });
483
502
  }
@@ -488,6 +507,7 @@ async function maybeCompress(resp, req) {
488
507
  // hoisting this would waste a native zlib/brotli handle on the common case.
489
508
  // `encoding` is guaranteed br/gzip/deflate (negotiateEncoding), so
490
509
  // `createCompressor` never returns null here.
510
+ headers.set('content-encoding', encoding);
491
511
  const compressor = createCompressor(encoding);
492
512
  // Feed the (peeked + drained) web body into the compressor through the reader
493
513
  // loop (NOT Readable.fromWeb, which does not propagate a mid-stream source
@@ -495,8 +515,9 @@ async function maybeCompress(resp, req) {
495
515
  // so a source error (or a client disconnect destroying the output) tears down
496
516
  // the whole chain instead of leaking/hanging the compressor. Backpressure is
497
517
  // preserved: a slow client stalls `toWeb`, which stalls the compressor, which
498
- // pauses the source.
499
- const source = Readable.from(peeked.stream);
518
+ // pauses the source. A known-buffered body over the sync cap is replayed as
519
+ // a single chunk through the same path.
520
+ const source = Readable.from(peeked.buffered !== undefined ? [peeked.buffered] : peeked.stream);
500
521
  pipeline(source, compressor, () => {});
501
522
  const body = Readable.toWeb(compressor);
502
523
  return new Response(body, { status: resp.status, statusText: resp.statusText, headers });
@@ -79,23 +79,60 @@ export function isCompressible(contentType) {
79
79
  return /^(?:text\/|application\/(?:javascript|json|xml|wasm|manifest)|image\/svg\+xml)/i.test(ct);
80
80
  }
81
81
 
82
+ /**
83
+ * The smallest BUFFERED body brotli is worth on the sync path (#1646). Below
84
+ * it gzip level 6 is preferred when the client accepts both: measured on the
85
+ * two runtimes, a 2.4 KB SSR page costs 60 to 82 µs at brotli quality 4
86
+ * against 18 to 29 µs at gzip 6 for 912 against 986 bytes, and at 8 KB the
87
+ * byte gap is 3 percent for a 1.7 to 2.8x CPU gap. From about 32 KB brotli's
88
+ * ratio advantage and Node's slower gzip cross over (a 133 KB page compresses
89
+ * to 36.3 KB either way, brotli 1.1 to 1.4 ms, gzip 1.3 to 1.8 ms). This is
90
+ * the sync, buffered path only; a streamed body's size is unknown when its
91
+ * head goes out, and a streamed body is large or Suspense-driven by nature.
92
+ */
93
+ export const BROTLI_MIN_BYTES = 32 * 1024;
94
+
82
95
  /**
83
96
  * Negotiate a response content-encoding from a request's `Accept-Encoding`,
84
97
  * preferring brotli (best ratio), then gzip, then deflate. Returns `''` when
85
98
  * none is acceptable. Shared by BOTH listener shells so they negotiate
86
99
  * identically; the matching uses the same `(?:^|,\s*)<enc>(?:;|,|$)` token test
87
- * the node shell has always used.
100
+ * the node shell has always used. With a known body size (the buffered path),
101
+ * gzip wins over brotli under `BROTLI_MIN_BYTES` when both are accepted.
102
+ * A coding listed with `q=0` is refused (RFC 9110), so `gzip;q=0, br` never
103
+ * gets gzip from the small-body preference and `br;q=0, gzip` gets gzip.
88
104
  * @param {string | string[] | undefined | null} acceptEncoding
105
+ * @param {number} [bodyBytes] the buffered body's size, when known
89
106
  * @returns {'br' | 'gzip' | 'deflate' | ''}
90
107
  */
91
- export function negotiateEncoding(acceptEncoding) {
108
+ export function negotiateEncoding(acceptEncoding, bodyBytes) {
92
109
  const accept = Array.isArray(acceptEncoding) ? acceptEncoding.join(',') : String(acceptEncoding || '');
93
- if (/(?:^|,\s*)br(?:;|,|$)/.test(accept)) return 'br';
94
- if (/(?:^|,\s*)gzip(?:;|,|$)/.test(accept)) return 'gzip';
95
- if (/(?:^|,\s*)deflate(?:;|,|$)/.test(accept)) return 'deflate';
110
+ const br = accepts(ACCEPT_BR, accept);
111
+ const gzip = accepts(ACCEPT_GZIP, accept);
112
+ if (br && gzip && bodyBytes !== undefined && bodyBytes < BROTLI_MIN_BYTES) return 'gzip';
113
+ if (br) return 'br';
114
+ if (gzip) return 'gzip';
115
+ if (accepts(ACCEPT_DEFLATE, accept)) return 'deflate';
96
116
  return '';
97
117
  }
98
118
 
119
+ // One coding token with its optional parameters, matched as a whole list
120
+ // member (`xbr` and `brx` are not `br`).
121
+ const ACCEPT_BR = /(?:^|,\s*)br(;[^,]*)?(?:,|$)/;
122
+ const ACCEPT_GZIP = /(?:^|,\s*)gzip(;[^,]*)?(?:,|$)/;
123
+ const ACCEPT_DEFLATE = /(?:^|,\s*)deflate(;[^,]*)?(?:,|$)/;
124
+ const Q_ZERO = /;\s*q\s*=\s*0(?:\.0*)?\s*(?:;|$)/i;
125
+
126
+ /**
127
+ * Whether the header lists the coding with a non-zero quality.
128
+ * @param {RegExp} token
129
+ * @param {string} accept
130
+ */
131
+ function accepts(token, accept) {
132
+ const m = token.exec(accept);
133
+ return m !== null && !(m[1] && Q_ZERO.test(m[1]));
134
+ }
135
+
99
136
  /**
100
137
  * Create a `node:zlib` compressor Transform for a negotiated encoding, or null.
101
138
  * `node:zlib` runs NATIVELY on Bun, so both shells get brotli through it (the web
@@ -121,6 +158,16 @@ export function createCompressor(encoding) {
121
158
  */
122
159
  export const MAX_SYNC_COMPRESS_BYTES = 256 * 1024;
123
160
 
161
+ /**
162
+ * The smallest BUFFERED body worth compressing (#1644), shared by both shells.
163
+ * Below it the encoding's own cost (a zlib context, the framing bytes, a
164
+ * decode on the client) outweighs the few bytes it saves, so the body is sent
165
+ * as is with no `content-encoding`. 1 KB is the threshold Express's
166
+ * `compression` middleware has used for years. A STREAMED body is never
167
+ * measured against it (its size is unknown when the head goes out).
168
+ */
169
+ export const MIN_COMPRESS_BYTES = 1024;
170
+
124
171
  /**
125
172
  * Compress an already-buffered body synchronously, byte-for-byte identical to
126
173
  * the streaming `createCompressor` ON THE SAME RUNTIME (same algorithm + params:
package/src/rate-limit.js CHANGED
@@ -313,9 +313,3 @@ export function parseWindow(w) {
313
313
  const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[unit];
314
314
  return n * (mult || 1);
315
315
  }
316
-
317
- /** Testing hook: reset the default store (for unit tests). */
318
- export function _resetRateLimits() {
319
- // With the cache store, there's nothing to reset here: the store
320
- // handles its own state. This function exists for API compatibility.
321
- }
package/src/session.js CHANGED
@@ -235,7 +235,7 @@ export function storeSessionStorage(opts = {}) {
235
235
  };
236
236
  }
237
237
 
238
- // Backwards-compatible aliases
238
+ // Short aliases
239
239
  export const cookieSession = cookieSessionStorage;
240
240
  export const storeSession = storeSessionStorage;
241
241
 
@@ -1,5 +1,6 @@
1
1
  import { jsonForScriptTag } from '../script-tag-json.js';
2
2
  import { escapeAttr } from './escape.js';
3
+ import { buildEpoch } from '../build-epoch.js';
3
4
 
4
5
  /**
5
6
  * The `window.process.env` shim, kept in its own module because BOTH the head
@@ -28,12 +29,48 @@ import { escapeAttr } from './escape.js';
28
29
  * `</...` sequences in stringified values are escaped so an env value
29
30
  * containing `</script>` cannot terminate the inline script tag.
30
31
  *
32
+ * **The payload is a per-build SNAPSHOT (#1644).** Enumerating `process.env`
33
+ * is an exotic getenv walk, about 266 µs with a couple of hundred variables,
34
+ * and it ran on EVERY page render (24% of a production render's CPU) for a
35
+ * result that only changes with the build: `applyEnvValidation` writes its
36
+ * coerced values back BEFORE any render, and a dev rebuild bumps the build
37
+ * epoch, which re-snapshots. So `WEBJS_PUBLIC_*` is read at the first render
38
+ * of a build. A `process.env` mutation made at runtime, after that render, is
39
+ * not reflected until the next build (restart, or a dev rebuild). Only the
40
+ * nonce attribute varies per call. An explicit `opts.env` (tests) is never
41
+ * memoized.
42
+ *
31
43
  * @param {{ dev: boolean, nonce?: string, env?: Record<string, string|undefined> }} opts
32
44
  * `env` defaults to `process.env`. Override for tests.
33
45
  * @returns {string}
34
46
  */
35
47
  export function publicEnvShim(opts) {
36
- const source = opts.env || process.env;
48
+ const dev = !!opts.dev;
49
+ let payload;
50
+ if (opts.env) {
51
+ payload = publicEnvPayload(dev, opts.env);
52
+ } else {
53
+ const m = dev ? _memo.dev : _memo.prod;
54
+ if (m.epoch === buildEpoch()) payload = m.value;
55
+ else {
56
+ payload = publicEnvPayload(dev, process.env);
57
+ _memo[dev ? 'dev' : 'prod'] = { epoch: buildEpoch(), value: payload };
58
+ }
59
+ }
60
+ const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
61
+ return `<script${n}>${payload}</script>`;
62
+ }
63
+
64
+ /**
65
+ * The script BODY (everything inside the tag): the `WEBJS_PUBLIC_*` subset of
66
+ * `source` plus `NODE_ENV`, serialized with `jsonForScriptTag`. Pure in its
67
+ * inputs, so the memo above can hold its result.
68
+ *
69
+ * @param {boolean} dev
70
+ * @param {Record<string, string|undefined>} source
71
+ * @returns {string}
72
+ */
73
+ export function publicEnvPayload(dev, source) {
37
74
  /** @type {Record<string, string>} */
38
75
  const env = {};
39
76
  for (const [k, v] of Object.entries(source)) {
@@ -41,11 +78,15 @@ export function publicEnvShim(opts) {
41
78
  env[k] = String(v);
42
79
  }
43
80
  }
44
- env.NODE_ENV = opts.dev ? 'development' : 'production';
45
- const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
46
- return `<script${n}>`
47
- + `window.process=window.process||{};`
48
- + `window.process.env=Object.assign(window.process.env||{},${jsonForScriptTag(env)});`
49
- + `</script>`;
81
+ env.NODE_ENV = dev ? 'development' : 'production';
82
+ return 'window.process=window.process||{};'
83
+ + `window.process.env=Object.assign(window.process.env||{},${jsonForScriptTag(env)});`;
50
84
  }
51
85
 
86
+ /**
87
+ * One snapshot per `dev` flag (a test process renders both ways), each
88
+ * `{ epoch, value }` keyed on the build epoch.
89
+ * @type {{ dev: { epoch: number, value: string }, prod: { epoch: number, value: string } }}
90
+ */
91
+ let _memo = { dev: { epoch: 0, value: '' }, prod: { epoch: 0, value: '' } };
92
+
package/src/ssr/head.js CHANGED
@@ -10,6 +10,7 @@ import { clientRouterEnabled } from './client-router-flag.js';
10
10
  import { embedScriptTag } from '../dev-embed.js';
11
11
  import { devReloadState } from '../dev-reload-state.js';
12
12
  import { openGraphPairs, twitterPairs, setMetadataImageRoutes } from './seo.js';
13
+ import { buildEpoch, bumpBuildEpoch } from '../build-epoch.js';
13
14
 
14
15
  // Which icon and manifest metadata the app has: the `app/icon.*`,
15
16
  // `app/apple-icon.*` and `app/manifest.*` files (a static file such as
@@ -99,6 +100,33 @@ export function setMetadataIconRoutes(metadataRoutes) {
99
100
  manifest: manifest ? /** @type {string} */ (manifest.urlPath) : null,
100
101
  }; // The og/twitter image routes ride the same rebuild hook (seo.js).
101
102
  setMetadataImageRoutes(metadataRoutes);
103
+ // The auto-link memos below key on the epoch (#1644).
104
+ bumpBuildEpoch();
105
+ }
106
+
107
+ /**
108
+ * Memo for `autoMetadataRouteIcons` / `autoManifestUrl` (#1644): both read
109
+ * only `_metadataIconRoutes` and `basePath()`, and `setMetadataIconRoutes` and
110
+ * `setBasePath` both bump the epoch, so one entry serves every render of a
111
+ * build instead of re-prefixing the lists per request.
112
+ * @type {{ epoch: number, icons: { icon?: AutoIcon[], apple?: AutoIcon[] } | null, manifest: string | null }}
113
+ */
114
+ let _autoLinkMemo = { epoch: 0, icons: null, manifest: null };
115
+
116
+ /** @returns {void} */
117
+ function refreshAutoLinkMemo() {
118
+ if (_autoLinkMemo.epoch === buildEpoch()) return;
119
+ const { icon, apple, manifest } = _metadataIconRoutes;
120
+ const bp = basePath();
121
+ const prefix = (/** @type {AutoIcon} */ i) => ({ ...i, url: withBasePath(i.url, bp) });
122
+ /** @type {{ icon?: AutoIcon[], apple?: AutoIcon[] } | null} */
123
+ let icons = null;
124
+ if (icon.length || apple.length) {
125
+ icons = {};
126
+ if (icon.length) icons.icon = icon.map(prefix);
127
+ if (apple.length) icons.apple = apple.map(prefix);
128
+ }
129
+ _autoLinkMemo = { epoch: buildEpoch(), icons, manifest: manifest ? withBasePath(manifest, bp) : null };
102
130
  }
103
131
 
104
132
  /**
@@ -112,25 +140,18 @@ export function setMetadataIconRoutes(metadataRoutes) {
112
140
  *
113
141
  * @returns {{ icon?: AutoIcon[], apple?: AutoIcon[] } | null}
114
142
  */
115
- function autoMetadataRouteIcons() {
116
- const { icon, apple } = _metadataIconRoutes;
117
- if (!icon.length && !apple.length) return null;
118
- const bp = basePath();
119
- const prefix = (/** @type {AutoIcon} */ i) => ({ ...i, url: withBasePath(i.url, bp) });
120
- /** @type {{ icon?: AutoIcon[], apple?: AutoIcon[] }} */
121
- const out = {};
122
- if (icon.length) out.icon = icon.map(prefix);
123
- if (apple.length) out.apple = apple.map(prefix);
124
- return out;
143
+ export function autoMetadataRouteIcons() {
144
+ refreshAutoLinkMemo();
145
+ return _autoLinkMemo.icons;
125
146
  }
126
147
 
127
148
  /**
128
149
  * The app's `app/manifest.*` URL (base-path prefixed), or null.
129
150
  * @returns {string | null}
130
151
  */
131
- function autoManifestUrl() {
132
- const { manifest } = _metadataIconRoutes;
133
- return manifest ? withBasePath(manifest, basePath()) : null;
152
+ export function autoManifestUrl() {
153
+ refreshAutoLinkMemo();
154
+ return _autoLinkMemo.manifest;
134
155
  }
135
156
 
136
157
  /**
@@ -457,9 +478,9 @@ export function wrapHead(opts) {
457
478
 
458
479
  if (m.description) metaTags.push(`<meta name="description" content="${escapeAttr(m.description)}">`);
459
480
 
460
- // viewport: support string form (legacy), `metadata.viewport` object form,
461
- // and the new Next.js 14+ `export const viewport = { … }` shape captured
462
- // into `_viewport` by collectMetadata.
481
+ // viewport: the `metadata.viewport` string or object form, or the Next.js
482
+ // 14+ `export const viewport = { … }` shape captured into `_viewport` by
483
+ // collectMetadata.
463
484
  let viewportStr = '';
464
485
  if (typeof m.viewport === 'string') {
465
486
  viewportStr = m.viewport;
@@ -93,7 +93,7 @@ export function componentPreloads(usedTags, appDir, elidable, lazyFiles) {
93
93
  * @param {Set<string>} [elidableComponents] absolute paths to skip in the walk
94
94
  * @returns {string[]}
95
95
  */
96
- export function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appDir, serverFiles, elidableComponents) {
96
+ export function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appDir, serverFiles, elidableComponents, precomputedDeps) {
97
97
  const seen = new Set(moduleUrls);
98
98
  const result = [];
99
99
 
@@ -119,17 +119,22 @@ export function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFile
119
119
 
120
120
  // Add transitive deps from the module graph
121
121
  if (graph) {
122
- // Combine entry files + component files for graph lookup
123
- const allEntries = [...entryFiles];
124
- for (const url of componentUrls) {
125
- // Convert URL back to absolute path for graph lookup
126
- const abs = resolve(appDir, url.startsWith('/') ? url.slice(1) : url);
127
- allEntries.push(abs);
122
+ // `precomputedDeps` is the closure `pagePreloadHints` already walked for
123
+ // this render (#1644); without it (a direct caller) walk it here.
124
+ let deps = precomputedDeps;
125
+ if (!deps) {
126
+ // Combine entry files + component files for graph lookup
127
+ const allEntries = [...entryFiles];
128
+ for (const url of componentUrls) {
129
+ // Convert URL back to absolute path for graph lookup
130
+ const abs = resolve(appDir, url.startsWith('/') ? url.slice(1) : url);
131
+ allEntries.push(abs);
132
+ }
133
+ // Skip elidable components and any subtree reachable only through
134
+ // them: their imports are stripped from served source, so the
135
+ // browser never fetches these modules.
136
+ deps = transitiveDeps(graph, allEntries, appDir, elidableComponents);
128
137
  }
129
- // Skip elidable components and any subtree reachable only through
130
- // them: their imports are stripped from served source, so the
131
- // browser never fetches these modules.
132
- const deps = transitiveDeps(graph, allEntries, appDir, elidableComponents);
133
138
  for (const dep of deps) {
134
139
  if (byIndex(dep)) continue;
135
140
  const url = toUrlPath(dep, appDir);
@@ -169,7 +174,7 @@ export function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFile
169
174
  * @param {Set<string>} [serverFiles] the action / server-file index (`'use server'`, incl. no-`.server.` files)
170
175
  * @returns {Set<string>}
171
176
  */
172
- export function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir, elidableComponents, serverFiles) {
177
+ export function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir, elidableComponents, serverFiles, precomputedDeps) {
173
178
  /** @type {Set<string>} */
174
179
  const specs = new Set();
175
180
  if (!graph) return specs;
@@ -177,13 +182,14 @@ export function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir
177
182
  if (!bare.size) return specs;
178
183
  // Roots = the SHIPPED page/layout modules (already inert-dropped + import-only
179
184
  // expanded by the caller) + the rendered components, keyed by the graph's own
180
- // absolute paths. Walk their non-elided transitive closure.
185
+ // absolute paths. Walk their non-elided transitive closure, unless
186
+ // `pagePreloadHints` already walked it for this render (#1644).
181
187
  const allEntries = [...entryFiles];
182
188
  for (const url of componentUrls) {
183
189
  allEntries.push(resolve(appDir, url.startsWith('/') ? url.slice(1) : url));
184
190
  }
185
191
  const files = new Set(allEntries);
186
- for (const dep of transitiveDeps(graph, allEntries, appDir, elidableComponents)) files.add(dep);
192
+ for (const dep of precomputedDeps || transitiveDeps(graph, allEntries, appDir, elidableComponents)) files.add(dep);
187
193
  for (const file of files) {
188
194
  // A server file is never served to the browser (its source is an RPC /
189
195
  // throw-at-load stub), so a vendor it imports never ships and must NOT be
@@ -198,3 +204,71 @@ export function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir
198
204
  }
199
205
  return specs;
200
206
  }
207
+
208
+ /**
209
+ * The skip set for the preload walks: the elidable components plus, when the
210
+ * app has `static lazy` components (#1524), every lazy file. Built once per
211
+ * lazy map (a per-build object), since it was rebuilt per render (#1644).
212
+ * @type {WeakMap<Map<string, string[]>, WeakMap<Set<string>, Set<string>>>}
213
+ */
214
+ const SKIP_MEMO = new WeakMap();
215
+ const NO_ELIDABLE = new Set();
216
+
217
+ /**
218
+ * @param {Set<string> | undefined} elidable
219
+ * @param {Map<string, string[]> | undefined} lazyFiles
220
+ * @returns {Set<string> | undefined}
221
+ */
222
+ function preloadSkipSet(elidable, lazyFiles) {
223
+ if (!lazyFiles || !lazyFiles.size) return elidable;
224
+ const key = elidable || NO_ELIDABLE;
225
+ let byElidable = SKIP_MEMO.get(lazyFiles);
226
+ if (!byElidable) { byElidable = new WeakMap(); SKIP_MEMO.set(lazyFiles, byElidable); }
227
+ let skip = byElidable.get(key);
228
+ if (!skip) {
229
+ skip = new Set([...(elidable || []), ...lazyFiles.keys()]);
230
+ byElidable.set(key, skip);
231
+ }
232
+ return skip;
233
+ }
234
+
235
+ /**
236
+ * The page's preload hints in ONE graph walk (#1644). The app-module preload
237
+ * pass (`deduplicatedPreloads`) and the vendor pass
238
+ * (`reachedVendorSpecifiers`) root at the same shipped set with the same skip
239
+ * set, so they used to walk the identical closure twice per render. This
240
+ * walks it once and hands the closure to both.
241
+ *
242
+ * The walk roots are the BOOT's actually-shipped module set (`moduleUrls`,
243
+ * inert modules already dropped and an import-only page already substituted)
244
+ * plus the rendered eager components, NOT the raw route entries: rooting at
245
+ * the raw entries would walk a dropped page's SSR-only subtree and hint a
246
+ * `modulepreload` for a module nothing that ships imports (#780, the
247
+ * app-module analog of the #754 vendor over-fetch).
248
+ *
249
+ * @param {{
250
+ * graph: import('../module-graph.js').ModuleGraph | undefined,
251
+ * appDir: string,
252
+ * moduleUrls: string[],
253
+ * eagerComponents: string[],
254
+ * elidableComponents: Set<string> | undefined,
255
+ * lazyFiles: Map<string, string[]> | undefined,
256
+ * serverFiles: Set<string> | undefined,
257
+ * }} input
258
+ * @returns {{ preloads: string[], vendorSpecifiers: Set<string>, skip: Set<string> | undefined }}
259
+ */
260
+ export function pagePreloadHints(input) {
261
+ const { graph, appDir, moduleUrls, eagerComponents, elidableComponents, lazyFiles, serverFiles } = input;
262
+ const skip = preloadSkipSet(elidableComponents, lazyFiles);
263
+ const shippedRoots = moduleUrls.map((u) => resolve(appDir, u.startsWith('/') ? u.slice(1) : u));
264
+ /** @type {string[] | undefined} */
265
+ let deps;
266
+ if (graph) {
267
+ const allEntries = [...shippedRoots];
268
+ for (const url of eagerComponents) allEntries.push(resolve(appDir, url.startsWith('/') ? url.slice(1) : url));
269
+ deps = transitiveDeps(graph, allEntries, appDir, skip);
270
+ }
271
+ const preloads = deduplicatedPreloads(eagerComponents, moduleUrls, graph, shippedRoots, appDir, serverFiles, skip, deps);
272
+ const vendorSpecifiers = reachedVendorSpecifiers(graph, shippedRoots, eagerComponents, appDir, skip, serverFiles, deps);
273
+ return { preloads, vendorSpecifiers, skip };
274
+ }