@webjsdev/server 0.8.26 → 0.8.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.26",
3
+ "version": "0.8.28",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -51,5 +51,8 @@
51
51
  ],
52
52
  "engines": {
53
53
  "node": ">=24.0.0"
54
+ },
55
+ "optionalDependencies": {
56
+ "amaro": "^1.1.10"
54
57
  }
55
58
  }
@@ -258,6 +258,18 @@ function seedLoadHook(url, context, nextLoad) {
258
258
  * second call.
259
259
  */
260
260
  export function registerSeedHooks() {
261
+ // The seed facade rides Node's synchronous `module.registerHooks` (#472).
262
+ // Bun (and any runtime without that API, #508) cannot install the hook, so
263
+ // seeding simply stays OFF there: `seedingEnabled()` returns false, ssr.js
264
+ // emits no seed block, and the client RPC stub falls back to a normal fetch.
265
+ // This is the same fail-open posture as a key miss, never wrong data.
266
+ if (typeof nodeModule.registerHooks !== 'function') {
267
+ if (!_registered) {
268
+ _registered = true;
269
+ console.warn('[webjs] SSR action-result seeding (#472) is disabled: module.registerHooks is unavailable on this runtime (e.g. Bun). Async-render components will re-fetch on hydration; no correctness impact.');
270
+ }
271
+ return;
272
+ }
261
273
  _enabled = true;
262
274
  if (_registered) return;
263
275
  _registered = true;
package/src/check.js CHANGED
@@ -93,6 +93,11 @@ export const RULES = [
93
93
  description:
94
94
  'Files that declare the `\'use server\'` directive at the top must also have the `.server.{js,ts,mts,mjs}` extension. The two markers are complementary, not interchangeable: `.server.ts` is the path-level boundary that triggers source protection by the file router; `\'use server\'` is the semantic opt-in that registers exports as RPC-callable from client code. A `\'use server\'` directive without the extension is silently ignored: the file is served to the browser as plain source, exports are NOT registered as RPC, and code the developer expects to run on the server actually runs in the browser. Rename the file to add the `.server.` infix.',
95
95
  },
96
+ {
97
+ name: 'use-server-exports-callable',
98
+ description:
99
+ 'A `.server.{js,ts}` file that declares the `\'use server\'` directive registers its function exports as RPC-callable server actions, but only its FUNCTION exports: `buildActionIndex` / the stub generator register an export only when `typeof export === \'function\'`, so a `\'use server\'` file that exports zero functions (or only a non-function `const` / a type / only verb config like `method` / `cache`) registers NOTHING and gives no signal. The developer believes they exposed an action; nothing did, and the failure only surfaces as a 404 / undefined at the first call site. This is the complement of `use-server-needs-extension` (the directive without the extension) and of `one-action-per-configured-file` (more than one action in a configured file); this rule catches the directive-present-but-nothing-callable case. The rule asserts only that the file exports at least one callable, NOT that the action returns a value (a server action may be a void side-effect or throw `redirect()` / `notFound()` and never return). Conservative: a re-export (`export ... from`, `export *`) or an `export const NAME = <identifier-or-call>` (a possible factory-produced function such as `export const get = cache(fetch)`) is treated as a possible callable and NOT flagged, so the rule fires only when every export is provably non-callable. Fix: export an `async function` action, or drop the `\'use server\'` directive if the file is a plain server-only utility.',
100
+ },
96
101
  {
97
102
  name: 'no-non-erasable-typescript',
98
103
  description:
@@ -733,6 +738,75 @@ export async function checkConventions(appDir) {
733
738
  }
734
739
  }
735
740
 
741
+ // --- Rule: use-server-exports-callable (#464) ---
742
+ // A `.server.{js,ts}` file with the `'use server'` directive registers only
743
+ // its FUNCTION exports as RPC actions (the registrar checks `typeof === 'function'`).
744
+ // A file that declares the directive but exports no callable registers nothing,
745
+ // silently: the developer thinks they exposed an action, and the only signal is
746
+ // a 404 / undefined at the first call site. Flag it. The complement of
747
+ // use-server-needs-extension (the directive without the extension) and of
748
+ // one-action-per-configured-file (more than one action).
749
+ {
750
+ for (const { rel, content, scan } of files) {
751
+ // Only properly-marked action files (the extension boundary). A `'use
752
+ // server'` file WITHOUT the .server. extension is the use-server-needs-
753
+ // extension rule's job; do not double-flag it here.
754
+ if (!/\.server\.m?[jt]s$/.test(rel)) continue;
755
+ if (!hasUseServerDirective(content)) continue;
756
+ // Count function-shaped EXPORTED callables, the SAME way the action
757
+ // registrar sees them: function declarations + arrow / function-expression
758
+ // consts (with an optional `: Type` annotation, #495). Reserved verb-config
759
+ // names (method/cache/...) are config, never a callable action.
760
+ const names = new Set();
761
+ let m;
762
+ const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
763
+ while ((m = reFn.exec(scan))) names.add(m[1]);
764
+ const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=(?!>)\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
765
+ while ((m = reArrow.exec(scan))) names.add(m[1]);
766
+ const callables = [...names].filter((n) => !RESERVED_CONFIG.has(n));
767
+ if (callables.length > 0) continue; // exports at least one callable -> fine
768
+ // A default export is assumed callable (an action commonly default-exports).
769
+ if (/\bexport\s+default\b/.test(scan)) continue;
770
+ // Conservative, avoid a FALSE POSITIVE: the runtime registrar
771
+ // (`actionFunctionNames`) keeps EVERY export whose value is a function at
772
+ // load time, regardless of the export syntax, so any export shape these
773
+ // patterns cannot prove is non-callable must NOT be flagged. Skip when the
774
+ // file has:
775
+ // - a named-export clause `export { a, b as c }` (with or WITHOUT `from`):
776
+ // it can surface a local function (`function getX(){}; export { getX }`)
777
+ // or a re-exported / imported function, neither matched above;
778
+ // - a star re-export `export * from ...`;
779
+ // - a destructuring export `export const { x } = obj` / `export const [x] = arr`,
780
+ // which may bind a function;
781
+ // - an `export const NAME = <identifier-or-call>`, a factory-produced
782
+ // function (`export const get = cache(fetch)`).
783
+ // A sole `export { aConst }` of a non-function is then a tolerated FALSE
784
+ // NEGATIVE, which is the right bias for a non-overridable correctness rule.
785
+ if (/\bexport\s*\{/.test(scan)) continue;
786
+ if (/\bexport\s*\*/.test(scan)) continue;
787
+ if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) continue;
788
+ // A factory / identifier-valued const could be a callable ACTION, so skip,
789
+ // BUT only when its name is NOT a reserved config key: the registrar
790
+ // excludes reserved names from actions (`actionFunctionNames`), so a file
791
+ // whose only ambiguous const is `validate` / `tags` / ... (config produced
792
+ // by an arrow or a factory) still has zero actions and must flag. Skip only
793
+ // for a NON-reserved ambiguous const (a real possible action).
794
+ const reFactory = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=\s*[A-Za-z_$(]/g;
795
+ let ambiguousAction = false;
796
+ while ((m = reFactory.exec(scan))) { if (!RESERVED_CONFIG.has(m[1])) { ambiguousAction = true; break; } }
797
+ if (ambiguousAction) continue;
798
+ // Every export is provably non-callable (a literal const, a type) or there
799
+ // are none: the directive exposes nothing.
800
+ violations.push({
801
+ rule: 'use-server-exports-callable',
802
+ file: rel,
803
+ message:
804
+ "File declares `'use server'` but exports no callable action. The `'use server'` directive registers FUNCTION exports as RPC-callable; a file exporting only a non-function (a `const` / a type / only verb config) registers nothing, so a client import resolves to nothing and the call 404s.",
805
+ fix: "Export an `async function` action from this file, or remove the `'use server'` directive if it is a plain server-only utility.",
806
+ });
807
+ }
808
+ }
809
+
736
810
  // --- Rule: one-action-per-configured-file (#488) ---
737
811
  // A `'use server'` file that declares HTTP-verb config (method/cache/tags/
738
812
  // invalidates/validate) must export exactly one callable action; the config
package/src/dev.js CHANGED
@@ -1,50 +1,15 @@
1
1
  import { createServer as createHttp1Server } from 'node:http';
2
2
  import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
- import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
5
4
  import { join, extname, resolve, dirname, relative, sep } from 'node:path';
6
5
  import { createRequire } from 'node:module';
7
- // Namespace import, NOT `import { stripTypeScriptTypes }`. On Node < 22.13 the
8
- // `stripTypeScriptTypes` named export does not exist, and a NAMED import of a
9
- // missing builtin export is a LINK-TIME SyntaxError that fires before any
10
- // module body runs, which would defeat the Node-version preflight (issue #238)
11
- // by crashing the import of @webjsdev/server itself. A namespace import links
12
- // on every Node (the property is just `undefined` at runtime on old Node), so
13
- // importing @webjsdev/server succeeds and `assertNodeVersion()` at the top of
14
- // createRequestHandler throws the clean "you need Node 24+" message instead.
15
- import * as nodeModule from 'node:module';
16
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
7
 
18
8
  // Server-side `.ts` imports are handled natively by Node 24+'s default
19
- // type-stripping (`process.features.typescript === 'strip'`). No loader
20
- // hook required. The browser-bound TypeScript request handler uses
21
- // `module.stripTypeScriptTypes` for the same transform, so SSR and
22
- // hydration produce identical JS.
23
- //
24
- // Runtime backing: Node ships `stripTypeScriptTypes` via the `amaro`
25
- // package internally (wraps SWC's WASM TypeScript transform in a
26
- // position-preserving strip-only mode). If the framework ever needs
27
- // to run on Bun, Deno, or another runtime that does NOT expose the
28
- // equivalent built-in, we will need to install `amaro` directly (or
29
- // an equivalent: Sucrase preserves lines but not columns; SWC's
30
- // strip-only also works). The fast-path `stripTs` helper would
31
- // change one import line.
32
- //
33
- // Suppress the one-shot ExperimentalWarning that Node prints the
34
- // first time `stripTypeScriptTypes` is called. The API is committed
35
- // per Node 24's release notes; the warning is a holdover. We keep
36
- // every other warning intact.
37
- const _origEmitWarning = process.emitWarning.bind(process);
38
- process.emitWarning = function (warning, type, code, ctor) {
39
- const msg = warning && warning.message ? warning.message : String(warning);
40
- if (
41
- (type === 'ExperimentalWarning' || (warning && warning.name === 'ExperimentalWarning')) &&
42
- msg.includes('stripTypeScriptTypes')
43
- ) {
44
- return;
45
- }
46
- return _origEmitWarning(warning, type, code, ctor);
47
- };
9
+ // type-stripping (`process.features.typescript === 'strip'`) or by Bun. The
10
+ // BROWSER-bound `.ts` request handler erases types via the pluggable stripper in
11
+ // `./ts-strip.js` (Node's built-in `module.stripTypeScriptTypes`, or `amaro` on
12
+ // Bun), so SSR and hydration produce identical JS on either runtime (#508).
48
13
 
49
14
  import { buildRouteTable, matchPage, matchApi } from './router.js';
50
15
  import { generateRouteTypes } from './route-types.js';
@@ -61,6 +26,7 @@ import {
61
26
  hashFile,
62
27
  } from './actions.js';
63
28
  import { registerSeedHooks } from './action-seed.js';
29
+ import { stripTypeScript, ensureStripper } from './ts-strip.js';
64
30
  import { defaultLogger } from './logger.js';
65
31
  import { assertNodeVersion } from './node-version.js';
66
32
  import { applyEnvValidation } from './env-schema.js';
@@ -68,6 +34,16 @@ import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as get
68
34
  import { buildInfoResponse } from './build-info.js';
69
35
  import { readCspConfig, mintNonce, buildCspHeader, cspHeaderName } from './csp.js';
70
36
  import { attachWebSocket } from './websocket.js';
37
+ import {
38
+ SseHub,
39
+ serverRuntime,
40
+ isCompressible,
41
+ installProcessHandlers,
42
+ makeShutdown,
43
+ negotiateEncoding,
44
+ createCompressor,
45
+ varyWithAcceptEncoding,
46
+ } from './listener-core.js';
71
47
  import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
72
48
  import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
73
49
  import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
@@ -441,6 +417,10 @@ export async function createRequestHandler(opts) {
441
417
  // Throw a clear Error here so an embedded host (Express/Fastify/Bun/Deno)
442
418
  // gets the actionable message at boot, not a cryptic API failure mid-request.
443
419
  assertNodeVersion({ onFail: 'throw' });
420
+ // Resolve the TS stripper backend at boot (#508): the Node built-in, or amaro
421
+ // on Bun. Doing it here pays the (one-time) amaro import up front and surfaces
422
+ // a missing-amaro error at boot rather than on the first `.ts` request.
423
+ await ensureStripper();
444
424
  const appDir = resolve(opts.appDir);
445
425
  // Load <appDir>/.env into process.env BEFORE anything else.
446
426
  // buildActionIndex below imports server-only files (lib/*.server.ts,
@@ -1402,26 +1382,28 @@ export async function startServer(opts) {
1402
1382
  const compress = opts.compress ?? !dev;
1403
1383
  const logger = opts.logger || defaultLogger({ dev });
1404
1384
 
1405
- /** @type {Set<import('node:http').ServerResponse>} */
1406
- const sseClients = new Set();
1407
- const app = await createRequestHandler({
1408
- ...opts,
1409
- logger,
1410
- onReload: () => {
1411
- for (const res of sseClients) {
1412
- try { res.write(`event: reload\ndata: now\n\n`); } catch {}
1413
- }
1414
- },
1415
- // Dev error overlay (#264): push a frame to every open tab over the SAME
1416
- // SSE channel. A distinct `webjs-error` event name (NOT `error`, which is
1417
- // EventSource's native connection-error event) carries the JSON frame.
1418
- onDevError: (frame) => {
1419
- const data = JSON.stringify(frame);
1420
- for (const res of sseClients) {
1421
- try { res.write(`event: webjs-error\ndata: ${data}\n\n`); } catch {}
1422
- }
1423
- },
1424
- });
1385
+ // Runtime-neutral SSE registry + fanout (shared by the node:http and Bun.serve
1386
+ // shells via listener-core.js, so live-reload + the dev error overlay behave
1387
+ // identically on both). Built before the handler so its onReload / onDevError
1388
+ // callbacks can fan out through it.
1389
+ const hub = new SseHub();
1390
+ let app;
1391
+ try {
1392
+ app = await createRequestHandler({
1393
+ ...opts,
1394
+ logger,
1395
+ onReload: () => hub.reload(),
1396
+ // Dev error overlay (#264): push a frame to every open tab over the SAME
1397
+ // SSE channel. A distinct `webjs-error` event name (NOT `error`, which is
1398
+ // EventSource's native connection-error event) carries the JSON frame.
1399
+ onDevError: (frame) => hub.devError(frame),
1400
+ });
1401
+ } catch (e) {
1402
+ // The hub starts its keepalive interval in its constructor (before this
1403
+ // await), so a boot failure must clear it rather than leak a live timer.
1404
+ hub.closeAll();
1405
+ throw e;
1406
+ }
1425
1407
 
1426
1408
  /** @type {AbortController | null} */
1427
1409
  let watcherAbort = null;
@@ -1451,14 +1433,42 @@ export async function startServer(opts) {
1451
1433
  })();
1452
1434
  }
1453
1435
 
1454
- // SSE keepalive: send a comment frame every 25s to defeat proxy idle timeouts.
1455
- // Cheap (no event listeners on the client side) and safe: comments are ignored.
1456
- const keepalive = setInterval(() => {
1457
- for (const res of sseClients) {
1458
- try { res.write(`: ka\n\n`); } catch {}
1459
- }
1460
- }, 25_000);
1461
- keepalive.unref();
1436
+ // Inbound server timeouts (issue #237). On node these are the node:http
1437
+ // built-ins; on Bun the node `requestTimeout` maps to Bun's single
1438
+ // `idleTimeout`. Defends against slowloris and hung connections. Overridable
1439
+ // via `webjs.requestTimeoutMs` / `headersTimeoutMs` / `keepAliveTimeoutMs` in
1440
+ // package.json or the matching WEBJS_*_MS env vars; `0` disables that timeout.
1441
+ const timeouts = await readServerTimeoutsFromApp(app.appDir);
1442
+
1443
+ // The shared context both listener shells consume. The transport-specific glue
1444
+ // (node:http `res.write`, Bun.serve streaming Response) lives in each shell;
1445
+ // the SSE registry, the live-reload path predicate, the WS module loader, and
1446
+ // the lifecycle wiring are shared via listener-core.js so the shells can't drift.
1447
+ /** @type {import('./listener-types.js').ListenerContext} */
1448
+ const ctx = { app, dev, compress, logger, hub, port, basePathStr: basePath(), timeouts, watcherAbort };
1449
+
1450
+ // Pick the adapter by runtime. Bun is Request/Response-native, so its shell
1451
+ // skips the node:http bridge (toWebRequest/sendWebResponse) for ~1.9x more
1452
+ // req/s on the listening path (#511); the Bun shell is dynamically imported so
1453
+ // the `Bun.*` global is never referenced on Node.
1454
+ if (serverRuntime() === 'bun') {
1455
+ const { startBunListener } = await import('./listener-bun.js');
1456
+ return startBunListener(ctx);
1457
+ }
1458
+ return startNodeListener(ctx);
1459
+ }
1460
+
1461
+ /**
1462
+ * The node:http listener shell: the original `startServer` socket path, now
1463
+ * reading the shared `ListenerContext`. Bridges node `IncomingMessage` ->
1464
+ * `Request` (`toWebRequest`) and `Response` -> `ServerResponse`
1465
+ * (`sendWebResponse`), emits 103 Early Hints, and drives SSE + WS over node
1466
+ * primitives, sharing the SSE registry + lifecycle wiring with the Bun shell.
1467
+ * @param {import('./listener-types.js').ListenerContext} ctx
1468
+ * @returns {{ server: import('node:http').Server, close: () => Promise<void> }}
1469
+ */
1470
+ function startNodeListener(ctx) {
1471
+ const { app, dev, compress, logger, hub, port, basePathStr, timeouts, watcherAbort } = ctx;
1462
1472
 
1463
1473
  const server = makeHttpServer(async (req, res) => {
1464
1474
  try {
@@ -1468,7 +1478,7 @@ export async function startServer(opts) {
1468
1478
  // base-path-stripped pathname so the reload stream answers at
1469
1479
  // `<basePath>/__webjs/events` under a sub-path deploy (#256). With no
1470
1480
  // basePath this is a pure pass-through (the bare path still matches).
1471
- if (stripBasePath(url.pathname, basePath()) === '/__webjs/events') {
1481
+ if (stripBasePath(url.pathname, basePathStr) === '/__webjs/events') {
1472
1482
  if (!dev) { res.writeHead(404); res.end(); return; }
1473
1483
  res.writeHead(200, {
1474
1484
  'content-type': 'text/event-stream',
@@ -1476,7 +1486,13 @@ export async function startServer(opts) {
1476
1486
  connection: 'keep-alive',
1477
1487
  });
1478
1488
  res.write(`event: hello\ndata: webjs\n\n`);
1479
- sseClients.add(res);
1489
+ // Register a node client wrapper in the shared hub: the fanout + keepalive
1490
+ // live in SseHub; only the transport write (res.write / res.end) is local.
1491
+ const client = {
1492
+ send: (s) => { try { res.write(s); } catch {} },
1493
+ close: () => { try { res.end(); } catch {} },
1494
+ };
1495
+ hub.add(client);
1480
1496
  // Replay an unresolved dev error (#264) so a tab that connects AFTER the
1481
1497
  // breaking edit (e.g. opened via a fresh navigation) still shows the
1482
1498
  // overlay, not only the tab that was open when the error fired.
@@ -1484,7 +1500,7 @@ export async function startServer(opts) {
1484
1500
  if (pending) {
1485
1501
  try { res.write(`event: webjs-error\ndata: ${JSON.stringify(pending)}\n\n`); } catch {}
1486
1502
  }
1487
- res.socket?.on('close', () => sseClients.delete(res));
1503
+ res.socket?.on('close', () => hub.remove(client));
1488
1504
  return;
1489
1505
  }
1490
1506
 
@@ -1519,16 +1535,10 @@ export async function startServer(opts) {
1519
1535
  }
1520
1536
  });
1521
1537
 
1522
- // Inbound server timeouts (issue #237), node:http built-ins. Defends against
1523
- // slowloris and hung connections: `requestTimeout` bounds the time to receive
1524
- // the WHOLE request, `headersTimeout` the time to receive just the headers
1525
- // (node measures both from the same request start, so it is kept strictly
1526
- // under requestTimeout to actually fire), and `keepAliveTimeout` the idle
1527
- // window before a kept-alive socket is closed. Secure production defaults,
1528
- // overridable via `webjs.requestTimeoutMs` / `headersTimeoutMs` /
1529
- // `keepAliveTimeoutMs` in package.json or the matching WEBJS_*_MS env vars.
1530
- // A value of 0 disables that timeout (node's own no-limit sentinel).
1531
- const timeouts = await readServerTimeoutsFromApp(app.appDir);
1538
+ // node:http built-in timeouts: `requestTimeout` bounds the time to receive the
1539
+ // WHOLE request, `headersTimeout` just the headers (kept strictly under
1540
+ // requestTimeout so it actually fires), `keepAliveTimeout` the idle window
1541
+ // before a kept-alive socket is closed.
1532
1542
  server.requestTimeout = timeouts.requestTimeout;
1533
1543
  server.headersTimeout = timeouts.headersTimeout;
1534
1544
  server.keepAliveTimeout = timeouts.keepAliveTimeout;
@@ -1546,7 +1556,10 @@ export async function startServer(opts) {
1546
1556
  app.warmup();
1547
1557
  });
1548
1558
 
1549
- const shutdown = gracefulShutdown(server, sseClients, logger);
1559
+ const closeServer = () => new Promise((resolve, reject) => {
1560
+ server.close((err) => (err ? reject(err) : resolve(undefined)));
1561
+ });
1562
+ const shutdown = makeShutdown({ closeServer, hub, logger });
1550
1563
  process.once('SIGINT', () => shutdown('SIGINT'));
1551
1564
  process.once('SIGTERM', () => shutdown('SIGTERM'));
1552
1565
 
@@ -1559,6 +1572,9 @@ export async function startServer(opts) {
1559
1572
  server,
1560
1573
  close: () => new Promise((r) => {
1561
1574
  if (watcherAbort) watcherAbort.abort();
1575
+ // Clear the shared SSE keepalive timer so repeated startServer/close cycles
1576
+ // (e.g. across a test run) don't accumulate live intervals.
1577
+ hub.closeAll();
1562
1578
  server.close(() => r());
1563
1579
  }),
1564
1580
  };
@@ -1963,54 +1979,6 @@ function makeHttpServer(handler) {
1963
1979
  return createHttp1Server(handler);
1964
1980
  }
1965
1981
 
1966
- /**
1967
- * Install once-only process error handlers. Idempotent across multiple
1968
- * `startServer` calls in the same process.
1969
- *
1970
- * @param {import('./logger.js').Logger} logger
1971
- * @param {() => void} onFatal
1972
- */
1973
- function installProcessHandlers(logger, onFatal) {
1974
- if (/** @type any */ (globalThis).__webjsProcHandlers) return;
1975
- /** @type any */ (globalThis).__webjsProcHandlers = true;
1976
- process.on('unhandledRejection', (reason) => {
1977
- logger.error('unhandledRejection', {
1978
- err: reason instanceof Error ? reason.stack || reason.message : String(reason),
1979
- });
1980
- });
1981
- process.on('uncaughtException', (err) => {
1982
- logger.error('uncaughtException', { err: err.stack || err.message });
1983
- // Begin orderly shutdown; process state may be corrupt.
1984
- try { onFatal(); } catch {}
1985
- });
1986
- }
1987
-
1988
- function gracefulShutdown(server, sseClients, logger) {
1989
- let shuttingDown = false;
1990
- return (signal) => {
1991
- if (shuttingDown) return;
1992
- shuttingDown = true;
1993
- logger.info(`received ${signal}, shutting down`);
1994
- for (const res of sseClients) {
1995
- try { res.end(); } catch {}
1996
- }
1997
- sseClients.clear();
1998
- server.close((err) => {
1999
- if (err) {
2000
- logger.error('server close error', { err: String(err) });
2001
- process.exit(1);
2002
- }
2003
- logger.info('bye');
2004
- process.exit(0);
2005
- });
2006
- // Hard-fail after 10s if we can't drain.
2007
- setTimeout(() => {
2008
- logger.warn('shutdown timed out, forcing exit');
2009
- process.exit(1);
2010
- }, 10_000).unref();
2011
- };
2012
- }
2013
-
2014
1982
  /* ------------ helpers ------------ */
2015
1983
 
2016
1984
  /** @param {import('node:http').IncomingMessage} req @param {URL} url */
@@ -2068,21 +2036,18 @@ async function sendWebResponse(res, webRes, req, opts) {
2068
2036
  headers[k] = v;
2069
2037
  });
2070
2038
 
2071
- // Negotiate compression.
2039
+ // Negotiate compression via the SHARED seam (listener-core.js), so the node and
2040
+ // Bun shells negotiate + compress identically (brotli > gzip > deflate, node:zlib
2041
+ // both sides). Skip a body that is already content-encoded (a route.ts returning
2042
+ // pre-compressed bytes), and merge into any pre-existing `Vary` rather than
2043
+ // clobbering it (`isCompressible` already excludes `text/event-stream`).
2072
2044
  let compressor = null;
2073
- if (opts?.compress && req && webRes.body && isCompressible(headers['content-type'])) {
2074
- const accept = String(req.headers['accept-encoding'] || '');
2075
- if (/(?:^|,\s*)br(?:;|,|$)/.test(accept)) {
2076
- compressor = createBrotliCompress({
2077
- params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
2078
- });
2079
- headers['content-encoding'] = 'br';
2080
- } else if (/(?:^|,\s*)gzip(?:;|,|$)/.test(accept)) {
2081
- compressor = createGzip({ level: 6 });
2082
- headers['content-encoding'] = 'gzip';
2083
- }
2045
+ if (opts?.compress && req && webRes.body && !headers['content-encoding'] && isCompressible(headers['content-type'])) {
2046
+ const encoding = negotiateEncoding(req.headers['accept-encoding']);
2047
+ compressor = createCompressor(encoding);
2084
2048
  if (compressor) {
2085
- headers['vary'] = 'Accept-Encoding';
2049
+ headers['content-encoding'] = encoding;
2050
+ headers['vary'] = varyWithAcceptEncoding(typeof headers['vary'] === 'string' ? headers['vary'] : '');
2086
2051
  delete headers['content-length'];
2087
2052
  }
2088
2053
  }
@@ -2114,13 +2079,6 @@ async function sendWebResponse(res, webRes, req, opts) {
2114
2079
  res.end();
2115
2080
  }
2116
2081
 
2117
- /** @param {string | string[] | undefined} contentType */
2118
- function isCompressible(contentType) {
2119
- if (!contentType) return false;
2120
- const ct = Array.isArray(contentType) ? contentType[0] : contentType;
2121
- return /^(?:text\/|application\/(?:javascript|json|xml|wasm|manifest)|image\/svg\+xml)/i.test(ct);
2122
- }
2123
-
2124
2082
  /**
2125
2083
  * Read a file and return a Response with appropriate caching.
2126
2084
  * Dev: no-cache (always revalidate).
@@ -2201,12 +2159,16 @@ async function exists(p) {
2201
2159
  * rules catch these at edit time. There is no bundler fallback;
2202
2160
  * webjs is buildless end-to-end.
2203
2161
  *
2162
+ * The backend is the runtime-appropriate stripper (#508): Node 24+'s built-in
2163
+ * `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, equally
2164
+ * position-preserving), resolved once via `./ts-strip.js`.
2165
+ *
2204
2166
  * @param {string} source
2205
2167
  * @param {string} _abs (unused; preserved for symmetry with prior signature)
2206
2168
  * @returns {Promise<string>}
2207
2169
  */
2208
2170
  async function stripTs(source, _abs) {
2209
- return nodeModule.stripTypeScriptTypes(source);
2171
+ return stripTypeScript(source);
2210
2172
  }
2211
2173
 
2212
2174
  /**
@@ -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