@webjsdev/server 0.8.24 → 0.8.25

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 CHANGED
@@ -844,3 +844,11 @@ export declare function GitHub(opts?: Record<string, unknown>): ProviderConfig;
844
844
  // ---------------------------------------------------------------------------
845
845
 
846
846
  export type { LayoutProps, PageProps, RouteHandlerContext };
847
+
848
+ /** The current action request AbortSignal (#492), or a never-aborting signal outside an action. */
849
+ export declare function actionSignal(): AbortSignal;
850
+ /** Run a function with a request AbortSignal available to actionSignal() (#492). */
851
+ export declare function runWithActionSignal<T>(signal: AbortSignal | undefined, fn: () => T): T;
852
+
853
+ /** The accumulated per-action middleware context (#490), or {} outside an action. */
854
+ export declare function actionContext(): Record<string, unknown>;
package/index.js CHANGED
@@ -48,6 +48,8 @@ export { cors, resolveOrigin, applyCorsHeaders } from './src/cors.js';
48
48
  export { memoryStore, redisStore, getStore, setStore } from './src/cache.js';
49
49
  export { cache } from './src/cache-fn.js';
50
50
  export { revalidateTag, revalidateTags } from './src/cache-tags.js';
51
+ export { actionSignal, runWithActionSignal } from './src/action-signal.js';
52
+ export { actionContext } from './src/action-middleware.js';
51
53
  export { revalidatePath, revalidateAll } from './src/html-cache.js';
52
54
  export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
53
55
  export { broadcast } from './src/broadcast.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.24",
