@webjsdev/server 0.8.25 → 0.8.27
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 +18 -2
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/action-middleware.js +1 -1
- package/src/action-route.js +147 -0
- package/src/action-seed.js +8 -3
- package/src/action-stream.js +158 -0
- package/src/actions.js +77 -275
- package/src/check.js +81 -1
- package/src/cors.js +5 -8
- package/src/csrf.js +5 -4
- package/src/dev.js +0 -33
- 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
|
// ---------------------------------------------------------------------------
|
package/index.js
CHANGED
package/package.json
CHANGED
package/src/action-middleware.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Per-action middleware (#490). An action file declares `export const middleware
|
|
3
3
|
* = [mw1, mw2]` (a reserved config export). Each middleware is
|
|
4
4
|
* `async (ctx, next) => result`, run around the action on BOTH the RPC endpoint
|
|
5
|
-
* and the `route.ts
|
|
5
|
+
* and the `route.ts` boundary (including the `route()` adapter), like `validate`.
|
|
6
6
|
*
|
|
7
7
|
* - `ctx` carries `{ request, args, signal, context }`. `context` is a shared
|
|
8
8
|
* mutable object the chain accumulates; the action reads it via
|
|
@@ -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,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming RPC results (#489), server side.
|
|
3
|
+
*
|
|
4
|
+
* When a `'use server'` action returns a `ReadableStream`, an async iterable, an
|
|
5
|
+
* async generator, or a Node `Readable` (all carry `Symbol.asyncIterator`, and a
|
|
6
|
+
* web `ReadableStream` is normalized to one), the framework streams the chunks
|
|
7
|
+
* over the single RPC response instead of buffering the whole thing. Each chunk
|
|
8
|
+
* is rich-serialized and length-prefixed via the shared wire protocol in
|
|
9
|
+
* `@webjsdev/core` (`encodeFrame`), so a `Date` / `Map` / `BigInt` inside a chunk
|
|
10
|
+
* round-trips exactly like a normal RPC value. The generated client stub
|
|
11
|
+
* (`actions.js`) decodes the frames and yields the chunks from an async iterable.
|
|
12
|
+
*
|
|
13
|
+
* Composes with the rest of the RPC stack:
|
|
14
|
+
* - #492 abort: the response is driven from the request's `AbortSignal`; a
|
|
15
|
+
* client disconnect (or a superseded async render) aborts the fetch, which
|
|
16
|
+
* cancels the source iterator, so a server generator stops producing.
|
|
17
|
+
* - #472 seeding: a streamed result is NOT seeded (a stream is not a
|
|
18
|
+
* serializer-safe value); `action-seed.js` skips a streamable result.
|
|
19
|
+
* - #488 verbs: streaming is orthogonal to the verb; detection is purely on the
|
|
20
|
+
* RETURN value, so a GET or a POST action can both stream.
|
|
21
|
+
*/
|
|
22
|
+
import { encodeFrame, FRAME_CHUNK, FRAME_END, FRAME_ERROR, STREAM_CONTENT_TYPE } from '@webjsdev/core';
|
|
23
|
+
import { getSerializer } from './serializer.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether an action's return value should stream over the RPC wire rather than
|
|
27
|
+
* buffer. True for a web `ReadableStream`, any async iterable (an async
|
|
28
|
+
* generator, a Node `Readable`), but NOT for a plain object / array / string /
|
|
29
|
+
* Promise / ActionResult envelope (none of which carry `Symbol.asyncIterator`).
|
|
30
|
+
* @param {unknown} v
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function isStreamable(v) {
|
|
34
|
+
if (!v || typeof v !== 'object') return false;
|
|
35
|
+
if (typeof ReadableStream !== 'undefined' && v instanceof ReadableStream) return true;
|
|
36
|
+
return typeof (/** @type any */ (v)[Symbol.asyncIterator]) === 'function';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalize a streamable source to an async iterator with `next()` / `return()`.
|
|
41
|
+
* A source with `Symbol.asyncIterator` (async generator, Node Readable, and a
|
|
42
|
+
* web ReadableStream in modern Node) uses it directly; a ReadableStream lacking
|
|
43
|
+
* async iteration falls back to a reader.
|
|
44
|
+
* @param {any} source
|
|
45
|
+
* @returns {{ next(): Promise<{ value: any, done?: boolean }>, return?(v?: any): Promise<any> }}
|
|
46
|
+
*/
|
|
47
|
+
function toAsyncIterator(source) {
|
|
48
|
+
if (source && typeof source[Symbol.asyncIterator] === 'function') {
|
|
49
|
+
return source[Symbol.asyncIterator]();
|
|
50
|
+
}
|
|
51
|
+
// A ReadableStream without async-iterator support: drive it from a reader.
|
|
52
|
+
const reader = source.getReader();
|
|
53
|
+
return {
|
|
54
|
+
next: () => reader.read(),
|
|
55
|
+
return: (v) => {
|
|
56
|
+
try { reader.cancel(v); } catch {}
|
|
57
|
+
try { reader.releaseLock(); } catch {}
|
|
58
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the streaming RPC `Response` for a streamable action result. The body is
|
|
65
|
+
* a back-pressured `ReadableStream` of frames: one CHUNK frame per serialized
|
|
66
|
+
* source value, a terminal END frame on clean completion, or an ERROR frame
|
|
67
|
+
* carrying the (author-controlled) message if the source throws mid-flight (the
|
|
68
|
+
* HTTP status is already 200 by then, so the error rides the frame channel).
|
|
69
|
+
*
|
|
70
|
+
* Back-pressure: chunks are pulled (and serialized) one at a time as the consumer
|
|
71
|
+
* reads, so a slow client throttles a fast producer. Cancellation: an aborted
|
|
72
|
+
* request signal (client disconnect / superseded render, #492) returns the source
|
|
73
|
+
* iterator, stopping a server generator.
|
|
74
|
+
*
|
|
75
|
+
* @param {any} source the streamable action return value
|
|
76
|
+
* @param {{ signal?: AbortSignal, onError?: (e: unknown) => void, headers?: Record<string,string> }} [opts]
|
|
77
|
+
* @returns {Response}
|
|
78
|
+
*/
|
|
79
|
+
export function streamActionResponse(source, opts = {}) {
|
|
80
|
+
const { signal, onError, headers } = opts;
|
|
81
|
+
const s = getSerializer();
|
|
82
|
+
const enc = new TextEncoder();
|
|
83
|
+
const iter = toAsyncIterator(source);
|
|
84
|
+
let closed = false;
|
|
85
|
+
/** @type {(() => void) | null} */
|
|
86
|
+
let onAbort = null;
|
|
87
|
+
|
|
88
|
+
const releaseSource = (reason) => {
|
|
89
|
+
if (typeof iter.return === 'function') {
|
|
90
|
+
try { return Promise.resolve(iter.return(reason)).catch(() => {}); } catch {}
|
|
91
|
+
}
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
};
|
|
94
|
+
const detach = () => { if (onAbort && signal) { try { signal.removeEventListener('abort', onAbort); } catch {} onAbort = null; } };
|
|
95
|
+
|
|
96
|
+
const body = new ReadableStream({
|
|
97
|
+
async pull(controller) {
|
|
98
|
+
if (closed) return;
|
|
99
|
+
try {
|
|
100
|
+
if (signal && signal.aborted) {
|
|
101
|
+
closed = true;
|
|
102
|
+
// Emit an ERROR frame so a still-reading client sees an explicit abort
|
|
103
|
+
// rather than a silent truncation (a clean close with no terminal
|
|
104
|
+
// frame is indistinguishable from success). Best-effort: the
|
|
105
|
+
// connection may already be gone, in which case enqueue throws.
|
|
106
|
+
try { controller.enqueue(encodeFrame(FRAME_ERROR, enc.encode('stream aborted'))); } catch {}
|
|
107
|
+
await releaseSource();
|
|
108
|
+
detach();
|
|
109
|
+
controller.close();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const { value, done } = await iter.next();
|
|
113
|
+
if (done) {
|
|
114
|
+
closed = true;
|
|
115
|
+
controller.enqueue(encodeFrame(FRAME_END));
|
|
116
|
+
detach();
|
|
117
|
+
controller.close();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const bytes = enc.encode(await s.serialize(value));
|
|
121
|
+
controller.enqueue(encodeFrame(FRAME_CHUNK, bytes));
|
|
122
|
+
} catch (e) {
|
|
123
|
+
closed = true;
|
|
124
|
+
// Surface the original error to an APM sink + the server log, exactly
|
|
125
|
+
// like the buffered error path, then ship the author-controlled message
|
|
126
|
+
// on the frame channel (the status is already 200, mid-stream).
|
|
127
|
+
if (typeof onError === 'function') { try { onError(e); } catch {} }
|
|
128
|
+
console.error('[webjs] streaming action threw:', e);
|
|
129
|
+
const msg = e instanceof Error && e.message ? e.message : 'Internal server error';
|
|
130
|
+
try { controller.enqueue(encodeFrame(FRAME_ERROR, enc.encode(msg))); } catch {}
|
|
131
|
+
await releaseSource(e);
|
|
132
|
+
detach();
|
|
133
|
+
try { controller.close(); } catch {}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
async cancel(reason) {
|
|
137
|
+
closed = true;
|
|
138
|
+
detach();
|
|
139
|
+
await releaseSource(reason);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// A client disconnect / superseded render aborts the request signal: cancel the
|
|
144
|
+
// source promptly even if the consumer is not actively pulling. The listener is
|
|
145
|
+
// detached on completion / cancel so it cannot outlive the stream.
|
|
146
|
+
if (signal) {
|
|
147
|
+
onAbort = () => { releaseSource(); };
|
|
148
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A streamed result is never cacheable; mark it streamed so the conditional-GET
|
|
152
|
+
// funnel never tries to buffer it into memory for an ETag.
|
|
153
|
+
const h = new Headers(headers || {});
|
|
154
|
+
h.set('content-type', STREAM_CONTENT_TYPE);
|
|
155
|
+
h.set('cache-control', 'no-store');
|
|
156
|
+
h.set('x-webjs-stream-rpc', '1');
|
|
157
|
+
return new Response(body, { status: 200, headers: h });
|
|
158
|
+
}
|
package/src/actions.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { digestHex } from './crypto-utils.js';
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
-
import { join,
|
|
5
|
-
import { getExposed } from '@webjsdev/core';
|
|
4
|
+
import { join, sep } from 'node:path';
|
|
6
5
|
import { walk } from './fs-walk.js';
|
|
7
6
|
import { verify as verifyCsrf, CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
|
|
8
7
|
import { getSerializer } from './serializer.js';
|
|
9
|
-
import { resolveOrigin } from './cors.js';
|
|
10
8
|
import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
|
|
11
9
|
import {
|
|
12
10
|
actionMethod, actionFunctionNames, actionCache, actionConfigFn, resolveTags,
|
|
@@ -16,6 +14,7 @@ import {
|
|
|
16
14
|
import { revalidateTags } from './cache-tags.js';
|
|
17
15
|
import { runWithActionSignal } from './action-signal.js';
|
|
18
16
|
import { runActionChain } from './action-middleware.js';
|
|
17
|
+
import { isStreamable, streamActionResponse } from './action-stream.js';
|
|
19
18
|
import { ifNoneMatchSatisfied } from './conditional-get.js';
|
|
20
19
|
import { getBodyLimits } from './context.js';
|
|
21
20
|
import { basePath } from './importmap.js';
|
|
@@ -44,9 +43,9 @@ export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
|
|
|
44
43
|
|
|
45
44
|
/**
|
|
46
45
|
* Run an attached input validator against an action's input, shared by the RPC
|
|
47
|
-
* path (`invokeAction`) and the `
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* path (`invokeAction`) and the `route()` REST adapter so the contract is
|
|
47
|
+
* identical across transports (#245). The framework only CALLS the validator and
|
|
48
|
+
* shapes the result; it ships no validation library.
|
|
50
49
|
*
|
|
51
50
|
* @param {(input: any) => any} validate the attached validator
|
|
52
51
|
* @param {any} input the value handed to the validator (the action's first arg
|
|
@@ -127,56 +126,41 @@ async function rpcResponse(payload, init = {}) {
|
|
|
127
126
|
* The server:
|
|
128
127
|
* 1. Scans the app tree lazily on the first request (in `ensureReady`),
|
|
129
128
|
* classifying server files into RPC-callable actions vs. server-only
|
|
130
|
-
* utilities. Hashing is eager-per-file;
|
|
129
|
+
* utilities. Hashing is eager-per-file; no module is loaded here.
|
|
131
130
|
* 2. Serves a generated ES-module stub when the browser imports
|
|
132
131
|
* the file URL (an RPC stub for actions, a throw-at-load stub
|
|
133
132
|
* for server-only utilities).
|
|
134
|
-
* 3. Exposes
|
|
135
|
-
* RPC-callable actions only.
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* @typedef {{
|
|
140
|
-
* method: string,
|
|
141
|
-
* pattern: RegExp,
|
|
142
|
-
* paramNames: string[],
|
|
143
|
-
* file: string,
|
|
144
|
-
* fnName: string,
|
|
145
|
-
* validate: ((input: any) => any) | null,
|
|
146
|
-
* cors: { origin: string | string[], credentials: boolean, maxAge: number, headers: string[] | null } | null,
|
|
147
|
-
* }} ExposedRoute
|
|
133
|
+
* 3. Exposes endpoints at /__webjs/action/:hash/:fn for
|
|
134
|
+
* RPC-callable actions only. A public REST endpoint is a `route.ts`
|
|
135
|
+
* that imports and calls the action (optionally via the `route()`
|
|
136
|
+
* adapter in `action-route.js`).
|
|
148
137
|
*
|
|
149
138
|
* @typedef {{
|
|
150
139
|
* hashToFile: Map<string,string>,
|
|
151
140
|
* fileToHash: Map<string,string>,
|
|
152
|
-
* httpRoutes: ExposedRoute[],
|
|
153
141
|
* appDir: string,
|
|
154
142
|
* dev: boolean,
|
|
155
143
|
* }} ActionIndex
|
|
156
144
|
*/
|
|
157
145
|
|
|
158
146
|
/**
|
|
159
|
-
* Build the action index by scanning the app directory.
|
|
147
|
+
* Build the action index by scanning the app directory. This is a pure
|
|
148
|
+
* file -> hash mapping: it walks `app/`, classifies `.server.*` + `'use server'`
|
|
149
|
+
* files as RPC-callable actions, and records the hash both ways. It loads NO
|
|
150
|
+
* module (the hash index is all the analysis needs; a module is imported on
|
|
151
|
+
* demand by `invokeAction` / `serveActionStub`), so it is safe for a read-only
|
|
152
|
+
* introspection caller (the MCP `list_actions` tool, #262) with no top-level
|
|
153
|
+
* side effects (Prisma init, DB connect).
|
|
160
154
|
*
|
|
161
155
|
* @param {string} appDir
|
|
162
156
|
* @param {boolean} dev
|
|
163
|
-
* @param {{ skipExposeLoad?: boolean }} [opts]
|
|
164
|
-
* `skipExposeLoad: true` builds the (load-free) `fileToHash` / `hashToFile`
|
|
165
|
-
* maps WITHOUT importing any `expose()`-referencing module, so `httpRoutes`
|
|
166
|
-
* stays empty. A read-only introspection caller (the MCP `list_actions` tool,
|
|
167
|
-
* #262) uses this to derive RPC endpoint hashes without running a server
|
|
168
|
-
* module's top-level side effects (Prisma init, DB connect) or risking a
|
|
169
|
-
* stray stdout write. The request pipeline keeps the default (loads expose
|
|
170
|
-
* routes, which the router must know before a request can hit them).
|
|
171
157
|
* @returns {Promise<ActionIndex>}
|
|
172
158
|
*/
|
|
173
|
-
export async function buildActionIndex(appDir, dev
|
|
159
|
+
export async function buildActionIndex(appDir, dev) {
|
|
174
160
|
/** @type {Map<string,string>} */
|
|
175
161
|
const hashToFile = new Map();
|
|
176
162
|
/** @type {Map<string,string>} */
|
|
177
163
|
const fileToHash = new Map();
|
|
178
|
-
/** @type {ExposedRoute[]} */
|
|
179
|
-
const httpRoutes = [];
|
|
180
164
|
|
|
181
165
|
for await (const file of walk(appDir, (p) => /\.m?[jt]s$/.test(p))) {
|
|
182
166
|
// Path-level: only `.server.{ts,js,mts,mjs}` files are server-only.
|
|
@@ -198,50 +182,10 @@ export async function buildActionIndex(appDir, dev, opts = {}) {
|
|
|
198
182
|
// serveActionStub import the module on demand (first RPC call / first stub
|
|
199
183
|
// fetch), so the hash index above is all the analysis needs. Eagerly
|
|
200
184
|
// running every server module (and its transitive Prisma init, DB
|
|
201
|
-
// connects, etc.) would be wasted work.
|
|
202
|
-
// which registers a REST route the router must know before any request can
|
|
203
|
-
// hit it. So load only files that REFERENCE expose. We match the bare
|
|
204
|
-
// `expose` identifier (not `expose(`) so an aliased import
|
|
205
|
-
// (`import { expose as exp }`, whose import clause still names `expose`) is
|
|
206
|
-
// not missed: missing it would silently 404 that file's REST route. A stray
|
|
207
|
-
// mention in a comment or string only over-matches, costing one harmless
|
|
208
|
-
// extra module load; the common pure-RPC file never names `expose` and so
|
|
209
|
-
// still defers entirely.
|
|
210
|
-
// A read-only caller (MCP introspection) only needs the file -> hash maps
|
|
211
|
-
// above, so skip the expose-load entirely (no module side effects).
|
|
212
|
-
if (opts.skipExposeLoad) continue;
|
|
213
|
-
let src = '';
|
|
214
|
-
try { src = await readFile(file, 'utf8'); } catch {}
|
|
215
|
-
if (!/\bexpose\b/.test(src)) continue;
|
|
216
|
-
try {
|
|
217
|
-
const mod = await loadModule(file, dev);
|
|
218
|
-
for (const [name, fn] of Object.entries(mod)) {
|
|
219
|
-
if (typeof fn !== 'function') continue;
|
|
220
|
-
const http = getExposed(fn);
|
|
221
|
-
if (!http) continue;
|
|
222
|
-
// A `validateInput(fn, ...)` attachment writes `__webjsHttp` with a
|
|
223
|
-
// `validate` but NO `method`/`path` (it does not create a REST route),
|
|
224
|
-
// so it is not an exposed route. Only `expose()` sets a path; skip a
|
|
225
|
-
// validate-only attachment here (its validator is read at RPC call
|
|
226
|
-
// time via getExposed in invokeAction).
|
|
227
|
-
if (!http.path || !http.method) continue;
|
|
228
|
-
const { pattern, paramNames } = pathToPattern(http.path);
|
|
229
|
-
httpRoutes.push({
|
|
230
|
-
method: http.method,
|
|
231
|
-
pattern,
|
|
232
|
-
paramNames,
|
|
233
|
-
file,
|
|
234
|
-
fnName: name,
|
|
235
|
-
validate: http.validate || null,
|
|
236
|
-
cors: http.cors || null,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
} catch (e) {
|
|
240
|
-
console.error(`[webjs] failed to scan server module ${file}:`, e);
|
|
241
|
-
}
|
|
185
|
+
// connects, etc.) would be wasted work.
|
|
242
186
|
}
|
|
243
187
|
|
|
244
|
-
return { hashToFile, fileToHash,
|
|
188
|
+
return { hashToFile, fileToHash, appDir, dev };
|
|
245
189
|
}
|
|
246
190
|
|
|
247
191
|
/** @param {string} file @returns {Promise<string>} */
|
|
@@ -376,7 +320,11 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
376
320
|
// `method` is a default POST), so the first client call resolves from the
|
|
377
321
|
// seed with no hydration round-trip. The browser-cache staleness check is
|
|
378
322
|
// 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'
|
|
323
|
+
const imports = ['stringify as __s', 'parse as __p', 'takeSeed as __seedTake', 'SEED_MISS as __MISS', 'markStale as __markStale', 'parseTagHeader as __tagHdr', 'activeActionSignal as __sig',
|
|
324
|
+
// Streaming RPC (#489): an action returning a stream / async iterable sends
|
|
325
|
+
// back a framed body; the stub decodes it into an async iterable the caller
|
|
326
|
+
// `for await`s. The imports are small constants + one decoder factory.
|
|
327
|
+
'STREAM_CONTENT_TYPE as __STREAM_CT', 'createFrameDecoder as __frameDec', 'FRAME_CHUNK as __F_CHUNK', 'FRAME_END as __F_END', 'FRAME_ERROR as __F_ERR'];
|
|
380
328
|
if (method === 'GET') {
|
|
381
329
|
imports.push('registerKeyTags as __regTags', 'consumeStale as __stale', 'fetchMark as __mark');
|
|
382
330
|
}
|
|
@@ -394,11 +342,43 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
394
342
|
lines.push(` const inv = __tagHdr(res.headers.get('x-webjs-invalidate')); if (inv.length) __markStale(inv);`);
|
|
395
343
|
lines.push(` if (key != null) { const t = __tagHdr(res.headers.get('x-webjs-tags')); if (t.length) __regTags(key, t, since); }`);
|
|
396
344
|
lines.push(` const ct = res.headers.get('content-type') || '';`);
|
|
345
|
+
// A streamed result (#489): the action returned a stream / async iterable.
|
|
346
|
+
// Return an async iterable that decodes the framed body and yields each
|
|
347
|
+
// deserialized chunk. The response is 200 (the stream started), so errors
|
|
348
|
+
// arrive as ERROR frames, not an HTTP status.
|
|
349
|
+
lines.push(` if (ct.includes(__STREAM_CT)) return __readStream(res, fn);`);
|
|
397
350
|
lines.push(` const text = await res.text();`);
|
|
398
351
|
lines.push(` const parsed = ct.includes(__CT) ? __p(text) : (ct.includes('application/json') ? JSON.parse(text) : text);`);
|
|
399
352
|
lines.push(` if (!res.ok) throw new Error((parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status));`);
|
|
400
353
|
lines.push(` return parsed;`);
|
|
401
354
|
lines.push(`}`);
|
|
355
|
+
// Decode a framed streamed body into an async generator of rich-deserialized
|
|
356
|
+
// chunks. A CHUNK frame yields a value, END returns, ERROR throws the
|
|
357
|
+
// (author-controlled) message. The reader is released on completion / abort.
|
|
358
|
+
lines.push(`async function* __readStream(res, fn) {`);
|
|
359
|
+
lines.push(` if (!res.body) throw new Error('webjs stream ' + fn + ' has no body');`);
|
|
360
|
+
lines.push(` const reader = res.body.getReader();`);
|
|
361
|
+
lines.push(` const dec = __frameDec();`);
|
|
362
|
+
lines.push(` const td = new TextDecoder();`);
|
|
363
|
+
lines.push(` let ended = false;`);
|
|
364
|
+
lines.push(` try {`);
|
|
365
|
+
lines.push(` for (;;) {`);
|
|
366
|
+
lines.push(` const { value, done } = await reader.read();`);
|
|
367
|
+
lines.push(` if (done) break;`);
|
|
368
|
+
lines.push(` for (const f of dec.push(value)) {`);
|
|
369
|
+
lines.push(` if (f.type === __F_CHUNK) yield __p(td.decode(f.payload));`);
|
|
370
|
+
lines.push(` else if (f.type === __F_END) { ended = true; return; }`);
|
|
371
|
+
lines.push(` else if (f.type === __F_ERR) throw new Error(td.decode(f.payload) || ('webjs stream ' + fn));`);
|
|
372
|
+
lines.push(` }`);
|
|
373
|
+
lines.push(` }`);
|
|
374
|
+
// The body ended without a terminal END/ERROR frame: the stream was truncated
|
|
375
|
+
// (a server crash, a dropped connection, an upstream timeout). A healthy
|
|
376
|
+
// stream always ends in END or ERROR, so surface this as an error rather than
|
|
377
|
+
// a silent clean completion. (A consumer that breaks early goes through the
|
|
378
|
+
// generator's return(), which skips this and runs only the finally.)
|
|
379
|
+
lines.push(` if (!ended) throw new Error('webjs stream ' + fn + ' truncated (no end frame)');`);
|
|
380
|
+
lines.push(` } finally { try { reader.cancel(); } catch {} try { reader.releaseLock(); } catch {} }`);
|
|
381
|
+
lines.push(`}`);
|
|
402
382
|
// Body sender (POST/PUT/PATCH, and the URL-arg too-large fallback). `sig` is
|
|
403
383
|
// captured SYNCHRONOUSLY at __call entry (#492): the active render signal must
|
|
404
384
|
// be read before the stub's first await (stringify), or the render's
|
|
@@ -501,17 +481,13 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
|
|
|
501
481
|
} catch {
|
|
502
482
|
return rpcResponse({ error: 'Invalid request body' }, { status: 400 });
|
|
503
483
|
}
|
|
504
|
-
// Input validation (#245): a validator
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
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.
|
|
513
|
-
const attached = getExposed(fn);
|
|
514
|
-
const validate = actionConfigFn(mod, 'validate') || (attached && attached.validate);
|
|
484
|
+
// Input validation (#245): a validator declared via the `validate` config
|
|
485
|
+
// export (#488) runs SERVER-SIDE before the body on this RPC path. The RPC
|
|
486
|
+
// stub sends an args array; an action conventionally takes one input object,
|
|
487
|
+
// so validate the FIRST arg. Validation is a BOUNDARY concern: it runs on
|
|
488
|
+
// this RPC boundary (and the `route()` REST adapter), never on a direct
|
|
489
|
+
// server-to-server call.
|
|
490
|
+
const validate = actionConfigFn(mod, 'validate');
|
|
515
491
|
if (typeof validate === 'function') {
|
|
516
492
|
const v = runValidate(validate, args[0]);
|
|
517
493
|
if (!v.ok) {
|
|
@@ -540,6 +516,19 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
|
|
|
540
516
|
let ranAction = false;
|
|
541
517
|
const result = await runWithActionSignal(req.signal, () =>
|
|
542
518
|
runActionChain(middleware, { request: req, args, signal: req.signal }, () => { ranAction = true; return fn(...args); }));
|
|
519
|
+
// Streaming result (#489): a COMPLETED action returning a ReadableStream /
|
|
520
|
+
// async iterable streams its chunks over the RPC wire instead of buffering.
|
|
521
|
+
// A streamed result is never cached, ETagged, or seeded (it is not a
|
|
522
|
+
// serializer-safe value). A middleware short-circuit (the action never ran)
|
|
523
|
+
// is a plain envelope and falls through to the normal verb handling below.
|
|
524
|
+
if (ranAction && isStreamable(result)) {
|
|
525
|
+
const headers = {};
|
|
526
|
+
if (method !== 'GET') {
|
|
527
|
+
const inv = resolveTags(actionConfigFn(mod, 'invalidates'), args);
|
|
528
|
+
if (inv.length) { await revalidateTags(inv); headers['x-webjs-invalidate'] = inv.join(','); }
|
|
529
|
+
}
|
|
530
|
+
return streamActionResponse(result, { signal: req.signal, onError, headers });
|
|
531
|
+
}
|
|
543
532
|
if (method === 'GET') {
|
|
544
533
|
// A short-circuit (the action did not run, e.g. an auth denial) is NEVER
|
|
545
534
|
// cached: serve the envelope no-store so a denial is not stored or shared.
|
|
@@ -592,179 +581,6 @@ async function getActionResponse(result, mod, args, req) {
|
|
|
592
581
|
return new Response(bodyStr, { status: 200, headers });
|
|
593
582
|
}
|
|
594
583
|
|
|
595
|
-
/**
|
|
596
|
-
* Match an incoming request against an expose()d action route.
|
|
597
|
-
* Returns the single matched route+params for normal methods.
|
|
598
|
-
* @param {ActionIndex} idx
|
|
599
|
-
* @param {string} method
|
|
600
|
-
* @param {string} pathname
|
|
601
|
-
*/
|
|
602
|
-
export function matchExposedAction(idx, method, pathname) {
|
|
603
|
-
for (const r of idx.httpRoutes) {
|
|
604
|
-
if (r.method !== method) continue;
|
|
605
|
-
const m = r.pattern.exec(pathname);
|
|
606
|
-
if (!m) continue;
|
|
607
|
-
/** @type {Record<string,string>} */
|
|
608
|
-
const params = {};
|
|
609
|
-
r.paramNames.forEach((n, i) => (params[n] = decodeURIComponent(m[i + 1] || '')));
|
|
610
|
-
return { route: r, params };
|
|
611
|
-
}
|
|
612
|
-
return null;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Find ALL exposed routes at a given path (any method). Used to build OPTIONS
|
|
617
|
-
* preflight responses and Allow headers.
|
|
618
|
-
* @param {ActionIndex} idx
|
|
619
|
-
* @param {string} pathname
|
|
620
|
-
*/
|
|
621
|
-
export function matchAllAtPath(idx, pathname) {
|
|
622
|
-
const out = [];
|
|
623
|
-
for (const r of idx.httpRoutes) {
|
|
624
|
-
if (r.pattern.exec(pathname)) out.push(r);
|
|
625
|
-
}
|
|
626
|
-
return out;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/**
|
|
630
|
-
* Build CORS response headers given a route's CORS config + the request.
|
|
631
|
-
* Returns null if CORS isn't configured for this route.
|
|
632
|
-
*
|
|
633
|
-
* @param {ExposedRoute} route
|
|
634
|
-
* @param {Request} req
|
|
635
|
-
*/
|
|
636
|
-
export function corsHeadersFor(route, req) {
|
|
637
|
-
if (!route.cors) return null;
|
|
638
|
-
const cfg = route.cors;
|
|
639
|
-
const origin = req.headers.get('origin') || '';
|
|
640
|
-
const allowed = matchOrigin(cfg.origin, origin);
|
|
641
|
-
if (!allowed && origin) return null;
|
|
642
|
-
const h = new Headers();
|
|
643
|
-
h.set('access-control-allow-origin', allowed === true ? '*' : allowed || origin);
|
|
644
|
-
if (cfg.credentials) h.set('access-control-allow-credentials', 'true');
|
|
645
|
-
h.append('vary', 'Origin');
|
|
646
|
-
return h;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
/** @param {ExposedRoute} route @param {Request} req */
|
|
650
|
-
export function buildPreflightResponse(route, req) {
|
|
651
|
-
const headers = corsHeadersFor(route, req);
|
|
652
|
-
if (!headers) return new Response(null, { status: 403 });
|
|
653
|
-
headers.set('access-control-allow-methods', `${route.method}, OPTIONS`);
|
|
654
|
-
const reqHdrs =
|
|
655
|
-
route.cors?.headers?.join(',') || req.headers.get('access-control-request-headers') || 'content-type';
|
|
656
|
-
headers.set('access-control-allow-headers', reqHdrs);
|
|
657
|
-
headers.set('access-control-max-age', String(route.cors?.maxAge ?? 86400));
|
|
658
|
-
return new Response(null, { status: 204, headers });
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
/** Apply CORS headers (if any) to an existing response. */
|
|
662
|
-
export function withCors(resp, route, req) {
|
|
663
|
-
const h = corsHeadersFor(route, req);
|
|
664
|
-
if (!h) return resp;
|
|
665
|
-
const newHeaders = new Headers(resp.headers);
|
|
666
|
-
h.forEach((v, k) => newHeaders.set(k, v));
|
|
667
|
-
return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers: newHeaders });
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* Match a route's configured origin policy against the request origin,
|
|
672
|
-
* preserving the expose() path's historical contract: `*` returns `true`
|
|
673
|
-
* (literal wildcard), an allowed concrete origin echoes back, and a
|
|
674
|
-
* mismatch returns `null`. Delegates the per-rule decision to the shared
|
|
675
|
-
* cors.js resolver (so RegExp + function policies work here too) but keeps
|
|
676
|
-
* the wildcard-as-`true` and echo-vs-null shape this caller expects.
|
|
677
|
-
*
|
|
678
|
-
* @param {string|string[]|RegExp|((origin: string) => boolean)} configured
|
|
679
|
-
* @param {string} origin
|
|
680
|
-
* @returns {true | string | null}
|
|
681
|
-
*/
|
|
682
|
-
function matchOrigin(configured, origin) {
|
|
683
|
-
if (configured === '*') return true;
|
|
684
|
-
const resolved = resolveOrigin(configured, origin, false);
|
|
685
|
-
if (!resolved) return null;
|
|
686
|
-
return resolved.allowOrigin === '*' ? true : resolved.allowOrigin;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* Invoke an exposed action as a REST endpoint.
|
|
691
|
-
* Builds a single object argument from URL params + query + JSON body.
|
|
692
|
-
* @param {ActionIndex} idx
|
|
693
|
-
* @param {ExposedRoute} route
|
|
694
|
-
* @param {Record<string,string>} params
|
|
695
|
-
* @param {Request} req
|
|
696
|
-
* @param {(error: unknown) => void} [onError] best-effort sink (issue #239)
|
|
697
|
-
* invoked when the exposed REST handler throws unexpectedly, BEFORE the
|
|
698
|
-
* sanitized 500 is returned, so an APM integration sees the original error.
|
|
699
|
-
* The caller wraps it so a throwing sink can never affect the response.
|
|
700
|
-
*/
|
|
701
|
-
export async function invokeExposedAction(idx, route, params, req, onError) {
|
|
702
|
-
const url = new URL(req.url);
|
|
703
|
-
const query = Object.fromEntries(url.searchParams.entries());
|
|
704
|
-
let body = {};
|
|
705
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
706
|
-
// Bounded read (issue #237): an over-limit body is a 413 before any parse.
|
|
707
|
-
const { tooLarge, text } = await readTextBounded(req, jsonBodyLimit());
|
|
708
|
-
if (tooLarge) return payloadTooLarge();
|
|
709
|
-
if (text) {
|
|
710
|
-
try {
|
|
711
|
-
const parsed = JSON.parse(text);
|
|
712
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) body = parsed;
|
|
713
|
-
else body = { body: parsed };
|
|
714
|
-
} catch {
|
|
715
|
-
return new Response('Invalid JSON body', { status: 400 });
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
let arg = { ...query, ...params, ...body };
|
|
720
|
-
if (route.validate) {
|
|
721
|
-
// Run the validator through the SHARED contract (#245) so the REST path and
|
|
722
|
-
// the RPC path interpret a `{ success, fieldErrors }` envelope, a throw, and
|
|
723
|
-
// a transform-return identically. A structured failure becomes a 422 JSON
|
|
724
|
-
// `{ error?, fieldErrors }` (not just a thrown 400); a throw stays a 400; a
|
|
725
|
-
// transform-return keeps replacing the input (back-compat).
|
|
726
|
-
const v = runValidate(route.validate, arg);
|
|
727
|
-
if (!v.ok) {
|
|
728
|
-
if (v.thrown !== undefined) {
|
|
729
|
-
// A thrown validator (the classic `Schema.parse` style): keep the exact
|
|
730
|
-
// legacy REST shape, including a schema lib's structured `issues` array.
|
|
731
|
-
const msg = v.result.error || 'Invalid input';
|
|
732
|
-
const issues = v.thrown && typeof v.thrown === 'object' && 'issues' in v.thrown
|
|
733
|
-
? /** @type any */ (v.thrown).issues
|
|
734
|
-
: undefined;
|
|
735
|
-
return Response.json({ error: msg, issues }, { status: 400 });
|
|
736
|
-
}
|
|
737
|
-
const { status, ...payload } = v.result;
|
|
738
|
-
return Response.json(payload, { status });
|
|
739
|
-
}
|
|
740
|
-
arg = v.value;
|
|
741
|
-
}
|
|
742
|
-
const mod = await loadModule(route.file, idx.dev);
|
|
743
|
-
const fn = mod[route.fnName];
|
|
744
|
-
if (typeof fn !== 'function') return new Response(`Unknown action ${route.fnName}`, { status: 404 });
|
|
745
|
-
try {
|
|
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 }); }));
|
|
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
|
-
}
|
|
761
|
-
return Response.json(result ?? null);
|
|
762
|
-
} catch (e) {
|
|
763
|
-
if (typeof onError === 'function') onError(e);
|
|
764
|
-
return actionErrorResponse(e, idx.dev);
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
|
|
768
584
|
/**
|
|
769
585
|
* Return a JSON error response with dev-vs-prod sanitization.
|
|
770
586
|
* In prod we return only the error message (not the stack), and we log the
|
|
@@ -789,20 +605,6 @@ function actionErrorResponse(err, dev) {
|
|
|
789
605
|
return rpcResponse({ error: msg }, { status: 500 });
|
|
790
606
|
}
|
|
791
607
|
|
|
792
|
-
/**
|
|
793
|
-
* Convert an `expose()` path like `/api/posts/:slug` to a regex + param list.
|
|
794
|
-
* Also accepts NextJs-style `[slug]` brackets for familiarity.
|
|
795
|
-
* @param {string} path
|
|
796
|
-
*/
|
|
797
|
-
function pathToPattern(path) {
|
|
798
|
-
const paramNames = [];
|
|
799
|
-
const re = path.replace(/:([A-Za-z_][A-Za-z0-9_]*)|\[([A-Za-z_][A-Za-z0-9_]*)\]/g, (_, a, b) => {
|
|
800
|
-
paramNames.push(a || b);
|
|
801
|
-
return '([^/]+)';
|
|
802
|
-
});
|
|
803
|
-
return { pattern: new RegExp(`^${re}/?$`), paramNames };
|
|
804
|
-
}
|
|
805
|
-
|
|
806
608
|
/**
|
|
807
609
|
* @param {string} file
|
|
808
610
|
* @param {boolean} dev
|
package/src/check.js
CHANGED
|
@@ -93,6 +93,11 @@ export const RULES = [
|
|
|
93
93
|
description:
|
|
94
94
|
'Files that declare the `\'use server\'` directive at the top must also have the `.server.{js,ts,mts,mjs}` extension. The two markers are complementary, not interchangeable: `.server.ts` is the path-level boundary that triggers source protection by the file router; `\'use server\'` is the semantic opt-in that registers exports as RPC-callable from client code. A `\'use server\'` directive without the extension is silently ignored: the file is served to the browser as plain source, exports are NOT registered as RPC, and code the developer expects to run on the server actually runs in the browser. Rename the file to add the `.server.` infix.',
|
|
95
95
|
},
|
|
96
|
+
{
|
|
97
|
+
name: 'use-server-exports-callable',
|
|
98
|
+
description:
|
|
99
|
+
'A `.server.{js,ts}` file that declares the `\'use server\'` directive registers its function exports as RPC-callable server actions, but only its FUNCTION exports: `buildActionIndex` / the stub generator register an export only when `typeof export === \'function\'`, so a `\'use server\'` file that exports zero functions (or only a non-function `const` / a type / only verb config like `method` / `cache`) registers NOTHING and gives no signal. The developer believes they exposed an action; nothing did, and the failure only surfaces as a 404 / undefined at the first call site. This is the complement of `use-server-needs-extension` (the directive without the extension) and of `one-action-per-configured-file` (more than one action in a configured file); this rule catches the directive-present-but-nothing-callable case. The rule asserts only that the file exports at least one callable, NOT that the action returns a value (a server action may be a void side-effect or throw `redirect()` / `notFound()` and never return). Conservative: a re-export (`export ... from`, `export *`) or an `export const NAME = <identifier-or-call>` (a possible factory-produced function such as `export const get = cache(fetch)`) is treated as a possible callable and NOT flagged, so the rule fires only when every export is provably non-callable. Fix: export an `async function` action, or drop the `\'use server\'` directive if the file is a plain server-only utility.',
|
|
100
|
+
},
|
|
96
101
|
{
|
|
97
102
|
name: 'no-non-erasable-typescript',
|
|
98
103
|
description:
|
|
@@ -733,6 +738,75 @@ export async function checkConventions(appDir) {
|
|
|
733
738
|
}
|
|
734
739
|
}
|
|
735
740
|
|
|
741
|
+
// --- Rule: use-server-exports-callable (#464) ---
|
|
742
|
+
// A `.server.{js,ts}` file with the `'use server'` directive registers only
|
|
743
|
+
// its FUNCTION exports as RPC actions (the registrar checks `typeof === 'function'`).
|
|
744
|
+
// A file that declares the directive but exports no callable registers nothing,
|
|
745
|
+
// silently: the developer thinks they exposed an action, and the only signal is
|
|
746
|
+
// a 404 / undefined at the first call site. Flag it. The complement of
|
|
747
|
+
// use-server-needs-extension (the directive without the extension) and of
|
|
748
|
+
// one-action-per-configured-file (more than one action).
|
|
749
|
+
{
|
|
750
|
+
for (const { rel, content, scan } of files) {
|
|
751
|
+
// Only properly-marked action files (the extension boundary). A `'use
|
|
752
|
+
// server'` file WITHOUT the .server. extension is the use-server-needs-
|
|
753
|
+
// extension rule's job; do not double-flag it here.
|
|
754
|
+
if (!/\.server\.m?[jt]s$/.test(rel)) continue;
|
|
755
|
+
if (!hasUseServerDirective(content)) continue;
|
|
756
|
+
// Count function-shaped EXPORTED callables, the SAME way the action
|
|
757
|
+
// registrar sees them: function declarations + arrow / function-expression
|
|
758
|
+
// consts (with an optional `: Type` annotation, #495). Reserved verb-config
|
|
759
|
+
// names (method/cache/...) are config, never a callable action.
|
|
760
|
+
const names = new Set();
|
|
761
|
+
let m;
|
|
762
|
+
const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
|
|
763
|
+
while ((m = reFn.exec(scan))) names.add(m[1]);
|
|
764
|
+
const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=(?!>)\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
|
|
765
|
+
while ((m = reArrow.exec(scan))) names.add(m[1]);
|
|
766
|
+
const callables = [...names].filter((n) => !RESERVED_CONFIG.has(n));
|
|
767
|
+
if (callables.length > 0) continue; // exports at least one callable -> fine
|
|
768
|
+
// A default export is assumed callable (an action commonly default-exports).
|
|
769
|
+
if (/\bexport\s+default\b/.test(scan)) continue;
|
|
770
|
+
// Conservative, avoid a FALSE POSITIVE: the runtime registrar
|
|
771
|
+
// (`actionFunctionNames`) keeps EVERY export whose value is a function at
|
|
772
|
+
// load time, regardless of the export syntax, so any export shape these
|
|
773
|
+
// patterns cannot prove is non-callable must NOT be flagged. Skip when the
|
|
774
|
+
// file has:
|
|
775
|
+
// - a named-export clause `export { a, b as c }` (with or WITHOUT `from`):
|
|
776
|
+
// it can surface a local function (`function getX(){}; export { getX }`)
|
|
777
|
+
// or a re-exported / imported function, neither matched above;
|
|
778
|
+
// - a star re-export `export * from ...`;
|
|
779
|
+
// - a destructuring export `export const { x } = obj` / `export const [x] = arr`,
|
|
780
|
+
// which may bind a function;
|
|
781
|
+
// - an `export const NAME = <identifier-or-call>`, a factory-produced
|
|
782
|
+
// function (`export const get = cache(fetch)`).
|
|
783
|
+
// A sole `export { aConst }` of a non-function is then a tolerated FALSE
|
|
784
|
+
// NEGATIVE, which is the right bias for a non-overridable correctness rule.
|
|
785
|
+
if (/\bexport\s*\{/.test(scan)) continue;
|
|
786
|
+
if (/\bexport\s*\*/.test(scan)) continue;
|
|
787
|
+
if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) continue;
|
|
788
|
+
// A factory / identifier-valued const could be a callable ACTION, so skip,
|
|
789
|
+
// BUT only when its name is NOT a reserved config key: the registrar
|
|
790
|
+
// excludes reserved names from actions (`actionFunctionNames`), so a file
|
|
791
|
+
// whose only ambiguous const is `validate` / `tags` / ... (config produced
|
|
792
|
+
// by an arrow or a factory) still has zero actions and must flag. Skip only
|
|
793
|
+
// for a NON-reserved ambiguous const (a real possible action).
|
|
794
|
+
const reFactory = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=\s*[A-Za-z_$(]/g;
|
|
795
|
+
let ambiguousAction = false;
|
|
796
|
+
while ((m = reFactory.exec(scan))) { if (!RESERVED_CONFIG.has(m[1])) { ambiguousAction = true; break; } }
|
|
797
|
+
if (ambiguousAction) continue;
|
|
798
|
+
// Every export is provably non-callable (a literal const, a type) or there
|
|
799
|
+
// are none: the directive exposes nothing.
|
|
800
|
+
violations.push({
|
|
801
|
+
rule: 'use-server-exports-callable',
|
|
802
|
+
file: rel,
|
|
803
|
+
message:
|
|
804
|
+
"File declares `'use server'` but exports no callable action. The `'use server'` directive registers FUNCTION exports as RPC-callable; a file exporting only a non-function (a `const` / a type / only verb config) registers nothing, so a client import resolves to nothing and the call 404s.",
|
|
805
|
+
fix: "Export an `async function` action from this file, or remove the `'use server'` directive if it is a plain server-only utility.",
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
736
810
|
// --- Rule: one-action-per-configured-file (#488) ---
|
|
737
811
|
// A `'use server'` file that declares HTTP-verb config (method/cache/tags/
|
|
738
812
|
// invalidates/validate) must export exactly one callable action; the config
|
|
@@ -749,7 +823,13 @@ export async function checkConventions(appDir) {
|
|
|
749
823
|
// An arrow-const action: `export const x = (...) => ...`, the paren-less
|
|
750
824
|
// `export const x = id => ...`, or a function expression. The `=>` /
|
|
751
825
|
// `function` anchor keeps a plain `export const N = 5` from counting.
|
|
752
|
-
|
|
826
|
+
// An OPTIONAL `: Type` annotation may sit between the name and the `=`
|
|
827
|
+
// (#495); the type itself can contain a function-type `=>`, so the
|
|
828
|
+
// annotation matcher consumes any non-`=` char OR a literal `=>`, and the
|
|
829
|
+
// assignment is the first `=` NOT followed by `>` (`=(?!>)`). The
|
|
830
|
+
// alternation is unambiguous at each position (a `=` can only start `=>`),
|
|
831
|
+
// so there is no catastrophic backtracking.
|
|
832
|
+
const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=(?!>)\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
|
|
753
833
|
while ((m = reArrow.exec(scan))) names.add(m[1]);
|
|
754
834
|
if (/\bexport\s+default\b/.test(scan)) names.add('default');
|
|
755
835
|
const actions = [...names].filter((n) => !RESERVED_CONFIG.has(n));
|
package/src/cors.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reusable CORS primitive for webjs.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 2. The `expose()` REST path (`actions.js`) reuses `resolveOrigin` and
|
|
10
|
-
* `applyCorsHeaders` so a route's `cors` config and a standalone
|
|
11
|
-
* `cors()` middleware compute identical headers.
|
|
4
|
+
* `cors(options)` returns a webjs MIDDLEWARE `(req, next) => Response`,
|
|
5
|
+
* usable in `middleware.js` (root or per-segment) or wrapped around a
|
|
6
|
+
* `route.js` handler. This is the public app-facing API. The shared
|
|
7
|
+
* origin-resolution + header-building core (`resolveOrigin` /
|
|
8
|
+
* `applyCorsHeaders`) is exported for reuse.
|
|
12
9
|
*
|
|
13
10
|
* Web-standards-first: the option shape mirrors the well-trodden
|
|
14
11
|
* `cors` npm package and the Fetch CORS protocol. We reflect the
|
package/src/csrf.js
CHANGED
|
@@ -20,10 +20,11 @@ const webCrypto = /** @type {Crypto} */ (globalThis.crypto);
|
|
|
20
20
|
* they cannot set the header to the matching value.
|
|
21
21
|
*
|
|
22
22
|
* Notes on scope:
|
|
23
|
-
* - Applies to internal RPC only. `
|
|
24
|
-
* CSRF-protected
|
|
25
|
-
* consumers;
|
|
26
|
-
* signed request, API key)
|
|
23
|
+
* - Applies to internal RPC only. A `route.ts` REST endpoint (hand-written
|
|
24
|
+
* or via the `route()` adapter) is *not* CSRF-protected because it is
|
|
25
|
+
* intended for external consumers; such an endpoint should carry its own
|
|
26
|
+
* auth (bearer token, signed request, API key) the app provides via
|
|
27
|
+
* middleware.
|
|
27
28
|
*/
|
|
28
29
|
|
|
29
30
|
export const CSRF_COOKIE = 'webjs_csrf';
|
package/src/dev.js
CHANGED
|
@@ -56,11 +56,6 @@ import {
|
|
|
56
56
|
serveActionStub,
|
|
57
57
|
serveServerOnlyStub,
|
|
58
58
|
invokeAction,
|
|
59
|
-
matchExposedAction,
|
|
60
|
-
matchAllAtPath,
|
|
61
|
-
invokeExposedAction,
|
|
62
|
-
buildPreflightResponse,
|
|
63
|
-
withCors,
|
|
64
59
|
isServerFile,
|
|
65
60
|
hasUseServerDirective,
|
|
66
61
|
hashFile,
|
|
@@ -1707,34 +1702,6 @@ async function handleCore(req, ctx) {
|
|
|
1707
1702
|
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError);
|
|
1708
1703
|
}
|
|
1709
1704
|
|
|
1710
|
-
// expose()d server actions (first-class REST), with optional CORS support.
|
|
1711
|
-
if (method === 'OPTIONS') {
|
|
1712
|
-
const allAtPath = matchAllAtPath(state.actionIndex, path);
|
|
1713
|
-
if (allAtPath.length) {
|
|
1714
|
-
const corsRoute = allAtPath.find((r) => r.cors);
|
|
1715
|
-
const methods = [...new Set(allAtPath.map((r) => r.method))];
|
|
1716
|
-
if (corsRoute) {
|
|
1717
|
-
// Preflight: respond with cors headers + the union of methods at this path.
|
|
1718
|
-
const preflight = buildPreflightResponse(corsRoute, req);
|
|
1719
|
-
const newHeaders = new Headers(preflight.headers);
|
|
1720
|
-
newHeaders.set('access-control-allow-methods', `${methods.join(', ')}, OPTIONS`);
|
|
1721
|
-
return new Response(null, { status: preflight.status, headers: newHeaders });
|
|
1722
|
-
}
|
|
1723
|
-
return new Response(null, { status: 204, headers: { allow: `${methods.join(', ')}, OPTIONS` } });
|
|
1724
|
-
}
|
|
1725
|
-
} else {
|
|
1726
|
-
const exposed = matchExposedAction(state.actionIndex, method, path);
|
|
1727
|
-
if (exposed) {
|
|
1728
|
-
// Pass the onError sink (issue #239): an exposed REST handler that throws
|
|
1729
|
-
// unexpectedly is reported to the APM hook before the sanitized 500, the
|
|
1730
|
-
// same as the RPC action path (phase 'action' covers both server-action
|
|
1731
|
-
// invocation shapes).
|
|
1732
|
-
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
1733
|
-
const resp = await invokeExposedAction(state.actionIndex, exposed.route, exposed.params, req, onActionError);
|
|
1734
|
-
return withCors(resp, exposed.route, req);
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
1705
|
// Static: /public/*
|
|
1739
1706
|
if (path.startsWith('/public/') || path === '/favicon.ico') {
|
|
1740
1707
|
const p = path === '/favicon.ico' ? '/public/favicon.ico' : path;
|
package/src/importmap.js
CHANGED
|
@@ -265,9 +265,9 @@ export function buildCoreEntries(coreDir, distMode) {
|
|
|
265
265
|
/** @type {Record<string, string>} */
|
|
266
266
|
const out = {
|
|
267
267
|
// Bare specifier: browser-only entry, slim by design (drops
|
|
268
|
-
// render-server,
|
|
269
|
-
//
|
|
270
|
-
//
|
|
268
|
+
// render-server, setCspNonceProvider). Node-side consumers
|
|
269
|
+
// resolve via the package.json exports `default` condition and
|
|
270
|
+
// land on `index.js` instead.
|
|
271
271
|
'@webjsdev/core': distMode
|
|
272
272
|
? '/__webjs/core/dist/webjs-core-browser.js'
|
|
273
273
|
: '/__webjs/core/index-browser.js',
|