@webjsdev/server 0.8.89 → 0.8.91

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.89",
3
+ "version": "0.8.91",
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 transient read failure is NOT memoized (returning '' fails safe to the
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
+ }
@@ -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) res.headers.delete(BUFFERED_MARKER);
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.
@@ -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
- const gen = vendorGen;
592
- resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); } }).catch(() => {});
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
- // Mirror ssr.js's boot assembly EXACTLY: drop inert route modules, and
1378
- // splice an import-only module's component imports in place of the module
1379
- // (#605). Otherwise the 103 Early Hints would preload the page / layout
1380
- // modules the body no longer imports (a wasted hint) and miss the component
1381
- // modules it actually fetches (a double-fetch on the real module).
1382
- const inert = state.inertRouteModules;
1383
- const importOnly = state.importOnlyRouteModules;
1384
- const files = [];
1385
- const seenFiles = new Set();
1386
- for (const f of [page.route.file, ...page.route.layouts]) {
1387
- if (inert && inert.has(f)) continue;
1388
- const emit = importOnly && importOnly.get(f);
1389
- for (const t of emit || [f]) if (!seenFiles.has(t)) { seenFiles.add(t); files.push(t); }
1390
- }
1391
- const moduleUrls = files.map((f) => {
1392
- let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
1393
- const url = rel.split('\\').join('/').replace(/^\/?/, '/');
1394
- // Mirror ssr.js's emit (basePath THEN `?v`) so the 103 Early Hints preload
1395
- // the SAME url the body's modulepreload + boot specifiers request (#243).
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
 
@@ -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 { isCompressible, negotiateEncoding, createCompressor, varyWithAcceptEncoding } from '../listener-core.js';
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
- let compressor = null;
70
- if (opts?.compress && req && webRes.body && !headers['content-encoding'] && isCompressible(headers['content-type'])) {
71
- const encoding = negotiateEncoding(req.headers['accept-encoding']);
72
- compressor = createCompressor(encoding);
73
- if (compressor) {
74
- headers['content-encoding'] = encoding;
75
- headers['vary'] = varyWithAcceptEncoding(typeof headers['vary'] === 'string' ? headers['vary'] : '');
76
- delete headers['content-length'];
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
- if (compressor) {
84
- compressor.pipe(res);
85
- const reader = webRes.body.getReader();
86
- try {
87
- while (true) {
88
- const { done, value } = await reader.read();
89
- if (done) break;
90
- compressor.write(value);
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
- const reader = webRes.body.getReader();
99
- while (true) {
100
- const { done, value } = await reader.read();
101
- if (done) break;
102
- res.write(value);
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.end();
164
+ res.writeHead(webRes.status, headers);
165
+ await sendBody(source, null, res);
105
166
  }
106
167
 
107
168
  export const MIME = {
@@ -0,0 +1,59 @@
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
+ // Closures over the state, not methods reading `this`, so a handle can be
43
+ // destructured or passed as a callback (`onRebuild(backoff.reset)`).
44
+ const succeeded = () => {
45
+ windowMs = 0;
46
+ notBefore = 0;
47
+ };
48
+ return {
49
+ allow: () => windowMs === 0 || now() >= notBefore,
50
+ failed: () => {
51
+ windowMs = windowMs === 0 ? initialMs : Math.min(maxMs, windowMs * 2);
52
+ notBefore = now() + windowMs;
53
+ },
54
+ succeeded,
55
+ // A rebuild forgets the same thing a success does.
56
+ reset: succeeded,
57
+ windowMs: () => windowMs,
58
+ };
59
+ }
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) return import(base);
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(req.url).protocol === 'https:';
279
+ return new URL(url).protocol === 'https:';
275
280
  } catch {
276
281
  return false;
277
282
  }