@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 CHANGED
@@ -257,8 +257,8 @@ export declare function handleApi(
257
257
  // actions.js (server-action scanner + RPC endpoint)
258
258
  // ---------------------------------------------------------------------------
259
259
 
260
- /** Scan the app for `.server.{js,ts}` files and build the RPC + expose index. */
261
- export declare function buildActionIndex(appDir: string, dev: boolean, opts?: { skipExposeLoad?: boolean }): Promise<unknown>;
260
+ /** Scan the app for `.server.{js,ts}` files and build the pure file -> hash RPC index. */
261
+ export declare function buildActionIndex(appDir: string, dev: boolean): Promise<unknown>;
262
262
  /** Whether a file path is a `.server.{js,ts,mjs,mts}` server file. */
263
263
  export declare function isServerFile(file: string): boolean;
264
264
  /** SHA-256 hash of an action file's absolute path (the RPC endpoint addressing scheme). */
@@ -276,6 +276,22 @@ export declare function invokeAction(
276
276
  onError?: (error: unknown) => void,
277
277
  ): Promise<Response>;
278
278
 
279
+ // ---------------------------------------------------------------------------
280
+ // action-route.js
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Wrap a plain `'use server'` action as a `route.ts`-style REST handler (#488).
285
+ * Merges query + route params + JSON body into one input object, runs the
286
+ * optional boundary validator, and dispatches through the request abort signal
287
+ * + the optional middleware chain. The always-works baseline is a hand-written
288
+ * `route.ts` that imports and calls the action; this is the convenience.
289
+ */
290
+ export declare function route<A, R>(
291
+ action: (input: A, ctx: { req: Request; params: Record<string, string> }) => R | Promise<R>,
292
+ opts?: { validate?: (input: any) => any; middleware?: Function[] },
293
+ ): (req: Request, ctx?: { params?: Record<string, string> }) => Promise<Response>;
294
+
279
295
  // ---------------------------------------------------------------------------
280
296
  // importmap.js
281
297
  // ---------------------------------------------------------------------------
@@ -844,3 +860,11 @@ export declare function GitHub(opts?: Record<string, unknown>): ProviderConfig;
844
860
  // ---------------------------------------------------------------------------
845
861
 
846
862
  export type { LayoutProps, PageProps, RouteHandlerContext };
863
+
864
+ /** The current action request AbortSignal (#492), or a never-aborting signal outside an action. */
865
+ export declare function actionSignal(): AbortSignal;
866
+ /** Run a function with a request AbortSignal available to actionSignal() (#492). */
867
+ export declare function runWithActionSignal<T>(signal: AbortSignal | undefined, fn: () => T): T;
868
+
869
+ /** The accumulated per-action middleware context (#490), or {} outside an action. */
870
+ export declare function actionContext(): Record<string, unknown>;
package/index.js CHANGED
@@ -13,6 +13,7 @@ export {
13
13
  serveActionStub,
14
14
  invokeAction,
15
15
  } from './src/actions.js';
16
+ export { route } from './src/action-route.js';
16
17
  export { buildImportMap, importMapTag, setVendorEntries } from './src/importmap.js';
17
18
  export {
18
19
  scanBareImports,
@@ -48,6 +49,8 @@ export { cors, resolveOrigin, applyCorsHeaders } from './src/cors.js';
48
49
  export { memoryStore, redisStore, getStore, setStore } from './src/cache.js';
49
50
  export { cache } from './src/cache-fn.js';
50
51
  export { revalidateTag, revalidateTags } from './src/cache-tags.js';
52
+ export { actionSignal, runWithActionSignal } from './src/action-signal.js';
53
+ export { actionContext } from './src/action-middleware.js';
51
54
  export { revalidatePath, revalidateAll } from './src/html-cache.js';
52
55
  export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
53
56
  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.26",
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` boundary (including the `route()` adapter), 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,147 @@
1
+ /**
2
+ * The optional `route(action, opts?)` convenience adapter (#488).
3
+ *
4
+ * REST endpoints in webjs go through `route.ts` (the framework's first-class
5
+ * HTTP handler). The ALWAYS-WORKS baseline is a hand-written `route.ts` that
6
+ * imports a `'use server'` action and calls it:
7
+ *
8
+ * ```js
9
+ * // app/api/posts/route.ts
10
+ * import { createPost } from '../../../modules/posts/actions/create-post.server.ts';
11
+ * export async function POST(req) {
12
+ * const body = await req.json();
13
+ * const result = await createPost(body);
14
+ * return Response.json(result);
15
+ * }
16
+ * ```
17
+ *
18
+ * `route()` is the one-liner shortcut for that pattern. It returns a
19
+ * `route.ts`-style handler that merges the URL query, the route params, and the
20
+ * JSON body into ONE input object, runs an optional boundary validator, and
21
+ * dispatches the action through the request abort signal + the per-action
22
+ * middleware chain, JSON-responding the result:
23
+ *
24
+ * ```js
25
+ * // app/api/posts/route.ts
26
+ * import { route } from '@webjsdev/server';
27
+ * import { createPost } from '../../../modules/posts/actions/create-post.server.ts';
28
+ * export const POST = route(createPost, { validate: createPostSchema });
29
+ * ```
30
+ *
31
+ * Adapter rules when invoked over HTTP:
32
+ * - URL query params, route params (`ctx.params`), and the parsed JSON body
33
+ * (for a method with a body) are merged into a single object argument
34
+ * (body wins over params wins over query). A non-object JSON body becomes
35
+ * `{ body: parsed }`; invalid JSON is a 400. An over-limit body is a 413.
36
+ * - `opts.validate` (when present) runs through the SHARED `runValidate` seam,
37
+ * so a `{ success, fieldErrors }` envelope, a throw, and a transform-return
38
+ * are interpreted exactly as on the RPC path: a structured failure is a 422
39
+ * JSON, a thrown validator is a 400 (keeping a schema lib's `issues`), and a
40
+ * transform-return replaces the input.
41
+ * - The action runs inside the request's `AbortSignal` scope (#492) and the
42
+ * `opts.middleware` chain (#490); a middleware short-circuit carrying a
43
+ * numeric `status` maps that to the HTTP status (else a 200 JSON envelope).
44
+ * - A returned `Response` passes through verbatim; any other value is
45
+ * `Response.json`'d.
46
+ *
47
+ * This is purely a convenience: it ships no behaviour a hand-written `route.ts`
48
+ * cannot express. For full control (custom headers, content negotiation,
49
+ * streaming), write the `route.ts` by hand.
50
+ */
51
+ import { runValidate } from './actions.js';
52
+ import { runWithActionSignal } from './action-signal.js';
53
+ import { runActionChain } from './action-middleware.js';
54
+ import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
55
+ import { getBodyLimits } from './context.js';
56
+
57
+ /**
58
+ * The JSON body cap in effect for the current request: the per-request limit
59
+ * the handler stamped, or the secure default outside a request scope. `0`
60
+ * disables the cap.
61
+ * @returns {number}
62
+ */
63
+ function jsonBodyLimit() {
64
+ const limits = getBodyLimits();
65
+ return limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
66
+ }
67
+
68
+ /**
69
+ * Expose a plain `'use server'` action over REST as a `route.ts`-style handler.
70
+ *
71
+ * @template A, R
72
+ * @param {(input: A, ctx: { req: Request, params: Record<string,string> }) => R | Promise<R>} action
73
+ * the server action to call (its source is unchanged; this only wraps the
74
+ * HTTP-to-action adapter around it).
75
+ * @param {{
76
+ * validate?: (input: any) => any,
77
+ * middleware?: Function[],
78
+ * }} [opts]
79
+ * @returns {(req: Request, ctx?: { params?: Record<string,string> }) => Promise<Response>}
80
+ */
81
+ export function route(action, opts = {}) {
82
+ return async function handler(req, ctx) {
83
+ const params = (ctx && ctx.params) || {};
84
+ const url = new URL(req.url);
85
+ const query = Object.fromEntries(url.searchParams.entries());
86
+
87
+ /** @type {Record<string, unknown>} */
88
+ let body = {};
89
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
90
+ // Bounded read (issue #237): an over-limit body is a 413 before any parse.
91
+ const { tooLarge, text } = await readTextBounded(req, jsonBodyLimit());
92
+ if (tooLarge) return payloadTooLarge();
93
+ if (text) {
94
+ try {
95
+ const parsed = JSON.parse(text);
96
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) body = parsed;
97
+ else body = { body: parsed };
98
+ } catch {
99
+ return new Response('Invalid JSON body', { status: 400 });
100
+ }
101
+ }
102
+ }
103
+
104
+ let arg = { ...query, ...params, ...body };
105
+ if (typeof opts.validate === 'function') {
106
+ // Run the validator through the SHARED contract (#245) so the REST adapter
107
+ // and the RPC path interpret a `{ success, fieldErrors }` envelope, a
108
+ // throw, and a transform-return identically. A structured failure becomes
109
+ // a 422 JSON; a throw stays a 400 (keeping a schema lib's `issues`); a
110
+ // transform-return replaces the input.
111
+ const v = runValidate(opts.validate, arg);
112
+ if (!v.ok) {
113
+ if (v.thrown !== undefined) {
114
+ const msg = v.result.error || 'Invalid input';
115
+ const issues = v.thrown && typeof v.thrown === 'object' && 'issues' in v.thrown
116
+ ? /** @type any */ (v.thrown).issues
117
+ : undefined;
118
+ return Response.json({ error: msg, issues }, { status: 400 });
119
+ }
120
+ const { status, ...payload } = v.result;
121
+ return Response.json(payload, { status });
122
+ }
123
+ arg = v.value;
124
+ }
125
+
126
+ // The action runs inside the request signal scope (#492) and the per-action
127
+ // middleware chain (#490). `ranAction` distinguishes a real completion from
128
+ // a middleware short-circuit (the action never ran).
129
+ const middleware = opts.middleware || [];
130
+ let ranAction = false;
131
+ const result = await runWithActionSignal(req.signal, () =>
132
+ runActionChain(middleware, { request: req, args: [arg], signal: req.signal }, () => {
133
+ ranAction = true;
134
+ return action(/** @type any */ (arg), { req, params });
135
+ }));
136
+
137
+ if (result instanceof Response) return result;
138
+ // A middleware short-circuit carrying a numeric `status` maps the envelope
139
+ // status to the HTTP status, so a non-webjs REST client sees the real status
140
+ // rather than a 200 with it in the body.
141
+ if (!ranAction && result && typeof result === 'object' && typeof (/** @type any */ (result).status) === 'number') {
142
+ const { status, ...payload } = /** @type any */ (result);
143
+ return Response.json(payload, { status });
144
+ }
145
+ return Response.json(result ?? null);
146
+ };
147
+ }
@@ -47,6 +47,7 @@ import { readFileSync } from 'node:fs';
47
47
  import { fileURLToPath } from 'node:url';
48
48
  import { stringify } from '@webjsdev/core';
49
49
  import { hashFile } from './actions.js';
50
+ import { isStreamable } from './action-stream.js';
50
51
 
51
52
  /** Ambient per-render seed collector. `Map<key, value>` or undefined. */
52
53
  const als = new AsyncLocalStorage();
@@ -103,6 +104,10 @@ async function hashFor(absPath) {
103
104
  * @param {unknown} value resolved action result
104
105
  */
105
106
  async function recordSeed(collector, file, fnName, args, value) {
107
+ // A streamed result (#489) is not a serializer-safe value: recording it would
108
+ // make buildSeedScript's stringify throw and drop EVERY seed on the page. A
109
+ // streamed action is never seeded; the client streams it fresh on each call.
110
+ if (isStreamable(value)) return;
106
111
  try {
107
112
  const hash = await hashFor(file);
108
113
  const argsKey = await stringify(args);
@@ -116,9 +121,9 @@ async function recordSeed(collector, file, fnName, args, value) {
116
121
  * Wrap one exported action function so that, when a collector is active, its
117
122
  * resolved result is recorded. Outside a collector (the RPC endpoint path) it is
118
123
  * a transparent passthrough. Non-functions (a `const VERSION = '1.0'` export)
119
- * pass through untouched, and the Proxy forwards property reads, so `expose()` /
120
- * `validateInput()` metadata stored on the function (`__webjsHttp`) still
121
- * resolves through `getExposed()`.
124
+ * pass through untouched, and the Proxy forwards property reads, so any
125
+ * metadata an app or the framework attaches to the function still resolves
126
+ * through the wrapper.
122
127
  * @param {string} file absolute action file path
123
128
  * @param {string} fnName
124
129
  * @param {unknown} orig
@@ -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
+ }