@webjsdev/mcp 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -25,9 +25,11 @@ subcommand) delegates to this same server, so both routes run identical code.
25
25
  ## What it exposes
26
26
 
27
27
  - **Introspection tools** (read-only, scoped to an `appDir`): `list_routes`,
28
- `list_actions` (with the `/__webjs/action/<hash>/<fn>` RPC endpoints),
29
- `list_components`, `check` (the structured `webjs check` violations). Each
30
- projects an existing `@webjsdev/server` data function and mutates nothing.
28
+ `list_actions` (RPC endpoints plus the full data contract: HTTP verb, cache
29
+ config, and boolean flags for tags/invalidates/validate/middleware; reserved
30
+ config exports are excluded from the callable-action list), `list_components`,
31
+ `check` (the structured `webjs check` violations). Each projects an existing
32
+ `@webjsdev/server` data function and mutates nothing.
31
33
  - **Knowledge layer**: an `init` mental-model primer, a `docs` retrieval tool,
32
34
  MCP `resources` (the `agent-docs/*` corpus + `AGENTS.md` as `webjs-docs://*`),
33
35
  and `prompts` (the recipes as guided workflows).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "The webjs Model Context Protocol server: live app introspection (routes / actions / components / check) plus the framework knowledge layer (docs, recipes, source) for AI coding agents",
