@webjsdev/server 0.8.39 → 0.8.41
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/index.d.ts +9 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/action-error.js +56 -0
- package/src/action-stream.js +20 -4
- package/src/actions.js +26 -14
- package/src/cache-fn.js +28 -5
- package/src/cache-tags.js +43 -19
- package/src/cache.js +53 -0
- package/src/check.js +100 -19
- package/src/component-elision.js +67 -18
- package/src/dev.js +48 -4
- package/src/importmap.js +36 -0
- package/src/listener-bun.js +78 -33
- package/src/listener-core.js +175 -1
- package/src/module-graph.js +161 -17
- package/src/page-action.js +9 -0
- package/src/rate-limit.js +60 -5
- package/src/router.js +63 -6
- package/src/ssr.js +100 -2
- package/src/testing.d.ts +10 -0
- package/src/testing.js +40 -1
package/src/component-elision.js
CHANGED
|
@@ -126,6 +126,35 @@ export const CLIENT_LIFECYCLE_HOOKS = [
|
|
|
126
126
|
*/
|
|
127
127
|
export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate'];
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Static class fields whose declaration (to a non-`false` value) marks a
|
|
131
|
+
* component interactive (must ship). Kept as ONE registry so a new
|
|
132
|
+
* interactivity-relevant static convention is added in a single place; the
|
|
133
|
+
* per-class analysis loops over it. Unlike prototype methods (guarded by
|
|
134
|
+
* `lifecycle-coverage.test.js` via prototype introspection), there is no
|
|
135
|
+
* enumerable runtime source of "all static conventions", so the contract is
|
|
136
|
+
* a documented one: a new interactivity static field MUST be added here AND
|
|
137
|
+
* to the lifecycle table in agent-docs/components.md. `sigil-coverage.test.js`
|
|
138
|
+
* asserts each entry is honoured as a ship signal.
|
|
139
|
+
*
|
|
140
|
+
* - `shadow`: Declarative Shadow DOM attaches ONLY during HTML parsing, so a
|
|
141
|
+
* component that arrives via a client DOM insertion (a soft-nav swap, a
|
|
142
|
+
* streamed <webjs-suspense> boundary's replaceWith) would never re-attach
|
|
143
|
+
* its shadow root if elided. Context-free, so any shadow component ships.
|
|
144
|
+
* - `refresh`: `static refresh = true` is the explicit opt-in to ship a bare
|
|
145
|
+
* async component so its stale-while-revalidate refresh runs after SSR;
|
|
146
|
+
* eliding drops that on-load re-fetch (moot for request-stable data).
|
|
147
|
+
*
|
|
148
|
+
* @type {readonly string[]}
|
|
149
|
+
*/
|
|
150
|
+
export const INTERACTIVITY_STATIC_FIELDS = ['shadow', 'refresh'];
|
|
151
|
+
|
|
152
|
+
/** Why each INTERACTIVITY_STATIC_FIELDS entry forces a ship (analyser reason). */
|
|
153
|
+
const STATIC_FIELD_REASONS = {
|
|
154
|
+
shadow: 'declares static shadow (DSD must re-attach on a client swap)',
|
|
155
|
+
refresh: 'declares static refresh = true (keeps the on-load re-fetch)',
|
|
156
|
+
};
|
|
157
|
+
|
|
129
158
|
/**
|
|
130
159
|
* A bare `async render()` is NOT a standalone ship signal (#474). Its SSR
|
|
131
160
|
* pass bakes the resolved data into the first paint, so a light-DOM async
|
|
@@ -144,8 +173,37 @@ export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'reques
|
|
|
144
173
|
* re-fetch that eliding would drop).
|
|
145
174
|
*/
|
|
146
175
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Template binding-prefix classification, the anchor for the elision drift
|
|
178
|
+
* guard. Every prefix core's renderers recognise (core's `BINDING_PREFIXES`)
|
|
179
|
+
* is one of two kinds:
|
|
180
|
+
* - SSR_DROPPED_PREFIXES: a CLIENT-BEHAVIOUR ship signal. The binding drops
|
|
181
|
+
* at SSR and is wired only after hydration (`@event`), so a component whose
|
|
182
|
+
* only interactivity is such a binding MUST ship.
|
|
183
|
+
* - ROUND_TRIP_PREFIXES: an SSR-SAFE binding that survives into the served
|
|
184
|
+
* HTML (`.prop` via `data-webjs-prop-*`, `?bool` as a boolean attribute),
|
|
185
|
+
* so it is NOT a ship signal on its own and stays elidable.
|
|
186
|
+
* `sigil-coverage.test.js` asserts these two lists PARTITION `BINDING_PREFIXES`
|
|
187
|
+
* exactly (union equal, disjoint), so a new sigil cannot be added to the
|
|
188
|
+
* renderers without being classified here. That closes the one interactivity
|
|
189
|
+
* surface the prototype-introspection guard (`lifecycle-coverage.test.js`)
|
|
190
|
+
* cannot see, because a sigil is template syntax, not a method or an export.
|
|
191
|
+
*/
|
|
192
|
+
export const SSR_DROPPED_PREFIXES = ['@'];
|
|
193
|
+
export const ROUND_TRIP_PREFIXES = ['.', '?'];
|
|
194
|
+
|
|
195
|
+
/** Escape a single char for safe inclusion in a regex character class. */
|
|
196
|
+
const escapeForCharClass = (c) => c.replace(/[\\\]^-]/g, '\\$&');
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Match a client-behaviour binding (`@event=${...}`), a ship signal. The
|
|
200
|
+
* prefix set is DERIVED from SSR_DROPPED_PREFIXES (not hardcoded), so a new
|
|
201
|
+
* client-behaviour sigil added to core is detected here automatically once it
|
|
202
|
+
* is classified above. (unquoted per invariant 4)
|
|
203
|
+
*/
|
|
204
|
+
const EVENT_BINDING_RE = new RegExp(
|
|
205
|
+
`[${SSR_DROPPED_PREFIXES.map(escapeForCharClass).join('')}][A-Za-z][\\w-]*\\s*=\\s*\\$\\{`,
|
|
206
|
+
);
|
|
149
207
|
|
|
150
208
|
/** Match a `.onclick=${...}` (native event-handler property) binding. */
|
|
151
209
|
const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*\$\{/;
|
|
@@ -505,22 +563,13 @@ export function analyzeComponentSource(src) {
|
|
|
505
563
|
}
|
|
506
564
|
|
|
507
565
|
for (const { body, factoryArg } of bodies) {
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (declaresStaticTrue(body, 'shadow')) {
|
|
516
|
-
return { interactive: true, reason: 'declares static shadow (DSD must re-attach on a client swap)' };
|
|
517
|
-
}
|
|
518
|
-
// `static refresh = true` is the explicit opt-in to ship a bare async
|
|
519
|
-
// component so its stale-while-revalidate refresh runs ~after SSR; eliding
|
|
520
|
-
// a bare async component drops that on-load re-fetch (moot for request-
|
|
521
|
-
// stable data, the default), so an author who wants fresh-on-load opts in.
|
|
522
|
-
if (declaresStaticTrue(body, 'refresh')) {
|
|
523
|
-
return { interactive: true, reason: 'declares static refresh = true (keeps the on-load re-fetch)' };
|
|
566
|
+
// Interactivity-signal static conventions (`static shadow` / `static
|
|
567
|
+
// refresh = true`), driven by the INTERACTIVITY_STATIC_FIELDS registry so a
|
|
568
|
+
// new convention is added in one place (see its doc for why each ships).
|
|
569
|
+
for (const field of INTERACTIVITY_STATIC_FIELDS) {
|
|
570
|
+
if (declaresStaticTrue(body, field)) {
|
|
571
|
+
return { interactive: true, reason: STATIC_FIELD_REASONS[field] };
|
|
572
|
+
}
|
|
524
573
|
}
|
|
525
574
|
for (const hook of CLIENT_LIFECYCLE_HOOKS) {
|
|
526
575
|
// A client lifecycle hook as a method (`hook(`) OR as an arrow class
|
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';
|
|
@@ -685,6 +686,14 @@ export async function createRequestHandler(opts) {
|
|
|
685
686
|
middleware: null,
|
|
686
687
|
bodyLimits,
|
|
687
688
|
logger,
|
|
689
|
+
// Test-mode serve gate (#806). Set ONLY by the browser-test harness
|
|
690
|
+
// (`@webjsdev/server/testing`'s `createBrowserTestHandler`), NEVER by
|
|
691
|
+
// `webjs dev` / `webjs start`. When true the source-serve gate is relaxed
|
|
692
|
+
// so ANY app file under appDir is servable (a component a browser test
|
|
693
|
+
// imports is not route-reachable, so it is absent from `browserBoundFiles`
|
|
694
|
+
// and would 404). The `.server.*` guardrail (source -> RPC/throw stub) and
|
|
695
|
+
// the core / vendor serving are unchanged, so no server source is exposed.
|
|
696
|
+
testMode: opts.testMode === true,
|
|
688
697
|
moduleGraph: null,
|
|
689
698
|
elidableComponents: new Set(),
|
|
690
699
|
inertRouteModules: new Set(),
|
|
@@ -1221,18 +1230,31 @@ export async function createRequestHandler(opts) {
|
|
|
1221
1230
|
// Rewrite the Request with the stripped URL, preserving method,
|
|
1222
1231
|
// headers, and body. `duplex: 'half'` is required by the spec when
|
|
1223
1232
|
// a body stream is present on a non-GET/HEAD request.
|
|
1233
|
+
//
|
|
1234
|
+
// SECURITY (#756): this is a fresh Request object, so it is NOT in
|
|
1235
|
+
// the listener's out-of-band trusted-IP WeakMap (the Bun shell
|
|
1236
|
+
// stamps the IP there instead of cloning the Request to set the
|
|
1237
|
+
// header). Without carrying it forward, `clientIp` would fall back
|
|
1238
|
+
// to the inbound `x-webjs-remote-ip` header that the copied
|
|
1239
|
+
// `req.headers` still carries, which a client can spoof. So strip
|
|
1240
|
+
// that header on the rebuild and propagate the framework-trusted IP
|
|
1241
|
+
// across the new object, the same pattern page-action.js uses.
|
|
1242
|
+
const headers = new Headers(req.headers);
|
|
1243
|
+
headers.delete('x-webjs-remote-ip');
|
|
1224
1244
|
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
1225
|
-
|
|
1245
|
+
const next = new Request(
|
|
1226
1246
|
reqUrl.toString(),
|
|
1227
1247
|
/** @type {any} */ ({
|
|
1228
1248
|
method: req.method,
|
|
1229
|
-
headers
|
|
1249
|
+
headers,
|
|
1230
1250
|
body: hasBody ? req.body : undefined,
|
|
1231
1251
|
duplex: hasBody ? 'half' : undefined,
|
|
1232
1252
|
redirect: req.redirect,
|
|
1233
1253
|
signal: req.signal,
|
|
1234
1254
|
}),
|
|
1235
1255
|
);
|
|
1256
|
+
propagateTrustedRemoteIp(req, next);
|
|
1257
|
+
req = next;
|
|
1236
1258
|
}
|
|
1237
1259
|
}
|
|
1238
1260
|
}
|
|
@@ -1850,8 +1872,16 @@ async function handleCore(req, ctx) {
|
|
|
1850
1872
|
// files (.server.{js,ts}) get a stub via the guardrail below; they
|
|
1851
1873
|
// ARE included in browserBoundFiles because client code imports
|
|
1852
1874
|
// them by path (the import rewrites to an RPC stub at request time).
|
|
1853
|
-
|
|
1854
|
-
|
|
1875
|
+
// In test mode any app file is servable (see the `state.testMode` note
|
|
1876
|
+
// above); otherwise the file must be in the browser-bound module graph.
|
|
1877
|
+
const inGraph = state.testMode || (state.browserBoundFiles && state.browserBoundFiles.has(abs));
|
|
1878
|
+
// Containment: `abs` must be appDir itself or genuinely UNDER it. The
|
|
1879
|
+
// trailing `sep` (matching the public-asset branch) stops a `..` path that
|
|
1880
|
+
// resolves to a sibling sharing the appDir name-prefix (`/x/app` ->
|
|
1881
|
+
// `/x/app-secrets/...`) from passing in test mode, where graph membership
|
|
1882
|
+
// is not the gate.
|
|
1883
|
+
const underAppDir = abs === appDir || abs.startsWith(appDir + sep);
|
|
1884
|
+
if (underAppDir && inGraph && (await exists(abs))) {
|
|
1855
1885
|
// Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
|
|
1856
1886
|
// MUST NEVER be served as source to the browser. The extension is
|
|
1857
1887
|
// the path-level boundary; we re-verify it on every request (not
|
|
@@ -1903,6 +1933,20 @@ async function handleCore(req, ctx) {
|
|
|
1903
1933
|
}
|
|
1904
1934
|
return fileResponse(abs, { dev, immutable: versioned });
|
|
1905
1935
|
}
|
|
1936
|
+
// Dev hint (#751): the request is for a real app source module that EXISTS
|
|
1937
|
+
// on disk but is NOT in the browser-bound graph, so the gate 404s it. The
|
|
1938
|
+
// most common cause is a dynamic `import()` the static scanner cannot
|
|
1939
|
+
// track: a string-literal `import('./x.ts')` IS tracked and servable, but a
|
|
1940
|
+
// computed `import(expr)` / `import('./' + name)` cannot be resolved
|
|
1941
|
+
// statically and falls through here. Surface the likely cause instead of a
|
|
1942
|
+
// bare 404 so the author is not left guessing. Dev-only and diagnostic; it
|
|
1943
|
+
// does not change the 404 status, only the body + a server log line.
|
|
1944
|
+
if (dev && abs.startsWith(appDir) && /\.m?[jt]s$/.test(abs) && (await exists(abs))) {
|
|
1945
|
+
const rel = relative(appDir, abs);
|
|
1946
|
+
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.`;
|
|
1947
|
+
console.warn(hint);
|
|
1948
|
+
return new Response(hint, { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
1949
|
+
}
|
|
1906
1950
|
}
|
|
1907
1951
|
|
|
1908
1952
|
// 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
|
package/src/listener-bun.js
CHANGED
|
@@ -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
|
|
7
|
-
* compat on Bun vs `Bun.serve`) measured ~1.9x more req/s,
|
|
8
|
-
* a second shell rather than running the node compat path
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
const resp = await app.handle(
|
|
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`,
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* the
|
|
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
|
-
|
|
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.
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
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 });
|
package/src/listener-core.js
CHANGED
|
@@ -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 {
|
|
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
|