@webjsdev/server 0.8.38 → 0.8.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/check.js CHANGED
@@ -1030,14 +1030,18 @@ export async function checkConventions(appDir) {
1030
1030
  * `middleware.ts` / `route.ts` (server-only, never page/layout/component
1031
1031
  * entries, so they are not in the candidate set to begin with).
1032
1032
  *
1033
- * Known limitation: a DYNAMIC `import('./x.server.ts')` is invisible to this
1034
- * rule. The framework's import scanner (`IMPORT_RE` in module-graph.js) matches
1035
- * only static `import`/`export … from`, not the `import(` call form, so a
1036
- * dynamic server import is not a graph edge here. This is deliberate and
1037
- * consistent with the framework-wide graph: a dynamic import is also not elided
1038
- * framework-wide, and its crash is deferred to call time (when the chunk is
1039
- * fetched), not module load. Catching it would mean teaching the shared scanner
1040
- * about `import(`, which is out of scope for this rule.
1033
+ * Scope note for dynamic imports: a string-literal `import('./widget.ts')` IS
1034
+ * tracked by the module graph now (#751), but only as a GATE edge (so the
1035
+ * lazily-imported module is servable); this rule's server-import detection
1036
+ * still runs over STATIC edges only (`transitiveDeps`), so a dynamic
1037
+ * `import('./x.server.ts')` of a no-`'use server'` utility is not flagged here.
1038
+ * That is deliberate: the throw-at-load crash is deferred to call time (when
1039
+ * the module is actually fetched), not module load, and a dynamic import is
1040
+ * also not elided framework-wide. A computed `import(expr)` cannot be resolved
1041
+ * statically at all; rather than a false-positive-prone check rule (a computed
1042
+ * import of an npm specifier or an otherwise-reachable app module is perfectly
1043
+ * valid, so it fails the check-is-correctness-only dividing line), the dev
1044
+ * server surfaces it with a 404 hint when the target 404s (see dev.js #751).
1041
1045
  *
1042
1046
  * @param {string} appDir
1043
1047
  * @param {Violation[]} violations appended to in place
package/src/dev.js CHANGED
@@ -87,6 +87,7 @@ function shouldAccessLog(pathname) {
87
87
  }
88
88
  import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from './importmap.js';
89
89
  import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
90
+ import { propagateTrustedRemoteIp } from './rate-limit.js';
90
91
  import { readAllowedOrigins } from './csrf.js';
91
92
  import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports } from './asset-hash.js';
92
93
  import { urlFromRequest } from './forwarded.js';
@@ -268,32 +269,6 @@ export async function readSeedEnabled(appDir) {
268
269
  return true;
269
270
  }
270
271
 
271
- /**
272
- * Read the Bun zero-install version-pinning switch (#685). The `WEBJS_PIN` env
273
- * override wins (same grammar as `WEBJS_SEED`), otherwise
274
- * `{ "webjs": { "pin": false } }` disables it. Default ON (opt-out). Consulted
275
- * only on Bun: the rewrite injects inline-version specifiers that Bun's
276
- * auto-install honors so zero-install fetches the pinned version, not latest
277
- * (#684). Node zero-install pinning is #669's separate concern.
278
- * @param {string} appDir
279
- * @returns {Promise<boolean>}
280
- */
281
- export async function readPinEnabled(appDir) {
282
- const raw = process.env.WEBJS_PIN;
283
- if (raw != null && raw !== '') {
284
- const v = String(raw).trim().toLowerCase();
285
- if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
286
- if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
287
- }
288
- try {
289
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
290
- if (pkg && pkg.webjs && pkg.webjs.pin === false) return false;
291
- } catch {
292
- // No package.json, malformed JSON, or unreadable. Keep the default.
293
- }
294
- return true;
295
- }
296
-
297
272
  /**
298
273
  * Read the client-router switch (`webjs.clientRouter`) from the app's
299
274
  * package.json (#629). Default `true`: the client router auto-enables in the
@@ -594,11 +569,7 @@ export async function createRequestHandler(opts) {
594
569
  // Read once (not per-rebuild): the hook is global and cannot be cleanly
595
570
  // un-installed, so toggling needs a restart. Disabled -> no hook, no ambient
596
571
  // collector wrap in ssr.js, and module loading stays byte-identical.
597
- // Pinning (#685) is Bun-only and independent of seeding, so install the hook
598
- // when EITHER is on (on Bun they share one onLoad).
599
- const seedEnabled = await readSeedEnabled(appDir);
600
- const pinEnabled = serverRuntime() === 'bun' && await readPinEnabled(appDir);
601
- if (seedEnabled || pinEnabled) await registerSeedHooks({ appDir, seedEnabled, pinEnabled });
572
+ if (await readSeedEnabled(appDir)) await registerSeedHooks();
602
573
 
603
574
  // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
604
575
  // deterministic vendor map that is cheap to read (one file, no analysis, no
@@ -1251,18 +1222,31 @@ export async function createRequestHandler(opts) {
1251
1222
  // Rewrite the Request with the stripped URL, preserving method,
1252
1223
  // headers, and body. `duplex: 'half'` is required by the spec when
1253
1224
  // a body stream is present on a non-GET/HEAD request.
1225
+ //
1226
+ // SECURITY (#756): this is a fresh Request object, so it is NOT in
1227
+ // the listener's out-of-band trusted-IP WeakMap (the Bun shell
1228
+ // stamps the IP there instead of cloning the Request to set the
1229
+ // header). Without carrying it forward, `clientIp` would fall back
1230
+ // to the inbound `x-webjs-remote-ip` header that the copied
1231
+ // `req.headers` still carries, which a client can spoof. So strip
1232
+ // that header on the rebuild and propagate the framework-trusted IP
1233
+ // across the new object, the same pattern page-action.js uses.
1234
+ const headers = new Headers(req.headers);
1235
+ headers.delete('x-webjs-remote-ip');
1254
1236
  const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
1255
- req = new Request(
1237
+ const next = new Request(
1256
1238
  reqUrl.toString(),
1257
1239
  /** @type {any} */ ({
1258
1240
  method: req.method,
1259
- headers: req.headers,
1241
+ headers,
1260
1242
  body: hasBody ? req.body : undefined,
1261
1243
  duplex: hasBody ? 'half' : undefined,
1262
1244
  redirect: req.redirect,
1263
1245
  signal: req.signal,
1264
1246
  }),
1265
1247
  );