6
6
  "bin": {
@@ -149,7 +149,7 @@ import { html, css, WebComponent, render } from '@webjsdev/core';
149
149
  import { renderToString } from '@webjsdev/core/server';
150
150
  ```
151
151
 
152
- The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server-only modules (`render-server.js`, `expose.js`, `setCspNonceProvider`); `renderToString` / `renderToStream` live at `@webjsdev/core/server` for Node-side consumers.
152
+ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server-only modules (`render-server.js`, `setCspNonceProvider`); `renderToString` / `renderToStream` live at `@webjsdev/core/server` for Node-side consumers.
153
153
 
154
154
  | Export | Purpose |
155
155
  |---|---|
@@ -159,8 +159,6 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
159
159
  | `render(v, el)` | Client-side render into a DOM element. |
160
160
  | `renderToString` | Server-side async render to HTML with DSD (from `/server`). |
161
161
  | `notFound()` / `redirect(url[, status])` | Throw to return 404, or a redirect. No-status default is convention-picked at the catching site: 302 for a GET page-render gate, 307 (method-preserving) for a server-action redirect. Override with `redirect(url, 308)` or `redirect(url, { status })`. |
162
- | `expose(p, fn)` | Tag a server action ALSO reachable at a REST path. Server-side only. |
163
- | `validateInput(fn, validate)` | Attach an input validator running on BOTH the RPC and `expose()` REST paths (#245). Server-side only. See Server actions. |
164
162
  | `Suspense({fallback, children})` | Page/region-level streaming boundary (a value in a hole). `repeat` keyed-list directive is also re-exported. |
165
163
  | `<webjs-suspense .fallback=${html\`…\`}>` | Component-level streaming boundary element (#471): wraps one or more components, flushes `.fallback` on the first byte, streams the resolved content in (concurrently across boundaries, progressively on soft nav). The renderer-recognized opt-in for SLOW async-render data. |
166
164
  | `connectWS(url, handlers)` / `richFetch<T>` | Client WebSocket (auto-reconnect, queued sends); content-negotiated rich-type fetch. |
@@ -197,7 +195,7 @@ MyThing.register('my-thing');
197
195
 
198
196
  **Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
199
197
 
200
- **Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, a `<slot>`, `static shadow = true`, `static refresh = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static refresh = true` (the explicit opt-in keeping the stale-while-revalidate on-load re-fetch that eliding drops, moot for request-stable data). **For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`…\`}>` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). (A hydration-seed optimization that would also skip the redundant client re-fetch on load is deferred, #472; stale-while-revalidate already hides it.)
198
+ **Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, a `<slot>`, `static shadow = true`, `static refresh = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static refresh = true` (the explicit opt-in keeping the stale-while-revalidate on-load re-fetch that eliding drops, moot for request-stable data). **For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`…\`}>` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). **The on-hydration re-fetch is itself eliminated by SSR action seeding (#472):** each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does NOT re-issue the RPC on hydration (a later refetch / arg-change still goes to the network). Keyed by action-hash + fn + serialized args, consume-once, fail-open (a miss degrades to a normal RPC, never wrong data). Captured via a transparent server-side `'use server'` facade (no source transform, no build step; the browser source tab and on-disk files are unchanged), default on, opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0`.
201
199
 
202
200
  **Light DOM (default) vs Shadow DOM.** Light DOM applies global CSS and Tailwind directly (default; for Tailwind/global CSS + simple composition). Shadow DOM (`static shadow = true`) is for `static styles` scoped CSS and third-party isolation; `<slot>` works in either. A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (invariant 7); prefer Tailwind. Install the `webjs` VSCode extension (`packages/editors/vscode`, VS Marketplace + Open VSX; also covers Cursor / Antigravity / Windsurf) or `webjs.nvim` (`packages/editors/nvim`, via lazy.nvim) for template highlighting + editor intelligence with no Lit plugin, or add the standalone `@webjsdev/intellisense` to `tsconfig.json` `plugins` manually (JetBrains). Full deep-dive in `agent-docs/components.md` + `agent-docs/lit-muscle-memory-gotchas.md`.
203
201
 
@@ -244,11 +242,13 @@ Optional app-root file default-exporting a **schema object** (env-var names to a
244
242
 
245
243
  The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap: its browser stub throws at module load, so a page / layout / component that ends up SHIPPING to the browser and transitively imports one crashes the moment the module loads, while `webjs typecheck` and the rest of `webjs check` pass. `webjs check`'s `no-server-import-in-browser-module` rule catches this statically by reusing the build's elision verdict (it only flags modules that genuinely ship; a display-only page the framework elides is fine, because the framework strips its server import). A `'use server'` action is exempt: its browser stub is a working RPC, which is the intended way to call the server from a shipping module.
246
244
 
247
- Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **Expose as REST:** `expose('METHOD /path', fn, { validate? })`. **Input validation runs on BOTH call paths (#245),** declared via `validateInput(fn, validate)` or `expose(..., { validate })`; the framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `agent-docs/recipes.md`.
245
+ Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **REST over HTTP is a `route.ts`** that imports and calls the action (optionally via the `route(action, opts?)` adapter from `@webjsdev/server`, which merges query + route params + JSON body into one input object and JSON-responds). **Input validation (#245)** is declared via the `export const validate` config export (read on the RPC boundary); a `route()` endpoint passes the same validator as its `{ validate }` option. The framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `agent-docs/recipes.md`.
248
246
 
249
- ### RPC + expose security
247
+ **HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`, run around the action on BOTH the RPC and `route.ts` (including the `route()` adapter) boundaries; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`, and reads the SSR seed (#472) first; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why webjs needs this and Next does not: webjs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod.
250
248
 
251
- Client to action RPC posts `x-webjs-csrf` matching the cookie issued on first SSR (mismatch 403); prod errors are sanitized to `message` only. `expose()`d REST endpoints are NOT CSRF-protected: authenticate every mutating endpoint, use `validate`, log without secrets, rate-limit. For CORS use the `cors()` middleware from `@webjsdev/server`; **`credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.** See `agent-docs/advanced.md`.
249
+ ### RPC + REST endpoint security
250
+
251
+ Client to action RPC posts `x-webjs-csrf` matching the cookie issued on first SSR (mismatch 403); prod errors are sanitized to `message` only. A `route.ts` REST endpoint (hand-written or via the `route()` adapter) is NOT CSRF-protected: authenticate every mutating endpoint, use `validate`, log without secrets, rate-limit. For CORS use the `cors()` middleware from `@webjsdev/server`; **`credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.** See `agent-docs/advanced.md`.
252
252
 
253
253
  ### Components (`components/*.{js,ts}`)
254
254
 
@@ -360,7 +360,7 @@ export async function GET(req) {
360
360
  ### Behavior
361
361
 
362
362
  - **Preflight.** An `OPTIONS` request carrying `Access-Control-Request-Method` short-circuits with a `204` carrying the Allow-Methods / Allow-Headers / Max-Age headers. `next()` is NOT called. A disallowed-origin preflight returns a bare `204` with no CORS headers, so the browser blocks the follow-up.
363
- - **Actual request.** `next()` runs, then `Access-Control-Allow-Origin` (plus credentials / exposed headers) is attached. A disallowed origin gets NO `Access-Control-Allow-Origin`, and the browser blocks the cross-origin read, but the server still serves the response (CORS is browser-enforced, not a server gate; this matches the `expose()` path, which never 403s a mismatched actual request).
363
+ - **Actual request.** `next()` runs, then `Access-Control-Allow-Origin` (plus credentials / exposed headers) is attached. A disallowed origin gets NO `Access-Control-Allow-Origin`, and the browser blocks the cross-origin read, but the server still serves the response (CORS is browser-enforced, not a server gate, so a mismatched actual request is never 403'd server-side).
364
364
  - **`Vary: Origin`.** Appended (never clobbering an existing `Vary`) whenever the allowed origin is dynamic (a reflected, per-origin value), so a shared cache keys on `Origin` and cannot poison one origin's response onto another. A constant `*` (no credentials) does not vary, so no `Vary` is added.
365
365
 
366
366
  ### `credentials: true` requires an explicit origin allowlist (spec, enforced, warned)
@@ -1096,6 +1096,47 @@ branch return a normal `ActionResult` instead. `renderStream` is auto-registered
1096
1096
  by the client router, so it (and the `<webjs-stream>` element) is available
1097
1097
  wherever a layout imports `@webjsdev/core/client-router`.
1098
1098
 
1099
+ ### Streaming RPC results: an action returns a stream (#489)
1100
+
1101
+ `<webjs-stream>` above is the SERVER-PUSHED render-side primitive (the server
1102
+ decides what changes and ships HTML actions). Streaming RPC is the complementary
1103
+ PULL-side primitive: a `'use server'` action that RETURNS a `ReadableStream`, an
1104
+ async iterable, or an async generator streams its chunks over the single RPC
1105
+ response, and the client gets back an async iterable to `for await`. This is the
1106
+ token-stream / progress / incremental-result case a component consumes
1107
+ imperatively after an interaction.
1108
+
1109
+ ```ts
1110
+ // modules/ai/actions/stream-answer.server.ts
1111
+ 'use server';
1112
+ export async function* streamAnswer(prompt: string) {
1113
+ for await (const token of llm.complete(prompt)) yield token;
1114
+ }
1115
+ ```
1116
+
1117
+ ```ts
1118
+ // inside a component
1119
+ for await (const token of await streamAnswer(q)) {
1120
+ this.text.set(this.text.get() + token); // renders incrementally
1121
+ }
1122
+ ```
1123
+
1124
+ The wire is a sequence of length-prefixed frames (`application/vnd.webjs+stream`):
1125
+ each chunk is rich-serialized (a `Date` / `Map` / `BigInt` round-trips), then a
1126
+ terminal frame closes the stream. Detection is purely on the RETURN value, so any
1127
+ verb (#488) can stream and there is no config export to declare. Back-pressure is
1128
+ respected (a slow consumer throttles a fast producer), and the request
1129
+ `AbortSignal` (#492) cancels the source generator on a client disconnect or a
1130
+ superseded `async render()`. A streamed result is never cached, ETagged, or
1131
+ seeded (#472); a mutation that streams still emits its `X-Webjs-Invalidate`
1132
+ header. A mid-stream throw surfaces as an error from the iterable (the HTTP status
1133
+ is already 200, so wrap the `for await` in `try/catch`), the author message in
1134
+ prod. A truncated stream (a server crash, a dropped connection) also throws
1135
+ rather than completing silently: a healthy stream always ends in an explicit
1136
+ terminal frame, so a missing one is an error. For a slow region you want behind a fallback on the FIRST paint, use
1137
+ `<webjs-suspense>` instead; streaming RPC is for an imperative stream consumed
1138
+ after an interaction. Full reference: the [Data fetching](https://docs.webjs.com/docs/data-fetching) page.
1139
+
1099
1140
  ### Opt out per link
1100
1141
 
1101
1142
  ```html
@@ -116,6 +116,8 @@ class Report extends WebComponent {
116
116
 
117
117
  **Elision (#474).** A **bare** `async render()` (no other client signal, light DOM) is **elided** like any display-only component: the SSR'd data is the complete first paint, so the framework drops the module and the redundant on-hydration re-fetch (a common content / docs leaf shape). It ships only when it ALSO carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, a `<slot>`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship. `static shadow = true` ships because Declarative Shadow DOM attaches only during HTML parsing, so a streamed (`<webjs-suspense>`) or soft-navigated shadow component arrives via a JS DOM insertion and needs its module to re-run `attachShadow`. `static refresh = true` is the explicit opt-in to keep the stale-while-revalidate on-load re-fetch that eliding drops (moot for request-stable data, the default; reach for it only when fresh-on-load matters). The only behaviour eliding removes is that on-load refresh; the first paint is byte-identical, verified by the differential-elision guard.
118
118
 
119
+ **SSR action seeding (#472).** For a SHIPPING async component (one that hydrates because it carries an interactivity signal), the on-hydration re-fetch is eliminated without eliding the module. Each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page as one `<script type="application/json" id="__webjs-seeds">` block, keyed by the action's hash + function name + serialized args. The generated client RPC stub reads that seed on its FIRST call: a hit resolves synchronously (no network, no hydration flicker), a later refetch or arg-change misses (consume-once) and goes to RPC as normal. So `const u = await getUser(this.id)` runs once, server-side, and its result is reused on the client's first render. The capture is a transparent server-side facade over the `'use server'` module (a synchronous `module.registerHooks` load hook wraps each export in a recording `Proxy`), so there is **no source transform and no build step**: the browser source tab and the on-disk files are byte-unchanged, and the RPC stub the browser fetches is the same shape as before (it just consults the seed first). Fail-open by construction: a key miss is always a normal RPC, never wrong data. Default on; opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0` (then the client re-fetches on hydration, hidden by stale-while-revalidate as before). A soft navigation carries the seed too: the client router ingests the incoming page's payload (`applySwap` -> `scanSeeds`) before its components hydrate, so a navigated async component also skips the refetch. Streamed regions (`<webjs-suspense>`) are NOT seeded (their data resolves after the first flush), so a slow boundary keeps the stale-while-revalidate refetch.
120
+
119
121
  ### Streaming a slow region with `<webjs-suspense>` (#471)
120
122
 
121
123
  `async render()` BLOCKS the first byte by default (the SSR HTML waits for the data). For a SLOW region where that wait hurts time-to-first-byte, wrap it in `<webjs-suspense>` to stream it: the fallback flushes immediately, the resolved content streams in.
@@ -130,12 +130,12 @@ export async function updateProfile(input: { name: string }) {
130
130
  Call it from a client component via a normal import. The dev server
131
131
  rewrites the import to a typed RPC stub.
132
132
 
133
- ## Validate a server action's input once, for both call paths (#245)
133
+ ## Validate a server action's input (#245)
134
134
 
135
- `validateInput(fn, validate)` attaches an input validator that runs
136
- SERVER-SIDE before the action body on EVERY call path (the RPC path a
137
- client component import takes AND the `expose()` REST route if the action
138
- has one). On failure it returns a structured `ActionResult`
135
+ Declare a `validate` config export beside the action (#488). It runs
136
+ SERVER-SIDE before the action body on the RPC boundary (the path a client
137
+ component import takes), receiving the action's FIRST argument. On failure
138
+ it returns a structured `ActionResult`
139
139
  (`{ success: false, fieldErrors, status: 422 }`) the client reads as
140
140
  `result.fieldErrors`. The framework ships no validation library; the
141
141
  validator is a plain function (or a three-line zod adapter).
@@ -143,25 +143,23 @@ validator is a plain function (or a three-line zod adapter).
143
143
  ```ts
144
144
  // modules/posts/actions/create-post.server.ts
145
145
  'use server';
146
- import { validateInput } from '@webjsdev/core';
147
146
  import { prisma } from '../../../lib/prisma.server.ts';
148
147
 
149
- export const createPost = validateInput(
150
- // the action body: runs ONLY when validation passes
151
- async (input: { title: string; body: string }) => {
152
- const row = await prisma.post.create({ data: input });
153
- return { success: true, data: row };
154
- },
155
- // the validator: receives the action's FIRST argument
156
- (input) => {
157
- const fieldErrors: Record<string, string> = {};
158
- const title = String(input?.title || '').trim();
159
- if (!title) fieldErrors.title = 'Title is required';
160
- if (String(input?.body || '').length < 10) fieldErrors.body = 'Too short';
161
- if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
162
- return { success: true, data: { title, body: String(input.body) } }; // coerced input
163
- },
164
- );
148
+ // the validator: receives the action's FIRST argument
149
+ export const validate = (input: any) => {
150
+ const fieldErrors: Record<string, string> = {};
151
+ const title = String(input?.title || '').trim();
152
+ if (!title) fieldErrors.title = 'Title is required';
153
+ if (String(input?.body || '').length < 10) fieldErrors.body = 'Too short';
154
+ if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
155
+ return { success: true, data: { title, body: String(input.body) } }; // coerced input
156
+ };
157
+
158
+ // the action body: runs ONLY when validation passes
159
+ export async function createPost(input: { title: string; body: string }) {
160
+ const row = await prisma.post.create({ data: input });
161
+ return { success: true, data: row };
162
+ }
165
163
  ```
166
164
 
167
165
  Reading the structured failure in a client component is just a property
@@ -187,23 +185,21 @@ result becomes the contract envelope.
187
185
  import { z } from 'zod';
188
186
  const Schema = z.object({ title: z.string().min(1), body: z.string().min(10) });
189
187
 
190
- export const createPost = validateInput(
191
- async (input) => { /* ... */ },
192
- (i) => {
193
- const r = Schema.safeParse(i);
194
- return r.success
195
- ? { success: true, data: r.data }
196
- : { success: false, fieldErrors: r.error.flatten().fieldErrors };
197
- },
198
- );
188
+ export const validate = (i: any) => {
189
+ const r = Schema.safeParse(i);
190
+ return r.success
191
+ ? { success: true, data: r.data }
192
+ : { success: false, fieldErrors: r.error.flatten().fieldErrors };
193
+ };
194
+ export async function createPost(input) { /* ... */ }
199
195
  ```
200
196
 
201
- To ALSO expose the action as REST with the SAME validator, pass `validate`
202
- to `expose()` instead of using `validateInput`:
203
- `expose('POST /api/posts', fn, { validate })`. A `{ success: false,
204
- fieldErrors }` return becomes a 422 JSON response there; a validator that
205
- THROWS (the classic `Schema.parse` style) becomes a 400, and a non-envelope
206
- return transforms the input (back-compat).
197
+ To ALSO expose the action over REST with the SAME validator, put it behind
198
+ a `route.ts` and pass the validator as the `route()` adapter's `{ validate }`
199
+ option: `export const POST = route(createPost, { validate })`. A
200
+ `{ success: false, fieldErrors }` return becomes a 422 JSON response there; a
201
+ validator that THROWS (the classic `Schema.parse` style) becomes a 400, and a
202
+ non-envelope return transforms the input.
207
203
 
208
204
  ## Add a component
209
205
 
@@ -74,7 +74,7 @@ packages/
74
74
  intellisense/test/plugin/ tsserver plugin tests (.test.mjs)
75
75
  test/ cross-package only
76
76
  ssr/ SSR pipeline (core + server)
77
- actions/ expose() (core + server)
77
+ actions/ server actions (core + server)
78
78
  serialization/ json-negotiation (core + server)
79
79
  scaffolds/ full app boots from CLI scaffolds
80
80
  blog/ examples/blog smoke + browser e2e
@@ -128,6 +128,31 @@ Supported: `Date`, `Map`, `Set`, `BigInt`, `Error`, `undefined`,
128
128
  instances come through as plain objects, with prototypes lost and methods
129
129
  gone (matches React Server Actions).
130
130
 
131
+ #### Catching a non-serializable signature at compile time (`SerializableActionFn`)
132
+
133
+ The class-instance / function caveat above is a silent runtime surprise: a
134
+ method-valued field or a function argument typechecks fine but vanishes on the
135
+ wire. The opt-in `SerializableActionFn` annotation turns it into a compile-time
136
+ error. webjs actions stay plain `export async function`s (the framework rewrites
137
+ the client import to an RPC stub at runtime, it does not wrap the authored
138
+ function), so the guard is an OPTIONAL type annotation the author applies when
139
+ they want it:
140
+
141
+ ```ts
142
+ import type { SerializableActionFn } from '@webjsdev/core';
143
+
144
+ export const getUser: SerializableActionFn<(id: number) => Promise<User>> =
145
+ async (id) => db.user.find(id);
146
+ // ^ if User carries a method (or any arg is a function), this is a type error
147
+ // pointing at the offending member, instead of a value that silently loses
148
+ // it on the wire.
149
+ ```
150
+
151
+ `Serializable<T>` (also exported) maps a fully serializable `T` to itself and a
152
+ non-serializable position (a function / method) to a branded `NonSerializable`
153
+ marker; `SerializableArgs` / `SerializableResult` apply it across an action's
154
+ parameters and (promise-unwrapped) return. All types-only, erased at runtime.
155
+
131
156
  ### API routes: opt in via content negotiation
132
157
 
133
158
  `route.ts` handlers use standard JSON by default so external consumers
package/src/mcp.js CHANGED
@@ -36,6 +36,13 @@ import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js';
36
36
 
37
37
  const PROTOCOL_VERSION = '2024-11-05';
38
38
 
39
+ // Mirrors packages/server/src/action-config.js. A drift test in
40
+ // packages/mcp/test/mcp.test.mjs asserts these stay in sync with the source.
41
+ /** HTTP verbs an action may declare (mirrors RPC_VERBS in action-config.js). */
42
+ const RPC_VERBS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
43
+ /** Reserved config export names in a 'use server' file (mirrors RESERVED_CONFIG in action-config.js). */
44
+ const RESERVED_CONFIG = new Set(['method', 'cache', 'tags', 'invalidates', 'validate', 'middleware']);
45
+
39
46
  /**
40
47
  * The four read-only tools. Each takes an optional `{ appDir }` (default the
41
48
  * server's cwd) and projects an EXISTING `@webjsdev/server` function's output
@@ -128,7 +135,7 @@ const TOOL_DEFS = [
128
135
  {
129
136
  name: 'list_actions',
130
137
  description:
131
- 'List registered server actions (the .server.{js,ts} files with "use server"): file, exported function name, and the /__webjs/action/<hash>/<fn> RPC endpoint. Read-only.',
138
+ 'List registered server actions (the .server.{js,ts} files with "use server"): file, exported function name, the /__webjs/action/<hash>/<fn> RPC endpoint, HTTP verb (method), cache config, and boolean flags for tags/invalidates/validate/middleware. Config-only exports (method, cache, tags, invalidates, validate, middleware) are NOT listed as actions. Read-only.',
132
139
  inputSchema: APPDIR_SCHEMA,
133
140
  },
134
141
  {
@@ -206,6 +213,67 @@ export function extractRouteMethods(src) {
206
213
  return METHODS.filter((m) => exported.has(m));
207
214
  }
208
215
 
216
+ /**
217
+ * Lexically extract the HTTP-verb action config declared as reserved sibling
218
+ * exports in a `'use server'` file (#488). Reads source text only (no module
219
+ * load) so there are no DB-init or Prisma side effects.
220
+ *
221
+ * Returned shape:
222
+ * {
223
+ * method: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE', // default 'POST'
224
+ * cache: string | null, // raw RHS literal when present, else null
225
+ * tags: boolean, // whether the tags export is declared
226
+ * invalidates: boolean, // whether the invalidates export is declared
227
+ * validate: boolean, // whether the validate export is declared
228
+ * middleware: boolean, // whether the middleware export is declared
229
+ * }
230
+ *
231
+ * @param {string} src
232
+ * @returns {{ method: string, cache: string|null, tags: boolean, invalidates: boolean, validate: boolean, middleware: boolean }}
233
+ */
234
+ export function extractActionConfig(src) {
235
+ // Extract `method` from: export const method = 'GET' (single/double/backtick)
236
+ const methodMatch = /\bexport\s+const\s+method\s*=\s*(['"`])([A-Za-z]+)\1/.exec(src);
237
+ let method = 'POST';
238
+ if (methodMatch) {
239
+ const upper = methodMatch[2].toUpperCase();
240
+ if (RPC_VERBS.has(upper)) method = upper;
241
+ }
242
+
243
+ // Extract `cache` RHS. Single-line: `export const cache = 60` -> '60'
244
+ // Multi-line object: capture balanced braces across lines.
245
+ let cache = null;
246
+ const cacheMatch = /\bexport\s+const\s+cache\s*=\s*/.exec(src);
247
+ if (cacheMatch) {
248
+ const rest = src.slice(cacheMatch.index + cacheMatch[0].length);
249
+ if (rest.startsWith('{')) {
250
+ // Capture the balanced-brace object (may span lines).
251
+ let depth = 0;
252
+ let end = 0;
253
+ for (let i = 0; i < rest.length; i++) {
254
+ if (rest[i] === '{') depth++;
255
+ else if (rest[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
256
+ }
257
+ cache = rest.slice(0, end + 1).trim();
258
+ } else {
259
+ // Single-line: read up to the first newline or semicolon.
260
+ const lineMatch = /^([^\n;]+)/.exec(rest);
261
+ if (lineMatch) cache = lineMatch[1].trim();
262
+ }
263
+ }
264
+
265
+ // Boolean presence of the remaining config names.
266
+ const exported = new Set(extractExportNames(src));
267
+ return {
268
+ method,
269
+ cache,
270
+ tags: exported.has('tags'),
271
+ invalidates: exported.has('invalidates'),
272
+ validate: exported.has('validate'),
273
+ middleware: exported.has('middleware'),
274
+ };
275
+ }
276
+
209
277
  /**
210
278
  * The literal URL path for a page/api directory: `blog/[slug]` -> `/blog/[slug]`,
211
279
  * the root `.` -> `/`. Route groups `(group)` and `_private` segments drop, the
@@ -274,23 +342,33 @@ export function makeToolRunners(deps) {
274
342
  },
275
343
 
276
344
  async list_actions(appDir) {
277
- // `skipExposeLoad` builds the file -> hash maps WITHOUT importing any
278
- // `expose()` module, so this stays truly read-only (no Prisma/DB init, and
279
- // no stray stdout from a loaded module corrupting the JSON-RPC channel).
280
- // The RPC hash is over the file path only, so no module load is needed.
281
- const idx = await buildActionIndex(appDir, false, { skipExposeLoad: true });
282
- /** @type {Array<{ file: string, fn: string, endpoint: string }>} */
345
+ // buildActionIndex is a pure file -> hash mapping that imports no module,
346
+ // so this stays truly read-only (no Prisma/DB init, and no stray stdout
347
+ // from a loaded module corrupting the JSON-RPC channel). The RPC hash is
348
+ // over the file path only, so no module load is needed.
349
+ const idx = await buildActionIndex(appDir, false);
350
+ /** @type {Array<{ file: string, fn: string, endpoint: string, method: string, cache: string|null, tags: boolean, invalidates: boolean, validate: boolean, middleware: boolean }>} */
283
351
  const actions = [];
284
352
  for (const [file, hash] of idx.fileToHash) {
285
- let names = [];
353
+ let src = '';
286
354
  try {
287
- names = extractExportNames(await readFile(file, 'utf8'));
355
+ src = await readFile(file, 'utf8');
288
356
  } catch {}
357
+ const names = extractExportNames(src);
358
+ const config = extractActionConfig(src);
289
359
  for (const fn of names) {
360
+ // Skip reserved config export names: they are config, not callable actions.
361
+ if (RESERVED_CONFIG.has(fn)) continue;
290
362
  actions.push({
291
363
  file: relative(appDir, file),
292
364
  fn,
293
365
  endpoint: `/__webjs/action/${hash}/${fn}`,
366
+ method: config.method,
367
+ cache: config.cache,
368
+ tags: config.tags,
369
+ invalidates: config.invalidates,
370
+ validate: config.validate,
371
+ middleware: config.middleware,
294
372
  });
295
373
  }
296
374
  }