@webjsdev/server 0.8.28 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.28",
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",
@@ -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
+ }
@@ -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 via Node's synchronous `module.registerHooks`
22
- * (Node 24+, main-thread): for a `'use server'` `*.server.*` module, the load
23
- * hook returns a facade that re-exports each function wrapped in a `Proxy`. The
24
- * Proxy records `(file, fn, args) -> result` into an ambient `AsyncLocalStorage`
25
- * collector WHENEVER a collector is active, and is a pure passthrough otherwise.
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
- * The synchronous `module.registerHooks` load hook. For a `'use server'`
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 (!SERVER_FILE_RE.test(url)) return nextLoad(url, context);
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 head = src.split('\n').slice(0, 5).join('\n');
241
- if (!USE_SERVER_RE.test(head)) return nextLoad(url, context);
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,27 +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 (ESM caches by
257
- * URL, so a module loaded before the hook would never be faceted). A no-op on a
258
- * second call.
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
- // 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.
308
+ export async function registerSeedHooks() {
309
+ if (_registered) return;
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.
266
323
  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
- }
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.');
271
325
  return;
272
326
  }
273
- _enabled = true;
274
- if (_registered) return;
275
- _registered = true;
327
+
276
328
  nodeModule.registerHooks({ load: seedLoadHook });
329
+ _enabled = true;
277
330
  }
278
331
 
279
332
  /**
package/src/dev.js CHANGED
@@ -508,7 +508,7 @@ export async function createRequestHandler(opts) {
508
508
  // Read once (not per-rebuild): the hook is global and cannot be cleanly
509
509
  // un-installed, so toggling needs a restart. Disabled -> no hook, no ambient
510
510
  // collector wrap in ssr.js, and module loading stays byte-identical.
511
- if (await readSeedEnabled(appDir)) registerSeedHooks();
511
+ if (await readSeedEnabled(appDir)) await registerSeedHooks();
512
512
 
513
513
  // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
514
514
  // deterministic vendor map that is cheap to read (one file, no analysis, no