1248
+ propagateTrustedRemoteIp(req, next);
1249
+ req = next;
1266
1250
  }
1267
1251
  }
1268
1252
  }
@@ -1933,6 +1917,20 @@ async function handleCore(req, ctx) {
1933
1917
  }
1934
1918
  return fileResponse(abs, { dev, immutable: versioned });
1935
1919
  }
1920
+ // Dev hint (#751): the request is for a real app source module that EXISTS
1921
+ // on disk but is NOT in the browser-bound graph, so the gate 404s it. The
1922
+ // most common cause is a dynamic `import()` the static scanner cannot
1923
+ // track: a string-literal `import('./x.ts')` IS tracked and servable, but a
1924
+ // computed `import(expr)` / `import('./' + name)` cannot be resolved
1925
+ // statically and falls through here. Surface the likely cause instead of a
1926
+ // bare 404 so the author is not left guessing. Dev-only and diagnostic; it
1927
+ // does not change the 404 status, only the body + a server log line.
1928
+ if (dev && abs.startsWith(appDir) && /\.m?[jt]s$/.test(abs) && (await exists(abs))) {
1929
+ const rel = relative(appDir, abs);
1930
+ const hint = `[webjs] 404: ${rel} exists but is not reachable from any browser-bound entry, so it is not servable. If you load it via a dynamic import(), use a STRING-LITERAL specifier (e.g. import('./x.ts')) so the scanner can track it; a computed import(expr) cannot be resolved statically and will 404. Otherwise this module is simply unreferenced by client code.`;
1931
+ console.warn(hint);
1932
+ return new Response(hint, { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' } });
1933
+ }
1936
1934
  }
1937
1935
 
1938
1936
  // Metadata routes: /sitemap.xml, /robots.txt, /icon, /opengraph-image, etc.
package/src/importmap.js CHANGED
@@ -459,6 +459,42 @@ export function buildImportMap(opts = {}) {
459
459
  return out;
460
460
  }
461
461
 
462
+ /**
463
+ * Resolve a set of bare vendor specifiers to `modulepreload` targets (#754), so
464
+ * SSR can flatten the vendor CDN waterfall: instead of discovering each vendor
465
+ * module level by level (fetch app module -> parse -> fetch vendor -> parse ...),
466
+ * the reached vendor URLs are hinted up front and fetched in parallel.
467
+ *
468
+ * The href is taken DIRECTLY from `buildImportMap().imports[spec]`, so it is
469
+ * BYTE-IDENTICAL to the importmap target (same base-path + `?v` rewrite): a
470
+ * differing href would make the browser treat the preload and the import as two
471
+ * resources and double-fetch. The matching `integrity` comes from the same map.
472
+ * A specifier NOT in the importmap (an unpinned / unreached / elided vendor)
473
+ * yields nothing, so this never over-fetches. Duplicate hrefs are collapsed.
474
+ *
475
+ * Framework runtime specifiers (`@webjsdev/core`...) are excluded: core is
476
+ * served same-origin and already on the boot path, not a CDN-waterfall vendor.
477
+ *
478
+ * @param {Iterable<string>} specifiers bare specifiers reached by the page
479
+ * @returns {Array<{ href: string, integrity?: string }>}
480
+ */
481
+ export function vendorPreloadTargets(specifiers) {
482
+ const specs = [...(specifiers || [])];
483
+ if (!specs.length) return [];
484
+ const map = buildImportMap();
485
+ /** @type {Array<{ href: string, integrity?: string }>} */
486
+ const out = [];
487
+ const seen = new Set();
488
+ for (const spec of specs) {
489
+ if (spec === '@webjsdev/core' || spec.startsWith('@webjsdev/core/')) continue;
490
+ const href = map.imports[spec];
491
+ if (!href || seen.has(href)) continue;
492
+ seen.add(href);
493
+ out.push({ href, integrity: map.integrity ? map.integrity[href] : undefined });
494
+ }
495
+ return out;
496
+ }
497
+
462
498
  /**
463
499
  * Derive the cross-origin vendor CDN origins from the resolved vendor
464
500
  * importmap, most-common first (issue #243, auto vendor preconnect). For an
@@ -3,9 +3,15 @@
3
3
  * selected when the host runtime is Bun. Bun's server is `Request`/`Response`
4
4
  * native, so this path skips the node:http `toWebRequest` / `sendWebResponse`
5
5
  * bridge entirely and hands the app's `handle(req): Response` straight to
6
- * `Bun.serve({ fetch })`. A benchmark on the full listening path (node:http
7
- * compat on Bun vs `Bun.serve`) measured ~1.9x more req/s, which is what justifies
8
- * a second shell rather than running the node compat path on Bun.
6
+ * `Bun.serve({ fetch })`. A microbenchmark of the LISTENING PATH ONLY (a trivial
7
+ * handler: node:http compat on Bun vs `Bun.serve`) measured ~1.9x more req/s,
8
+ * which is what justifies a second shell rather than running the node compat path
9
+ * on Bun. That figure is the listening-path delta, NOT end-to-end throughput: for
10
+ * a real SSR page the render cost dominates and the listener delta is small (see
11
+ * the end-to-end benchmark in `scripts/bench-listener.mjs`, #756). The hot path
12
+ * is kept allocation-lean: the remote IP is stamped out of band (no per-request
13
+ * `Request` clone, #756) and a buffered response is compressed synchronously (no
14
+ * per-response stream bridge).
9
15
  *
10
16
  * Feature parity with the node shell, routed through the shared
11
17
  * `listener-core.js` so the two cannot drift:
@@ -39,6 +45,7 @@ import { EventEmitter } from 'node:events';
39
45
  import { Readable, pipeline } from 'node:stream';
40
46
  import { matchApi } from './router.js';
41
47
  import { registerClient } from './broadcast.js';
48
+ import { setTrustedRemoteIp } from './rate-limit.js';
42
49
  import {
43
50
  isCompressible,
44
51
  isEventsPath,
@@ -47,8 +54,10 @@ import {
47
54
  makeShutdown,
48
55
  negotiateEncoding,
49
56
  createCompressor,
57
+ compressBufferSync,
58
+ MAX_SYNC_COMPRESS_BYTES,
50
59
  varyWithAcceptEncoding,
51
- webStreamChunks,
60
+ readBufferedOrStream,
52
61
  } from './listener-core.js';
53
62
 
54
63
  /* global Bun */
@@ -93,9 +102,9 @@ export function startBunListener(ctx) {
93
102
  // hints still ship in the rendered <head>, so the only loss is the
94
103
  // head-start during SSR compute, not the preloads themselves.
95
104
 
96
- const webReq = stampRemoteIp(req, srv);
97
- const resp = await app.handle(webReq);
98
- return compress ? maybeCompress(resp, req) : resp;
105
+ stampRemoteIp(req, srv);
106
+ const resp = await app.handle(req);
107
+ return compress ? await maybeCompress(resp, req) : resp;
99
108
  } catch (e) {
100
109
  logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) });
