@webjsdev/mcp 0.1.10 → 0.1.11

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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@webjsdev/mcp",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "type": "module",
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",
5
+ "description": "The Model Context Protocol server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Gives AI coding agents live app introspection over routes, actions, components, and convention checks, plus a knowledge layer of docs, recipes, and framework source.",
6
6
  "bin": {
7
7
  "webjs-mcp": "bin/webjs-mcp.js"
8
8
  },
@@ -19,7 +19,7 @@ to these `references/`:
19
19
  | `references/styling.md` | Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout |
20
20
  | `references/client-router-and-streaming.md` | Client router, prefetch, frames, view transitions, Suspense streaming |
21
21
  | `references/optimistic-ui.md` | Optimistic UI for a user-facing mutation |
22
- | `references/typescript.md` | TypeScript at runtime, erasable syntax, full-stack types |
22
+ | `references/typescript.md` | TypeScript at runtime, erasable syntax, full-stack types, the derive-the-type rule (never `unknown` / `any`) |
23
23
  | `references/testing.md` | Unit, browser, e2e tests, the `handle()` harness, Bun parity |
24
24
  | `references/built-ins.md` | Auth, caching, env vars, rate limit, file storage, the `webjs` config block |
25
25
  | `references/runtime.md` | Node vs Bun, running the app, deploying, runtime-specific differences |
@@ -37,15 +37,15 @@ itself): commands, repo-health git config, changelog flow, dev error overlay.
37
37
 
38
38
  ### Before starting ANY work: verify and sync the branch
39
39
 
40
- 1. `git branch --show-current`. If on `main` / `master`, **STOP** and `git checkout -b feature/<task-slug>`.
40
+ 1. Work NEVER happens in the primary checkout. Cut the task's worktree first: `git worktree add -b <prefix>/<task-slug> ../<repo>-<task-slug> origin/main`, and do ALL work there.
41
41
  2. Verify the branch matches the task. Don't mix unrelated work.
42
42
  3. Sync with parent: `git fetch origin && git log HEAD..origin/main --oneline`. If there are upstream commits, `git rebase origin/main` first.
43
43
 
44
- Claude Code enforces step 1 via `.claude/hooks/guard-branch-context.sh`. Other agents check manually.
44
+ Claude Code enforces step 1 via `.claude/hooks/require-worktree-for-edits.sh` (blocks tracked-file edits in a primary checkout; test `test/hooks/require-worktree-for-edits.test.mjs`, escape hatch `WEBJS_NO_WORKTREE_GATE=1`). Other agents check manually.
45
45
 
46
- ### One task per git worktree when agents run concurrently
46
+ ### One task per git worktree, ALWAYS
47
47
 
48
- WebJs is worked by MULTIPLE agents at once. If more than one agent (or more than one in-flight task) shares ONE working checkout, they collide: a `git checkout` in one moves `HEAD` under the other, so the next commit lands on the WRONG branch. This has happened (a `chore: release` commit landed on an unrelated `feat/` branch, with a contaminated changelog). So give each task its own worktree:
48
+ WebJs is worked by MULTIPLE agents at once, and "no other agent is active right now" is unverifiable mid-task (another session can start any minute), so the worktree rule is unconditional. If more than one agent (or more than one in-flight task) shares ONE working checkout, they collide: a `git checkout` in one moves `HEAD` under the other, so the next commit lands on the WRONG branch. This has happened (a `chore: release` commit landed on an unrelated `feat/` branch, with a contaminated changelog). So give each task its own worktree:
49
49
 
50
50
  ```sh
51
51
  git worktree add -b <prefix>/<slug> ../<repo>-<slug> origin/main
@@ -53,9 +53,20 @@ cd ../<repo>-<slug> # do ALL work for the task here
53
53
  # ... commit + push from this worktree; after merge: git worktree remove ../<repo>-<slug>
54
54
  ```
55
55
 