3
+ "version": "0.8.25",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -0,0 +1,162 @@
1
+ /**
2
+ * HTTP-verb server actions (#488): read the per-action configuration declared as
3
+ * RESERVED sibling exports in a `'use server'` action file, and the helpers that
4
+ * turn that config into transport + caching behavior.
5
+ *
6
+ * The function stays a plain `export async function`; the framework reads these
7
+ * statically-named exports the same way a page declares `export const
8
+ * revalidate` / `metadata`:
9
+ *
10
+ * 'use server';
11
+ * export const method = 'GET'; // 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'; absent => POST
12
+ * export const cache = 60; // seconds, or { maxAge, swr, public }
13
+ * export const tags = (id) => [`user:${id}`]; // GET: tags the cached read
14
+ * export const invalidates = (id) => [...]; // mutation: tags to evict
15
+ * export const validate = (input) => ...; // boundary validator
16
+ * export async function getUser(id) { ... } // THE action (one per file)
17
+ *
18
+ * No folder automation: an action with no `method` export stays a POST, exactly
19
+ * as before this feature (additive, non-breaking).
20
+ */
21
+
22
+ /** The HTTP verbs an action may declare. */
23
+ export const RPC_VERBS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
24
+
25
+ /** Reserved config export names (special only in a `'use server'` file). */
26
+ export const RESERVED_CONFIG = new Set(['method', 'cache', 'tags', 'invalidates', 'validate', 'middleware']);
27
+
28
+ /** Verbs whose args ride the URL (so the read is cacheable / the URL is the key). */
29
+ export const URL_ARG_VERBS = new Set(['GET', 'DELETE']);
30
+
31
+ /** Verbs that are CSRF-exempt (safe, no state change). */
32
+ export const SAFE_VERBS = new Set(['GET']);
33
+
34
+ /**
35
+ * Max length of the URL-encoded args for a URL-arg verb before the client stub
36
+ * falls back to a POST with the args in the body (uncacheable). Conservative so
37
+ * the URL stays within common browser / CDN limits. TanStack Start uses 1MB for
38
+ * its server-side GET cap; the browser/CDN URL ceiling is the tighter bound.
39
+ */
40
+ export const MAX_URL_ARGS = 4096;
41
+
42
+ /**
43
+ * The exported function names that are ACTIONS (callable), i.e. every function
44
+ * export whose name is not a reserved config key. With one-function-per-file
45
+ * there is exactly one; the framework still returns the list so a misconfigured
46
+ * multi-function file is detectable (a `webjs check` rule).
47
+ * @param {Record<string, unknown>} mod
48
+ * @returns {string[]}
49
+ */
50
+ export function actionFunctionNames(mod) {
51
+ const names = Object.keys(mod).filter(
52
+ (k) => typeof mod[k] === 'function' && !RESERVED_CONFIG.has(k),
53
+ );
54
+ if (typeof mod.default === 'function' && !names.includes('default')) names.push('default');
55
+ return names;
56
+ }
57
+
58
+ /**
59
+ * The action's HTTP method: the declared `method` export uppercased and
60
+ * validated against {@link RPC_VERBS}, defaulting to POST (an unknown value
61
+ * falls back to POST rather than throwing, the #232 fail-safe posture).
62
+ * @param {Record<string, unknown>} mod
63
+ * @returns {string}
64
+ */
65
+ export function actionMethod(mod) {
66
+ const raw = mod && mod.method;
67
+ if (typeof raw !== 'string') return 'POST';
68
+ const m = raw.trim().toUpperCase();
69
+ return RPC_VERBS.has(m) ? m : 'POST';
70
+ }
71
+
72
+ /**
73
+ * Normalize the `cache` export to `{ maxAge, swr, public }` or null (no
74
+ * caching). A number is `maxAge` seconds; an object carries `maxAge` / `swr`
75
+ * (stale-while-revalidate) / `public`. Default `private` (a read is per-user
76
+ * safe unless the author opts into shared caching with `public: true`).
77
+ * @param {Record<string, unknown>} mod
78
+ * @returns {{ maxAge: number, swr: number, public: boolean } | null}
79
+ */
80
+ export function actionCache(mod) {
81
+ const raw = mod && mod.cache;
82
+ if (raw == null) return null;
83
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
84
+ return { maxAge: Math.floor(raw), swr: 0, public: false };
85
+ }
86
+ if (typeof raw === 'object') {
87
+ const maxAge = Number.isFinite(raw.maxAge) && raw.maxAge >= 0 ? Math.floor(raw.maxAge) : 0;
88
+ const swr = Number.isFinite(raw.swr) && raw.swr >= 0 ? Math.floor(raw.swr) : 0;
89
+ return { maxAge, swr, public: raw.public === true };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * The action's per-action middleware chain (#490): the `middleware` export, an
96
+ * array of `(ctx, next) => result` functions, or [] when absent / malformed.
97
+ * @param {Record<string, unknown>} mod
98
+ * @returns {Function[]}
99
+ */
100
+ export function actionMiddleware(mod) {
101
+ const raw = mod && mod.middleware;
102
+ if (!Array.isArray(raw)) return [];
103
+ return raw.filter((m) => typeof m === 'function');
104
+ }
105
+
106
+ /**
107
+ * Resolve the `tags` / `invalidates` / `validate` config functions off the
108
+ * module (each is a function or absent).
109
+ * @param {Record<string, unknown>} mod
110
+ * @param {'tags'|'invalidates'|'validate'} name
111
+ * @returns {Function | null}
112
+ */
113
+ export function actionConfigFn(mod, name) {
114
+ return mod && typeof mod[name] === 'function' ? mod[name] : null;
115
+ }
116
+
117
+ /**
118
+ * Compute the tag list for a `tags` / `invalidates` config given the call args.
119
+ * A function is invoked with the args (so `(id) => [`user:${id}`]` works); an
120
+ * array is used verbatim. Never throws into the caller; a bad tag fn yields [].
121
+ * @param {Function | unknown[] | null} tagsConfig
122
+ * @param {unknown[]} args
123
+ * @returns {string[]}
124
+ */
125
+ export function resolveTags(tagsConfig, args) {
126
+ try {
127
+ const out = typeof tagsConfig === 'function' ? tagsConfig(...args) : tagsConfig;
128
+ if (!Array.isArray(out)) return [];
129
+ return out.filter((t) => typeof t === 'string' && t.length > 0);
130
+ } catch {
131
+ return [];
132
+ }
133
+ }
134
+
135
+ /**
136
+ * The `Cache-Control` value for a GET action response, or null (caller emits
137
+ * `no-store`). A cached GET is `public|private, max-age=N[, stale-while-
138
+ * revalidate=M]`; a GET with no `cache` config is uncached (null -> no-store).
139
+ * @param {string} method
140
+ * @param {{ maxAge: number, swr: number, public: boolean } | null} cache
141
+ * @returns {string | null}
142
+ */
143
+ export function cacheControlFor(method, cache) {
144
+ if (method !== 'GET' || !cache) return null;
145
+ const scope = cache.public ? 'public' : 'private';
146
+ let h = `${scope}, max-age=${cache.maxAge}`;
147
+ if (cache.swr > 0) h += `, stale-while-revalidate=${cache.swr}`;
148
+ return h;
149
+ }
150
+
151
+ /**
152
+ * The set of request methods the action endpoint accepts for an action whose
153
+ * declared method is `method`. A URL-arg verb (GET/DELETE) ALSO accepts POST as
154
+ * the too-large-args fallback; a body verb accepts only itself.
155
+ * @param {string} method
156
+ * @returns {Set<string>}
157
+ */
158
+ export function allowedRequestMethods(method) {
159
+ const s = new Set([method]);
160
+ if (URL_ARG_VERBS.has(method)) s.add('POST');
161
+ return s;
162
+ }
@@ -0,0 +1,21 @@
1
+ /** Per-action middleware (#490). */
2
+
3
+ /** The context object middleware accumulate and the action reads via actionContext(). */
4
+ export interface ActionContext { [key: string]: unknown }
5
+
6
+ /** A middleware's ctx: the request, the action args, the abort signal, and the shared context. */
7
+ export interface ActionMiddlewareCtx {
8
+ request?: Request;
9
+ args?: unknown[];
10
+ signal?: AbortSignal;
11
+ context: ActionContext;
12
+ }
13
+
14
+ /** A per-action middleware: run `next()` to proceed, or return a value to short-circuit. */
15
+ export type ActionMiddleware = (ctx: ActionMiddlewareCtx, next: () => Promise<unknown>) => unknown;
16
+
17
+ /** The accumulated middleware context for the current action, or {} outside one. */
18
+ export declare function actionContext(): ActionContext;
19
+
20
+ /** Run an action through its middleware chain (framework-internal). */
21
+ export declare function runActionChain(middleware: ActionMiddleware[], baseCtx: Partial<ActionMiddlewareCtx>, finalFn: () => unknown): Promise<unknown>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Per-action middleware (#490). An action file declares `export const middleware
3
+ * = [mw1, mw2]` (a reserved config export). Each middleware is
4
+ * `async (ctx, next) => result`, run around the action on BOTH the RPC endpoint
5
+ * and the `route.ts`/`expose()` boundary, like `validate`.
6
+ *
7
+ * - `ctx` carries `{ request, args, signal, context }`. `context` is a shared
8
+ * mutable object the chain accumulates; the action reads it via
9
+ * `actionContext()` (no signature change, the same plumbing as
10
+ * `actionSignal()`).
11
+ * - `next()` runs the next middleware, ending at the action; its return value
12
+ * flows back up. A middleware SHORT-CIRCUITS by returning a value (an
13
+ * `ActionResult` envelope, e.g. `{ success: false, status: 401 }`) WITHOUT
14
+ * calling `next()`, so the action never runs. A short-circuit is NOT a
15
+ * completion: a GET short-circuit is served `no-store` (never cached) and a
16
+ * mutation short-circuit does not run `invalidates`. On the RPC boundary the
17
+ * short-circuit must be an `ActionResult` (serialized as the result, the
18
+ * `status` riding inside per the #245 contract); on the `route.ts` / REST
19
+ * boundary it may also be a `Response`, and an `ActionResult`'s `status`
20
+ * maps to the HTTP status.
21
+ *
22
+ * The framework ships no middleware; it only runs the chain. Server-only.
23
+ */
24
+ import { AsyncLocalStorage } from 'node:async_hooks';
25
+
26
+ /** Per-request accumulated context, readable by the action via actionContext(). */
27
+ const als = new AsyncLocalStorage();
28
+
29
+ /** Shared empty context returned outside an action (a direct server-to-server call). */
30
+ const EMPTY = Object.freeze({});
31
+
32
+ /**
33
+ * The accumulated middleware context for the current action, or a frozen empty
34
+ * object outside an action. The action reads `actionContext().user` etc.
35
+ * @returns {Record<string, unknown>}
36
+ */
37
+ export function actionContext() {
38
+ return als.getStore() || EMPTY;
39
+ }
40
+
41
+ /**
42
+ * Run `finalFn` (the action) through the middleware chain, with a fresh shared
43
+ * `context` available to `actionContext()` for the whole chain. A middleware
44
+ * that does not call `next()` short-circuits (its return is the result). Guards
45
+ * against a middleware calling `next()` more than once.
46
+ * @param {Function[]} middleware
47
+ * @param {{ request?: Request, args?: unknown[], signal?: AbortSignal }} baseCtx
48
+ * @param {() => unknown} finalFn
49
+ * @returns {Promise<unknown>}
50
+ */
51
+ export function runActionChain(middleware, baseCtx, finalFn) {
52
+ if (!middleware || middleware.length === 0) return finalFn();
53
+ const context = {};
54
+ const ctx = { ...baseCtx, context };
55
+ return als.run(context, () => {
56
+ let lastCalled = -1;
57
+ const dispatch = (i) => {
58
+ if (i <= lastCalled) {
59
+ return Promise.reject(new Error('next() called multiple times in a webjs action middleware'));
60
+ }
61
+ lastCalled = i;
62
+ const mw = middleware[i];
63
+ if (!mw) return finalFn();
64
+ return mw(ctx, () => dispatch(i + 1));
65
+ };
66
+ return dispatch(0);
67
+ });
68
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Server-side action AbortSignal (#492). The RPC endpoint runs an action inside
3
+ * `runWithActionSignal(req.signal, fn)`, so the action can read the request's
4
+ * AbortSignal via `actionSignal()` and stop expensive work (a DB query, an
5
+ * upstream fetch) when the client disconnects or aborts the call.
6
+ *
7
+ * Isomorphic-friendly: `actionSignal()` returns a never-aborting signal when
8
+ * called outside an action (e.g. a direct server-to-server call), so the same
9
+ * `fetch(url, { signal: actionSignal() })` line is safe everywhere.
10
+ */
11
+ import { AsyncLocalStorage } from 'node:async_hooks';
12
+
13
+ const als = new AsyncLocalStorage();
14
+
15
+ /** A shared signal that never aborts, returned outside an action. */
16
+ const NEVER = (() => {
17
+ const c = new AbortController();
18
+ return c.signal;
19
+ })();
20
+
21
+ /**
22
+ * Run `fn` with `signal` available to `actionSignal()` for its async extent.
23
+ * @template T
24
+ * @param {AbortSignal | undefined} signal
25
+ * @param {() => T} fn
26
+ * @returns {T}
27
+ */
28
+ export function runWithActionSignal(signal, fn) {
29
+ return als.run(signal || NEVER, fn);
30
+ }
31
+
32
+ /**
33
+ * The current action's request AbortSignal (fires on client disconnect / abort),
34
+ * or a never-aborting signal outside an action.
35
+ * @returns {AbortSignal}
36
+ */
37
+ export function actionSignal() {
38
+ return als.getStore() || NEVER;
39
+ }
package/src/actions.js CHANGED
@@ -8,6 +8,15 @@ import { verify as verifyCsrf, CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
8
8
  import { getSerializer } from './serializer.js';
9
9
  import { resolveOrigin } from './cors.js';
10
10
  import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
11
+ import {
12
+ actionMethod, actionFunctionNames, actionCache, actionConfigFn, resolveTags,
13
+ cacheControlFor, allowedRequestMethods, URL_ARG_VERBS, SAFE_VERBS, MAX_URL_ARGS,
14
+ RESERVED_CONFIG, actionMiddleware,
15
+ } from './action-config.js';
16
+ import { revalidateTags } from './cache-tags.js';
17
+ import { runWithActionSignal } from './action-signal.js';
18
+ import { runActionChain } from './action-middleware.js';
19
+ import { ifNoneMatchSatisfied } from './conditional-get.js';
11
20
  import { getBodyLimits } from './context.js';
12
21
  import { basePath } from './importmap.js';
13
22
  import { withBasePath } from './base-path.js';
@@ -327,66 +336,116 @@ throw new Error(${JSON.stringify(msg)});
327
336
  export async function serveActionStub(idx, absFile) {
328
337
  const mod = await loadModule(absFile, idx.dev);
329
338
  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
- }
339
+ // HTTP-verb actions (#488): the file declares its verb via `export const
340
+ // method` (default POST). The function exports are the actions; the reserved
341
+ // config exports (method/cache/tags/invalidates/validate/middleware) are excluded.
342
+ const fnNames = actionFunctionNames(mod);
343
+ const method = actionMethod(mod);
334
344
  // The RPC endpoint is a framework-emitted same-origin URL, so it must
335
345
  // carry the basePath prefix under a sub-path deploy (#256), exactly like
336
346
  // the importmap targets and the boot module specifiers. Without this the
337
347
  // stub would POST to a bare /__webjs/action/... that the ingress strip
338
348
  // 404s, breaking every server action when webjs.basePath is set.
339
349
  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';
350
+ const body = buildStubBody({ hash, method, fnNames, actionUrl });
387
351
  return body;
388
352
  }
389
353
 
354
+ /**
355
+ * Build the generated client stub source for a `'use server'` action file. The
356
+ * transport depends on the file's declared `method`:
357
+ * - GET: args ride the URL (`?a=`), the SSR seed (#472) is read first, the
358
+ * browser HTTP cache is consulted, and a tag-invalidated key revalidates;
359
+ * CSRF-exempt (a safe read). Over-large args fall back to a POST.
360
+ * - DELETE: args ride the URL, CSRF-protected, over-large args fall back to a
361
+ * POST.
362
+ * - POST / PUT / PATCH: rich body, CSRF-protected.
363
+ * Every path reads `X-Webjs-Invalidate` (mark tags stale) and a GET reads
364
+ * `X-Webjs-Tags` (register the key's tags), so a later read sees a mutation.
365
+ * @param {{ hash: string, method: string, fnNames: string[], actionUrl: string }} opts
366
+ * @returns {string}
367
+ */
368
+ function buildStubBody({ hash, method, fnNames, actionUrl }) {
369
+ const URL_ARG = URL_ARG_VERBS.has(method);
370
+ const SAFE = SAFE_VERBS.has(method);
371
+ const J = JSON.stringify;
372
+ const lines = [];
373
+ lines.push(`// webjs: generated server-action stub (${method})`);
374
+ // Every verb reads the SSR seed (#472) first: an async-render READ invoked
375
+ // during SSR is seeded regardless of its declared verb (a read with no
376
+ // `method` is a default POST), so the first client call resolves from the
377
+ // seed with no hydration round-trip. The browser-cache staleness check is
378
+ // GET-only (only a GET is browser-cached).
379
+ const imports = ['stringify as __s', 'parse as __p', 'takeSeed as __seedTake', 'SEED_MISS as __MISS', 'markStale as __markStale', 'parseTagHeader as __tagHdr', 'activeActionSignal as __sig'];
380
+ if (method === 'GET') {
381
+ imports.push('registerKeyTags as __regTags', 'consumeStale as __stale', 'fetchMark as __mark');
382
+ }
383
+ lines.push(`import { ${imports.join(', ')} } from '@webjsdev/core';`);
384
+ lines.push(`const __URL = ${J(actionUrl)};`);
385
+ lines.push(`const __HASH = ${J(hash)};`);
386
+ lines.push(`const __METHOD = ${J(method)};`);
387
+ lines.push(`const __MAX = ${MAX_URL_ARGS};`);
388
+ lines.push(`const __CT = ${J(RPC_CONTENT_TYPE)};`);
389
+ lines.push(`function __csrf() { const m = document.cookie.match(/(?:^|;\\s*)${CSRF_COOKIE}=([^;]+)/); return m ? decodeURIComponent(m[1]) : ''; }`);
390
+ // Shared: parse a response, surface invalidation, register a GET's tags
391
+ // (stamped with the clock SAMPLED BEFORE the fetch, so a mutation in flight is
392
+ // caught on the next read).
393
+ lines.push(`async function __handle(res, fn, key, since) {`);
394
+ lines.push(` const inv = __tagHdr(res.headers.get('x-webjs-invalidate')); if (inv.length) __markStale(inv);`);
395
+ lines.push(` if (key != null) { const t = __tagHdr(res.headers.get('x-webjs-tags')); if (t.length) __regTags(key, t, since); }`);
396
+ lines.push(` const ct = res.headers.get('content-type') || '';`);
397
+ lines.push(` const text = await res.text();`);
398
+ lines.push(` const parsed = ct.includes(__CT) ? __p(text) : (ct.includes('application/json') ? JSON.parse(text) : text);`);
399
+ lines.push(` if (!res.ok) throw new Error((parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status));`);
400
+ lines.push(` return parsed;`);
401
+ lines.push(`}`);
402
+ // Body sender (POST/PUT/PATCH, and the URL-arg too-large fallback). `sig` is
403
+ // captured SYNCHRONOUSLY at __call entry (#492): the active render signal must
404
+ // be read before the stub's first await (stringify), or the render's
405
+ // synchronous window has already closed and cleared it.
406
+ lines.push(`async function __body(fn, body, m, csrf, sig) {`);
407
+ lines.push(` const headers = { 'content-type': __CT }; if (csrf) headers[${J(CSRF_HEADER)}] = __csrf();`);
408
+ lines.push(` const res = await fetch(__URL + fn, { method: m, headers, credentials: 'same-origin', body, signal: sig });`);
409
+ lines.push(` return __handle(res, fn, null);`);
410
+ lines.push(`}`);
411
+ if (URL_ARG) {
412
+ // GET/DELETE: args in the URL, with a POST fallback when too large.
413
+ lines.push(`async function __call(fn, args) {`);
414
+ lines.push(` const sig = __sig();`); // sync capture before any await
415
+ lines.push(` const key = await __s(args);`);
416
+ lines.push(` const seeded = __seedTake(__HASH, fn, key); if (seeded !== __MISS) return seeded;`);
417
+ lines.push(` if (key.length > __MAX) return __body(fn, key, 'POST', ${SAFE ? 'false' : 'true'}, sig);`);
418
+ if (method === 'GET') {
419
+ lines.push(` const bypass = __stale(key);`);
420
+ lines.push(` const since = __mark();`);
421
+ lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: 'GET', credentials: 'same-origin', cache: bypass ? 'no-cache' : 'default', signal: sig });`);
422
+ lines.push(` return __handle(res, fn, key, since);`);
423
+ } else {
424
+ lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: __METHOD, headers: { ${J(CSRF_HEADER)}: __csrf() }, credentials: 'same-origin', signal: sig });`);
425
+ lines.push(` return __handle(res, fn, null);`);
426
+ }
427
+ lines.push(`}`);
428
+ } else {
429
+ // POST/PUT/PATCH: rich body. The seed read makes a default-POST async-render
430
+ // read resolve from the SSR seed on hydration (#472); a true mutation is
431
+ // never SSR-invoked, so the seed simply misses.
432
+ lines.push(`async function __call(fn, args) {`);
433
+ lines.push(` const sig = __sig();`); // sync capture before any await
434
+ lines.push(` const key = await __s(args);`);
435
+ lines.push(` const seeded = __seedTake(__HASH, fn, key); if (seeded !== __MISS) return seeded;`);
436
+ lines.push(` return __body(fn, key, __METHOD, true, sig);`);
437
+ lines.push(`}`);
438
+ }
439
+ for (const name of fnNames) {
440
+ lines.push(
441
+ name === 'default'
442
+ ? `export default (...args) => __call('default', args);`
443
+ : `export const ${name} = (...args) => __call(${J(name)}, args);`,
444
+ );
445
+ }
446
+ return lines.join('\n') + '\n';
447
+ }
448
+
390
449
  /**
391
450
  * Invoke a server action via the internal RPC wire format.
392
451
  * @param {ActionIndex} idx
@@ -399,34 +458,62 @@ export async function serveActionStub(idx, absFile) {
399
458
  * it so a throwing sink can never affect the response.
400
459
  */
