@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 +5 -3
- package/package.json +1 -1
- package/resources/AGENTS.md +7 -7
- package/resources/agent-docs/advanced.md +42 -1
- package/resources/agent-docs/components.md +2 -0
- package/resources/agent-docs/recipes.md +33 -37
- package/resources/agent-docs/testing.md +1 -1
- package/resources/agent-docs/typescript.md +25 -0
- package/src/mcp.js +87 -9
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` (
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
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": {
|
package/resources/AGENTS.md
CHANGED
|
@@ -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`, `
|
|
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).
|
|
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()`). **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
133
|
+
## Validate a server action's input (#245)
|
|
134
134
|
|
|
135
|
-
`
|
|
136
|
-
SERVER-SIDE before the action body on
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
(input)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
`
|
|
204
|
-
fieldErrors }` return becomes a 422 JSON response there; a
|
|
205
|
-
THROWS (the classic `Schema.parse` style) becomes a 400, and a
|
|
206
|
-
return transforms the input
|
|
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/
|
|
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,
|
|
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
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
const idx = await buildActionIndex(appDir, false
|
|
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
|
|
353
|
+
let src = '';
|
|
286
354
|
try {
|
|
287
|
-
|
|
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
|
}
|