@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/package.json +1 -1
- package/src/asset-hash.js +44 -3
- package/src/build-epoch.js +50 -0
- package/src/conditional-get.js +33 -1
- package/src/dev/handler.js +65 -29
- package/src/dev/helpers.js +91 -30
- package/src/dev/vendor-retry.js +61 -0
- package/src/dev-import.js +38 -1
- package/src/headers.js +6 -1
- package/src/importmap.js +51 -3
- package/src/listener-bun.js +27 -6
- package/src/listener-core.js +52 -5
- package/src/ssr/env-shim.js +48 -7
- package/src/ssr/head.js +34 -13
- package/src/ssr/preloads.js +88 -14
- package/src/ssr/render.js +32 -70
- package/src/ssr/shipped-modules.js +113 -0
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/listener-bun.js
CHANGED
|
@@ -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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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 });
|
package/src/listener-core.js
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
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/ssr/env-shim.js
CHANGED
|
@@ -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
|
|
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 =
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
133
|
-
return manifest
|
|
152
|
+
export function autoManifestUrl() {
|
|
153
|
+
refreshAutoLinkMemo();
|
|
154
|
+
return _autoLinkMemo.manifest;
|
|
134
155
|
}
|
|
135
156
|
|
|
136
157
|
/**
|
package/src/ssr/preloads.js
CHANGED
|
@@ -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
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
allEntries
|
|
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
|
+
}
|