@webjsdev/server 0.8.58 → 0.8.60

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.
@@ -1,5 +1,17 @@
1
1
  /**
2
- * SSR action-result seeding (#472), follow-up to async render (#469).
2
+ * The `'use server'` module load hook: action IDENTITY (#1155) and SSR
3
+ * action-result seeding (#472, follow-up to async render #469).
4
+ *
5
+ * Two jobs share ONE hook because they need the same thing, a handle on every
6
+ * exported action function at the moment its module loads, and installing two
7
+ * hooks to get it twice would be pure cost.
8
+ *
9
+ * They are gated differently, and that difference is the point. SEEDING is an
10
+ * optimization an app can switch off (`webjs.seed: false` / `WEBJS_SEED=0`).
11
+ * IDENTITY is not optional: it is what lets `<form action=${action}>` resolve
12
+ * to something the server can run, so a seeding-disabled app would otherwise
13
+ * have every no-JS form silently stop working. So the hook installs
14
+ * unconditionally and only the seed COLLECTION is gated.
3
15
  *
4
16
  * When a component's `async render()` does a bare `const u = await getUser(id)`
5
17
  * during SSR, the action runs server-side and its result is baked into the
@@ -24,7 +36,7 @@
24
36
  * into an ambient `AsyncLocalStorage` collector WHENEVER a collector is active,
25
37
  * and is a pure passthrough otherwise.
26
38
  *
27
- * The facade SOURCE and the wrapping (`__seedWrap`, `buildSeedFacade`) are
39
+ * The facade SOURCE and the wrapping (`__actionWrap`, `buildSeedFacade`) are
28
40
  * runtime-neutral; only the INSTALL mechanism differs by runtime (`#529`), chosen
29
41
  * by `serverRuntime()`: Node uses the synchronous `module.registerHooks` load
30
42
  * hook (Node 24+, main-thread); Bun uses a `Bun.plugin` `onLoad` (Bun has no
@@ -56,15 +68,43 @@ import { stringify } from '@webjsdev/core';
56
68
  import { hashFile } from './actions.js';
57
69
  import { isStreamable } from './action-stream.js';
58
70
  import { serverRuntime } from './listener-core.js';
71
+ import { redactStringsAndTemplates } from './js-scan.js';
59
72
 
60
73
  /** Ambient per-render seed collector. `Map<key, value>` or undefined. */
61
74
  const als = new AsyncLocalStorage();
62
75
 
63
- /** Set once the load hook is installed (the flag is on at boot). */
64
- let _enabled = false;
76
+ /** Whether seed COLLECTION is on (the `webjs.seed` switch). */
77
+ let _seedEnabled = false;
78
+ /** Dev mode, threaded from `dev.js` at boot. Gates the determinism assertion. */
79
+ let _devMode = false;
80
+ /** Whether the load hook is installed at all (so identity is available). */
81
+ let _hookInstalled = false;
65
82
  /** Idempotency guard: `module.registerHooks` must run at most once. */
66
83
  let _registered = false;
67
84
 
85
+ /**
86
+ * `action function -> { file, fnName }` for every export the hook wrapped.
87
+ *
88
+ * A WeakMap rather than a property on the function: the wrapped value may be a
89
+ * Proxy whose property writes pass through to the target, so stamping it would
90
+ * mutate the app's own function object, and a frozen action would make the
91
+ * stamp fail silently. Both the wrapper and the original are registered,
92
+ * because which one a caller holds depends on whether seeding is on.
93
+ *
94
+ * EVERY registration is kept, defining module first, because one function can
95
+ * legitimately be exported from more than one `'use server'` module (a barrel
96
+ * re-exporting it). The defining module is the right answer whenever it is
97
+ * available, since it is the one carrying the action's `validate` /
98
+ * `middleware` / `method` / `invalidates` config exports. But it is not always
99
+ * REACHABLE: the action index only walks the app tree, so an action defined in
100
+ * a linked workspace package and re-exported through an in-app barrel has an
101
+ * indexed name only under the barrel. Keeping both lets the caller prefer the
102
+ * defining module and fall back rather than fail.
103
+ *
104
+ * @type {WeakMap<Function, { file: string, fnName: string }[]>}
105
+ */
106
+ const _identity = new WeakMap();
107
+
68
108
  /** Memoized `absPath -> hash` so a hot action call does not re-hash per call. */
69
109
  const _hashCache = new Map();
70
110
 
@@ -78,7 +118,41 @@ const SELF_URL = import.meta.url;
78
118
  * @returns {boolean}