101
110
  return new Response(
@@ -287,20 +296,21 @@ function bunSseResponse(req, hub, app) {
287
296
  }
288
297
 
289
298
  /**
290
- * Stamp the framework-trusted remote IP from Bun's `server.requestIP`, after
291
- * stripping any spoofed inbound `x-webjs-remote-ip` (the node `toWebRequest`
292
- * equivalent, which reads `req.socket.remoteAddress`). Re-wraps the request with
293
- * the adjusted headers, preserving method + body.
299
+ * Stamp the framework-trusted remote IP from Bun's `server.requestIP`,
300
+ * out-of-band via `setTrustedRemoteIp` (#756). The previous implementation
301
+ * cloned the whole `Request` (`new Request(req, { headers })`) on EVERY request
302
+ * just to set one header; the WeakMap channel avoids that hot-path reallocation.
303
+ * The stamp is authoritative (`clientIp` reads it and ignores the inbound
304
+ * `x-webjs-remote-ip` header), so a client cannot spoof it even though the
305
+ * unmodified request still carries any inbound copy. Always stamps (with `''`
306
+ * when no IP is available) so the inbound header is never trusted on Bun.
294
307
  * @param {Request} req
295
308
  * @param {any} srv
296
309
  */
297
310
  function stampRemoteIp(req, srv) {
298
- const headers = new Headers(req.headers);
299
- headers.delete('x-webjs-remote-ip');
300
311
  let ip;
301
312
  try { ip = srv.requestIP(req)?.address; } catch {}
302
- if (ip) headers.set('x-webjs-remote-ip', ip);
303
- return new Request(req, { headers });
313
+ setTrustedRemoteIp(req, ip || '');
304
314
  }
305
315
 
306
316
  /**
@@ -308,35 +318,70 @@ function stampRemoteIp(req, srv) {
308
318
  * negotiation + compressor factory the node shell uses (shared in
309
319
  * `listener-core.js`). `node:zlib` runs natively on Bun, so this gives **brotli
310
320
  * on Bun** (the web `CompressionStream` used before had no brotli) and full
311
- * compression parity with the node shell. The body is bridged web -> node ->
312
- * web (`Readable.from(webStreamChunks(...))` -> `compressor` -> `Readable.toWeb`,
313
- * driven by `pipeline`; NOT `Readable.fromWeb`, which does not propagate a
314
- * mid-stream source error through `pipeline` on Bun, the #509 hang). Skips an
315
- * already-encoded body and a non-compressible media type (`isCompressible`
316
- * already excludes `text/event-stream`).
321
+ * compression parity with the node shell.
322
+ *
323
+ * A BUFFERED body (the common SSR page / JSON / file response, detected by
324
+ * `readBufferedOrStream` peeking a single bounded chunk) is compressed
325
+ * SYNCHRONOUSLY (`compressBufferSync`), skipping the per-response
326
+ * web -> node -> web stream bridge entirely (#756). A genuinely STREAMED body
327
+ * (Suspense, an action / route stream) or an oversized one keeps the bridge
328
+ * (`Readable.from(peeked.stream)` -> `compressor` -> `Readable.toWeb`, driven by
329
+ * `pipeline`; NOT `Readable.fromWeb`, which does not propagate a mid-stream
330
+ * source error through `pipeline` on Bun, the #509 hang). The sync and streamed
331
+ * paths use the SAME algo + params, so WITHIN a runtime the buffered fast path
332
+ * and the streaming bridge produce identical bytes (a buffered body is not
333
+ * served differently from a streamed one). Across runtimes the exact bytes can
334
+ * differ for gzip / deflate (Bun's bundled zlib is not the same build as Node's,
335
+ * so the compressed output is not guaranteed identical, byte-for-byte; brotli
336
+ * does match), which is fine since each response is self-describing via
337
+ * `content-encoding` and a client just decodes it. Skips an already-encoded body
338
+ * and a non-compressible media type (`isCompressible` already excludes
339
+ * `text/event-stream`).
317
340
  * @param {Response} resp
318
341
  * @param {Request} req
319
342
  */
320
- function maybeCompress(resp, req) {
343
+ async function maybeCompress(resp, req) {
321
344
  if (!resp.body) return resp;
322
345
  if (resp.headers.has('content-encoding')) return resp;
323
346
  if (!isCompressible(resp.headers.get('content-type'))) return resp;
324
347
  const encoding = negotiateEncoding(req.headers.get('accept-encoding'));
325
- const compressor = createCompressor(encoding);
326
- if (!compressor) return resp;
348
+ if (!encoding) return resp;
327
349
 
328
350
  const headers = new Headers(resp.headers);
329
351
  headers.set('content-encoding', encoding);
330
- headers.delete('content-length');
331
352
  headers.set('vary', varyWithAcceptEncoding(headers.get('vary')));
332
- // Feed the web body into the compressor through the reader-loop generator (NOT
333
- // Readable.fromWeb, which does not propagate a mid-stream source error through
334
- // `pipeline` on Bun, the #509 hang) and drive it with `pipeline` so a source
335
- // error (or a client disconnect destroying the output) tears down the whole
336
- // chain instead of leaking/hanging the compressor. Backpressure is preserved:
337
- // a slow client stalls `toWeb`, which stalls the compressor, which pauses the
338
- // source.
339
- const source = Readable.from(webStreamChunks(resp.body));
353
+
354
+ // Buffered-body fast path (#756): peek the body to tell a single bounded
355
+ // buffered chunk (the common SSR page / JSON / file response) from a genuinely
356
+ // streamed body (Suspense, an action / route stream) WITHOUT buffering a real
357
+ // stream. A buffered body cannot error mid-stream, so compress it synchronously
358
+ // and skip the per-response web -> node -> web stream bridge entirely. The sync
359
+ // path and the streaming bridge share the SAME algo + params, so within this
360
+ // runtime a buffered body and a streamed one compress identically (the wire is
361
+ // self-describing via content-encoding either way). A streamed / oversized body
362
+ // falls through to the bridge below.
363
+ const peeked = await readBufferedOrStream(resp.body, MAX_SYNC_COMPRESS_BYTES);
364
+ if (peeked.buffered !== undefined) {
365
+ const out = compressBufferSync(encoding, peeked.buffered);
366
+ headers.set('content-length', String(out.length));
367
+ return new Response(out, { status: resp.status, statusText: resp.statusText, headers });
368
+ }
369
+
370
+ headers.delete('content-length');
371
+ // Allocate the streaming compressor ONLY on the streamed path: the buffered
372
+ // fast path above used `compressBufferSync` and never needs a Transform, so
373
+ // hoisting this would waste a native zlib/brotli handle on the common case.
374
+ // `encoding` is guaranteed br/gzip/deflate (negotiateEncoding), so
375
+ // `createCompressor` never returns null here.
376
+ const compressor = createCompressor(encoding);
377
+ // Feed the (peeked + drained) web body into the compressor through the reader
378
+ // loop (NOT Readable.fromWeb, which does not propagate a mid-stream source
379
+ // error through `pipeline` on Bun, the #509 hang) and drive it with `pipeline`
380
+ // so a source error (or a client disconnect destroying the output) tears down
381
+ // the whole chain instead of leaking/hanging the compressor. Backpressure is
382
+ // preserved: a slow client stalls `toWeb`, which stalls the compressor, which
383
+ // pauses the source.
384
+ const source = Readable.from(peeked.stream);
340
385
  pipeline(source, compressor, () => {});
341
386
  const body = Readable.toWeb(compressor);
342
387
  return new Response(body, { status: resp.status, statusText: resp.statusText, headers });
@@ -16,7 +16,11 @@
16
16
  * and its own `closeServer` thunk, and reuses everything else verbatim.
17
17
  */
18
18
  import { pathToFileURL } from 'node:url';
19
- import { createBrotliCompress, createGzip, createDeflate, constants as zlibConstants } from 'node:zlib';
19
+ import {
20
+ createBrotliCompress, createGzip, createDeflate,
21
+ brotliCompressSync, gzipSync, deflateSync,
22
+ constants as zlibConstants,
23
+ } from 'node:zlib';
20
24
  import { stripBasePath } from './base-path.js';
21
25
 
22
26
  /** The dev live-reload SSE path (matched after base-path stripping). */
@@ -96,6 +100,37 @@ export function createCompressor(encoding) {
96
100
  return null;
97
101
  }
98
102
 
103
+ /**
104
+ * The largest buffered body that takes the SYNCHRONOUS compression fast path
105
+ * (#756). Sync `node:zlib` blocks the event loop, whereas the streaming
106
+ * compressor offloads to the libuv threadpool, so the sync path is a win ONLY
107
+ * for small bodies (a typical SSR HTML page) where the per-response stream
108
+ * bridge dominates; a larger buffered body keeps the non-blocking stream path.
109
+ */
110
+ export const MAX_SYNC_COMPRESS_BYTES = 256 * 1024;
111
+
112
+ /**
113
+ * Compress an already-buffered body synchronously, byte-for-byte identical to
114
+ * the streaming `createCompressor` ON THE SAME RUNTIME (same algorithm + params:
115
+ * brotli quality 4, gzip / deflate level 6, over the same `node:zlib`), so a
116
+ * buffered fast path and a streamed slow path produce the same wire bytes within
117
+ * a runtime (#756). Across runtimes the exact gzip / deflate bytes can differ
118
+ * (Bun's bundled zlib is not Node's build; brotli matches), which is fine since
119
+ * each response is self-describing via `content-encoding`. For a buffered body
120
+ * (one that cannot error mid-stream) this skips the per-response
121
+ * web -> node -> web stream bridge entirely. Returns null for an unknown / empty
122
+ * encoding so the caller leaves the body uncompressed.
123
+ * @param {'br' | 'gzip' | 'deflate' | ''} encoding
124
+ * @param {Uint8Array} buf
125
+ * @returns {Buffer | null}
126
+ */
127
+ export function compressBufferSync(encoding, buf) {
128
+ if (encoding === 'br') return brotliCompressSync(buf, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } });
129
+ if (encoding === 'gzip') return gzipSync(buf, { level: 6 });
130
+ if (encoding === 'deflate') return deflateSync(buf, { level: 6 });
131
+ return null;
132
+ }
133
+
99
134
  /**
100
135
  * Merge `Accept-Encoding` into an existing `Vary` header (or create it) without
101
136
  * duplicating, so compressing a response that already varies (on `Cookie`, an
@@ -135,6 +170,145 @@ export async function* webStreamChunks(web) {
135
170
  }
136
171
  }
137
172
 
173
+ /** An async generator that throws on first pull (a source that errored before
174
+ * any output), so the caller's `pipeline` tears down exactly as it would for a
175
+ * mid-stream error. @param {unknown} err */
176
+ async function* failingStream(err) { throw err; }
177
+
178
+ /** Yield a fixed set of already-read chunks with no further reads (the source is
179
+ * already exhausted). @param {Uint8Array[]} chunks */
180
+ async function* justChunks(chunks) { for (const c of chunks) yield c; }
181
+
182
+ /**
183
+ * Drain a reader after replaying already-peeked `prefix` chunks, mirroring
184
+ * `webStreamChunks`' error + early-cancel semantics (so the #509 hang fix is
185
+ * preserved for the reconstructed stream). If `pendingError` is set (the peek's
186
+ * second read rejected, a mid-stream source error), the prefix is replayed and
187
+ * then the error is rethrown.
188
+ * @param {Uint8Array[]} prefix
189
+ * @param {ReadableStreamDefaultReader} reader
190
+ * @param {unknown} pendingError
191
+ */
192
+ async function* drainAfter(prefix, reader, pendingError) {
193
+ let finished = false;
194
+ try {
195
+ for (const c of prefix) yield c;
196
+ if (pendingError) { finished = true; throw pendingError; }
197
+ for (;;) {
198
+ const { done, value } = await reader.read();
199
+ if (done) { finished = true; return; }
200
+ yield value;
201
+ }
202
+ } finally {
203
+ if (!finished) { try { await reader.cancel(); } catch {} }
204
+ try { reader.releaseLock(); } catch {}
205
+ }
206
+ }
207
+
208
+ /** Sentinel: the classifying second read has not settled within a macrotask, so
209
+ * the body is genuinely streaming and must not be withheld (#756). */
210
+ const STREAM_PENDING = Symbol('webjs.stream-pending');
211
+
212
+ /**
213
+ * Drain a reader for a STREAMED body whose classifying second read is still
214
+ * in flight (`secondP`, a settled-shape promise `{ r }` | `{ e }`). Yields the
215
+ * already-peeked `firstChunk`, then CONSUMES the in-flight second read (never
216
+ * issuing a duplicate `reader.read()`), then drains the rest. Same error +
217
+ * early-cancel teardown as `drainAfter` / `webStreamChunks` (the #509 fix).
218
+ * @param {Uint8Array} firstChunk
219
+ * @param {ReadableStreamDefaultReader} reader
220
+ * @param {Promise<{ r?: ReadableStreamReadResult<Uint8Array>, e?: unknown }>} secondP
221
+ */
222
+ async function* drainAfterPending(firstChunk, reader, secondP) {
223
+ let finished = false;
224
+ try {
225
+ yield firstChunk;
226
+ const settled = await secondP;
227
+ if (settled.e !== undefined) { finished = true; throw settled.e; }
228
+ if (settled.r.done) { finished = true; return; }
229
+ yield settled.r.value;
230
+ for (;;) {
231
+ const { done, value } = await reader.read();
232
+ if (done) { finished = true; return; }
233
+ yield value;
234
+ }
235
+ } finally {
236
+ if (!finished) { try { await reader.cancel(); } catch {} }
237
+ try { reader.releaseLock(); } catch {}
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Decide whether a web body is a single bounded buffered chunk (the common SSR
243
+ * page / JSON / file response) or a genuinely streamed body (Suspense, an action
244
+ * / route `ReadableStream`), WITHOUT buffering a real stream and WITHOUT
245
+ * withholding a streamed body's first byte (#756). It peeks the first chunk, then
246
+ * classifies on the SECOND read, but does NOT block on it: a buffered Response
247
+ * (built from a string / Uint8Array) has an already-closed underlying stream, so
248
+ * its second read settles on the next microtask; a streamed body's second chunk
249
+ * may be arbitrarily far in the future (a Suspense boundary resolving), and
250
+ * awaiting it would stall the status line + first byte until then. So the second
251
+ * read is RACED against a macrotask sentinel: if it settles first AND is `done`
252
+ * (single chunk) within `maxBytes`, the body is buffered and returned as bytes so
253
+ * the caller can sync-compress and skip the stream bridge; otherwise (still
254
+ * pending = streaming, or multi-chunk, oversized, empty, or a mid-stream error)
255
+ * it returns an async iterable that replays the peeked chunk(s) then drains the
256
+ * rest, with the same error + cancel semantics as `webStreamChunks`.
257
+ *
258
+ * @param {ReadableStream} web
259
+ * @param {number} maxBytes
260
+ * @returns {Promise<{ buffered: Uint8Array } | { stream: AsyncIterable<Uint8Array> }>}
261
+ */
262
+ export async function readBufferedOrStream(web, maxBytes) {
263
+ const reader = web.getReader();
264
+ let first;
265
+ try {
266
+ first = await reader.read();
267
+ } catch (e) {
268
+ // Source errored before any output: hand back a stream that rethrows it.
269
+ try { reader.releaseLock(); } catch {}
270
+ return { stream: failingStream(e) };
271
+ }
272
+ if (first.done) {
273
+ try { reader.releaseLock(); } catch {}
274
+ return { buffered: new Uint8Array(0) };
275
+ }
276
+ const firstChunk = first.value;
277
+
278
+ // Issue the classifying second read but DO NOT await it directly: race it
279
+ // against a macrotask sentinel so a streamed body (whose second chunk is far
280
+ // off) is handed back immediately as a stream instead of stalling first paint.
281
+ const secondP = reader.read().then((r) => ({ r }), (e) => ({ e }));
282
+ const settled = await Promise.race([
283
+ secondP,
284
+ new Promise((res) => { setImmediate(() => res(STREAM_PENDING)); }),
285
+ ]);
286
+ if (settled === STREAM_PENDING) {
287
+ // Still in flight after a macrotask: a genuinely streamed body. Emit the
288
+ // first chunk now and continue from the in-flight second read (consumed by
289
+ // the generator, never re-issued).
290
+ return { stream: drainAfterPending(firstChunk, reader, secondP) };
291
+ }
292
+ if (settled.e !== undefined) {
293
+ // The second read errored on a short body (a mid-stream source error):
294
+ // replay the first chunk then rethrow, matching webStreamChunks teardown.
295
+ return { stream: drainAfter([firstChunk], reader, settled.e) };
296
+ }
297
+ const second = settled.r;
298
+ if (second.done) {
299
+ try { reader.releaseLock(); } catch {}
300
+ if (firstChunk.byteLength <= maxBytes) return { buffered: firstChunk };
301
+ // One chunk but too large to block the event loop on a sync compress: stream
302
+ // it. The source is already exhausted (second read was `done`), so just
303
+ // replay the single chunk with no further reads.
304
+ return { stream: justChunks([firstChunk]) };
305
+ }
306
+ // Two quick chunks (a small multi-chunk buffered body): not a single buffer,
307
+ // so stream it (replay both, then drain the rest). The second read is already
308
+ // consumed, so drainAfter continues from the third chunk.
309
+ return { stream: drainAfter([firstChunk, second.value], reader, null) };
310
+ }
311
+
138
312
  /**
139
313
  * Load a `route.{js,ts}` module for its `WS` export, cache-busting in dev so a
140
314
  * code edit is picked up per connection. Shared by the node WebSocket subsystem