@webjsdev/mcp 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,9 +25,11 @@ subcommand) delegates to this same server, so both routes run identical code.
25
25
  ## What it exposes
26
26
 
27
27
  - **Introspection tools** (read-only, scoped to an `appDir`): `list_routes`,
28
- `list_actions` (with the `/__webjs/action/<hash>/<fn>` RPC endpoints),
29
- `list_components`, `check` (the structured `webjs check` violations). Each
30
- projects an existing `@webjsdev/server` data function and mutates nothing.
28
+ `list_actions` (RPC endpoints plus the full data contract: HTTP verb, cache
29
+ config, and boolean flags for tags/invalidates/validate/middleware; reserved
30
+ config exports are excluded from the callable-action list), `list_components`,
31
+ `check` (the structured `webjs check` violations). Each projects an existing
32
+ `@webjsdev/server` data function and mutates nothing.
31
33
  - **Knowledge layer**: an `init` mental-model primer, a `docs` retrieval tool,
32
34
  MCP `resources` (the `agent-docs/*` corpus + `AGENTS.md` as `webjs-docs://*`),
33
35
  and `prompts` (the recipes as guided workflows).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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": {
@@ -14,10 +14,9 @@ reference there.
14
14
  | `agent-docs/components.md` | WebComponent deep-dive (controllers, hooks, light/shadow DOM, slots) |
15
15
  | `agent-docs/styling.md` | Tailwind helpers + vanilla-CSS opt-out conventions |
16
16
  | `agent-docs/built-ins.md` | Auth, sessions, env vars, caching (cache(), HTTP cache, asset-hash, conditional GET), rate-limit, broadcast, file storage |
17
- | `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, ingress caps) + observability |
17
+ | `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, ingress caps, dev/start task orchestration) + observability |
18
18
  | `agent-docs/advanced.md` | Suspense streaming, performance, bundling, client router (prefetch, frames, view transitions, stream actions), WebSockets |
19
19
  | `agent-docs/typescript.md` | TS at runtime + full-stack type safety |
20
- | `agent-docs/deployment.md` | Production, runtime targets, embedded use |
21
20
  | `agent-docs/service-worker.md` | The opt-in progressive-enhancement service worker (`public/sw.js`) |
22
21
  | `agent-docs/testing.md` | Unit, browser, convention validation, the `handle()` test harness (`@webjsdev/server/testing`) |
23
22
  | `agent-docs/framework-dev.md` | Monorepo dev (only when editing webjs itself): commands, repo-health git config, changelog flow, dev error overlay |
@@ -40,7 +39,7 @@ Claude Code enforces step 1 via `.claude/hooks/guard-branch-context.sh`. Other a
40
39
 
41
40
  ### Skills are routed deterministically, never skipped
42
41
 
43
- A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. Tests in `test/hooks/route-skills.test.mjs`.
42
+ A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. The skills themselves are committed under `.claude/skills/` (alongside the hooks), so a fresh clone has both the router and the skills it routes to (no machine-local dependency). Tests in `test/hooks/route-skills.test.mjs`, which also asserts every skill the hook references is committed in-repo.
44
43
 
45
44
  ### Autonomous mode (sandbox / bypass permissions)
46
45
 
@@ -78,7 +77,7 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, per-f
78
77
 
79
78
  An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. The component runtime API matches lit (reactive `static properties`, the lit lifecycle hooks, ReactiveControllers, the `lit-html` directive set, `html` / `css` templates) so lit training data transfers directly, but webjs ships its own no-build implementation under `packages/core/src/`. Decorators are the one lit exception (invariant 10); use `declare` + `static properties`.
80
79
 
