@webjsdev/server 0.8.89 → 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 })));
@@ -338,6 +356,7 @@ let _coreEntries = {
338
356
  * @returns {Promise<void>}
339
357
  */
340
358
  export async function setCoreInstall(coreDir, distMode) {
359
+ bumpBuildEpoch();
341
360
  _coreEntries = buildCoreEntries(coreDir, !!distMode);
342
361
  // Capture the installed @webjsdev/core version (#899). The importmap targets
343
362
  // core at a version-independent URL, so a core RELEASE (new implementation,
@@ -454,6 +473,18 @@ export function buildCoreEntries(coreDir, distMode) {
454
473
  */
455
474
  export function buildImportMap(opts = {}) {
456
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();
457
488
  const merged = {
458
489
  ..._coreEntries,
459
490
  ..._extraEntries,
@@ -505,6 +536,7 @@ export function buildImportMap(opts = {}) {
505
536
  for (const k of intKeys) integrity[withBasePath(k, _basePath)] = _vendorIntegrity[k];
506
537
  out.integrity = integrity;
507
538
  }
539
+ if (fingerprint && assetHashMisses() === missesBefore) _mapMemo = { epoch: buildEpoch(), value: out };
508
540
  return out;
509
541
  }
510
542
 
@@ -563,6 +595,10 @@ export function vendorPreloadTargets(specifiers) {
563
595
  * @returns {string[]} e.g. `['https://ga.jspm.io']`
564
596
  */
565
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;
566
602
  /** @type {Map<string, number>} */
567
603
  const counts = new Map();
568
604
  for (const target of Object.values(_extraEntries)) {
@@ -575,10 +611,12 @@ export function vendorPreconnectOrigins(max = 2) {
575
611
  try { origin = new URL(target).origin; } catch { continue; }
576
612
  counts.set(origin, (counts.get(origin) || 0) + 1);
577
613
  }
578
- return [...counts.entries()]
614
+ const origins = [...counts.entries()]
579
615
  .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
580
616
  .slice(0, Math.max(0, max))
581
617
  .map(([origin]) => origin);
618
+ _preconnectMemo = { epoch: buildEpoch(), max, value: origins };
619
+ return origins;
582
620
  }
583
621
 
584
622
  /**
@@ -615,5 +653,15 @@ export function importMapTag(opts = {}) {
615
653
  // change here evicts the client caches softly (see appSourceId()); empty until
616
654
  // the analysis is ready, which the client reads as "unknown".
617
655
  const s = ` data-webjs-src="${appSourceId()}"`;
618
- 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>`;
619
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:
@@ -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
  /**
@@ -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
+ }