56
- **A fresh worktree has NO `node_modules`** (git worktrees do not copy it), so running an app from one (`webjs dev` / `webjs start`, the test runner, a scaffolded app) fails to resolve `@webjsdev/*` until you install or link it. `webjs doctor` warns for this exact case (#954) and `webjs dev` / `webjs start` print the cause + remedy instead of a raw `ERR_MODULE_NOT_FOUND`. Fix by installing in the worktree (`npm install`) or symlinking the primary checkout's modules (`ln -s ../<primary-checkout>/node_modules node_modules`). When only a subset of `@webjsdev/*` packages was edited, link those from the worktree and the rest from the primary checkout so a built `dist/` (e.g. `@webjsdev/core`) still resolves.
56
+ **A fresh worktree has NO `node_modules`** (git worktrees do not copy it), so running an app from one (`webjs dev` / `webjs start`, the test runner, a scaffolded app) fails to resolve `@webjsdev/*` until you install or link it. `webjs doctor` warns for this exact case (#954) and `webjs dev` / `webjs start` print the cause + remedy instead of a raw `ERR_MODULE_NOT_FOUND`.
57
57
 
58
- Git enforces one-branch-per-worktree, so separate worktrees make the collision impossible. Before any commit in a shared checkout, confirm `git branch --show-current` is still the branch you created; if it moved, you are colliding, switch to a worktree. A lone agent in a clean checkout may still use a plain branch. The repo's `.hooks/pre-commit` additionally BLOCKS a published-library (`core`/`server`/`cli`/`mcp`/`ui`/`intellisense`) version bump on any non-`chore/release-*` branch, the canonical wrong-branch-release symptom.
58
+ Fix it with **`npm run worktree:link`** from inside the worktree (or a full `npm install` there, which is correct but slow and duplicates a large tree per worktree). **Do NOT hand-symlink only the root `node_modules`.** That is the obvious move and it produces a worktree that looks set up and then fails dozens of tests for reasons that point nowhere near the real cause. Two things beyond the root tree are needed, and the script handles both:
59
+
60
+ - **Every NESTED `node_modules`, not just the root.** npm hoists what it can, but a workspace whose range conflicts with the hoisted copy keeps its own nested tree. `packages/server` is the live example: the root carries `ws@7` (hoisted for another dependent) while `packages/server` declares `^8.20.0` and keeps `ws@8` nested. Link only the root and `WebSocketServer`, a ws@8-only named export, resolves up to ws@7 and throws at module load. That single miss failed hundreds of assertions across the server, integration, and smoke suites, none of them naming ws.
61
+ - **`packages/core/dist`**, which is built rather than committed, so a fresh worktree has none and every test importing the built bundle fails to resolve it.
62
+
63
+ The script discovers the `node_modules` set from the primary checkout rather than hardcoding a list (it changes whenever a package gains a nested tree), never overwrites an existing path, and never creates a dangling link, so it is safe to re-run and safe in a worktree where you already ran a real `npm install`.
64
+
65
+ **Know what this does NOT give you.** The worktree then runs the PRIMARY checkout's framework source through every bare `@webjsdev/*` specifier, because `<primary>/node_modules/@webjsdev/core` is a relative symlink into `<primary>/packages/core` and resolving through the linked root lands there. Relative imports (`../../../src/x.js`) and the browser suite, which web-test-runner serves from the worktree, do use the worktree's own files. So linking makes the suite RUNNABLE, not self-testing: if you are editing `packages/core/src` or `packages/server/src` and need a bare-specifier consumer to exercise YOUR copy, run a real `npm install` in the worktree, or repoint the individual `@webjsdev/<pkg>` entries at it. CI always builds from the branch, so it is unaffected either way.
66
+
67
+ Note the `webjs doctor` / `webjs dev` remedy message still suggests the root-only symlink. That advice is correct for a scaffolded APP worktree, which has no nested trees and no built `dist/`, and wrong only for this monorepo.
68
+
69
+ Git enforces one-branch-per-worktree, so separate worktrees make the collision impossible. There is NO lone-agent exception: every task cuts a worktree, and the primary checkout stays an untouched mirror of main (tracked-file edits there are hook-blocked). The repo's `.hooks/pre-commit` additionally BLOCKS a published-library (`core`/`server`/`cli`/`mcp`/`ui`/`intellisense`) version bump on any non-`chore/release-*` branch, the canonical wrong-branch-release symptom.
59
70
 
60
71
  **Cleanup is automatic after a merge.** The `.claude/hooks/cleanup-merged-worktree.sh` PostToolUse hook fires after any `gh pr merge` and removes each linked worktree whose branch is merged AND whose tree is clean, so a merged branch's worktree never leaks (accumulated stale worktrees are exactly what it prevents). It is conservative: it KEEPS anything with uncommitted changes, an unmerged branch, or the worktree you ran the merge from (you cannot remove your current directory, so `cd` out and `git worktree remove` it yourself), and never touches the primary checkout. Disable with `WEBJS_NO_WORKTREE_CLEANUP=1`. Test: `test/hooks/cleanup-merged-worktree.test.mjs`.
61
72
 
@@ -65,16 +76,16 @@ A Skill is model-invoked, so it fires only when the model judges a match. The `.
65
76
 
66
77
  ### Autonomous mode (sandbox / bypass permissions)
67
78
 
68
- When interactive approval is disabled, never block on questions. Auto-decide: on `main`, auto-create `feature/<task-slug>`; auto-rebase if the parent moved; auto-merge when ready; **delete** feature/fix branches after merge but **keep** long-lived ones (dev, staging, release/*); auto-generate meaningful commit messages; fix failing tests / convention violations rather than asking. Autonomous mode is MORE disciplined, not less, with the same quality bar.
79
+ When interactive approval is disabled, never block on questions. Auto-decide: cut the task's worktree from `origin/main` (auto-create `<prefix>/<task-slug>` per the label scheme); auto-rebase if the parent moved; auto-merge when ready; **delete** feature/fix branches after merge but **keep** long-lived ones (dev, staging, release/*); auto-generate meaningful commit messages; fix failing tests / convention violations rather than asking. Autonomous mode is MORE disciplined, not less, with the same quality bar.
69
80
 
70
81
  ### Code workflow (mandatory)
71
82
 
72
83
  Every code change MUST include, automatically:
73
84
 
74
85
  1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** WebJs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/<feature>.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `references/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`).
75
- 2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`docs/app/docs/<topic>`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`).
76
- 3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`packages/cli/templates/gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,saas-template,api-gallery}.js`), a gallery demo (`packages/cli/templates/gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`.
77
- 4. **Convention validation.** Run `webjs check` and fix violations.
86
+ 2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`website/app/docs/<topic>`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`).
87
+ 3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`packages/cli/templates/gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,api-gallery}.js`), a gallery demo (`packages/cli/templates/gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`.
88
+ 4. **Convention validation.** Run `webjs check` and fix violations. Run `webjs doctor` too when you touched an in-repo app (`examples/blog`, `website`, `docs`, `packages/ui/packages/website`): the required `conventions` CI job runs it over all four, and it fails on a hard toolchain check or on whatever that app's `webjs.doctor.gate` marks `error` (today `UNMARKED_ASSET_LINKS` in `website` and `examples/blog`), so a clean `webjs check` alone is not enough to predict that job (#1257).
78
89
 
79
90
  ### Git workflow (mandatory)
80
91
 
@@ -102,7 +113,7 @@ An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rai
102
113
 
103
114
  - **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 + `references/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. See `references/runtime.md` for the Node vs Bun command + difference reference.
104
115
  - **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.
105
- - **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.
116
+ - **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 action=${importedAction}>` would do.
106
117
  - **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 `references/components.md`.
107
118
  - **Server actions with rich types.** A `*.server.{js,ts}` file with `'use server'` exports functions importable from the client (the import is rewritten to a typed RPC stub); the wire round-trips `Date` / `Map` / `Set` / `BigInt` / `Error` / typed arrays / `Blob` / `File` / `FormData` / Symbols / cycles. The source is never served to the browser (invariant 1).
108
119
  - **Only files reachable from a browser-bound entry are servable.** The dev server walks the import graph from every page / layout / error / loading / not-found / component file (lazily on first request, re-derived after each `fs.watch` rebuild); that Set is the authorisation gate, so anything no client code imports returns 404. The walk follows static `import` / `export … from` AND string-literal dynamic `import('./x.ts')` edges (#751), so a lazily-imported app module is servable (it is NOT eagerly preloaded, since a dynamic import is lazy by intent). A computed `import(expr)` cannot be resolved statically and 404s with a dev hint.
@@ -157,7 +168,7 @@ app/ ROUTING ONLY (thin adapters importing from modules/;
157
168
  <path>/route.js HTTP handler at /<path>
158
169
  <segment>/middleware.js per-segment middleware
159
170
  <segment>/loading.js auto Suspense boundary
160
- middleware.js root middleware (every request)
171
+ middleware.js root middleware (every request; .ts/.mts/.mjs too)
161
172
  readiness.js optional /__webjs/ready check (return false/throw = 503)
162
173
  env.js optional boot-time env validation (schema or validator fn; fails fast)
163
174
  instrumentation.js optional boot-time hook (register(); wire APM via setOnError, #848)
@@ -199,7 +210,7 @@ import { html, css, WebComponent, prop, render } from '@webjsdev/core';
199
210
  import { renderToString } from '@webjsdev/core/server';
200
211
  ```
201
212
 
