@webjsdev/server 0.8.57 → 0.8.59

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/index.d.ts CHANGED
@@ -537,11 +537,26 @@ export declare function rateLimit(opts?: {
537
537
  key?: string | ((req: Request) => string | Promise<string>);
538
538
  message?: string;
539
539
  store?: CacheStore;
540
+ /**
541
+ * Bucket on the forwarded-IP headers (`X-Forwarded-For`, `CF-Connecting-IP`,
542
+ * `X-Real-IP`) instead of the framework-stamped peer. Inert while
543
+ * `WEBJS_NO_TRUST_PROXY=1` is set: that env switch is the operator's
544
+ * statement about the topology and it outranks this option, so the limiter
545
+ * falls back to the stamped peer and logs once. Only safe behind a proxy
546
+ * that STRIPS inbound `X-Forwarded-For` before adding its own.
547
+ */
540
548
  trustProxy?: boolean;
541
549
  }): Middleware;
542
550
  /** Parse a window string (`'1m'`, `'30s'`) to milliseconds. */
543
551
  export declare function parseWindow(w: number | string): number;
544
- /** Resolve the client IP for a request (honoring proxy-trust posture). */
552
+ /**
553
+ * Resolve the client IP for a request (honoring proxy-trust posture).
554
+ *
555
+ * Defaults to the framework-stamped peer, or `_anon_` when there is none.
556
+ * `trustProxy: true` reads the forwarded-IP headers instead, UNLESS
557
+ * `WEBJS_NO_TRUST_PROXY=1` is set, which overrides the option back to the
558
+ * stamped peer and logs once per process.
559
+ */
545
560
  export declare function clientIp(req: Request, opts?: { trustProxy?: boolean }): string;
546
561
  /** Stamp the socket remote address onto a request for `clientIp` to read. */
547
562
  export declare function stampRemoteIp(req: Request, remoteAddress: string): void;
