@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/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, relative, sep } from 'node:path';
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 `expose()` REST path (`invokeExposedAction`) so
39
- * the contract is identical across transports (#245). The framework only CALLS
40
- * the validator and shapes the result; it ships no validation library.
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; only `expose()` files load.
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 POST endpoints at /__webjs/action/:hash/:fn for
126
- * RPC-callable actions only.
127
- * 4. If an exported function was wrapped in `expose('METHOD /path', fn)`,
128
- * also registers it as a first-class REST endpoint.
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, opts = {}) {
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. The one thing that DOES need eager loading is expose(),
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, httpRoutes, appDir, dev };
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
- const fnNames = Object.keys(mod).filter((k) => typeof mod[k] === 'function');
331
- if (typeof mod.default === 'function' && !fnNames.includes('default')) {
332
- fnNames.push('default');
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 = `// webjs: generated server-action stub for ${relative(idx.appDir, absFile)}\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` +
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
- // Bounded read (issue #237): reject an over-limit RPC body with 413 without
408
- // buffering it whole (Content-Length fast-reject, plus a streaming cap for a
409
- // chunked body with no declared length).
410
- const { tooLarge, text: body } = await readTextBounded(req, jsonBodyLimit());
411
- if (tooLarge) return payloadTooLarge();
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
- args = body ? getSerializer().deserialize(body) : [];
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
- const mod = await loadModule(file, idx.dev);
420
- const fn = fnName === 'default' ? mod.default : mod[fnName];
421
- if (typeof fn !== 'function') return rpcResponse({ error: `Unknown action ${fnName}` }, { status: 404 });
422
- // Input validation (#245): a validator attached via `validateInput(fn, ...)`
423
- // or `expose(spec, fn, { validate })` runs SERVER-SIDE before the body on
424
- // this RPC path too, not just the REST path. The RPC stub sends an args
425
- // array; an action conventionally takes one input object, so validate the
426
- // FIRST arg (matching how the REST path validates its single merged object).
427
- const attached = getExposed(fn);
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
- const result = await fn(...args);
448
- return rpcResponse(result ?? null);
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
- * Match an incoming request against an expose()d action route.
457
- * Returns the single matched route+params for normal methods.
458
- * @param {ActionIndex} idx
459
- * @param {string} method
460
- * @param {string} pathname
461
- */
462
- export function matchExposedAction(idx, method, pathname) {
463
- for (const r of idx.httpRoutes) {
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
- * @param {(error: unknown) => void} [onError] best-effort sink (issue #239)
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
- export async function invokeExposedAction(idx, route, params, req, onError) {
562
- const url = new URL(req.url);
563
- const query = Object.fromEntries(url.searchParams.entries());
564
- let body = {};
565
- if (req.method !== 'GET' && req.method !== 'HEAD') {
566
- // Bounded read (issue #237): an over-limit body is a 413 before any parse.
567
- const { tooLarge, text } = await readTextBounded(req, jsonBodyLimit());
568
- if (tooLarge) return payloadTooLarge();
569
- if (text) {
570
- try {
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
- const mod = await loadModule(route.file, idx.dev);
603
- const fn = mod[route.fnName];
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