@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 +26 -2
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +364 -64
- package/src/actions.js +14 -4
- package/src/asset-hash.js +66 -4
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/conditional-get.js +32 -9
- package/src/context.js +1 -1
- package/src/csp.js +22 -6
- package/src/csrf.js +33 -12
- package/src/dev-error.js +13 -2
- package/src/dev-overlay.js +218 -6
- package/src/dev.js +111 -29
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +500 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +35 -5
- package/src/rate-limit.js +31 -3
- package/src/route-types.js +1 -1
- package/src/ssr.js +172 -33
- package/src/stream.js +1 -1
- package/src/testing.d.ts +19 -0
- package/src/testing.js +113 -0
- package/src/vendor.js +85 -22
- package/webjs-config.schema.json +19 -3
- package/src/page-action.js +0 -259
|
@@ -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
|
+
}
|