79
119
  */
80
120
  export function seedingEnabled() {
81
- return _enabled;
121
+ return _seedEnabled && _hookInstalled;
122
+ }
123
+
124
+ /**
125
+ * Whether the load hook is installed, and therefore whether action identity is
126
+ * being registered. False only on a runtime with neither `module.registerHooks`
127
+ * nor `Bun.plugin`, where the form-action resolver falls back to a module scan.
128
+ * @returns {boolean}
129
+ */
130
+ export function identityHookInstalled() {
131
+ return _hookInstalled;
132
+ }
133
+
134
+ /**
135
+ * The `{ file, fnName }` an action function was exported as, or null when the
136
+ * hook never saw it (a module loaded before the hook installed, or a runtime
137
+ * with no hook).
138
+ * @param {unknown} fn
139
+ * @returns {{ file: string, fnName: string } | null}
140
+ */
141
+ export function actionIdentityOf(fn) {
142
+ const all = actionIdentitiesOf(fn);
143
+ return all.length ? all[0] : null;
144
+ }
145
+
146
+ /**
147
+ * EVERY `{ file, fnName }` the function was exported as, defining module first.
148
+ * The form-action resolver needs the whole list, because only some of them may
149
+ * be in the action index (see the `_identity` note above).
150
+ * @param {unknown} fn
151
+ * @returns {{ file: string, fnName: string }[]}
152
+ */
153
+ export function actionIdentitiesOf(fn) {
154
+ if (typeof fn !== 'function') return [];
155
+ return _identity.get(/** @type {Function} */ (fn)) || [];
82
156
  }
83
157
 
84
158
  /**
@@ -86,11 +160,14 @@ export function seedingEnabled() {
86
160
  * action index do (`hashFile` over the absolute path string), so the seed key
87
161
  * the server emits matches the key the client stub looks up. A path mismatch
88
162
  * (e.g. a symlinked appDir whose realpath differs) only yields a key MISS,
89
- * which safely degrades to a normal RPC.
163
+ * which safely degrades to a normal RPC. Note that the FORM path does not reach
164
+ * here: it resolves an identity only against the action index, because a hash
165
+ * the index does not know is unresolvable at submit time (see
166
+ * `resolveActionIdentity`).
90
167
  * @param {string} absPath
91
168
  * @returns {Promise<string>}
92
169
  */
