@webjsdev/server 0.8.27 → 0.8.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/action-seed-bun.js +67 -0
- package/src/action-seed.js +89 -24
- package/src/dev.js +113 -151
- 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.29",
|
|
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
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun install for SSR action-result seeding (#472, #529).
|
|
3
|
+
*
|
|
4
|
+
* Bun has no `module.registerHooks`, so the seed facade is installed via a
|
|
5
|
+
* `Bun.plugin` `onLoad`, the Bun analog of the Node load hook in
|
|
6
|
+
* `action-seed.js`. This module is dynamically imported by `registerSeedHooks`
|
|
7
|
+
* ONLY when `serverRuntime()` is `'bun'`, so the `Bun.*` global is never
|
|
8
|
+
* referenced on Node (the same isolation as `listener-bun.js`).
|
|
9
|
+
*
|
|
10
|
+
* The faceting DECISION (`isSeedCandidate`) and the facade SOURCE
|
|
11
|
+
* (`buildSeedFacade`) are the shared, runtime-neutral helpers passed in, so Node
|
|
12
|
+
* and Bun emit byte-identical facades that feed the same `AsyncLocalStorage`
|
|
13
|
+
* collector. The only Bun-specific glue here is the plugin shell + reading the
|
|
14
|
+
* source via `Bun.file`.
|
|
15
|
+
*
|
|
16
|
+
* Bun's `onLoad` `args.path` includes any query (verified), so the facade's
|
|
17
|
+
* `?webjs-seed-orig` passthrough and a dev `?t=` re-import are both seen, exactly
|
|
18
|
+
* like the Node hook's URL.
|
|
19
|
+
*
|
|
20
|
+
* UNLIKE Node's hook, a Bun `onLoad` registered for a filter MUST return a
|
|
21
|
+
* `{ contents, loader }` object for EVERY match; returning `undefined` to defer
|
|
22
|
+
* to the default loader is an error ("onLoad() expects an object returned"). So
|
|
23
|
+
* the non-facet cases (the `?webjs-seed-orig` passthrough, a `.server.*` with no
|
|
24
|
+
* `'use server'`, an unenumerable `export *`) are handled by reading and
|
|
25
|
+
* RETURNING the real source ourselves with the extension's loader, which is the
|
|
26
|
+
* same module Bun would have loaded.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Install the `Bun.plugin` seed `onLoad`. Idempotency is the caller's
|
|
31
|
+
* responsibility (`registerSeedHooks` guards on `_registered`).
|
|
32
|
+
*
|
|
33
|
+
* @param {{
|
|
34
|
+
* isSeedCandidate: (specifier: string) => boolean,
|
|
35
|
+
* buildSeedFacade: (origSpec: string, absPath: string, src: string) => (string | null),
|
|
36
|
+
* serverFileRe: RegExp,
|
|
37
|
+
* }} helpers
|
|
38
|
+
*/
|
|
39
|
+
export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe }) {
|
|
40
|
+
Bun.plugin({
|
|
41
|
+
name: 'webjs-action-seed',
|
|
42
|
+
setup(build) {
|
|
43
|
+
// The filter is a cheap path pre-screen (`*.server.*`, optional query).
|
|
44
|
+
build.onLoad({ filter: serverFileRe }, async (args) => {
|
|
45
|
+
const absPath = args.path.split('?')[0];
|
|
46
|
+
// `.ts` / `.mts` strip via the `ts` loader; `.js` / `.mjs` via `js`.
|
|
47
|
+
const loader = /\.m?ts$/.test(absPath) ? 'ts' : 'js';
|
|
48
|
+
// Read the real source. A genuine read failure (missing file) propagates,
|
|
49
|
+
// which Bun reports as a load error exactly as it would without the plugin.
|
|
50
|
+
const src = await Bun.file(absPath).text();
|
|
51
|
+
try {
|
|
52
|
+
// Facet only a `'use server'` candidate (not the `?webjs-seed-orig`
|
|
53
|
+
// passthrough); a non-candidate or a passthrough falls through to the
|
|
54
|
+
// raw source below.
|
|
55
|
+
if (isSeedCandidate(args.path)) {
|
|
56
|
+
const source = buildSeedFacade(args.path, absPath, src);
|
|
57
|
+
if (source != null) return { contents: source, loader: 'js' };
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Fail-open: any faceting error serves the raw source (no seeding for
|
|
61
|
+
// this module), the Bun analog of the Node hook's `nextLoad` fallback.
|
|
62
|
+
}
|
|
63
|
+
return { contents: src, loader };
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
package/src/action-seed.js
CHANGED
|
@@ -18,11 +18,18 @@
|
|
|
18
18
|
* The framework promise is "what you write is what you see in the browser source
|
|
19
19
|
* tab", not "what you write is the exact function object that runs server-side"
|
|
20
20
|
* (the RPC stub already replaces the action on the client). So we install a
|
|
21
|
-
* SERVER-SIDE transparent facade
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* SERVER-SIDE transparent facade at module load: for a `'use server'`
|
|
22
|
+
* `*.server.*` module, the load hook returns a facade that re-exports each
|
|
23
|
+
* function wrapped in a `Proxy`. The Proxy records `(file, fn, args) -> result`
|
|
24
|
+
* into an ambient `AsyncLocalStorage` collector WHENEVER a collector is active,
|
|
25
|
+
* and is a pure passthrough otherwise.
|
|
26
|
+
*
|
|
27
|
+
* The facade SOURCE and the wrapping (`__seedWrap`, `buildSeedFacade`) are
|
|
28
|
+
* runtime-neutral; only the INSTALL mechanism differs by runtime (`#529`), chosen
|
|
29
|
+
* by `serverRuntime()`: Node uses the synchronous `module.registerHooks` load
|
|
30
|
+
* hook (Node 24+, main-thread); Bun uses a `Bun.plugin` `onLoad` (Bun has no
|
|
31
|
+
* `module.registerHooks`). Both feed the same `AsyncLocalStorage` collector and
|
|
32
|
+
* emit the same seed wire, so a page seeds identically on either runtime.
|
|
26
33
|
*
|
|
27
34
|
* Recording is gated entirely by the ALS collector, which is established ONLY
|
|
28
35
|
* around the SSR page render (`collectSeeds`). The RPC endpoint path runs with
|
|
@@ -48,6 +55,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
48
55
|
import { stringify } from '@webjsdev/core';
|
|
49
56
|
import { hashFile } from './actions.js';
|
|
50
57
|
import { isStreamable } from './action-stream.js';
|
|
58
|
+
import { serverRuntime } from './listener-core.js';
|
|
51
59
|
|
|
52
60
|
/** Ambient per-render seed collector. `Map<key, value>` or undefined. */
|
|
53
61
|
const als = new AsyncLocalStorage();
|
|
@@ -205,6 +213,17 @@ function buildFacade(origUrl, absPath, exports) {
|
|
|
205
213
|
const file = JSON.stringify(absPath);
|
|
206
214
|
let out = `import * as __orig from ${origSpec};\n`;
|
|
207
215
|
out += `import { __seedWrap as __w } from ${JSON.stringify(SELF_URL)};\n`;
|
|
216
|
+
// Fail-open catch-all (#535). Re-export every named binding of the real module.
|
|
217
|
+
// An explicit `export const NAME = __w(...)` below SHADOWS the matching star
|
|
218
|
+
// binding (an explicit export wins over a star re-export of the same name, with
|
|
219
|
+
// no SyntaxError), so an enumerated export is still wrapped and seeded. A named
|
|
220
|
+
// export the `extractExportNames` regex MISSED (exotic syntax, an unusual
|
|
221
|
+
// re-export form, a codegen-produced export) is NOT enumerated below, so it
|
|
222
|
+
// flows through this star unwrapped: it resolves and works over a normal RPC,
|
|
223
|
+
// just is not seeded, instead of being dropped and crashing the importer with
|
|
224
|
+
// `undefined`. `export *` does not carry `default`, which the explicit default
|
|
225
|
+
// line below still handles.
|
|
226
|
+
out += `export * from ${origSpec};\n`;
|
|
208
227
|
for (const n of exports.names) {
|
|
209
228
|
const k = JSON.stringify(n);
|
|
210
229
|
out += `export const ${n} = __w(${file}, ${k}, __orig[${k}]);\n`;
|
|
@@ -215,13 +234,46 @@ function buildFacade(origUrl, absPath, exports) {
|
|
|
215
234
|
return out;
|
|
216
235
|
}
|
|
217
236
|
|
|
218
|
-
/** Match `*.server.{js,ts,mjs,mts}` (optionally with a query). */
|
|
219
|
-
const SERVER_FILE_RE = /\.server\.m?[jt]s(\?|$)/;
|
|
237
|
+
/** Match `*.server.{js,ts,mjs,mts}` (optionally with a query). Also the Bun plugin filter. */
|
|
238
|
+
export const SERVER_FILE_RE = /\.server\.m?[jt]s(\?|$)/;
|
|
220
239
|
/** The `'use server'` directive in the file head. */
|
|
221
240
|
const USE_SERVER_RE = /^\s*(['"])use server\1\s*;?\s*$/m;
|
|
222
241
|
|
|
223
242
|
/**
|
|
224
|
-
*
|
|
243
|
+
* Whether a load specifier (a Node file URL or a Bun file path, with an optional
|
|
244
|
+
* query) is a faceting candidate: a `*.server.*` module that is NOT the facade's
|
|
245
|
+
* own `?webjs-seed-orig` passthrough of the real module. Runtime-neutral.
|
|
246
|
+
* @param {string} specifier
|
|
247
|
+
* @returns {boolean}
|
|
248
|
+
*/
|
|
249
|
+
export function isSeedCandidate(specifier) {
|
|
250
|
+
return SERVER_FILE_RE.test(specifier) && !specifier.includes('webjs-seed-orig');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Build the wrapping facade for a candidate module's source, or null to pass it
|
|
255
|
+
* through unwrapped (no `'use server'`, a non-enumerable `export *`, or no
|
|
256
|
+
* exports). Runtime-neutral: the caller passes the load specifier (URL on Node,
|
|
257
|
+
* path on Bun) used as the facade's `?webjs-seed-orig` import base, plus the
|
|
258
|
+
* absolute file path (the hash basis) and the already-read source.
|
|
259
|
+
* @param {string} origSpec the real module's load specifier (URL or path), no seed query
|
|
260
|
+
* @param {string} absPath the real module's absolute file path
|
|
261
|
+
* @param {string} src the module source
|
|
262
|
+
* @returns {string | null} facade source, or null for passthrough
|
|
263
|
+
*/
|
|
264
|
+
export function buildSeedFacade(origSpec, absPath, src) {
|
|
265
|
+
const head = src.split('\n').slice(0, 5).join('\n');
|
|
266
|
+
if (!USE_SERVER_RE.test(head)) return null;
|
|
267
|
+
const exports = extractExportNames(src);
|
|
268
|
+
// A `export *` re-export cannot be enumerated; skip faceting (passthrough)
|
|
269
|
+
// so we never emit a facade that silently drops re-exported bindings.
|
|
270
|
+
if (exports.hasStar) return null;
|
|
271
|
+
if (exports.names.length === 0 && !exports.hasDefault) return null;
|
|
272
|
+
return buildFacade(origSpec, absPath, exports);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The synchronous `module.registerHooks` load hook (Node). For a `'use server'`
|
|
225
277
|
* `*.server.*` module it returns a wrapping facade; for everything else
|
|
226
278
|
* (including the `?webjs-seed-orig` passthrough of the real module) it defers to
|
|
227
279
|
* `nextLoad`. Fail-open: any error defers to `nextLoad`, so a load that the hook
|
|
@@ -232,19 +284,11 @@ const USE_SERVER_RE = /^\s*(['"])use server\1\s*;?\s*$/m;
|
|
|
232
284
|
*/
|
|
233
285
|
function seedLoadHook(url, context, nextLoad) {
|
|
234
286
|
try {
|
|
235
|
-
if (!
|
|
236
|
-
// The facade's own `?webjs-seed-orig` import must load the REAL module.
|
|
237
|
-
if (url.includes('webjs-seed-orig')) return nextLoad(url, context);
|
|
287
|
+
if (!isSeedCandidate(url)) return nextLoad(url, context);
|
|
238
288
|
const absPath = fileURLToPath(url.split('?')[0]);
|
|
239
289
|
const src = readFileSync(absPath, 'utf8');
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
242
|
-
const exports = extractExportNames(src);
|
|
243
|
-
// A `export *` re-export cannot be enumerated; skip faceting (passthrough)
|
|
244
|
-
// so we never emit a facade that silently drops re-exported bindings.
|
|
245
|
-
if (exports.hasStar) return nextLoad(url, context);
|
|
246
|
-
if (exports.names.length === 0 && !exports.hasDefault) return nextLoad(url, context);
|
|
247
|
-
const source = buildFacade(url, absPath, exports);
|
|
290
|
+
const source = buildSeedFacade(url, absPath, src);
|
|
291
|
+
if (source == null) return nextLoad(url, context);
|
|
248
292
|
return { source, format: 'module', shortCircuit: true };
|
|
249
293
|
} catch {
|
|
250
294
|
return nextLoad(url, context);
|
|
@@ -253,15 +297,36 @@ function seedLoadHook(url, context, nextLoad) {
|
|
|
253
297
|
|
|
254
298
|
/**
|
|
255
299
|
* Install the seed load hook (idempotent). Called once at boot from `dev.js`
|
|
256
|
-
* when seeding is enabled, BEFORE any action module is imported (
|
|
257
|
-
*
|
|
258
|
-
*
|
|
300
|
+
* when seeding is enabled, BEFORE any action module is imported (a module loaded
|
|
301
|
+
* before the hook would already be cached unwrapped). The install mechanism is
|
|
302
|
+
* chosen by `serverRuntime()` (#529): Node's synchronous `module.registerHooks`,
|
|
303
|
+
* or a `Bun.plugin` `onLoad` on Bun. A no-op on a second call. Async because the
|
|
304
|
+
* Bun path dynamically imports `action-seed-bun.js` (so the `Bun.*` global is
|
|
305
|
+
* never referenced on Node); the Node path resolves synchronously.
|
|
306
|
+
* @returns {Promise<void>}
|
|
259
307
|
*/
|
|
260
|
-
export function registerSeedHooks() {
|
|
261
|
-
_enabled = true;
|
|
308
|
+
export async function registerSeedHooks() {
|
|
262
309
|
if (_registered) return;
|
|
263
310
|
_registered = true;
|
|
311
|
+
|
|
312
|
+
if (serverRuntime() === 'bun') {
|
|
313
|
+
// Bun has no module.registerHooks; install the same facade via Bun.plugin.
|
|
314
|
+
const { installBunSeedPlugin } = await import('./action-seed-bun.js');
|
|
315
|
+
installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE });
|
|
316
|
+
_enabled = true;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A runtime that is neither Node-with-registerHooks nor Bun: seeding stays
|
|
321
|
+
// OFF (fail-open). `seedingEnabled()` is false, ssr.js emits no seed block,
|
|
322
|
+
// and the client RPC stub falls back to a normal fetch. Never wrong data.
|
|
323
|
+
if (typeof nodeModule.registerHooks !== 'function') {
|
|
324
|
+
console.warn('[webjs] SSR action-result seeding (#472) is disabled: this runtime has neither module.registerHooks nor Bun.plugin. Async-render components re-fetch on hydration; no correctness impact.');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
264
328
|
nodeModule.registerHooks({ load: seedLoadHook });
|
|
329
|
+
_enabled = true;
|
|
265
330
|
}
|
|
266
331
|
|
|
267
332
|
/**
|
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,
|
|
@@ -528,7 +508,7 @@ export async function createRequestHandler(opts) {
|
|
|
528
508
|
// Read once (not per-rebuild): the hook is global and cannot be cleanly
|
|
529
509
|
// un-installed, so toggling needs a restart. Disabled -> no hook, no ambient
|
|
530
510
|
// collector wrap in ssr.js, and module loading stays byte-identical.
|
|
531
|
-
if (await readSeedEnabled(appDir)) registerSeedHooks();
|
|
511
|
+
if (await readSeedEnabled(appDir)) await registerSeedHooks();
|
|
532
512
|
|
|
533
513
|
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
534
514
|
// deterministic vendor map that is cheap to read (one file, no analysis, no
|
|
@@ -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
|
/**
|