81
- - **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped via Node 24+'s `module.stripTypeScriptTypes` (invariant 10 + `agent-docs/typescript.md`). Node 24+ is required, enforced by an early `assertNodeVersion()` preflight.
80
+ - **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped through a pluggable stripper (#508): Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, position-preserving) (invariant 10 + `agent-docs/typescript.md`). **Runs on Node 24+ or Bun** (run a Bun app with `bun --bun run dev` / `start`); the early `assertNodeVersion()` preflight enforces the Node floor and admits Bun. On Bun, `startServer` selects a native `Bun.serve` listener shell instead of the node:http one (skipping the compat bridge for ~1.9x more req/s on the listening path, at near-complete feature parity, the one node-only gap being 103 Early Hints since `Bun.serve` has no informational-response API), via a runtime-neutral seam that also sets up future `Deno.serve` / embedded adapters. Edge runtimes (no filesystem) are a separate, later target.
82
81
  - **SSR + CSR by default.** Pages are server-rendered HTML; components render light DOM by default, shadow DOM opt-in via `static shadow = true` with DSD SSR.
83
82
  - **Progressive enhancement is the default architecture.** Pages and components are SSR'd; with JS off, content reads, `<a>` navigates, `<form>` server actions submit, display-only elements render. JS is opt-in *per interactive behaviour* (`@click`, a reactive property assignment, a signal mutation). Never write a first paint that depends on hydration; never use `fetch` + JS where a `<form>` + server action would do.
84
83
  - **Display-only components are elided from the browser.** A component with no interactivity signal renders identical HTML with or without its JS, so the framework strips its import (and any vendor reachable only through it, importmap entry included) from the served source. Automatic, conservative, verified differentially. Disable with `"webjs": { "elide": false }` or `WEBJS_ELIDE=0`. See `agent-docs/components.md`.
@@ -101,7 +100,7 @@ A page/layout module still **loads** in the browser for its top-level side effec
101
100
 
102
101
  `route.{js,ts}` is the one routing file that is NOT isomorphic: a server-only HTTP handler (named `GET` / `POST` exports), the webjs equivalent of a Next route handler. It never ships to the client.
103
102
 
104
- **`.server.{js,ts}` is the one server boundary, an RPC + source-protection mechanism, NOT an RSC component.** With `'use server'` exports are RPC-callable (the browser import becomes a stub POSTing to `/__webjs/action/<hash>/<fn>`); without it the file is a server-only utility whose browser import **throws at module load** (Prisma, secrets, `node:*`, hashing). Consequence: **never import a no-`'use server'` util directly into a page, layout, or component** (it works at SSR but the client stub crashes on load); use it inside `'use server'` actions, `route.{js,ts}`, or `middleware`, and reach it from a page by importing a `'use server'` action (whose RPC stub loads safely client-side). This boundary, not a component annotation, is how a dependency is kept off the client (a date library used only during SSR belongs in `lib/format.server.ts`). See `agent-docs/components.md`.
103
+ **`.server.{js,ts}` is the one server boundary, an RPC + source-protection mechanism, NOT an RSC component.** With `'use server'` exports are RPC-callable (the browser import becomes a stub POSTing to `/__webjs/action/<hash>/<fn>`); without it the file is a server-only utility whose browser import **throws at module load** (the DB driver, secrets, `node:*`, hashing). Consequence: **never import a no-`'use server'` util directly into a page, layout, or component** (it works at SSR but the client stub crashes on load); use it inside `'use server'` actions, `route.{js,ts}`, or `middleware`, and reach it from a page by importing a `'use server'` action (whose RPC stub loads safely client-side). This boundary, not a component annotation, is how a dependency is kept off the client (a date library used only during SSR belongs in `lib/format.server.ts`). See `agent-docs/components.md`.
105
104
 
106
105
  ---
107
106
 
@@ -135,7 +134,7 @@ lib/ app-wide code (lib/*.server.js infra, lib/utils/ bro
135
134
  modules/<feature>/ feature-scoped: actions/ (mutations), queries/ (reads), components/, utils/, types.js
136
135
  components/*.js SHARED presentational primitives
137
136
  public/* static assets, served at /<name>
138
- prisma/schema.prisma data models
137
+ db/*.server.{js,ts} data layer (Drizzle: schema, columns, connection)
139
138
  ```
140
139
 
141
140
  Every file is a plain ES module.
@@ -149,7 +148,7 @@ import { html, css, WebComponent, render } from '@webjsdev/core';
149
148
  import { renderToString } from '@webjsdev/core/server';
150
149
  ```
151
150
 
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.
151
+ 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
152
 
154
153
  | Export | Purpose |
155
154
  |---|---|
@@ -159,8 +158,6 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
159
158
  | `render(v, el)` | Client-side render into a DOM element. |
160
159
  | `renderToString` | Server-side async render to HTML with DSD (from `/server`). |
161
160
  | `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
161
  | `Suspense({fallback, children})` | Page/region-level streaming boundary (a value in a hole). `repeat` keyed-list directive is also re-exported. |
165
162
  | `<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
163
  | `connectWS(url, handlers)` / `richFetch<T>` | Client WebSocket (auto-reconnect, queued sends); content-negotiated rich-type fetch. |
@@ -197,7 +194,7 @@ MyThing.register('my-thing');
197
194
 
198
195
  **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
196
 
200
- **Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, a `<slot>`, `static shadow = true`, `static refresh = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static refresh = true` (the explicit opt-in keeping the stale-while-revalidate on-load re-fetch that eliding drops, moot for request-stable data). **For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`…\`}>` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). (A hydration-seed optimization that would also skip the redundant client re-fetch on load is deferred, #472; stale-while-revalidate already hides it.)
197
+ **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
198
 
202
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/intellisense` to `tsconfig.json` `plugins` manually (JetBrains). Full deep-dive in `agent-docs/components.md` + `agent-docs/lit-muscle-memory-gotchas.md`.
203
200
 
@@ -244,11 +241,13 @@ Optional app-root file default-exporting a **schema object** (env-var names to a
244
241
 
245
242
  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
243
 
247
- Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **Expose as REST:** `expose('METHOD /path', fn, { validate? })`. **Input validation runs on BOTH call paths (#245),** declared via `validateInput(fn, validate)` or `expose(..., { validate })`; the framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `agent-docs/recipes.md`.
244
+ 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
245
 
249
- ### RPC + expose security
246
+ **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
247
 
251
- Client to action RPC posts `x-webjs-csrf` matching the cookie issued on first SSR (mismatch 403); prod errors are sanitized to `message` only. `expose()`d REST endpoints are NOT CSRF-protected: authenticate every mutating endpoint, use `validate`, log without secrets, rate-limit. For CORS use the `cors()` middleware from `@webjsdev/server`; **`credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.** See `agent-docs/advanced.md`.
248
+ ### RPC + REST endpoint security
249
+
250
+ 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
251
 
253
252
  ### Components (`components/*.{js,ts}`)
254
253
 
@@ -259,7 +258,7 @@ One custom element per file; call `Class.register('tag')` at module top level. S
259
258
  ## Modules architecture (preferred for non-trivial apps)
260
259
 
261
260
  - **`modules/<feature>/actions/*.server.{js,ts}`** mutations, **`queries/*.server.{js,ts}`** reads (one function per file), **`components/*.{js,ts}`** feature-owned components (shared UI in top-level `components/`), **`utils/*.{js,ts}`** pure helpers (no `'use server'`, no DB), **`types.{js,ts}`** typedefs.
262
- - **`lib/`** cross-cutting: `lib/*.server.{js,ts}` server-only infra (Prisma, session, hashing), `lib/utils/*` browser-safe helpers, `lib/*.ts` app-wide values.
261
+ - **`lib/`** cross-cutting: `lib/*.server.{js,ts}` server-only infra (session, hashing; the DB connection lives in `db/connection.server.ts`), `lib/utils/*` browser-safe helpers, `lib/*.ts` app-wide values.
263
262
 
264
263
  ### The `ActionResult<T>` envelope
265
264
 
@@ -294,7 +293,7 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
294
293
 
295
294
  > Hit one of these as a runtime error? The [Troubleshooting page](https://docs.webjs.com/docs/troubleshooting) is keyed by symptom (the throw-at-load server import, the backtick-in-template 500, the TypeScript strip failure, the SSR browser-global crash, the missing-frame swap) and maps each back to the invariant and the `webjs check` rule below.
296
295
 
297
- 1. **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** The `.server.{js,ts}` extension is the path-level boundary (the file router refuses to serve the source); a `'use server'` directive additionally makes exports RPC-callable, else the file is a server-only utility whose browser import is a throw-at-load stub. Importing `@prisma/client`, `node:*`, or any server-only dep from a component or an `app/**` page / layout / loading / error / not-found file crashes the browser at module load.
296
+ 1. **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** The `.server.{js,ts}` extension is the path-level boundary (the file router refuses to serve the source); a `'use server'` directive additionally makes exports RPC-callable, else the file is a server-only utility whose browser import is a throw-at-load stub. Importing a DB driver (`better-sqlite3` / `pg`), `node:*`, or any server-only dep from a component or an `app/**` page / layout / loading / error / not-found file crashes the browser at module load.
298
297
  2. **Every `*.server.{js,ts}` file with `'use server'` exports must be `async` functions returning serializer-safe values.** Args and results round-trip via webjs's wire. Files without `'use server'` (server-only utilities) can export anything, including singletons.
299
298
  3. **Custom element tag names must contain a hyphen** (HTML spec). Pass the tag to `Class.register('tag-name')`, not a static field. Any short-string quote works: `'tag-name'`, `"tag-name"`, or `` `tag-name` `` (single-line, no interpolation).
300
299
  4. **Event (`@`), property (`.`), boolean (`?`) holes in `html` must be unquoted**, e.g. `@click=${fn}`, never `@click="${fn}"`.
@@ -311,17 +310,17 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
311
310
 
312
311
  ## Scaffolding
313
312
 
314
- Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Prisma+SQLite), `webjs create <name> --template api` (backend-only routes + modules + Prisma, no SSR), `webjs create <name> --template saas` (auth + login/signup + protected dashboard + User model). Pick from the request: default for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce), `api` for an HTTP/JSON API with no UI, `saas` for accounts/login/signup; default to full-stack when ambiguous.
313
+ Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite), `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR), `webjs create <name> --template saas` (auth + login/signup + protected dashboard + User model). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). Pick from the request: default for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce), `api` for an HTTP/JSON API with no UI, `saas` for accounts/login/signup; default to full-stack when ambiguous.
315
314
 
316
- Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Prisma + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `prisma/schema.prisma` to real models FIRST, then `webjs db migrate <name>`, then build pages/actions/queries. **Treat the scaffold as REFERENCE, not the final product:** replace the example page / `User` model / components and adapt `app/layout.ts` (brand, nav, content width; the default `<main class="max-w-[760px]">` reading column needs widening for a full-bleed app). ENFORCED: examples carry a `webjs-scaffold-placeholder` comment and `no-scaffold-placeholder` fails until the content is replaced and the marker deleted. Docs at https://docs.webjs.com.
315
+ Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **Treat the scaffold as REFERENCE, not the final product:** replace the example page / `User` model / components and adapt `app/layout.ts` (brand, nav, content width; the default `<main class="max-w-[760px]">` reading column needs widening for a full-bleed app). ENFORCED: examples carry a `webjs-scaffold-placeholder` comment and `no-scaffold-placeholder` fails until the content is replaced and the marker deleted. Docs at https://docs.webjs.com.
317
316
 
318
317
  ---
319
318
 
320
319
  ## CLI reference
321
320
 
322
321
  ```sh
323
- webjs dev [--port N] # dev server with live reload
324
- webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2)
322
+ webjs dev [--port N] [--no-hot] # dev server with live reload (node --watch on Node, bun --hot on Bun). --no-hot runs in-process. Runs webjs.dev.before + webjs.dev.parallel (#550)
323
+ webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2). Runs webjs.start.before first (#550)
325
324
  webjs test [--server] [--browser] [--watch]
326
325
  webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop
327
326
  webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check
@@ -329,19 +328,19 @@ webjs doctor # project-health checklist; non-zero exit on
329
328
  webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258)
330
329
  webjs typecheck [tsc args...] # the project's own tsc --noEmit
331
330
  webjs create <name> [--template api|saas]
332
- webjs db <prisma-subcommand> [...]
331
+ webjs db <generate|migrate|push|studio|seed> # wraps drizzle-kit (+ runs db/seed.server.ts)
333
332
  webjs ui init | add <names...> | list | view <name>
334
333
  webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importmap pinning, .webjs/vendor/importmap.json
335
334
  ```
336
335
 
337
- `--from PROVIDER` accepts `jspm` (default), `jsdelivr`, `unpkg`, `skypack` and is persisted in the pin file. `PORT` is honoured when `--port` is absent; `webjs dev` emits `routes.d.ts` automatically. Running this repo's own apps (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`): always `cd` in and use **its** `npm run dev` / `npm start`, never `webjs dev` / `webjs start` directly (each composes extra watchers via `npm`).
336
+ `--from PROVIDER` accepts `jspm` (default), `jsdelivr`, `unpkg`, `skypack` and is persisted in the pin file. `PORT` is honoured when `--port` is absent; `webjs dev` emits `routes.d.ts` automatically. Running this repo's own apps (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`): `cd` in and use **its** `npm run dev` / `npm start`; as of #550 a bare `webjs dev` / `webjs start` is equivalent (the per-app orchestration moved into the `webjs.dev` / `webjs.start` tasks config, which the primitive runs), so the npm scripts are now just thin aliases.
338
337
 
339
338
  ---
340
339
 
341
340
  ## Environment, server config, caching, observability
342
341
 
343
342
  - **Env vars.** `process.env.X` reads are server-only; `WEBJS_PUBLIC_`-prefixed names are exposed in the browser via an inline `<script>` (no build); `NODE_ENV` is defined both sides. See `agent-docs/built-ins.md`.
344
- - **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), and ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts). Type it with `WebjsConfig` + the JSON Schema. See `agent-docs/configuration.md`.
343
+ - **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts), and dev/start task orchestration (`webjs.dev.before` / `webjs.dev.parallel` / `webjs.start.before`, #550, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`). Type it with `WebjsConfig` + the JSON Schema. See `agent-docs/configuration.md`.
345
344
  - **Caching + file storage** (`agent-docs/built-ins.md`). HTTP `Cache-Control`, the `cache()` query helper + `revalidateTag`, the server HTML response cache (`export const revalidate` + `revalidatePath`, #241), content-hash asset URLs (`?v=`, #243), conditional GET (ETag, #240), and `FileStore` + `diskStore` (streaming, traversal-safe, signed URLs, S3-pluggable, #247).
346
345
  - **Observability** (`agent-docs/configuration.md`). Access log, `requestId()` + `X-Request-Id`, the `onError` APM hook, `GET /__webjs/version` (#239).
347
346
 
@@ -386,11 +385,13 @@ export default async function User({ params }: { params: { id: string } }) {
386
385
  ```ts
387
386
  // modules/users/actions/update-profile.server.ts
388
387
  'use server';
389
- import { prisma } from '../../../lib/prisma.server.ts';
388
+ import { eq } from 'drizzle-orm';
389
+ import { db } from '../../../db/connection.server.ts';
390
+ import { users } from '../../../db/schema.server.ts';
390
391
  export async function updateProfile(input: { name: string }) {
391
392
  const name = String(input?.name || '').trim();
392
393
  if (!name) return { success: false, error: 'name required', status: 400 };
393
- const row = await prisma.user.update({ where: { id: me.id }, data: { name } });
394
+ const [row] = await db.update(users).set({ name }).where(eq(users.id, me.id)).returning();
394
395
  return { success: true, data: row };
395
396
  }
396
397
  ```
@@ -406,4 +407,4 @@ Not in v1. Do not implement as part of other tasks:
406
407
  - **Bundling and per-route code splitting.** webjs is **no-build** (the Rails 7 + importmap model); prod perf comes from HTTP/2 multiplex + `<link rel="modulepreload">` hints, not concatenation. **Do not propose a bundler or `webjs build`.**
407
408
  - **Vite-grade HMR with state preservation.** Custom elements only `define` once, so full reload is necessary; data reloads are near-instant via `fs.watch` to SSE.
408
409
  - **React Server Components Flight.** Server actions + `Suspense` streaming cover the need.
409
- - **Edge-runtime bundling / full portability** (see `agent-docs/deployment.md`), **i18n, image optimization** (layer libraries on top).
410
+ - **Edge-runtime bundling / full portability** (deployment guidance lives in the docs site at `/docs/deployment`), **i18n, image optimization** (layer libraries on top).
@@ -360,7 +360,7 @@ export async function GET(req) {
360
360
  ### Behavior
361
361
 
362
362
  - **Preflight.** An `OPTIONS` request carrying `Access-Control-Request-Method` short-circuits with a `204` carrying the Allow-Methods / Allow-Headers / Max-Age headers. `next()` is NOT called. A disallowed-origin preflight returns a bare `204` with no CORS headers, so the browser blocks the follow-up.
363
- - **Actual request.** `next()` runs, then `Access-Control-Allow-Origin` (plus credentials / exposed headers) is attached. A disallowed origin gets NO `Access-Control-Allow-Origin`, and the browser blocks the cross-origin read, but the server still serves the response (CORS is browser-enforced, not a server gate; this matches the `expose()` path, which never 403s a mismatched actual request).
363
+ - **Actual request.** `next()` runs, then `Access-Control-Allow-Origin` (plus credentials / exposed headers) is attached. A disallowed origin gets NO `Access-Control-Allow-Origin`, and the browser blocks the cross-origin read, but the server still serves the response (CORS is browser-enforced, not a server gate, so a mismatched actual request is never 403'd server-side).
364
364
  - **`Vary: Origin`.** Appended (never clobbering an existing `Vary`) whenever the allowed origin is dynamic (a reflected, per-origin value), so a shared cache keys on `Origin` and cannot poison one origin's response onto another. A constant `*` (no credentials) does not vary, so no `Vary` is added.
365
365
 
366
366
  ### `credentials: true` requires an explicit origin allowlist (spec, enforced, warned)
@@ -1096,6 +1096,47 @@ branch return a normal `ActionResult` instead. `renderStream` is auto-registered
1096
1096
  by the client router, so it (and the `<webjs-stream>` element) is available
1097
1097
  wherever a layout imports `@webjsdev/core/client-router`.
1098
1098
 
1099
+ ### Streaming RPC results: an action returns a stream (#489)
1100
+
1101
+ `<webjs-stream>` above is the SERVER-PUSHED render-side primitive (the server
1102
+ decides what changes and ships HTML actions). Streaming RPC is the complementary
1103
+ PULL-side primitive: a `'use server'` action that RETURNS a `ReadableStream`, an
1104
+ async iterable, or an async generator streams its chunks over the single RPC
1105
+ response, and the client gets back an async iterable to `for await`. This is the
1106
+ token-stream / progress / incremental-result case a component consumes
1107
+ imperatively after an interaction.
1108
+
1109
+ ```ts
1110
+ // modules/ai/actions/stream-answer.server.ts
1111
+ 'use server';
1112
+ export async function* streamAnswer(prompt: string) {
1113
+ for await (const token of llm.complete(prompt)) yield token;
1114
+ }
1115
+ ```
1116
+
1117
+ ```ts
1118
+ // inside a component
1119
+ for await (const token of await streamAnswer(q)) {
1120
+ this.text.set(this.text.get() + token); // renders incrementally
1121
+ }
1122
+ ```
1123
+
1124
+ The wire is a sequence of length-prefixed frames (`application/vnd.webjs+stream`):
1125
+ each chunk is rich-serialized (a `Date` / `Map` / `BigInt` round-trips), then a
1126
+ terminal frame closes the stream. Detection is purely on the RETURN value, so any
1127
+ verb (#488) can stream and there is no config export to declare. Back-pressure is
1128
+ respected (a slow consumer throttles a fast producer), and the request
1129
+ `AbortSignal` (#492) cancels the source generator on a client disconnect or a
1130
+ superseded `async render()`. A streamed result is never cached, ETagged, or
1131
+ seeded (#472); a mutation that streams still emits its `X-Webjs-Invalidate`
1132
+ header. A mid-stream throw surfaces as an error from the iterable (the HTTP status
1133
+ is already 200, so wrap the `for await` in `try/catch`), the author message in
1134
+ prod. A truncated stream (a server crash, a dropped connection) also throws
1135
+ rather than completing silently: a healthy stream always ends in an explicit
1136
+ terminal frame, so a missing one is an error. For a slow region you want behind a fallback on the FIRST paint, use
1137
+ `<webjs-suspense>` instead; streaming RPC is for an imperative stream consumed
1138
+ after an interaction. Full reference: the [Data fetching](https://docs.webjs.com/docs/data-fetching) page.
1139
+
1099
1140
  ### Opt out per link
1100
1141
 
1101
1142
  ```html
@@ -13,7 +13,7 @@ layer to debug.
13
13
  ```js
14
14
  // route handler
15
15
  export async function GET() {
16
- const posts = await prisma.post.findMany();
16
+ const posts = await db.query.posts.findMany();
17
17
  return new Response(JSON.stringify(posts), {
18
18
  headers: {
19
19
  'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
@@ -51,10 +51,10 @@ until the TTL expires:
51
51
  // modules/posts/queries/list-posts.server.ts
52
52
  'use server';
53
53
  import { cache } from '@webjsdev/server';
54
- import { prisma } from '../../../lib/prisma.server.ts';
54
+ import { db } from '../../../db/connection.server.ts';
55
55
 
56
56
  export const listPosts = cache(
57
- async () => prisma.post.findMany({ orderBy: { createdAt: 'desc' } }),
57
+ async () => db.query.posts.findMany({ orderBy: { createdAt: 'desc' } }),
58
58
  { key: 'posts', ttl: 60 }
59
59
  );
60
60
  ```
@@ -65,12 +65,12 @@ wrapper), add `tags`. It is either a static `string[]` or a function
65
65
 
66
66
  ```ts
67
67
  export const postById = cache(
68
- async (id: string) => prisma.post.findUnique({ where: { id } }),
68
+ async (id: string) => db.query.posts.findFirst({ where: { id } }),
69
69
  { key: 'post', ttl: 300, tags: (id) => ['post:' + id] } // per-entity tag
70
70
  );
71
71
 
72
72
  export const listPosts = cache(
73
- async () => prisma.post.findMany(),
73
+ async () => db.query.posts.findMany(),
74
74
  { key: 'posts', ttl: 60, tags: ['posts'] } // static tag
75
75
  );
76
76
  ```
@@ -83,9 +83,11 @@ no import of the wrapper):
83
83
  // modules/comments/actions/create-comment.server.ts
84
84
  'use server';
85
85
  import { revalidateTag, revalidatePath } from '@webjsdev/server';
86
+ import { db } from '../../../db/connection.server.ts';
87
+ import { comments } from '../../../db/schema.server.ts';
86
88
 
87
89
  export async function createComment(input) {
88
- await prisma.comment.create({ data: input });
90
+ await db.insert(comments).values(input);
89
91
  await revalidateTag('post:' + input.postId); // postById(postId) recomputes
90
92
  await revalidateTag('posts'); // listPosts recomputes
91
93
  await revalidatePath('/blog'); // also evict the cached HTML
@@ -163,7 +165,7 @@ Evict on a write with `revalidatePath`:
163
165
  import { revalidatePath } from '@webjsdev/server';
164
166
 
165
167
  export async function publishPost(input) {
166
- // ... persist via Prisma ...
168
+ // ... persist via Drizzle ...
167
169
  await revalidatePath('/blog'); // next /blog request re-renders fresh
168
170
  return { success: true };
169
171
  }
@@ -248,7 +250,7 @@ export const { auth, signIn, signOut, handlers } = createAuth({
248
250
  providers: [
249
251
  Credentials({
250
252
  async authorize(credentials) {
251
- const user = await prisma.user.findUnique({ where: { email: credentials.email } });
253
+ const user = await db.query.users.findFirst({ where: { email: credentials.email } });
252
254
  if (!user || !verifyPassword(credentials.password, user.passwordHash)) return null;
253
255
  return { id: user.id, name: user.name, email: user.email, role: user.role };
254
256
  },
@@ -116,6 +116,10 @@ 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 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). The install mechanism is runtime-neutral (#529): Node uses `module.registerHooks`, Bun uses a `Bun.plugin` `onLoad` (Bun has no `module.registerHooks`), and both emit the identical seed, so a page seeds the same on either runtime. 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
+ **Interactivity during a COLD on-hydration re-fetch (#528).** Seeding matters for more than a wasted round trip. When a SHIPPING async component DOES re-fetch on hydration (seeding off via `WEBJS_SEED=0`, a `static refresh = true` leaf, or a seed miss), its FIRST client commit is deferred until that re-fetch resolves, and that commit is the step that binds the component's event listeners. So for the fetch's duration the SSR'd markup is visible but NOT yet interactive: an `@click` does nothing until the data lands (the window equals the action's latency, not a constant). Seeding erases this by resolving the first render synchronously, so the component hydrates and binds its listeners immediately, which is the default and works on both Node and Bun (#529, seeding is no longer Node-only). The component is fully reactive once that first commit lands: this is purely a cold-start window, NOT a lost signal subscription (a bump after the commit re-renders normally). To keep an async leaf interactive from the first paint regardless, give it a synchronous first render (read request-known data from a prop / the constructor) and move the async work to a signal-driven `Task` or an `@event` handler.
122
+
119
123
  ### Streaming a slow region with `<webjs-suspense>` (#471)
120
124
 
121
125
  `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.
@@ -208,6 +208,37 @@ Returns JSON describing the live build, alongside the `/__webjs/health` and `/__
208
208
 
209
209
  ---
210
210
 
211
+ ## Dev/start task orchestration: `webjs.dev` / `webjs.start` (#550)
212
+
213
+ `webjs dev` and `webjs start` run an app's per-environment orchestration
214
+ themselves, read from the `package.json` `"webjs"` block, so the framework
215
+ primitive is not a degraded run versus `npm run dev` / `npm start`. The npm
216
+ scripts become thin aliases (`"dev": "webjs dev"`), and both forms behave
217
+ identically. This replaces the old `predev` / `prestart` npm hooks +
218
+ `concurrently` watchers (which a bare `webjs dev` silently skipped, #452).
219
+
220
+ ```jsonc
221
+ "webjs": {
222
+ "dev": {
223
+ "before": ["webjs db migrate"], // one-shot, runs to completion first
224
+ "parallel": ["tailwindcss -i ./public/input.css -o ./public/tailwind.css --watch"] // long-lived, runs alongside the server
225
+ },
226
+ "start": {
227
+ "before": ["webjs db migrate"] // one-shot, before serving
228
+ }
229
+ }
230
+ ```
231
+
232
+ - **`before`** (dev and start): commands run sequentially to completion BEFORE the server boots (the old `predev` / `prestart`: `webjs db migrate`, a registry copy). A non-zero exit ABORTS the boot with a clear message, so a failed migration never serves stale code/schema.
233
+ - **`parallel`** (dev only): long-lived child processes that run ALONGSIDE the server (the old `concurrently` watchers: the Tailwind CLI `--watch`). They are spawned once in the parent (not on every hot-reload restart) and TORN DOWN on exit (SIGINT / SIGTERM / server exit), so a watcher cannot leak past the dev server.
234
+ - Each command runs through a shell, so a normal command line works. An empty / absent block means `webjs dev` / `start` run unchanged, so a plain app with no Tailwind/DB needs no config.
235
+ - The scaffold uses the Tailwind browser runtime (no CSS build step), so it ships only `dev.before` / `start.before` (the Drizzle migration apply); an app that adds the Tailwind CLI puts its `--watch` under `webjs.dev.parallel`. The in-repo apps (`examples/blog`, `website`, `docs`, ui-website) show the Tailwind `parallel` watcher pattern.
236
+ - **Prod note:** `before` runs at boot, so `webjs start` runs `webjs db migrate` in-process to apply pending migrations. Drizzle has no client-codegen step (the schema IS the types, inferred at compile time), so there is nothing to run at image-BUILD time. Authoring a new migration from a schema change is a dev-time `webjs db generate`, committed to source control, not a boot step. So `CMD ["npm", "start"]` and `CMD ["webjs", "start"]` are equivalent.
237
+
238
+ Read by `readAppTasks` in `packages/cli/lib/app-tasks.js` (pure, unit-tested); orchestrated in `packages/cli/bin/webjs.js` (`runBeforeSteps` / `startParallelTasks`).
239
+
240
+ ---
241
+
211
242
  ## Server port resolution (`--port` / `PORT` / `.env`) (#447)
212
243
 
213
244
  Both `webjs dev` and `webjs start` resolve the listen port with a single
@@ -1,6 +1,6 @@
1
1
  # Framework development (editing webjs itself)
2
2
 
3
- Read this only when editing the webjs monorepo (this repo), not a scaffolded app. The repo is buildless: `packages/` is plain `.js` with JSDoc (never add `.ts` there); TypeScript is fine in `examples/`, `docs/`, `website/`. Each in-repo app (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`) is run via its OWN `npm run dev` / `npm start`, never `webjs dev` directly, since each composes extra watchers. The sections below cover the repo-health git config, the changelog flow, and the dev error overlay.
3
+ Read this only when editing the webjs monorepo (this repo), not a scaffolded app. The repo is buildless: `packages/` is plain `.js` with JSDoc (never add `.ts` there); TypeScript is fine in `examples/`, `docs/`, `website/`. Each in-repo app (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`) is run from its OWN dir via `npm run dev` / `npm start`; as of #550 a bare `webjs dev` / `webjs start` is equivalent (each app's per-environment orchestration, the Tailwind watcher, `webjs db migrate`, the registry copy, moved into its `webjs.dev` / `webjs.start` tasks config, which `webjs dev`/`start` run). The sections below cover the repo-health git config, the changelog flow, and the dev error overlay.
4
4
 
5
5
  ---
6
6
 
@@ -13,41 +13,36 @@ schema with the real domain models, then build features on top. This is the
13
13
  transition agents most often get wrong, so it is the first recipe.
14
14
 
15
15
  > **Two non-negotiables.** NEVER leave the example `User` model in
16
- > `schema.prisma` if the app does not actually have users (delete or replace
17
- > it). NEVER persist app data in JSON files (`data/todos.json`, `db.json`), in
18
- > a module-scope array or `Map`, or in `localStorage`. Those reset on every
19
- > reload and cannot scale. Every piece of stored data is a Prisma model.
20
-
21
- 1. **Edit `prisma/schema.prisma`** to the real domain. Replace the example
22
- `User` model with the models the app needs.
23
-
24
- ```prisma
25
- // prisma/schema.prisma
26
- model Post {
27
- id String @id @default(cuid())
28
- title String
29
- body String
30
- published Boolean @default(false)
31
- createdAt DateTime @default(now())
32
- }
33
- ```
16
+ > `db/schema.server.ts` if the app does not actually have users (delete or
17
+ > replace it). NEVER persist app data in JSON files (`data/todos.json`,
18
+ > `db.json`), in a module-scope array or `Map`, or in `localStorage`. Those
19
+ > reset on every reload and cannot scale. Every piece of stored data is a
20
+ > Drizzle table.
21
+
22
+ 1. **Edit `db/schema.server.ts`** to the real domain. Replace the example
23
+ `User` model with the models the app needs (the helpers come from
24
+ `db/columns.server.ts`).
34
25
 
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.
26
+ ```ts
27
+ // db/schema.server.ts
28
+ import { table, pk, text, bool, createdAt, index } from './columns.server.ts';
29
+ export const posts = table('posts', {
30
+ id: pk(),
31
+ title: text().notNull(),
32
+ body: text().notNull(),
33
+ published: bool().notNull().default(false),
34
+ createdAt: createdAt(),
35
+ }, (t) => [index(t.createdAt)]);
36
+ ```
40
37
 
41
- 2. **Migrate.** Run the npm script (not the `webjs`/`prisma` binary directly,
42
- so the `predev` / `db:*` hooks fire):
38
+ 2. **Generate + migrate.** Drizzle has no codegen; `generate` turns the schema
39
+ into SQL, `migrate` applies it:
43
40
 
44
41
  ```sh
45
- npm run db:migrate -- --name add_post
42
+ npm run db:generate # schema -> SQL migration (drizzle-kit)
43
+ npm run db:migrate # apply it to the dev SQLite database
46
44
  ```
47
45
 
48
- This creates the migration, applies it to the dev SQLite database, and
49
- regenerates the Prisma client.
50
-
51
46
  3. **Generate one query and one action per operation**, one exported function
52
47
  per file, named after the file, under the feature module. Reads go in
53
48
  `queries/`, mutations in `actions/`. Both are `.server.ts` with
@@ -56,27 +51,28 @@ transition agents most often get wrong, so it is the first recipe.
56
51
  ```ts
57
52
  // modules/posts/queries/list-posts.server.ts
58
53
  'use server';
59
- import { prisma } from '../../../lib/prisma.server.ts';
54
+ import { db } from '../../../db/connection.server.ts';
60
55
  export async function listPosts() {
61
- return prisma.post.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' } });
56
+ return db.query.posts.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' } });
62
57
  }
63
58
  ```
64
59
 
65
60
  ```ts
66
61
  // modules/posts/actions/create-post.server.ts
67
62
  'use server';
68
- import { prisma } from '../../../lib/prisma.server.ts';
63
+ import { db } from '../../../db/connection.server.ts';
64
+ import { posts } from '../../../db/schema.server.ts';
69
65
  export async function createPost(input: { title: string; body: string }) {
70
66
  const title = String(input?.title || '').trim();
71
67
  if (!title) return { success: false, error: 'title required', status: 400 };
72
- const post = await prisma.post.create({ data: { title, body: String(input?.body || '') } });
68
+ const [post] = await db.insert(posts).values({ title, body: String(input?.body || '') }).returning();
73
69
  return { success: true, data: post };
74
70
  }
75
71
  ```
76
72
 
77
73
  4. **Wire it into a page** by calling the query (the page runs on the server,
78
74
  so it imports the `.server` query directly and awaits it). Never import
79
- `@prisma/client` into a page; reach the database through the query.
75
+ the DB driver into a page; reach the database through the query.
80
76
 
81
77
  ```ts
82
78
  // app/posts/page.ts
@@ -118,11 +114,13 @@ export default async function User({ params }: { params: { id: string } }) {
118
114
  ```ts
119
115
  // modules/users/actions/update-profile.server.ts
120
116
  'use server';
121
- import { prisma } from '../../../lib/prisma.server.ts';
117
+ import { eq } from 'drizzle-orm';
118
+ import { db } from '../../../db/connection.server.ts';
119
+ import { users } from '../../../db/schema.server.ts';
122
120
  export async function updateProfile(input: { name: string }) {
123
121
  const name = String(input?.name || '').trim();
124
122
  if (!name) return { success: false, error: 'name required', status: 400 };
125
- const row = await prisma.user.update({ where: { id: me.id }, data: { name } });
123
+ const [row] = await db.update(users).set({ name }).where(eq(users.id, me.id)).returning();
126
124
  return { success: true, data: row };
127
125
  }
128
126
  ```
@@ -130,12 +128,12 @@ export async function updateProfile(input: { name: string }) {
130
128
  Call it from a client component via a normal import. The dev server
131
129
  rewrites the import to a typed RPC stub.
132
130
 
133
- ## Validate a server action's input once, for both call paths (#245)
131
+ ## Validate a server action's input (#245)
134
132
 
135
- `validateInput(fn, validate)` attaches an input validator that runs
136
- SERVER-SIDE before the action body on EVERY call path (the RPC path a
137
- client component import takes AND the `expose()` REST route if the action
138
- has one). On failure it returns a structured `ActionResult`
133
+ Declare a `validate` config export beside the action (#488). It runs
134
+ SERVER-SIDE before the action body on the RPC boundary (the path a client
135
+ component import takes), receiving the action's FIRST argument. On failure
136
+ it returns a structured `ActionResult`
139
137
  (`{ success: false, fieldErrors, status: 422 }`) the client reads as
140
138
  `result.fieldErrors`. The framework ships no validation library; the
141
139
  validator is a plain function (or a three-line zod adapter).
@@ -143,25 +141,24 @@ validator is a plain function (or a three-line zod adapter).
143
141
  ```ts
144
142
  // modules/posts/actions/create-post.server.ts
145
143
  'use server';
146
- import { validateInput } from '@webjsdev/core';
147
- import { prisma } from '../../../lib/prisma.server.ts';
148
-
149
- export const createPost = validateInput(
150
- // the action body: runs ONLY when validation passes
151
- async (input: { title: string; body: string }) => {
152
- const row = await prisma.post.create({ data: input });
153
- return { success: true, data: row };
154
- },
155
- // the validator: receives the action's FIRST argument
156
- (input) => {
157
- const fieldErrors: Record<string, string> = {};
158
- const title = String(input?.title || '').trim();
159
- if (!title) fieldErrors.title = 'Title is required';
160
- if (String(input?.body || '').length < 10) fieldErrors.body = 'Too short';
161
- if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
162
- return { success: true, data: { title, body: String(input.body) } }; // coerced input
163
- },
164
- );
144
+ import { db } from '../../../db/connection.server.ts';
145
+ import { posts } from '../../../db/schema.server.ts';
146
+
147
+ // the validator: receives the action's FIRST argument
148
+ export const validate = (input: any) => {
149
+ const fieldErrors: Record<string, string> = {};
150
+ const title = String(input?.title || '').trim();
151
+ if (!title) fieldErrors.title = 'Title is required';
152
+ if (String(input?.body || '').length < 10) fieldErrors.body = 'Too short';
153
+ if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
154
+ return { success: true, data: { title, body: String(input.body) } }; // coerced input
155
+ };
156
+
157
+ // the action body: runs ONLY when validation passes
158
+ export async function createPost(input: { title: string; body: string }) {
159
+ const [row] = await db.insert(posts).values(input).returning();
160
+ return { success: true, data: row };
161
+ }
165
162
  ```
166
163
 
167
164
  Reading the structured failure in a client component is just a property
@@ -187,23 +184,21 @@ result becomes the contract envelope.
187
184
  import { z } from 'zod';
188
185
  const Schema = z.object({ title: z.string().min(1), body: z.string().min(10) });
189
186
 
190
- export const createPost = validateInput(
191
- async (input) => { /* ... */ },
192
- (i) => {
193
- const r = Schema.safeParse(i);
194
- return r.success
195
- ? { success: true, data: r.data }
196
- : { success: false, fieldErrors: r.error.flatten().fieldErrors };
197
- },
198
- );
187
+ export const validate = (i: any) => {
188
+ const r = Schema.safeParse(i);
189
+ return r.success
190
+ ? { success: true, data: r.data }
191
+ : { success: false, fieldErrors: r.error.flatten().fieldErrors };
192
+ };
193
+ export async function createPost(input) { /* ... */ }
199
194
  ```
200
195
 
201
- To ALSO expose the action as REST with the SAME validator, pass `validate`
202
- to `expose()` instead of using `validateInput`:
203
- `expose('POST /api/posts', fn, { validate })`. A `{ success: false,
204
- fieldErrors }` return becomes a 422 JSON response there; a validator that
205
- THROWS (the classic `Schema.parse` style) becomes a 400, and a non-envelope
206
- return transforms the input (back-compat).
196
+ To ALSO expose the action over REST with the SAME validator, put it behind
197
+ a `route.ts` and pass the validator as the `route()` adapter's `{ validate }`
198
+ option: `export const POST = route(createPost, { validate })`. A
199
+ `{ success: false, fieldErrors }` return becomes a 422 JSON response there; a
200
+ validator that THROWS (the classic `Schema.parse` style) becomes a 400, and a
201
+ non-envelope return transforms the input.
207
202
 
208
203
  ## Add a component
209
204
 
@@ -414,7 +409,9 @@ the user-supplied filename as a key; `generateKey` makes an opaque one.
414
409
  // modules/avatar/actions/save-avatar.server.ts
415
410
  'use server';
416
411
  import { getFileStore, generateKey } from '@webjsdev/server';
417
- import { prisma } from '../../../lib/prisma.server.ts';
412
+ import { eq } from 'drizzle-orm';
413
+ import { db } from '../../../db/connection.server.ts';
414
+ import { users } from '../../../db/schema.server.ts';
418
415
 
419
416
  export async function saveAvatar(file: File) {
420
417
  const key = generateKey(file.name); // <uuid>.<ext>, safe
@@ -423,7 +420,7 @@ export async function saveAvatar(file: File) {
423
420
  await getFileStore().delete(key);
424
421
  return { success: false, fieldErrors: { avatar: 'Max 5 MB' }, status: 422 };
425
422
  }
426
- await prisma.user.update({ where: { id: 'me' }, data: { avatarKey: key } });
423
+ await db.update(users).set({ avatarKey: key }).where(eq(users.id, 'me'));
427
424
  return { success: true, data: { key, contentType } };
428
425
  }
429
426
  ```
