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