202
- 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.
213
+ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server-only modules (`render-server.js`, `setCspNonceProvider`, `setAssetUrlProvider`); `renderToString` / `renderToStream` live at `@webjsdev/core/server` for Node-side consumers.
203
214
 
204
215
  | Export | Purpose |
205
216
  |---|---|
@@ -210,9 +221,10 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
210
221
  | `render(v, el)` | Client-side render into a DOM element. |
211
222
  | `renderToString` | Server-side async render to HTML with DSD (from `/server`). |
212
223
  | `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 })`. **NEVER** throw `redirect()` inside API route handlers (`route.ts`), as they must return a standard `Response.redirect(url, 303)` response object instead. |
213
- | `forbidden()` / `unauthorized()` | Throw to return 403 / 401 (#848, Next 15/16 parity). Renders the nearest `forbidden.{js,ts}` / `unauthorized.{js,ts}` boundary (innermost wins), else a default page, from a page/layout render OR a page `action` (the no-JS write path). `forbidden()` for an authenticated user lacking permission, `unauthorized()` for a request that is not authenticated. Same control-flow-throw model as `notFound()`: NOT for a `route.ts` handler, and inside a `'use server'` RPC action return an `ActionResult` for an auth failure instead (a raw throw there is a generic 500, like `notFound()`/`redirect()`). |
224
+ | `forbidden()` / `unauthorized()` | Throw to return 403 / 401 (#848, Next 15/16 parity). Renders the nearest `forbidden.{js,ts}` / `unauthorized.{js,ts}` boundary (innermost wins), else a default page, from a page/layout render OR a form-bound server action (the no-JS write path). `forbidden()` for an authenticated user lacking permission, `unauthorized()` for a request that is not authenticated. Same control-flow-throw model as `notFound()`: NOT for a `route.ts` handler, and inside a `'use server'` RPC action return an `ActionResult` for an auth failure instead (a raw throw there is a generic 500, like `notFound()`/`redirect()`). |
214
225
  | `Suspense({fallback, children})` | Page/region-level streaming boundary (a value in a hole). `repeat` keyed-list directive is also re-exported. |
215
226
  | `<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. |
227
+ | `asset(path)` | Content-hash a `public/` asset url so a deploy cannot serve stale bytes: `href=${asset('/public/app.css')}` emits `?v=<hash>` and is served `immutable` for a year (#1194). Prod-only, `public/` paths only. Use it in a PAGE, LAYOUT, or metadata route, not inside a component that ships: hydration re-renders on the client where there is no resolver, so the hashed url is swapped for the bare path and the asset is fetched twice. Call it inside the render function, since a module-scope call is a side effect that ships the module. Mark only files that change with a DEPLOY (the hash is memoized for the process lifetime). Opt-in on purpose: do NOT mark a `rel=preload` hint whose asset is fetched by CSS `url()`, or the preload can never match. |
216
228
  | `connectWS(url, handlers)` / `richFetch<T>` | Client WebSocket (auto-reconnect, queued sends); content-negotiated rich-type fetch. |
217
229
  | `navigate(url, opts?)` / `revalidate(url?)` | Programmatic client-router nav; evict the BROWSER snapshot cache. |
218
230
  | `optimistic(signal, value, action)` / `optimistic(host, { source, update })` | Imperative: set `signal` immediately, run `action`, roll back on error. Declarative (preferred): queue optimistic updates with auto-release via `.add(payload, promise?)`. See `references/client-router-and-streaming.md`. |
@@ -225,7 +237,7 @@ lit-html parity: `repeat` (keyed lists), `unsafeHTML(str)` (trusted raw HTML, **
225
237
 
226
238
  ### `html` expression prefixes
227
239
 
228
- `<div>${x}</div>` text child; `class=${x}` attribute (escaped); `@click=${fn}` event listener (client-only, drops at SSR); `.value=${v}` DOM property (round-trips through SSR on custom elements via `data-webjs-prop-*`, drops on native elements); `?disabled=${b}` boolean attribute. Event/property/boolean holes **must be unquoted** (invariant 4). Every hole is identical server and client except `@event` and `.prop` on native elements.
240
+ `<div>${x}</div>` text child; `class=${x}` attribute (escaped); `action=${importedAction}` on a `<form>` or `formaction=${importedAction}` on a submitter inside a bound form binds a server action (#1155, #1207: the function-valued attribute holes where the attribute is omitted/rewritten and a `__webjs_action` identity is emitted; submitters must be actual submit controls and cannot carry their own `name`, `value`, `form`, or static `formaction` attributes; a quoted `action="${fn}"` / `formaction="${fn}"` or `action=${fn}` on un-supported tags is a render error, since stringifying a function would leak a server action's source); `@click=${fn}` event listener (client-only, drops at SSR); `.value=${v}` DOM property (round-trips through SSR on custom elements via `data-webjs-prop-*`, drops on native elements); `?disabled=${b}` boolean attribute. Event/property/boolean holes **must be unquoted** (invariant 4). Every hole is identical server and client except `@event`, `.prop` on native elements, and a **nullish-or-`false` plain-attribute hole**: the client REMOVES the attribute for `null` / `undefined` / `false`, while the server stringifies, emitting `attr=""` for nullish and `attr="false"` for `false`. So the two renderers disagree, and there is no "omit this attribute" hole value at all. `false` is the subtler half: `aria-expanded=${false}` serves `aria-expanded="false"` and hydrates to no attribute, and for a tri-state ARIA attribute those are NOT the same thing, so `?attr=${bool}` is not a substitute either. To omit an attribute conditionally, BRANCH the template (`cond ? html\`<div a=${v}>\` : html\`<div>\``) rather than passing a nullish value, and use `?attr=${bool}` for a genuine boolean attribute. This matters for ARIA in particular, where an empty `aria-labelledby` is an IDREF list resolving to nothing and an `aria-checked=""` is an attribute the role may not even allow.
229
241
 
230
242
  ---
231
243
 
@@ -256,9 +268,9 @@ MyThing.register('my-thing');
256
268
 
257
269
  **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).
258
270
 
259
- **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 interactive = 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 interactive = true` (the explicit author override that forces a ship when the analyser cannot see a component's interactivity statically, for example a dynamically-computed tag string or a `:defined` rule in an external stylesheet outside the module graph). **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`.
271
+ **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()`, the dynamic slot READ surface (`slotchange` / `assignedNodes` / `assignedElements` / `assignedSlot`; merely RENDERING a `<slot>` does not ship, since the SSR output carries the placed children), `static shadow = true`, `static interactive = 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 interactive = true` (the explicit author override that forces a ship when the analyser cannot see a component's interactivity statically, for example a dynamically-computed tag string or a `:defined` rule in an external stylesheet outside the module graph). **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`.
260
272
 