@@ -74,7 +74,7 @@ packages/
74
74
  intellisense/test/plugin/ tsserver plugin tests (.test.mjs)
75
75
  test/ cross-package only
76
76
  ssr/ SSR pipeline (core + server)
77
- actions/ expose() (core + server)
77
+ actions/ server actions (core + server)
78
78
  serialization/ json-negotiation (core + server)
79
79
  scaffolds/ full app boots from CLI scaffolds
80
80
  blog/ examples/blog smoke + browser e2e
@@ -108,6 +108,51 @@ cross-package suite (`packages/*/test/` + `test/`). They do NOT walk the in-repo
108
108
  apps' own test dirs (`website/test/`, `examples/blog/test/`), so those run from
109
109
  each app's OWN `webjs test` script, not the root runner.
110
110
 
111
+ ### The Bun test matrix (#509)
112
+
113
+ webjs runs on Node 24+ or Bun (#508). The **Node suite (`npm test`) is the
114
+ source of truth**; a separate, additive **Bun matrix** re-runs the
115
+ runtime-sensitive suite under Bun to catch the long tail of cross-runtime
116
+ incompatibilities (a `node:*` API Bun implements differently, a crypto/stream
117
+ edge case, an error-message-format quirk).
118
+
119
+ - `node scripts/run-bun-tests.js` (needs `bun` on PATH; `BUN=…` overrides) runs
120
+ the `node:test` files under `test/`, `packages/core/test/`, and
121
+ `packages/server/test/` (excluding `browser/`, `e2e/`, the network-bound
122
+ `vendor/`) file by file via `bun test <file>`, and CLASSIFIES each result:
123
+ **pass**, **skip(node-only)** (a documented file that asserts Node-only
124
+ behavior or trips a Bun-test-runner quirk, listed with a reason in the
125
+ script's `DENYLIST`, its Bun behavior covered elsewhere), **skip(harness)**
126
+ (a Bun `node:test` compat gap, auto-detected by error signature),
127
+ **skip(env)** (needs Redis / a DOM), and **genuine fail** (a real Bun
128
+ failure, which fails the job). Set `WEBJS_BUN_TESTS=<substr,…>` to scope a
129
+ local run.
130
+ - CI runs it in the `bun` job alongside `test/bun/smoke.mjs` (#508) and
131
+ `test/bun/listener.mjs` (#511, the listener-shell parity).
132
+ - Two cross-runtime test scripts also run under BOTH runtimes: `test/bun/smoke.mjs`
133
+ (boot + SSR + TS strip + a server-action RPC) and `test/bun/listener.mjs`
134
+ (`startServer` over a real socket: SSR + route + SSE + WebSocket). Plain assert
135
+ scripts (not `node:test`) so the same file runs identically on each runtime.
136
+
137
+ **Dev hot-reload of server modules (cross-runtime, #514).** webjs's dev server
138
+ re-imports a `route.ts` / `.server.ts` / page module per request with a
139
+ `?t=<timestamp>` query cache-bust to pick up edits. Node honors that query and
140
+ re-imports, so under `node --watch` an edit is picked up live. Bun's ESM loader
141
+ IGNORES the query string and exposes no module-eviction API, so that mechanism
142
+ alone leaves a server-side edit stale on Bun. The CLI closes the gap by
143
+ re-execing `webjs dev` under `bun --hot` on Bun (vs `node --watch` on Node),
144
+ whose file-watching cache invalidation makes the next re-import fresh with no
145
+ restart (`Bun.serve` is reused across hot reloads, so the listener is not
146
+ duplicated). The cross-runtime test `test/bun/dev-hot-reload.mjs` proves the
147
+ edit-picked-up behaviour on BOTH runtimes (and runs as a dedicated CI step on
148
+ Bun). The `api` dev-cache-bust UNIT test asserts the bare server-level `?t=`
149
+ mechanism directly (no supervisor), which Bun ignores by design, so it stays on
150
+ the Bun-matrix denylist; the user-facing hot reload it underpins is covered on
151
+ Bun by the dev-hot-reload script instead. A `--no-hot` flag opts out of the
152
+ supervisor on either runtime (run the server in-process, edits need a manual
153
+ restart). Component / page / layout SOURCE edits already hot-reload on both
154
+ runtimes (the served `.ts`/`.js` is read from disk per request, not imported).
155
+
111
156
  ### In-repo app tests in CI (#342)
112
157
 
113
158
  Each in-repo app (`website`, `examples/blog`) carries its own test suite under
@@ -116,7 +161,7 @@ its `test/` dir and runs it through its own `webjs test` script (the website's
116
161
  not discover these, so a dedicated `.github/workflows/ci.yml` job, **In-repo app
117
162
  tests (website + blog)**, runs `npm test --workspace=@webjsdev/website` and
118
163
  `npm test --workspace=@webjsdev/example-blog` (with Playwright installed for the
119
- website browser tests and the Prisma DB prepared for the blog, the same setup
164
+ website browser tests and the SQLite DB prepared for the blog, the same setup
120
165
  the `unit` + `e2e` jobs use). It is a required status check, so a regression in
121
166
  an app's tests gates the merge. The app test dirs are not walked by the root
122
167
  runner, so the framework-package tests never double-run. `docs` and the
@@ -6,12 +6,13 @@ run is part of the user-visible workflow, no separate build step:
6
6
 
7
7
  - **Editor** (VS Code) runs the TypeScript language server continuously. Red-squiggle on wrong types, including non-erasable syntax (see below).
8
8
  - **CI** (optional) runs `tsc --noEmit` against `tsconfig.json`. Type-check only. Also catches non-erasable syntax via `erasableSyntaxOnly`.
9
- - **Dev + prod server** (runtime, both directions): Node 24+'s built-in TypeScript type-stripping handles server-side `.ts` imports automatically (`process.features.typescript === 'strip'`). Browser-bound `.ts` requests go through `module.stripTypeScriptTypes` on the dev server, which performs whitespace replacement: every (line, column) in the source maps to the same position in the stripped output, so no sourcemap needs to be shipped and stack traces are byte-exact. The transform is cached by mtime (~microseconds per cache hit). Implementation backing: Node ships the [`amaro`](https://github.com/nodejs/amaro) package internally, which wraps SWC's WASM TypeScript transform in a position-preserving strip-only mode. If the framework ever needs to run on a non-Node runtime (Bun, Deno) we will install `amaro` directly or an equivalent position-preserving stripper (Sucrase preserves lines but not columns; SWC's strip mode also works).
9
+ - **Dev + prod server** (runtime, both directions): the runtime's TypeScript type-stripping handles server-side `.ts` imports automatically (on Node, `process.features.typescript === 'strip'`; Bun runs `.ts` natively). Browser-bound `.ts` requests go through the pluggable stripper in `packages/server/src/ts-strip.js` (#508) on the dev server, which performs whitespace replacement: every (line, column) in the source maps to the same position in the stripped output, so no sourcemap needs to be shipped and stack traces are byte-exact. The transform is cached by mtime (~microseconds per cache hit). Implementation backing: on **Node 24+** it is the built-in `module.stripTypeScriptTypes` (which itself wraps the [`amaro`](https://github.com/nodejs/amaro) package, SWC's WASM TypeScript transform in position-preserving strip-only mode); on **Bun** (no such built-in) it is `amaro` loaded directly, an `optionalDependency` of `@webjsdev/server`, producing byte-identical output. `WEBJS_TS_STRIPPER=builtin|amaro` forces a backend. Edge runtimes without a filesystem are a separate, later target.
10
10
 
11
11
  ## TypeScript feature support: erasable only
12
12
 
13
- The framework uses Node 24+'s built-in `module.stripTypeScriptTypes`,
14
- which only supports **erasable TypeScript**: type annotations,
13
+ The framework strips with Node 24+'s built-in `module.stripTypeScriptTypes`
14
+ (or `amaro` on Bun, the same engine), which only supports **erasable
15
+ TypeScript**: type annotations,
15
16
  `interface`, `type`, `declare`, generics, `import type`, `as` casts,
16
17
  and `satisfies`. Non-erasable syntax is rejected.
17
18
 
@@ -62,15 +63,15 @@ off so you catch the configuration drift before runtime.
62
63
 
63
64
  ## Import convention
64
65
 
65
- Use explicit `.ts` extensions in imports. Node 24+'s built-in
66
- type-stripping and the dev server's HTTP handler both key on the
66
+ Use explicit `.ts` extensions in imports. The runtime's type-stripping
67
+ and the dev server's HTTP handler both key on the
67
68
  file URL ending in `.ts` / `.mts`. For mixed codebases, `.js` imports
68
69
  that point at a `.ts` sibling also resolve in the dev server. Still
69
70
  prefer explicit `.ts`.
70
71
 
71
72
  ```ts
72
73
  // modules/posts/queries/list-posts.server.ts
73
- import { prisma } from '../../../lib/prisma.js'; // JS file unchanged
74
+ import { db } from '../../../db/connection.server.js'; // .js specifier resolves the .ts sibling
74
75
  import { formatPost } from '../utils/slugify.ts'; // TS file
75
76
  ```
76
77
 
@@ -128,6 +129,31 @@ Supported: `Date`, `Map`, `Set`, `BigInt`, `Error`, `undefined`,
128
129
  instances come through as plain objects, with prototypes lost and methods
129
130
  gone (matches React Server Actions).
130
131
 
132
+ #### Catching a non-serializable signature at compile time (`SerializableActionFn`)
133
+
134
+ The class-instance / function caveat above is a silent runtime surprise: a
135
+ method-valued field or a function argument typechecks fine but vanishes on the
136
+ wire. The opt-in `SerializableActionFn` annotation turns it into a compile-time
137
+ error. webjs actions stay plain `export async function`s (the framework rewrites
138
+ the client import to an RPC stub at runtime, it does not wrap the authored
139
+ function), so the guard is an OPTIONAL type annotation the author applies when
140
+ they want it:
141
+
142
+ ```ts
143
+ import type { SerializableActionFn } from '@webjsdev/core';
144
+
145
+ export const getUser: SerializableActionFn<(id: number) => Promise<User>> =
146
+ async (id) => db.user.find(id);
147
+ // ^ if User carries a method (or any arg is a function), this is a type error
148
+ // pointing at the offending member, instead of a value that silently loses
149
+ // it on the wire.
150
+ ```
151
+
152
+ `Serializable<T>` (also exported) maps a fully serializable `T` to itself and a
153
+ non-serializable position (a function / method) to a branded `NonSerializable`
154
+ marker; `SerializableArgs` / `SerializableResult` apply it across an action's
155
+ parameters and (promise-unwrapped) return. All types-only, erased at runtime.
156
+
131
157
  ### API routes: opt in via content negotiation
132
158
 
133
159
  `route.ts` handlers use standard JSON by default so external consumers
package/src/mcp-docs.js CHANGED
@@ -333,11 +333,13 @@ const PROMPT_BODIES = {
333
333
  '```ts',
334
334
  '// modules/<feature>/actions/<name>.server.ts',
335
335
  "'use server';",
336
- "import { prisma } from '../../../lib/prisma.server.ts';",
336
+ "import { db } from '../../../db/connection.server.ts';",
337
+ "import { things } from '../../../db/schema.server.ts';",
337
338
  'export async function doThing(input: { name: string }) {',
338
339
  " const name = String(input?.name || '').trim();",
339
340
  " if (!name) return { success: false, error: 'name required', status: 400 };",
340
- ' return { success: true, data: await prisma.thing.create({ data: { name } }) };',
341
+ ' const [row] = await db.insert(things).values({ name }).returning();',
342
+ ' return { success: true, data: row };',
341
343
  '}',
342
344
  '```',
343
345
  '',
package/src/mcp.js CHANGED
@@ -36,6 +36,13 @@ import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js';
36
36
 
37
37
  const PROTOCOL_VERSION = '2024-11-05';
38
38
 
39
+ // Mirrors packages/server/src/action-config.js. A drift test in
40
+ // packages/mcp/test/mcp.test.mjs asserts these stay in sync with the source.
41
+ /** HTTP verbs an action may declare (mirrors RPC_VERBS in action-config.js). */
42
+ const RPC_VERBS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
43
+ /** Reserved config export names in a 'use server' file (mirrors RESERVED_CONFIG in action-config.js). */
44
+ const RESERVED_CONFIG = new Set(['method', 'cache', 'tags', 'invalidates', 'validate', 'middleware']);
45
+
39
46
  /**
40
47
  * The four read-only tools. Each takes an optional `{ appDir }` (default the
41
48
  * server's cwd) and projects an EXISTING `@webjsdev/server` function's output
@@ -128,7 +135,7 @@ const TOOL_DEFS = [
128
135
  {
129
136
  name: 'list_actions',
130
137
  description:
131
- 'List registered server actions (the .server.{js,ts} files with "use server"): file, exported function name, and the /__webjs/action/<hash>/<fn> RPC endpoint. Read-only.',
138
+ 'List registered server actions (the .server.{js,ts} files with "use server"): file, exported function name, the /__webjs/action/<hash>/<fn> RPC endpoint, HTTP verb (method), cache config, and boolean flags for tags/invalidates/validate/middleware. Config-only exports (method, cache, tags, invalidates, validate, middleware) are NOT listed as actions. Read-only.',
132
139
  inputSchema: APPDIR_SCHEMA,
133
140
  },
134
141
  {
@@ -148,7 +155,7 @@ const TOOL_DEFS = [
148
155
  /**
149
156
  * Lexically extract the names exported from a module source. Recognises the
150
157
  * common forms a server-action / route file uses without LOADING the module
151
- * (loading would run its top-level side effects: Prisma init, DB connects).
158
+ * (loading would run its top-level side effects: DB init, connections).
152
159
  *
153
160
  * export async function foo() {} export function foo() {}
154
161
  * export const foo = ... export let/var foo = ...
@@ -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 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 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
  }
@@ -1,214 +0,0 @@
1
- # SSR partial navigation: design note
2
-
3
- **Status:** SHIPPED (feature/nested-layout-partial-swap, 2026-05-16).
4
- The mechanism described below is implemented and tested. This document
5
- is preserved as the design record. Runtime reference for callers lives
6
- in `agent-docs/advanced.md` (Client router section) and the framework
7
- API table in `AGENTS.md`.
8
-
9
- **Motivating bug (resolved):** ui-website docs sidenav lost scroll on
10
- every link click because the docs layout sat 2 levels deep under the
11
- root layout, beyond `findLayoutShell`'s body-direct-child probe.
12
- **Previous workaround (now deleted):** `app/docs/layout.ts` saved /
13
- restored `.docs-sidenav` `scrollTop` via `sessionStorage` on every
14
- `webjs:navigate` event. Removed in the same PR as the framework fix.
15
-
16
- ---
17
-
18
- **What actually shipped vs. what's below:**
19
- - The recommendation in this doc was `<webjs-frame>` as the primary
20
- primitive. During design discussion the decision evolved to make
21
- layout-marker discovery **auto-derived from folder structure**, so
22
- layout authors write nothing. `<webjs-frame>` ships as the escape
23
- hatch for non-layout partial-swap regions (rare).
24
- - The marker format is `<!--wj:children:<segment-path>-->` comment
25
- pairs (Remix v3 lineage), not the `<webjs-frame>`-element approach
26
- sketched below.
27
- - Wire-byte optimization, snapshot cache, keyed DOM diff with live-
28
- attribute preservation, and per-segment `<template id="wj-loading:...">`
29
- cloning all shipped in the same PR (originally deferred as v2+).
30
-
31
- The original `<webjs-frame>`-centric sketch below is preserved as
32
- historical context.
33
-
34
- ---
35
-
36
- ## Goal
37
-
38
- Preserve the DOM of any layout, at any depth, across same-origin navigations. Re-render only the deepest segment that actually changed.
39
-
40
- ## Non-goals
41
-
42
- - Per-segment data fetching (Remix v3 `<Frame src>` style). Wire model stays one SSR response per nav.
43
- - React-style reconciler with full keyed-DOM diff inside the swap region. Out of scope for v1, can come later.
44
- - Parallel routes / intercepting routes (Next.js feature). Separate design.
45
-
46
- ## Background: how the four references handle this
47
-
48
- | Framework | Mechanism | Wire format | Scope decided by |
49
- |---|---|---|---|
50
- | **Turbo** | `<turbo-frame id="X">` (flat DOM element) | full HTML response, server may optimize via `Turbo-Frame: X` header | Innermost enclosing `<turbo-frame>` of the click (`closest()`) |
51
- | **Remix v3** | `<!--rmx:f:id-->...<!--/rmx:f-->` comment markers + per-frame `src` | per-frame HTML or `<template id>` streams | Author-declared `<Frame name="...">` + `rmx-target` on link |
52
- | **Next.js App Router** | Recursive `FlightRouterState` tuple + per-segment `CacheNode` tree | RSC Flight (`react-server-dom-webpack`) | Server walks the tree, returns from divergence point |
53
- | **Lit Labs** | `Routes` controller with `outlet()` + child controllers via `RoutesConnectedEvent` | full template re-render (no partial scoping) | N/A (full subtree re-render every nav) |
54
-
55
- **Closest fit to webjs's current router:** Turbo. webjs already mirrors Turbo Drive (link interception, body swap, `pushState`, `data-no-router` ≡ `data-turbo="false"`).
56
-
57
- ## Recommendation
58
-
59
- Adopt a Turbo-style frame primitive: `<webjs-frame id="...">`. Layouts that want partial-swap behavior wrap their replaceable region:
60
-
61
- ```ts
62
- // app/docs/layout.ts
63
- import { html } from '@webjsdev/core';
64
- import { sidenav } from './sidenav.ts';
65
-
66
- export default function DocsLayout({ children }) {
67
- return html`
68
- <div class="docs-grid">
69
- ${sidenav()}
70
- <webjs-frame id="docs-content">${children}</webjs-frame>
71
- </div>
72
- `;
73
- }
74
- ```
75
-
76
- ### Algorithm: `packages/core/src/router-client.js` delta
77
-
78
- Existing `findLayoutShell(body)` stays as a fallback. Add `findActiveFrame(linkEl)`:
79
-
80
- ```js
81
- function findActiveFrame(linkEl) {
82
- // Walk up through shadow boundaries and into light DOM via composedPath at call site.
83
- const frame = linkEl.closest('webjs-frame');
84
- return frame ? frame.id : null;
85
- }
86
-
87
- async function navigate(url, event) {
88
- const frameId = event ? findActiveFrame(event.target) : null;
89
-
90
- const res = await fetch(url, {
91
- headers: frameId ? { 'X-Webjs-Frame': frameId } : {},
92
- });
93
- if (!res.headers.get('content-type')?.startsWith('text/html')) {
94
- // existing fallback: full nav
95
- window.location.href = url;
96
- return;
97
- }
98
-
99
- const html = await res.text();
100
- const incoming = Document.parseHTMLUnsafe(html);
101
-
102
- // 1. Frame path: preferred if active frame exists in both.
103
- if (frameId) {
104
- const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
105
- const source = incoming.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
106
- if (target && source) {
107
- target.replaceChildren(...source.childNodes);
108
- mergeHead(incoming.head);
109
- runFrameScripts(target);
110
- customElements.upgrade(target);
111
- history.pushState({}, '', url);
112
- document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url, frameId } }));
113
- return;
114
- }
115
- }
116
-
117
- // 2. Existing layout-shell path (one level deep).
118
- const shell = findLayoutShell(document.body);
119
- const incomingShell = shell ? findLayoutShell(incoming.body) : null;
120
- if (shell && incomingShell && shellsMatch(shell, incomingShell)) {
121
- swapShellContent(shell, incomingShell);
122
- /* existing path... */
123
- return;
124
- }
125
-
126
- // 3. Full body swap (existing fallback).
127
- document.body.replaceChildren(...incoming.body.childNodes);
128
- /* existing path... */
129
- }
130
- ```
131
-
132
- That's the entire detection delta: a `querySelector` keyed by the active frame's id, with the existing logic preserved as fallback.
133
-
134
- ### `<webjs-frame>` element: ~30 lines
135
-
136
- ```js
137
- // packages/core/src/webjs-frame.js
138
- import { WebComponent, html } from './index.js';
139
-
140
- export class WebjsFrame extends WebComponent {
141
- static properties = { id: { type: String, reflect: true } };
142
- render() { return html`<slot></slot>`; }
143
- }
144
- WebjsFrame.register('webjs-frame');
145
- ```
146
-
147
- Light DOM (default): no shadow boundary, no slot mechanics. The element exists purely as a swap anchor with an addressable `id`. Children are normal light-DOM children that the router replaces via `replaceChildren`.
148
-
149
- ### Server side: `X-Webjs-Frame` request header (optional optimization)
150
-
151
- When set, the SSR pipeline can return only the matching frame's HTML wrapped in a minimal stub document, skipping the rest of the layout chain. Wire is still plain HTML (no new format). v1 ships **without** this optimization. Full SSR response, client extracts what it needs. The header is forward-compat for the perf pass.
152
-
153
- ### Head merging
154
-
155
- Same as today's `mergeHead`: replace `<title>`, merge `<meta>` tags by `name`/`property`, append new `<link>`/`<style>` elements, dedupe.
156
-
157
- ### Script handling inside the swap region
158
-
159
- Re-execute `<script>` elements that match the existing one-level-shell path's `runScripts` logic. Idempotent registration via `Class.register()` makes this safe. The framework already handles `customElements.define` collisions.
160
-
161
- ## Edge cases
162
-
163
- | Case | Behavior |
164
- |---|---|
165
- | Click on a link inside `<webjs-frame>` but `data-no-router` | Full browser navigation (existing semantics) |
166
- | Click on a link *outside* any frame, both pages share a `findLayoutShell` match | Falls through to existing layout-shell path |
167
- | Frame in old page but not in new (route change leaves the layout tree) | Frame lookup fails → fall to layout-shell or full body swap. Correct. |
168
- | Nested `<webjs-frame>`s | Innermost wins: `closest('webjs-frame')` returns the nearest enclosing frame. Mirrors Turbo behavior. |
169
- | Form submission inside a frame | Same. POST response gets the same frame-extract treatment. (Implement in form-submit path alongside link-click.) |
170
- | Hash-fragment-only navigation | Existing behavior. No fetch, browser handles. |
171
- | `data-frame="_top"` on a link | Escapes the enclosing frame, full nav. (Turbo precedent.) |
172
-
173
- ## What this fixes
174
-
175
- - **ui-website docs sidenav scroll**: docs layout wraps content in `<webjs-frame id="docs-content">`. Sidenav lives *outside* the frame. Navigation between `/docs/components/a` → `/docs/components/b` only swaps frame children. The sidenav DOM is untouched, and `<aside>` scroll position is preserved natively. The `sessionStorage` workaround in `app/docs/layout.ts` can be deleted.
176
- - **Any nested-layout app**: the same primitive works whether the partial-swap region is 2, 3, or 5 levels deep.
177
- - **Mixed layouts**: pages that don't opt-in fall through to the existing one-level shell detection or full body swap. No regression risk.
178
-
179
- ## What's deliberately deferred (future passes)
180
-
181
- 1. **Keyed `data-key` DOM diff inside the frame.** Adopt Remix v3's `diff-dom.ts` algorithm to preserve input values, `<details>` open state, popover state, scroll positions on inner scroll containers across nav. Today's `replaceChildren` is coarse, but fine for v1 since the frame *itself* is preserved (outer scroll, sidenav, etc.).
182
- 2. **`X-Webjs-Frame` server optimization** to avoid re-rendering layouts the client already has.
183
- 3. **Server-pushed partial updates** (turbo-stream equivalent) via `<webjs-stream action="replace" target="...">`. Separate feature, useful for SSE/WebSocket-driven UI.
184
- 4. **Frame-scoped error boundaries.** If a frame fetch 5xxs, render only the frame's `error.ts`, not the whole page.
185
-
186
- ## Implementation plan
187
-
188
- 1. **`packages/core/src/webjs-frame.js`**: new file, the custom element.
189
- 2. **`packages/core/index.js`**: export `WebjsFrame`. Re-export `<webjs-frame>` via auto-registration import (so any app that imports `@webjsdev/core` gets it).
190
- 3. **`packages/core/src/router-client.js`**: add `findActiveFrame()`, frame-swap branch in `navigate()`. Preserve existing `findLayoutShell` and full-body fallback.
191
- 4. **`packages/core/src/router-client.js` (form path)**: apply the same frame-extract to form submit responses.
192
- 5. **`packages/server/src/dev.js`**: accept `X-Webjs-Frame` header in dev mode (no-op for v1 but adds the request signal for telemetry).
193
- 6. **Tests:**
194
- - `packages/core/test/routing/router-client.test.js`: frame detection, querySelector with various ids, fallback when source frame missing.
195
- - `test/e2e/nested-layout-partial-swap.test.mjs`: load `/docs/components/a`, scroll sidenav, click `/docs/components/b`, assert scroll preserved AND only frame children swapped.
196
- 7. **Docs:**
197
- - `AGENTS.md`: add `<webjs-frame>` to the public API table.
198
- - `agent-docs/advanced.md`: new "Frames" section under the client-router doc.
199
- - `docs/` app: a new docs page showing the pattern.
200
- 8. **`packages/cli/templates/`**: none for v1 (frames are opt-in, no scaffold change needed).
201
- 9. **ui-website cleanup**: remove the `sessionStorage` workaround in `app/docs/layout.ts` after the frame lands and tests prove scroll preservation.
202
-
203
- ## Open questions
204
-
205
- 1. **Should `<webjs-frame>` ship a `src=""` attribute** for lazy loading like turbo-frame does? Probably yes eventually, but not for v1. The motivating use case is layout-scope, not lazy data.
206
- 2. **Should there be a sub-element registry** (something like `data-layout="docs"` as a shorthand)? Keep it explicit for v1: one mechanism. Consider sugar later.
207
- 3. **`X-Webjs-Frame` header naming.** `Webjs-Frame` (like `Turbo-Frame`) is shorter. Either works, and v1 implements neither for response routing so the bikeshed is deferred.
208
-
209
- ## References
210
-
211
- - Turbo source: `frame_controller.js:132-148` (response parse), `frame_renderer.js:5-16` (swap), `link_interceptor.js:48` (innermost-frame rule)
212
- - Remix v3 source: `packages/component/src/lib/frame.ts:1134-1146` (comment markers), `diff-dom.ts:124-162` (live-attr preservation list)
213
- - Next.js source: `packages/next/src/client/components/router-reducer/ppr-navigations.ts:230,251,292,354,486` (cache reuse vs create), `walk-tree-with-flight-router-state.tsx:106-112` (server-side tree walk)
214
- - webjs current: `packages/core/src/router-client.js` (`findLayoutShell`, single body-children scan)