93
- async function hashFor(absPath) {
170
+ export async function actionFileHash(absPath) {
94
171
  let h = _hashCache.get(absPath);
95
172
  if (h === undefined) {
96
173
  h = await hashFile(absPath);
@@ -117,28 +194,105 @@ async function recordSeed(collector, file, fnName, args, value) {
117
194
  // streamed action is never seeded; the client streams it fresh on each call.
118
195
  if (isStreamable(value)) return;
119
196
  try {
120
- const hash = await hashFor(file);
197
+ const hash = await actionFileHash(file);
121
198
  const argsKey = await stringify(args);
122
- collector.set(`${hash}/${fnName}/${argsKey}`, value);
199
+ const key = `${hash}/${fnName}/${argsKey}`;
200
+ // Dev-only determinism assertion (#1309). A duplicate key means the SAME
201
+ // action ran twice with the SAME arguments in ONE render; the collector
202
+ // keeps the LAST result, so a component that painted the first one hydrates
203
+ // with the second. Compared on the FULL key, never on `hash/fn`: a
204
+ // legitimate second call with different arguments has a different key and
205
+ // cannot false-fire. In its OWN try/catch so a diagnostic failure can never
206
+ // skip `collector.set` and turn observability into a dropped seed.
207
+ if (_devMode && collector.has(key)) {
208
+ try { await assertDeterministic(collector.get(key), value, hash, fnName); } catch { /* never affect the seed */ }
209
+ }
210
+ collector.set(key, value);
123
211
  } catch {
124
212
  // Drop the seed; the client stub falls back to a normal RPC.
125
213
  }
126
214
  }
127
215
 
216
+ /** Warned-once ids, keyed `hash/fn` so the Set is bounded by the action count. */
217
+ const _nonDeterministic = new Set();
218
+
219
+ /**
220
+ * Warn (once per action function) when one render recorded two DIFFERENT results
221
+ * for the same key. `Object.is` settles a memoized or cached return for free;
222
+ * the fallback compares through the SAME serializer the seed uses, so
223
+ * "different" means different on the wire, which is the only difference that can
224
+ * reach a client. Dev only, and only on a duplicate key.
225
+ * @param {unknown} prev
226
+ * @param {unknown} next
227
+ * @param {string} hash
228
+ * @param {string} fnName
229
+ */
230
+ async function assertDeterministic(prev, next, hash, fnName) {
231
+ if (Object.is(prev, next)) return;
232
+ const id = `${hash}/${fnName}`;
233
+ if (_nonDeterministic.has(id)) return;
234
+ if ((await stringify(prev)) === (await stringify(next))) return;
235
+ _nonDeterministic.add(id);
236
+ console.warn(
237
+ `[webjs] SSR action seeding: "${fnName}" returned two DIFFERENT results for the SAME arguments during one render. `
238
+ + 'The seed carries the LAST result, so a component that painted the first one hydrates with the second. '
239
+ + 'Make the action deterministic for a given argument list, or turn seeding off with "webjs": { "seed": false }.',
240
+ );
241
+ }
242
+
128
243
  /**
129
- * Wrap one exported action function so that, when a collector is active, its
130
- * resolved result is recorded. Outside a collector (the RPC endpoint path) it is
131
- * a transparent passthrough. Non-functions (a `const VERSION = '1.0'` export)
132
- * pass through untouched, and the Proxy forwards property reads, so any
133
- * metadata an app or the framework attaches to the function still resolves
134
- * through the wrapper.
244
+ * Register one exported action function's identity, and, when seeding is on,
245
+ * wrap it so that a resolved result is recorded into an active collector.
246
+ *
247
+ * Identity is registered unconditionally: it is what `<form action=${action}>`
248
+ * resolves through (#1155), so it cannot depend on the seed switch.
249
+ *
250
+ * The Proxy is NOT created when seeding is off. Outside a collector it is a
251
+ * transparent passthrough anyway, so with collection disabled it would be pure
252
+ * indirection on every action call for the lifetime of the process. Identity
253
+ * is registered for BOTH the original and the wrapper, so a caller resolves
254
+ * whichever of the two it happens to hold.
255
+ *
256
+ * Non-functions (a `const VERSION = '1.0'` export) pass through untouched, and
257
+ * the Proxy forwards property reads, so any metadata an app or the framework
258
+ * attaches to the function still resolves through the wrapper.
259
+ *
135
260
  * @param {string} file absolute action file path
136
261
  * @param {string} fnName
137
262
  * @param {unknown} orig
138
263
  * @returns {unknown}
139
264
  */
140
- export function __seedWrap(file, fnName, orig) {
265
+ export function __actionWrap(file, fnName, orig) {
141
266
  if (typeof orig !== 'function') return orig;
267
+ // APPEND, defining module first. A barrel is faceted too and its body runs
268
+ // AFTER the module it re-exports from, so the first registration is the
269
+ // defining one: the module that also carries `validate` / `middleware` /
270
+ // `method` / `invalidates`. Overwriting re-filed the function under the
271
+ // BARREL, and the dispatcher then read those config exports off a namespace
272
+ // carrying none of them, running a submission with the action's validation
273
+ // and auth middleware silently skipped. Keeping the later ones too matters
274
+ // just as much: when the defining module is outside the walked app tree, the
275
+ // barrel is the only name the action index knows.
276
+ const list = _identity.get(orig) || [];
277
+ if (!list.some((e) => e.file === file && e.fnName === fnName)) list.push({ file, fnName });
278
+ _identity.set(orig, list);
279
+ if (!_seedEnabled) return orig;
280
+ const wrapped = seedProxy(file, fnName, orig);
281
+ // The wrapper is a fresh object every time, so it shares the TARGET's list
282
+ // rather than starting one of its own.
283
+ _identity.set(wrapped, list);
284
+ return wrapped;
285
+ }
286
+
287
+ /**
288
+ * The seed-recording Proxy. Split out of `__actionWrap` so the identity
289
+ * registration above reads as the unconditional half and this as the gated one.
290
+ * @param {string} file
291
+ * @param {string} fnName
292
+ * @param {Function} orig
293
+ * @returns {Function}
294
+ */
295
+ function seedProxy(file, fnName, orig) {
142
296
  return new Proxy(orig, {
143
297
  apply(target, thisArg, args) {
144
298
  const collector = als.getStore();
@@ -162,69 +316,242 @@ export function __seedWrap(file, fnName, orig) {
162
316
  });
163
317
  }
164
318
 
319
+ /** A declarator right-hand side that is unambiguously a function. */
320
+ const RHS_FN_RE = /^(?:async\s+)?function\b|^(?:async\s*)?(?:<[^>]*>\s*)?\([^)]*\)\s*=>|^(?:async\s*)?[A-Za-z_$][\w$]*\s*=>/;
321
+ /**
322
+ * A declarator right-hand side that is unambiguously NOT a function: a string /
323
+ * template / numeric literal, an object or array literal, a `new` expression, a
324
+ * tagged template, or a keyword literal. Runs on REDACTED source, so a literal
325
+ * body is blank but its delimiters survive, which is all this needs.
326
+ */
327
+ const RHS_VAL_RE = /^['"`]|^[+-]?\d|^[{[]|^new\s|^(?:true|false|null|undefined)\s*[;,]?\s*$|^[A-Za-z_$][\w$]*\s*`/;
328
+
329
+ /**
330
+ * Split a declaration statement into its declarators on TOP-LEVEL commas, so
331
+ * `const a = 1, b = f(x, y)` yields two parts rather than three. Depth-tracking
332
+ * is enough because the input is redacted (no string / template / regex body can
333
+ * carry an unbalanced bracket).
334
+ * @param {string} stmt
335
+ * @returns {string[]}
336
+ */
337
+ function splitDeclarators(stmt) {
338
+ const parts = [];
339
+ let depth = 0;
340
+ let start = 0;
341
+ for (let i = 0; i < stmt.length; i++) {
342
+ const c = stmt[i];
343
+ if (c === '(' || c === '[' || c === '{') depth++;
344
+ else if (c === ')' || c === ']' || c === '}') depth--;
345
+ else if (c === ',' && depth === 0) {
346
+ parts.push(stmt.slice(start, i));
347
+ start = i + 1;
348
+ }
349
+ }
350
+ parts.push(stmt.slice(start));
351
+ return parts;
352
+ }
353
+
165
354
  /**
166
- * Extract the names of every named export from an action module's source, used
167
- * to generate the facade's `export const NAME = wrap(...)` lines. Conservative:
168
- * a name it misses simply is not wrapped (no seed for it, RPC fallback). A
169
- * `export *` re-export cannot be enumerated statically, so its presence makes
170
- * the caller skip faceting that module entirely (passthrough = no seeding for
171
- * it), never producing a broken facade.
355
+ * Extract the names of every named export from an action module's source, split
356
+ * into the ones that must be emitted as HOISTED function declarations and the
357
+ * ones that must stay plain value bindings. Conservative: a name it misses
358
+ * simply is not wrapped (no seed for it, RPC fallback). A `export *` re-export
359
+ * cannot be enumerated statically and is not reported here: the facade
360
+ * re-exports it wholesale through its own `export * from` catch-all (#538), so
361
+ * nothing about it needs a decision.
362
+ *
363
+ * ## Why the split matters, and which way to guess
364
+ *
365
+ * The two buckets get structurally different facade code, and each is wrong for
366
+ * the other's members in a DIFFERENT way, so the fallback direction is a real
367
+ * decision rather than a detail:
368
+ *
369
+ * - `fnNames` emits a hoisted `export function n(...)`. Hoisting is what makes
370
+ * a circular re-export between two `'use server'` modules load (#1208), but
371
+ * a VALUE emitted this way is silently handed to importers as a callable.
372
+ * - `valNames` emits `export const n = __w(...)`. Correct for any value, but a
373
+ * `const` is in TDZ until the facade body runs, so a FUNCTION emitted this
374
+ * way re-breaks the #1208 cycle.
375
+ *
376
+ * The two export FORMS are therefore defaulted in opposite directions, because
377
+ * each starts from a different prior:
378
+ *
379
+ * - An `export { ... }` LIST is the cycle-critical form: its local may not be
380
+ * declared in this file at all (`export { helper } from './c2.server.js'` is
381
+ * the #1208 fixture). So a listed name is demoted to a value only on POSITIVE
382
+ * evidence (a literal / object / array / `new` / tagged-template right-hand
383
+ * side, or a `class`), and anything undecidable stays a function.
384
+ * - A direct `export const` always HAS its initializer right here, and is much
385
+ * more often a genuine value, so it defaults to `valNames` and is promoted to
386
+ * `fnNames` only on positive function evidence.
387
+ *
388
+ * Both residues are wrong in the value-as-function direction and both PREDATE
389
+ * this split, so it is a strict improvement rather than a new trade, but neither
390
+ * is eliminated. A list-exported name whose right-hand side is computed rather
391
+ * than literal (`const limit = Number(env.L)`, `const x = cond ? a : b`,
392
+ * `const x = someCall()`, or a value whose TS annotation contains a generic
393
+ * comma, which splits the declarator) is still emitted as a function. And a
394
+ * direct `export const` whose right-hand side is a call (`export const post =
395
+ * withAuth(...)`) is still emitted as a `const`, so it keeps the TDZ exposure in
396
+ * a circular import that every direct value export has always had.
397
+ *
398
+ * Scanning runs over a REDACTED copy (string / template / regex / comment bodies
399
+ * blanked by the shared `js-scan` lexer), so a `const` written in a doc comment
400
+ * or quoted in a string cannot register as a real declaration.
401
+ *
172
402
  * @param {string} src
173
- * @returns {{ names: string[], hasDefault: boolean, hasStar: boolean } }
403
+ * @returns {{ fnNames: string[], valNames: string[], names: string[], hasDefault: boolean }}
174
404
  */
175
405
  export function extractExportNames(src) {
176
- const names = new Set();
406
+ const fnNames = new Set();
407
+ const valNames = new Set();
408
+ // Blank every literal body (`blankStrings`), so only code position is read.
409
+ const code = redactStringsAndTemplates(src, true);
410
+
411
+ // Locally declared functions: declarations, and declarators whose right-hand
412
+ // side is unambiguously a function.
413
+ const localFns = new Set();
414
+ // Locally declared non-functions, on POSITIVE evidence only (see above).
415
+ const localVals = new Set();
177
416
  let m;
417
+
418
+ const reLocalFn = /\b(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
419
+ while ((m = reLocalFn.exec(code))) localFns.add(m[1]);
420
+
421
+ const reLocalClass = /\b(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g;
422
+ while ((m = reLocalClass.exec(code))) localVals.add(m[1]);
423
+
424
+ const reDeclStmt = /\b(?:const|let|var)\s+([^;\n]+)/g;
425
+ while ((m = reDeclStmt.exec(code))) {
426
+ for (const part of splitDeclarators(m[1])) {
427
+ // `name = rhs`, tolerating a TS type annotation. A destructuring head or a
428
+ // bare `let x;` does not match and is left undecided on purpose.
429
+ const d = /^\s*([A-Za-z_$][\w$]*)\s*(?::[^=]*)?=\s*([\s\S]*)$/.exec(part);
430
+ if (!d) continue;
431
+ const [, name, rhs] = d;
432
+ const body = rhs.trim();
433
+ if (RHS_FN_RE.test(body)) localFns.add(name);
434
+ else if (RHS_VAL_RE.test(body)) localVals.add(name);
435
+ // Otherwise undecided: left out of both, so a list export of it falls
436
+ // through to `fnNames` (hoisted, cycle-safe).
437
+ }
438
+ }
439
+
440
+ // Direct function exports.
178
441
  const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
179
- while ((m = reFn.exec(src))) names.add(m[1]);
180
- const reVar = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g;
181
- while ((m = reVar.exec(src))) names.add(m[1]);
442
+ while ((m = reFn.exec(code))) fnNames.add(m[1]);
443
+
444
+ const reFnVar = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[\w$]+\s*=>)/g;
445
+ while ((m = reFnVar.exec(code))) fnNames.add(m[1]);
446
+
447
+ // Direct class exports.
182
448
  const reClass = /\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g;
183
- while ((m = reClass.exec(src))) names.add(m[1]);
449
+ while ((m = reClass.exec(code))) valNames.add(m[1]);
450
+
451
+ // Direct variable exports that are not function assignments. `reFnVar` above
452
+ // only sees a bare `function` / arrow right-hand side, so consult `localFns`
453
+ // too: it ran the declarator through `RHS_FN_RE`, which tolerates a TS type
454
+ // annotation, and so recognises the very common
455
+ // `export const create: Handler = async (i) => ...`. This only ever PROMOTES
456
+ // a name to the hoisted bucket on positive function evidence; the default for
457
+ // a direct `export const` stays the value binding.
458
+ const reVar = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g;
459
+ while ((m = reVar.exec(code))) {
460
+ const n = m[1];
461
+ if (fnNames.has(n)) continue;
462
+ if (localFns.has(n)) fnNames.add(n);
463
+ else valNames.add(n);
464
+ }
465
+
466
+ // Export list: `export { a, b as bee }`.
184
467
  const reList = /\bexport\s*\{([^}]*)\}/g;
185
- while ((m = reList.exec(src))) {
468
+ while ((m = reList.exec(code))) {
469
+ // `export { default as X } from './other.js'` re-exports ANOTHER module's
470
+ // default under a named binding. It gives this module no default export, so
471
+ // it must not set `hasDefault` (that would fabricate `export default
472
+ // undefined` on a module that has none). `X` itself needs no entry here: it
473
+ // is a named export of the re-export target, which the facade's own
474
+ // `export * from` catch-all already carries.
186
475
  for (const part of m[1].split(',')) {
187
476
  const seg = part.trim();
188
477
  if (!seg) continue;
189
- // `a` or `a as b` (the EXPORTED name is what an importer binds).
190
478
  const as = seg.split(/\s+as\s+/);
479
+ const local = as[0].trim();
191
480
  const exported = (as[1] || as[0]).trim();
192
- if (/^[A-Za-z_$][\w$]*$/.test(exported) && exported !== 'default') names.add(exported);
193
- else if (exported === 'default') names.add('__default__');
481
+ if (!/^[A-Za-z_$][\w$]*$/.test(exported)) continue;
482
+ if (local === 'default') continue;
483
+
484
+ if (exported === 'default') fnNames.add('__default__');
485
+ else if (localVals.has(local) && !localFns.has(local)) valNames.add(exported);
486
+ else fnNames.add(exported);
194
487
  }
195
488
  }
196
- const hasDefault = /\bexport\s+default\b/.test(src) || names.delete('__default__');
197
- const hasStar = /\bexport\s*\*/.test(src);
198
- return { names: [...names], hasDefault, hasStar };
489
+
490
+ // A name reaching both buckets (e.g. `export const x = () => {}` also seen as
491
+ // a plain variable export) is a function: the hoisted form is the safe one.
492
+ for (const fn of fnNames) valNames.delete(fn);
493
+
494
+ const hasDefault = /\bexport\s+default\b/.test(code) || fnNames.delete('__default__') || valNames.delete('__default__');
495
+ return { fnNames: [...fnNames], valNames: [...valNames], names: [...fnNames, ...valNames], hasDefault };
496
+ }
497
+
498
+ /**
499
+ * Pick a prefix for the facade's per-function memo variables that cannot collide
500
+ * with anything else the facade declares.
501
+ *
502
+ * The memo for export `n` is declared at module scope as `<prefix>n`, so a module
503
+ * exporting both `ping` and `_fn_ping` would emit `var _fn_ping` alongside
504
+ * `export function _fn_ping`, a duplicate declaration. That is a SyntaxError in
505
+ * generated source, which the load hook's try/catch cannot contain (the parse
506
+ * happens after the hook returns), so it would crash the module load outright
507
+ * instead of degrading to no seeding. Pathological naming, but the feature's
508
+ * contract is that any failure is fail-open, and a hard crash is not.
509
+ *
510
+ * @param {{ fnNames: string[], valNames: string[] }} exports
511
+ * @returns {string}
512
+ */
513
+ function memoPrefix(exports) {
514
+ const taken = new Set([...exports.fnNames, ...exports.valNames, '__orig', '__w']);
515
+ let prefix = '_fn_';
516
+ while (exports.fnNames.some((n) => taken.has(prefix + n))) prefix = `_${prefix}`;
517
+ return prefix;
199
518
  }
200
519
 
201
520
  /**
202
521
  * Build the facade module source for a `'use server'` action module: it imports
203
522
  * the REAL module via a `?webjs-seed-orig` query (which the hook passes through
204
- * unwrapped) and re-exports each function wrapped through `__seedWrap`.
523
+ * unwrapped) and re-exports each function through `__actionWrap`.
205
524
  * @param {string} origUrl the real module URL, WITHOUT the seed query
206
525
  * @param {string} absPath the real module's absolute file path (the hash basis)
207
- * @param {{ names: string[], hasDefault: boolean }} exports
526
+ * @param {{ fnNames: string[], valNames: string[], names: string[], hasDefault: boolean }} exports
208
527
  * @returns {string}
209
528
  */
210
529
  function buildFacade(origUrl, absPath, exports) {
211
530
  const sep = origUrl.includes('?') ? '&' : '?';
212
531
  const origSpec = JSON.stringify(origUrl + sep + 'webjs-seed-orig');
213
532
  const file = JSON.stringify(absPath);
533
+ const memo = memoPrefix(exports);
214
534
  let out = `import * as __orig from ${origSpec};\n`;
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.
535
+ out += `import { __actionWrap as __w } from ${JSON.stringify(SELF_URL)};\n`;
226
536
  out += `export * from ${origSpec};\n`;
227
- for (const n of exports.names) {
537
+ for (const n of exports.fnNames) {
538
+ const k = JSON.stringify(n);
539
+ const v = `${memo}${n}`;
540
+ // `var`, NOT `let`. The exported function is hoisted, which is the whole
541
+ // reason a circular re-export between two `'use server'` modules loads
542
+ // (#1208): the other module in the cycle can call it before this facade's
543
+ // body has run. A `let` memo would be in TDZ at that moment, so the call
544
+ // would throw `Cannot access '...' before initialization`, re-breaking the
545
+ // very cycle the hoisting exists to survive. `var` hoists initialized to
546
+ // undefined, so the first call falls through to the lookup as intended.
547
+ out += `var ${v};\n`;
548
+ out += `export function ${n}(...args) {\n`;
549
+ out += ` const fn = ${v} || (${v} = __w(${file}, ${k}, __orig[${k}]));\n`;
550
+ out += ` return typeof fn === 'function' ? fn.apply(this, args) : fn;\n`;
551
+ out += `}\n`;
552
+ out += `__w(${file}, ${k}, ${n});\n`;
553
+ }
554
+ for (const n of exports.valNames) {
228
555
  const k = JSON.stringify(n);
229
556
  out += `export const ${n} = __w(${file}, ${k}, __orig[${k}]);\n`;
230
557
  }
@@ -265,9 +592,14 @@ export function buildSeedFacade(origSpec, absPath, src) {
265
592
  const head = src.split('\n').slice(0, 5).join('\n');
266
593
  if (!USE_SERVER_RE.test(head)) return null;
267
594
  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;
595
+ // `export *` used to bail out to a passthrough here, so a facade could never
596
+ // drop a re-exported binding it was unable to enumerate. #538 then gave the
597
+ // facade its own `export * from` catch-all, which covers exactly that, and
598
+ // the bail-out was left behind. Keeping it is now actively harmful: identity
599
+ // rides the facade (#1155), so a passthrough means `<form action=${fn}>`
600
+ // throws "is not a server action" at SSR for a function that works perfectly
601
+ // over RPC. A star re-export therefore facades like anything else, its
602
+ // enumerable names wrapped and the rest carried by the catch-all.
271
603
  if (exports.names.length === 0 && !exports.hasDefault) return null;
272
604
  return buildFacade(origSpec, absPath, exports);
273
605
  }
@@ -296,16 +628,24 @@ function seedLoadHook(url, context, nextLoad) {
296
628
  }
297
629
 
298
630
  /**
299
- * Install the seed load hook (idempotent). Called once at boot from `dev.js`
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.
631
+ * Install the `'use server'` load hook (idempotent). Called once at boot from
632
+ * `dev.js`, BEFORE any action module is imported (a module loaded before the
633
+ * hook would already be cached unwrapped). The install mechanism is chosen by
634
+ * `serverRuntime()` (#529): Node's synchronous `module.registerHooks`, or a
635
+ * `Bun.plugin` `onLoad` on Bun. A no-op on a second call. Async because the Bun
636
+ * path dynamically imports `action-seed-bun.js` (so the `Bun.*` global is never
637
+ * referenced on Node); the Node path resolves synchronously.
638
+ *
639
+ * The hook installs whatever `seed` says, because action IDENTITY (#1155) rides
640
+ * it and is not optional. `seed` gates only whether results are COLLECTED.
641
+ *
642
+ * @param {{ seed?: boolean, dev?: boolean }} [opts] `dev` gates the determinism
643
+ * assertion in `recordSeed` (a console warning has no place in production).
306
644
  * @returns {Promise<void>}
307
645
  */
308
- export async function registerSeedHooks() {
646
+ export async function registerActionHooks(opts = {}) {
647
+ _seedEnabled = opts.seed !== false;
648
+ _devMode = opts.dev === true;
309
649
  if (_registered) return;
310
650
  _registered = true;
311
651
 
@@ -313,20 +653,22 @@ export async function registerSeedHooks() {
313
653
  // Bun has no module.registerHooks; install the same facade via Bun.plugin.
314
654
  const { installBunSeedPlugin } = await import('./action-seed-bun.js');
315
655
  installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE });
316
- _enabled = true;
656
+ _hookInstalled = true;
317
657
  return;
318
658
  }
319
659
 
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,
660
+ // A runtime that is neither Node-with-registerHooks nor Bun. Seeding stays
661
+ // OFF (fail-open): `seedingEnabled()` is false, ssr.js emits no seed block,
322
662
  // and the client RPC stub falls back to a normal fetch. Never wrong data.
663
+ // Identity has no such graceful degradation, so the form-action resolver
664
+ // falls back to a module scan instead (see `resolveActionIdentity`).
323
665
  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.');
666
+ console.warn('[webjs] the \'use server\' load hook is not installed: this runtime has neither module.registerHooks nor Bun.plugin. SSR action-result seeding (#472) is off, and form-action identity (#1155) resolves by scanning the action index instead.');
325
667
  return;
326
668
  }
327
669
 
328
670
  nodeModule.registerHooks({ load: seedLoadHook });
329
- _enabled = true;
671
+ _hookInstalled = true;
330
672
  }
331
673
 
332
674
  /**
@@ -344,6 +686,15 @@ export async function collectSeeds(fn) {
344
686
  return { value, collector };
345
687
  }
346
688
 
689
+ /**
690
+ * The dev-only block emitted when the serializer THREW and the whole payload was
691
+ * dropped. Exported so the SSR emitter can tell it apart from a real block: it
692
+ * carries a marker so the browser can name the cause, but it ships NO seeds, so
693
+ * counting it as emitted would report a total success on the one failure the
694
+ * counts exist to expose.
695
+ */
696
+ export const SEED_DROP_BLOCK = '<script type="application/json" id="__webjs-seeds" data-webjs-dev="drop">{}</script>';
697
+
347
698
  /**
348
699
  * Serialize a collector into a `<script type="application/json">` block to embed
349
700
  * in the page. Returns '' for an empty collector (so a render with no seeded
@@ -351,14 +702,21 @@ export async function collectSeeds(fn) {
351
702
  * format the RPC stub's `parse` reads) and HTML-escaped so it can never break out
352
703
  * of the script element. A `type="application/json"` script is DATA, not
353
704
  * executable JS, so it needs no CSP nonce.
354
- * @param {Map<string, unknown>} collector
705
+ * @param {Map<string, unknown> | null} collector
706
+ * @param {{ dev?: boolean, reason?: string }} [opts] in DEV, stamp a
707
+ * `data-webjs-dev` marker and emit the block even when the collector is EMPTY.
708
+ * The marker is the only dev signal the browser gets (#1309): a
709
+ * `process.env.NODE_ENV` gate is a compile-time constant in the built core
710
+ * bundle, so the client cannot decide this for itself. Emitting an empty block
711
+ * in dev is what lets a page that seeded NOTHING still be reported on.
355
712
  * @returns {Promise<string>}
356
713
  */
357
- export async function buildSeedScript(collector) {
358
- if (!collector || collector.size === 0) return '';
714
+ export async function buildSeedScript(collector, opts = {}) {
715
+ const dev = opts.dev === true;
716
+ if ((!collector || collector.size === 0) && !dev) return '';
359
717
  try {
360
718
  const obj = {};
361
- for (const [k, v] of collector) obj[k] = v;
719
+ if (collector) for (const [k, v] of collector) obj[k] = v;
362
720
  const payload = await stringify(obj);
363
721
  const safe = payload
364
722
  .replace(/</g, '\\u003c')
@@ -366,8 +724,18 @@ export async function buildSeedScript(collector) {
366
724
  .replace(/&/g, '\\u0026')
367
725
  .replace(/\u2028/g, '\\u2028')
368
726
  .replace(/\u2029/g, '\\u2029');
369
- return `<script type="application/json" id="__webjs-seeds">${safe}</script>`;
727
+ // Attribute-safe by construction: `reason` is a framework literal ('ok' /
728
+ // 'streamed' / 'drop'), never app input, and is emitted only in dev.
729
+ const marker = dev ? ` data-webjs-dev="${opts.reason || 'ok'}"` : '';
730
+ return `<script type="application/json" id="__webjs-seeds"${marker}>${safe}</script>`;
370
731
  } catch {
732
+ // The serializer threw, so the whole block is dropped. This is the ONE
733
+ // failure the counting exists to expose (`collected` above `emitted`), and
734
+ // in prod it stays silent as before. In DEV emit a marker-only block so the
735
+ // browser can name it: with no marker at all the client opens no report
736
+ // window, so every missed hydration call on the page prints nothing, which
737
+ // is precisely the invisible failure this feature removes.
738
+ if (dev) return SEED_DROP_BLOCK;
371
739
  return '';
372
740
  }
373
741
  }