@webjsdev/server 0.8.27 → 0.8.29

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.
@@ -19,8 +19,10 @@
19
19
  * Design notes:
20
20
  *
21
21
  * - **Streaming write.** `put` never buffers the whole file. It pipes
22
- * `file.stream()` -> `Readable.fromWeb` -> `createWriteStream` via
23
- * `node:stream/promises.pipeline`, so a large upload uses constant memory.
22
+ * `file.stream()` -> a reader-loop async generator (`webStreamChunks`) ->
23
+ * `createWriteStream` via `node:stream/promises.pipeline`, so a large upload
24
+ * uses constant memory (the reader loop instead of `Readable.fromWeb` so a
25
+ * mid-stream source error propagates through `pipeline` on Bun too, #509).
24
26
  * The upstream body-size cap (#237, `maxMultipartBytes`) bounds the size
25
27
  * BEFORE the bytes ever reach the store, so the store does not re-implement
26
28
  * a limit; it only stays streaming.
@@ -44,6 +46,7 @@ import { dirname, join, resolve, sep, extname } from 'node:path';
44
46
  import { Readable } from 'node:stream';
45
47
  import { pipeline } from 'node:stream/promises';
46
48
  import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto';
49
+ import { webStreamChunks } from './listener-core.js';
47
50
 
48
51
  /**
49
52
  * @typedef {Object} StoredObjectHandle
@@ -181,15 +184,19 @@ export function assertSafeKey(dir, key) {
181
184
  * @returns {{ stream: import('node:stream').Readable, contentType: string | null }}
182
185
  */
183
186
  function toNodeStream(file) {
184
- // A Blob / File exposes a web ReadableStream via `.stream()`; converting it
185
- // with Readable.fromWeb keeps it streaming (no `.arrayBuffer()`).
187
+ // A Blob / File exposes a web ReadableStream via `.stream()`; consuming it
188
+ // through a reader-loop async generator (NOT `Readable.fromWeb`) keeps it
189
+ // streaming AND propagates a mid-stream source error reliably on both Node and
190
+ // Bun. `Readable.fromWeb` does NOT forward a web-stream error through
191
+ // `stream/promises.pipeline` on Bun (the `pipeline` promise never settles, so
192
+ // `put` would hang instead of rejecting + cleaning up the partial file, #509).
186
193
  if (file && typeof file.stream === 'function' && typeof file.size === 'number') {
187
194
  const contentType = typeof file.type === 'string' && file.type ? file.type : null;
188
- return { stream: Readable.fromWeb(file.stream()), contentType };
195
+ return { stream: Readable.from(webStreamChunks(file.stream())), contentType };
189
196
  }
190
197
  // A web ReadableStream directly.
191
198
  if (file && typeof file.getReader === 'function') {
192
- return { stream: Readable.fromWeb(/** @type {ReadableStream} */ (file)), contentType: null };
199
+ return { stream: Readable.from(webStreamChunks(/** @type {ReadableStream} */ (file))), contentType: null };
193
200
  }
194
201
  // A Uint8Array / Buffer: wrap as a single-chunk readable. This DOES hold the
195
202
  // bytes the caller already has in memory, but it does not COPY-buffer them
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Bun.serve listener adapter (#511): the `Bun.serve` shell for `startServer`,
3
+ * selected when the host runtime is Bun. Bun's server is `Request`/`Response`
4
+ * native, so this path skips the node:http `toWebRequest` / `sendWebResponse`
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.
9
+ *
10
+ * Feature parity with the node shell, routed through the shared
11
+ * `listener-core.js` so the two cannot drift:
12
+ * - **SSE live-reload** (#264): a streaming `text/event-stream` `Response` whose
13
+ * `ReadableStream` controller is registered in the shared `SseHub`; the
14
+ * request `AbortSignal` (and the stream `cancel`) deregister it on disconnect.
15
+ * - **WebSocket upgrade**: a `route.ts` `WS` export. Bun's native WS dispatches
16
+ * through server-level `websocket` handlers (not the node `ws`-library
17
+ * EventEmitter contract the `WS(ws, …)` export expects), so a small
18
+ * `BunWsAdapter` re-exposes `.on('message')` / `.on('close')` / `.send()` /
19
+ * `.readyState` over Bun's `ServerWebSocket`, keeping the handler contract and
20
+ * the `broadcast()` registry identical across runtimes.
21
+ * - **Compression**: brotli / gzip / deflate via `node:zlib` (which runs
22
+ * natively on Bun), the SAME negotiation + compressor factory the node shell
23
+ * uses (shared in `listener-core.js`), so the Bun path gets brotli too (#517).
24
+ * - **Timeouts** (#237): the node `requestTimeout` maps to Bun's single
25
+ * `idleTimeout` (seconds), clamped above the 25s SSE keepalive so a dev
26
+ * live-reload stream is never reaped.
27
+ * - **Proxy IP**: `server.requestIP(req).address` stamps the framework-trusted
28
+ * `x-webjs-remote-ip` (the node socket-address equivalent), after stripping
29
+ * any spoofed inbound value.
30
+ *
31
+ * Not supported on Bun (documented, node-only): **103 Early Hints**. `Bun.serve`
32
+ * exposes no API to write an informational response before the body.
33
+ *
34
+ * This module is imported DYNAMICALLY by `dev.js` only when `serverRuntime()` is
35
+ * `'bun'`, so the `Bun.*` global is never referenced on Node. It still LINKS on
36
+ * Node (the global is only read inside functions), but it is never loaded there.
37
+ */
38
+ import { EventEmitter } from 'node:events';
39
+ import { Readable, pipeline } from 'node:stream';
40
+ import { matchApi } from './router.js';
41
+ import { registerClient } from './broadcast.js';
42
+ import {
43
+ isCompressible,
44
+ isEventsPath,
45
+ loadWsModule,
46
+ installProcessHandlers,
47
+ makeShutdown,
48
+ negotiateEncoding,
49
+ createCompressor,
50
+ varyWithAcceptEncoding,
51
+ webStreamChunks,
52
+ } from './listener-core.js';
53
+
54
+ /* global Bun */
55
+
56
+ /**
57
+ * Start the Bun.serve listener.
58
+ * @param {import('./listener-types.js').ListenerContext} ctx
59
+ * @returns {{ server: any, close: () => Promise<void> }}
60
+ */
61
+ export function startBunListener(ctx) {
62
+ const { app, dev, compress, logger, hub, port, basePathStr, timeouts, watcherAbort } = ctx;
63
+
64
+ const server = Bun.serve({
65
+ port,
66
+ idleTimeout: bunIdleTimeout(timeouts),
67
+ // webjs owns its dev error overlay (over SSE) and handles thrown errors in
68
+ // `fetch`, so Bun's own development error page must never interfere; keep it
69
+ // deterministic (off) rather than tied to NODE_ENV.
70
+ development: false,
71
+ /**
72
+ * @param {Request} req
73
+ * @param {any} srv the Bun server (requestIP / upgrade)
74
+ */
75
+ async fetch(req, srv) {
76
+ try {
77
+ const url = new URL(req.url);
78
+
79
+ // SSE live-reload stream (dev only); does not fit the req->Response model
80
+ // on node, but on Bun it is a perfectly ordinary streaming Response.
81
+ if (isEventsPath(url.pathname, basePathStr)) {
82
+ if (!dev) return new Response(null, { status: 404 });
83
+ return bunSseResponse(req, hub, app);
84
+ }
85
+
86
+ // WebSocket upgrade: a route.ts exporting WS.
87
+ if ((req.headers.get('upgrade') || '').toLowerCase() === 'websocket') {
88
+ return await bunUpgrade(req, srv, ctx);
89
+ }
90
+
91
+ // 103 Early Hints are intentionally skipped: Bun.serve has no API to
92
+ // write an informational response before the body. The modulepreload
93
+ // hints still ship in the rendered <head>, so the only loss is the
94
+ // head-start during SSR compute, not the preloads themselves.
95
+
96
+ const webReq = stampRemoteIp(req, srv);
97
+ const resp = await app.handle(webReq);
98
+ return compress ? maybeCompress(resp, req) : resp;
99
+ } catch (e) {
100
+ logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) });
101
+ return new Response(
102
+ dev && e instanceof Error ? `webjs error: ${e.stack}` : 'Internal server error',
103
+ { status: 500, headers: { 'content-type': 'text/plain' } },
104
+ );
105
+ }
106
+ },
107
+ websocket: {
108
+ /** @param {any} ws */
109
+ open(ws) {
110
+ const d = ws.data;
111
+ try {
112
+ d.wrapper._bind(ws);
113
+ registerClient(d.pathname, d.wrapper);
114
+ d.mod.WS(d.wrapper, d.req, { params: d.params });
115
+ } catch (e) {
116
+ logger.error('WebSocket handler threw', {
117
+ err: e instanceof Error ? e.stack || e.message : String(e),
118
+ });
119
+ try { ws.close(1011, 'Internal error'); } catch {}
120
+ }
121
+ },
122
+ /** @param {any} ws @param {string | Buffer} message */
123
+ message(ws, message) {
124
+ try { ws.data.wrapper.emit('message', message); } catch {}
125
+ },
126
+ /** @param {any} ws @param {number} code @param {string} reason */
127
+ close(ws, code, reason) {
128
+ const w = ws.data.wrapper;
129
+ w.readyState = 3;
130
+ try { w.emit('close', code, reason); } catch {}
131
+ },
132
+ },
133
+ });
134
+
135
+ logger.info(
136
+ `webjs ${dev ? 'dev' : 'prod'} server ready on http://localhost:${server.port} (Bun ${process.versions.bun})`,
137
+ );
138
+ // Warm the first-request analysis in the background; listening does not wait
139
+ // on it, matching the node shell.
140
+ app.warmup();
141
+
142
+ const closeServer = () => Promise.resolve(server.stop(true));
143
+ const shutdown = makeShutdown({ closeServer, hub, logger });
144
+ process.once('SIGINT', () => shutdown('SIGINT'));
145
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
146
+ installProcessHandlers(logger, () => shutdown('uncaughtException'));
147
+
148
+ return {
149
+ server,
150
+ close: () => {
151
+ if (watcherAbort) watcherAbort.abort();
152
+ hub.closeAll();
153
+ return Promise.resolve(server.stop(true)).then(() => undefined);
154
+ },
155
+ };
156
+ }
157
+
158
+ /**
159
+ * The node `ws`-library WebSocket contract (`.on('message')` / `.on('close')` /
160
+ * `.send()` / `.readyState` / `.close()`) re-exposed over a Bun `ServerWebSocket`,
161
+ * which otherwise dispatches messages through server-level handlers rather than
162
+ * per-socket events. Constructed at upgrade time and bound to the real socket in
163
+ * the `open` handler; messages/close are forwarded to its emitter from the
164
+ * `websocket.message` / `websocket.close` handlers.
165
+ *
166
+ * Only `'message'` and `'close'` are forwarded (the events webjs WS handlers and
167
+ * `broadcast()` use). Bun exposes no per-socket `'error'` / `'ping'` / `'pong'`
168
+ * handler to bridge; a socket failure on Bun surfaces as a `'close'`, so
169
+ * `'close'`-keyed cleanup still runs. Documented in the WebSockets doc.
170
+ */
171
+ class BunWsAdapter extends EventEmitter {
172
+ constructor() {
173
+ super();
174
+ /** @type {any} */
175
+ this._ws = null;
176
+ // WebSocket.readyState constants: 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED.
177
+ this.readyState = 0;
178
+ }
179
+
180
+ /** @param {any} ws the Bun ServerWebSocket, available once `open` fires */
181
+ _bind(ws) {
182
+ this._ws = ws;
183
+ this.readyState = 1;
184
+ }
185
+
186
+ /** @param {string | ArrayBufferView | ArrayBuffer} data */
187
+ send(data) {
188
+ try { if (this._ws) this._ws.send(data); } catch {}
189
+ }
190
+
191
+ /** @param {number} [code] @param {string} [reason] */
192
+ close(code, reason) {
193
+ try { if (this._ws) this._ws.close(code, reason); } catch {}
194
+ }
195
+
196
+ get bufferedAmount() {
197
+ try { return this._ws ? this._ws.getBufferedAmount() : 0; } catch { return 0; }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Resolve a `route.ts` `WS` export and upgrade the connection. Mirrors the node
203
+ * `attachWebSocket` path (match the route, load the module, require a `WS`
204
+ * function) but drives Bun's `server.upgrade`. Returns `undefined` on a
205
+ * successful upgrade (Bun's contract: the fetch handler must not return a
206
+ * Response once the socket is taken over).
207
+ * @param {Request} req
208
+ * @param {any} srv
209
+ * @param {import('./listener-types.js').ListenerContext} ctx
210
+ */
211
+ async function bunUpgrade(req, srv, ctx) {
212
+ const url = new URL(req.url);
213
+ // Matches the node WS path, which keys on the full pathname (no base-path
214
+ // stripping) and on `registerClient(url.pathname, …)`.
215
+ const match = matchApi(ctx.app.getRouteTable(), url.pathname);
216
+ if (!match) return new Response('Not Found', { status: 404 });
217
+
218
+ let mod;
219
+ try {
220
+ mod = await loadWsModule(match.route.file, ctx.dev);
221
+ } catch (e) {
222
+ ctx.logger.error('WebSocket module load failed', { err: String(e) });
223
+ return new Response('Upgrade failed', { status: 500 });
224
+ }
225
+ if (typeof mod.WS !== 'function') {
226
+ return new Response('Upgrade not supported at this route', { status: 426 });
227
+ }
228
+
229
+ const wrapper = new BunWsAdapter();
230
+ const handlerReq = upgradeRequest(req, url);
231
+ const ok = srv.upgrade(req, {
232
+ data: { wrapper, mod, req: handlerReq, params: match.params, pathname: url.pathname },
233
+ });
234
+ if (ok) return undefined;
235
+ return new Response('Upgrade failed', { status: 500 });
236
+ }
237
+
238
+ /**
239
+ * Best-effort `Request` for the WS handler: headers + GET + URL, no body (it is a
240
+ * handshake). Mirrors the node `buildRequestFromUpgrade` so the handler reads the
241
+ * same cookies/auth either side.
242
+ * @param {Request} req
243
+ * @param {URL} url
244
+ */
245
+ function upgradeRequest(req, url) {
246
+ const headers = new Headers();
247
+ req.headers.forEach((v, k) => { if (!k.startsWith(':')) headers.set(k, v); });
248
+ return new Request(url, { method: 'GET', headers });
249
+ }
250
+
251
+ /**
252
+ * Build the dev live-reload SSE `Response`: a streaming `text/event-stream` whose
253
+ * controller is registered in the shared `SseHub`. The request `AbortSignal` and
254
+ * the stream `cancel` both deregister the client on disconnect.
255
+ * @param {Request} req
256
+ * @param {import('./listener-core.js').SseHub} hub
257
+ * @param {any} app
258
+ */
259
+ function bunSseResponse(req, hub, app) {
260
+ const enc = new TextEncoder();
261
+ /** @type {{ send: (s: string) => void, close: () => void } | null} */
262
+ let client = null;
263
+ const stream = new ReadableStream({
264
+ start(controller) {
265
+ client = {
266
+ send: (s) => { try { controller.enqueue(enc.encode(s)); } catch {} },
267
+ close: () => { try { controller.close(); } catch {} },
268
+ };
269
+ controller.enqueue(enc.encode('event: hello\ndata: webjs\n\n'));
270
+ hub.add(client);
271
+ // Replay an unresolved dev error (#264) so a tab connecting AFTER the
272
+ // breaking edit still shows the overlay.
273
+ const pending = app.getLastDevError && app.getLastDevError();
274
+ if (pending) {
275
+ try { controller.enqueue(enc.encode(`event: webjs-error\ndata: ${JSON.stringify(pending)}\n\n`)); } catch {}
276
+ }
277
+ },
278
+ cancel() { if (client) hub.remove(client); },
279
+ });
280
+ if (req.signal) {
281
+ req.signal.addEventListener('abort', () => { if (client) hub.remove(client); });
282
+ }
283
+ return new Response(stream, {
284
+ status: 200,
285
+ headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' },
286
+ });
287
+ }
288
+
289
+ /**
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.
294
+ * @param {Request} req
295
+ * @param {any} srv
296
+ */
297
+ function stampRemoteIp(req, srv) {
298
+ const headers = new Headers(req.headers);
299
+ headers.delete('x-webjs-remote-ip');
300
+ let ip;
301
+ try { ip = srv.requestIP(req)?.address; } catch {}
302
+ if (ip) headers.set('x-webjs-remote-ip', ip);
303
+ return new Request(req, { headers });
304
+ }
305
+
306
+ /**
307
+ * Compress a `Response` via `node:zlib` (brotli / gzip / deflate), the SAME
308
+ * negotiation + compressor factory the node shell uses (shared in
309
+ * `listener-core.js`). `node:zlib` runs natively on Bun, so this gives **brotli
310
+ * 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`).
317
+ * @param {Response} resp
318
+ * @param {Request} req
319
+ */
320
+ function maybeCompress(resp, req) {
321
+ if (!resp.body) return resp;
322
+ if (resp.headers.has('content-encoding')) return resp;
323
+ if (!isCompressible(resp.headers.get('content-type'))) return resp;
324
+ const encoding = negotiateEncoding(req.headers.get('accept-encoding'));
325
+ const compressor = createCompressor(encoding);
326
+ if (!compressor) return resp;
327
+
328
+ const headers = new Headers(resp.headers);
329
+ headers.set('content-encoding', encoding);
330
+ headers.delete('content-length');
331
+ 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));
340
+ pipeline(source, compressor, () => {});
341
+ const body = Readable.toWeb(compressor);
342
+ return new Response(body, { status: resp.status, statusText: resp.statusText, headers });
343
+ }
344
+
345
+ /**
346
+ * Map the node `requestTimeout` (ms) to Bun's single `idleTimeout` (seconds). Bun
347
+ * caps it at 255s; we clamp it above the 25s SSE keepalive so a dev live-reload
348
+ * stream is never reaped as idle. `0` (the node "disable" sentinel) disables it
349
+ * on Bun too.
350
+ * @param {{ requestTimeout?: number } | undefined} timeouts
351
+ */
352
+ function bunIdleTimeout(timeouts) {
353
+ const reqMs = timeouts && timeouts.requestTimeout;
354
+ if (reqMs === 0) return 0;
355
+ let secs = Math.ceil((reqMs || 30_000) / 1000);
356
+ if (secs < 30) secs = 30;
357
+ if (secs > 255) secs = 255;
358
+ return secs;
359
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Runtime-neutral listener core (#511): the pieces both the node:http shell and
3
+ * the `Bun.serve` shell share, so the two adapters cannot drift.
4
+ *
5
+ * `createRequestHandler` already returns a runtime-agnostic `handle(req): Response`.
6
+ * The remaining shell work (accept a socket, dispatch SSE live-reload, upgrade a
7
+ * WebSocket, apply timeouts + compression, run graceful shutdown) is split here
8
+ * into the genuinely-shared decision logic (this module) and the irreducibly
9
+ * transport-specific glue (the node `res.write` path in `dev.js`, the `Bun.serve`
10
+ * streaming-`Response` path in `listener-bun.js`). The SSE registry + fanout, the
11
+ * live-reload path predicate, the WS module loader, the runtime detector, and the
12
+ * lifecycle wiring all live here so a feature added to one shell is added to both.
13
+ *
14
+ * The seam also sets up future `Deno.serve` / embedded-host adapters: a new shell
15
+ * supplies its own client wrapper to `SseHub`, its own socket-to-`Request` bridge,
16
+ * and its own `closeServer` thunk, and reuses everything else verbatim.
17
+ */
18
+ import { pathToFileURL } from 'node:url';
19
+ import { createBrotliCompress, createGzip, createDeflate, constants as zlibConstants } from 'node:zlib';
20
+ import { stripBasePath } from './base-path.js';
21
+
22
+ /** The dev live-reload SSE path (matched after base-path stripping). */
23
+ export const EVENTS_PATH = '/__webjs/events';
24
+
25
+ /**
26
+ * Detect the host runtime so `startServer` can pick an adapter. Bun sets
27
+ * `process.versions.bun` (and also reports a `node` version via its compat
28
+ * layer, so the Bun check must come first).
29
+ * @returns {'bun' | 'node'}
30
+ */
31
+ export function serverRuntime() {
32
+ return (typeof process !== 'undefined' && process.versions && process.versions.bun)
33
+ ? 'bun'
34
+ : 'node';
35
+ }
36
+
37
+ /**
38
+ * Is this request the dev live-reload SSE stream? Base-path-aware so the stream
39
+ * answers at `<basePath>/__webjs/events` under a sub-path deploy (#256); a no-op
40
+ * pass-through when there is no base path.
41
+ * @param {string} pathname
42
+ * @param {string} basePathStr
43
+ */
44
+ export function isEventsPath(pathname, basePathStr) {
45
+ return stripBasePath(pathname, basePathStr) === EVENTS_PATH;
46
+ }
47
+
48
+ /**
49
+ * Whether a response body of this content type is worth compressing. Shared by
50
+ * the node `sendWebResponse` and the Bun `maybeCompress` so both shells gzip the
51
+ * exact same set of media types.
52
+ * @param {string | string[] | null | undefined} contentType
53
+ */
54
+ export function isCompressible(contentType) {
55
+ if (!contentType) return false;
56
+ const ct = Array.isArray(contentType) ? contentType[0] : contentType;
57
+ // Never compress an event stream: a compressor buffers/chunk-delays bytes that
58
+ // an SSE body (a user route.ts returning text/event-stream) is meant to flush
59
+ // incrementally, so it would stall the stream. The framework's own
60
+ // /__webjs/events stream is intercepted before compression; this guards a
61
+ // user-authored one on both shells.
62
+ if (/^text\/event-stream/i.test(ct)) return false;
63
+ return /^(?:text\/|application\/(?:javascript|json|xml|wasm|manifest)|image\/svg\+xml)/i.test(ct);
64
+ }
65
+
66
+ /**
67
+ * Negotiate a response content-encoding from a request's `Accept-Encoding`,
68
+ * preferring brotli (best ratio), then gzip, then deflate. Returns `''` when
69
+ * none is acceptable. Shared by BOTH listener shells so they negotiate
70
+ * identically; the matching uses the same `(?:^|,\s*)<enc>(?:;|,|$)` token test
71
+ * the node shell has always used.
72
+ * @param {string | string[] | undefined | null} acceptEncoding
73
+ * @returns {'br' | 'gzip' | 'deflate' | ''}
74
+ */
75
+ export function negotiateEncoding(acceptEncoding) {
76
+ const accept = Array.isArray(acceptEncoding) ? acceptEncoding.join(',') : String(acceptEncoding || '');
77
+ if (/(?:^|,\s*)br(?:;|,|$)/.test(accept)) return 'br';
78
+ if (/(?:^|,\s*)gzip(?:;|,|$)/.test(accept)) return 'gzip';
79
+ if (/(?:^|,\s*)deflate(?:;|,|$)/.test(accept)) return 'deflate';
80
+ return '';
81
+ }
82
+
83
+ /**
84
+ * Create a `node:zlib` compressor Transform for a negotiated encoding, or null.
85
+ * `node:zlib` runs NATIVELY on Bun, so both shells get brotli through it (the web
86
+ * `CompressionStream` the Bun shell used before had gzip/deflate only, no brotli).
87
+ * Brotli quality 4 / gzip + deflate level 6 match the node shell's prior tuning
88
+ * (fast, good ratio for on-the-fly compression).
89
+ * @param {'br' | 'gzip' | 'deflate' | ''} encoding
90
+ * @returns {import('node:stream').Transform | null}
91
+ */
92
+ export function createCompressor(encoding) {
93
+ if (encoding === 'br') return createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } });
94
+ if (encoding === 'gzip') return createGzip({ level: 6 });
95
+ if (encoding === 'deflate') return createDeflate({ level: 6 });
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Merge `Accept-Encoding` into an existing `Vary` header (or create it) without
101
+ * duplicating, so compressing a response that already varies (on `Cookie`, an
102
+ * `Origin`, etc.) does not clobber that. Shared so both shells behave identically.
103
+ * @param {string | null | undefined} existingVary
104
+ * @returns {string}
105
+ */
106
+ export function varyWithAcceptEncoding(existingVary) {
107
+ const vary = existingVary || '';
108
+ return vary && !/accept-encoding/i.test(vary) ? `${vary}, Accept-Encoding` : (vary || 'Accept-Encoding');
109
+ }
110
+
111
+ /**
112
+ * Read a web `ReadableStream` chunk by chunk as an async iterable. A read error
113
+ * (a source body that errors mid-stream) throws OUT of the generator, which
114
+ * `Readable.from` surfaces as a node stream `error` that `pipeline` then
115
+ * propagates by destroying the whole chain. This is the cross-runtime-reliable
116
+ * way to feed a web body into a node stream: `Readable.fromWeb` does NOT
117
+ * propagate a web-stream error through `pipeline` on Bun (the #509 hang), so the
118
+ * Bun compression path must NOT use it. On early termination (the consumer
119
+ * aborted, e.g. a client disconnect destroyed the compressor) the source is
120
+ * cancelled so an upstream producer stops.
121
+ * @param {ReadableStream} web
122
+ */
123
+ export async function* webStreamChunks(web) {
124
+ const reader = web.getReader();
125
+ let finished = false;
126
+ try {
127
+ for (;;) {
128
+ const { done, value } = await reader.read();
129
+ if (done) { finished = true; return; }
130
+ yield value;
131
+ }
132
+ } finally {
133
+ if (!finished) { try { await reader.cancel(); } catch {} }
134
+ try { reader.releaseLock(); } catch {}
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Load a `route.{js,ts}` module for its `WS` export, cache-busting in dev so a
140
+ * code edit is picked up per connection. Shared by the node WebSocket subsystem
141
+ * (`websocket.js`) and the Bun upgrade path so both resolve the handler identically.
142
+ * @param {string} file
143
+ * @param {boolean} dev
144
+ */
145
+ export function loadWsModule(file, dev) {
146
+ const url = pathToFileURL(file).toString();
147
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
148
+ return import(url + bust);
149
+ }
150
+
151
+ /**
152
+ * Runtime-neutral SSE registry + fanout for dev live-reload and the dev error
153
+ * overlay (#264). Owns the connected-client Set and the keepalive timer; the
154
+ * fanout writes plain SSE frames through each client's `send`. Each shell adds a
155
+ * thin client wrapper over its own transport (node `res.write`, a Bun
156
+ * `ReadableStreamDefaultController`), so this fanout logic is written once.
157
+ */
158
+ export class SseHub {
159
+ /** @param {{ keepaliveMs?: number }} [opts] */
160
+ constructor(opts = {}) {
161
+ /** @type {Set<{ send: (s: string) => void, close: () => void }>} */
162
+ this.clients = new Set();
163
+ // Keepalive: a comment frame every 25s defeats proxy idle timeouts (and, on
164
+ // Bun, keeps the connection under the server idleTimeout). Cheap and safe:
165
+ // SSE comments are ignored by the client. Unref'd so it never holds the
166
+ // process open.
167
+ this._timer = setInterval(() => this._raw(': ka\n\n'), opts.keepaliveMs ?? 25_000);
168
+ if (typeof this._timer.unref === 'function') this._timer.unref();
169
+ }
170
+
171
+ /** @param {{ send: (s: string) => void, close: () => void }} client */
172
+ add(client) { this.clients.add(client); }
173
+ /** @param {{ send: (s: string) => void, close: () => void }} client */
174
+ remove(client) { this.clients.delete(client); }
175
+
176
+ /** @param {string} frame */
177
+ _raw(frame) {
178
+ for (const c of this.clients) {
179
+ try { c.send(frame); } catch { /* a dead client is dropped by its own close handler */ }
180
+ }
181
+ }
182
+
183
+ /** Push a live-reload event to every open tab. */
184
+ reload() { this._raw('event: reload\ndata: now\n\n'); }
185
+
186
+ /** Push a dev-error overlay frame (#264) to every open tab. @param {object} frame */
187
+ devError(frame) { this._raw(`event: webjs-error\ndata: ${JSON.stringify(frame)}\n\n`); }
188
+
189
+ /** Close every client and stop the keepalive (graceful shutdown). */
190
+ closeAll() {
191
+ for (const c of this.clients) { try { c.close(); } catch {} }
192
+ this.clients.clear();
193
+ clearInterval(this._timer);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Install once-only process error handlers. Idempotent across multiple
199
+ * `startServer` calls in the same process. Runtime-neutral (plain `process.on`).
200
+ * @param {import('./logger.js').Logger} logger
201
+ * @param {() => void} onFatal
202
+ */
203
+ export function installProcessHandlers(logger, onFatal) {
204
+ if (/** @type any */ (globalThis).__webjsProcHandlers) return;
205
+ /** @type any */ (globalThis).__webjsProcHandlers = true;
206
+ process.on('unhandledRejection', (reason) => {
207
+ logger.error('unhandledRejection', {
208
+ err: reason instanceof Error ? reason.stack || reason.message : String(reason),
209
+ });
210
+ });
211
+ process.on('uncaughtException', (err) => {
212
+ logger.error('uncaughtException', { err: err.stack || err.message });
213
+ // Begin orderly shutdown; process state may be corrupt.
214
+ try { onFatal(); } catch {}
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Build a graceful-shutdown signal handler. Runtime-neutral: the shell supplies a
220
+ * `closeServer()` thunk that resolves once the server has stopped accepting
221
+ * connections (node `server.close`, Bun `server.stop(true)`). Closes the SSE hub,
222
+ * then drains, then exits; hard-exits after 10s if the drain hangs.
223
+ * @param {{ closeServer: () => Promise<unknown>, hub: SseHub, logger: import('./logger.js').Logger }} opts
224
+ * @returns {(signal: string) => void}
225
+ */
226
+ export function makeShutdown({ closeServer, hub, logger }) {
227
+ let shuttingDown = false;
228
+ return (signal) => {
229
+ if (shuttingDown) return;
230
+ shuttingDown = true;
231
+ logger.info(`received ${signal}, shutting down`);
232
+ try { hub.closeAll(); } catch {}
233
+ const hard = setTimeout(() => {
234
+ logger.warn('shutdown timed out, forcing exit');
235
+ process.exit(1);
236
+ }, 10_000);
237
+ if (typeof hard.unref === 'function') hard.unref();
238
+ Promise.resolve()
239
+ .then(closeServer)
240
+ .then(
241
+ () => { logger.info('bye'); process.exit(0); },
242
+ (err) => { logger.error('server close error', { err: String(err) }); process.exit(1); },
243
+ );
244
+ };
245
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared shape passed from `startServer` to whichever listener shell it selects
3
+ * (the node:http path in `dev.js`, the `Bun.serve` path in `listener-bun.js`).
4
+ * Types only; no runtime exports.
5
+ *
6
+ * @typedef {object} ListenerContext
7
+ * @property {any} app the `createRequestHandler` result (`handle`, `routeFor`,
8
+ * `getRouteTable`, `getLastDevError`, `warmup`, `appDir`).
9
+ * @property {boolean} dev
10
+ * @property {boolean} compress
11
+ * @property {import('./logger.js').Logger} logger
12
+ * @property {import('./listener-core.js').SseHub} hub
13
+ * @property {number} port
14
+ * @property {string} basePathStr the configured base path (`''` when unset).
15
+ * @property {{ requestTimeout: number, headersTimeout: number, keepAliveTimeout: number }} timeouts
16
+ * @property {AbortController | null} watcherAbort the dev fs.watch controller, aborted on close.
17
+ */
18
+
19
+ export {};