@webjsdev/mcp 0.1.0 → 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.0",
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": {
@@ -107,7 +107,7 @@ A page/layout module still **loads** in the browser for its top-level side effec
107
107
 
108
108
  ## Framework source: where to find it
109
109
 
110
- Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `ts-plugin/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `check`, plus a knowledge layer (docs / recipes / framework source).
110
+ Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `intellisense/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `check`, plus a knowledge layer (docs / recipes / framework source).
111
111
 
112
112
  ---
113
113
 
@@ -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
  |---|---|
@@ -158,10 +158,9 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
158
158
  | `register(tag, C)` | Tag binding. Auto-called by `Class.register('tag')`. |
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
- | `notFound()` / `redirect(url)` | Throw to return 404, or 307/308 redirect. |
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
- | `Suspense({fallback, children})` | Streaming boundary. `repeat` keyed-list directive is also re-exported. |
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
+ | `Suspense({fallback, children})` | Page/region-level streaming boundary (a value in a hole). `repeat` keyed-list directive is also re-exported. |
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. |
165
164
  | `connectWS(url, handlers)` / `richFetch<T>` | Client WebSocket (auto-reconnect, queued sends); content-negotiated rich-type fetch. |
166
165
  | `navigate(url, opts?)` / `revalidate(url?)` | Programmatic client-router nav; evict the BROWSER snapshot cache. |
167
166
  | `optimistic(signal, value, action)` | Set `signal` immediately, run `action`, roll back on error or `{ success: false }`. |
@@ -196,7 +195,9 @@ MyThing.register('my-thing');
196
195
 
197
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).
198
197
 