401
460
  export async function invokeAction(idx, hash, fnName, req, onError) {
402
- if (!verifyCsrf(req)) {
403
- return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
404
- }
405
461
  const file = idx.hashToFile.get(hash);
406
462
  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();
463
+ const mod = await loadModule(file, idx.dev);
464
+ const fn = fnName === 'default' ? mod.default : mod[fnName];
465
+ // A reserved config export (method/cache/tags/invalidates/validate/middleware) is never
466
+ // a callable action even though some are functions (#488).
467
+ if (typeof fn !== 'function' || RESERVED_CONFIG.has(fnName)) {
468
+ return rpcResponse({ error: `Unknown action ${fnName}` }, { status: 404 });
469
+ }
470
+
471
+ // HTTP-verb dispatch (#488). The action declares its method (default POST). A
472
+ // URL-arg verb (GET/DELETE) also accepts a POST fallback for over-large args.
473
+ const method = actionMethod(mod);
474
+ const reqMethod = req.method.toUpperCase();
475
+ const allowed = allowedRequestMethods(method);
476
+ if (!allowed.has(reqMethod)) {
477
+ return rpcResponse(
478
+ { error: `expected ${method} for ${fnName}, got ${reqMethod}` },
479
+ { status: 405, headers: { allow: [...allowed].join(', ') } },
480
+ );
481
+ }
482
+ // CSRF: required for every verb except a safe GET (a read with no state
483
+ // change, which is also browser-cacheable so it cannot carry a fresh token).
484
+ if (!SAFE_VERBS.has(method) && !verifyCsrf(req)) {
485
+ return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
486
+ }
487
+
488
+ // Args ride the URL for a URL-arg request (GET / DELETE), else the body.
412
489
  let args = [];
490
+ const fromUrl = reqMethod === 'GET' || reqMethod === 'DELETE';
413
491
  try {
414
- args = body ? getSerializer().deserialize(body) : [];
492
+ if (fromUrl) {
493
+ const a = new URL(req.url).searchParams.get('a');
494
+ args = a ? getSerializer().deserialize(a) : [];
495
+ } else {
496
+ const { tooLarge, text: body } = await readTextBounded(req, jsonBodyLimit());
497
+ if (tooLarge) return payloadTooLarge();
498
+ args = body ? getSerializer().deserialize(body) : [];
499
+ }
415
500
  if (!Array.isArray(args)) args = [args];
416
501
  } catch {
417
502
  return rpcResponse({ error: 'Invalid request body' }, { status: 400 });
418
503
  }
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
504
  // Input validation (#245): a validator attached via `validateInput(fn, ...)`
423
505
  // or `expose(spec, fn, { validate })` runs SERVER-SIDE before the body on
424
506
  // this RPC path too, not just the REST path. The RPC stub sends an args
425
507
  // array; an action conventionally takes one input object, so validate the
426
508
  // FIRST arg (matching how the REST path validates its single merged object).
509
+ // Validation is a BOUNDARY concern (#488): the validator is the `validate`
510
+ // config export (the new model), or the legacy `expose`/`validateInput`
511
+ // attachment. It runs on this RPC boundary on the FIRST arg (the conventional
512
+ // single input object), never on a direct server-to-server call.
427
513
  const attached = getExposed(fn);
428
- if (attached && typeof attached.validate === 'function') {
429
- const v = runValidate(attached.validate, args[0]);
514
+ const validate = actionConfigFn(mod, 'validate') || (attached && attached.validate);
515
+ if (typeof validate === 'function') {
516
+ const v = runValidate(validate, args[0]);
430
517
  if (!v.ok) {
431
518
  if (v.thrown !== undefined) {
432
519
  // A THROWN validator behaves like a thrown action: a sanitized error
@@ -444,14 +531,67 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
444
531
  args = [v.value, ...args.slice(1)];
445
532
  }
446
533
  try {
447
- const result = await fn(...args);
448
- return rpcResponse(result ?? null);
534
+ // Run inside the request's AbortSignal scope (#492) and the per-action
535
+ // middleware chain (#490): the chain wraps the action, can short-circuit
536
+ // (an auth middleware returning an ActionResult), and accumulates context
537
+ // the action reads via actionContext(). `ranAction` distinguishes a real
538
+ // action completion from a middleware short-circuit (the action never ran).
539
+ const middleware = actionMiddleware(mod);
540
+ let ranAction = false;
541
+ const result = await runWithActionSignal(req.signal, () =>
542
+ runActionChain(middleware, { request: req, args, signal: req.signal }, () => { ranAction = true; return fn(...args); }));
543
+ if (method === 'GET') {
544
+ // A short-circuit (the action did not run, e.g. an auth denial) is NEVER
545
+ // cached: serve the envelope no-store so a denial is not stored or shared.
546
+ if (!ranAction) return rpcResponse(result ?? null, { headers: { 'cache-control': 'no-store' } });
547
+ return await getActionResponse(result, mod, args, req);
548
+ }
549
+ // A mutation (POST/PUT/PATCH/DELETE): only a COMPLETED action evicts its
550
+ // `invalidates` tags; a short-circuit (the action never ran) does not.
551
+ const headers = {};
552
+ if (ranAction) {
553
+ const inv = resolveTags(actionConfigFn(mod, 'invalidates'), args);
554
+ if (inv.length) {
555
+ await revalidateTags(inv);
556
+ headers['x-webjs-invalidate'] = inv.join(',');
557
+ }
558
+ }
559
+ return rpcResponse(result ?? null, { headers });
449
560
  } catch (e) {
450
561
  if (typeof onError === 'function') onError(e);
451
562
  return actionErrorResponse(e, idx.dev);
452
563
  }
453
564
  }
454
565
 
566
+ /**
567
+ * Build a GET action's response (#488): the serialized body plus a weak ETag
568
+ * (content hash), the `Cache-Control` from the `cache` config (else `no-store`),
569
+ * and `X-Webjs-Tags`. Answers `If-None-Match` with a 304 itself rather than
570
+ * relying on the conditional-GET funnel, which EXCLUDES `private` responses (the
571
+ * default here); a per-user browser cache may still revalidate to a 304.
572
+ * @param {unknown} result
573
+ * @param {Record<string, unknown>} mod
574
+ * @param {unknown[]} args
575
+ * @param {Request} req
576
+ * @returns {Promise<Response>}
577
+ */
578
+ async function getActionResponse(result, mod, args, req) {
579
+ const s = getSerializer();
580
+ const bodyStr = await s.serialize(result ?? null);
581
+ const cache = actionCache(mod);
582
+ const etag = `W/"${(await digestHex('SHA-256', bodyStr)).slice(0, 16)}"`;
583
+ const headers = new Headers({ 'content-type': s.contentType, etag });
584
+ headers.set('cache-control', cacheControlFor('GET', cache) || 'no-store');
585
+ if (cache) {
586
+ const tags = resolveTags(actionConfigFn(mod, 'tags'), args);
587
+ if (tags.length) headers.set('x-webjs-tags', tags.join(','));
588
+ }
589
+ if (ifNoneMatchSatisfied(req.headers.get('if-none-match'), etag)) {
590
+ return new Response(null, { status: 304, headers });
591
+ }
592
+ return new Response(bodyStr, { status: 200, headers });
593
+ }
594
+
455
595
  /**
456
596
  * Match an incoming request against an expose()d action route.
457
597
  * Returns the single matched route+params for normal methods.
@@ -603,8 +743,21 @@ export async function invokeExposedAction(idx, route, params, req, onError) {
603
743
  const fn = mod[route.fnName];
604
744
  if (typeof fn !== 'function') return new Response(`Unknown action ${route.fnName}`, { status: 404 });
605
745
  try {
606
- const result = await fn(arg, { req, params });
746
+ // The REST boundary wires the request signal (#492) and the per-action
747
+ // middleware chain (#490) too, symmetric with the RPC path. `ranAction`
748
+ // marks whether the action ran (vs a middleware short-circuit).
749
+ const middleware = actionMiddleware(mod);
750
+ let ranAction = false;
751
+ const result = await runWithActionSignal(req.signal, () =>
752
+ runActionChain(middleware, { request: req, args: [arg], signal: req.signal }, () => { ranAction = true; return fn(arg, { req, params }); }));
607
753
  if (result instanceof Response) return result;
754
+ // A middleware short-circuit on the REST boundary maps the envelope's
755
+ // `status` to the HTTP status (like the validate failure above), so a
756
+ // non-webjs REST client sees the real status, not a 200 with it in the body.
757
+ if (!ranAction && result && typeof result === 'object' && typeof result.status === 'number') {
758
+ const { status, ...payload } = result;
759
+ return Response.json(payload, { status });
760
+ }
608
761
  return Response.json(result ?? null);
609
762
  } catch (e) {
610
763
  if (typeof onError === 'function') onError(e);
package/src/check.js CHANGED
@@ -10,6 +10,7 @@ import { buildModuleGraph, transitiveDeps } from './module-graph.js';
10
10
  import { scanComponents } from './component-scanner.js';
11
11
  import { buildRouteTable } from './router.js';
12
12
  import { analyzeElision } from './component-elision.js';
13
+ import { RESERVED_CONFIG } from './action-config.js';
13
14
 
14
15
  /**
15
16
  * Convention validator for webjs apps.
@@ -107,6 +108,11 @@ export const RULES = [
107
108
  description:
108
109
  'A page / layout / component module that SHIPS to the browser (the build does NOT elide it) must not transitively import a server-only `.server.{ts,js}` module. The server module is replaced by a stub in the browser, so the import is fine while the page is display-only and elided (its server import is stripped), but the moment the page also does client work (it imports a component to register, enables the client router, uses a reactive primitive, …) it stops being elided and must load in the browser, dragging the server import with it. The stub then throws (or a server-only export like `auth` is missing) the instant the module loads, a runtime browser crash that `webjs typecheck` and the rest of `webjs check` miss. The rule reuses the build\'s own elision verdict, so it ONLY fires on modules that genuinely ship; a display-only page the framework elides is never flagged. The fix is to keep the server call off the browser-shipped module: gate the route in `middleware.ts`, call the server through a `\'use server\'` action, or register the component in a layout so the page elides again. Server-to-server imports (`.server.ts` importing `.server.ts`) and `middleware.ts` / `route.ts` (never shipped) are never flagged.',
109
110
  },
111
+ {
112
+ name: 'one-action-per-configured-file',
113
+ description:
114
+ 'A `\'use server\'` action file that declares HTTP-verb config (any of `method` / `cache` / `tags` / `invalidates` / `validate`, #488) must export exactly ONE callable action function. The config is file-level (it applies to the action in the file), so a second exported function would silently inherit the same verb / cache, which is almost never intended and makes the contract ambiguous. Move the extra function to its own `.server.ts` file (the one-function-per-file convention), or, if it is a private helper, do not export it. Files with no verb config are unaffected.',
115
+ },
110
116
  {
111
117
  name: 'no-scaffold-placeholder',
112
118
  description:
@@ -727,6 +733,37 @@ export async function checkConventions(appDir) {
727
733
  }
728
734
  }
729
735
 
736
+ // --- Rule: one-action-per-configured-file (#488) ---
737
+ // A `'use server'` file that declares HTTP-verb config (method/cache/tags/
738
+ // invalidates/validate) must export exactly one callable action; the config
739
+ // is file-level, so a second exported function would silently inherit it.
740
+ {
741
+ for (const { rel, content, scan } of files) {
742
+ if (!/\.server\.m?[jt]s$/.test(rel)) continue;
743
+ if (!hasUseServerDirective(content)) continue;
744
+ if (!/\bexport\s+const\s+(?:method|cache|tags|invalidates|validate|middleware)\b/.test(scan)) continue;
745
+ const names = new Set();
746
+ let m;
747
+ const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
748
+ while ((m = reFn.exec(scan))) names.add(m[1]);
749
+ // An arrow-const action: `export const x = (...) => ...`, the paren-less
750
+ // `export const x = id => ...`, or a function expression. The `=>` /
751
+ // `function` anchor keeps a plain `export const N = 5` from counting.
752
+ const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
753
+ while ((m = reArrow.exec(scan))) names.add(m[1]);
754
+ if (/\bexport\s+default\b/.test(scan)) names.add('default');
755
+ const actions = [...names].filter((n) => !RESERVED_CONFIG.has(n));
756
+ if (actions.length > 1) {
757
+ violations.push({
758
+ rule: 'one-action-per-configured-file',
759
+ file: rel,
760
+ message: `Configured action file exports ${actions.length} callable functions (${actions.join(', ')}); the verb/cache config is file-level, so only one action per file is allowed.`,
761
+ fix: 'Move the extra function to its own .server.{js,ts} file, or keep it private (do not export it).',
762
+ });
763
+ }
764
+ }
765
+ }
766
+
730
767
  // --- Rule: tag-name-has-hyphen ---
731
768
  {
732
769
  for (const { rel, scan } of files) {
package/src/dev.js CHANGED
@@ -1695,7 +1695,12 @@ async function handleCore(req, ctx) {
1695
1695
  // Internal server-action RPC endpoint
1696
1696
  const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
1697
1697
  if (actMatch) {
1698
- if (method !== 'POST') return new Response('POST only', { status: 405 });
1698
+ // HTTP-verb actions (#488): any RPC verb may hit the endpoint; invokeAction
1699
+ // enforces the action's DECLARED method (405 + Allow on a mismatch) and
1700
+ // reads args from the URL (GET/DELETE) or the body (POST/PUT/PATCH).
1701
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
1702
+ return new Response('method not allowed', { status: 405 });
1703
+ }
1699
1704
  // Pass the onError sink (issue #239): a server action that throws
1700
1705
  // unexpectedly is reported to the APM hook before the sanitized 500.
1701
1706
  const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;