@webjsdev/server 0.8.24 → 0.8.26
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 +3 -0
- package/package.json +1 -1
- package/src/action-config.js +162 -0
- package/src/action-middleware.d.ts +21 -0
- package/src/action-middleware.js +68 -0
- package/src/action-route.js +147 -0
- package/src/action-seed.js +8 -3
- package/src/action-signal.js +39 -0
- package/src/action-stream.js +158 -0
- package/src/actions.js +271 -316
- package/src/check.js +43 -0
- package/src/cors.js +5 -8
- package/src/csrf.js +5 -4
- package/src/dev.js +6 -34
- package/src/importmap.js +3 -3
package/src/actions.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { digestHex } from './crypto-utils.js';
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
-
import { join,
|
|
5
|
-
import { getExposed } from '@webjsdev/core';
|
|
4
|
+
import { join, sep } from 'node:path';
|
|
6
5
|
import { walk } from './fs-walk.js';
|
|
7
6
|
import { verify as verifyCsrf, CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
|
|
8
7
|
import { getSerializer } from './serializer.js';
|
|
9
|
-
import { resolveOrigin } from './cors.js';
|
|
10
8
|
import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
|
|
9
|
+
import {
|
|
10
|
+
actionMethod, actionFunctionNames, actionCache, actionConfigFn, resolveTags,
|
|
11
|
+
cacheControlFor, allowedRequestMethods, URL_ARG_VERBS, SAFE_VERBS, MAX_URL_ARGS,
|
|
12
|
+
RESERVED_CONFIG, actionMiddleware,
|
|
13
|
+
} from './action-config.js';
|
|
14
|
+
import { revalidateTags } from './cache-tags.js';
|
|
15
|
+
import { runWithActionSignal } from './action-signal.js';
|
|
16
|
+
import { runActionChain } from './action-middleware.js';
|
|
17
|
+
import { isStreamable, streamActionResponse } from './action-stream.js';
|
|
18
|
+
import { ifNoneMatchSatisfied } from './conditional-get.js';
|
|
11
19
|
import { getBodyLimits } from './context.js';
|
|
12
20
|
import { basePath } from './importmap.js';
|
|
13
21
|
import { withBasePath } from './base-path.js';
|
|
@@ -35,9 +43,9 @@ export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
|
|
|
35
43
|
|
|
36
44
|
/**
|
|
37
45
|
* Run an attached input validator against an action's input, shared by the RPC
|
|
38
|
-
* path (`invokeAction`) and the `
|
|
39
|
-
*
|
|
40
|
-
*
|
|
46
|
+
* path (`invokeAction`) and the `route()` REST adapter so the contract is
|
|
47
|
+
* identical across transports (#245). The framework only CALLS the validator and
|
|
48
|
+
* shapes the result; it ships no validation library.
|
|
41
49
|
*
|
|
42
50
|
* @param {(input: any) => any} validate the attached validator
|
|
43
51
|
* @param {any} input the value handed to the validator (the action's first arg
|
|
@@ -118,56 +126,41 @@ async function rpcResponse(payload, init = {}) {
|
|
|
118
126
|
* The server:
|
|
119
127
|
* 1. Scans the app tree lazily on the first request (in `ensureReady`),
|
|
120
128
|
* classifying server files into RPC-callable actions vs. server-only
|
|
121
|
-
* utilities. Hashing is eager-per-file;
|
|
129
|
+
* utilities. Hashing is eager-per-file; no module is loaded here.
|
|
122
130
|
* 2. Serves a generated ES-module stub when the browser imports
|
|
123
131
|
* the file URL (an RPC stub for actions, a throw-at-load stub
|
|
124
132
|
* for server-only utilities).
|
|
125
|
-
* 3. Exposes
|
|
126
|
-
* RPC-callable actions only.
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* @typedef {{
|
|
131
|
-
* method: string,
|
|
132
|
-
* pattern: RegExp,
|
|
133
|
-
* paramNames: string[],
|
|
134
|
-
* file: string,
|
|
135
|
-
* fnName: string,
|
|
136
|
-
* validate: ((input: any) => any) | null,
|
|
137
|
-
* cors: { origin: string | string[], credentials: boolean, maxAge: number, headers: string[] | null } | null,
|
|
138
|
-
* }} ExposedRoute
|
|
133
|
+
* 3. Exposes endpoints at /__webjs/action/:hash/:fn for
|
|
134
|
+
* RPC-callable actions only. A public REST endpoint is a `route.ts`
|
|
135
|
+
* that imports and calls the action (optionally via the `route()`
|
|
136
|
+
* adapter in `action-route.js`).
|
|
139
137
|
*
|
|
140
138
|
* @typedef {{
|
|
141
139
|
* hashToFile: Map<string,string>,
|
|
142
140
|
* fileToHash: Map<string,string>,
|
|
143
|
-
* httpRoutes: ExposedRoute[],
|
|
144
141
|
* appDir: string,
|
|
145
142
|
* dev: boolean,
|
|
146
143
|
* }} ActionIndex
|
|
147
144
|
*/
|
|
148
145
|
|
|
149
146
|
/**
|
|
150
|
-
* Build the action index by scanning the app directory.
|
|
147
|
+
* Build the action index by scanning the app directory. This is a pure
|
|
148
|
+
* file -> hash mapping: it walks `app/`, classifies `.server.*` + `'use server'`
|
|
149
|
+
* files as RPC-callable actions, and records the hash both ways. It loads NO
|
|
150
|
+
* module (the hash index is all the analysis needs; a module is imported on
|
|
151
|
+
* demand by `invokeAction` / `serveActionStub`), so it is safe for a read-only
|
|
152
|
+
* introspection caller (the MCP `list_actions` tool, #262) with no top-level
|
|
153
|
+
* side effects (Prisma init, DB connect).
|
|
151
154
|
*
|
|
152
155
|
* @param {string} appDir
|
|
153
156
|
* @param {boolean} dev
|
|
154
|
-
* @param {{ skipExposeLoad?: boolean }} [opts]
|
|
155
|
-
* `skipExposeLoad: true` builds the (load-free) `fileToHash` / `hashToFile`
|
|
156
|
-
* maps WITHOUT importing any `expose()`-referencing module, so `httpRoutes`
|
|
157
|
-
* stays empty. A read-only introspection caller (the MCP `list_actions` tool,
|
|
158
|
-
* #262) uses this to derive RPC endpoint hashes without running a server
|
|
159
|
-
* module's top-level side effects (Prisma init, DB connect) or risking a
|
|
160
|
-
* stray stdout write. The request pipeline keeps the default (loads expose
|
|
161
|
-
* routes, which the router must know before a request can hit them).
|
|
162
157
|
* @returns {Promise<ActionIndex>}
|
|
163
158
|
*/
|
|
164
|
-
export async function buildActionIndex(appDir, dev
|
|
159
|
+
export async function buildActionIndex(appDir, dev) {
|
|
165
160
|
/** @type {Map<string,string>} */
|
|
166
161
|
const hashToFile = new Map();
|
|
167
162
|
/** @type {Map<string,string>} */
|
|
168
163
|
const fileToHash = new Map();
|
|
169
|
-
/** @type {ExposedRoute[]} */
|
|
170
|
-
const httpRoutes = [];
|
|
171
164
|
|
|
172
165
|
for await (const file of walk(appDir, (p) => /\.m?[jt]s$/.test(p))) {
|
|
173
166
|
// Path-level: only `.server.{ts,js,mts,mjs}` files are server-only.
|
|
@@ -189,50 +182,10 @@ export async function buildActionIndex(appDir, dev, opts = {}) {
|
|
|
189
182
|
// serveActionStub import the module on demand (first RPC call / first stub
|
|
190
183
|
// fetch), so the hash index above is all the analysis needs. Eagerly
|
|
191
184
|
// running every server module (and its transitive Prisma init, DB
|
|
192
|
-
// connects, etc.) would be wasted work.
|
|
193
|
-
// which registers a REST route the router must know before any request can
|
|
194
|
-
// hit it. So load only files that REFERENCE expose. We match the bare
|
|
195
|
-
// `expose` identifier (not `expose(`) so an aliased import
|
|
196
|
-
// (`import { expose as exp }`, whose import clause still names `expose`) is
|
|
197
|
-
// not missed: missing it would silently 404 that file's REST route. A stray
|
|
198
|
-
// mention in a comment or string only over-matches, costing one harmless
|
|
199
|
-
// extra module load; the common pure-RPC file never names `expose` and so
|
|
200
|
-
// still defers entirely.
|
|
201
|
-
// A read-only caller (MCP introspection) only needs the file -> hash maps
|
|
202
|
-
// above, so skip the expose-load entirely (no module side effects).
|
|
203
|
-
if (opts.skipExposeLoad) continue;
|
|
204
|
-
let src = '';
|
|
205
|
-
try { src = await readFile(file, 'utf8'); } catch {}
|
|
206
|
-
if (!/\bexpose\b/.test(src)) continue;
|
|
207
|
-
try {
|
|
208
|
-
const mod = await loadModule(file, dev);
|
|
209
|
-
for (const [name, fn] of Object.entries(mod)) {
|
|
210
|
-
if (typeof fn !== 'function') continue;
|
|
211
|
-
const http = getExposed(fn);
|
|
212
|
-
if (!http) continue;
|
|
213
|
-
// A `validateInput(fn, ...)` attachment writes `__webjsHttp` with a
|
|
214
|
-
// `validate` but NO `method`/`path` (it does not create a REST route),
|
|
215
|
-
// so it is not an exposed route. Only `expose()` sets a path; skip a
|
|
216
|
-
// validate-only attachment here (its validator is read at RPC call
|
|
217
|
-
// time via getExposed in invokeAction).
|
|
218
|
-
if (!http.path || !http.method) continue;
|
|
219
|
-
const { pattern, paramNames } = pathToPattern(http.path);
|
|
220
|
-
httpRoutes.push({
|
|
221
|
-
method: http.method,
|
|
222
|
-
pattern,
|
|
223
|
-
paramNames,
|
|
224
|
-
file,
|
|
225
|
-
fnName: name,
|
|
226
|
-
validate: http.validate || null,
|
|
227
|
-
cors: http.cors || null,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
} catch (e) {
|
|
231
|
-
console.error(`[webjs] failed to scan server module ${file}:`, e);
|
|
232
|
-
}
|
|
185
|
+
// connects, etc.) would be wasted work.
|
|
233
186
|
}
|
|
234
187
|
|
|
235
|
-
return { hashToFile, fileToHash,
|
|
188
|
+
return { hashToFile, fileToHash, appDir, dev };
|
|
236
189
|
}
|
|
237
190
|
|
|
238
191
|
/** @param {string} file @returns {Promise<string>} */
|
|
@@ -327,66 +280,152 @@ throw new Error(${JSON.stringify(msg)});
|
|
|
327
280
|
export async function serveActionStub(idx, absFile) {
|
|
328
281
|
const mod = await loadModule(absFile, idx.dev);
|
|
329
282
|
const hash = idx.fileToHash.get(absFile) || await hashFile(absFile);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
283
|
+
// HTTP-verb actions (#488): the file declares its verb via `export const
|
|
284
|
+
// method` (default POST). The function exports are the actions; the reserved
|
|
285
|
+
// config exports (method/cache/tags/invalidates/validate/middleware) are excluded.
|
|
286
|
+
const fnNames = actionFunctionNames(mod);
|
|
287
|
+
const method = actionMethod(mod);
|
|
334
288
|
// The RPC endpoint is a framework-emitted same-origin URL, so it must
|
|
335
289
|
// carry the basePath prefix under a sub-path deploy (#256), exactly like
|
|
336
290
|
// the importmap targets and the boot module specifiers. Without this the
|
|
337
291
|
// stub would POST to a bare /__webjs/action/... that the ingress strip
|
|
338
292
|
// 404s, breaking every server action when webjs.basePath is set.
|
|
339
293
|
const actionUrl = withBasePath(`/__webjs/action/${hash}/`, basePath());
|
|
340
|
-
const body =
|
|
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` +
|
|
343
|
-
`function __csrf() {\n` +
|
|
344
|
-
` const m = document.cookie.match(/(?:^|;\\s*)${CSRF_COOKIE}=([^;]+)/);\n` +
|
|
345
|
-
` return m ? decodeURIComponent(m[1]) : '';\n` +
|
|
346
|
-
`}\n` +
|
|
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` +
|
|
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` +
|
|
360
|
-
` const res = await fetch(${JSON.stringify(actionUrl)} + fn, {\n` +
|
|
361
|
-
` method: 'POST',\n` +
|
|
362
|
-
` headers: {\n` +
|
|
363
|
-
` 'content-type': ${JSON.stringify(RPC_CONTENT_TYPE)},\n` +
|
|
364
|
-
` ${JSON.stringify(CSRF_HEADER)}: __csrf()\n` +
|
|
365
|
-
` },\n` +
|
|
366
|
-
` credentials: 'same-origin',\n` +
|
|
367
|
-
` body\n` +
|
|
368
|
-
` });\n` +
|
|
369
|
-
` const ct = res.headers.get('content-type') || '';\n` +
|
|
370
|
-
` const text = await res.text();\n` +
|
|
371
|
-
` const parsed = ct.includes(${JSON.stringify(RPC_CONTENT_TYPE)})\n` +
|
|
372
|
-
` ? __wjParse(text)\n` +
|
|
373
|
-
` : (ct.includes('application/json') ? JSON.parse(text) : text);\n` +
|
|
374
|
-
` if (!res.ok) {\n` +
|
|
375
|
-
` const msg = (parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status);\n` +
|
|
376
|
-
` throw new Error(msg);\n` +
|
|
377
|
-
` }\n` +
|
|
378
|
-
` return parsed;\n` +
|
|
379
|
-
`}\n` +
|
|
380
|
-
fnNames
|
|
381
|
-
.map((name) =>
|
|
382
|
-
name === 'default'
|
|
383
|
-
? `export default (...args) => __call('default', args);`
|
|
384
|
-
: `export const ${name} = (...args) => __call(${JSON.stringify(name)}, args);`
|
|
385
|
-
)
|
|
386
|
-
.join('\n') + '\n';
|
|
294
|
+
const body = buildStubBody({ hash, method, fnNames, actionUrl });
|
|
387
295
|
return body;
|
|
388
296
|
}
|
|
389
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Build the generated client stub source for a `'use server'` action file. The
|
|
300
|
+
* transport depends on the file's declared `method`:
|
|
301
|
+
* - GET: args ride the URL (`?a=`), the SSR seed (#472) is read first, the
|
|
302
|
+
* browser HTTP cache is consulted, and a tag-invalidated key revalidates;
|
|
303
|
+
* CSRF-exempt (a safe read). Over-large args fall back to a POST.
|
|
304
|
+
* - DELETE: args ride the URL, CSRF-protected, over-large args fall back to a
|
|
305
|
+
* POST.
|
|
306
|
+
* - POST / PUT / PATCH: rich body, CSRF-protected.
|
|
307
|
+
* Every path reads `X-Webjs-Invalidate` (mark tags stale) and a GET reads
|
|
308
|
+
* `X-Webjs-Tags` (register the key's tags), so a later read sees a mutation.
|
|
309
|
+
* @param {{ hash: string, method: string, fnNames: string[], actionUrl: string }} opts
|
|
310
|
+
* @returns {string}
|
|
311
|
+
*/
|
|
312
|
+
function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
313
|
+
const URL_ARG = URL_ARG_VERBS.has(method);
|
|
314
|
+
const SAFE = SAFE_VERBS.has(method);
|
|
315
|
+
const J = JSON.stringify;
|
|
316
|
+
const lines = [];
|
|
317
|
+
lines.push(`// webjs: generated server-action stub (${method})`);
|
|
318
|
+
// Every verb reads the SSR seed (#472) first: an async-render READ invoked
|
|
319
|
+
// during SSR is seeded regardless of its declared verb (a read with no
|
|
320
|
+
// `method` is a default POST), so the first client call resolves from the
|
|
321
|
+
// seed with no hydration round-trip. The browser-cache staleness check is
|
|
322
|
+
// GET-only (only a GET is browser-cached).
|
|
323
|
+
const imports = ['stringify as __s', 'parse as __p', 'takeSeed as __seedTake', 'SEED_MISS as __MISS', 'markStale as __markStale', 'parseTagHeader as __tagHdr', 'activeActionSignal as __sig',
|
|
324
|
+
// Streaming RPC (#489): an action returning a stream / async iterable sends
|
|
325
|
+
// back a framed body; the stub decodes it into an async iterable the caller
|
|
326
|
+
// `for await`s. The imports are small constants + one decoder factory.
|
|
327
|
+
'STREAM_CONTENT_TYPE as __STREAM_CT', 'createFrameDecoder as __frameDec', 'FRAME_CHUNK as __F_CHUNK', 'FRAME_END as __F_END', 'FRAME_ERROR as __F_ERR'];
|
|
328
|
+
if (method === 'GET') {
|
|
329
|
+
imports.push('registerKeyTags as __regTags', 'consumeStale as __stale', 'fetchMark as __mark');
|
|
330
|
+
}
|
|
331
|
+
lines.push(`import { ${imports.join(', ')} } from '@webjsdev/core';`);
|
|
332
|
+
lines.push(`const __URL = ${J(actionUrl)};`);
|
|
333
|
+
lines.push(`const __HASH = ${J(hash)};`);
|
|
334
|
+
lines.push(`const __METHOD = ${J(method)};`);
|
|
335
|
+
lines.push(`const __MAX = ${MAX_URL_ARGS};`);
|
|
336
|
+
lines.push(`const __CT = ${J(RPC_CONTENT_TYPE)};`);
|
|
337
|
+
lines.push(`function __csrf() { const m = document.cookie.match(/(?:^|;\\s*)${CSRF_COOKIE}=([^;]+)/); return m ? decodeURIComponent(m[1]) : ''; }`);
|
|
338
|
+
// Shared: parse a response, surface invalidation, register a GET's tags
|
|
339
|
+
// (stamped with the clock SAMPLED BEFORE the fetch, so a mutation in flight is
|
|
340
|
+
// caught on the next read).
|
|
341
|
+
lines.push(`async function __handle(res, fn, key, since) {`);
|
|
342
|
+
lines.push(` const inv = __tagHdr(res.headers.get('x-webjs-invalidate')); if (inv.length) __markStale(inv);`);
|
|
343
|
+
lines.push(` if (key != null) { const t = __tagHdr(res.headers.get('x-webjs-tags')); if (t.length) __regTags(key, t, since); }`);
|
|
344
|
+
lines.push(` const ct = res.headers.get('content-type') || '';`);
|
|
345
|
+
// A streamed result (#489): the action returned a stream / async iterable.
|
|
346
|
+
// Return an async iterable that decodes the framed body and yields each
|
|
347
|
+
// deserialized chunk. The response is 200 (the stream started), so errors
|
|
348
|
+
// arrive as ERROR frames, not an HTTP status.
|
|
349
|
+
lines.push(` if (ct.includes(__STREAM_CT)) return __readStream(res, fn);`);
|
|
350
|
+
lines.push(` const text = await res.text();`);
|
|
351
|
+
lines.push(` const parsed = ct.includes(__CT) ? __p(text) : (ct.includes('application/json') ? JSON.parse(text) : text);`);
|
|
352
|
+
lines.push(` if (!res.ok) throw new Error((parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status));`);
|
|
353
|
+
lines.push(` return parsed;`);
|
|
354
|
+
lines.push(`}`);
|
|
355
|
+
// Decode a framed streamed body into an async generator of rich-deserialized
|
|
356
|
+
// chunks. A CHUNK frame yields a value, END returns, ERROR throws the
|
|
357
|
+
// (author-controlled) message. The reader is released on completion / abort.
|
|
358
|
+
lines.push(`async function* __readStream(res, fn) {`);
|
|
359
|
+
lines.push(` if (!res.body) throw new Error('webjs stream ' + fn + ' has no body');`);
|
|
360
|
+
lines.push(` const reader = res.body.getReader();`);
|
|
361
|
+
lines.push(` const dec = __frameDec();`);
|
|
362
|
+
lines.push(` const td = new TextDecoder();`);
|
|
363
|
+
lines.push(` let ended = false;`);
|
|
364
|
+
lines.push(` try {`);
|
|
365
|
+
lines.push(` for (;;) {`);
|
|
366
|
+
lines.push(` const { value, done } = await reader.read();`);
|
|
367
|
+
lines.push(` if (done) break;`);
|
|
368
|
+
lines.push(` for (const f of dec.push(value)) {`);
|
|
369
|
+
lines.push(` if (f.type === __F_CHUNK) yield __p(td.decode(f.payload));`);
|
|
370
|
+
lines.push(` else if (f.type === __F_END) { ended = true; return; }`);
|
|
371
|
+
lines.push(` else if (f.type === __F_ERR) throw new Error(td.decode(f.payload) || ('webjs stream ' + fn));`);
|
|
372
|
+
lines.push(` }`);
|
|
373
|
+
lines.push(` }`);
|
|
374
|
+
// The body ended without a terminal END/ERROR frame: the stream was truncated
|
|
375
|
+
// (a server crash, a dropped connection, an upstream timeout). A healthy
|
|
376
|
+
// stream always ends in END or ERROR, so surface this as an error rather than
|
|
377
|
+
// a silent clean completion. (A consumer that breaks early goes through the
|
|
378
|
+
// generator's return(), which skips this and runs only the finally.)
|
|
379
|
+
lines.push(` if (!ended) throw new Error('webjs stream ' + fn + ' truncated (no end frame)');`);
|
|
380
|
+
lines.push(` } finally { try { reader.cancel(); } catch {} try { reader.releaseLock(); } catch {} }`);
|
|
381
|
+
lines.push(`}`);
|
|
382
|
+
// Body sender (POST/PUT/PATCH, and the URL-arg too-large fallback). `sig` is
|
|
383
|
+
// captured SYNCHRONOUSLY at __call entry (#492): the active render signal must
|
|
384
|
+
// be read before the stub's first await (stringify), or the render's
|
|
385
|
+
// synchronous window has already closed and cleared it.
|
|
386
|
+
lines.push(`async function __body(fn, body, m, csrf, sig) {`);
|
|
387
|
+
lines.push(` const headers = { 'content-type': __CT }; if (csrf) headers[${J(CSRF_HEADER)}] = __csrf();`);
|
|
388
|
+
lines.push(` const res = await fetch(__URL + fn, { method: m, headers, credentials: 'same-origin', body, signal: sig });`);
|
|
389
|
+
lines.push(` return __handle(res, fn, null);`);
|
|
390
|
+
lines.push(`}`);
|
|
391
|
+
if (URL_ARG) {
|
|
392
|
+
// GET/DELETE: args in the URL, with a POST fallback when too large.
|
|
393
|
+
lines.push(`async function __call(fn, args) {`);
|
|
394
|
+
lines.push(` const sig = __sig();`); // sync capture before any await
|
|
395
|
+
lines.push(` const key = await __s(args);`);
|
|
396
|
+
lines.push(` const seeded = __seedTake(__HASH, fn, key); if (seeded !== __MISS) return seeded;`);
|
|
397
|
+
lines.push(` if (key.length > __MAX) return __body(fn, key, 'POST', ${SAFE ? 'false' : 'true'}, sig);`);
|
|
398
|
+
if (method === 'GET') {
|
|
399
|
+
lines.push(` const bypass = __stale(key);`);
|
|
400
|
+
lines.push(` const since = __mark();`);
|
|
401
|
+
lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: 'GET', credentials: 'same-origin', cache: bypass ? 'no-cache' : 'default', signal: sig });`);
|
|
402
|
+
lines.push(` return __handle(res, fn, key, since);`);
|
|
403
|
+
} else {
|
|
404
|
+
lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: __METHOD, headers: { ${J(CSRF_HEADER)}: __csrf() }, credentials: 'same-origin', signal: sig });`);
|
|
405
|
+
lines.push(` return __handle(res, fn, null);`);
|
|
406
|
+
}
|
|
407
|
+
lines.push(`}`);
|
|
408
|
+
} else {
|
|
409
|
+
// POST/PUT/PATCH: rich body. The seed read makes a default-POST async-render
|
|
410
|
+
// read resolve from the SSR seed on hydration (#472); a true mutation is
|
|
411
|
+
// never SSR-invoked, so the seed simply misses.
|
|
412
|
+
lines.push(`async function __call(fn, args) {`);
|
|
413
|
+
lines.push(` const sig = __sig();`); // sync capture before any await
|
|
414
|
+
lines.push(` const key = await __s(args);`);
|
|
415
|
+
lines.push(` const seeded = __seedTake(__HASH, fn, key); if (seeded !== __MISS) return seeded;`);
|
|
416
|
+
lines.push(` return __body(fn, key, __METHOD, true, sig);`);
|
|
417
|
+
lines.push(`}`);
|
|
418
|
+
}
|
|
419
|
+
for (const name of fnNames) {
|
|
420
|
+
lines.push(
|
|
421
|
+
name === 'default'
|
|
422
|
+
? `export default (...args) => __call('default', args);`
|
|
423
|
+
: `export const ${name} = (...args) => __call(${J(name)}, args);`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
return lines.join('\n') + '\n';
|
|
427
|
+
}
|
|
428
|
+
|
|
390
429
|
/**
|
|
391
430
|
* Invoke a server action via the internal RPC wire format.
|
|
392
431
|
* @param {ActionIndex} idx
|
|
@@ -399,34 +438,58 @@ export async function serveActionStub(idx, absFile) {
|
|
|
399
438
|
* it so a throwing sink can never affect the response.
|
|
400
439
|
*/
|
|
401
440
|
export async function invokeAction(idx, hash, fnName, req, onError) {
|
|
402
|
-
if (!verifyCsrf(req)) {
|
|
403
|
-
return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
|
|
404
|
-
}
|
|
405
441
|
const file = idx.hashToFile.get(hash);
|
|
406
442
|
if (!file) return rpcResponse({ error: 'Unknown action' }, { status: 404 });
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
if (
|
|
443
|
+
const mod = await loadModule(file, idx.dev);
|
|
444
|
+
const fn = fnName === 'default' ? mod.default : mod[fnName];
|
|
445
|
+
// A reserved config export (method/cache/tags/invalidates/validate/middleware) is never
|
|
446
|
+
// a callable action even though some are functions (#488).
|
|
447
|
+
if (typeof fn !== 'function' || RESERVED_CONFIG.has(fnName)) {
|
|
448
|
+
return rpcResponse({ error: `Unknown action ${fnName}` }, { status: 404 });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// HTTP-verb dispatch (#488). The action declares its method (default POST). A
|
|
452
|
+
// URL-arg verb (GET/DELETE) also accepts a POST fallback for over-large args.
|
|
453
|
+
const method = actionMethod(mod);
|
|
454
|
+
const reqMethod = req.method.toUpperCase();
|
|
455
|
+
const allowed = allowedRequestMethods(method);
|
|
456
|
+
if (!allowed.has(reqMethod)) {
|
|
457
|
+
return rpcResponse(
|
|
458
|
+
{ error: `expected ${method} for ${fnName}, got ${reqMethod}` },
|
|
459
|
+
{ status: 405, headers: { allow: [...allowed].join(', ') } },
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
// CSRF: required for every verb except a safe GET (a read with no state
|
|
463
|
+
// change, which is also browser-cacheable so it cannot carry a fresh token).
|
|
464
|
+
if (!SAFE_VERBS.has(method) && !verifyCsrf(req)) {
|
|
465
|
+
return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Args ride the URL for a URL-arg request (GET / DELETE), else the body.
|
|
412
469
|
let args = [];
|
|
470
|
+
const fromUrl = reqMethod === 'GET' || reqMethod === 'DELETE';
|
|
413
471
|
try {
|
|
414
|
-
|
|
472
|
+
if (fromUrl) {
|
|
473
|
+
const a = new URL(req.url).searchParams.get('a');
|
|
474
|
+
args = a ? getSerializer().deserialize(a) : [];
|
|
475
|
+
} else {
|
|
476
|
+
const { tooLarge, text: body } = await readTextBounded(req, jsonBodyLimit());
|
|
477
|
+
if (tooLarge) return payloadTooLarge();
|
|
478
|
+
args = body ? getSerializer().deserialize(body) : [];
|
|
479
|
+
}
|
|
415
480
|
if (!Array.isArray(args)) args = [args];
|
|
416
481
|
} catch {
|
|
417
482
|
return rpcResponse({ error: 'Invalid request body' }, { status: 400 });
|
|
418
483
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
if (attached && typeof attached.validate === 'function') {
|
|
429
|
-
const v = runValidate(attached.validate, args[0]);
|
|
484
|
+
// Input validation (#245): a validator declared via the `validate` config
|
|
485
|
+
// export (#488) runs SERVER-SIDE before the body on this RPC path. The RPC
|
|
486
|
+
// stub sends an args array; an action conventionally takes one input object,
|
|
487
|
+
// so validate the FIRST arg. Validation is a BOUNDARY concern: it runs on
|
|
488
|
+
// this RPC boundary (and the `route()` REST adapter), never on a direct
|
|
489
|
+
// server-to-server call.
|
|
490
|
+
const validate = actionConfigFn(mod, 'validate');
|
|
491
|
+
if (typeof validate === 'function') {
|
|
492
|
+
const v = runValidate(validate, args[0]);
|
|
430
493
|
if (!v.ok) {
|
|
431
494
|
if (v.thrown !== undefined) {
|
|
432
495
|
// A THROWN validator behaves like a thrown action: a sanitized error
|
|
@@ -444,8 +507,45 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
|
|
|
444
507
|
args = [v.value, ...args.slice(1)];
|
|
445
508
|
}
|
|
446
509
|
try {
|
|
447
|
-
|
|
448
|
-
|
|
510
|
+
// Run inside the request's AbortSignal scope (#492) and the per-action
|
|
511
|
+
// middleware chain (#490): the chain wraps the action, can short-circuit
|
|
512
|
+
// (an auth middleware returning an ActionResult), and accumulates context
|
|
513
|
+
// the action reads via actionContext(). `ranAction` distinguishes a real
|
|
514
|
+
// action completion from a middleware short-circuit (the action never ran).
|
|
515
|
+
const middleware = actionMiddleware(mod);
|
|
516
|
+
let ranAction = false;
|
|
517
|
+
const result = await runWithActionSignal(req.signal, () =>
|
|
518
|
+
runActionChain(middleware, { request: req, args, signal: req.signal }, () => { ranAction = true; return fn(...args); }));
|
|
519
|
+
// Streaming result (#489): a COMPLETED action returning a ReadableStream /
|
|
520
|
+
// async iterable streams its chunks over the RPC wire instead of buffering.
|
|
521
|
+
// A streamed result is never cached, ETagged, or seeded (it is not a
|
|
522
|
+
// serializer-safe value). A middleware short-circuit (the action never ran)
|
|
523
|
+
// is a plain envelope and falls through to the normal verb handling below.
|
|
524
|
+
if (ranAction && isStreamable(result)) {
|
|
525
|
+
const headers = {};
|
|
526
|
+
if (method !== 'GET') {
|
|
527
|
+
const inv = resolveTags(actionConfigFn(mod, 'invalidates'), args);
|
|
528
|
+
if (inv.length) { await revalidateTags(inv); headers['x-webjs-invalidate'] = inv.join(','); }
|
|
529
|
+
}
|
|
530
|
+
return streamActionResponse(result, { signal: req.signal, onError, headers });
|
|
531
|
+
}
|
|
532
|
+
if (method === 'GET') {
|
|
533
|
+
// A short-circuit (the action did not run, e.g. an auth denial) is NEVER
|
|
534
|
+
// cached: serve the envelope no-store so a denial is not stored or shared.
|
|
535
|
+
if (!ranAction) return rpcResponse(result ?? null, { headers: { 'cache-control': 'no-store' } });
|
|
536
|
+
return await getActionResponse(result, mod, args, req);
|
|
537
|
+
}
|
|
538
|
+
// A mutation (POST/PUT/PATCH/DELETE): only a COMPLETED action evicts its
|
|
539
|
+
// `invalidates` tags; a short-circuit (the action never ran) does not.
|
|
540
|
+
const headers = {};
|
|
541
|
+
if (ranAction) {
|
|
542
|
+
const inv = resolveTags(actionConfigFn(mod, 'invalidates'), args);
|
|
543
|
+
if (inv.length) {
|
|
544
|
+
await revalidateTags(inv);
|
|
545
|
+
headers['x-webjs-invalidate'] = inv.join(',');
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return rpcResponse(result ?? null, { headers });
|
|
449
549
|
} catch (e) {
|
|
450
550
|
if (typeof onError === 'function') onError(e);
|
|
451
551
|
return actionErrorResponse(e, idx.dev);
|
|
@@ -453,163 +553,32 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
|
|
|
453
553
|
}
|
|
454
554
|
|
|
455
555
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (r.method !== method) continue;
|
|
465
|
-
const m = r.pattern.exec(pathname);
|
|
466
|
-
if (!m) continue;
|
|
467
|
-
/** @type {Record<string,string>} */
|
|
468
|
-
const params = {};
|
|
469
|
-
r.paramNames.forEach((n, i) => (params[n] = decodeURIComponent(m[i + 1] || '')));
|
|
470
|
-
return { route: r, params };
|
|
471
|
-
}
|
|
472
|
-
return null;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Find ALL exposed routes at a given path (any method). Used to build OPTIONS
|
|
477
|
-
* preflight responses and Allow headers.
|
|
478
|
-
* @param {ActionIndex} idx
|
|
479
|
-
* @param {string} pathname
|
|
480
|
-
*/
|
|
481
|
-
export function matchAllAtPath(idx, pathname) {
|
|
482
|
-
const out = [];
|
|
483
|
-
for (const r of idx.httpRoutes) {
|
|
484
|
-
if (r.pattern.exec(pathname)) out.push(r);
|
|
485
|
-
}
|
|
486
|
-
return out;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Build CORS response headers given a route's CORS config + the request.
|
|
491
|
-
* Returns null if CORS isn't configured for this route.
|
|
492
|
-
*
|
|
493
|
-
* @param {ExposedRoute} route
|
|
494
|
-
* @param {Request} req
|
|
495
|
-
*/
|
|
496
|
-
export function corsHeadersFor(route, req) {
|
|
497
|
-
if (!route.cors) return null;
|
|
498
|
-
const cfg = route.cors;
|
|
499
|
-
const origin = req.headers.get('origin') || '';
|
|
500
|
-
const allowed = matchOrigin(cfg.origin, origin);
|
|
501
|
-
if (!allowed && origin) return null;
|
|
502
|
-
const h = new Headers();
|
|
503
|
-
h.set('access-control-allow-origin', allowed === true ? '*' : allowed || origin);
|
|
504
|
-
if (cfg.credentials) h.set('access-control-allow-credentials', 'true');
|
|
505
|
-
h.append('vary', 'Origin');
|
|
506
|
-
return h;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/** @param {ExposedRoute} route @param {Request} req */
|
|
510
|
-
export function buildPreflightResponse(route, req) {
|
|
511
|
-
const headers = corsHeadersFor(route, req);
|
|
512
|
-
if (!headers) return new Response(null, { status: 403 });
|
|
513
|
-
headers.set('access-control-allow-methods', `${route.method}, OPTIONS`);
|
|
514
|
-
const reqHdrs =
|
|
515
|
-
route.cors?.headers?.join(',') || req.headers.get('access-control-request-headers') || 'content-type';
|
|
516
|
-
headers.set('access-control-allow-headers', reqHdrs);
|
|
517
|
-
headers.set('access-control-max-age', String(route.cors?.maxAge ?? 86400));
|
|
518
|
-
return new Response(null, { status: 204, headers });
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/** Apply CORS headers (if any) to an existing response. */
|
|
522
|
-
export function withCors(resp, route, req) {
|
|
523
|
-
const h = corsHeadersFor(route, req);
|
|
524
|
-
if (!h) return resp;
|
|
525
|
-
const newHeaders = new Headers(resp.headers);
|
|
526
|
-
h.forEach((v, k) => newHeaders.set(k, v));
|
|
527
|
-
return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers: newHeaders });
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* Match a route's configured origin policy against the request origin,
|
|
532
|
-
* preserving the expose() path's historical contract: `*` returns `true`
|
|
533
|
-
* (literal wildcard), an allowed concrete origin echoes back, and a
|
|
534
|
-
* mismatch returns `null`. Delegates the per-rule decision to the shared
|
|
535
|
-
* cors.js resolver (so RegExp + function policies work here too) but keeps
|
|
536
|
-
* the wildcard-as-`true` and echo-vs-null shape this caller expects.
|
|
537
|
-
*
|
|
538
|
-
* @param {string|string[]|RegExp|((origin: string) => boolean)} configured
|
|
539
|
-
* @param {string} origin
|
|
540
|
-
* @returns {true | string | null}
|
|
541
|
-
*/
|
|
542
|
-
function matchOrigin(configured, origin) {
|
|
543
|
-
if (configured === '*') return true;
|
|
544
|
-
const resolved = resolveOrigin(configured, origin, false);
|
|
545
|
-
if (!resolved) return null;
|
|
546
|
-
return resolved.allowOrigin === '*' ? true : resolved.allowOrigin;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* Invoke an exposed action as a REST endpoint.
|
|
551
|
-
* Builds a single object argument from URL params + query + JSON body.
|
|
552
|
-
* @param {ActionIndex} idx
|
|
553
|
-
* @param {ExposedRoute} route
|
|
554
|
-
* @param {Record<string,string>} params
|
|
556
|
+
* Build a GET action's response (#488): the serialized body plus a weak ETag
|
|
557
|
+
* (content hash), the `Cache-Control` from the `cache` config (else `no-store`),
|
|
558
|
+
* and `X-Webjs-Tags`. Answers `If-None-Match` with a 304 itself rather than
|
|
559
|
+
* relying on the conditional-GET funnel, which EXCLUDES `private` responses (the
|
|
560
|
+
* default here); a per-user browser cache may still revalidate to a 304.
|
|
561
|
+
* @param {unknown} result
|
|
562
|
+
* @param {Record<string, unknown>} mod
|
|
563
|
+
* @param {unknown[]} args
|
|
555
564
|
* @param {Request} req
|
|
556
|
-
* @
|
|
557
|
-
* invoked when the exposed REST handler throws unexpectedly, BEFORE the
|
|
558
|
-
* sanitized 500 is returned, so an APM integration sees the original error.
|
|
559
|
-
* The caller wraps it so a throwing sink can never affect the response.
|
|
565
|
+
* @returns {Promise<Response>}
|
|
560
566
|
*/
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const parsed = JSON.parse(text);
|
|
572
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) body = parsed;
|
|
573
|
-
else body = { body: parsed };
|
|
574
|
-
} catch {
|
|
575
|
-
return new Response('Invalid JSON body', { status: 400 });
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
let arg = { ...query, ...params, ...body };
|
|
580
|
-
if (route.validate) {
|
|
581
|
-
// Run the validator through the SHARED contract (#245) so the REST path and
|
|
582
|
-
// the RPC path interpret a `{ success, fieldErrors }` envelope, a throw, and
|
|
583
|
-
// a transform-return identically. A structured failure becomes a 422 JSON
|
|
584
|
-
// `{ error?, fieldErrors }` (not just a thrown 400); a throw stays a 400; a
|
|
585
|
-
// transform-return keeps replacing the input (back-compat).
|
|
586
|
-
const v = runValidate(route.validate, arg);
|
|
587
|
-
if (!v.ok) {
|
|
588
|
-
if (v.thrown !== undefined) {
|
|
589
|
-
// A thrown validator (the classic `Schema.parse` style): keep the exact
|
|
590
|
-
// legacy REST shape, including a schema lib's structured `issues` array.
|
|
591
|
-
const msg = v.result.error || 'Invalid input';
|
|
592
|
-
const issues = v.thrown && typeof v.thrown === 'object' && 'issues' in v.thrown
|
|
593
|
-
? /** @type any */ (v.thrown).issues
|
|
594
|
-
: undefined;
|
|
595
|
-
return Response.json({ error: msg, issues }, { status: 400 });
|
|
596
|
-
}
|
|
597
|
-
const { status, ...payload } = v.result;
|
|
598
|
-
return Response.json(payload, { status });
|
|
599
|
-
}
|
|
600
|
-
arg = v.value;
|
|
567
|
+
async function getActionResponse(result, mod, args, req) {
|
|
568
|
+
const s = getSerializer();
|
|
569
|
+
const bodyStr = await s.serialize(result ?? null);
|
|
570
|
+
const cache = actionCache(mod);
|
|
571
|
+
const etag = `W/"${(await digestHex('SHA-256', bodyStr)).slice(0, 16)}"`;
|
|
572
|
+
const headers = new Headers({ 'content-type': s.contentType, etag });
|
|
573
|
+
headers.set('cache-control', cacheControlFor('GET', cache) || 'no-store');
|
|
574
|
+
if (cache) {
|
|
575
|
+
const tags = resolveTags(actionConfigFn(mod, 'tags'), args);
|
|
576
|
+
if (tags.length) headers.set('x-webjs-tags', tags.join(','));
|
|
601
577
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
if (typeof fn !== 'function') return new Response(`Unknown action ${route.fnName}`, { status: 404 });
|
|
605
|
-
try {
|
|
606
|
-
const result = await fn(arg, { req, params });
|
|
607
|
-
if (result instanceof Response) return result;
|
|
608
|
-
return Response.json(result ?? null);
|
|
609
|
-
} catch (e) {
|
|
610
|
-
if (typeof onError === 'function') onError(e);
|
|
611
|
-
return actionErrorResponse(e, idx.dev);
|
|
578
|
+
if (ifNoneMatchSatisfied(req.headers.get('if-none-match'), etag)) {
|
|
579
|
+
return new Response(null, { status: 304, headers });
|
|
612
580
|
}
|
|
581
|
+
return new Response(bodyStr, { status: 200, headers });
|
|
613
582
|
}
|
|
614
583
|
|
|
615
584
|
/**
|
|
@@ -636,20 +605,6 @@ function actionErrorResponse(err, dev) {
|
|
|
636
605
|
return rpcResponse({ error: msg }, { status: 500 });
|
|
637
606
|
}
|
|
638
607
|
|
|
639
|
-
/**
|
|
640
|
-
* Convert an `expose()` path like `/api/posts/:slug` to a regex + param list.
|
|
641
|
-
* Also accepts NextJs-style `[slug]` brackets for familiarity.
|
|
642
|
-
* @param {string} path
|
|
643
|
-
*/
|
|
644
|
-
function pathToPattern(path) {
|
|
645
|
-
const paramNames = [];
|
|
646
|
-
const re = path.replace(/:([A-Za-z_][A-Za-z0-9_]*)|\[([A-Za-z_][A-Za-z0-9_]*)\]/g, (_, a, b) => {
|
|
647
|
-
paramNames.push(a || b);
|
|
648
|
-
return '([^/]+)';
|
|
649
|
-
});
|
|
650
|
-
return { pattern: new RegExp(`^${re}/?$`), paramNames };
|
|
651
|
-
}
|
|
652
|
-
|
|
653
608
|
/**
|
|
654
609
|
* @param {string} file
|
|
655
610
|
* @param {boolean} dev
|