261
- **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. **Light-DOM component hosts default to `display: block`**: a custom element is `display: inline` in plain CSS, so the framework marks every LIGHT-DOM host `data-wj-host` and injects one head rule in a low-priority cascade layer, `@layer webjs-host { :where([data-wj-host]) { display: block } }`, so a container component does not collapse; the layer keeps it overridable by any author style INCLUDING Tailwind utilities (`class="flex"`/`grid`/`hidden` win, because their layer is ordered after `webjs-host`), a `[hidden]` carve-out keeps `?hidden` working, and an inline light component opts out with `my-tag { display: inline }`. **Shadow-DOM hosts are NOT marked** (a document rule would override the shadow tree's `:host`), so a shadow component sets its host display via `:host { display: block }` in `static styles` (respected because the host is unmarked; set it for a shadow block container). See the even-grid / no-reflow layout recipes in `references/styling.md`. **Never interpolate into a component's `<style>` or `<script>` body** (`html\`<style>${css}</style>\``): the server emits it but the client drops the raw-text hole, so it paints at SSR then wipes to empty on hydrate. Use `static styles` or Tailwind instead (flagged by `no-interpolation-in-raw-text-element`). A page/layout, which never hydrates, may interpolate a `css` result into `<style>`. 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 `references/components.md` + `references/muscle-memory-gotchas.md`.
273
+ **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. **Light-DOM slots ARE the native DOM slot API (#1021, full shadow parity):** `<slot>` works identically in light and shadow DOM, so post-mount native writes are LIVE (`appendChild`, `insertBefore`, `removeChild`, `el.remove()`, `innerHTML`, `el.slot=` flips, `HTMLSlotElement.assign()`) and the reads (`assignedNodes` / `assignedElements` / `{flatten}` / `assignedSlot` / `slotchange`, with native async-coalesced timing) match. Flip `static shadow` and nothing else changes; there is NO WebJs-specific slot API. The one write that does NOT flip is `assign()`: the light-DOM version is an extension (element-bound overlay alongside name matching), while native shadow `assign()` needs `slotAssignment: 'manual'`, which WebJs does not set, so avoid `assign()` in a component meant to flip modes. A FORWARDED slot (a template forwarding `<slot>` into a nested component) projects its content on the client and through hydration (#1023): the renderer stamps each slot with its template owner (`SLOT_OWNER`, carried across SSR as `data-wj-slot-owner`) so it routes to the OUTER host that rendered it, and a layout's `${children}` inside a slotted shell keeps its named slots in sync across a soft-nav swap (#1024, the swap resyncs every own slot of the enclosing host). Four inherent gaps, all a consequence of light DOM having no shadow boundary: structural host reads (`host.children` / the `innerHTML` getter show the rendered template, not the authored children, so read slotted content with `assignedNodes()`), `assignedChild.parentNode` is the `<slot>`, `::slotted()` CSS (style slotted content with normal selectors / Tailwind), and initial-projection lifecycle timing (`firstUpdated` sees the `<slot>` element with EMPTY `assignedNodes()`, the projection lands one microtask later; read assigned content from `slotchange` or after a microtask). Live writes need the component's JS on the page, so a display-only slotted wrapper elides (its writes are inert like anything elided; force a ship with `static interactive = true` for an imperative consumer the analyser cannot see). A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (invariant 7); prefer Tailwind. **Light-DOM component hosts default to `display: block`**: a custom element is `display: inline` in plain CSS, so the framework marks every LIGHT-DOM host `data-wj-host` and injects one head rule in a low-priority cascade layer, `@layer webjs-host { :where([data-wj-host]) { display: block } }`, so a container component does not collapse; the layer keeps it overridable by any author style INCLUDING Tailwind utilities (`class="flex"`/`grid`/`hidden` win, because their layer is ordered after `webjs-host`), a `[hidden]` carve-out keeps `?hidden` working, and an inline light component opts out with `my-tag { display: inline }`. **Shadow-DOM hosts are NOT marked** (a document rule would override the shadow tree's `:host`), so a shadow component sets its host display via `:host { display: block }` in `static styles` (respected because the host is unmarked; set it for a shadow block container). See the even-grid / no-reflow layout recipes in `references/styling.md`. **Never interpolate into a component's `<style>` or `<script>` body** (`html\`<style>${css}</style>\``): the server emits it but the client drops the raw-text hole, so it paints at SSR then wipes to empty on hydrate. Use `static styles` or Tailwind instead (flagged by `no-interpolation-in-raw-text-element`). A page/layout, which never hydrates, may interpolate a `css` result into `<style>`. 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 `references/components.md` + `references/muscle-memory-gotchas.md`.
262
274
 
263
275
  ---
264
276
 
@@ -268,8 +280,8 @@ MyThing.register('my-thing');
268
280
 
269
281
  - Default export is a possibly-async function receiving `{ params, searchParams, url, actionData }`. Runs **only on the server**. Throw `notFound()` / `redirect(url)` to short-circuit. `params` / `searchParams` are awaitable AND synchronously readable (`params.id` and `await params` both work, Next 15/16 parity, #848).
270
282
  - Named exports: `metadata` (static), `generateMetadata(ctx)` (async, takes precedence). Type both with `Metadata`. See `references/routing-and-pages.md`.
271
- - Optional `export const revalidate` (seconds) opts into the server HTML response cache (#241). SAFETY: only on a page identical for every visitor (no `cookies()` / session / per-user data); keyed by URL only. See `references/built-ins.md`.
272
- - Optional `export const action`: a fn `({ request, params, searchParams, url, formData })` handling a non-GET submission to the page's own URL (the no-JS write-path, #244), returning an `ActionResult`. Success is a `303` (PRG); failure re-renders the SAME page at `422` with the result on `ctx.actionData`. See `references/data-and-actions.md`.
283
+ - Optional `export const revalidate` (seconds) opts into the server HTML response cache (#241). SAFETY: only on a page identical for every visitor (no `cookies()` / session / per-user data); keyed by the request origin plus the URL, with no per-user keying. See `references/built-ins.md`.
284
+ - A page has NO `action` export. The no-JS write path is a `<form action=${importedAction}>` binding a `'use server'` action (#1155): a non-GET submission to the page's own URL is dispatched to the action the hidden `__webjs_action` field names, returning an `ActionResult`. Success is a `303` (PRG); failure re-renders the SAME page at `422` with the result on `ctx.actionData`; a submission binding nothing is a `405`. See `references/data-and-actions.md`.
273
285
  - Page modules also load on the client so imported components register; keep top-level imports browser-safe. **Server-only code goes only in `.server.{js,ts}`, `route.ts`, or `middleware.ts`. Never in pages, layouts, or components.**
274
286
 
275
287
  ### Layouts (`app/**/layout.{js,ts}`)
@@ -278,7 +290,7 @@ Default export receives `{ children, params, searchParams, url }`, must embed `c
278
290
 
279
291
  ### Error / loading / metadata routes
280
292
 
281
- `error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. `forbidden.{js,ts}` / `unauthorized.{js,ts}` render the nearest 403 / 401 boundary for a thrown `forbidden()` / `unauthorized()` (#848). Two **root-only** boundaries (`app/` root exactly): `global-error.{js,ts}` is the app-wide catch-all tried after the nested `error` boundaries are exhausted, and it renders its **own** `<!doctype><html><body>` document (returned verbatim, since a root-layout failure is when it fires). Because it is returned verbatim (no framework `<head>` splice), it ships **no importmap or boot script**, so keep it **static HTML with no components/hydration** (a last-resort page must not depend on the module system that may have just failed); under an opt-in CSP, an inline `<style>`/`<script>` in it must carry the nonce via `cspNonce()`. `global-not-found.{js,ts}` renders for an unmatched-anywhere URL when no `not-found` matches. `not-found` is nearest-wins from the throwing page's chain (#848 fixed the prior root-only behavior). Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML.
293
+ `error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. `forbidden.{js,ts}` / `unauthorized.{js,ts}` render the nearest 403 / 401 boundary for a thrown `forbidden()` / `unauthorized()` (#848). Two **root-only** boundaries (`app/` root exactly): `global-error.{js,ts}` is the app-wide catch-all tried after the nested `error` boundaries are exhausted, and it renders its **own** `<!doctype><html><body>` document (returned verbatim, since a root-layout failure is when it fires). Because it is returned verbatim (no framework `<head>` splice), it ships **no importmap or boot script**, so keep it **static HTML with no components/hydration** (a last-resort page must not depend on the module system that may have just failed); under an opt-in CSP, an inline `<script>` in it must carry the nonce via `cspNonce()` (an inline `<style>` needs one only if you tighten `style-src`, since the default allows inline style outright). `global-not-found.{js,ts}` renders for an unmatched-anywhere URL when no `not-found` matches. `not-found` is nearest-wins from the throwing page's chain (#848 fixed the prior root-only behavior). Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML.
282
294
 
283
295
  ### Route handlers (`app/**/route.{js,ts}`)
284
296
 
@@ -286,7 +298,7 @@ Named async exports per method (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`), each `(Req
286
298
 
287
299
  ### Middleware (`middleware.{js,ts}`)
288
300
 
289
- Optional top-level + per-segment. Default export `async (req, next) => Response`. Return a Response to short-circuit, or call `next()` then post-process. Per-segment applies to its subtree, outermost to innermost.
301
+ Optional top-level + per-segment. Default export `async (req, next) => Response`. Return a Response to short-circuit, or call `next()` then post-process. Per-segment applies to its subtree, outermost to innermost. The root file sits beside `app/` and may be `middleware.ts` / `.js` / `.mts` / `.mjs` (`.ts` wins if more than one exists).
290
302
 
291
303
  ### Env validation (`env.{js,ts}`)
292
304
 
@@ -296,7 +308,7 @@ Optional app-root file default-exporting a **schema object** (env-var names to a
296
308
 
297
309
  | File | `'use server'`? | What it is |
298
310
  |---|---|---|
299
- | `*.server.ts` | yes | **Server action.** Source-protected AND RPC-callable; client imports become stubs POSTing to `/__webjs/action/<hash>/<fn>`. |
311
+ | `*.server.ts` | yes | **Server action.** Source-protected AND RPC-callable; client imports become stubs POSTing to `/__webjs/action/<hash>/<fn>`. The same `<hash>/<fn>` identity is what a bound `<form action=${fn}>` submits, so one action is reachable over RPC AND as a plain HTML form post to the page's own url. |
300
312
  | `*.server.ts` | no | **Server-only utility.** Source-protected; browser imports get a throw-at-load stub. |
301
313
  | Plain `.ts` | yes | **Lint violation** (`use-server-needs-extension`). Rename to add `.server.`. |
302
314
  | Plain `.ts` | no | Browser-safe; standard. |
@@ -305,7 +317,7 @@ The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap:
305
317
 
306
318
  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 `references/data-and-actions.md`.
307
319
 
308
- **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`; 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 declared middleware runs automatically on the RPC boundary. On the `route.ts` boundary it applies when the `route()` adapter is given the action's MODULE NAMESPACE (`import * as postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). 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.
320
+ **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`, where `swr` appends `stale-while-revalidate=<swr>` seconds so an expired response is still served instantly while the browser revalidates it in the background; **`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`; 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 declared middleware runs automatically on the RPC boundary. On the `route.ts` boundary it applies when the `route()` adapter is given the action's MODULE NAMESPACE (`import * as postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). 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, and carries a weak ETag (answering `If-None-Match` with a 304); with a `cache` export it also carries `Cache-Control` + `X-Webjs-Tags`, and without one it is `no-store` (caching is opted into by `cache`, never granted by the verb); 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.
309
321
 
310
322
  ### RPC + REST endpoint security
311
323
 
@@ -337,6 +349,16 @@ type ActionResult<T> =
337
349
 
338
350
  ---
339
351
 
352
+ ## Types: derive them, never `unknown` or `any`
353
+
354
+ **Full-stack type safety is the point of the `.server.{js,ts}` boundary, so an app that types its boundaries `unknown` or `any` gives up WebJs's central guarantee for nothing.** There is no build step and no code generation between the two sides: a client component importing a server action resolves to the action's real signature at type-check time, so the type is already there to be used. Nothing enforces this (both are valid TypeScript, so `tsc` and `webjs check` pass either way), which is exactly why it is written down.
355
+
356
+ Derive the type at every boundary: a DB row from the schema (`typeof todos.$inferSelect`, carried into a shipping component with `import type`), an action's input from a named `interface` and its result from `ActionResult<T>`, a page from `PageProps<'/blog/[slug]'>`, a layout from `LayoutProps`, a route handler's second argument from `RouteHandlerContext`, an href from the generated `Route` union (`npx webjsdev types`), a reactive property from `prop<Student>(Object)`.
357
+
358
+ `unknown` is right for a payload nothing has vouched for yet, narrowed on the next line (a `route.ts` `await req.json()`, an action's `export const validate`, a `catch` binding), and for a value headed for an `html` template hole (a hole renders a string, a number, a `TemplateResult`, or an array of those, so `TemplateResult` alone is too narrow). Everywhere else it is a missing type, not a safe one: `unknown` surviving into a return type, a component prop, a layout's `children`, or an action signature is the shape to fix. The template-hole case covers a parameter of YOUR OWN helper, never a value the framework already types. `any` has no carve-out at all. Full ladder, with the end-to-end example, in `references/typescript.md`.
359
+
360
+ ---
361
+
340
362
  ## Styling: Tailwind-first
341
363
 
342
364
  **Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`). Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`.
@@ -345,9 +367,9 @@ type ActionResult<T> =
345
367
 
346
368
  ## Client navigation: automatic, nothing to opt into
347
369
 
348
- The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. Nested layouts auto-emit `<!--wj:children:<segment-path>-->` markers; the client router walks both DOMs and replaces only the deepest shared layout's children slot, preserving outer-layout DOM identity. Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (the server returns only the divergent fragment); scroll is restored on back/forward. A non-GET `<form>` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
370
+ The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, served `private` so a shared cache can never store the reduced body and serve it to a full-page navigation, and additionally marked `Vary: X-Webjs-Have` for caches that honour it (#1140; `Vary` alone was not enough, since Cloudflare honours only `Accept-Encoding`)); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. A non-GET `<form>` that BINDS a server action (`action=${fn}`) is the no-JS write-path (with JS the router posts the same body to the same url and applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
349
371
 
350
- The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition">`, plus `data-webjs-permanent` to persist a live element), **stream actions** (`<webjs-stream>` element-level updates, #248), and the **opt-in nav-loading indicator** (`<html data-webjs-nav-progress>` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2).
372
+ The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition" content="same-origin">`, where the router checks that exact `content` value and a bare tag enables nothing, plus `data-webjs-permanent` to persist a live element), **stream actions** (`<webjs-stream>` element-level updates, #248), and the **opt-in nav-loading indicator** (`<html data-webjs-nav-progress>` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2).
351
373
 
352
374
  ---
353
375
 
@@ -366,9 +388,15 @@ class TodoList extends WebComponent({
366
388
  }) {
367
389
  private optimisticTodos = optimistic(this, {
368
390
  source: () => this.todos,
369
- update: (state, title: string) => [
391
+ // KEEP THIS REDUCER PURE. `.value` re-folds every queued update on EVERY
392
+ // read, so a `crypto.randomUUID()` here would mint a NEW id per render and
393
+ // a keyed list would tear the pending row down on each update. Mint the
394
+ // temp id in the handler and carry it in the payload.
395
+ update: (state, add: { tempId: string; title: string }) => [
370
396
  ...state,
371
- { id: crypto.randomUUID() as any, title, completed: false, pending: true },
397
+ // `createdAt` is rebuilt per read too, tolerable only because nothing
398
+ // keys on it. Put it in the payload as well if anything does.
399
+ { id: add.tempId, title: add.title, completed: false, createdAt: new Date(), pending: true },
372
400
  ],
373
401
  });
374
402
 
@@ -378,12 +406,20 @@ class TodoList extends WebComponent({
378
406
  if (!title) return;
379
407
  (e.target as HTMLFormElement).reset();
380
408
 
409
+ // Minted ONCE here, not in the reducer. No cast needed: `Todo['id']` is a
410
+ // string (a uuid primary key). Against an auto-increment integer id, model
411
+ // the temp row instead of casting.
412
+ const tempId = crypto.randomUUID();
381
413
  const promise = createTodo({ title });
382
- this.optimisticTodos.add(title, promise);
414
+ this.optimisticTodos.add({ tempId, title }, promise);
383
415
 
384
416
  const result = await promise;
385
417
  if (result.success && result.data) {
386
- this.todos = [...this.todos.filter(t => t.pending), result.data, ...this.todos.filter(t => !t.pending)];
418
+ // The optimistic row is never written to `this.todos`: the overlay holds
419
+ // only the payload and `update` rebuilds the row on each `.value` read,
420
+ // so this prop holds only confirmed rows. Append the server's row,
421
+ // matching the order the `update` reducer used.
422
+ this.todos = [...this.todos, result.data];
387
423
  }
388
424
  }
389
425
 
@@ -396,7 +432,9 @@ class TodoList extends WebComponent({
396
432
  TodoList.register('todo-list');
397
433
  ```
398
434
 
399
- `.add(payload, promise)` auto-releases when the promise settles (resolve or reject). No try-catch, no manual rollback, no temp-ID bookkeeping.
435
+ `.add(payload, promise)` auto-releases when the promise settles (resolve or reject). No try-catch, no manual rollback, no reconciling a temp id against the real one.
436
+
437
+ **Keep `update` pure.** `.value` re-folds the whole queue on every read, so the reducer runs again on each render rather than once per `.add()`, and anything it mints is minted per render. Mint a temp id in the handler and pass it in the payload, as above. A `crypto.randomUUID()` inside the reducer hands the pending row a fresh id every read, which breaks `repeat()` keying and anything else treating that id as stable.
400
438
 
401
439
  ### Simple flips: imperative API
402
440
 
@@ -430,7 +468,7 @@ const result = await optimistic(liked, true, () => likePost(postId));
430
468
 
431
469
  ## Invariants (for both humans and agents)
432
470
 
433
- > Hit one of these as a runtime error? The [Troubleshooting page](https://docs.webjs.dev/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.
471
+ > Hit one of these as a runtime error? The [Troubleshooting page](https://webjs.dev/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.
434
472
 
435
473
  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 (`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.
436
474
  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.
@@ -445,13 +483,17 @@ const result = await optimistic(liked, true, () => likePost(postId));
445
483
 
446
484
  11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a `<my-tag>`, an `[expr]` subscript, or a `<code>foo()</code>` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges; semicolons and colons stay fine inside code / TS / JSON / CSS. The same hook also enforces brand casing with one simple rule: `WebJs` is a proper noun, so write it capitalized wherever it NAMES the project in prose, at a sentence start AND mid-sentence (`WebJs ships`, `Most WebJs apps`, `the WebJs serializer`). It stays lowercase `webjs` ONLY as a literal code token: a `webjs <subcommand>` CLI command (`webjs dev`, `webjs db migrate`), a `webjs.dev` domain, an `@webjsdev` package, a `"webjs"` config key, a `WEBJS_*` env var, the `webjsdev/webjs` org path, or anything inside a `` `code` `` span or fenced block. If you mean the literal config key or command in prose, wrap it in backticks. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to fix a glyph or casing).
447
485
 
486
+ 12. **A form that writes binds its action: `<form action=${importedAction}>`, and a form whose buttons run different actions binds each on its submitter, `<button formaction=${importedAction}>` inside that bound form** (#1155, #1207). Those are the two shapes the renderer reads, and every near-miss throws rather than producing a form that posts nowhere. The submitter identity rides the pressed button's own `name`/`value` pair (the one channel a browser submits for that button alone), so no `formaction` url is emitted, both entries reach the server, and the dispatcher takes the LAST `__webjs_action` in DOM order, which is the submitter's whenever one was pressed. **Refused on a form:** a quoted `action="${fn}"`, `action=${fn}` on any tag other than `<form>`, `method="get"` or an enctype the server cannot parse, a `.method` / `.enctype` / `.encoding` PROPERTY binding (a `.prop` drops at SSR and applies in the browser, so the form would submit differently with JS than without), a second `action` hole, a plain `action="/url"` alongside the bound hole (SSR keeps it and the client drops it), a whitespace-padded `method=" post "` (an enumerated attribute is matched against exact keywords, so a padded value falls to the invalid-value default and submits as a GET), and a function that is not a `'use server'` export. **Refused on a submitter:** an enclosing form that is not itself bound (`method` and the enctype are forced on the form's start tag, too late to add from the button), a control that is not a submit control, an `<input type="image">` (it submits `name.x` / `name.y` coordinates, so the identity never arrives), an `<input type="submit">` (the identity has to occupy its `value`, which on that control is also its visible label, so it would render captioned with the action id and could never be labelled; a `<button>` has no such conflict), a submitter carrying its own `name`, `value`, `form`, or static `formaction`, a `.prop` spelling of any of those (`.name` / `.value` / `.formAction` / `.formMethod` / `.formEnctype` all REFLECT on a submitter, so the write is dropped at SSR and lands in the attribute in the browser), and a second `formaction` hole. **Refused on ANY submitter inside a bound form, whether or not it binds:** `formmethod="get"` and an unparseable `formenctype` such as `text/plain`, since either works under JS (the router posts `FormData`) and is a bare `405` without it. Two carve-outs there: `formmethod="dialog"` never submits, and a plain `formaction="/url"` retargets away from the bound action. WebJs supplies `method` and `enctype` only where your template supplies neither, judged from the TEMPLATE rather than the rendered element: `?method=${false}` emits nothing so it is supplied, while `method=${null}` emits `method=""` and is refused. An `encoding=` attribute is inert in HTML (only the `.encoding` PROPERTY aliases `enctype`), so both renderers ignore it. A page has no `action` export, so a bare `<form method="post">` is a `405`. **Boundness is BEST EFFORT in both renderers, and neither always knows.** SSR reads a linear byte stream, so it knows within one template scan, but a COMPONENT renders its own template in a separate pass with no view of the host page, and the client may reconcile a submitter whose form lives in a parent template and is not in the tree yet (a `repeat()` row). Each renderer therefore distinguishes three answers: bound (fine), unbound (refused), and cannot-tell (bind anyway). Cannot-tell has to bind: refusing there would reject a per-row button in a list or a button inside a component, both ordinary shapes, and at SSR a refused component is ISOLATED, so in production the button would silently vanish from a page that still returned 200. The refusal still fires wherever the answer is genuinely known.
487
+
448
488
  ---
449
489
 
450
490
  ## Scaffolding
451
491
 
452
- 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). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors any of the three for Bun (`bun --bun` dev/start scripts so the SERVER runs on Bun, `bun.lock`, a pure `oven/bun:1` Dockerfile (#595; safe since cli@0.10.20's npx-free `webjs db migrate` (#570) needs no Node in the image) + bun-install CI, bun-command agent docs; the test/db/check tooling stays on Node); `bun create webjs <name>` auto-detects it. 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.
492
+ Two scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite, plus a browsable feature gallery), and `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR). Auth is one of the full-stack gallery cards (login + a signed session + a real protected route), so a full-stack app already carries a promotable auth baseline (this replaced the former `--template saas`). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors either for Bun (`bun --bun` dev/start scripts so the SERVER runs on Bun, `bun.lock`, a pure `oven/bun:1` Dockerfile (#595; safe since cli@0.10.20's npx-free `webjs db migrate` (#570) needs no Node in the image) + bun-install CI, bun-command agent docs; the test/db/check tooling stays on Node); `bun create webjs <name>` auto-detects it. Pick from the request: default (full-stack) for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce, a SaaS with accounts/login), `api` for an HTTP/JSON API with no UI; default to full-stack when ambiguous.
453
493
 
454
- 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. **The scaffold ships a gallery index home, a neutral-palette root layout, db wiring, and a densely-commented feature gallery** (single-concept demos under `app/features/` plus the `app/examples/todo` app, with logic in `modules/`), alongside the one cross-agent skill at `.agents/skills/webjs/` (a routing `SKILL.md` + on-demand `references/`) that teaches how to build. Treat the gallery as browsable reference: read the demos to learn the idioms, then build the app the user asked for by growing the scaffold (add routes under `app/`, components under `components/`, features under `modules/<feature>/`, keep server-only code behind `.server.ts`) and prune the demos the app does not use (delete the `app/features/<x>` route AND its `modules/<x>`). Give a UI app its own palette by setting the token values in `app/layout.ts`. There is no design gate and no placeholder gate: taste is the agent's job, not the checker's. Docs at https://docs.webjs.dev.
494
+ The `<name>` must be a valid package name (letters, digits, and the separators `-`, `.`, `_`, starting with a letter or a digit, at most 214 characters, and not `node_modules` or `favicon.ico`, which npm reserves). It becomes the app directory, the generated `package.json` `name`, AND a value the scaffold writes into generated source, so anything else is rejected before a single file is written (#1066). Uppercase is fine (`webjs create MyApp` works), since the scaffold's manifest is private and never published.
495
+
496
+ 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. **The scaffold ships a gallery index home, a neutral-palette root layout, db wiring, and a densely-commented feature gallery** (single-concept demos under `app/features/` plus the `app/examples/todo` app, with logic in `modules/`), alongside the one cross-agent skill at `.agents/skills/webjs/` (a routing `SKILL.md` + on-demand `references/`) that teaches how to build. Treat the gallery as browsable reference: read the demos to learn the idioms, then build the app the user asked for by growing the scaffold (add routes under `app/`, components under `components/`, features under `modules/<feature>/`, keep server-only code behind `.server.ts`) and prune the demos the app does not use (delete the `app/features/<x>` route AND its `modules/<x>`). Give a UI app its own palette by setting the token values in `app/layout.ts`. There is no design gate and no placeholder gate: taste is the agent's job, not the checker's. Docs at https://webjs.dev/docs.
455
497
 
456
498
  ---
457
499
 
@@ -464,25 +506,25 @@ webjs test [--server] [--browser] [--watch]
464
506
  webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop
465
507
  webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping
466
508
  webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check, ui kit
467
- webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory); non-zero exit on a hard fail. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code); --strict also fails the exit on warnings (#975)
509
+ webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory; a warning when a route module writes a `<link rel="stylesheet">` without `asset()`, #1095); non-zero exit on a hard fail OR on a check the app gated `error`. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code + its effective severity; summary counts pass/warn/fail/off), plus a third `configErrors` key on the one path where a rejected `webjs.doctor` config stops any check running; --strict additionally fails on every REMAINING warning (#975). Per-check severity is CONFIG, not a flag: `webjs.doctor.gate` maps a code to `off` / `warn` / `error` so CI gates a chosen subset (#1257)
468
510
  webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258)
469
511
  webjs version # print the installed @webjsdev/cli version (also: webjs --version / -v, #975)
470
512
  webjs help [command] # full usage banner, or per-command usage + Options + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs <command> --help / -h (that command). typecheck/db/ui --help forward to their wrapped tool; an unknown topic exits 1
471
513
  webjs typecheck [tsc args...] # the project's own tsc --noEmit
472
- webjs create <name> [--template api|saas]
514
+ webjs create <name> [--template api]
473
515
  webjs db <generate|migrate|push|studio|seed> # wraps drizzle-kit (+ runs db/seed.server.ts)
474
516
  webjs ui init | add <names...> | list | view <name>
475
517
  webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importmap pinning, .webjs/vendor/importmap.json
476
518
  ```
477
519
 
478
- `--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.
520
+ `--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/`, which serves the docs and the UI gallery, plus the redirect-only `docs/` and `packages/ui/packages/website/` hosts, and `examples/blog/`): `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.
479
521
 
480
522
  ---
481
523
 
482
524
  ## Environment, server config, caching, observability
483
525
 
484
526
  - **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 `references/built-ins.md`.
485
- - **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.dev.regenerate` / `webjs.start.before`, #550 + #967, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`; `regenerate` recompiles a stale served build output like `public/tailwind.css` ON REQUEST in dev, replacing a fragile `--watch`). Type it with `WebjsConfig` + the JSON Schema. See `references/built-ins.md`.
527
+ - **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.dev.regenerate` / `webjs.start.before`, #550 + #967, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`; `regenerate` recompiles a stale served build output like `public/tailwind.css` ON REQUEST in dev, replacing a fragile `--watch`), and the doctor severity gate (`webjs.doctor.gate`, #1257, mapping a stable doctor code to `off` / `warn` / `error` so CI fails on a chosen subset of project-health checks instead of on every warning). Type it with `WebjsConfig` + the JSON Schema. See `references/built-ins.md`.
486
528
  - **Caching + file storage** (`references/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).
487
529
  - **Observability** (`references/built-ins.md`). Access log, `requestId()` + `X-Request-Id`, the `onError` APM hook, `GET /__webjs/version` (#239).
488
530
 
@@ -492,7 +534,7 @@ webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importma
492
534
 
493
535
  Every app ships the **conventions** as prose, in `.agents/skills/webjs/SKILL.md` (how to build) and `.agents/rules/workflow.md` (git, tests, review). These are guidance a reasonable project could do differently (modules layout, action placement, one-function-per-file, testing, styling, git workflow), followed by judgment, not tool-enforced.
494
536
 
495
- **`webjs check` is a separate, narrower tool: correctness checks only.** Every rule catches code that is wrong to ship (a crash, a security leak, a build/type-strip failure). They run unconditionally with no per-project disabling. The dividing line: could a sensible app legitimately want this to pass? If yes it is a convention (prose), if no it is a check (the tool). `webjs check --rules` lists them.
537
+ **`webjs check` is a separate, narrower tool: correctness checks only.** Every rule catches code that is wrong to ship (a crash, a security leak, a reactive prop that silently stops re-rendering, a build/type-strip failure). They run unconditionally with no per-project disabling. The dividing line: could a sensible app legitimately want this to pass? If yes it is a convention (prose), if no it is a check (the tool). `webjs check --rules` lists them.
496
538
 
497
539
  So: read the skill and follow its conventions by judgment; run `webjs check` and fix every violation (correctness bugs, not style).
498
540
 
@@ -522,14 +564,43 @@ export default async function User({ params }: { params: { id: string } }) {
522
564
  }
523
565
  ```
524
566
 
567
+ ### Add a form (the no-JS write path)
568
+
569
+ ```ts
570
+ // modules/feedback/actions/submit-feedback.server.ts
571
+ 'use server';
572
+ export async function submitFeedback(formData: FormData) {
573
+ const email = String(formData.get('email') || '').trim();
574
+ if (!email.includes('@')) {
575
+ return { success: false, fieldErrors: { email: 'Enter a valid email' }, values: { email } };
576
+ }
577
+ return { success: true, redirect: '/feedback/thanks' };
578
+ }
579
+
580
+ // app/feedback/page.ts
581
+ import { html } from '@webjsdev/core';
582
+ import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts';
583
+ export default function Feedback({ actionData }: { actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> } }) {
584
+ return html`
585
+ <form action=${submitFeedback}>
586
+ <input name="email" value=${actionData?.values?.email || ''}>
587
+ ${actionData?.fieldErrors?.email ? html`<p>${actionData.fieldErrors.email}</p>` : ''}
588
+ <button>Send</button>
589
+ </form>
590
+ `;
591
+ }
592
+ ```
593
+
594
+ Binding the action is the whole wiring: no `method`, no `enctype`, no adapter. Works with JS off; with JS the client router applies the 422 in place and follows the 303.
595
+
525
596
  ### Add a server action
526
597
 
527
598
  ```ts
528
599
  // modules/users/actions/update-profile.server.ts
529
600
  'use server';
530
601
  import { eq } from 'drizzle-orm';
531
- import { db } from '../../../db/connection.server.ts';
532
- import { users } from '../../../db/schema.server.ts';
602
+ import { db } from '#db/connection.server.ts';
603
+ import { users } from '#db/schema.server.ts';
533
604
  export async function updateProfile(input: { name: string }) {
534
605
  const name = String(input?.name || '').trim();
535
606
  if (!name) return { success: false, error: 'name required', status: 400 };
@@ -538,7 +609,7 @@ export async function updateProfile(input: { name: string }) {
538
609
  }
539
610
  ```
540
611
 
541
- Call it from a client component via a normal import (rewritten to an RPC stub). A component is one custom element per file (`class X extends WebComponent { render() { return html\`…\`; } }` then `X.register('x-tag')`). Full recipes, including the no-JS page-action form, are in `references/data-and-actions.md`.
612
+ Call it from a client component via a normal import (rewritten to an RPC stub). A component is one custom element per file (`class X extends WebComponent { render() { return html\`…\`; } }` then `X.register('x-tag')`). Full recipes, including the no-JS bound form, are in `references/data-and-actions.md`.
542
613
 
543
614
  ---
544
615