199
- **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/ts-plugin` to `tsconfig.json` `plugins` manually (JetBrains). Full deep-dive in `agent-docs/components.md` + `agent-docs/lit-muscle-memory-gotchas.md`.
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`.
199
+
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`.
200
201
 
201
202
  ---
202
203
 
@@ -239,11 +240,15 @@ Optional app-root file default-exporting a **schema object** (env-var names to a
239
240
  | Plain `.ts` | yes | **Lint violation** (`use-server-needs-extension`). Rename to add `.server.`. |
240
241
  | Plain `.ts` | no | Browser-safe; standard. |
241
242
 
242
- 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`.
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.
244
+
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`.
246
+
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.
243
248
 
244
- ### RPC + expose security
249
+ ### RPC + REST endpoint security
245
250
 
246
- 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`.
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`.
247
252
 
248
253
  ### Components (`components/*.{js,ts}`)
249
254
 
@@ -18,6 +18,25 @@ fallback flushes immediately, and the resolved content streams in as a
18
18
  `<template>` + inline `__webjsResolve('id')` script when the promise
19
19
  lands. Nested Suspense supported.
20
20
 
21
+ ### Component-level streaming: `<webjs-suspense>` (#471)
22
+
23
+ `Suspense({ fallback, children })` above is the page/region-level primitive (a promise passed as `children`). With **async render** (`agent-docs/components.md`), a COMPONENT is the suspending unit: a component doing `async render() { const u = await getUser(this.uid); … }` BLOCKS the first byte by default (real data in the first paint). To STREAM a slow component, wrap it in the renderer-recognized `<webjs-suspense>` element:
24
+
25
+ ```js
26
+ html`
27
+ <webjs-suspense .fallback=${html`<p>Loading section…</p>`}>
28
+ <user-profile uid="42"></user-profile>
29
+ <user-activity uid="42"></user-activity>
30
+ </webjs-suspense>
31
+ `;
32
+ ```
33
+
34
+ `.fallback` is read at SSR as the inline placeholder (`render-server.js`'s `processSuspenseElements` carries it via `data-webjs-fallback`, since a `TemplateResult` is not serializer-safe) and flushed on the first byte; the children push to `ctx.pending` and stream in via the same `<template data-webjs-resolve>` engine. Multiple boundaries resolve via `Promise.all`, so they fetch concurrently (no server waterfall). One boundary groups several components under one fallback (the boundary `.fallback` wins over a contained component's `renderFallback()`), and a throwing component inside is isolated to its own error state while siblings stream. Without a streaming context (`renderToString`) the children render inline (blocking).
35
+
36
+ ### Progressive soft-nav streaming (#473)
37
+
38
+ On a client-router navigation to a streamed page, the router applies the response PROGRESSIVELY: the SSR stream flushes the shell (with fallbacks) plus a `<!--wj-stream-shell-->` sentinel, the router's `readStreamedShell` swaps the shell in immediately and advances the URL, then `streamBoundariesProgressively` applies each resolved boundary into the live DOM as it streams (fast-before-slow), upgrading the custom elements inside. So a soft nav matches the initial-load experience instead of buffering the whole response. A non-streaming page is read to completion and applied once; a navigation superseded mid-stream stops and cancels the reader; a mid-stream transport failure leaves the applied boundaries in place (non-destructive).
39
+
21
40
  ## First-paint performance without a build step
22
41
 
23
42
  Five stacked zero-build optimizations:
@@ -42,10 +61,20 @@ Five stacked zero-build optimizations:
42
61
  `connectedCallback` activation. Ideal for below-the-fold widgets.
43
62
  5. **Auto-vendor via jspm.io (Rails 7 + importmap-rails posture).** At
44
63
  startup the server scans client-reachable source for bare npm import
45
- specifiers. Each `pkg@version` is resolved through `api.jspm.io/generate`
46
- to a CDN URL (`https://ga.jspm.io/npm:<pkg>@<version>/...`) and added
47
- to the import map; the browser fetches each package directly from
48
- the CDN. **SRI integrity (SHA-384) is computed on BOTH paths.** A
64
+ specifiers. The WHOLE scanned set is resolved in ONE
65
+ `api.jspm.io/generate` call (a single `install[]` array) so jspm
66
+ computes one mutually-consistent dependency graph: a directly-imported
67
+ package and a transitive that needs a newer version of the same package
68
+ agree on one CDN URL, instead of skewing (a direct dep pinned to its
69
+ local version while a transitive floats to jspm-latest, which crashed
70
+ the browser with a missing export). Each resolved entry is a CDN URL
71
+ (`https://ga.jspm.io/npm:<pkg>@<version>/...`) added to the import map;
72
+ the browser fetches each package directly from the CDN. If the unified
73
+ call fails because some install is unresolvable (a 401 for a private or
74
+ server-only dep), the resolver falls back to per-package isolation for
75
+ the offending install(s) and re-resolves the resolvable survivors as one
76
+ graph, so one bad dep can never collapse the whole map.
77
+ **SRI integrity (SHA-384) is computed on BOTH paths.** A
49
78
  live-resolved (unpinned) app hashes each cross-origin bundle at warmup
50
79
  and emits `integrity` + `crossorigin` on the importmap and modulepreload
51
80
  tags, so a swapped or compromised CDN response is rejected by the browser
@@ -60,7 +89,19 @@ Five stacked zero-build optimizations:
60
89
  stable boot-time build id with no warmup fetch); `webjs vendor pin
61
90
  --download` also caches the bundle bytes locally under
62
91
  `.webjs/vendor/<pkg>@<version>.js` for air-gapped / strict-CSP
63
- deployments. No bundler runs at any point.
92
+ deployments. No bundler runs at any point. **`webjs doctor` validates
93
+ importmap coherence** (#450): for each resolved package it checks that the
94
+ version pinned for every OTHER resolved package it depends on satisfies the
95
+ declared dependency / peer range, and WARNS naming both packages, the range,
96
+ and the pinned version when they skew (the class of bug where a pinned
97
+ package needs a newer minor of another pinned package than is pinned, so a
98
+ symbol it expects is missing at runtime). It is a validation over the
99
+ produced importmap, not a re-resolution and not bundling, and it runs the
100
+ SAME check with the SAME verdict over the live importmap and the vendored
101
+ `.webjs/vendor/importmap.json` (vendoring freezes the runtime-resolved
102
+ graph, so a coherent live graph stays coherent vendored). It reads
103
+ dependency metadata from the installed `node_modules` (no network of its
104
+ own) and degrades to "could not verify" when a manifest is unavailable.
64
105
 
65
106
  ## No-build production model
66
107
 
@@ -70,9 +111,10 @@ production. The Rails 7+ / Hotwire pattern:
70
111
 
71
112
  - **Importmap-driven**: bare-specifier imports (`from "react"`) are
72
113
  resolved via `<script type="importmap">` emitted into the document
73
- head. By default each package resolves through `api.jspm.io/generate`
74
- to a `https://ga.jspm.io/npm:<pkg>@<version>/...` URL and the browser
75
- fetches it from the CDN directly, with an `integrity` SRI hash on every
114
+ head. By default the whole scanned set resolves through one
115
+ `api.jspm.io/generate` call (one consistent graph, see point 5 above)
116
+ to `https://ga.jspm.io/npm:<pkg>@<version>/...` URLs and the browser
117
+ fetches them from the CDN directly, with an `integrity` SRI hash on every
76
118
  cross-origin entry (computed live at warmup for an unpinned app, or read
77
119
  from the pin file for a pinned one). `webjs vendor pin` commits the
78
120
  resolved URLs + SHA-384 integrity hashes to `.webjs/vendor/importmap.json`
@@ -80,7 +122,12 @@ production. The Rails 7+ / Hotwire pattern:
80
122
  caches each bundle to `.webjs/vendor/<pkg>@<version>.js` and rewrites
81
123
  the importmap to `/__webjs/vendor/<pkg>@<version>.js` so the server
82
124
  serves the bytes from disk (air-gapped / strict-CSP path). No bundler
83
- runs at any point.
125
+ runs at any point. Because the pins are meant to be committed,
126
+ `webjs vendor pin` makes its output committable: the scaffold already
127
+ un-ignores `.webjs/vendor/`, and if a `.gitignore` would swallow the
128
+ pins it heals it (or prints how to commit them when the ignore is not
129
+ in the app's own `.gitignore`). A no-vendor app, which never pins, is
130
+ unaffected.
84
131
  - **Per-file ESM serving**: every app `.js` / `.ts` becomes its own HTTP
85
132
  resource. The browser walks the import graph and fetches each module
86
133
  on demand.
@@ -313,7 +360,7 @@ export async function GET(req) {
313
360
  ### Behavior
314
361
 
315
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.
316
- - **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).
317
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.
318
365
 
319
366
  ### `credentials: true` requires an explicit origin allowlist (spec, enforced, warned)
@@ -760,16 +807,24 @@ The action receives `{ request, params, searchParams, url, formData }`
760
807
  and returns an `ActionResult`. The server interprets the result:
761
808
 
762
809
  - **Success** (`{ success: true, redirect? }`, or any non-`false`
763
- result, or a thrown `redirect()`): a `303 See Other` to
764
- `result.redirect` if present, else the page's own path
765
- (Post/Redirect/Get, so a reload does not resubmit).
810
+ result): a `303 See Other` to `result.redirect` if present, else the
811
+ page's own path (Post/Redirect/Get, so a reload does not resubmit).
812
+ This 303 is the success-result path only; a `redirect()` *thrown* from
813
+ the action is a separate case (see below).
766
814
  - **Failure** (`{ success: false, fieldErrors?, values?, status? }`):
767
815
  re-SSR the SAME page with `status` (default `422`) and the result on
768
816
  `ctx.actionData`. The page reads `actionData.fieldErrors.<field>` for
769
817
  messages and `actionData.values.<field>` to repopulate native
770
818
  `<input value=...>`, so the user's typed input survives.
771
- - A thrown `notFound()` yields a 404, a thrown `redirect()` keeps its
772
- own 307/308 status (PRG uses 303 only for the success-result path).
819
+ - A thrown `notFound()` yields a 404. A thrown `redirect()` carries no
820
+ baked-in status; the catching site picks the convention: thrown from an
821
+ action (a POST) it defaults to `307 Temporary Redirect`
822
+ (method-preserving, so the bounce keeps the POST's intent), thrown
823
+ during a GET page/layout render or gate it defaults to `302 Found` (the
824
+ code an auth bounce conventionally wants). An explicit status overrides
825
+ either default, in both the positional `redirect(url, 308)` and the
826
+ options `redirect(url, { status })` forms. PRG's 303 (above) is only
827
+ the success-result path, distinct from these thrown defaults.
773
828
 
774
829
  A page WITHOUT an `action` export keeps the old behavior, a non-GET to
775
830
  it 404s. There is no form library: native input repopulation plus the
@@ -802,6 +857,20 @@ mounted across nav and keep their `scrollTop` natively.
802
857
 
803
858
  ### `<webjs-frame>` escape hatch
804
859
 
860
+ `<webjs-frame>` is webjs's take on **Turbo Frames** (Hotwire Turbo), so the
861
+ mental model and most `<turbo-frame>` muscle memory transfer directly: a lazy,
862
+ URL-addressable region that swaps on its own, driven by a link or form
863
+ targeting its id. It is the unit for a region that loads or refreshes
864
+ INDEPENDENTLY of a full-page navigation (something a page/layout, which
865
+ re-renders only at the route level, cannot express), and it ships zero
866
+ component JS. A frame's route can itself use `<webjs-suspense>`, so a deferred
867
+ (`loading="lazy"`) frame whose data is slow streams that data behind a fallback
868
+ inside the frame (one caveat: a streamed framed route skips the byte-saving
869
+ subtree extraction, so the full page renders and the client slices out the
870
+ region). See the data-fetching doc page (`docs/app/docs/data-fetching/page.ts`)
871
+ for the async-render vs `<webjs-suspense>` vs `<webjs-frame>` vs `<webjs-stream>`
872
+ decision boundary.
873
+
805
874
  For partial-swap regions NOT tied to a folder layout (a marketing-page
806
875
  widget, tabbed UI, etc.), wrap the region in a frame:
807
876
 
@@ -954,6 +1023,13 @@ those fallback children).
954
1023
 
955
1024
  ### Stream actions: surgical element-level updates (#248)
956
1025
 
1026
+ `<webjs-stream>` is webjs's take on **Turbo Streams** (Hotwire Turbo); the
1027
+ action set (`append` / `prepend` / `before` / `after` / `replace` / `update` /
1028
+ `remove`) mirrors `<turbo-stream>`, so that muscle memory transfers directly. It
1029
+ is the ONLY surgical single-element update primitive (and the live-channel
1030
+ applier); a region swap or a `<webjs-frame>` reload redraws a whole region, and
1031
+ `<webjs-suspense>` is for streaming, so none of them cover "append one row".
1032
+
957
1033
  A region swap (a layout marker or a `<webjs-frame>`) is the right tool for "this
958
1034
  part of the page changed". It is too coarse for "append ONE comment", "remove
959
1035
  ONE row", "bump a count", or "insert a toast". For those, a server response can
@@ -1020,6 +1096,47 @@ branch return a normal `ActionResult` instead. `renderStream` is auto-registered
1020
1096
  by the client router, so it (and the `<webjs-stream>` element) is available
1021
1097
  wherever a layout imports `@webjsdev/core/client-router`.
1022
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
+
1023
1140
  ### Opt out per link
1024
1141
 
1025
1142
  ```html
@@ -277,6 +277,16 @@ if (!session) throw redirect('/login');
277
277
 
278
278
  JWT sessions by default (stateless, scales horizontally). OAuth providers handle the full redirect flow.
279
279
 
280
+ `auth()` resolves `{ user }` where `user` is `Record<string, unknown>` by default. To read the custom fields the callbacks set (`session.user.id`, `session.user.role`) without a cast and with typo-checking, type the session. Augment `AuthUser` for global typing, or use `createAuth<AppUser>(...)` for per-instance typing:
281
+
282
+ ```ts
283
+ declare module '@webjsdev/server' {
284
+ interface AuthUser { id: string; role: 'admin' | 'member'; }
285
+ }
286
+ ```
287
+
288
+ Both are opt-in and types-only. See `agent-docs/typescript.md` and the auth recipe.
289
+
280
290
  ## File storage (`FileStore` + `diskStore`)
281
291
 
282
292
  webjs round-trips a native `File` / `Blob` / `FormData` over the wire, and the file-storage primitive decides WHERE the bytes land. The model mirrors the cache / session adapters: a documented `FileStore` interface, a default local-disk adapter (`diskStore`), and a module singleton (`setFileStore` / `getFileStore`) so an app swaps the backend in one call without touching any call site.
@@ -351,7 +361,7 @@ See the "Receive and persist an uploaded file" recipe in `agent-docs/recipes.md`
351
361
  | `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` | GitHub OAuth (optional) |
352
362
  | `SESSION_SECRET` | Cookie session signing |
353
363
  | `REDIS_URL` | When set, sessions + rate limit + cache use Redis |
354
- | `PORT` | Server port (default 8080) |
364
+ | `PORT` | Server port. Precedence `--port` > `PORT` (real env or `.env`) > 8080 (#447) |
355
365
 
356
366
  ## Scaling to multiple instances
357
367
 
@@ -50,6 +50,96 @@ For component-local state, create an instance signal in the constructor and call
50
50
 
51
51
  See [`/docs/lifecycle`](https://docs.webjs.com/docs/lifecycle) for per-hook usage examples.
52
52
 
53
+ ## Async render: bare-await data fetch (#469)
54
+
55
+ A component can fetch its own server data into the first paint. `render()` may be `async`, so you write the natural line directly:
56
+
57
+ ```ts
58
+ class UserProfile extends WebComponent {
59
+ static properties = { uid: { type: String } };
60
+ declare uid: string;
61
+ async render() {
62
+ const u = await getUser(this.uid); // a 'use server' action: real fn at SSR, RPC stub on the client
63
+ return html`<h3>${u.name}</h3>`;
64
+ }
65
+ }
66
+ UserProfile.register('user-profile');
67
+ ```
68
+
69
+ Writing `await` makes the function async by the JS rule, and every render path awaits a promise-returning `render()` automatically. There is no flag. Plain sync `render()` stays the zero-cost default.
70
+
71
+ **Three concerns, decoupled. Do not conflate them.**
72
+
73
+ 1. **SSR always blocks by default.** The server awaits `async render()`, so the resolved DATA is baked into the first paint. There is no fallback on first paint, ever. JS-off reads the data (a progressive-enhancement UPGRADE over a client-fetched `Task`, which shows nothing without JS).
74
+ 2. **The client re-fetch default is stale-while-revalidate.** When a prop / dependency change re-runs `async render()`, the previously rendered content stays until the new render resolves. No blank, no flash, no user code.
75
+ 3. **`renderFallback()` is the OPTIONAL re-fetch loading UI.** Define it to OVERRIDE the stale-while-revalidate default with a loading state (skeleton / spinner) shown DURING a client re-fetch. It is shown ONLY on a re-fetch, NEVER on the first paint, and it does NOT create a server-streaming boundary. It is a prop-aware method (not a static field), so it can branch on the component's current state.
76
+
77
+ ```ts
78
+ class UserActivity extends WebComponent {
79
+ static properties = { uid: { type: String } };
80
+ declare uid: string;
81
+ renderFallback() { return html`<div class="skeleton h-24"></div>`; } // shown only while a re-fetch is in flight
82
+ async render() {
83
+ const items = await getActivity(this.uid);
84
+ return html`<ul>${items.map((i) => html`<li>${i.label}</li>`)}</ul>`;
85
+ }
86
+ }
87
+ ```
88
+
89
+ **Errors are isolated per component by default, no user code.** A thrown `await getData()` (or any render throw) renders a component-scoped error state while its siblings render normally; it never bubbles to the route `error.js`. The default surfaces the tag and message in dev and renders a silent empty element in prod (no leak). Override `renderError(error)` only to customize the error UI:
90
+
91
+ ```ts
92
+ class Report extends WebComponent {
93
+ async render() { return html`<pre>${await getReport()}</pre>`; }
94
+ renderError(error) { return html`<p class="error">Could not load the report.</p>`; } // optional
95
+ }
96
+ ```
97
+
98
+ `Task` and a signal cannot replace this: a `Task` renders its pending state at SSR (it loses the first-paint data), and you cannot wrap a signal around your own `await` inside `render()`. So `async render()` plus `renderFallback()` is the only way to get SSR-first-paint data AND a custom re-fetch loading state.
99
+
100
+ **Decision rules (which tool to reach for):**
101
+
102
+ 1. Server data knowable at request time: fetch it IN the component with `async render()`. Co-located, no prop-drilling, data in the first paint. The default, simplest case.
103
+ 2. Client re-fetch where stale content would mislead: add `renderFallback()` for a loading state during the re-fetch.
104
+ 3. Genuinely CLIENT-ONLY data (depends on a click, viewport, localStorage, or live updates, and does NOT need to be in the first paint): use `Task` / signals plus an RPC action.
105
+ 4. Slow server data where blocking the first byte hurts: stream it. Wrap the component in `<webjs-suspense .fallback=${html\`…\`}>` to flush the fallback on the first byte and stream the data in (the only way to show a first-paint fallback; concurrent across boundaries; progressive on soft nav). Do it deliberately for slow regions, not by default.
106
+
107
+ **Anti-patterns (likely footguns):**
108
+
109
+ - Do NOT prop-drill server data through layers when the leaf component can fetch it itself.
110
+ - Do NOT put `await getData()` in a page / layout function if it can live in a component (page / layout fetches run sequentially, a route-level waterfall).
111
+ - Do NOT fetch in `connectedCallback` / `Task` for data that is knowable server-side (that yields a fallback-then-RPC, not first-paint data).
112
+ - Do NOT expect `renderFallback()` to affect the first paint (it is the CLIENT re-fetch loading state).
113
+ - Do NOT add `renderError()` on every component (isolation is automatic).
114
+
115
+ **How it works.** On the server the SSR walker already awaits a promise-returning `render()` and bakes the data in; a throw is caught per component and rendered as the error state. On the client, `update()` detects a thenable from `render()` and routes to a stale-while-revalidate commit: the current DOM stays until the promise resolves, a monotonic render token drops a superseded resolution (an out-of-order fetch never commits stale DOM), and a rejection routes to `renderError()`. `firstUpdated` / `updated` / `updateComplete` fire after the async commit lands. Only signal reads BEFORE the first `await` establish reactive dependencies.
116
+
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
+
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
+
121
+ ### Streaming a slow region with `<webjs-suspense>` (#471)
122
+
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.
124
+
125
+ ```ts
126
+ html`
127
+ <webjs-suspense .fallback=${html`<p>Loading section…</p>`}>
128
+ <user-profile uid="42"></user-profile>
129
+ <user-activity uid="42"></user-activity>
130
+ </webjs-suspense>
131
+ `;
132
+ ```
133
+
134
+ - **The fallback is on the first byte, the content streams in.** This is the ONLY way a fallback appears on the first paint. It is a deliberate choice for slow data (fast TTFB, accepting a fallback-then-content swap), exactly like page-level `Suspense`. PE-critical content stays unwrapped (blocking) so it is in the first paint with no JS.
135
+ - **Grouping + override.** One boundary wraps several components under ONE fallback. The boundary `.fallback` wins over a contained component's `renderFallback()`.
136
+ - **Concurrent.** Multiple `<webjs-suspense>` boundaries on a page fetch their data in parallel (each is a non-blocking boundary resolved together), so they stream fast-before-slow with no server waterfall.
137
+ - **Error-isolated.** A throwing component inside a boundary renders its component-scoped error state (via `renderError()` or the default) while its siblings stream normally.
138
+ - **Progressive on soft navigation (#473).** A client-router navigation to a streamed page applies the shell (with fallbacks) immediately, advances the URL, then streams each boundary in, matching the initial-load experience instead of buffering the whole response.
139
+ - **`.fallback` is special-cased at SSR:** the renderer reads it inline as the placeholder, never through the `data-webjs-prop-*` path (a `TemplateResult` is not serializer-safe). It must be an UNQUOTED property hole (`.fallback=${...}`, invariant 4).
140
+
141
+ Decision: a bare `async render()` for request-time data that should be in the first paint (the default); `<webjs-suspense>` ONLY when the data is slow enough that blocking the first byte is the worse tradeoff.
142
+
53
143
  ## Display-only components are elided from the browser
54
144
 
55
145
  A component that does no client-side work renders the same SSR'd HTML
@@ -113,6 +203,46 @@ which fails if a new `WebComponent` hook is added without teaching the
113
203
  analyser about it. If you add an interactivity feature to the framework,
114
204
  update that file.
115
205
 
206
+ ### Elision is what keeps a server import off a display-only page (and `webjs check` guards the seam)
207
+
208
+ Elision is also why a page can call a server-only utility and stay
209
+ browser-safe, and why the same code crashes once the page gains client
210
+ work. A page that does `const s = await auth()` (where `auth` comes from
211
+ a `lib/auth.server.ts` UTILITY, no `'use server'`) is fine while the page
212
+ is display-only: the framework elides the page, strips the server import,
213
+ and the browser never sees it. The moment the page also imports a
214
+ component to register it (`import '../components/workspace.ts'`), enables
215
+ the client router, or uses a reactive primitive, the page stops being
216
+ display-only, must load in the browser to do that work, and drags the
217
+ server import with it. In the browser that import is a throw-at-load
218
+ stub, so the page crashes the instant its module loads. `webjs typecheck`
219
+ and the rest of `webjs check` pass; only the running page fails. This was
220
+ the single biggest source of extra AI iterations when porting a real app.
221
+
222
+ `webjs check`'s `no-server-import-in-browser-module` rule catches it
223
+ statically. It reuses the SAME elision verdict described above (over the
224
+ module graph, scanned components, and route table), so it flags ONLY a
225
+ module that genuinely ships: a display-only page the framework elides is
226
+ never flagged, because its server import really is stripped. The fix it
227
+ suggests is the three legitimate shapes: gate the route in
228
+ `middleware.ts` (server-only, never shipped), call the server through a
229
+ `'use server'` ACTION (its browser stub is a working RPC, so it is
230
+ exempt), or register the component in a `layout.{ts,js}` so the page
231
+ elides again. Server-to-server imports (`.server.ts` importing
232
+ `.server.ts`) and `middleware.ts` / `route.ts` are never flagged.
233
+
234
+ The rule covers every module the build ships, not just pages: a shipping
235
+ component, and `error.{ts,js}` / `loading.{ts,js}` / `not-found.{ts,js}`
236
+ modules, are checked too. Those three boundaries always ship and are
237
+ never elided (only an elidable component import is ever stripped), so a
238
+ personalized 404 that does `await auth()` is the same throw-at-load crash
239
+ and is flagged. One known gap: a DYNAMIC `import('./x.server.ts')` is not
240
+ caught, because the framework's import scanner tracks only static
241
+ `import` / `export … from`, not the `import(` call form. That is
242
+ consistent with the rest of the framework (a dynamic import is also not
243
+ elided framework-wide, and its crash is deferred to call time, not module
244
+ load), so the rule leaves it to the runtime.
245
+
116
246
  ### Turning elision off
117
247
 
118
248
  Elision is on by default. To disable it app-wide, set `elide` to `false`
@@ -205,3 +205,21 @@ Returns JSON describing the live build, alongside the `/__webjs/health` and `/__
205
205
  ```
206
206
 
207
207
  `version` is the `@webjsdev/server` framework version (read from its own `package.json`), `build` is the published importmap build id (the same fingerprint the client router reads from `data-webjs-build`; empty until the vendor map resolves), `node` is the running Node version, `uptime` is process uptime in seconds. Mechanism: `requestId()` / `setRequestId` in `packages/server/src/context.js`, `buildInfo` / `buildInfoResponse` in `packages/server/src/build-info.js`, all wired in `packages/server/src/dev.js`.
208
+
209
+ ---
210
+
211
+ ## Server port resolution (`--port` / `PORT` / `.env`) (#447)
212
+
213
+ Both `webjs dev` and `webjs start` resolve the listen port with a single
214
+ precedence: **`--port` flag > `PORT` (a real exported env var OR a `PORT`
215
+ in the app's `.env`) > `8080`**. A real exported `PORT` still wins over a
216
+ `.env` `PORT`, matching the `.env` auto-load's shell-wins-over-file rule
217
+ (`process.loadEnvFile` does not clobber an already-set var).
218
+
219
+ The CLI loads `<appDir>/.env` BEFORE resolving the port, so a `PORT` set
220
+ only in `.env` is honored (it previously was not: the port was computed
221
+ from `process.env.PORT || 8080` before the server's own `.env` load ran,
222
+ so the file's `PORT` never reached the comparison and the server always
223
+ bound 8080). Mechanism: `loadAppEnv(appDir)` + the pure
224
+ `resolvePort(portFlag, env)` in `packages/cli/lib/port.js`, called by the
225
+ `dev` and `start` bins before listening.
@@ -181,7 +181,32 @@ client then renders the resolved state, causing a flash.
181
181
 
182
182
  `Task` is still useful for client-time async (interaction-triggered
183
183
  mutations, polling, websocket reactions). For initial-paint data, fetch
184
- in the page function instead.
184
+ in the page function OR in the component itself with an `async render()`
185
+ (see the next entry), which Lit does not have.
186
+
187
+ ### 2b. Lit has no async render; webjs does (#469)
188
+
189
+ Lit keeps `render()` synchronous (its async-SSR work signals a promise the
190
+ renderer awaits BEFORE a still-sync render). webjs lets `render()` itself be
191
+ `async`, so you write `const u = await getUser(this.id)` directly in the
192
+ component and SSR bakes the resolved data into the first paint. Three things
193
+ trip up Lit muscle memory:
194
+
195
+ - **SSR blocks by default. Streaming is NOT automatic.** A bare `async
196
+ render()` (no wrapper) renders real data in the first paint with no
197
+ fallback. There is no skeleton flash. To STREAM slow data (fallback on
198
+ first byte), wrap the region in `<webjs-suspense .fallback=${html`…`}>`.
199
+ Do not reach for a fallback expecting it to show on first load; the
200
+ unwrapped default is block-real-data, and streaming is the deliberate
201
+ opt-in for slow regions.
202
+ - **`renderFallback()` is NOT a first-paint concern.** It is the OPTIONAL
203
+ client re-fetch loading UI, shown only when a prop / dependency change
204
+ re-runs `async render()`, never on the first paint. The first-paint and
205
+ re-fetch defaults are both no-flash: SSR has the data, and a re-fetch keeps
206
+ the stale content (stale-while-revalidate) until the new render resolves.
207
+ - **You do not need an error boundary.** A thrown `await getData()` is
208
+ isolated to that component automatically (siblings render, the page does
209
+ not blank). Add `renderError()` only to customize the error UI.
185
210
 
186
211
  ### 3. Browser-only APIs in the constructor or `render()`
187
212
 
@@ -32,6 +32,12 @@ transition agents most often get wrong, so it is the first recipe.
32
32
  }
33
33
  ```
34
34
 
35
+ Start the dev server with `npm run dev` (the canonical command), NOT a bare
36
+ `webjs dev`: `npm run dev` runs the `predev` hook (`prisma generate`) first,
37
+ while `webjs dev` skips it and boots against an ungenerated client. A bare
38
+ `webjs dev` will now warn and point you back at `npm run dev`, but use the
39
+ npm script from the start.
40
+
35
41
  2. **Migrate.** Run the npm script (not the `webjs`/`prisma` binary directly,
36
42
  so the `predev` / `db:*` hooks fire):
37
43
 
@@ -124,12 +130,12 @@ export async function updateProfile(input: { name: string }) {
124
130
  Call it from a client component via a normal import. The dev server
125
131
  rewrites the import to a typed RPC stub.
126
132
 
127
- ## Validate a server action's input once, for both call paths (#245)
133
+ ## Validate a server action's input (#245)
128
134
 
129
- `validateInput(fn, validate)` attaches an input validator that runs
130
- SERVER-SIDE before the action body on EVERY call path (the RPC path a
131
- client component import takes AND the `expose()` REST route if the action
132
- 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`
133
139
  (`{ success: false, fieldErrors, status: 422 }`) the client reads as
134
140
  `result.fieldErrors`. The framework ships no validation library; the
135
141
  validator is a plain function (or a three-line zod adapter).
@@ -137,25 +143,23 @@ validator is a plain function (or a three-line zod adapter).
137
143
  ```ts
138
144
  // modules/posts/actions/create-post.server.ts
139
145
  'use server';
140
- import { validateInput } from '@webjsdev/core';
141
146
  import { prisma } from '../../../lib/prisma.server.ts';
142
147
 
143
- export const createPost = validateInput(
144
- // the action body: runs ONLY when validation passes
145
- async (input: { title: string; body: string }) => {
146
- const row = await prisma.post.create({ data: input });
147
- return { success: true, data: row };
148
- },
149
- // the validator: receives the action's FIRST argument
150
- (input) => {
151
- const fieldErrors: Record<string, string> = {};
152
- const title = String(input?.title || '').trim();
153
- if (!title) fieldErrors.title = 'Title is required';
154
- if (String(input?.body || '').length < 10) fieldErrors.body = 'Too short';
155
- if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
156
- return { success: true, data: { title, body: String(input.body) } }; // coerced input
157
- },
158
- );
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
+ }
159
163
  ```
160
164
 
161
165
  Reading the structured failure in a client component is just a property
@@ -181,23 +185,21 @@ result becomes the contract envelope.
181
185
  import { z } from 'zod';
182
186
  const Schema = z.object({ title: z.string().min(1), body: z.string().min(10) });
183
187
 
184
- export const createPost = validateInput(
185
- async (input) => { /* ... */ },
186
- (i) => {
187
- const r = Schema.safeParse(i);
188
- return r.success
189
- ? { success: true, data: r.data }
190
- : { success: false, fieldErrors: r.error.flatten().fieldErrors };
191
- },
192
- );
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) { /* ... */ }
193
195
  ```
194
196
 
195
- To ALSO expose the action as REST with the SAME validator, pass `validate`
196
- to `expose()` instead of using `validateInput`:
197
- `expose('POST /api/posts', fn, { validate })`. A `{ success: false,
198
- fieldErrors }` return becomes a 422 JSON response there; a validator that
199
- THROWS (the classic `Schema.parse` style) becomes a 400, and a non-envelope
200
- 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.
201
203
 
202
204
  ## Add a component
203
205
 
@@ -210,6 +212,48 @@ export class HelloWorld extends WebComponent {
210
212
  HelloWorld.register('hello-world');
211
213
  ```
