@webjsdev/server 0.8.58 → 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/src/dev.js CHANGED
@@ -15,7 +15,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
15
15
  import { buildRouteTable, matchPage, matchApi } from './router.js';
16
16
  import { generateRouteTypes } from './route-types.js';
17
17
  import { ssrPage, ssrNotFound, setClientRouterEnabled } from './ssr.js';
18
- import { loadPageAction, runPageAction } from './page-action.js';
18
+ import { runFormAction } from './form-dispatch.js';
19
19
  import { handleApi } from './api.js';
20
20
  import {
21
21
  buildActionIndex,
@@ -26,7 +26,7 @@ import {
26
26
  hasUseServerDirective,
27
27
  hashFile,
28
28
  } from './actions.js';
29
- import { registerSeedHooks } from './action-seed.js';
29
+ import { registerActionHooks } from './action-seed.js';
30
30
  import { readRegenerateRules, maybeRegenerate, isRegenerateOutputPath } from './dev-regenerate.js';
31
31
  import { stripTypeScript, ensureStripper } from './ts-strip.js';
32
32
  import { defaultLogger } from './logger.js';
@@ -93,7 +93,8 @@ import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBa
93
93
  import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
94
94
  import { propagateTrustedRemoteIp } from './rate-limit.js';
95
95
  import { readAllowedOrigins } from './csrf.js';
96
- import { setAssetUrlProvider } from '@webjsdev/core';
96
+ import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
97
+ import { resolveActionIdentity } from './form-action-identity.js';
97
98
  import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports, resolveAssetUrl } from './asset-hash.js';
98
99
  import { urlFromRequest } from './forwarded.js';
99
100
  import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
