@webjsdev/server 0.8.23 → 0.8.24
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/package.json +1 -1
- package/src/action-seed.js +308 -0
- package/src/actions.js +16 -4
- package/src/dev.js +51 -0
- package/src/ssr.js +28 -2
- package/webjs-config.schema.json +6 -1
package/package.json
CHANGED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR action-result seeding (#472), follow-up to async render (#469).
|
|
3
|
+
*
|
|
4
|
+
* When a component's `async render()` does a bare `const u = await getUser(id)`
|
|
5
|
+
* during SSR, the action runs server-side and its result is baked into the
|
|
6
|
+
* first paint. On HYDRATION the client re-runs `async render()`, which re-calls
|
|
7
|
+
* the action over RPC. Stale-while-revalidate (#470) hides the flicker, but the
|
|
8
|
+
* redundant round-trip still happens once per async component on first load.
|
|
9
|
+
*
|
|
10
|
+
* This module captures each `'use server'` action result invoked DURING SSR and
|
|
11
|
+
* serializes it into the page (one `<script type="application/json">` block).
|
|
12
|
+
* The generated client RPC stub (`actions.js`) reads that seed on its FIRST call
|
|
13
|
+
* with matching args and resolves without the RPC; a later refetch / arg-change
|
|
14
|
+
* goes to the network as normal.
|
|
15
|
+
*
|
|
16
|
+
* ## How the capture works (no source transform, no build step)
|
|
17
|
+
*
|
|
18
|
+
* The framework promise is "what you write is what you see in the browser source
|
|
19
|
+
* tab", not "what you write is the exact function object that runs server-side"
|
|
20
|
+
* (the RPC stub already replaces the action on the client). So we install a
|
|
21
|
+
* SERVER-SIDE transparent facade via Node's synchronous `module.registerHooks`
|
|
22
|
+
* (Node 24+, main-thread): for a `'use server'` `*.server.*` module, the load
|
|
23
|
+
* hook returns a facade that re-exports each function wrapped in a `Proxy`. The
|
|
24
|
+
* Proxy records `(file, fn, args) -> result` into an ambient `AsyncLocalStorage`
|
|
25
|
+
* collector WHENEVER a collector is active, and is a pure passthrough otherwise.
|
|
26
|
+
*
|
|
27
|
+
* Recording is gated entirely by the ALS collector, which is established ONLY
|
|
28
|
+
* around the SSR page render (`collectSeeds`). The RPC endpoint path runs with
|
|
29
|
+
* NO collector, so the Proxy is a transparent passthrough there. The browser
|
|
30
|
+
* NEVER sees this module (it sees the RPC stub), and the on-disk source is
|
|
31
|
+
* unchanged, so the source-fidelity promise holds.
|
|
32
|
+
*
|
|
33
|
+
* ## Safety
|
|
34
|
+
*
|
|
35
|
+
* A key HIT returns the exact SSR value (correct by construction); a key MISS
|
|
36
|
+
* degrades to a normal RPC (never wrong data, only a missed optimization). The
|
|
37
|
+
* whole feature is therefore fail-open: any failure in the hook, the facade, or
|
|
38
|
+
* the serializer simply skips seeding and the client re-fetches as before.
|
|
39
|
+
*
|
|
40
|
+
* Disabled by default-off of the flag removes the hook entirely, so module
|
|
41
|
+
* loading is byte-identical to before the feature.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import * as nodeModule from 'node:module';
|
|
45
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
46
|
+
import { readFileSync } from 'node:fs';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
import { stringify } from '@webjsdev/core';
|
|
49
|
+
import { hashFile } from './actions.js';
|
|
50
|
+
|
|
51
|
+
/** Ambient per-render seed collector. `Map<key, value>` or undefined. */
|
|
52
|
+
const als = new AsyncLocalStorage();
|
|
53
|
+
|
|
54
|
+
/** Set once the load hook is installed (the flag is on at boot). */
|
|
55
|
+
let _enabled = false;
|
|
56
|
+
/** Idempotency guard: `module.registerHooks` must run at most once. */
|
|
57
|
+
let _registered = false;
|
|
58
|
+
|
|
59
|
+
/** Memoized `absPath -> hash` so a hot action call does not re-hash per call. */
|
|
60
|
+
const _hashCache = new Map();
|
|
61
|
+
|
|
62
|
+
/** This module's own URL, embedded into the generated facade's import. */
|
|
63
|
+
const SELF_URL = import.meta.url;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether seeding is active (the load hook is installed). The SSR emitter
|
|
67
|
+
* (`ssr.js`) checks this so a disabled app does ZERO extra work and produces
|
|
68
|
+
* byte-identical output.
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
export function seedingEnabled() {
|
|
72
|
+
return _enabled;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compute (and memoize) the action file's hash the SAME way the RPC stub /
|
|
77
|
+
* action index do (`hashFile` over the absolute path string), so the seed key
|
|
78
|
+
* the server emits matches the key the client stub looks up. A path mismatch
|
|
79
|
+
* (e.g. a symlinked appDir whose realpath differs) only yields a key MISS,
|
|
80
|
+
* which safely degrades to a normal RPC.
|
|
81
|
+
* @param {string} absPath
|
|
82
|
+
* @returns {Promise<string>}
|
|
83
|
+
*/
|
|
84
|
+
async function hashFor(absPath) {
|
|
85
|
+
let h = _hashCache.get(absPath);
|
|
86
|
+
if (h === undefined) {
|
|
87
|
+
h = await hashFile(absPath);
|
|
88
|
+
_hashCache.set(absPath, h);
|
|
89
|
+
}
|
|
90
|
+
return h;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Record one action call's resolved result into the active collector, keyed
|
|
95
|
+
* `hash/fn/stringify(args)`. The args key uses the SAME serializer the client
|
|
96
|
+
* stub uses to form its lookup key, so they match for identical args. Never
|
|
97
|
+
* throws into the caller's render: a serialization failure just drops the seed
|
|
98
|
+
* (the client re-fetches).
|
|
99
|
+
* @param {Map<string, unknown>} collector
|
|
100
|
+
* @param {string} file absolute action file path
|
|
101
|
+
* @param {string} fnName
|
|
102
|
+
* @param {unknown[]} args
|
|
103
|
+
* @param {unknown} value resolved action result
|
|
104
|
+
*/
|
|
105
|
+
async function recordSeed(collector, file, fnName, args, value) {
|
|
106
|
+
try {
|
|
107
|
+
const hash = await hashFor(file);
|
|
108
|
+
const argsKey = await stringify(args);
|
|
109
|
+
collector.set(`${hash}/${fnName}/${argsKey}`, value);
|
|
110
|
+
} catch {
|
|
111
|
+
// Drop the seed; the client stub falls back to a normal RPC.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Wrap one exported action function so that, when a collector is active, its
|
|
117
|
+
* resolved result is recorded. Outside a collector (the RPC endpoint path) it is
|
|
118
|
+
* a transparent passthrough. Non-functions (a `const VERSION = '1.0'` export)
|
|
119
|
+
* pass through untouched, and the Proxy forwards property reads, so `expose()` /
|
|
120
|
+
* `validateInput()` metadata stored on the function (`__webjsHttp`) still
|
|
121
|
+
* resolves through `getExposed()`.
|
|
122
|
+
* @param {string} file absolute action file path
|
|
123
|
+
* @param {string} fnName
|
|
124
|
+
* @param {unknown} orig
|
|
125
|
+
* @returns {unknown}
|
|
126
|
+
*/
|
|
127
|
+
export function __seedWrap(file, fnName, orig) {
|
|
128
|
+
if (typeof orig !== 'function') return orig;
|
|
129
|
+
return new Proxy(orig, {
|
|
130
|
+
apply(target, thisArg, args) {
|
|
131
|
+
const collector = als.getStore();
|
|
132
|
+
const result = Reflect.apply(target, thisArg, args);
|
|
133
|
+
if (!collector) return result;
|
|
134
|
+
if (result && typeof result.then === 'function') {
|
|
135
|
+
// Record the RESOLVED value, and return the same value to the caller so
|
|
136
|
+
// the awaiting `async render()` gets its data unchanged.
|
|
137
|
+
return result.then(async (value) => {
|
|
138
|
+
await recordSeed(collector, file, fnName, args, value);
|
|
139
|
+
return value;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// A synchronous return (rare for an action): record best-effort. The
|
|
143
|
+
// record is async but fire-and-forget here; collectSeeds awaits the
|
|
144
|
+
// render's own microtasks, and a sync action that the render awaits will
|
|
145
|
+
// have settled the record before the render resolves.
|
|
146
|
+
recordSeed(collector, file, fnName, args, result);
|
|
147
|
+
return result;
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Extract the names of every named export from an action module's source, used
|
|
154
|
+
* to generate the facade's `export const NAME = wrap(...)` lines. Conservative:
|
|
155
|
+
* a name it misses simply is not wrapped (no seed for it, RPC fallback). A
|
|
156
|
+
* `export *` re-export cannot be enumerated statically, so its presence makes
|
|
157
|
+
* the caller skip faceting that module entirely (passthrough = no seeding for
|
|
158
|
+
* it), never producing a broken facade.
|
|
159
|
+
* @param {string} src
|
|
160
|
+
* @returns {{ names: string[], hasDefault: boolean, hasStar: boolean } }
|
|
161
|
+
*/
|
|
162
|
+
export function extractExportNames(src) {
|
|
163
|
+
const names = new Set();
|
|
164
|
+
let m;
|
|
165
|
+
const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
|
|
166
|
+
while ((m = reFn.exec(src))) names.add(m[1]);
|
|
167
|
+
const reVar = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g;
|
|
168
|
+
while ((m = reVar.exec(src))) names.add(m[1]);
|
|
169
|
+
const reClass = /\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g;
|
|
170
|
+
while ((m = reClass.exec(src))) names.add(m[1]);
|
|
171
|
+
const reList = /\bexport\s*\{([^}]*)\}/g;
|
|
172
|
+
while ((m = reList.exec(src))) {
|
|
173
|
+
for (const part of m[1].split(',')) {
|
|
174
|
+
const seg = part.trim();
|
|
175
|
+
if (!seg) continue;
|
|
176
|
+
// `a` or `a as b` (the EXPORTED name is what an importer binds).
|
|
177
|
+
const as = seg.split(/\s+as\s+/);
|
|
178
|
+
const exported = (as[1] || as[0]).trim();
|
|
179
|
+
if (/^[A-Za-z_$][\w$]*$/.test(exported) && exported !== 'default') names.add(exported);
|
|
180
|
+
else if (exported === 'default') names.add('__default__');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const hasDefault = /\bexport\s+default\b/.test(src) || names.delete('__default__');
|
|
184
|
+
const hasStar = /\bexport\s*\*/.test(src);
|
|
185
|
+
return { names: [...names], hasDefault, hasStar };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Build the facade module source for a `'use server'` action module: it imports
|
|
190
|
+
* the REAL module via a `?webjs-seed-orig` query (which the hook passes through
|
|
191
|
+
* unwrapped) and re-exports each function wrapped through `__seedWrap`.
|
|
192
|
+
* @param {string} origUrl the real module URL, WITHOUT the seed query
|
|
193
|
+
* @param {string} absPath the real module's absolute file path (the hash basis)
|
|
194
|
+
* @param {{ names: string[], hasDefault: boolean }} exports
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
function buildFacade(origUrl, absPath, exports) {
|
|
198
|
+
const sep = origUrl.includes('?') ? '&' : '?';
|
|
199
|
+
const origSpec = JSON.stringify(origUrl + sep + 'webjs-seed-orig');
|
|
200
|
+
const file = JSON.stringify(absPath);
|
|
201
|
+
let out = `import * as __orig from ${origSpec};\n`;
|
|
202
|
+
out += `import { __seedWrap as __w } from ${JSON.stringify(SELF_URL)};\n`;
|
|
203
|
+
for (const n of exports.names) {
|
|
204
|
+
const k = JSON.stringify(n);
|
|
205
|
+
out += `export const ${n} = __w(${file}, ${k}, __orig[${k}]);\n`;
|
|
206
|
+
}
|
|
207
|
+
if (exports.hasDefault) {
|
|
208
|
+
out += `export default __w(${file}, 'default', __orig.default);\n`;
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Match `*.server.{js,ts,mjs,mts}` (optionally with a query). */
|
|
214
|
+
const SERVER_FILE_RE = /\.server\.m?[jt]s(\?|$)/;
|
|
215
|
+
/** The `'use server'` directive in the file head. */
|
|
216
|
+
const USE_SERVER_RE = /^\s*(['"])use server\1\s*;?\s*$/m;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The synchronous `module.registerHooks` load hook. For a `'use server'`
|
|
220
|
+
* `*.server.*` module it returns a wrapping facade; for everything else
|
|
221
|
+
* (including the `?webjs-seed-orig` passthrough of the real module) it defers to
|
|
222
|
+
* `nextLoad`. Fail-open: any error defers to `nextLoad`, so a load that the hook
|
|
223
|
+
* cannot facade simply runs unwrapped (no seeding for it).
|
|
224
|
+
* @param {string} url
|
|
225
|
+
* @param {object} context
|
|
226
|
+
* @param {(u: string, c: object) => any} nextLoad
|
|
227
|
+
*/
|
|
228
|
+
function seedLoadHook(url, context, nextLoad) {
|
|
229
|
+
try {
|
|
230
|
+
if (!SERVER_FILE_RE.test(url)) return nextLoad(url, context);
|
|
231
|
+
// The facade's own `?webjs-seed-orig` import must load the REAL module.
|
|
232
|
+
if (url.includes('webjs-seed-orig')) return nextLoad(url, context);
|
|
233
|
+
const absPath = fileURLToPath(url.split('?')[0]);
|
|
234
|
+
const src = readFileSync(absPath, 'utf8');
|
|
235
|
+
const head = src.split('\n').slice(0, 5).join('\n');
|
|
236
|
+
if (!USE_SERVER_RE.test(head)) return nextLoad(url, context);
|
|
237
|
+
const exports = extractExportNames(src);
|
|
238
|
+
// A `export *` re-export cannot be enumerated; skip faceting (passthrough)
|
|
239
|
+
// so we never emit a facade that silently drops re-exported bindings.
|
|
240
|
+
if (exports.hasStar) return nextLoad(url, context);
|
|
241
|
+
if (exports.names.length === 0 && !exports.hasDefault) return nextLoad(url, context);
|
|
242
|
+
const source = buildFacade(url, absPath, exports);
|
|
243
|
+
return { source, format: 'module', shortCircuit: true };
|
|
244
|
+
} catch {
|
|
245
|
+
return nextLoad(url, context);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Install the seed load hook (idempotent). Called once at boot from `dev.js`
|
|
251
|
+
* when seeding is enabled, BEFORE any action module is imported (ESM caches by
|
|
252
|
+
* URL, so a module loaded before the hook would never be faceted). A no-op on a
|
|
253
|
+
* second call.
|
|
254
|
+
*/
|
|
255
|
+
export function registerSeedHooks() {
|
|
256
|
+
_enabled = true;
|
|
257
|
+
if (_registered) return;
|
|
258
|
+
_registered = true;
|
|
259
|
+
nodeModule.registerHooks({ load: seedLoadHook });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Run `fn` (the page render) inside a fresh ambient seed collector and return
|
|
264
|
+
* both its value and the collected seeds. Every action call made during the
|
|
265
|
+
* render (however deeply nested in the SSR walker / async render chain) records
|
|
266
|
+
* into this collector via the ambient ALS.
|
|
267
|
+
* @template T
|
|
268
|
+
* @param {() => Promise<T>} fn
|
|
269
|
+
* @returns {Promise<{ value: T, collector: Map<string, unknown> }>}
|
|
270
|
+
*/
|
|
271
|
+
export async function collectSeeds(fn) {
|
|
272
|
+
const collector = new Map();
|
|
273
|
+
const value = await als.run(collector, fn);
|
|
274
|
+
return { value, collector };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Serialize a collector into a `<script type="application/json">` block to embed
|
|
279
|
+
* in the page. Returns '' for an empty collector (so a render with no seeded
|
|
280
|
+
* action is byte-identical to before). The payload is rich-serialized (same wire
|
|
281
|
+
* format the RPC stub's `parse` reads) and HTML-escaped so it can never break out
|
|
282
|
+
* of the script element. A `type="application/json"` script is DATA, not
|
|
283
|
+
* executable JS, so it needs no CSP nonce.
|
|
284
|
+
* @param {Map<string, unknown>} collector
|
|
285
|
+
* @returns {Promise<string>}
|
|
286
|
+
*/
|
|
287
|
+
export async function buildSeedScript(collector) {
|
|
288
|
+
if (!collector || collector.size === 0) return '';
|
|
289
|
+
try {
|
|
290
|
+
const obj = {};
|
|
291
|
+
for (const [k, v] of collector) obj[k] = v;
|
|
292
|
+
const payload = await stringify(obj);
|
|
293
|
+
const safe = payload
|
|
294
|
+
.replace(/</g, '\\u003c')
|
|
295
|
+
.replace(/>/g, '\\u003e')
|
|
296
|
+
.replace(/&/g, '\\u0026')
|
|
297
|
+
.replace(/\u2028/g, '\\u2028')
|
|
298
|
+
.replace(/\u2029/g, '\\u2029');
|
|
299
|
+
return `<script type="application/json" id="__webjs-seeds">${safe}</script>`;
|
|
300
|
+
} catch {
|
|
301
|
+
return '';
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Test seam: clear the per-file hash memo (e.g. between fixtures). */
|
|
306
|
+
export function __clearSeedHashCache() {
|
|
307
|
+
_hashCache.clear();
|
|
308
|
+
}
|
package/src/actions.js
CHANGED
|
@@ -338,13 +338,25 @@ export async function serveActionStub(idx, absFile) {
|
|
|
338
338
|
// 404s, breaking every server action when webjs.basePath is set.
|
|
339
339
|
const actionUrl = withBasePath(`/__webjs/action/${hash}/`, basePath());
|
|
340
340
|
const body = `// webjs: generated server-action stub for ${relative(idx.appDir, absFile)}\n` +
|
|
341
|
-
`import { stringify as __wjStringify, parse as __wjParse } from '@webjsdev/core';\n` +
|
|
341
|
+
`import { stringify as __wjStringify, parse as __wjParse, takeSeed as __seedTake, SEED_MISS as __SEED_MISS } from '@webjsdev/core';\n` +
|
|
342
|
+
`const __HASH = ${JSON.stringify(hash)};\n` +
|
|
342
343
|
`function __csrf() {\n` +
|
|
343
344
|
` const m = document.cookie.match(/(?:^|;\\s*)${CSRF_COOKIE}=([^;]+)/);\n` +
|
|
344
345
|
` return m ? decodeURIComponent(m[1]) : '';\n` +
|
|
345
346
|
`}\n` +
|
|
346
|
-
|
|
347
|
+
// The first client call of an async-render action reads the SSR seed
|
|
348
|
+
// (#472): __wjStringify(args) is BOTH the seed lookup key and the RPC body,
|
|
349
|
+
// so a miss reuses it for the POST with no double serialization. A hit
|
|
350
|
+
// resolves synchronously (no network), so hydration does not re-fetch; a
|
|
351
|
+
// later refetch / arg-change misses (consume-once) and goes to RPC.
|
|
352
|
+
`async function __call(fn, args) {\n` +
|
|
347
353
|
` const body = await __wjStringify(args);\n` +
|
|
354
|
+
` const seeded = __seedTake(__HASH, fn, body);\n` +
|
|
355
|
+
` if (seeded !== __SEED_MISS) return seeded;\n` +
|
|
356
|
+
` return __rpc(fn, args, body);\n` +
|
|
357
|
+
`}\n` +
|
|
358
|
+
`async function __rpc(fn, args, body) {\n` +
|
|
359
|
+
` if (body === undefined) body = await __wjStringify(args);\n` +
|
|
348
360
|
` const res = await fetch(${JSON.stringify(actionUrl)} + fn, {\n` +
|
|
349
361
|
` method: 'POST',\n` +
|
|
350
362
|
` headers: {\n` +
|
|
@@ -368,8 +380,8 @@ export async function serveActionStub(idx, absFile) {
|
|
|
368
380
|
fnNames
|
|
369
381
|
.map((name) =>
|
|
370
382
|
name === 'default'
|
|
371
|
-
? `export default (...args) =>
|
|
372
|
-
: `export const ${name} = (...args) =>
|
|
383
|
+
? `export default (...args) => __call('default', args);`
|
|
384
|
+
: `export const ${name} = (...args) => __call(${JSON.stringify(name)}, args);`
|
|
373
385
|
)
|
|
374
386
|
.join('\n') + '\n';
|
|
375
387
|
return body;
|
package/src/dev.js
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
hasUseServerDirective,
|
|
66
66
|
hashFile,
|
|
67
67
|
} from './actions.js';
|
|
68
|
+
import { registerSeedHooks } from './action-seed.js';
|
|
68
69
|
import { defaultLogger } from './logger.js';
|
|
69
70
|
import { assertNodeVersion } from './node-version.js';
|
|
70
71
|
import { applyEnvValidation } from './env-schema.js';
|
|
@@ -253,6 +254,48 @@ export async function readElideEnabled(appDir) {
|
|
|
253
254
|
return true;
|
|
254
255
|
}
|
|
255
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Read the `WEBJS_SEED` environment override, if set. Same grammar as
|
|
259
|
+
* `WEBJS_ELIDE`: `0`/`false`/`off`/`no` force seeding OFF, `1`/`true`/`on`/`yes`
|
|
260
|
+
* force it ON, anything else (or unset) falls through to the package.json
|
|
261
|
+
* switch. The deploy-time / ops escape hatch and the seam tests use to render
|
|
262
|
+
* an app with seeding on and off in one process.
|
|
263
|
+
* @returns {boolean | undefined}
|
|
264
|
+
*/
|
|
265
|
+
function seedEnvOverride() {
|
|
266
|
+
const raw = process.env.WEBJS_SEED;
|
|
267
|
+
if (raw == null || raw === '') return undefined;
|
|
268
|
+
const v = String(raw).trim().toLowerCase();
|
|
269
|
+
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
|
|
270
|
+
if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Read the project-level SSR action-seeding switch (#472). Precedence: the
|
|
276
|
+
* `WEBJS_SEED` env override wins when set, otherwise the package.json
|
|
277
|
+
* `{ "webjs": { "seed": false } }` switch disables it (the client re-fetches on
|
|
278
|
+
* hydration as it did before the feature). Default ON (opt-out), mirroring
|
|
279
|
+
* elision: any value other than the literal `false` keeps seeding enabled.
|
|
280
|
+
*
|
|
281
|
+
* Seeding installs a process-global `module.registerHooks` load hook, so unlike
|
|
282
|
+
* elision it is read ONCE at boot (not re-read per rebuild): toggling it needs a
|
|
283
|
+
* restart, like a deploy.
|
|
284
|
+
* @param {string} appDir
|
|
285
|
+
* @returns {Promise<boolean>}
|
|
286
|
+
*/
|
|
287
|
+
export async function readSeedEnabled(appDir) {
|
|
288
|
+
const override = seedEnvOverride();
|
|
289
|
+
if (override !== undefined) return override;
|
|
290
|
+
try {
|
|
291
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
292
|
+
if (pkg && pkg.webjs && pkg.webjs.seed === false) return false;
|
|
293
|
+
} catch {
|
|
294
|
+
// No package.json, malformed JSON, or unreadable. Keep the default.
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
256
299
|
/**
|
|
257
300
|
* Read the per-path response-header config (`webjs.headers`) from the
|
|
258
301
|
* app's package.json and compile it to URLPattern rules. A missing,
|
|
@@ -484,6 +527,14 @@ export async function createRequestHandler(opts) {
|
|
|
484
527
|
// id is a stable deploy fingerprint independent of per-file content hashes.
|
|
485
528
|
setAssetRoots({ appDir, coreDir, enabled: !dev });
|
|
486
529
|
|
|
530
|
+
// SSR action-result seeding (#472). Install the process-global module load
|
|
531
|
+
// hook NOW, at boot, before any `'use server'` action module is imported (ESM
|
|
532
|
+
// caches by URL, so a module loaded before the hook would never be faceted).
|
|
533
|
+
// Read once (not per-rebuild): the hook is global and cannot be cleanly
|
|
534
|
+
// un-installed, so toggling needs a restart. Disabled -> no hook, no ambient
|
|
535
|
+
// collector wrap in ssr.js, and module loading stays byte-identical.
|
|
536
|
+
if (await readSeedEnabled(appDir)) registerSeedHooks();
|
|
537
|
+
|
|
487
538
|
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
488
539
|
// deterministic vendor map that is cheap to read (one file, no analysis, no
|
|
489
540
|
// network). Resolve it AT BOOT and publish the build id immediately so the
|
package/src/ssr.js
CHANGED
|
@@ -7,6 +7,7 @@ import { withAssetHash } from './asset-hash.js';
|
|
|
7
7
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
8
8
|
import { readToken, newToken, cookieHeader } from './csrf.js';
|
|
9
9
|
import { transitiveDeps } from './module-graph.js';
|
|
10
|
+
import { seedingEnabled, collectSeeds, buildSeedScript } from './action-seed.js';
|
|
10
11
|
import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
|
|
11
12
|
import {
|
|
12
13
|
readRevalidate,
|
|
@@ -94,7 +95,21 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
94
95
|
const have = haveHeader
|
|
95
96
|
? new Set(haveHeader.split(',').map((s) => s.trim()).filter(Boolean))
|
|
96
97
|
: null;
|
|
97
|
-
|
|
98
|
+
// SSR action-result seeding (#472). When enabled, run the whole render
|
|
99
|
+
// inside an ambient seed collector so every `'use server'` action a
|
|
100
|
+
// component awaits in `async render()` records its (args -> result) for the
|
|
101
|
+
// hydration payload. Disabled -> the plain render, byte-identical to before.
|
|
102
|
+
let seedCollector = null;
|
|
103
|
+
let body;
|
|
104
|
+
if (seedingEnabled()) {
|
|
105
|
+
const seeded = await collectSeeds(() =>
|
|
106
|
+
renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule),
|
|
107
|
+
);
|
|
108
|
+
body = seeded.value;
|
|
109
|
+
seedCollector = seeded.collector;
|
|
110
|
+
} else {
|
|
111
|
+
body = await renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule);
|
|
112
|
+
}
|
|
98
113
|
|
|
99
114
|
// Frame subtree render (#253). A `<webjs-frame src>` self-load (or a
|
|
100
115
|
// click-driven frame nav) sends `x-webjs-frame: <id>` and applies ONLY the
|
|
@@ -170,9 +185,20 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
170
185
|
// shell. Either way the returned `prefix` ends just past the open <body>
|
|
171
186
|
// and `closer` is the matching `</body></html>`.
|
|
172
187
|
const { prefix, streamBody, closer } = buildDocumentParts(body, wrapOpts);
|
|
188
|
+
// Append the SSR action-seed payload (#472) to the non-streamed body so the
|
|
189
|
+
// client's first render reads it instead of re-issuing the RPC. Only for a
|
|
190
|
+
// fully-buffered (non-streaming) render: a streamed page's deferred
|
|
191
|
+
// boundaries resolve AFTER the first flush, so their seeds cannot ride this
|
|
192
|
+
// block (those slow regions keep the stale-while-revalidate refetch). An
|
|
193
|
+
// empty collector yields '' so the output stays byte-identical.
|
|
194
|
+
let outBody = streamBody;
|
|
195
|
+
if (seedCollector && suspenseCtx.pending.length === 0) {
|
|
196
|
+
const seedScript = await buildSeedScript(seedCollector);
|
|
197
|
+
if (seedScript) outBody = streamBody + seedScript;
|
|
198
|
+
}
|
|
173
199
|
const res = streamingHtmlResponse(
|
|
174
200
|
prefix,
|
|
175
|
-
|
|
201
|
+
outBody,
|
|
176
202
|
closer,
|
|
177
203
|
suspenseCtx,
|
|
178
204
|
// Normally 200. After a failed page `action` submission the caller passes
|
package/webjs-config.schema.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "https://webjs.dev/schemas/webjs-config.schema.json",
|
|
4
4
|
"title": "webjs config block",
|
|
5
5
|
"description": "Schema for the `webjs` object in a webjs app's package.json. Each key maps to a documented server reader. An unknown key is flagged (additionalProperties is false) so a typo no longer silently falls back to the default.",
|
|
6
|
-
"$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the server reader functions (readElideEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readBasePath in base-path.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.",
|
|
6
|
+
"$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the server reader functions (readElideEnabled in dev.js, readSeedEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readBasePath in base-path.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
9
9
|
"properties": {
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
"type": "boolean",
|
|
13
13
|
"default": true
|
|
14
14
|
},
|
|
15
|
+
"seed": {
|
|
16
|
+
"description": "SSR action-result seeding switch (#472). Default true. When on, a 'use server' action's SSR result is serialized into the page so an async-render component does not re-fetch it over RPC on hydration. Set to false to disable (the client re-fetches as before). Read by readSeedEnabled in dev.js; the WEBJS_SEED env override wins over this.",
|
|
17
|
+
"type": "boolean",
|
|
18
|
+
"default": true
|
|
19
|
+
},
|
|
15
20
|
"headers": {
|
|
16
21
|
"description": "Per-path response-header rules, shaped like Next's. Each rule pairs a URLPattern source against header directives. Read by compileHeaderRules in headers.js.",
|
|
17
22
|
"type": "array",
|