212
214
 
215
+ ## Fetch data in a component (async render, #469)
216
+
217
+ A leaf component fetches its own server data into the first paint, co-located, with no prop-drilling. Make `render()` async and call a `'use server'` action directly (real fn at SSR, RPC stub on the client). SSR awaits it, so the data is in the first paint.
218
+
219
+ ```ts
220
+ // (a) blocking async render: real data in the first paint, the common case
221
+ class UserProfile extends WebComponent {
222
+ static properties = { uid: { type: String } };
223
+ declare uid: string;
224
+ async render() {
225
+ const u = await getUser(this.uid);
226
+ return html`<h3>${u.name}</h3>`;
227
+ }
228
+ }
229
+ UserProfile.register('user-profile');
230
+
231
+ // (b) stream a SLOW region (fallback on the first byte, content streams in)
232
+ html`
233
+ <webjs-suspense .fallback=${html`<p>Loading…</p>`}>
234
+ <user-profile uid="42"></user-profile>
235
+ </webjs-suspense>
236
+ `;
237
+
238
+ // (c) renderFallback() = the CLIENT re-fetch loading state (never the first paint)
239
+ class UserActivity extends WebComponent {
240
+ static properties = { uid: { type: String } };
241
+ declare uid: string;
242
+ renderFallback() { return html`<div class="skeleton h-24"></div>`; }
243
+ async render() {
244
+ const items = await getActivity(this.uid);
245
+ return html`<ul>${items.map((i) => html`<li>${i.label}</li>`)}</ul>`;
246
+ }
247
+ }
248
+
249
+ // (d) errors are isolated by default: NO renderError() needed (add it only to customize)
250
+ class Report extends WebComponent {
251
+ async render() { return html`<pre>${await getReport()}</pre>`; }
252
+ }
253
+ ```
254
+
255
+ The client re-fetch default is stale-while-revalidate (the current content stays until the new render resolves). Use `<webjs-suspense>` only for genuinely slow data; keep `Task` / signals for client-only data that should NOT be in the first paint. Full decision guide in `agent-docs/components.md` and the `data-fetching` doc page.
256
+
213
257
  ## Form mutation with server-side validation (no JS required)
