@webjsdev/server 0.8.24 → 0.8.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +26 -2
- package/index.js +3 -0
- package/package.json +1 -1
- package/src/action-config.js +162 -0
- package/src/action-middleware.d.ts +21 -0
- package/src/action-middleware.js +68 -0
- package/src/action-route.js +147 -0
- package/src/action-seed.js +8 -3
- package/src/action-signal.js +39 -0
- package/src/action-stream.js +158 -0
- package/src/actions.js +271 -316
- package/src/check.js +43 -0
- package/src/cors.js +5 -8
- package/src/csrf.js +5 -4
- package/src/dev.js +6 -34
- package/src/importmap.js +3 -3
package/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
|
|
261
|
-
export declare function buildActionIndex(appDir: string, dev: boolean
|
|
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
|
@@ -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
|
+
}
|
package/src/action-seed.js
CHANGED
|
@@ -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
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
+
}
|