@webjsdev/server 0.8.25 → 0.8.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -257,8 +257,8 @@ export declare function handleApi(
257
257
  // actions.js (server-action scanner + RPC endpoint)
258
258
  // ---------------------------------------------------------------------------
259
259
 
260
- /** Scan the app for `.server.{js,ts}` files and build the RPC + expose index. */
261
- export declare function buildActionIndex(appDir: string, dev: boolean, opts?: { skipExposeLoad?: boolean }): Promise<unknown>;
260
+ /** Scan the app for `.server.{js,ts}` files and build the pure file -> hash RPC index. */
261
+ export declare function buildActionIndex(appDir: string, dev: boolean): Promise<unknown>;
262
262
  /** Whether a file path is a `.server.{js,ts,mjs,mts}` server file. */
263
263
  export declare function isServerFile(file: string): boolean;
264
264
  /** SHA-256 hash of an action file's absolute path (the RPC endpoint addressing scheme). */
@@ -276,6 +276,22 @@ export declare function invokeAction(
276
276
  onError?: (error: unknown) => void,
277
277
  ): Promise<Response>;
278
278
 
279
+ // ---------------------------------------------------------------------------
280
+ // action-route.js
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Wrap a plain `'use server'` action as a `route.ts`-style REST handler (#488).
285
+ * Merges query + route params + JSON body into one input object, runs the
286
+ * optional boundary validator, and dispatches through the request abort signal
287
+ * + the optional middleware chain. The always-works baseline is a hand-written
288
+ * `route.ts` that imports and calls the action; this is the convenience.
289
+ */
290
+ export declare function route<A, R>(
291
+ action: (input: A, ctx: { req: Request; params: Record<string, string> }) => R | Promise<R>,
292
+ opts?: { validate?: (input: any) => any; middleware?: Function[] },
293
+ ): (req: Request, ctx?: { params?: Record<string, string> }) => Promise<Response>;
294
+
279
295
  // ---------------------------------------------------------------------------
280
296
  // importmap.js
281
297
  // ---------------------------------------------------------------------------
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.26",
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
@@ -749,7 +749,13 @@ export async function checkConventions(appDir) {
749
749
  // An arrow-const action: `export const x = (...) => ...`, the paren-less
750
750
  // `export const x = id => ...`, or a function expression. The `=>` /
751
751
  // `function` anchor keeps a plain `export const N = 5` from counting.
752
- const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
752
+ // An OPTIONAL `: Type` annotation may sit between the name and the `=`
753
+ // (#495); the type itself can contain a function-type `=>`, so the
754
+ // annotation matcher consumes any non-`=` char OR a literal `=>`, and the
755
+ // assignment is the first `=` NOT followed by `>` (`=(?!>)`). The
756
+ // alternation is unambiguous at each position (a `=` can only start `=>`),
757
+ // so there is no catastrophic backtracking.
758
+ const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=(?!>)\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
753
759
  while ((m = reArrow.exec(scan))) names.add(m[1]);
754
760
  if (/\bexport\s+default\b/.test(scan)) names.add('default');
755
761
  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',