@webjsdev/server 0.8.27 → 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 +4 -1
- package/src/action-seed.js +12 -0
- package/src/dev.js +112 -150
- package/src/file-storage.js +13 -6
- package/src/listener-bun.js +359 -0
- package/src/listener-core.js +245 -0
- package/src/listener-types.js +19 -0
- package/src/node-version.js +9 -0
- package/src/ts-strip.js +157 -0
- package/src/websocket.js +2 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
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
|
}
|
package/src/action-seed.js
CHANGED
|
@@ -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/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'`).
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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,
|
|
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
|
-
|
|
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', () =>
|
|
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
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
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
|
|
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
|
|
2075
|
-
|
|
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['
|
|
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
|
|
2171
|
+
return stripTypeScript(source);
|
|
2210
2172
|
}
|
|
2211
2173
|
|
|
2212
2174
|
/**
|
package/src/file-storage.js
CHANGED
|
@@ -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()` ->
|
|
23
|
-
* `node:stream/promises.pipeline`, so a large upload
|
|
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()`;
|
|
185
|
-
//
|
|
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.
|
|
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.
|
|
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 {};
|
package/src/node-version.js
CHANGED
|
@@ -89,6 +89,15 @@ export function requiredNodeMajor() {
|
|
|
89
89
|
* @returns {void}
|
|
90
90
|
*/
|
|
91
91
|
export function assertNodeVersion(opts = {}) {
|
|
92
|
+
// Bun (#508) satisfies webjs's requirements through a different mechanism: the
|
|
93
|
+
// TS strip comes from `amaro` (resolved by ts-strip.js), and `fs.watch` /
|
|
94
|
+
// `node:crypto` are provided by its node-compat layer, even though Bun reports
|
|
95
|
+
// a Node version string. The Node-major gate is a proxy for "the Node built-ins
|
|
96
|
+
// exist", which does not hold on Bun, so skip it there. Only when no explicit
|
|
97
|
+
// `current` is passed (real runtime detection, not a unit test override).
|
|
98
|
+
if (opts.current === undefined && typeof process !== 'undefined' && process.versions && process.versions.bun) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
92
101
|
const current = opts.current ?? process.versions.node;
|
|
93
102
|
const requiredMajor = opts.requiredMajor ?? requiredNodeMajor();
|
|
94
103
|
const onFail = opts.onFail ?? 'throw';
|
package/src/ts-strip.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable TypeScript stripper (#508), the one seam that lets webjs run on both
|
|
3
|
+
* Node and Bun.
|
|
4
|
+
*
|
|
5
|
+
* webjs serves `.ts` / `.mts` source to the browser as JavaScript by ERASING the
|
|
6
|
+
* type syntax in place (position-preserving whitespace replacement, so no
|
|
7
|
+
* sourcemap is shipped and stack traces stay byte-exact). On Node 24+ that is the
|
|
8
|
+
* built-in `module.stripTypeScriptTypes`. Bun has no such built-in, so this
|
|
9
|
+
* module picks the backend at boot:
|
|
10
|
+
*
|
|
11
|
+
* - **Node 24+**: the built-in `module.stripTypeScriptTypes` (zero deps, the
|
|
12
|
+
* default; nothing about the Node path changes).
|
|
13
|
+
* - **Bun (or any runtime lacking the built-in)**: `amaro`, lazily imported.
|
|
14
|
+
* Node's built-in is itself a thin wrapper over `amaro`'s `strip-only` mode,
|
|
15
|
+
* so the output is BYTE-IDENTICAL and equally position-preserving. `amaro` is
|
|
16
|
+
* an `optionalDependency` of `@webjsdev/server`: a Node-only install that
|
|
17
|
+
* prunes optionals still runs (it never loads amaro), while a Bun install
|
|
18
|
+
* gets it.
|
|
19
|
+
*
|
|
20
|
+
* `WEBJS_TS_STRIPPER=builtin|amaro` forces a backend (used by the tests to
|
|
21
|
+
* exercise the amaro path under Node, and an escape hatch).
|
|
22
|
+
*
|
|
23
|
+
* Erasable TypeScript only, either way: non-erasable syntax (`enum`, value
|
|
24
|
+
* `namespace`, parameter properties, legacy decorators, `import = require`)
|
|
25
|
+
* throws at strip time, which the `erasable-typescript-only` /
|
|
26
|
+
* `no-non-erasable-typescript` lint rules catch at edit time.
|
|
27
|
+
*/
|
|
28
|
+
// Namespace import, NOT `import { stripTypeScriptTypes }`. On Node < 22.13 the
|
|
29
|
+
// named export does not exist, and a NAMED import of a missing builtin export is
|
|
30
|
+
// a LINK-TIME SyntaxError that fires before any module body runs, which would
|
|
31
|
+
// defeat the Node-version preflight by crashing the import of @webjsdev/server
|
|
32
|
+
// itself. A namespace import links on every runtime (the property is just
|
|
33
|
+
// `undefined` where absent, e.g. Bun) and we branch on it at runtime instead.
|
|
34
|
+
import * as nodeModule from 'node:module';
|
|
35
|
+
|
|
36
|
+
// Suppress the one-shot ExperimentalWarning Node prints the first time
|
|
37
|
+
// `stripTypeScriptTypes` is called (the API is committed per Node 24's release
|
|
38
|
+
// notes; the warning is a holdover). Installed here, co-located with the strip
|
|
39
|
+
// call, so it covers every consumer of this module (dev.js, the CLI, tests),
|
|
40
|
+
// not just the dev server. Every other warning passes through untouched.
|
|
41
|
+
const _origEmitWarning = process.emitWarning.bind(process);
|
|
42
|
+
process.emitWarning = function (warning, type, code, ctor) {
|
|
43
|
+
const msg = warning && warning.message ? warning.message : String(warning);
|
|
44
|
+
if (
|
|
45
|
+
(type === 'ExperimentalWarning' || (warning && warning.name === 'ExperimentalWarning')) &&
|
|
46
|
+
msg.includes('stripTypeScriptTypes')
|
|
47
|
+
) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
return _origEmitWarning(warning, type, code, ctor);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** @typedef {{ fn: (source: string) => string, name: 'builtin' | 'amaro' }} Stripper */
|
|
54
|
+
|
|
55
|
+
/** @type {Stripper | null} */
|
|
56
|
+
let _strip = null;
|
|
57
|
+
/** @type {Promise<Stripper> | null} */
|
|
58
|
+
let _resolving = null;
|
|
59
|
+
|
|
60
|
+
/** Forced backend from the env, or '' for auto-detect. */
|
|
61
|
+
function forcedBackend() {
|
|
62
|
+
const v = String(process.env.WEBJS_TS_STRIPPER || '').toLowerCase().trim();
|
|
63
|
+
return v === 'builtin' || v === 'amaro' ? v : '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the active stripper backend once. Auto-detects (built-in when present,
|
|
68
|
+
* else amaro); honors a `WEBJS_TS_STRIPPER` override.
|
|
69
|
+
* @returns {Promise<Stripper>}
|
|
70
|
+
*/
|
|
71
|
+
async function resolve() {
|
|
72
|
+
const forced = forcedBackend();
|
|
73
|
+
const hasBuiltin = typeof nodeModule.stripTypeScriptTypes === 'function';
|
|
74
|
+
if (forced !== 'amaro' && (forced === 'builtin' || hasBuiltin)) {
|
|
75
|
+
if (forced === 'builtin' && !hasBuiltin) {
|
|
76
|
+
throw new Error('WEBJS_TS_STRIPPER=builtin but module.stripTypeScriptTypes is unavailable on this runtime.');
|
|
77
|
+
}
|
|
78
|
+
return { fn: (source) => nodeModule.stripTypeScriptTypes(source), name: 'builtin' };
|
|
79
|
+
}
|
|
80
|
+
// amaro backend (Bun, a Node without the built-in, or forced).
|
|
81
|
+
let amaro;
|
|
82
|
+
try {
|
|
83
|
+
amaro = await import('amaro');
|
|
84
|
+
} catch (e) {
|
|
85
|
+
const why = e && e.message ? e.message : String(e);
|
|
86
|
+
throw new Error(
|
|
87
|
+
"webjs needs a TypeScript stripper: this runtime has no built-in " +
|
|
88
|
+
"`module.stripTypeScriptTypes` (e.g. Bun) and the `amaro` fallback failed " +
|
|
89
|
+
"to load. Install `amaro` (it is an optionalDependency of @webjsdev/server) " +
|
|
90
|
+
"to run webjs here. Underlying: " + why,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const tx = amaro.transformSync || (amaro.default && amaro.default.transformSync);
|
|
94
|
+
if (typeof tx !== 'function') {
|
|
95
|
+
throw new Error('webjs: the resolved `amaro` module has no `transformSync` export.');
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
fn: (source) => {
|
|
99
|
+
try {
|
|
100
|
+
return tx(source, { mode: 'strip-only' }).code;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// Normalize amaro's non-erasable-syntax error code to Node's, so a
|
|
103
|
+
// single downstream check (`err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'`
|
|
104
|
+
// in dev.js's tsResponse) classifies the failure identically on both
|
|
105
|
+
// runtimes (amaro reports `code: 'UnsupportedSyntax'` for an enum /
|
|
106
|
+
// namespace-with-values / parameter-property / legacy-decorator). The
|
|
107
|
+
// strip-error contract is part of this seam, like the strip itself.
|
|
108
|
+
if (e && e.code === 'UnsupportedSyntax') {
|
|
109
|
+
try { e.code = 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'; } catch {}
|
|
110
|
+
}
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
name: 'amaro',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve (once, memoized) and return the active stripper. Call at boot so the
|
|
120
|
+
* backend is ready and a missing-amaro error surfaces early rather than on the
|
|
121
|
+
* first `.ts` request.
|
|
122
|
+
* @returns {Promise<Stripper>}
|
|
123
|
+
*/
|
|
124
|
+
export async function ensureStripper() {
|
|
125
|
+
if (_strip) return _strip;
|
|
126
|
+
// Clear `_resolving` on BOTH settle paths so a failed resolve (e.g. a missing
|
|
127
|
+
// amaro on a runtime lacking the built-in) re-detects on the next call instead
|
|
128
|
+
// of caching the rejection forever (self-healing, matching `__resetStripper`).
|
|
129
|
+
if (!_resolving) {
|
|
130
|
+
_resolving = resolve().then(
|
|
131
|
+
(s) => { _strip = s; _resolving = null; return s; },
|
|
132
|
+
(e) => { _resolving = null; throw e; },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return _resolving;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Strip TypeScript types from `source`, resolving the backend on first use.
|
|
140
|
+
* @param {string} source
|
|
141
|
+
* @returns {Promise<string>}
|
|
142
|
+
*/
|
|
143
|
+
export async function stripTypeScript(source) {
|
|
144
|
+
const s = _strip || await ensureStripper();
|
|
145
|
+
return s.fn(source);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The active backend name, or null before resolution (diagnostics / tests). */
|
|
149
|
+
export function stripperName() {
|
|
150
|
+
return _strip ? _strip.name : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Test seam: forget the resolved backend so the next call re-detects. */
|
|
154
|
+
export function __resetStripper() {
|
|
155
|
+
_strip = null;
|
|
156
|
+
_resolving = null;
|
|
157
|
+
}
|
package/src/websocket.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { WebSocketServer } from 'ws';
|
|
2
|
-
import { pathToFileURL } from 'node:url';
|
|
3
2
|
import { matchApi } from './router.js';
|
|
4
3
|
import { urlFromRequest } from './forwarded.js';
|
|
5
4
|
import { registerClient } from './broadcast.js';
|
|
5
|
+
import { loadWsModule } from './listener-core.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* WebSocket support.
|
|
@@ -48,7 +48,7 @@ export function attachWebSocket(server, getRouteTable, opts) {
|
|
|
48
48
|
return reject(socket, 404, 'Not Found');
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const mod = await
|
|
51
|
+
const mod = await loadWsModule(match.route.file, opts.dev);
|
|
52
52
|
if (typeof mod.WS !== 'function') {
|
|
53
53
|
return reject(socket, 426, 'Upgrade not supported at this route');
|
|
54
54
|
}
|
|
@@ -109,13 +109,3 @@ function buildRequestFromUpgrade(req, url) {
|
|
|
109
109
|
}
|
|
110
110
|
return new Request(url, { method: 'GET', headers });
|
|
111
111
|
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* @param {string} file
|
|
115
|
-
* @param {boolean} dev
|
|
116
|
-
*/
|
|
117
|
-
async function loadModule(file, dev) {
|
|
118
|
-
const url = pathToFileURL(file).toString();
|
|
119
|
-
const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
|
|
120
|
-
return import(url + bust);
|
|
121
|
-
}
|