@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 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
  // ---------------------------------------------------------------------------
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.25",
3
+ "version": "0.8.27",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -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`/`expose()` boundary, like `validate`.
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
+ }
@@ -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,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, relative, sep } from 'node:path';
5
- import { getExposed } from '@webjsdev/core';
4
+ import { join, sep } from 'node:path';
6
5
  import { walk } from './fs-walk.js';
7
6
  import { verify as verifyCsrf, CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
8
7
  import { getSerializer } from './serializer.js';
9
- import { resolveOrigin } from './cors.js';
10
8
  import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
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 `expose()` REST path (`invokeExposedAction`) so
48
- * the contract is identical across transports (#245). The framework only CALLS
49
- * the validator and shapes the result; it ships no validation library.
46
+ * path (`invokeAction`) and the `route()` REST adapter so the contract is
47
+ * identical across transports (#245). The framework only CALLS the validator and
48
+ * shapes the result; it ships no validation library.
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; only `expose()` files load.
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 POST endpoints at /__webjs/action/:hash/:fn for
135
- * RPC-callable actions only.
136
- * 4. If an exported function was wrapped in `expose('METHOD /path', fn)`,
137
- * also registers it as a first-class REST endpoint.
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, opts = {}) {
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. The one thing that DOES need eager loading is expose(),
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, httpRoutes, appDir, dev };
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 attached via `validateInput(fn, ...)`
505
- // or `expose(spec, fn, { validate })` runs SERVER-SIDE before the body on
506
- // this RPC path too, not just the REST path. The RPC stub sends an args
507
- // array; an action conventionally takes one input object, so validate the
508
- // FIRST arg (matching how the REST path validates its single merged object).
509
- // Validation is a BOUNDARY concern (#488): the validator is the `validate`
510
- // config export (the new model), or the legacy `expose`/`validateInput`
511
- // attachment. It runs on this RPC boundary on the FIRST arg (the conventional
512
- // single input object), never on a direct server-to-server call.
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
- const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
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
- * Two surfaces share one origin-resolution + header-building core:
5
- *
6
- * 1. `cors(options)` returns a webjs MIDDLEWARE `(req, next) => Response`,
7
- * usable in `middleware.js` (root or per-segment) or wrapped around a
8
- * `route.js` handler. This is the public app-facing API.
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. `expose()`d REST endpoints are *not*
24
- * CSRF-protected by default because they're intended for external
25
- * consumers; those endpoints should carry their own auth (bearer token,
26
- * signed request, API key) which the app provides via middleware.
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, expose, setCspNonceProvider). Node-side
269
- // consumers resolve via the package.json exports `default`
270
- // condition and land on `index.js` instead.
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',