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