@@ -640,13 +641,17 @@ export async function createRequestHandler(opts) {
640
641
  // never touched the disk.
641
642
  setAssetUrlProvider((p) => resolveAssetUrl(p, basePath()));
642
643
 
643
- // SSR action-result seeding (#472). Install the process-global module load
644
- // hook NOW, at boot, before any `'use server'` action module is imported (ESM
645
- // caches by URL, so a module loaded before the hook would never be faceted).
646
- // Read once (not per-rebuild): the hook is global and cannot be cleanly
647
- // un-installed, so toggling needs a restart. Disabled -> no hook, no ambient
648
- // collector wrap in ssr.js, and module loading stays byte-identical.
649
- if (await readSeedEnabled(appDir)) await registerSeedHooks();
644
+ // The `'use server'` load hook. Install it NOW, at boot, before any action
645
+ // module is imported (ESM caches by URL, so a module loaded before the hook
646
+ // would never be wrapped). Read once (not per-rebuild): the hook is global
647
+ // and cannot be cleanly un-installed, so toggling needs a restart.
648
+ //
649
+ // It installs UNCONDITIONALLY, and only seed collection (#472) follows the
650
+ // `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
651
+ // what a bound `<form action=${action}>` resolves through, so gating the hook
652
+ // on seeding would mean `webjs.seed: false` silently broke every no-JS form.
653
+ await registerActionHooks({ seed: await readSeedEnabled(appDir) });
654
+
650
655
 
651
656
  // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
652
657
  // deterministic vendor map that is cheap to read (one file, no analysis, no
@@ -757,7 +762,7 @@ export async function createRequestHandler(opts) {
757
762
  // package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env
758
763
  // overrides. The secure defaults (1 MiB JSON/RPC, 10 MiB form) apply when
759
764
  // unconfigured. Stamped on every request via `setBodyLimits` so `readBody`
760
- // (used inside route handlers) enforces the same cap the RPC and page-action
765
+ // (used inside route handlers) enforces the same cap the RPC and form-dispatch
761
766
  // paths do.
762
767
  const bodyLimits = await readBodyLimitsFromApp(appDir);
763
768
 
@@ -797,14 +802,27 @@ export async function createRequestHandler(opts) {
797
802
  regenerateRules: dev ? await readRegenerateRules(appDir) : [],
798
803
  };
799
804
 
805
+ // Teach the renderers how to turn a real server-action function into the
806
+ // `<hash>/<fn>` identity a form submits (#1155). The hook path is
807
+ // process-global and needs no index; `state.actionIndex` is consulted only as
808
+ // a hash memo, and by the scan fallback for a runtime with no hook. In a
809
+ // process running several handlers the fallback therefore resolves against
810
+ // the most recently booted one, which is the same scope the asset-url
811
+ // provider already has.
812
+ setFormActionResolver((fn) => (state.actionIndex ? resolveActionIdentity(state.actionIndex, fn) : null));
813
+
800
814
  /**
801
815
  * Report a dev error (#264): build a frame and push it to the open tab via
802
816
  * the SSE overlay channel. DEV-ONLY and best-effort, so it can never affect a
803
817
  * response or crash the server (a frame-build failure is swallowed). No file
804
818
  * path or source is ever built in prod (the early return), so nothing leaks.
805
819
  *
820
+ * `info.url` stamps a `render` frame with the URL that produced it (#1047), so
821
+ * the browser overlay can refuse a frame for a page the tab is not viewing.
822
+ * A `ts-strip` / `rebuild` frame passes none and stays unscoped.
823
+ *
806
824
  * @param {unknown} error
807
- * @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string }} [info]
825
+ * @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string, url?: string }} [info]
808
826
  */
809
827
  function reportDevError(error, info = {}) {
810
828
  if (!dev) return;
@@ -1181,7 +1199,7 @@ export async function createRequestHandler(opts) {
1181
1199
 
1182
1200
  // Make the resolved body-size limits (issue #237) readable from the
1183
1201
  // request scope, so `readBody` inside a route handler enforces the same
1184
- // cap the framework's own RPC / page-action body reads do.
1202
+ // cap the framework's own RPC / form-dispatch body reads do.
1185
1203
  setBodyLimits(state.bodyLimits);
1186
1204
 
1187
1205
  let pathname = '/';
@@ -1349,7 +1367,7 @@ export async function createRequestHandler(opts) {
1349
1367
  // to the inbound `x-webjs-remote-ip` header that the copied
1350
1368
  // `req.headers` still carries, which a client can spoof. So strip
1351
1369
  // that header on the rebuild and propagate the framework-trusted IP
1352
- // across the new object, the same pattern page-action.js uses.
1370
+ // across the new object, the same pattern form-dispatch.js uses.
1353
1371
  const headers = new Headers(req.headers);
1354
1372
  headers.delete('x-webjs-remote-ip');
1355
1373
  const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
@@ -1839,7 +1857,7 @@ function startNodeListener(ctx) {
1839
1857
  // Catch-all process handlers: log, but don't tear the process down on a
1840
1858
  // single mishandled promise. Uncaught exceptions are different: state may be
1841
1859
  // corrupted, so log + start an orderly shutdown rather than continuing.
1842
- installProcessHandlers(logger, () => shutdown('uncaughtException'));
1860
+ installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
1843
1861
 
1844
1862
  return {
1845
1863
  server,
@@ -2179,14 +2197,30 @@ async function handleCore(req, ctx) {
2179
2197
  }
2180
2198
 
2181
2199
  // Page route. GET/HEAD render the page. A NON-GET/HEAD method (POST/PUT/…)
2182
- // is only routed here when the page module exports an `action` (#244); the
2183
- // action runs inside the page's segment middleware, then either PRG-redirects
2184
- // (303) on success, re-renders the same page (422) with field errors on
2185
- // failure, or honors a thrown redirect()/notFound(). Without an `action`
2186
- // export, a non-GET/HEAD request falls through to the 404 below, unchanged.
2200
+ // is a form submission (#1155): the `__webjs_action` hidden field names the
2201
+ // server action to run, it runs inside the page's segment middleware, and the
2202
+ // result either PRG-redirects (303), re-renders the same page (422) with
2203
+ // field errors, or honors a thrown redirect()/notFound(). A non-GET carrying
2204
+ // no action identity is a 405 on a path that exists but only renders.
2187
2205
  {
2188
2206
  const page = matchPage(state.routeTable, path);
2189
2207
  if (page) {
2208
+ // The URL this render is FOR, in the form the browser sees it (#1047), so
2209
+ // the overlay's scope gate can compare it against `location.pathname +
2210
+ // location.search`. Two details are load-bearing: the RAW `url.pathname`
2211
+ // (not the decoded `path`) is used, because `location.pathname` is
2212
+ // percent-encoded too; and the base path is put back, because the ingress
2213
+ // strip already removed it from `url` while the browser's location still
2214
+ // carries it, so without this the gate would suppress every overlay on a
2215
+ // sub-path deploy.
2216
+ const devErrorUrl = withBasePath(url.pathname, basePath()) + url.search;
2217
+ // A speculative link prefetch must never raise an overlay (#1047): the
2218
+ // user is only hovering a link to a page that throws, and the page they
2219
+ // are actually looking at is fine. Dropping the hook also keeps the throw
2220
+ // out of `state.lastDevError`, so the SSE replay cannot hand it to a
2221
+ // freshly-connected tab either. The APM `onError` sink below still fires,
2222
+ // because the render really did throw.
2223
+ const isPrefetch = req.headers.get('x-webjs-prefetch') === '1';
2190
2224
  const ssrOpts = {
2191
2225
  dev, appDir, moduleGraph: state.moduleGraph,
2192
2226
  serverFiles: state.actionIndex.fileToHash,
@@ -2213,17 +2247,43 @@ async function handleCore(req, ctx) {
2213
2247
  // Dev error overlay (#264): a render crash pushes a frame to the open
2214
2248
  // tab. Dev-only (reportDevError early-returns in prod), so no source
2215
2249
  // leaks. Distinct from onError (the APM sink), which always fires.
2216
- onDevError: dev ? (e) => reportDevError(e, { kind: 'render' }) : undefined,
2250
+ onDevError: dev && !isPrefetch
2251
+ ? (e) => reportDevError(e, { kind: 'render', url: devErrorUrl })
2252
+ : undefined,
2217
2253
  };
2218
2254
  if (method === 'GET' || method === 'HEAD') {
2219
- const handler = () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
2220
- return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
2221
- }
2222
- const loaded = await loadPageAction(page.route.file, dev);
2223
- if (loaded) {
2224
- const handler = () => runPageAction(page.route, page.params, url, loaded, req, ssrOpts);
2255
+ // A successful render of URL U supersedes a RETAINED render error for
2256
+ // that same URL (#1047), so a reconnecting tab is not handed a frame the
2257
+ // page has since recovered from. Keyed on BOTH frame identity and url:
2258
+ // identity so a frame this very render just reported is never wiped, url
2259
+ // so a good render of an unrelated page cannot erase an error that is
2260
+ // still current for the page the user is looking at (the #893 gap the
2261
+ // retention exists to close). Dev-only, and skipped for a prefetch,
2262
+ // which is not the user's view of anything.
2263
+ const handler = dev && !isPrefetch
2264
+ ? async () => {
2265
+ const before = state.lastDevError;
2266
+ const resp = await ssrPage(page.route, page.params, url, { ...ssrOpts, req });
2267
+ if (
2268
+ before && state.lastDevError === before
2269
+ && before.kind === 'render' && before.url === devErrorUrl
2270
+ ) state.lastDevError = null;
2271
+ return resp;
2272
+ }
2273
+ : () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
2225
2274
  return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
2226
2275
  }
2276
+ // Every non-GET/HEAD to a page runs the page's segment middleware, and
2277
+ // the dispatcher always answers with a Response (a 405 when there is no
2278
+ // action to run), so a middleware that post-processes `await next()`
2279
+ // never has to handle an absent one.
2280
+ const deps = {
2281
+ actionIndex: state.actionIndex,
2282
+ allowedOrigins,
2283
+ onError: reportError ? (e) => reportError(e, req, 'action') : undefined,
2284
+ };
2285
+ const handler = () => runFormAction(page.route, page.params, url, req, ssrOpts, deps);
2286
+ return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
2227
2287
  }
2228
2288
  }
2229
2289
 
@@ -2845,6 +2905,11 @@ function __webjsApplyError(data) {
2845
2905
  let f; try { f = JSON.parse(data); } catch (_) { return; }
2846
2906
  renderDevOverlay(f);
2847
2907
  }
2908
+ // Keep the overlay tracking the page actually on screen (#1047): a render frame
2909
+ // is scoped to the URL that produced it, so navigating away takes it down, and a
2910
+ // frame that arrived before the URL advanced goes up once it does. The gate
2911
+ // itself lives inside renderDevOverlay, so __webjsApplyError needs no change.
2912
+ installDevOverlayNavSync();
2848
2913
  // Never reload INTO a server that is still restarting (Node's node --watch
2849
2914
  // briefly kills the process on an edit), which would paint a style-less,
2850
2915
  // half-rendered page (#893). Probe the lightweight /__webjs/version endpoint
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Resolving a bound `<form action=${action}>` back to `<hash>/<fn>` (#1155).
3
+ *
4
+ * The renderer holds a function and needs the identity the browser will submit.
5
+ * On the client that is trivial (the RPC stub stamps its own identity on
6
+ * itself), but at SSR the value is the REAL server function, so something has
7
+ * to have watched it load. The `'use server'` load hook did, which is why it
8
+ * installs unconditionally rather than only when seeding is on.
9
+ *
10
+ * The naming is the RPC endpoint's, deliberately: the same `hashFile(file)`
11
+ * over the absolute path, the same `<hash>/<fnName>` shape. One identity
12
+ * scheme means the form dispatcher and the RPC endpoint resolve the same
13
+ * string to the same function, so the two transports can never disagree about
14
+ * what an action IS.
15
+ */
16
+
17
+ import { actionIdentitiesOf, identityHookInstalled } from './action-seed.js';
18
+ import { pathToFileURL } from 'node:url';
19
+
20
+ /**
21
+ * Resolved identities for functions the hook never registered. Only ever
22
+ * populated by the scan fallback below, so on a supported runtime it stays
23
+ * empty.
24
+ * @type {WeakMap<Function, string>}
25
+ */
26
+ const _scanned = new WeakMap();
27
+
28
+ /** Emit the scan-fallback warning once per process rather than per render. */
29
+ let _warnedScan = false;
30
+
31
+ /**
32
+ * The `<hash>/<fn>` identity of an action function, or null.
33
+ *
34
+ * @param {import('./actions.js').ActionIndex} idx
35
+ * @param {Function} fn
36
+ * @returns {Promise<string | null>}
37
+ */
38
+ export async function resolveActionIdentity(idx, fn) {
39
+ const known = actionIdentitiesOf(fn);
40
+ if (known.length) {
41
+ // The FIRST registration the index knows. Order is defining-module-first,
42
+ // so a function re-exported through a barrel resolves to the module that
43
+ // also carries its `validate` / `middleware` / `method` / `invalidates`
44
+ // config exports; the barrel is used only when the defining module is not
45
+ // in the index at all (an action from a linked workspace package, or one
46
+ // reached under a path the app-tree walk never produced, such as through a
47
+ // symlink).
48
+ for (const entry of known) {
49
+ const hash = idx.fileToHash.get(entry.file);
50
+ if (!hash) continue;
51
+ // Falling back past the defining module can COST something, so say so.
52
+ // The dispatcher loads whatever module the identity names and reads
53
+ // `validate` / `middleware` / `method` / `invalidates` off THAT namespace
54
+ // BY NAME, which cuts both ways: config declared beside the action does
55
+ // not travel with a `export { fn } from '...'` re-export, and config the
56
+ // re-exporting module declares for its own exports applies to this action
57
+ // instead. Neither direction is knowable without loading the module,
58
+ // which is why the warning states the condition rather than the
59
+ // consequence (see `warnConfigExportsBypassed`). It warns
60
+ // rather than refuses because the RPC endpoint resolves the same barrel
61
+ // hash the same way: both transports agree, and refusing would break a
62
+ // page that works while leaving the RPC path open anyway.
63
+ if (entry !== known[0]) warnConfigExportsBypassed(known[0], entry);
64
+ return `${hash}/${entry.fnName}`;
65
+ }
66
+ // Registered, but under no name the index knows. Minting a hash from the
67
+ // path (which `actionFileHash` will happily do for any string) would
68
+ // produce an identity the dispatcher cannot resolve, and an unresolvable
69
+ // hash reads as a deploy skew: every submission would answer 422 "please
70
+ // submit again", forever, with nothing logged. Refusing at render is the
71
+ // better failure, but the renderer's message can only say "not a server
72
+ // action", which is wrong here and would send the author looking in the
73
+ // wrong place. So name the real cause once, on the server.
74
+ warnUnindexedAction(known[0]);
75
+ return null;
76
+ }
77
+ return await scanForIdentity(idx, fn);
78
+ }
79
+
80
+ /** Files already warned about, so a re-render does not repeat the line. */
81
+ const _warnedUnindexed = new Set();
82
+
83
+ /**
84
+ * No name the action index knows.
85
+ *
86
+ * The message names the SYMPTOM and lists the causes rather than asserting one.
87
+ * Two produce this, and they need opposite fixes: the module genuinely lives
88
+ * outside the app tree, or it lives inside but under a different path than the
89
+ * one that was walked (an `appDir` reached through a symlink, since the index
90
+ * stores the walked path while the ESM loader realpaths the module). Asserting
91
+ * the first would confidently misdiagnose the second, whose author would then
92
+ * go looking for a package that does not exist.
93
+ *
94
+ * The remedy has to say NAMED, too: `export * from` cannot be enumerated, so
95
+ * the facade never wraps it and nothing registers, which leaves the page
96
+ * failing in exactly the same way it already was.
97
+ *
98
+ * @param {{ file: string, fnName: string }} entry
99
+ */
100
+ function warnUnindexedAction(entry) {
101
+ if (_warnedUnindexed.has(entry.file)) return;
102
+ _warnedUnindexed.add(entry.file);
103
+ console.warn(
104
+ `[webjs] cannot bind ${entry.fnName} to a <form>: it is a 'use server' export, but the `
105
+ + `action index has no entry for ${entry.file}, so the identity the browser submits `
106
+ + 'could never be resolved back. Common causes: the module is outside the app '
107
+ + 'directory, or the app directory was reached through a symlink so the walked path '
108
+ + `and the loaded path differ. Fix the path, or add a NAMED re-export ("export { ${entry.fnName} } `
109
+ + `from ...") in a 'use server' module inside the app; a star re-export cannot be `
110
+ + 'enumerated and will not register it.',
111
+ );
112
+ }
113
+
114
+ /** Defining modules already warned about for a bypassed-config fallback. */
115
+ const _warnedBypass = new Set();
116
+
117
+ /**
118
+ * Warn that the dispatched module is not the defining one.
119
+ *
120
+ * Deliberately CONDITIONAL about the consequence. What is known here is cheap
121
+ * and certain: the identity names a re-exporting module, so the dispatcher will
122
+ * read config off that namespace. What that costs is not knowable without
123
+ * loading the module, which this path refuses to do (it is a render-time
124
+ * resolve, and eager-loading every action module is exactly the cost
125
+ * `scanForIdentity` exists to avoid).
126
+ *
127
+ * Config is matched BY NAME off the dispatched namespace
128
+ * (`actionConfigFn(mod, 'validate')`), with no association back to a particular
129
+ * function, and that cuts BOTH ways. Three wordings have now been wrong by
130
+ * describing only one direction:
131
+ *
132
+ * - config declared beside the action does NOT travel with a plain
133
+ * `export { fn } from` re-export, so it stops applying
134
+ * - config the DISPATCHING module declares DOES apply to this action, even
135
+ * though it was written for one of that module's own exports
136
+ *
137
+ * The second is why "an action that declares no config loses nothing" was
138
+ * false: a config-less action re-exported through a module that has a
139
+ * `validate` for its own sibling inherits that validator, and its submissions
140
+ * start failing with a message about a different action.
141
+ *
142
+ * It follows that re-exporting the config into a SHARED barrel is bad advice
143
+ * (it attaches one action's validator to every sibling), while re-exporting it
144
+ * into a module holding only this action is safe and is the only option when
145
+ * the action lives in a workspace package several apps share, which is exactly
146
+ * the case that produces this warning. The remedy has to offer both, and the
147
+ * second needs its CONDITION stated: among indexed re-exporters the identity
148
+ * goes to the first one LOADED (registration order, and module load is lazy per
149
+ * route), so a stray second re-exporter can silently win over the remedy module
150
+ * depending on which page a visitor hits first.
151
+ *
152
+ * The second remedy also cannot silence this line. The defining module stays
153
+ * outside the index, so the fallback still runs and this check cannot see
154
+ * inside the dispatched module to know the config now travels with it. Round 4
155
+ * of this PR's review found a diagnostic that persisted in the state its own
156
+ * remedy produced while still describing it as broken; the honest fix is the
157
+ * message saying so itself.
158
+ *
159
+ * `webjs check` catches none of it: `one-action-per-configured-file` counts
160
+ * callables from `export function` / `export const … =>`, so a re-exported
161
+ * action is invisible to it (a pure barrel reads as zero callables).
162
+ *
163
+ * @param {{ file: string, fnName: string }} defining
164
+ * @param {{ file: string, fnName: string }} used
165
+ */
166
+ function warnConfigExportsBypassed(defining, used) {
167
+ if (_warnedBypass.has(defining.file)) return;
168
+ _warnedBypass.add(defining.file);
169
+ console.warn(
170
+ `[webjs] ${defining.fnName} is bound to a <form>, but its module ${defining.file} is `
171
+ + `not in the action index, so the submission is dispatched through ${used.file} `
172
+ + 'instead. Config is matched by NAME off that module, so it flows both ways: a '
173
+ + '`validate` / `middleware` / `method` / `invalidates` declared beside the action does '
174
+ + 'not travel with a plain `export { fn } from` re-export, and any that module declares '
175
+ + 'of its own applies to this action. Move the action inside the app directory. Or '
176
+ + 're-export it from a module holding only this one action, put its config there, and '
177
+ + 'make sure NO other in-app module re-exports it: among re-exporters the first one '
178
+ + 'loaded wins the identity, so a shared barrel can silently override the dedicated '
179
+ + 'module. Adding config to a re-export module that carries several actions applies it '
180
+ + 'to all of them, and `webjs check` cannot see that. This notice keeps printing after '
181
+ + 'the dedicated-module fix, because the check cannot see inside the dispatched module; '
182
+ + 'if that module carries the config, it is working as intended. (The RPC endpoint '
183
+ + 'resolves the same way, so this is not specific to forms.)',
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Cold-path fallback: find the function by loading the indexed action modules
189
+ * and comparing exports.
190
+ *
191
+ * Reached in exactly one situation: a runtime with neither
192
+ * `module.registerHooks` nor `Bun.plugin`, which never installs the hook at all.
193
+ * A module imported BEFORE the hook installed is also unwrapped, but the gate
194
+ * below rules it out, and the server installs the hook at boot ahead of any app
195
+ * module, so that shape only exists in a unit test that imports one by hand.
196
+ *
197
+ * It is gated on the hook being ABSENT rather than merely on a registry miss.
198
+ * The scan imports every `'use server'` module in the app, which is exactly the
199
+ * eager loading (DB driver init, connection pools) that `buildActionIndex`
200
+ * avoids by only hashing paths. Paying that during a render because one lookup
201
+ * missed would turn a fast miss into a slow one on every page; with the hook
202
+ * installed, a miss means the function genuinely is not an action, and the
203
+ * renderer should say so immediately.
204
+ *
205
+ * @param {import('./actions.js').ActionIndex} idx
206
+ * @param {Function} fn
207
+ * @returns {Promise<string | null>}
208
+ */
209
+ export async function scanForIdentity(idx, fn) {
210
+ if (identityHookInstalled()) return null;
211
+ const cached = _scanned.get(fn);
212
+ if (cached) return cached;
213
+ if (!_warnedScan) {
214
+ _warnedScan = true;
215
+ console.warn(
216
+ '[webjs] resolving a bound form action by scanning the action index, because '
217
+ + "the 'use server' load hook is not installed on this runtime. Every action "
218
+ + 'module is imported to do it. Node 24+ and Bun both install the hook.',
219
+ );
220
+ }
221
+ for (const [hash, file] of idx.hashToFile) {
222
+ let mod;
223
+ try {
224
+ // Deliberately NOT cache-busted, unlike every other dev-time import here.
225
+ // The match is function-object identity against the instance the page
226
+ // already holds, and a busted specifier is a fresh module whose exports
227
+ // are new objects, so `value === fn` could never be true. In dev that
228
+ // made this fallback import every action module and always return null.
229
+ mod = await import(pathToFileURL(file).toString());
230
+ } catch {
231
+ continue;
232
+ }
233
+ for (const [name, value] of Object.entries(mod)) {
234
+ if (value !== fn) continue;
235
+ const id = `${hash}/${name}`;
236
+ _scanned.set(fn, id);
237
+ return id;
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * Resolve an identity string back to the function it names, for the form
245
+ * dispatcher.
246
+ *
247
+ * Returns a `reason` rather than a bare null on failure, because the failures
248
+ * need different responses. An unknown HASH is a deploy skew (a form rendered
249
+ * by an older build, submitted against a newer one), which re-renders the page
250
+ * with a resubmit message.
251
+ *
252
+ * An unknown FUNCTION inside a known file is NOT decided here: this resolves
253
+ * the file and hands back its module namespace, and the caller decides whether
254
+ * the named export is a callable action (it also has to reject a reserved
255
+ * config export, which only it knows about). That is why there is no
256
+ * `unknown-fn` reason.
257
+ *
258
+ * A module that THROWS at import is a third case, and folding it into skew was
259
+ * a real defect. The hash resolves (the index only hashes paths, it never
260
+ * imports), so a `.server.ts` whose module scope throws (a DB connection built
261
+ * at load with a missing env var) would answer every submission with "please
262
+ * submit again" forever, with the actual error discarded: no log line, no
263
+ * `onError`, nothing. The same broken module surfaces a logged 500 through a
264
+ * page render and through the RPC endpoint, which lets `loadModule` throw on
265
+ * purpose, so the form path was the one place it went silent. The error is
266
+ * carried out instead and the caller turns it into a sanitized, digest-logged
267
+ * 500 like any other action throw.
268
+ *
269
+ * @param {import('./actions.js').ActionIndex} idx
270
+ * @param {string} id the submitted `<hash>/<fn>` value
271
+ * @returns {Promise<{ ok: true, file: string, fnName: string, module: Record<string, unknown> }
272
+ * | { ok: false, reason: 'malformed' | 'skew' }
273
+ * | { ok: false, reason: 'load-failed', error: unknown }>}
274
+ */
275
+ export async function lookupActionIdentity(idx, id) {
276
+ const raw = typeof id === 'string' ? id : '';
277
+ const slash = raw.indexOf('/');
278
+ if (slash <= 0 || slash === raw.length - 1) return { ok: false, reason: 'malformed' };
279
+ const hash = raw.slice(0, slash);
280
+ const fnName = raw.slice(slash + 1);
281
+ const file = idx.hashToFile.get(hash);
282
+ if (!file) return { ok: false, reason: 'skew' };
283
+ let module;
284
+ try {
285
+ module = await import(pathToFileURL(file).toString() + (idx.dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : ''));
286
+ } catch (error) {
287
+ return { ok: false, reason: 'load-failed', error };
288
+ }
289
+ return { ok: true, file, fnName, module };
290
+ }