@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/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/context.js +3 -3
- 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 +55 -11
- package/src/listener-bun.js +27 -6
- package/src/listener-core.js +52 -5
- package/src/rate-limit.js +0 -6
- package/src/session.js +1 -1
- package/src/ssr/env-shim.js +48 -7
- package/src/ssr/head.js +37 -16
- package/src/ssr/preloads.js +88 -14
- package/src/ssr/render.js +36 -74
- package/src/ssr/responses.js +4 -8
- package/src/ssr/shipped-modules.js +113 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.90",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
package/src/asset-hash.js
CHANGED
|
@@ -42,6 +42,7 @@ import { readFileSync } from 'node:fs';
|
|
|
42
42
|
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
43
43
|
import { redactStringsAndTemplates } from './js-scan.js';
|
|
44
44
|
import { resolveImport, expandImportAlias } from './module-graph.js';
|
|
45
|
+
import { bumpBuildEpoch } from './build-epoch.js';
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* Length of the emitted hex hash. 12 hex chars (48 bits) is plenty to
|
|
@@ -111,6 +112,9 @@ export function setAssetRoots(opts) {
|
|
|
111
112
|
_appDir = opts.appDir || '';
|
|
112
113
|
_coreDir = opts.coreDir || '';
|
|
113
114
|
_enabled = !!opts.enabled && !!_appDir && !!_coreDir;
|
|
115
|
+
// Flipping the roots or the enable switch changes every `?v` the memoized
|
|
116
|
+
// importmap carries (#1644).
|
|
117
|
+
bumpBuildEpoch();
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
/**
|
|
@@ -129,6 +133,9 @@ export function setElisionFingerprint(fp) {
|
|
|
129
133
|
if (next === _elisionFp) return;
|
|
130
134
|
_elisionFp = next;
|
|
131
135
|
_hashCache.clear();
|
|
136
|
+
// Past the early return on purpose: an unchanged fingerprint moves no hash,
|
|
137
|
+
// so the epoch-keyed memos over those hashes stay valid (#1644).
|
|
138
|
+
bumpBuildEpoch();
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
/**
|
|
@@ -139,8 +146,26 @@ export function setElisionFingerprint(fp) {
|
|
|
139
146
|
*
|
|
140
147
|
* @returns {void}
|
|
141
148
|
*/
|
|
149
|
+
/**
|
|
150
|
+
* How many times `assetHashFor` fell back to '' on a TRANSIENT read failure
|
|
151
|
+
* (anything but EISDIR and ENOENT). A memo keyed on the build epoch compares this
|
|
152
|
+
* before and after computing its value and skips storing when it moved, so a
|
|
153
|
+
* transient EMFILE / EIO on one render cannot pin an un-fingerprinted url for
|
|
154
|
+
* the rest of the build (in prod, the process lifetime); the next render
|
|
155
|
+
* re-attempts the read, exactly as the hash cache itself does.
|
|
156
|
+
* @returns {number}
|
|
157
|
+
*/
|
|
158
|
+
export function assetHashMisses() {
|
|
159
|
+
return _misses;
|
|
160
|
+
}
|
|
161
|
+
let _misses = 0;
|
|
162
|
+
|
|
142
163
|
export function clearAssetHashCache() {
|
|
143
164
|
_hashCache.clear();
|
|
165
|
+
// The memoized importmap and every other epoch-keyed value embed these
|
|
166
|
+
// hashes, so a cleared cache is a new build as far as they are concerned
|
|
167
|
+
// (#1644).
|
|
168
|
+
bumpBuildEpoch();
|
|
144
169
|
}
|
|
145
170
|
|
|
146
171
|
/**
|
|
@@ -191,10 +216,26 @@ export function assetHashFor(absPath) {
|
|
|
191
216
|
const hash = h.digest('hex').slice(0, HASH_LEN);
|
|
192
217
|
_hashCache.set(absPath, hash);
|
|
193
218
|
return hash;
|
|
194
|
-
} catch {
|
|
195
|
-
// A
|
|
219
|
+
} catch (err) {
|
|
220
|
+
// A DIRECTORY is memoized as '' (#1644): the importmap carries trailing-slash
|
|
221
|
+
// prefix targets (the core `src/` catch-all and every `#<dir>/` alias scope),
|
|
222
|
+
// each of which resolved to a directory and threw EISDIR on EVERY request,
|
|
223
|
+
// an open + read + throw per entry per render. A directory never becomes a
|
|
224
|
+
// file without a rebuild, and a rebuild clears this cache.
|
|
225
|
+
//
|
|
226
|
+
// Any OTHER read failure stays un-memoized (returning '' fails safe to the
|
|
196
227
|
// 1h fallback), so a later emit re-attempts instead of pinning the file to
|
|
197
|
-
// the fallback for the process lifetime.
|
|
228
|
+
// the fallback for the process lifetime. A TRANSIENT one (EMFILE, EAGAIN,
|
|
229
|
+
// EIO, anything but a missing file) also moves `assetHashMisses`, so an
|
|
230
|
+
// epoch-keyed memo built over this url (the served importmap, the
|
|
231
|
+
// early-hint list) declines to store a value that carries the fallback.
|
|
232
|
+
// ENOENT does not count: like a directory, a missing file stays missing
|
|
233
|
+
// until a rebuild, so a memo over its bare url is not stale, and counting
|
|
234
|
+
// it would keep the memo from ever filling for an app whose map names a
|
|
235
|
+
// file that is not there.
|
|
236
|
+
const code = err && /** @type {{ code?: string }} */ (err).code;
|
|
237
|
+
if (code === 'EISDIR') _hashCache.set(absPath, '');
|
|
238
|
+
else if (code !== 'ENOENT') _misses += 1;
|
|
198
239
|
return '';
|
|
199
240
|
}
|
|
200
241
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The build epoch: one counter every request-independent memo on the SSR
|
|
3
|
+
* hot path keys on (#1644).
|
|
4
|
+
*
|
|
5
|
+
* "Build" here is the word in its no-build sense, the one `data-webjs-build`
|
|
6
|
+
* already uses: the analysis generation of a RUNNING process (its route table,
|
|
7
|
+
* module graph, elision verdict, importmap, asset hashes), which changes on a
|
|
8
|
+
* deploy or a dev rebuild. Nothing is compiled, bundled, or written to disk.
|
|
9
|
+
*
|
|
10
|
+
* A production page render recomputed several values on every request that
|
|
11
|
+
* only change when the BUILD changes: the served importmap and its `?v`
|
|
12
|
+
* fingerprints, the public env shim, the auto-linked icon routes, the vendor
|
|
13
|
+
* preconnect origins, the shipped-module list behind the 103 Early Hints.
|
|
14
|
+
* Each of those is now memoized as `{ epoch, value }` and recomputed when
|
|
15
|
+
* `buildEpoch()` has moved since the value was stored.
|
|
16
|
+
*
|
|
17
|
+
* One counter rather than one clear per memo: the invalidators (the importmap
|
|
18
|
+
* setters, the asset-hash roots and fingerprint, the icon routes, the analysis
|
|
19
|
+
* stage, the dev rebuild) would otherwise each need to know every memo, and a
|
|
20
|
+
* forgotten wire would serve a stale value for the process lifetime. With the
|
|
21
|
+
* epoch, an invalidator bumps once and every memo notices, and a single unit
|
|
22
|
+
* test enumerates the bump sites.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately NOT routed through `process-shared.js`: this is framework
|
|
25
|
+
* internal state, so after a `bun --hot` reset the fresh server copy starts
|
|
26
|
+
* its own epoch, which is exactly right (its memos are empty too).
|
|
27
|
+
*
|
|
28
|
+
* The epoch starts at 1 so a memo initialised with `epoch: 0` is always
|
|
29
|
+
* stale on first read.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
let _epoch = 1;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The current build epoch. A memo compares its stored epoch to this.
|
|
36
|
+
* @returns {number}
|
|
37
|
+
*/
|
|
38
|
+
export function buildEpoch() {
|
|
39
|
+
return _epoch;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Invalidate every epoch-keyed memo. Call from any setter whose value feeds a
|
|
44
|
+
* per-build computation, synchronously and BEFORE any `await` in that setter,
|
|
45
|
+
* so a render racing the setter cannot refill a memo with pre-change state.
|
|
46
|
+
* @returns {number} the new epoch
|
|
47
|
+
*/
|
|
48
|
+
export function bumpBuildEpoch() {
|
|
49
|
+
return ++_epoch;
|
|
50
|
+
}
|
package/src/conditional-get.js
CHANGED
|
@@ -73,6 +73,29 @@ export const BUFFERED_MARKER = 'x-webjs-buffered';
|
|
|
73
73
|
/** Internal header the SSR pipeline stamps on a genuinely-streamed body. */
|
|
74
74
|
export const STREAM_MARKER = 'x-webjs-stream';
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The Response objects whose body the framework positively marked as fully
|
|
78
|
+
* buffered (`BUFFERED_MARKER`), recorded when the funnel strips the marker so
|
|
79
|
+
* the listener shells can still tell (#1644). A WeakSet, so a response holds
|
|
80
|
+
* nothing once it is sent.
|
|
81
|
+
* @type {WeakSet<Response>}
|
|
82
|
+
*/
|
|
83
|
+
const KNOWN_BUFFERED = new WeakSet();
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether `res` is a response the framework built from a string or bytes
|
|
87
|
+
* (an SSR page, a served module, a static file, a JSON response), as opposed
|
|
88
|
+
* to a user stream the shell must never read ahead of the client. True only
|
|
89
|
+
* for an object that passed through `applyConditionalGet` carrying the
|
|
90
|
+
* buffered marker; a response the funnel never saw, or a user `route.ts`
|
|
91
|
+
* `ReadableStream`, is false, and the shell then peeks it the slow, safe way.
|
|
92
|
+
* @param {Response} res
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
export function isBufferedResponse(res) {
|
|
96
|
+
return KNOWN_BUFFERED.has(res);
|
|
97
|
+
}
|
|
98
|
+
|
|
76
99
|
/**
|
|
77
100
|
* Headers that must not ride a 304 response (it has no body). Everything
|
|
78
101
|
* else (ETag, Cache-Control, Vary, the framework's X-Webjs-Build /
|
|
@@ -159,7 +182,16 @@ export async function applyConditionalGet(req, res) {
|
|
|
159
182
|
const method = req.method.toUpperCase();
|
|
160
183
|
// Always consume the internal buffered marker so it never reaches a client.
|
|
161
184
|
const buffered = res.headers.has(BUFFERED_MARKER);
|
|
162
|
-
if (buffered)
|
|
185
|
+
if (buffered) {
|
|
186
|
+
res.headers.delete(BUFFERED_MARKER);
|
|
187
|
+
// Remember the verdict off the headers (#1644), so the listener shell can
|
|
188
|
+
// read a KNOWN-buffered body whole instead of peeking it chunk by chunk
|
|
189
|
+
// through a reader (on Bun the reader machinery costs about 45 µs per
|
|
190
|
+
// response against 3 µs for `arrayBuffer()`). Keyed by the Response
|
|
191
|
+
// object the funnel hands to the shell; a 304 built below is a new,
|
|
192
|
+
// body-less object and is never marked.
|
|
193
|
+
KNOWN_BUFFERED.add(res);
|
|
194
|
+
}
|
|
163
195
|
// A genuinely streamed Suspense response is flagged by the SSR pipeline.
|
|
164
196
|
// Strip that marker too and skip conditional-GET so the live stream is
|
|
165
197
|
// never consumed.
|
package/src/context.js
CHANGED
|
@@ -178,9 +178,9 @@ setCspNonceProvider(() => {
|
|
|
178
178
|
return match ? match[1] : '';
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
181
|
+
// `cspNonce` is also reachable from @webjsdev/server for server-only modules
|
|
182
|
+
// (route handlers, actions); a layout or page imports it from @webjsdev/core
|
|
183
|
+
// so the module stays browser-isomorphic.
|
|
184
184
|
export { cspNonce };
|
|
185
185
|
|
|
186
186
|
/**
|
package/src/dev/handler.js
CHANGED
|
@@ -42,6 +42,9 @@ import { stripBasePath, withBasePath } from '../base-path.js';
|
|
|
42
42
|
import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
|
|
43
43
|
import { resolveActionIdentity } from '../form-action-identity.js';
|
|
44
44
|
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, resolveAssetUrl } from '../asset-hash.js';
|
|
45
|
+
import { bumpBuildEpoch } from '../build-epoch.js';
|
|
46
|
+
import { hintedBootModules } from '../ssr/shipped-modules.js';
|
|
47
|
+
import { createRetryBackoff } from './vendor-retry.js';
|
|
45
48
|
import { applySecurityHeaders, webRequestIsHttps } from '../headers.js';
|
|
46
49
|
import { resolveEmbedOrigins, setEmbedOrigins, allowEmbedFraming } from '../dev-embed.js';
|
|
47
50
|
import {
|
|
@@ -75,7 +78,7 @@ import {
|
|
|
75
78
|
import {
|
|
76
79
|
handleCore, loadMiddleware, tryServeFrameworkStatic, tryServePublicAsset,
|
|
77
80
|
} from './serve.js';
|
|
78
|
-
import { devImport } from '../dev-import.js';
|
|
81
|
+
import { devImport, clearProdImportCache } from '../dev-import.js';
|
|
79
82
|
|
|
80
83
|
/**
|
|
81
84
|
* A short content digest of a single file's bytes for the app-source deploy
|
|
@@ -588,8 +591,22 @@ export async function createRequestHandler(opts) {
|
|
|
588
591
|
// This is the request/probe-driven retry (no timer). Until it succeeds the
|
|
589
592
|
// served build id stays empty (reload-safe), so no navigation hard-reloads.
|
|
590
593
|
if (analysisDone && vendorAttemptedOnce) {
|
|
591
|
-
|
|
592
|
-
|
|
594
|
+
// Gated by a backoff window (#1644): before it, EVERY request during an
|
|
595
|
+
// outage restarted the resolve (the single flight only collapsed
|
|
596
|
+
// concurrent ones), re-running the scan, the manifest reads, and a jspm
|
|
597
|
+
// fetch per request for as long as the CDN was down. The window starts
|
|
598
|
+
// at 2 s and doubles to a minute; a success or a rebuild clears it. The
|
|
599
|
+
// verdict is recorded INSIDE the single flight (`resolveAndApplyVendor`),
|
|
600
|
+
// once per attempt, never here: every request that lands while an
|
|
601
|
+
// attempt is in flight shares its promise, and recording per caller
|
|
602
|
+
// widened the window once per concurrent request, so one failed attempt
|
|
603
|
+
// under load jumped straight to the cap.
|
|
604
|
+
if (vendorBackoff.allow()) {
|
|
605
|
+
const gen = vendorGen;
|
|
606
|
+
resolveAndApplyVendor().then((ok) => {
|
|
607
|
+
if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); }
|
|
608
|
+
}).catch(() => {});
|
|
609
|
+
}
|
|
593
610
|
return;
|
|
594
611
|
}
|
|
595
612
|
// Otherwise run the (single-flighted) full warm: the analysis, then the
|
|
@@ -756,6 +773,13 @@ export async function createRequestHandler(opts) {
|
|
|
756
773
|
: `[webjs] elision: disabled (WEBJS_ELIDE / webjs.elide), every module ships.`,
|
|
757
774
|
);
|
|
758
775
|
}
|
|
776
|
+
// The analysis stage is the last writer of per-build inputs (the
|
|
777
|
+
// elision fingerprint, the import-only and lazy sets, the route
|
|
778
|
+
// table), so every epoch-keyed memo is invalidated here once,
|
|
779
|
+
// unconditionally: a request that rendered in the pre-warm window
|
|
780
|
+
// hinted against an empty fingerprint and must not keep serving
|
|
781
|
+
// that memo (#1644).
|
|
782
|
+
bumpBuildEpoch();
|
|
759
783
|
analysisDone = true;
|
|
760
784
|
analysisEverDone = true;
|
|
761
785
|
ranAnalysis = true;
|
|
@@ -768,6 +792,9 @@ export async function createRequestHandler(opts) {
|
|
|
768
792
|
const ok = await resolveAndApplyVendor();
|
|
769
793
|
t.vendor = now() - m;
|
|
770
794
|
ranVendor = true;
|
|
795
|
+
// The attempt itself seeded the retry backoff (#1644): a transient
|
|
796
|
+
// first failure opened the first window, so the request-driven
|
|
797
|
+
// retry above does not fire on the very next request.
|
|
771
798
|
// Only memoize success (and only if a rebuild didn't intervene). A
|
|
772
799
|
// transient failure leaves vendorResolved false; the next ensureReady
|
|
773
800
|
// call re-attempts it non-blocking. A permanent unresolvable (jspm
|
|
@@ -826,6 +853,9 @@ export async function createRequestHandler(opts) {
|
|
|
826
853
|
// partial map resolved so the app is no worse off).
|
|
827
854
|
/** @type {Promise<boolean> | null} */
|
|
828
855
|
let vendorResolveInFlight = null;
|
|
856
|
+
// The retry gate for a transiently failed live resolve (#1644): see the
|
|
857
|
+
// request-driven retry in `ensureReady`. Pure state, cleared on a rebuild.
|
|
858
|
+
const vendorBackoff = createRetryBackoff();
|
|
829
859
|
function resolveAndApplyVendor() {
|
|
830
860
|
if (vendorResolveInFlight) return vendorResolveInFlight;
|
|
831
861
|
vendorResolveInFlight = (async () => {
|
|
@@ -847,9 +877,13 @@ export async function createRequestHandler(opts) {
|
|
|
847
877
|
({ imports, integrity } = prunePinToReachable(imports, integrity, reachable, { appDir }));
|
|
848
878
|
}
|
|
849
879
|
await setVendorEntries(imports, integrity);
|
|
880
|
+
// The backoff verdict is recorded here, once per attempt (#1644):
|
|
881
|
+
// every caller awaiting this flight shares the one promise.
|
|
882
|
+
if (v.ok) vendorBackoff.succeeded(); else vendorBackoff.failed();
|
|
850
883
|
return v.ok;
|
|
851
884
|
} catch (e) {
|
|
852
885
|
logger.error?.(`[webjs] vendor resolve failed (will retry on the next request):`, e);
|
|
886
|
+
vendorBackoff.failed();
|
|
853
887
|
return false;
|
|
854
888
|
}
|
|
855
889
|
})().finally(() => { vendorResolveInFlight = null; });
|
|
@@ -924,11 +958,20 @@ export async function createRequestHandler(opts) {
|
|
|
924
958
|
// best-effort (see emitRouteTypes).
|
|
925
959
|
if (dev) void emitRouteTypes();
|
|
926
960
|
clearVendorCache();
|
|
961
|
+
// A rebuild changes the bare-import set, so a failed resolve's backoff
|
|
962
|
+
// window no longer describes anything; the next request may try at once.
|
|
963
|
+
vendorBackoff.reset();
|
|
927
964
|
// Content-hash asset cache (#243): clear so a changed file re-hashes and
|
|
928
965
|
// its emitted `?v` busts the stale immutable copy. A no-op in dev (the
|
|
929
966
|
// module is never enabled there), kept for correctness + the deploy-busts
|
|
930
967
|
// regression test, which clears + re-hashes to observe a changed `?v`.
|
|
931
968
|
clearAssetHashCache();
|
|
969
|
+
// Explicit as well as implicit (`clearAssetHashCache` bumps too): a rebuild
|
|
970
|
+
// is a new build for every epoch-keyed memo, whatever else changed (#1644).
|
|
971
|
+
bumpBuildEpoch();
|
|
972
|
+
// A prod handler an embedded host rebuilds must re-import its route
|
|
973
|
+
// modules too (#1644); the plain `webjs start` path never gets here.
|
|
974
|
+
clearProdImportCache();
|
|
932
975
|
state.tsCache.clear();
|
|
933
976
|
// Invalidate the lazy analysis; the next request rebuilds the graph,
|
|
934
977
|
// component scan, gate, action index, middleware, elision, and vendor map.
|
|
@@ -1374,32 +1417,25 @@ export async function createRequestHandler(opts) {
|
|
|
1374
1417
|
if (matchPathname === null) return null;
|
|
1375
1418
|
const page = matchPage(state.routeTable, matchPathname);
|
|
1376
1419
|
if (!page) return null;
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
// (#
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
// A bare url would warm a different url and waste the hint. No-op in dev.
|
|
1397
|
-
// Best-effort: a request landing in the narrow pre-warm window (before the
|
|
1398
|
-
// elision verdict is set) hashes against an empty fingerprint, so the hint
|
|
1399
|
-
// can mismatch the post-warm body for that one request, only a wasted
|
|
1400
|
-
// speculative preload, never a stale body (the body url is authoritative).
|
|
1401
|
-
return withAssetHash(withBasePath(url, basePathValue), basePathValue);
|
|
1402
|
-
});
|
|
1420
|
+
// The boot's OWN assembly (#1644): the same inert drop, import-only splice
|
|
1421
|
+
// (#605), lazy-frontier diversion (#1524) and instrumentation-client
|
|
1422
|
+
// ordering (#848) the render emits, from one memoized function, so the
|
|
1423
|
+
// 103 Early Hints preload exactly the urls the body's boot + modulepreload
|
|
1424
|
+
// request (basePath THEN `?v`, #243) and never a module the boot does not
|
|
1425
|
+
// import. This used to be a second copy of the loop, which had drifted: it
|
|
1426
|
+
// knew nothing of lazy files, so a lazy frontier component was hinted
|
|
1427
|
+
// although the boot registers it with the lazy loader instead. Memoized
|
|
1428
|
+
// per route object and build epoch, so a warm GET costs one WeakMap read.
|
|
1429
|
+
// Best-effort in the pre-warm window as before: the analysis stage bumps
|
|
1430
|
+
// the epoch, so a hint computed against an empty fingerprint is recomputed
|
|
1431
|
+
// on the first warm request.
|
|
1432
|
+
const moduleUrls = hintedBootModules(page.route, {
|
|
1433
|
+
appDir,
|
|
1434
|
+
inert: state.inertRouteModules,
|
|
1435
|
+
importOnly: state.importOnlyRouteModules,
|
|
1436
|
+
lazyFiles: state.lazyComponentFiles,
|
|
1437
|
+
instrumentationClient: state.routeTable.instrumentationClient,
|
|
1438
|
+
}, basePathValue);
|
|
1403
1439
|
return { moduleUrls };
|
|
1404
1440
|
}
|
|
1405
1441
|
|
package/src/dev/helpers.js
CHANGED
|
@@ -4,7 +4,35 @@ import { stat } from 'node:fs/promises';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { withBasePath } from '../base-path.js';
|
|
7
|
-
import {
|
|
7
|
+
import { Readable } from 'node:stream';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
import { isBufferedResponse } from '../conditional-get.js';
|
|
10
|
+
import {
|
|
11
|
+
isCompressible, negotiateEncoding, createCompressor, compressBufferSync, readBufferedOrStream,
|
|
12
|
+
varyWithAcceptEncoding, MAX_SYNC_COMPRESS_BYTES, MIN_COMPRESS_BYTES,
|
|
13
|
+
} from '../listener-core.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Drive a response body through `pipeline` and resolve once it is sent, so
|
|
17
|
+
* `sendWebResponse` still completes when the body does (the reader loop it
|
|
18
|
+
* replaced did). A client that went away mid-body is the one error that is
|
|
19
|
+
* nobody's bug and is swallowed; anything else (a `route.ts` stream that
|
|
20
|
+
* errors after its first chunk, a compressor failure) propagates to the
|
|
21
|
+
* listener's catch and its `request pipeline threw` log line, as it did
|
|
22
|
+
* before #1644. By then `pipeline` has destroyed the response, so the
|
|
23
|
+
* listener's `res.end(...)` fallback is a no-op rather than a write after end.
|
|
24
|
+
* @param {import('node:stream').Readable} source
|
|
25
|
+
* @param {import('node:stream').Duplex | null} compressor
|
|
26
|
+
* @param {import('node:http').ServerResponse} res
|
|
27
|
+
*/
|
|
28
|
+
async function sendBody(source, compressor, res) {
|
|
29
|
+
try {
|
|
30
|
+
if (compressor) await pipeline(source, compressor, res);
|
|
31
|
+
else await pipeline(source, res);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
if (!(e && typeof e === 'object' && /** @type {{ code?: string }} */ (e).code === 'ERR_STREAM_PREMATURE_CLOSE')) throw e;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
8
36
|
|
|
9
37
|
/** @param {import('node:http').IncomingMessage} req @param {URL} url */
|
|
10
38
|
export function toWebRequest(req, url) {
|
|
@@ -61,47 +89,80 @@ export async function sendWebResponse(res, webRes, req, opts) {
|
|
|
61
89
|
headers[k] = v;
|
|
62
90
|
});
|
|
63
91
|
|
|
92
|
+
if (!webRes.body) { res.writeHead(webRes.status, headers); res.end(); return; }
|
|
93
|
+
|
|
64
94
|
// Negotiate compression via the SHARED seam (listener-core.js), so the node and
|
|
65
95
|
// Bun shells negotiate + compress identically (brotli > gzip > deflate, node:zlib
|
|
66
96
|
// both sides). Skip a body that is already content-encoded (a route.ts returning
|
|
67
97
|
// pre-compressed bytes), and merge into any pre-existing `Vary` rather than
|
|
68
98
|
// clobbering it (`isCompressible` already excludes `text/event-stream`).
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
res.writeHead(webRes.status, headers);
|
|
81
|
-
if (!webRes.body) { res.end(); return; }
|
|
99
|
+
const encoding = (opts?.compress && req && !headers['content-encoding'] && isCompressible(headers['content-type']))
|
|
100
|
+
? negotiateEncoding(req.headers['accept-encoding'])
|
|
101
|
+
: '';
|
|
102
|
+
// `encoding` decides WHETHER the response is compressed and is the coding a
|
|
103
|
+
// streamed body gets; a buffered body re-asks with its size (the small-body
|
|
104
|
+
// gzip preference, #1646). Either way the header is written once, here.
|
|
105
|
+
/** @param {string} enc */
|
|
106
|
+
const withEncoding = (enc) => {
|
|
107
|
+
headers['content-encoding'] = enc;
|
|
108
|
+
headers['vary'] = varyWithAcceptEncoding(typeof headers['vary'] === 'string' ? headers['vary'] : '');
|
|
109
|
+
};
|
|
82
110
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
111
|
+
// Buffered-body fast path, the same one the Bun shell has had since #756 and
|
|
112
|
+
// the node shell lacked (#1644): peek the body to tell a single bounded chunk
|
|
113
|
+
// (the common SSR page / JSON / file response) from a genuinely streamed one
|
|
114
|
+
// (Suspense, an action / route stream) WITHOUT buffering a real stream. A
|
|
115
|
+
// buffered body is compressed SYNCHRONOUSLY on the main thread and sent with
|
|
116
|
+
// its `content-length`; the node shell used to allocate a streaming zlib
|
|
117
|
+
// Transform per response and run it on the libuv threadpool even for a 2 KB
|
|
118
|
+
// page, which is also where the arena fragmentation that held RSS after a
|
|
119
|
+
// load burst came from. A body under `MIN_COMPRESS_BYTES` is sent as is on
|
|
120
|
+
// both shells: at that size the encoding costs more than the bytes it saves.
|
|
121
|
+
// A response the framework built from a string or bytes is read whole
|
|
122
|
+
// (`isBufferedResponse`, set when the funnel consumed the buffered marker);
|
|
123
|
+
// anything else, a user stream above all, is peeked without reading ahead.
|
|
124
|
+
const peeked = isBufferedResponse(webRes)
|
|
125
|
+
? { buffered: new Uint8Array(await webRes.arrayBuffer()) }
|
|
126
|
+
: await readBufferedOrStream(webRes.body, MAX_SYNC_COMPRESS_BYTES);
|
|
127
|
+
if (peeked.buffered !== undefined && peeked.buffered.byteLength <= MAX_SYNC_COMPRESS_BYTES) {
|
|
128
|
+
const buf = peeked.buffered;
|
|
129
|
+
if (encoding && buf.byteLength >= MIN_COMPRESS_BYTES) {
|
|
130
|
+
// The size is known now, so the small-body gzip preference applies (#1646).
|
|
131
|
+
const sized = negotiateEncoding(req.headers['accept-encoding'], buf.byteLength);
|
|
132
|
+
const out = compressBufferSync(sized, buf);
|
|
133
|
+
if (out) {
|
|
134
|
+
withEncoding(sized);
|
|
135
|
+
headers['content-length'] = String(out.length);
|
|
136
|
+
res.writeHead(webRes.status, headers);
|
|
137
|
+
res.end(out);
|
|
138
|
+
return;
|
|
91
139
|
}
|
|
92
|
-
} finally {
|
|
93
|
-
compressor.end();
|
|
94
140
|
}
|
|
141
|
+
if (headers['content-length'] === undefined) headers['content-length'] = String(buf.byteLength);
|
|
142
|
+
res.writeHead(webRes.status, headers);
|
|
143
|
+
res.end(buf.byteLength ? Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength) : undefined);
|
|
95
144
|
return;
|
|
96
145
|
}
|
|
97
146
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
147
|
+
// A genuinely streamed (or oversized) body keeps the streaming path, driven by
|
|
148
|
+
// `pipeline` so a client disconnect destroys the compressor and cancels the
|
|
149
|
+
// web reader instead of leaving a paused Transform with a live native handle,
|
|
150
|
+
// and so a slow client exerts backpressure on the source (the old reader loop
|
|
151
|
+
// never waited for `drain`). A known-buffered body over the sync cap is
|
|
152
|
+
// replayed as a single chunk through the same path.
|
|
153
|
+
const source = Readable.from(peeked.buffered !== undefined ? [peeked.buffered] : peeked.stream);
|
|
154
|
+
if (encoding) {
|
|
155
|
+
const compressor = createCompressor(encoding);
|
|
156
|
+
if (compressor) {
|
|
157
|
+
withEncoding(encoding);
|
|
158
|
+
delete headers['content-length'];
|
|
159
|
+
res.writeHead(webRes.status, headers);
|
|
160
|
+
await sendBody(source, compressor, res);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
103
163
|
}
|
|
104
|
-
res.
|
|
164
|
+
res.writeHead(webRes.status, headers);
|
|
165
|
+
await sendBody(source, null, res);
|
|
105
166
|
}
|
|
106
167
|
|
|
107
168
|
export const MIME = {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backoff for a failed live vendor resolve (#1644).
|
|
3
|
+
*
|
|
4
|
+
* Once the analysis is warm and the FIRST vendor attempt has failed
|
|
5
|
+
* transiently (a jspm timeout, a 5xx, no network), `ensureReady` re-attempts
|
|
6
|
+
* the resolve on the next request, non-blocking and single-flighted. Before
|
|
7
|
+
* this module, "the next request" meant EVERY request: the single flight only
|
|
8
|
+
* collapsed concurrent attempts, so a server behind a flaky CDN re-ran the
|
|
9
|
+
* bare-import scan, re-read `package.json` manifests, and fired a jspm fetch
|
|
10
|
+
* once per request for as long as the outage lasted, and every failed result
|
|
11
|
+
* was evicted from the jspm cache (`vendor/jspm.js`), so nothing short of a
|
|
12
|
+
* success ever quietened it.
|
|
13
|
+
*
|
|
14
|
+
* `createRetryBackoff` is the gate `ensureReady` consults: an attempt is
|
|
15
|
+
* allowed when no window is open; a failure opens one, doubling each time up
|
|
16
|
+
* to the cap; a success or a rebuild clears it. Pure and clock-injectable so
|
|
17
|
+
* the ladder is unit-testable without a server.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} RetryBackoff
|
|
22
|
+
* @property {() => boolean} allow Whether a new attempt is due now.
|
|
23
|
+
* @property {() => void} failed Record a failed attempt: open (or widen) the window.
|
|
24
|
+
* @property {() => void} succeeded Record a success: clear the window.
|
|
25
|
+
* @property {() => void} reset Forget everything (a rebuild).
|
|
26
|
+
* @property {() => number} windowMs The current window, for diagnostics.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {{ initialMs?: number, maxMs?: number, now?: () => number }} [opts]
|
|
31
|
+
* `initialMs` is the first window after a failure (default 2000), `maxMs`
|
|
32
|
+
* the cap the doubling stops at (default 60000), `now` the clock (default
|
|
33
|
+
* `Date.now`, injected by tests).
|
|
34
|
+
* @returns {RetryBackoff}
|
|
35
|
+
*/
|
|
36
|
+
export function createRetryBackoff(opts = {}) {
|
|
37
|
+
const initialMs = Math.max(0, opts.initialMs ?? 2000);
|
|
38
|
+
const maxMs = Math.max(initialMs, opts.maxMs ?? 60000);
|
|
39
|
+
const now = opts.now ?? Date.now;
|
|
40
|
+
let windowMs = 0;
|
|
41
|
+
let notBefore = 0;
|
|
42
|
+
return {
|
|
43
|
+
allow() {
|
|
44
|
+
return windowMs === 0 || now() >= notBefore;
|
|
45
|
+
},
|
|
46
|
+
failed() {
|
|
47
|
+
windowMs = windowMs === 0 ? initialMs : Math.min(maxMs, windowMs * 2);
|
|
48
|
+
notBefore = now() + windowMs;
|
|
49
|
+
},
|
|
50
|
+
succeeded() {
|
|
51
|
+
windowMs = 0;
|
|
52
|
+
notBefore = 0;
|
|
53
|
+
},
|
|
54
|
+
reset() {
|
|
55
|
+
this.succeeded();
|
|
56
|
+
},
|
|
57
|
+
windowMs() {
|
|
58
|
+
return windowMs;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/dev-import.js
CHANGED
|
@@ -70,6 +70,23 @@ export function contentVersion(file) {
|
|
|
70
70
|
const RETRY_KEYS = new Map();
|
|
71
71
|
let retrySeq = 0;
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Prod import memo (#1644): absolute file path -> the `import()` promise.
|
|
75
|
+
* @type {Map<string, Promise<any>>}
|
|
76
|
+
*/
|
|
77
|
+
const PROD_MODULES = new Map();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Forget every memoized prod import, so the next `devImport(file, false)`
|
|
81
|
+
* goes through the loader again. Called from the dev handler's rebuild path
|
|
82
|
+
* (an embedded host can call `rebuild()` on a prod handler); a plain
|
|
83
|
+
* `webjs start` never rebuilds and never needs it.
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
export function clearProdImportCache() {
|
|
87
|
+
PROD_MODULES.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
/**
|
|
74
91
|
* `import()` a module the dev server re-imports on every request, through
|
|
75
92
|
* `devImportSpecifier`. Use this rather than `import(devImportSpecifier(...))`.
|
|
@@ -79,8 +96,28 @@ let retrySeq = 0;
|
|
|
79
96
|
* @returns {Promise<any>}
|
|
80
97
|
*/
|
|
81
98
|
export async function devImport(file, dev) {
|
|
99
|
+
if (!dev) {
|
|
100
|
+
// The memo is consulted BEFORE the specifier is built: `pathToFileURL`
|
|
101
|
+
// was still paid on every hit, about 3 percent of a Bun request (#1646).
|
|
102
|
+
const hit = PROD_MODULES.get(file);
|
|
103
|
+
if (hit) return hit;
|
|
104
|
+
}
|
|
82
105
|
const base = devImportSpecifier(file, dev);
|
|
83
|
-
if (!dev)
|
|
106
|
+
if (!dev) {
|
|
107
|
+
// Prod memo (#1644). The URL is stable outside dev, so Node's module cache
|
|
108
|
+
// already serves one evaluation, but every `import()` still paid the ESM
|
|
109
|
+
// loader's resolve, the always-installed seed load hook, and
|
|
110
|
+
// `pathToFileURL`, about 5 to 12 µs each, and a page render does it for
|
|
111
|
+
// the page, its layouts, its metadata files, its loading templates, and
|
|
112
|
+
// each segment middleware on EVERY request. One Map hands back the same
|
|
113
|
+
// promise instead. A rejection is evicted so the next call re-imports (the
|
|
114
|
+
// retry semantic a cold import had), and `clearProdImportCache` drops the
|
|
115
|
+
// whole map on an embedded host's `rebuild()`.
|
|
116
|
+
const p = import(base);
|
|
117
|
+
PROD_MODULES.set(file, p);
|
|
118
|
+
p.catch(() => { if (PROD_MODULES.get(file) === p) PROD_MODULES.delete(file); });
|
|
119
|
+
return p;
|
|
120
|
+
}
|
|
84
121
|
const spec = RETRY_KEYS.get(base) || base;
|
|
85
122
|
try {
|
|
86
123
|
return await import(spec);
|
package/src/headers.js
CHANGED
|
@@ -270,8 +270,13 @@ export function webRequestIsHttps(req) {
|
|
|
270
270
|
const proto = req.headers.get('x-forwarded-proto');
|
|
271
271
|
if (proto) return proto.split(',')[0].trim().toLowerCase() === 'https';
|
|
272
272
|
}
|
|
273
|
+
// The request url is absolute on every runtime the shells run on, so the
|
|
274
|
+
// scheme is its prefix; parsing a URL per request to read five characters
|
|
275
|
+
// was a measurable share of a Bun request (#1646).
|
|
276
|
+
const url = req.url;
|
|
277
|
+
if (typeof url === 'string') return /^https:/i.test(url);
|
|
273
278
|
try {
|
|
274
|
-
return new URL(
|
|
279
|
+
return new URL(url).protocol === 'https:';
|
|
275
280
|
} catch {
|
|
276
281
|
return false;
|
|
277
282
|
}
|