@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.
@@ -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
+ }