214
258
 
215
259
  This is webjs's progressive-enhancement write-path. A `<form method="POST">`
@@ -265,7 +309,7 @@ export default function Contact({ actionData }: {
265
309
  | Action outcome | HTTP response |
266
310
  |---|---|
267
311
  | success result (see the failure rule below) | `303 See Other` to a same-site `redirect` if present, else the page's own path (Post/Redirect/Get) |
268
- | thrown `redirect('/x')` | `307`/`308` (keeps the status `redirect()` was called with) |
312
+ | thrown `redirect('/x')` | `307` (method-preserving, the action default since it is a POST), or the explicit status if `redirect('/x', 308)` / `redirect('/x', { status })` was used. A redirect thrown during a plain GET page render instead defaults to `302`. |
269
313
  | thrown `notFound()` | `404` rendered via `not-found.{js,ts}` |
270
314
  | failure result (`success: false`, or `fieldErrors`, or an `error`) | re-SSR the SAME page with `status` (default `422`) and the result on `ctx.actionData` |
271
315
 
@@ -171,7 +171,7 @@ Naming convention: derive the scope class from the file path. Slashes
171
171
 
172
172
  Styles colocate with the markup as `const STYLES = css\`…\`` and
173
173
  interpolate via `<style>${STYLES.text}</style>`. The standalone
174
- `@webjsdev/ts-plugin` (and the `webjs` editor extension) resolves class
174
+ `@webjsdev/intellisense` (and the `webjs` editor extension) resolves class
175
175
  go-to-definition inside those blocks.
176
176
 
177
177
  ### Example: a page
@@ -71,10 +71,10 @@ packages/
71
71
  cli/test/ scaffold validation only
72
72
  ui/test/ schema, resolver, project-detect, components
73
73
  components/browser/ real-browser tests for the kit
74
- ts-plugin/test/plugin/ tsserver plugin tests (.test.mjs)
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
@@ -131,7 +131,7 @@ ui-website ship no test suite yet, so they are not in the job.
131
131
  3. Drop the test in directly when it's a node test; nest it
132
132
  inside `browser/` / `e2e/` / `smoke/` when it's that kind.
133
133
  4. Use `.test.js` for ESM packages, `.test.mjs` when the
134
- surrounding package is CJS (e.g. `@webjsdev/ts-plugin`).
134
+ surrounding package is CJS (e.g. `@webjsdev/intellisense`).
135
135
 
136
136
  ---
137
137
 
@@ -162,6 +162,12 @@ assert.ok(el.innerHTML.includes('10'));
162
162
 
163
163
  **Hydration-mismatch pattern.** To assert SSR and the hydrated DOM agree, normalise the SSR string (strip the `<!--webjs-hydrate-->` marker, `data-webjs-prop-*` attributes, part comments) and compare against the live `el.innerHTML`. The counterfactual is a component whose `render()` is non-deterministic across the SSR call and the hydration render; `ssrFixture` returns the live hydrated element, so the divergence is detectable. The worked tests live in `packages/core/test/testing/browser/ssr-fixture.test.js`, alongside the broader SSR-vs-client parity corpus in `packages/core/test/rendering/browser/ssr-client-parity.test.js`.
164
164
 
165
+ **Testing async render + streaming + error isolation (#469 / #471 / #473).** Split by layer:
166
+ - **SSR async render + error isolation** (unit, node, `renderToString`): assert an `async render()` bakes the resolved DATA into the HTML with no fallback markup, and that a throwing component renders a component-scoped error state while siblings render (worked in `packages/core/test/suspense/async-render-ssr.test.js`).
167
+ - **`<webjs-suspense>` streaming** (unit, node, `renderToStream` + a `suspenseCtx`): collect the stream and assert the fallback flushes first, then the `<template data-webjs-resolve>` boundaries, and that multiple boundaries fetch concurrently via a timing assertion that fails if serial (worked in `packages/core/test/suspense/webjs-suspense-ssr.test.js`).
168
+ - **Client async render** (browser, WTR): mount a component and `await el.updateComplete`, then assert stale-while-revalidate keeps prior content during a gated re-fetch, `renderFallback()` shows only on a re-fetch (never first paint), a rejected render commits `renderError()`, and the race guard drops a superseded resolution. Use a gate promise inside `render()` to control timing (worked in `packages/core/test/suspense/browser/async-render-client.test.js`).
169
+ - **Progressive soft-nav streaming** (e2e, `WEBJS_E2E=1`): soft-navigate to a streamed page and assert the fallback is live in the DOM at the moment the URL advances (a buffered swap would only advance the URL once the boundary already resolved), then that the boundary streams in. The DOM-free reader helpers are unit-tested in `packages/core/test/routing/progressive-stream.test.js`.
170
+
165
171
  ### `assertNoA11yViolations(el, opts?)` (opt-in)
166
172
 
167
173
  An OPT-IN accessibility assertion that runs the standard axe-core engine against an element's subtree in the WTR Chromium session. Nothing calls it for you, it is never a forced gate.
@@ -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
@@ -304,13 +329,47 @@ core prop / metadata types rather than redefining them. The runtime stays plain
304
329
  `.js` + JSDoc; the overlay is types-only with zero runtime cost. A drift test
305
330
  keeps `index.d.ts` in lockstep with `index.js`'s runtime exports.
306
331
 
332
+ ### Typing the `auth()` session user
333
+
334
+ `auth()` resolves `{ user }`, and by default `user` is `Record<string,
335
+ unknown>`, so reading a custom field your `session`/`jwt` callbacks set
336
+ (such as `session.user.id`) needs a cast and a typo is not caught. Two
337
+ opt-in ways make `user` typed, both types-only.
338
+
339
+ Augment the `AuthUser` interface (NextAuth/Auth.js style) to type every
340
+ `auth()` call across the app:
341
+
342
+ ```ts
343
+ declare module '@webjsdev/server' {
344
+ interface AuthUser {
345
+ id: string;
346
+ role: 'admin' | 'member';
347
+ }
348
+ }
349
+ ```
350
+
351
+ Or parameterise the factory to type one instance without a global
352
+ augmentation. `createAuth<AppUser>(...)` returns an `auth()` whose `user`
353
+ is `AppUser`:
354
+
355
+ ```ts
356
+ const { auth } = createAuth<AppUser>({ secret, providers });
357
+ const session = await auth();
358
+ session?.user.role; // typed, no cast
359
+ ```
360
+
361
+ Un-augmented and un-parameterised, `AuthUser` is empty and resolves back
362
+ to `Record<string, unknown>`, so pre-existing untyped code keeps
363
+ compiling. The declared fields should mirror what the callbacks write
364
+ onto `session.user`.
365
+
307
366
  ### TypeScript is not required
308
367
 
309
368
  JS + JSDoc gets the same call-site type safety. The TypeScript language
310
369
  server reads `@typedef` / `@param` / `@returns` identically to `.ts`
311
370
  syntax. Add `"checkJs": true` to enforce types in editor + CI.
312
371
 
313
- ## Editor plugin: `@webjsdev/ts-plugin`
372
+ ## Editor plugin: `@webjsdev/intellisense`
314
373
 
315
374
  **Editor-only. Not required for the framework to run.** The runtime
316
375
  has no dependency on it.
@@ -319,7 +378,7 @@ A single plugin with its OWN in-template intelligence (no Lit dependency; the `w
319
378
 
320
379
  ```jsonc
321
380
  "plugins": [
322
- { "name": "@webjsdev/ts-plugin" }
381
+ { "name": "@webjsdev/intellisense" }
323
382
  ]
324
383
  ```
325
384
 
package/src/mcp-docs.js CHANGED
@@ -284,6 +284,7 @@ export const PROMPTS = [
284
284
  { name: 'add_dynamic_route', description: 'Scaffold a dynamic page reading params, e.g. app/users/[id]/page.ts.', arguments: [{ name: 'route', description: 'URL path with a [param], e.g. /users/[id]', required: false }] },
285
285
  { name: 'add_server_action', description: 'Scaffold a server action (.server.ts + use server) called from a component.', arguments: [{ name: 'feature', description: 'Feature/module name', required: false }] },
286
286
  { name: 'add_component', description: 'Scaffold an interactive WebComponent (signals, light DOM, register).', arguments: [{ name: 'tag', description: 'Custom-element tag, e.g. my-thing', required: false }] },
287
+ { name: 'fetch_data_in_component', description: 'Fetch server data IN a component with async render() (co-located, first-paint data), plus webjs-suspense streaming and renderFallback.', arguments: [{ name: 'tag', description: 'Custom-element tag, e.g. user-profile', required: false }] },
287
288
  { name: 'add_module', description: 'Scaffold a modules/<feature>/ slice (actions/queries/components/utils).', arguments: [{ name: 'feature', description: 'Feature name', required: false }] },
288
289
  ];
289
290
 
@@ -364,6 +365,30 @@ const PROMPT_BODIES = {
364
365
  'a class-field initializer that overwrites a reactive accessor breaks reactivity',
365
366
  '(use `declare` + `static properties`).',
366
367
  ].join('\n'),
368
+ fetch_data_in_component: [
369
+ 'Fetch server data IN the component that needs it, with an async render() (#469).',
370
+ 'No prop-drilling: SSR awaits the render, so the DATA is in the first paint.',
371
+ '',
372
+ '```ts',
373
+ 'class UserProfile extends WebComponent {',
374
+ ' static properties = { uid: { type: String } };',
375
+ ' declare uid: string;',
376
+ ' async render() {',
377
+ ' const u = await getUser(this.uid); // a `use server` action: real fn at SSR, RPC stub on the client',
378
+ ' return html`<h3>${u.name}</h3>`;',
379
+ ' }',
380
+ '}',
381
+ "UserProfile.register('user-profile');",
382
+ '```',
383
+ '',
384
+ 'Decoupled model: SSR BLOCKS by default (real data, no fallback). The client',
385
+ 're-fetch default is stale-while-revalidate. `renderFallback()` is the OPTIONAL',
386
+ 're-fetch loading state (re-fetch only, NEVER first paint). Errors are isolated',
387
+ 'per component by default (add `renderError()` only to customize). For SLOW data,',
388
+ 'wrap in `<webjs-suspense .fallback=${html`...`}>` to STREAM it (fallback first).',
389
+ 'Keep `Task` / signals for client-only data (it shows pending at SSR). Read',
390
+ '`webjs-docs://components` for the full decision guide and anti-patterns.',
391
+ ].join('\n'),
367
392
  add_module: [
368
393
  'Add a feature module. `modules/<feature>/` holds `actions/*.server.ts` (mutations),',
369
394
  '`queries/*.server.ts` (reads), `components/*.ts` (feature UI), `utils/*.ts` (pure),',
package/src/mcp-source.js CHANGED
@@ -28,7 +28,7 @@ import { createRequire } from 'node:module';
28
28
  import { join, resolve, sep, relative } from 'node:path';
29
29
 
30
30
  /** The published framework packages whose source an agent may want to read. */
31
- export const FRAMEWORK_PACKAGES = ['core', 'server', 'cli', 'ts-plugin', 'ui'];
31
+ export const FRAMEWORK_PACKAGES = ['core', 'server', 'cli', 'intellisense', 'ui'];
32
32
 
33
33
  /** Source file extensions worth grepping / reading (text, not assets). */
34
34
  const TEXT_EXT = /\.(?:js|ts|mjs|mts|cjs|cts|json|md)$/i;
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
@@ -87,7 +94,7 @@ const SOURCE_SCHEMA = {
87
94
  },
88
95
  package: {
89
96
  type: 'string',
90
- description: 'Limit a no-args listing to one package (core, server, cli, ts-plugin, ui).',
97
+ description: 'Limit a no-args listing to one package (core, server, cli, intellisense, ui).',
91
98
  },
92
99
  },
93
100
  required: [],
@@ -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
  }