@@ -613,7 +628,16 @@ export declare function cache<T extends (...args: any[]) => Promise<any>>(
613
628
  export declare function revalidateTag(tag: string): Promise<void>;
614
629
  /** Evict every `cache()` entry tagged with any of `tags`. */
615
630
  export declare function revalidateTags(tags: string[]): Promise<void>;
616
- /** Evict the cached HTML for a path (the next request re-renders). */
631
+ /**
632
+ * Evict the cached HTML for a path (the next request re-renders).
633
+ *
634
+ * Keys carry the request origin, so a bare path resolves it from the calling
635
+ * request, which is exact from a server action. Pass an absolute url
636
+ * (`https://app.example/blog`) from a caller with no request in scope (a cron
637
+ * job, a queue worker); a bare path there warns and evicts nothing. Under
638
+ * `webjs.basePath` the absolute url is the PUBLIC one and its mount prefix is
639
+ * stripped, so it evicts the entry the write actually stored.
640
+ */
617
641
  export declare function revalidatePath(path: string): Promise<void>;
618
642
  /** Evict all cached HTML (per-process generation bump). */
619
643
  export declare function revalidateAll(): Promise<void>;
package/index.js CHANGED
@@ -75,6 +75,7 @@ export {
75
75
  withCookies,
76
76
  withSessionCookie,
77
77
  loginAndGetCookies,
78
+ submitForm,
78
79
  actionEndpoint,
79
80
  invokeActionForTest,
80
81
  rawActionRequest,
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.57",
3
+ "version": "0.8.59",
4
4
  "type": "module",
5
- "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
5
+ "description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
6
6
  "main": "index.js",
7
7
  "types": "./index.d.ts",
8
8
  "exports": {
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@webjsdev/core": "^0.7.1",
31
+ "@webjsdev/core": "^0.7.47",
32
32
  "ws": "^8.20.0"
33
33
  },
34
34
  "publishConfig": {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Bun has no `module.registerHooks`, so the seed facade is installed via a
5
5
  * `Bun.plugin` `onLoad`, the Bun analog of the Node load hook in
6
- * `action-seed.js`. This module is dynamically imported by `registerSeedHooks`
6
+ * `action-seed.js`. This module is dynamically imported by `registerActionHooks`
7
7
  * ONLY when `serverRuntime()` is `'bun'`, so the `Bun.*` global is never
8
8
  * referenced on Node (the same isolation as `listener-bun.js`).
9
9
  *
@@ -28,7 +28,7 @@
28
28
 
29
29
  /**
30
30
  * Install the `Bun.plugin` seed `onLoad`. Idempotency is the caller's
31
- * responsibility (`registerSeedHooks` guards on `_registered`).
31
+ * responsibility (`registerActionHooks` guards on `_registered`).
32
32
  *
33
33
  * @param {{
34
34
  * isSeedCandidate: (specifier: string) => boolean,
@@ -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,41 @@ 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
+ /** Whether the load hook is installed at all (so identity is available). */
79
+ let _hookInstalled = false;
65
80
  /** Idempotency guard: `module.registerHooks` must run at most once. */
66
81
  let _registered = false;
67
82
 
83
+ /**
84
+ * `action function -> { file, fnName }` for every export the hook wrapped.
85
+ *
86
+ * A WeakMap rather than a property on the function: the wrapped value may be a
87
+ * Proxy whose property writes pass through to the target, so stamping it would
88
+ * mutate the app's own function object, and a frozen action would make the
89
+ * stamp fail silently. Both the wrapper and the original are registered,
90
+ * because which one a caller holds depends on whether seeding is on.
91
+ *
92
+ * EVERY registration is kept, defining module first, because one function can
93
+ * legitimately be exported from more than one `'use server'` module (a barrel
94
+ * re-exporting it). The defining module is the right answer whenever it is
95
+ * available, since it is the one carrying the action's `validate` /
96
+ * `middleware` / `method` / `invalidates` config exports. But it is not always
97
+ * REACHABLE: the action index only walks the app tree, so an action defined in
98
+ * a linked workspace package and re-exported through an in-app barrel has an
99
+ * indexed name only under the barrel. Keeping both lets the caller prefer the
100
+ * defining module and fall back rather than fail.
101
+ *
102
+ * @type {WeakMap<Function, { file: string, fnName: string }[]>}
103
+ */
104
+ const _identity = new WeakMap();
105
+
68
106
  /** Memoized `absPath -> hash` so a hot action call does not re-hash per call. */
69
107
  const _hashCache = new Map();
70
108
 
@@ -78,7 +116,41 @@ const SELF_URL = import.meta.url;
78
116
  * @returns {boolean}
79
117
  */
80
118
  export function seedingEnabled() {
81
- return _enabled;
119
+ return _seedEnabled && _hookInstalled;
120
+ }
121
+
122
+ /**
123
+ * Whether the load hook is installed, and therefore whether action identity is
124
+ * being registered. False only on a runtime with neither `module.registerHooks`
125
+ * nor `Bun.plugin`, where the form-action resolver falls back to a module scan.
126
+ * @returns {boolean}
127
+ */
128
+ export function identityHookInstalled() {
129
+ return _hookInstalled;
130
+ }
131
+
132
+ /**
133
+ * The `{ file, fnName }` an action function was exported as, or null when the
134
+ * hook never saw it (a module loaded before the hook installed, or a runtime
135
+ * with no hook).
136
+ * @param {unknown} fn
137
+ * @returns {{ file: string, fnName: string } | null}
138
+ */
139
+ export function actionIdentityOf(fn) {
140
+ const all = actionIdentitiesOf(fn);
141
+ return all.length ? all[0] : null;
142
+ }
143
+
144
+ /**
145
+ * EVERY `{ file, fnName }` the function was exported as, defining module first.
146
+ * The form-action resolver needs the whole list, because only some of them may
147
+ * be in the action index (see the `_identity` note above).
148
+ * @param {unknown} fn
149
+ * @returns {{ file: string, fnName: string }[]}
150
+ */
151
+ export function actionIdentitiesOf(fn) {
152
+ if (typeof fn !== 'function') return [];
153
+ return _identity.get(/** @type {Function} */ (fn)) || [];
82
154
  }
83
155
 
84
156
  /**
@@ -86,11 +158,14 @@ export function seedingEnabled() {
86
158
  * action index do (`hashFile` over the absolute path string), so the seed key
87
159
  * the server emits matches the key the client stub looks up. A path mismatch
88
160
  * (e.g. a symlinked appDir whose realpath differs) only yields a key MISS,
89
- * which safely degrades to a normal RPC.
161
+ * which safely degrades to a normal RPC. Note that the FORM path does not reach
162
+ * here: it resolves an identity only against the action index, because a hash
163
+ * the index does not know is unresolvable at submit time (see
164
+ * `resolveActionIdentity`).
90
165
  * @param {string} absPath
91
166
  * @returns {Promise<string>}
92
167
  */
93
- async function hashFor(absPath) {
168
+ export async function actionFileHash(absPath) {
94
169
  let h = _hashCache.get(absPath);
95
170
  if (h === undefined) {
96
171
  h = await hashFile(absPath);
@@ -117,7 +192,7 @@ async function recordSeed(collector, file, fnName, args, value) {
117
192
  // streamed action is never seeded; the client streams it fresh on each call.
118
193
  if (isStreamable(value)) return;
119
194
  try {
120
- const hash = await hashFor(file);
195
+ const hash = await actionFileHash(file);
121
196
  const argsKey = await stringify(args);
122
197
  collector.set(`${hash}/${fnName}/${argsKey}`, value);
123
198
  } catch {
@@ -126,19 +201,58 @@ async function recordSeed(collector, file, fnName, args, value) {
126
201
  }
127
202
 
128
203
  /**
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.
204
+ * Register one exported action function's identity, and, when seeding is on,
205
+ * wrap it so that a resolved result is recorded into an active collector.
206
+ *
207
+ * Identity is registered unconditionally: it is what `<form action=${action}>`
208
+ * resolves through (#1155), so it cannot depend on the seed switch.
209
+ *
210
+ * The Proxy is NOT created when seeding is off. Outside a collector it is a
211
+ * transparent passthrough anyway, so with collection disabled it would be pure
212
+ * indirection on every action call for the lifetime of the process. Identity
213
+ * is registered for BOTH the original and the wrapper, so a caller resolves
214
+ * whichever of the two it happens to hold.
215
+ *
216
+ * Non-functions (a `const VERSION = '1.0'` export) pass through untouched, and
217
+ * the Proxy forwards property reads, so any metadata an app or the framework
218
+ * attaches to the function still resolves through the wrapper.
219
+ *
135
220
  * @param {string} file absolute action file path
136
221
  * @param {string} fnName
137
222
  * @param {unknown} orig
138
223
  * @returns {unknown}
139
224
  */
140
- export function __seedWrap(file, fnName, orig) {
225
+ export function __actionWrap(file, fnName, orig) {
141
226
  if (typeof orig !== 'function') return orig;
227
+ // APPEND, defining module first. A barrel is faceted too and its body runs
228
+ // AFTER the module it re-exports from, so the first registration is the
229
+ // defining one: the module that also carries `validate` / `middleware` /
230
+ // `method` / `invalidates`. Overwriting re-filed the function under the
231
+ // BARREL, and the dispatcher then read those config exports off a namespace
232
+ // carrying none of them, running a submission with the action's validation
233
+ // and auth middleware silently skipped. Keeping the later ones too matters
234
+ // just as much: when the defining module is outside the walked app tree, the
235
+ // barrel is the only name the action index knows.
236
+ const list = _identity.get(orig) || [];
237
+ if (!list.some((e) => e.file === file && e.fnName === fnName)) list.push({ file, fnName });
238
+ _identity.set(orig, list);
239
+ if (!_seedEnabled) return orig;
240
+ const wrapped = seedProxy(file, fnName, orig);
241
+ // The wrapper is a fresh object every time, so it shares the TARGET's list
242
+ // rather than starting one of its own.
243
+ _identity.set(wrapped, list);
244
+ return wrapped;
245
+ }
246
+
247
+ /**
248
+ * The seed-recording Proxy. Split out of `__actionWrap` so the identity
249
+ * registration above reads as the unconditional half and this as the gated one.
250
+ * @param {string} file
251
+ * @param {string} fnName
252
+ * @param {Function} orig
253
+ * @returns {Function}
254
+ */
255
+ function seedProxy(file, fnName, orig) {
142
256
  return new Proxy(orig, {
143
257
  apply(target, thisArg, args) {
144
258
  const collector = als.getStore();
@@ -162,69 +276,242 @@ export function __seedWrap(file, fnName, orig) {
162
276
  });
163
277
  }
164
278
 
279
+ /** A declarator right-hand side that is unambiguously a function. */
280
+ const RHS_FN_RE = /^(?:async\s+)?function\b|^(?:async\s*)?(?:<[^>]*>\s*)?\([^)]*\)\s*=>|^(?:async\s*)?[A-Za-z_$][\w$]*\s*=>/;
165
281
  /**
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.
282
+ * A declarator right-hand side that is unambiguously NOT a function: a string /
283
+ * template / numeric literal, an object or array literal, a `new` expression, a
284
+ * tagged template, or a keyword literal. Runs on REDACTED source, so a literal
285
+ * body is blank but its delimiters survive, which is all this needs.
286
+ */
287
+ const RHS_VAL_RE = /^['"`]|^[+-]?\d|^[{[]|^new\s|^(?:true|false|null|undefined)\s*[;,]?\s*$|^[A-Za-z_$][\w$]*\s*`/;
288
+
289
+ /**
290
+ * Split a declaration statement into its declarators on TOP-LEVEL commas, so
291
+ * `const a = 1, b = f(x, y)` yields two parts rather than three. Depth-tracking
292
+ * is enough because the input is redacted (no string / template / regex body can
293
+ * carry an unbalanced bracket).
294
+ * @param {string} stmt
295
+ * @returns {string[]}
296
+ */
297
+ function splitDeclarators(stmt) {
298
+ const parts = [];
299
+ let depth = 0;
300
+ let start = 0;
301
+ for (let i = 0; i < stmt.length; i++) {
302
+ const c = stmt[i];
303
+ if (c === '(' || c === '[' || c === '{') depth++;
304
+ else if (c === ')' || c === ']' || c === '}') depth--;
305
+ else if (c === ',' && depth === 0) {
306
+ parts.push(stmt.slice(start, i));
307
+ start = i + 1;
308
+ }
309
+ }
310
+ parts.push(stmt.slice(start));
311
+ return parts;
312
+ }
313
+
314
+ /**
315
+ * Extract the names of every named export from an action module's source, split
316
+ * into the ones that must be emitted as HOISTED function declarations and the
317
+ * ones that must stay plain value bindings. Conservative: a name it misses
318
+ * simply is not wrapped (no seed for it, RPC fallback). A `export *` re-export
319
+ * cannot be enumerated statically and is not reported here: the facade
320
+ * re-exports it wholesale through its own `export * from` catch-all (#538), so
321
+ * nothing about it needs a decision.
322
+ *
323
+ * ## Why the split matters, and which way to guess
324
+ *
325
+ * The two buckets get structurally different facade code, and each is wrong for
326
+ * the other's members in a DIFFERENT way, so the fallback direction is a real
327
+ * decision rather than a detail:
328
+ *
329
+ * - `fnNames` emits a hoisted `export function n(...)`. Hoisting is what makes
330
+ * a circular re-export between two `'use server'` modules load (#1208), but
331
+ * a VALUE emitted this way is silently handed to importers as a callable.
332
+ * - `valNames` emits `export const n = __w(...)`. Correct for any value, but a
333
+ * `const` is in TDZ until the facade body runs, so a FUNCTION emitted this
334
+ * way re-breaks the #1208 cycle.
335
+ *
336
+ * The two export FORMS are therefore defaulted in opposite directions, because
337
+ * each starts from a different prior:
338
+ *
339
+ * - An `export { ... }` LIST is the cycle-critical form: its local may not be
340
+ * declared in this file at all (`export { helper } from './c2.server.js'` is
341
+ * the #1208 fixture). So a listed name is demoted to a value only on POSITIVE
342
+ * evidence (a literal / object / array / `new` / tagged-template right-hand
343
+ * side, or a `class`), and anything undecidable stays a function.
344
+ * - A direct `export const` always HAS its initializer right here, and is much
345
+ * more often a genuine value, so it defaults to `valNames` and is promoted to
346
+ * `fnNames` only on positive function evidence.
347
+ *
348
+ * Both residues are wrong in the value-as-function direction and both PREDATE
349
+ * this split, so it is a strict improvement rather than a new trade, but neither
350
+ * is eliminated. A list-exported name whose right-hand side is computed rather
351
+ * than literal (`const limit = Number(env.L)`, `const x = cond ? a : b`,
352
+ * `const x = someCall()`, or a value whose TS annotation contains a generic
353
+ * comma, which splits the declarator) is still emitted as a function. And a
354
+ * direct `export const` whose right-hand side is a call (`export const post =
355
+ * withAuth(...)`) is still emitted as a `const`, so it keeps the TDZ exposure in
356
+ * a circular import that every direct value export has always had.
357
+ *
358
+ * Scanning runs over a REDACTED copy (string / template / regex / comment bodies
359
+ * blanked by the shared `js-scan` lexer), so a `const` written in a doc comment
360
+ * or quoted in a string cannot register as a real declaration.
361
+ *
172
362
  * @param {string} src
173
- * @returns {{ names: string[], hasDefault: boolean, hasStar: boolean } }
363
+ * @returns {{ fnNames: string[], valNames: string[], names: string[], hasDefault: boolean }}
174
364
  */
175
365
  export function extractExportNames(src) {
176
- const names = new Set();
366
+ const fnNames = new Set();
367
+ const valNames = new Set();
368
+ // Blank every literal body (`blankStrings`), so only code position is read.
369
+ const code = redactStringsAndTemplates(src, true);
370
+
371
+ // Locally declared functions: declarations, and declarators whose right-hand
372
+ // side is unambiguously a function.
373
+ const localFns = new Set();
374
+ // Locally declared non-functions, on POSITIVE evidence only (see above).
375
+ const localVals = new Set();
177
376
  let m;
377
+
378
+ const reLocalFn = /\b(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
379
+ while ((m = reLocalFn.exec(code))) localFns.add(m[1]);
380
+
381
+ const reLocalClass = /\b(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g;
382
+ while ((m = reLocalClass.exec(code))) localVals.add(m[1]);
383
+
384
+ const reDeclStmt = /\b(?:const|let|var)\s+([^;\n]+)/g;
385
+ while ((m = reDeclStmt.exec(code))) {
386
+ for (const part of splitDeclarators(m[1])) {
387
+ // `name = rhs`, tolerating a TS type annotation. A destructuring head or a
388
+ // bare `let x;` does not match and is left undecided on purpose.
389
+ const d = /^\s*([A-Za-z_$][\w$]*)\s*(?::[^=]*)?=\s*([\s\S]*)$/.exec(part);
390
+ if (!d) continue;
391
+ const [, name, rhs] = d;
392
+ const body = rhs.trim();
393
+ if (RHS_FN_RE.test(body)) localFns.add(name);
394
+ else if (RHS_VAL_RE.test(body)) localVals.add(name);
395
+ // Otherwise undecided: left out of both, so a list export of it falls
396
+ // through to `fnNames` (hoisted, cycle-safe).
397
+ }
398
+ }
399
+
400
+ // Direct function exports.
178
401
  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]);
402
+ while ((m = reFn.exec(code))) fnNames.add(m[1]);
403
+
404
+ const reFnVar = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[\w$]+\s*=>)/g;
405
+ while ((m = reFnVar.exec(code))) fnNames.add(m[1]);
406
+
407
+ // Direct class exports.
182
408
  const reClass = /\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g;
183
- while ((m = reClass.exec(src))) names.add(m[1]);
409
+ while ((m = reClass.exec(code))) valNames.add(m[1]);
410
+
411
+ // Direct variable exports that are not function assignments. `reFnVar` above
412
+ // only sees a bare `function` / arrow right-hand side, so consult `localFns`
413
+ // too: it ran the declarator through `RHS_FN_RE`, which tolerates a TS type
414
+ // annotation, and so recognises the very common
415
+ // `export const create: Handler = async (i) => ...`. This only ever PROMOTES
416
+ // a name to the hoisted bucket on positive function evidence; the default for
417
+ // a direct `export const` stays the value binding.
418
+ const reVar = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g;
419
+ while ((m = reVar.exec(code))) {
420
+ const n = m[1];
421
+ if (fnNames.has(n)) continue;
422
+ if (localFns.has(n)) fnNames.add(n);
423
+ else valNames.add(n);
424
+ }
425
+
426
+ // Export list: `export { a, b as bee }`.
184
427
  const reList = /\bexport\s*\{([^}]*)\}/g;
185
- while ((m = reList.exec(src))) {
428
+ while ((m = reList.exec(code))) {
429
+ // `export { default as X } from './other.js'` re-exports ANOTHER module's
430
+ // default under a named binding. It gives this module no default export, so
431
+ // it must not set `hasDefault` (that would fabricate `export default
432
+ // undefined` on a module that has none). `X` itself needs no entry here: it
433
+ // is a named export of the re-export target, which the facade's own
434
+ // `export * from` catch-all already carries.
186
435
  for (const part of m[1].split(',')) {
187
436
  const seg = part.trim();
188
437
  if (!seg) continue;
189
- // `a` or `a as b` (the EXPORTED name is what an importer binds).
190
438
  const as = seg.split(/\s+as\s+/);
439
+ const local = as[0].trim();
191
440
  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__');
441
+ if (!/^[A-Za-z_$][\w$]*$/.test(exported)) continue;
442
+ if (local === 'default') continue;
443
+
444
+ if (exported === 'default') fnNames.add('__default__');
445
+ else if (localVals.has(local) && !localFns.has(local)) valNames.add(exported);
446
+ else fnNames.add(exported);
194
447
  }
195
448
  }
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 };
449
+
450
+ // A name reaching both buckets (e.g. `export const x = () => {}` also seen as
451
+ // a plain variable export) is a function: the hoisted form is the safe one.
452
+ for (const fn of fnNames) valNames.delete(fn);
453
+
454
+ const hasDefault = /\bexport\s+default\b/.test(code) || fnNames.delete('__default__') || valNames.delete('__default__');
455
+ return { fnNames: [...fnNames], valNames: [...valNames], names: [...fnNames, ...valNames], hasDefault };
456
+ }
457
+
458
+ /**
459
+ * Pick a prefix for the facade's per-function memo variables that cannot collide
460
+ * with anything else the facade declares.
461
+ *
462
+ * The memo for export `n` is declared at module scope as `<prefix>n`, so a module
463
+ * exporting both `ping` and `_fn_ping` would emit `var _fn_ping` alongside
464
+ * `export function _fn_ping`, a duplicate declaration. That is a SyntaxError in
465
+ * generated source, which the load hook's try/catch cannot contain (the parse
466
+ * happens after the hook returns), so it would crash the module load outright
467
+ * instead of degrading to no seeding. Pathological naming, but the feature's
468
+ * contract is that any failure is fail-open, and a hard crash is not.
469
+ *
470
+ * @param {{ fnNames: string[], valNames: string[] }} exports
471
+ * @returns {string}
472
+ */
473
+ function memoPrefix(exports) {
474
+ const taken = new Set([...exports.fnNames, ...exports.valNames, '__orig', '__w']);
475
+ let prefix = '_fn_';
476
+ while (exports.fnNames.some((n) => taken.has(prefix + n))) prefix = `_${prefix}`;
477
+ return prefix;
199
478
  }
200
479
 
201
480
  /**
202
481
  * Build the facade module source for a `'use server'` action module: it imports
203
482
  * the REAL module via a `?webjs-seed-orig` query (which the hook passes through
204
- * unwrapped) and re-exports each function wrapped through `__seedWrap`.
483
+ * unwrapped) and re-exports each function through `__actionWrap`.
205
484
  * @param {string} origUrl the real module URL, WITHOUT the seed query
206
485
  * @param {string} absPath the real module's absolute file path (the hash basis)
207
- * @param {{ names: string[], hasDefault: boolean }} exports
486
+ * @param {{ fnNames: string[], valNames: string[], names: string[], hasDefault: boolean }} exports
208
487
  * @returns {string}
209
488
  */
210
489
  function buildFacade(origUrl, absPath, exports) {
211
490
  const sep = origUrl.includes('?') ? '&' : '?';
212
491
  const origSpec = JSON.stringify(origUrl + sep + 'webjs-seed-orig');
213
492
  const file = JSON.stringify(absPath);
493
+ const memo = memoPrefix(exports);
214
494
  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.
495
+ out += `import { __actionWrap as __w } from ${JSON.stringify(SELF_URL)};\n`;
226
496
  out += `export * from ${origSpec};\n`;
227
- for (const n of exports.names) {
497
+ for (const n of exports.fnNames) {
498
+ const k = JSON.stringify(n);
499
+ const v = `${memo}${n}`;
500
+ // `var`, NOT `let`. The exported function is hoisted, which is the whole
501
+ // reason a circular re-export between two `'use server'` modules loads
502
+ // (#1208): the other module in the cycle can call it before this facade's
503
+ // body has run. A `let` memo would be in TDZ at that moment, so the call
504
+ // would throw `Cannot access '...' before initialization`, re-breaking the
505
+ // very cycle the hoisting exists to survive. `var` hoists initialized to
506
+ // undefined, so the first call falls through to the lookup as intended.
507
+ out += `var ${v};\n`;
508
+ out += `export function ${n}(...args) {\n`;
509
+ out += ` const fn = ${v} || (${v} = __w(${file}, ${k}, __orig[${k}]));\n`;
510
+ out += ` return typeof fn === 'function' ? fn.apply(this, args) : fn;\n`;
511
+ out += `}\n`;
512
+ out += `__w(${file}, ${k}, ${n});\n`;
513
+ }
514
+ for (const n of exports.valNames) {
228
515
  const k = JSON.stringify(n);
229
516
  out += `export const ${n} = __w(${file}, ${k}, __orig[${k}]);\n`;
230
517
  }
@@ -265,9 +552,14 @@ export function buildSeedFacade(origSpec, absPath, src) {
265
552
  const head = src.split('\n').slice(0, 5).join('\n');
266
553
  if (!USE_SERVER_RE.test(head)) return null;
267
554
  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;
555
+ // `export *` used to bail out to a passthrough here, so a facade could never
556
+ // drop a re-exported binding it was unable to enumerate. #538 then gave the
557
+ // facade its own `export * from` catch-all, which covers exactly that, and
558
+ // the bail-out was left behind. Keeping it is now actively harmful: identity
559
+ // rides the facade (#1155), so a passthrough means `<form action=${fn}>`
560
+ // throws "is not a server action" at SSR for a function that works perfectly
561
+ // over RPC. A star re-export therefore facades like anything else, its
562
+ // enumerable names wrapped and the rest carried by the catch-all.
271
563
  if (exports.names.length === 0 && !exports.hasDefault) return null;
272
564
  return buildFacade(origSpec, absPath, exports);
273
565
  }
@@ -296,16 +588,22 @@ function seedLoadHook(url, context, nextLoad) {
296
588
  }
297
589
 
298
590
  /**
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.
591
+ * Install the `'use server'` load hook (idempotent). Called once at boot from
592
+ * `dev.js`, BEFORE any action module is imported (a module loaded before the
593
+ * hook would already be cached unwrapped). The install mechanism is chosen by
594
+ * `serverRuntime()` (#529): Node's synchronous `module.registerHooks`, or a
595
+ * `Bun.plugin` `onLoad` on Bun. A no-op on a second call. Async because the Bun
596
+ * path dynamically imports `action-seed-bun.js` (so the `Bun.*` global is never
597
+ * referenced on Node); the Node path resolves synchronously.
598
+ *
599
+ * The hook installs whatever `seed` says, because action IDENTITY (#1155) rides
600
+ * it and is not optional. `seed` gates only whether results are COLLECTED.
601
+ *
602
+ * @param {{ seed?: boolean }} [opts]
306
603
  * @returns {Promise<void>}
307
604
  */
308
- export async function registerSeedHooks() {
605
+ export async function registerActionHooks(opts = {}) {
606
+ _seedEnabled = opts.seed !== false;
309
607
  if (_registered) return;
310
608
  _registered = true;
311
609
 
@@ -313,20 +611,22 @@ export async function registerSeedHooks() {
313
611
  // Bun has no module.registerHooks; install the same facade via Bun.plugin.
314
612
  const { installBunSeedPlugin } = await import('./action-seed-bun.js');
315
613
  installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE });
316
- _enabled = true;
614
+ _hookInstalled = true;
317
615
  return;
318
616
  }
319
617
 
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,
618
+ // A runtime that is neither Node-with-registerHooks nor Bun. Seeding stays
619
+ // OFF (fail-open): `seedingEnabled()` is false, ssr.js emits no seed block,
322
620
  // and the client RPC stub falls back to a normal fetch. Never wrong data.
621
+ // Identity has no such graceful degradation, so the form-action resolver
622
+ // falls back to a module scan instead (see `resolveActionIdentity`).
323
623
  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.');
624
+ 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
625
  return;
326
626
  }
327
627
 
328
628
  nodeModule.registerHooks({ load: seedLoadHook });
329
- _enabled = true;
629
+ _hookInstalled = true;
330
630
  }
331
631
 
332
632
  /**