@webjsdev/mcp 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,9 @@ subcommand) delegates to this same server, so both routes run identical code.
34
34
  MCP `resources` (the `agent-docs/*` corpus + `AGENTS.md` as `webjs-docs://*`),
35
35
  and `prompts` (the recipes as guided workflows).
36
36
  - **`source` tool**: reads the framework's own no-build source from
37
- `node_modules/@webjsdev/*/src` (read-only, traversal-guarded).
37
+ `node_modules/@webjsdev/*/src` (read-only, traversal-guarded). Under Bun
38
+ zero-install (no `node_modules`) it falls back to Bun's global cache, where the
39
+ packages live, so it works without an install.
38
40
 
39
41
  The docs corpus is bundled into the package at `prepack`, so `npx @webjsdev/mcp`
40
42
  is self-contained; in the monorepo it falls back to the live repo-root docs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "The webjs Model Context Protocol server: live app introspection (routes / actions / components / check) plus the framework knowledge layer (docs, recipes, source) for AI coding agents",
6
6
  "bin": {
@@ -14,7 +14,8 @@ reference there.
14
14
  | `agent-docs/components.md` | WebComponent deep-dive (controllers, hooks, light/shadow DOM, slots) |
15
15
  | `agent-docs/styling.md` | Tailwind helpers + vanilla-CSS opt-out conventions |
16
16
  | `agent-docs/built-ins.md` | Auth, sessions, env vars, caching (cache(), HTTP cache, asset-hash, conditional GET), rate-limit, broadcast, file storage |
17
- | `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, ingress caps, dev/start task orchestration) + observability |
17
+ | `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, client-router opt-out, ingress caps, dev/start task orchestration) + observability |
18
+ | `agent-docs/runtime.md` | Node vs Bun (commands, the zero-install Bun bootstrap, the listener/stripper/SQLite/WebSocket differences) + the future-Deno seam |
18
19
  | `agent-docs/advanced.md` | Suspense streaming, performance, bundling, client router (prefetch, frames, view transitions, stream actions), WebSockets |
19
20
  | `agent-docs/typescript.md` | TS at runtime + full-stack type safety |
20
21
  | `agent-docs/service-worker.md` | The opt-in progressive-enhancement service worker (`public/sw.js`) |
@@ -37,6 +38,18 @@ reference there.
37
38
 
38
39
  Claude Code enforces step 1 via `.claude/hooks/guard-branch-context.sh`. Other agents check manually.
39
40
 
41
+ ### One task per git worktree when agents run concurrently
42
+
43
+ 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:
44
+
45
+ ```sh
46
+ git worktree add -b <prefix>/<slug> ../<repo>-<slug> origin/main
47
+ cd ../<repo>-<slug> # do ALL work for the task here
48
+ # ... commit + push from this worktree; after merge: git worktree remove ../<repo>-<slug>
49
+ ```
50
+
51
+ 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.
52
+
40
53
  ### Skills are routed deterministically, never skipped
41
54
 
42
55
  A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. The skills themselves are committed under `.claude/skills/` (alongside the hooks), so a fresh clone has both the router and the skills it routes to (no machine-local dependency). Tests in `test/hooks/route-skills.test.mjs`, which also asserts every skill the hook references is committed in-repo.
@@ -49,8 +62,8 @@ When interactive approval is disabled, never block on questions. Auto-decide: on
49
62
 
50
63
  Every code change MUST include, automatically:
51
64
 
52
- 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). `npm test` does NOT run browser or e2e; run them yourself and report the result. Never report work done with failing or missing tests. See `agent-docs/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`).
53
- 2. **Documentation.** Update `AGENTS.md` for new API surface, `CONVENTIONS.md` for new conventions, `docs/` or `website/` for user-facing features.
65
+ 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 `agent-docs/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`).
66
+ 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` + `agent-docs/*.md` 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`).
54
67
  3. **Convention validation.** Run `webjs check` and fix violations.
55
68
 
56
69
  ### Git workflow (mandatory)
@@ -75,9 +88,9 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, per-f
75
88
 
76
89
  ## What webjs is
77
90
 
78
- An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. The component runtime API matches lit (reactive `static properties`, the lit lifecycle hooks, ReactiveControllers, the `lit-html` directive set, `html` / `css` templates) so lit training data transfers directly, but webjs ships its own no-build implementation under `packages/core/src/`. Decorators are the one lit exception (invariant 10); use `declare` + `static properties`.
91
+ An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. The component runtime API matches lit (reactive properties, the lit lifecycle hooks, ReactiveControllers, the `lit-html` directive set, `html` / `css` templates) so lit training data transfers directly, but webjs ships its own no-build implementation under `packages/core/src/`. Reactive properties are the one deliberate divergence (invariant 10): instead of lit's `@property()` decorator or a `static properties` block, webjs declares them through the declare-free base-class factory `extends WebComponent({ count: Number })`.
79
92
 
80
- - **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped through a pluggable stripper (#508): Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, position-preserving) (invariant 10 + `agent-docs/typescript.md`). **Runs on Node 24+ or Bun** (run a Bun app with `bun --bun run dev` / `start`); the early `assertNodeVersion()` preflight enforces the Node floor and admits Bun. On Bun, `startServer` selects a native `Bun.serve` listener shell instead of the node:http one (skipping the compat bridge for ~1.9x more req/s on the listening path, at near-complete feature parity, the one node-only gap being 103 Early Hints since `Bun.serve` has no informational-response API), via a runtime-neutral seam that also sets up future `Deno.serve` / embedded adapters. Edge runtimes (no filesystem) are a separate, later target.
93
+ - **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped through a pluggable stripper (#508): Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, position-preserving) (invariant 10 + `agent-docs/typescript.md`). **Runs on Node 24+ or Bun** (run a Bun app with `bun --bun run dev` / `start`); the early `assertNodeVersion()` preflight enforces the Node floor and admits Bun. On Bun, `startServer` selects a native `Bun.serve` listener shell instead of the node:http one (skipping the compat bridge for ~1.9x more req/s on the listening path, at near-complete feature parity, the one node-only gap being 103 Early Hints since `Bun.serve` has no informational-response API), via a runtime-neutral seam that also sets up future `Deno.serve` / embedded adapters. Edge runtimes (no filesystem) are a separate, later target. See `agent-docs/runtime.md` for the Node vs Bun command + difference reference (and the zero-install Bun bootstrap).
81
94
  - **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.
82
95
  - **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.
83
96
  - **Display-only components are elided from the browser.** A component with no interactivity signal renders identical HTML with or without its JS, so the framework strips its import (and any vendor reachable only through it, importmap entry included) from the served source. Automatic, conservative, verified differentially. Disable with `"webjs": { "elide": false }` or `WEBJS_ELIDE=0`. See `agent-docs/components.md`.
@@ -96,7 +109,13 @@ webjs has **no server/client component split.** There is no RSC render tree, no
96
109
  - **Components hydrate.** The module loads in the browser, registers the custom element, the browser upgrades the SSR'd tag, and `render()` / lifecycle / `@event` / signals run client-side. Per-element, islands-style. **All interactivity lives here.**
97
110
  - **Pages and layouts do NOT hydrate.** Their function runs only on the server to produce HTML and is never re-invoked in the browser. So a page/layout cannot be interactive in its own markup (an `@click` in a page template is dropped at SSR; a signal read in a page body never re-renders). For interactivity, render a component's tag.
98
111
 
99
- A page/layout module still **loads** in the browser for its top-level side effects: registering imported components (so their tags upgrade) and, for a layout, enabling the client router via `import '@webjsdev/core/client-router'`. That load is also how its imports reach the client (`import dayjs` at the top of a page fetches dayjs when the module loads, not via hydration). An inert page/layout is dead weight, which is exactly when elision drops it.
112
+ A page/layout module still **loads** in the browser for its top-level side effects: registering imported components (so their tags upgrade). The client router is automatic and needs no import: it auto-enables when `@webjsdev/core` loads in the browser (the bundle every component pulls), so any page that ships a component gets client navigation for free (#620). That load is also how its imports reach the client (`import dayjs` at the top of a page fetches dayjs when the module loads, not via hydration). An inert page/layout is dead weight, which is exactly when elision drops it. A page/layout that is non-inert ONLY because it imports interactive components is **import-only** (#605): since it never hydrates, the boot emits its component modules directly and drops the page/layout module, so the browser fetches just the interactive leaves (it still ships whole when it has its own client side effect: a `client-router` import, a module-scope call, or a self-registering bare import).
113
+
114
+ **Keep pages and layouts as pure carriers (#621), so their modules stay out of the network tab.** A page/layout drops from the boot (import-only / inert) only while its sole browser-relevant job is registering the components it imports. It starts SHIPPING its own module the moment its closure does any OTHER client work, which is invisible in `npm test` (an elision verdict, not a behaviour change) and easy to introduce by accident. Avoid, in a page/layout (and in any util it imports):
115
+ - module-scope client work: a top-level call, a `new SomethingNonData()`, a browser-global (`window` / `document` / `customElements`) access, or a bare side-effect import of a non-component or `@webjsdev/core/client-router` (routing is automatic). A page-template `@event` / inline-`<script>` is fine (it is SSR output, never module client work, #623 / #634).
116
+ - importing a client-effecting NON-component util. A helper that touches a client global or self-executes drags the whole page in. Put client-only behaviour inside a component; put server-only work in `.server.{js,ts}` (it never reaches the client closure); and if a util MIXES a pure helper with client-global code (the `cn.ts` shape, #619), split the client part into its own module so the pure helper does not pin every importer.
117
+
118
+ Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab or the boot `<script type="module">`; if one does, something in its closure does client work and is not a component.
100
119
 
101
120
  `route.{js,ts}` is the one routing file that is NOT isomorphic: a server-only HTTP handler (named `GET` / `POST` exports), the webjs equivalent of a Next route handler. It never ships to the client.
102
121
 
@@ -139,12 +158,30 @@ db/*.server.{js,ts} data layer (Drizzle: schema, columns, connection)
139
158
 
140
159
  Every file is a plain ES module.
141
160
 
161
+ ### Imports: prefer the `#` root alias over deep relatives (#555)
162
+
163
+ App-internal imports use the **`#` path alias** instead of deep `../../../`
164
+ relatives: `import { db } from '#db/connection.server.ts'`, `import { Button }
165
+ from '#components/ui/button.ts'`, `#lib/...`, `#modules/...`. It is Node's native
166
+ `package.json "imports"` field (the scaffold ships the single catch-all `"#*":
167
+ "./*"`, so any top-level folder is aliased with no config change), resolved at
168
+ runtime by Node 24+ AND Bun with **no build step and no tsconfig `paths`**. The
169
+ sigil is `#` (not `@`) and there is **no slash after it** (`#lib/...`, not
170
+ `#/lib/...`): a `#/`-prefixed key does not resolve on Bun. The webjs server
171
+ expands the same map for the import graph / auth gate / elision / browser
172
+ importmap, so a `#`-aliased `.server.ts` still trips the server-only boundary.
173
+ A same-directory import stays relative (`./sibling.ts`); only deep relatives
174
+ become `#`. The alias addresses a top-level SUBDIRECTORY (`#lib/...`,
175
+ `#components/...`); a bare root-level file (`./env.ts`) is imported relatively
176
+ (the browser importmap scope is per-directory, so a `#`-imported root file has
177
+ no browser mapping in dev). Opt out anywhere by writing a plain relative import.
178
+
142
179
  ---
143
180
 
144
181
  ## Public API of `@webjsdev/core`
145
182
 
146
183
  ```js
147
- import { html, css, WebComponent, render } from '@webjsdev/core';
184
+ import { html, css, WebComponent, prop, render } from '@webjsdev/core';
148
185
  import { renderToString } from '@webjsdev/core/server';
149
186
  ```
150
187
 
@@ -153,7 +190,8 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
153
190
  | Export | Purpose |
154
191
  |---|---|
155
192
  | `html` / `css` | Tagged template literals. `css` goes in `static styles`. |
156
- | `WebComponent` | Base class for interactive components. |
193
+ | `WebComponent` | Base class for interactive components. Called as `WebComponent({ ... })` it returns a typed base declaring reactive properties. |
194
+ | `prop(type?, opts?)` | Declares one reactive property inside the `WebComponent({ ... })` factory with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`) and a narrowable TS type (`prop<Student>(Object)`). |
157
195
  | `register(tag, C)` | Tag binding. Auto-called by `Class.register('tag')`. |
158
196
  | `render(v, el)` | Client-side render into a DOM element. |
159
197
  | `renderToString` | Server-side async render to HTML with DSD (from `/server`). |
@@ -179,18 +217,27 @@ lit-html parity: `repeat` (keyed lists), `unsafeHTML(str)` (trusted raw HTML, **
179
217
  ## `WebComponent` essentials
180
218
 
181
219
  ```ts
182
- class MyThing extends WebComponent {
220
+ // Reactive properties are declared in the base-class factory (declare-free)
221
+ class MyThing extends WebComponent({
222
+ count: prop(Number, { reflect: true })
223
+ }) {
183
224
  static shadow = false; // default light DOM; true = scoped shadow DOM
184
225
  static lazy = false; // true = load module on viewport entry
185
- static properties = { count: { type: Number, reflect: true } };
186
- declare count: number; // TS only, typed accessor
187
226
  static styles = css`…`;
227
+
228
+ constructor() {
229
+ super();
230
+ this.count = 0; // Fully typed, no declare!
231
+ }
232
+
188
233
  render() { return html`…`; }
189
234
  }
190
235
  MyThing.register('my-thing');
191
236
  ```
192
237
 
193
- **Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local. `static properties` is reserved for values that ride an HTML attribute, reflect to one, or arrive via `.prop=${value}` SSR hydration. **Typed props in TS use the `declare` pattern** (a `student: Student = {…}` class-field initializer overwrites the reactive accessor after `super()` and breaks reactivity; instead declare the runtime in `static properties`, the type via `declare student: Student`, and set defaults in the constructor, enforced by `reactive-props-use-declare`). Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`.
238
+ **Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local.
239
+
240
+ **Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field initializer (e.g., `count = 0`), which clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`.
194
241
 
195
242
  **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).
196
243
 
@@ -247,7 +294,7 @@ Server actions export named async functions whose args + returns round-trip thro
247
294
 
248
295
  ### RPC + REST endpoint security
249
296
 
250
- Client to action RPC posts `x-webjs-csrf` matching the cookie issued on first SSR (mismatch 403); prod errors are sanitized to `message` only. A `route.ts` REST endpoint (hand-written or via the `route()` adapter) is NOT CSRF-protected: authenticate every mutating endpoint, use `validate`, log without secrets, rate-limit. For CORS use the `cors()` middleware from `@webjsdev/server`; **`credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.** See `agent-docs/advanced.md`.
297
+ Action RPC CSRF is an **Origin / `Sec-Fetch-Site` check** (the Remix 3 / Go 1.25 model), NOT a token cookie: a state-changing verb passes only when `Sec-Fetch-Site` is `same-origin` / `none`, or (older browsers, no `Sec-Fetch-Site`) the `Origin` host matches the request host, or the source is in `webjs.allowedOrigins`; otherwise 403. **No `Set-Cookie` rides the SSR HTML**, which is why a page that opts into a public `Cache-Control` (via `metadata.cacheControl`, e.g. on a root layout for a whole visitor-identical app) is CDN-edge-cacheable. A safe GET action is CSRF-exempt. Prod errors are sanitized to `message` only. A `route.ts` REST endpoint (hand-written or via the `route()` adapter) is NOT covered by this check: authenticate every mutating endpoint, use `validate`, log without secrets, rate-limit. For CORS use the `cors()` middleware from `@webjsdev/server`; **`credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.** See `agent-docs/advanced.md`.
251
298
 
252
299
  ### Components (`components/*.{js,ts}`)
253
300
 
@@ -277,15 +324,15 @@ type ActionResult<T> =
277
324
 
278
325
  ## Styling: Tailwind-first
279
326
 
280
- **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\`\``. See `agent-docs/styling.md`.
327
+ **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 `agent-docs/styling.md`.
281
328
 
282
329
  ---
283
330
 
284
331
  ## Client navigation: automatic, nothing to opt into
285
332
 
286
- 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.
333
+ 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.
287
334
 
288
- The advanced client-router surface is in `agent-docs/advanced.md`: **link prefetch** (on by default, `intent` strategy, per-link `data-prefetch`), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition">`, plus `data-webjs-permanent` to persist a live element), and **stream actions** (`<webjs-stream>` element-level updates, #248). 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).
335
+ The advanced client-router surface is in `agent-docs/advanced.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).
289
336
 
290
337
  ---
291
338
 
@@ -293,11 +340,11 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
293
340
 
294
341
  > Hit one of these as a runtime error? The [Troubleshooting page](https://docs.webjs.com/docs/troubleshooting) is keyed by symptom (the throw-at-load server import, the backtick-in-template 500, the TypeScript strip failure, the SSR browser-global crash, the missing-frame swap) and maps each back to the invariant and the `webjs check` rule below.
295
342
 
296
- 1. **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** The `.server.{js,ts}` extension is the path-level boundary (the file router refuses to serve the source); a `'use server'` directive additionally makes exports RPC-callable, else the file is a server-only utility whose browser import is a throw-at-load stub. Importing a DB driver (`better-sqlite3` / `pg`), `node:*`, or any server-only dep from a component or an `app/**` page / layout / loading / error / not-found file crashes the browser at module load.
343
+ 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.
297
344
  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.
298
345
  3. **Custom element tag names must contain a hyphen** (HTML spec). Pass the tag to `Class.register('tag-name')`, not a static field. Any short-string quote works: `'tag-name'`, `"tag-name"`, or `` `tag-name` `` (single-line, no interpolation).
299
346
  4. **Event (`@`), property (`.`), boolean (`?`) holes in `html` must be unquoted**, e.g. `@click=${fn}`, never `@click="${fn}"`.
300
- 5. **Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core` and read via `signal.get()` inside `render()`; the built-in SignalWatcher tracks the reads and re-renders. Module-scope signals share state across components; instance-scope signals (constructor) are component-local. `static properties` (with a sibling `declare`) is reserved for values riding an HTML attribute, reflected to one, or arriving via `.prop=${value}` SSR hydration. For fine-grained DOM swap use `${watch(signal)}` from `@webjsdev/core/directives`.
347
+ 5. **Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core` and read via `signal.get()` inside `render()`; the built-in SignalWatcher tracks the reads and re-renders. Module-scope signals share state across components; instance-scope signals (constructor) are component-local. The factory `WebComponent({ ... })` is reserved for values riding an HTML attribute, reflected to one, or arriving via `.prop=${value}` SSR hydration. For fine-grained DOM swap use `${watch(signal)}` from `@webjsdev/core/directives`.
301
348
  6. **Page and layout default exports must be functions.** They return a value (usually `TemplateResult`). They do not call `render()` themselves.
302
349
  7. **Light-DOM components with custom CSS MUST prefix every class selector with their tag name.** Tailwind utilities are unique by construction, so prefer them.
303
350
  8. **Non-root layouts and pages MUST NOT** write `<!doctype>` / `<html>` / `<head>` / `<body>`. Only the root layout may.
@@ -310,7 +357,7 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
310
357
 
311
358
  ## Scaffolding
312
359
 
313
- Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite), `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR), `webjs create <name> --template saas` (auth + login/signup + protected dashboard + User model). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). Pick from the request: default for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce), `api` for an HTTP/JSON API with no UI, `saas` for accounts/login/signup; default to full-stack when ambiguous.
360
+ 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 (the dev/start/db scripts run through a generated `webjs-bun.mjs` bootstrap under `bun --bun`, so the SERVER runs on Bun AND Bun auto-install resolves deps on demand at the package.json/`bun.lock` versions (an `onLoad` specifier rewrite pins them so zero-install fetches the declared version, not latest, #685), so a Bun app serves with NO `bun install`, install optional for editor types / offline, #675; `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/check/typecheck tooling stays plain `webjs` on Node and still expects an install); `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.
314
361
 
315
362
  Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **Treat the scaffold as REFERENCE, not the final product:** replace the example page / `User` model / components and adapt `app/layout.ts` (brand, nav, content width; the default `<main class="max-w-[760px]">` reading column needs widening for a full-bleed app). ENFORCED: examples carry a `webjs-scaffold-placeholder` comment and `no-scaffold-placeholder` fails until the content is replaced and the marker deleted. Docs at https://docs.webjs.com.
316
363
 
@@ -324,7 +371,7 @@ webjs start [--port N] # prod server; source IS the runtime, plain H
324
371
  webjs test [--server] [--browser] [--watch]
325
372
  webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop
326
373
  webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check
327
- webjs doctor # project-health checklist; non-zero exit on a hard fail
374
+ webjs doctor # project-health checklist (incl. a page/layout elision advisory, #646); non-zero exit on a hard fail
328
375
  webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258)
329
376
  webjs typecheck [tsc args...] # the project's own tsc --noEmit
330
377
  webjs create <name> [--template api|saas]
@@ -380,7 +380,7 @@ The warning is informational; the request still proceeds. Treat it as a prompt t
380
380
 
381
381
  ## Client router: nested-layout-aware partial swap
382
382
 
383
- `import '@webjsdev/core/client-router'` enables SPA-style navigation that
383
+ The client router auto-enables when `@webjsdev/core` loads in the browser (any page that ships a component), no import needed (#620). It gives SPA-style navigation that
384
384
  preserves outer-layout DOM identity at any depth. Intercepts same-origin
385
385
  `<a>` clicks (incl. inside shadow DOM via `composedPath()`), fetches the
386
386
  target HTML, and replaces only the inside of the deepest shared layout.
@@ -570,20 +570,30 @@ affects a cached page.
570
570
  ### Link prefetch
571
571
 
572
572
  Same-origin in-app links are prefetched speculatively so a click
573
- resolves from a warm cache with no round-trip. On by default with the
574
- `intent` strategy (no per-link opt-in needed), the way Next / Nuxt /
575
- SvelteKit ship auto-prefetch. The prefetch request carries the same
576
- `X-Webjs-Have` header a real navigation sends, so the server returns the
577
- same divergent fragment; that fragment lands in a dedicated prefetch
578
- cache (separate from the back/forward snapshot cache) and `fetchAndApply`
579
- consumes it via `prefetchTake` before falling back to the network.
573
+ resolves from a warm cache with no round-trip. On by default (no per-link
574
+ opt-in needed), the way Next / Nuxt / SvelteKit ship auto-prefetch. The
575
+ prefetch request carries the same `X-Webjs-Have` header a real navigation
576
+ sends, so the server returns the same divergent fragment; that fragment
577
+ lands in a dedicated prefetch cache (separate from the back/forward
578
+ snapshot cache) and `fetchAndApply` consumes it via `prefetchTake` before
579
+ falling back to the network.
580
+
581
+ The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve
582
+ both input modalities. On a hover-capable fine pointer (mouse / trackpad)
583
+ the default is `intent` (warm on hover / focus, a real head-start before
584
+ the click). On touch the default is `viewport` (warm as links settle
585
+ on-screen), because a touch device has no hover and `touchstart` fires at
586
+ tap time, too late to front-run the navigation. The modality is detected
587
+ with `matchMedia('(hover: hover) and (pointer: fine)')`, not a user-agent
588
+ sniff. A per-link `data-prefetch` always overrides the adaptive default.
580
589
 
581
590
  Per link, set `data-prefetch` (a valid-HTML `data-*` attribute, the shape
582
591
  SvelteKit and Astro use; Next / Nuxt / Remix use a component prop, which
583
592
  webjs has no equivalent for since links are plain `<a href>`):
584
593
 
585
594
  ```html
586
- <a href="/dashboard">intent: hover / focus / touch (default)</a>
595
+ <a href="/dashboard">adaptive: intent on pointer, viewport on touch (default)</a>
596
+ <a href="/dashboard" data-prefetch="intent">hover / focus / touch</a>
587
597
  <a href="/dashboard" data-prefetch="render">eager on insert</a>
588
598
  <a href="/dashboard" data-prefetch="viewport">on scroll-into-view</a>
589
599
  <a href="/dashboard" data-prefetch="none">never</a>
@@ -591,9 +601,14 @@ webjs has no equivalent for since links are plain `<a href>`):
591
601
 
592
602
  Next-style aliases are accepted: `true` = `render`, `auto` = `viewport`,
593
603
  `false` = `none`. `intent` waits a short dwell (~100ms) after hover/focus
594
- so a pointer passing over a link does not fetch it; `viewport` uses an
595
- IntersectionObserver at threshold 0.5; `render` and `viewport` are
596
- applied by a document scan on enable and after each navigation.
604
+ so a pointer passing over a link does not fetch it. `viewport` uses an
605
+ IntersectionObserver at threshold 0.5 and waits a ~250ms dwell, cancelled
606
+ the instant the link scrolls back out, so a fast scroll through a long
607
+ list spends no requests (the same gate Astro / Next / Nuxt / Remix /
608
+ TanStack / Turbo apply). On touch, `touchstart` additionally warms the
609
+ tapped link itself (a single request for a link about to be navigated).
610
+ `render` and `viewport` are applied by a document scan on enable and after
611
+ each navigation.
597
612
 
598
613
  Only internal links qualify, using the same eligibility as a click:
599
614
  cross-origin, `download`, `target` other than `_self`, non-HTML
@@ -602,7 +617,9 @@ Opt out with `data-prefetch="none"`, `data-no-prefetch`, or
602
617
  `rel="external"`. Speculation is bounded by a concurrency cap (excess
603
618
  requests queue and drain as slots free, rather than being dropped),
604
619
  in-flight de-dupe, and an LRU + TTL cache, and is disabled entirely under
605
- `Save-Data` or `prefers-reduced-data`. A mutating form submission and
620
+ `Save-Data`, `prefers-reduced-data`, or a 2g `effectiveType` connection.
621
+ The guiding rule: snappy, but never at the cost of bloating the client
622
+ network tab; when the two conflict, the gate under-fetches. A mutating form submission and
606
623
  `revalidate(url)` both evict the prefetch cache alongside the snapshot
607
624
  cache, so a fragment prefetched before a mutation is never served stale.
608
625
 
@@ -851,6 +868,17 @@ revalidation fetch that follows does **not** scroll, so the restored
851
868
  position survives the refresh. Cache miss → browser-native scroll
852
869
  restoration takes over.
853
870
 
871
+ Every programmatic nav scroll (the popstate restore and the
872
+ scroll-to-top on a forward nav) is issued with `behavior: 'instant'`, so
873
+ an app-level `html { scroll-behavior: smooth }` does not animate it. The
874
+ restore matches a native page load (an instant jump), not a visible
875
+ slide. The one nav scroll left smooth is a hash-anchor (`#section`)
876
+ target, where smooth scrolling is the intent. So `scroll-behavior:
877
+ smooth` on `<html>` only affects in-page anchors, not route
878
+ transitions. In development the router logs a one-time console hint when
879
+ it detects that setting (and notes that pairing it with a sticky
880
+ `backdrop-filter` header can flash on iOS during navigation).
881
+
854
882
  Inner scroll containers (e.g. `.docs-sidenav`) are preserved
855
883
  automatically by the outer-layout-DOM-identity invariant. They stay
856
884
  mounted across nav and keep their `scrollTop` natively.
@@ -1094,7 +1122,7 @@ more parts in a `Response` with the stream content type. A page `action` may
1094
1122
  return `streamResponse(...)` directly (it is honored verbatim); on the no-JS
1095
1123
  branch return a normal `ActionResult` instead. `renderStream` is auto-registered
1096
1124
  by the client router, so it (and the `<webjs-stream>` element) is available
1097
- wherever a layout imports `@webjsdev/core/client-router`.
1125
+ on any page that loads `@webjsdev/core` (the router auto-enables there).
1098
1126
 
1099
1127
  ### Streaming RPC results: an action returns a stream (#489)
1100
1128
 
@@ -1158,7 +1186,23 @@ Use `data-no-router` for:
1158
1186
 
1159
1187
  ### Loading indicator
1160
1188
 
1161
- `<html>` gets `data-navigating` during fetch. Style a progress bar off that attribute.
1189
+ **When to use it.** You want a CSS-only progress affordance (a top bar, a `cursor: progress`) while a client navigation is in flight, with no JavaScript. For a JS-driven indicator, skip this and listen for the `webjs:navigate` event (and `webjs:submit-start` for forms) instead, which always fires.
1190
+
1191
+ **How to use it.** It is OPT-IN. Add `data-webjs-nav-progress` to your `<html>` element (once, in the root layout), and the router then sets a `data-navigating` attribute on `<html>` while a nav is in flight (deferred 150ms, so quick sub-150ms navs never trigger it). Style off that attribute:
1192
+
1193
+ ```html
1194
+ <html data-webjs-nav-progress> <!-- opt in once -->
1195
+ ```
1196
+
1197
+ ```css
1198
+ html[data-navigating] { cursor: progress; }
1199
+ html[data-navigating]::after {
1200
+ content: ''; position: fixed; top: 0; left: 0; right: 0; height: 2px;
1201
+ background: var(--accent); animation: progress 1s ease-in-out infinite;
1202
+ }
1203
+ ```
1204
+
1205
+ **Why it is opt-in, not default (#610).** Toggling an attribute on `<html>` re-runs global style resolution, and on WebKit (so every iOS browser) that re-resolves the page's `oklch()` / `color-mix()` design tokens to an equivalent oklab representation and repaints them for one frame. On a token-driven theme that is a visible background flash, worst on a slow mobile forward nav (which exceeds the 150ms defer). So without the opt-in the attribute is never written and the flash cannot happen. Enable it only when your theme does not lean on wide-gamut color tokens, or use the event-based path above.
1162
1206
 
1163
1207
  ## WebSockets
1164
1208
 
@@ -137,10 +137,10 @@ The cache is keyed by the FULL URL (path + search) only, with no per-user
137
137
  keying, so a page that reads `cookies()` / a session / per-user data MUST
138
138
  NOT set `revalidate`. The framework also refuses to cache (defense in
139
139
  depth) any response that is not a `200`, is a streamed Suspense body,
140
- sets a non-framework `Set-Cookie` (the framework `webjs_csrf` cookie is
141
- re-minted per response on a hit and does not block), or runs under CSP
142
- (its body carries a per-request nonce). A cached page served to a brand
143
- new visitor still gets a fresh CSRF cookie, so it stays correct.
140
+ sets any `Set-Cookie`, or runs under CSP (its body carries a per-request
141
+ nonce). SSR responses no longer carry a CSRF cookie (action CSRF is an
142
+ Origin / `Sec-Fetch-Site` check, not a token cookie), so a cacheable page
143
+ is cookieless and safe to share.
144
144
 
145
145
  **Framework defense, not just the contract.** When the render reads
146
146
  per-user state through a framework helper (`cookies()`, `headers()`,
@@ -221,7 +221,7 @@ The ETag is WEAK (`W/"..."`). It hashes the UNCOMPRESSED body and the prod compr
221
221
  | A `route.{js,ts}` handler returning a `ReadableStream` (incl. an SSE `text/event-stream`) | The body is not marked buffered, so the funnel never reads it. Buffering a stream would blow up memory, and an SSE stream never ends so the read would hang forever |
222
222
  | Non-GET / non-HEAD, and any status other than 200 | A validator is only meaningful for a successful, replayable read |
223
223
 
224
- **Stable-body handling.** The ETag is computed over the response's OWN body bytes, so an identical body yields an identical ETag across requests. Per-response varying bits that ride RESPONSE HEADERS (the `x-webjs-build` id, the `set-cookie` CSRF token, the CSP nonce on the header) are NOT part of the body hash, so they do not destabilise the ETag. The one body-level varying input is the CSP nonce stamped INTO the inline boot script: with CSP enabled the HTML body changes every request, so its ETag changes every request and a 304 is simply never produced for that page (correct, not a bug). CSP is off by default, so the common cacheable-page case has a stable body and a stable ETag. The 304 preserves the validators and caching headers (`ETag`, `Cache-Control`, `Vary`, plus the framework's `X-Webjs-Build` / `X-Request-Id` and any `Set-Cookie`) and drops only the body-describing headers (`Content-Length`, `Content-Type`, `Content-Encoding`), so a shared cache and the client router behave identically to a 200.
224
+ **Stable-body handling.** The ETag is computed over the response's OWN body bytes, so an identical body yields an identical ETag across requests. Per-response varying bits that ride RESPONSE HEADERS (the `x-webjs-build` id, the CSP nonce on the header) are NOT part of the body hash, so they do not destabilise the ETag. The one body-level varying input is the CSP nonce stamped INTO the inline boot script: with CSP enabled the HTML body changes every request, so its ETag changes every request and a 304 is simply never produced for that page (correct, not a bug). CSP is off by default, so the common cacheable-page case has a stable body and a stable ETag. The 304 preserves the validators and caching headers (`ETag`, `Cache-Control`, `Vary`, plus the framework's `X-Webjs-Build` / `X-Request-Id` and any `Set-Cookie`) and drops only the body-describing headers (`Content-Length`, `Content-Type`, `Content-Encoding`), so a shared cache and the client router behave identically to a 200.
225
225
 
226
226
  ## Sessions
227
227
 
@@ -4,9 +4,15 @@
4
4
 
5
5
  | Option | Type | Default | Meaning |
6
6
  |---|---|---|---|
7
- | `type` | `Number\|String\|Boolean\|Object\|Array` | `String` | Used by the default attribute converter |
7
+ These options are passed to the `prop()` helper inside the `WebComponent({ ... })` factory (e.g. `count: prop(Number, { reflect: true })`); the bare form `count: Number` is shorthand for `prop(Number)`.
8
+
9
+ | Option | Type | Default | Meaning |
10
+ |---|---|---|---|
11
+ | `type` | `Number\|String\|Boolean\|Object\|Array` | `String` | Used by the default attribute converter (the first `prop()` argument) |
8
12
  | `reflect` | `boolean` | `false` | Property changes write back to the HTML attribute |
9
13
  | `state` | `boolean` | `false` | Internal-only. No attribute, not in `observedAttributes` |
14
+ | `attribute` | `string` | derived from the prop name | The HTML attribute name the property rides |
15
+ | `default` | value or `() => value` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
10
16
  | `hasChanged` | `(newVal, oldVal) => boolean` | strict `!==` | Custom change detection |
11
17
  | `converter` | `{ fromAttribute?, toAttribute? }` | type-based | Custom attribute ↔ property serialization |
12
18
 
@@ -14,18 +20,60 @@ Built-in constructors (`String`, `Number`, `Boolean`, `Array`, `Object`) feed
14
20
  the default attribute coercion. For anything the default can't parse correctly
15
21
  (Date, Map, Set, discriminated unions) supply a custom `converter`.
16
22
 
17
- ## Why `declare` is required in TypeScript
23
+ For an array-typed prop, pass `Array`, not `Object` (`items: prop<Tag[]>(Array)`).
24
+ The default converter handles both identically (each JSON-encodes the value), so
25
+ `Object` does not break anything, but `Array` states the prop's shape to the next
26
+ reader. The `array-prop-uses-array-type` `webjs check` rule flags an array-typed
27
+ generic (`T[]`, `readonly T[]`, `Array<T>`, `ReadonlyArray<T>`) declared with the
28
+ `Object` constructor.
18
29
 
19
- The framework installs reactive getter/setter on `this` inside the
20
- constructor via `Object.defineProperty`. Without `declare`, TypeScript
21
- emits `student = undefined` after `super()`, which under modern class-
22
- field semantics uses `[[Define]]` to overwrite the accessor. Result:
23
- `this.student = …` no longer goes through the setter, no `requestUpdate`,
24
- no `hasChanged`, no reflect, and reactivity silently breaks.
30
+ ## Declaring reactive properties: the base-class factory
25
31
 
26
- The `.d.ts` overlay shipped with the framework makes every other class
27
- member fully typed, so only the reactive properties need the `declare`
28
- line, and only in TypeScript files.
32
+ Reactive properties are declared by passing the property shape into the **base-class factory** `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is no `static properties` block and no `declare` line. A direct `static properties` block throws at runtime (caught statically by the `no-static-properties` rule).
33
+
34
+ ```ts
35
+ class Counter extends WebComponent({
36
+ count: Number
37
+ }) {
38
+ constructor() {
39
+ super();
40
+ this.count = 0; // fully typed, no declare needed
41
+ }
42
+ }
43
+ ```
44
+
45
+ The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`). The `prop()` helper carries options and narrows the TS type:
46
+
47
+ ```ts
48
+ class Dialog extends WebComponent({
49
+ open: prop(Boolean, { reflect: true }), // reflects to the `open` attribute
50
+ showClose: prop(Boolean, { attribute: 'show-close-button' }), // custom attribute name
51
+ variant: prop<'info' | 'danger'>(String, { reflect: true }), // narrowed union type
52
+ student: prop<Student>(Object), // narrowed object type
53
+ internal: prop({ state: true }), // internal state, no attribute, no type
54
+ }) {
55
+ constructor() {
56
+ super();
57
+ this.student = { name: '', email: '' };
58
+ }
59
+ }
60
+ ```
61
+
62
+ Set defaults in the constructor after `super()`. A declarative `default` option also exists (lit-parity, a function default runs per instance for a fresh object / array), but the constructor is the recommended way. **Never** use a class-field initializer (e.g., `count = 0` or `student: Student = { ... }`): the framework installs reactive getter/setter on `this` inside the constructor via `Object.defineProperty`, and a class-field initializer compiles to an assignment after `super()` that uses `[[Define]]` to overwrite the accessor, silently breaking reactivity. The `reactive-props-no-class-field` rule catches this.
63
+
64
+ ```ts
65
+ // set defaults in the constructor after super()
66
+ class Counter extends WebComponent({
67
+ count: prop(Number, { reflect: true }),
68
+ items: prop(Array),
69
+ }) {
70
+ constructor() {
71
+ super();
72
+ this.count = 0;
73
+ this.items = [];
74
+ }
75
+ }
76
+ ```
29
77
 
30
78
  ## Lifecycle hooks (lit-aligned)
31
79
 
@@ -55,9 +103,7 @@ See [`/docs/lifecycle`](https://docs.webjs.com/docs/lifecycle) for per-hook usag
55
103
  A component can fetch its own server data into the first paint. `render()` may be `async`, so you write the natural line directly:
56
104
 
57
105
  ```ts
58
- class UserProfile extends WebComponent {
59
- static properties = { uid: { type: String } };
60
- declare uid: string;
106
+ class UserProfile extends WebComponent({ uid: String }) {
61
107
  async render() {
62
108
  const u = await getUser(this.uid); // a 'use server' action: real fn at SSR, RPC stub on the client
63
109
  return html`<h3>${u.name}</h3>`;
@@ -75,9 +121,7 @@ Writing `await` makes the function async by the JS rule, and every render path a
75
121
  3. **`renderFallback()` is the OPTIONAL re-fetch loading UI.** Define it to OVERRIDE the stale-while-revalidate default with a loading state (skeleton / spinner) shown DURING a client re-fetch. It is shown ONLY on a re-fetch, NEVER on the first paint, and it does NOT create a server-streaming boundary. It is a prop-aware method (not a static field), so it can branch on the component's current state.
76
122
 
77
123
  ```ts
78
- class UserActivity extends WebComponent {
79
- static properties = { uid: { type: String } };
80
- declare uid: string;
124
+ class UserActivity extends WebComponent({ uid: String }) {
81
125
  renderFallback() { return html`<div class="skeleton h-24"></div>`; } // shown only while a re-fetch is in flight
82
126
  async render() {
83
127
  const items = await getActivity(this.uid);
@@ -153,11 +197,11 @@ server/client split to reason about. A component stays elidable as long
153
197
  as it has none of the following.
154
198
 
155
199
  - An `@event` binding in a template (`@click=${...}`), or a native event-handler property (`.onclick=${...}`).
156
- - A reactive property in `static properties` that is not `{ state: true }`. Attribute-driven or `.prop`-driven values are the channel a parent uses to push client updates.
200
+ - A factory-declared reactive property (`WebComponent({ ... })`) that is not `{ state: true }`. Attribute-driven or `.prop`-driven values are the channel a parent uses to push client updates.
157
201
  - An overridden lifecycle hook (anything in the table above), as a method or an arrow class field.
158
202
  - A `signal` / `computed` / `watch` / `Task` / `ref` / `live` / streaming directive imported from `@webjsdev/core`, OR a transitive import of a module that reads shared module-scope signal state.
159
203
  - An `addController(...)` or `requestUpdate()` call.
160
- - Any code that runs at module load. A display-only module's top level may only *declare* things (imports, the `WebComponent` class, `const` / `let` / `var`, pure initializers like `css\`...\``) and *register* the component (`X.register(...)` / `customElements.define(...)`). Any other top-level call, `new`, dynamic `import(...)`, or top-level `await` is client work and ships (a top-level `fetch('/track')`, `new WebSocket(...)`, `setTimeout(...)`, `someInit()`). This is checked structurally as an allowlist of safe top-level forms, not a denylist of global names, so a brand-new browser API is caught automatically with no code change. Code inside a method, `render()`, or an uninvoked function does not count (it does not run at load), nor do these words in rendered template text or a `.fetch` / `.location` member access.
204
+ - Any code that runs at module load. A display-only module's top level may only *declare* things (imports, the `WebComponent` class, `const` / `let` / `var`, pure initializers like `css\`...\``) and *register* the component (`X.register(...)` / `customElements.define(...)`). Any other top-level call, a non-data `new`, dynamic `import(...)`, or top-level `await` is client work and ships (a top-level `fetch('/track')`, `new WebSocket(...)`, `setTimeout(...)`, `someInit()`). A module-scope pure-DATA constructor is the one exception (`const TAGS = new Set([...])`, a compiled `new RegExp(...)`, a parsed `new URL(...)`, a typed array): it produces inert data with no side effect, so it does not force shipping. This is checked structurally as an allowlist of safe top-level forms, not a denylist of global names, so a brand-new browser API is caught automatically with no code change. Code inside a method, `render()`, or an uninvoked function does not count (it does not run at load), nor do these words in rendered template text or a `.fetch` / `.location` member access.
161
205
  - A rendered `<slot>`. Light-DOM slots rely on the client projection runtime, and proving a slot is purely native (shadow DOM) is beyond static analysis, so any `<slot>` ships.
162
206
  - Being rendered or imported by a component that itself ships (an interactive parent can re-create the child on the client).
163
207
 
@@ -205,6 +249,56 @@ which fails if a new `WebComponent` hook is added without teaching the
205
249
  analyser about it. If you add an interactivity feature to the framework,
206
250
  update that file.
207
251
 
252
+ ## Pages and layouts: keep them carriers, out of the network tab
253
+
254
+ The same elision applies to whole routes, and it is what keeps `page.ts`
255
+ and `layout.ts` out of the browser's network tab. A page/layout NEVER
256
+ hydrates: it loads in the browser only to register the components it
257
+ imports (so their tags upgrade). When that registration is its sole
258
+ browser-relevant job, the framework drops the page/layout module and the
259
+ boot script imports those components directly. It is **import-only** (the
260
+ page/layout is dropped but reaches shipping components, #605) or **inert**
261
+ (it reaches none, so zero application JS, #179).
262
+
263
+ A page/layout starts SHIPPING its own module the moment its closure does
264
+ any OTHER client work. Because that is an elision verdict, not a behaviour
265
+ change, it is invisible in `npm test` and easy to introduce by accident
266
+ (a code sample mistaken for real code, #634; a util that touches a client
267
+ global, #619). To keep a route's modules out of the network tab:
268
+
269
+ - Do not give a page/layout its own module-scope client work: no top-level
270
+ call, no `new SomethingNonData()`, no browser-global access
271
+ (`window` / `document` / `customElements`) at module scope, no bare
272
+ side-effect import of a non-component, and no
273
+ `import '@webjsdev/core/client-router'` (routing is automatic, #620). A
274
+ page-template `@event` or inline `<script>` is fine: it is SSR output,
275
+ never module client work (the analyser scans route-module template
276
+ content as inert, #623 / #634).
277
+ - Do not import a client-effecting NON-component util into a page/layout
278
+ (or into a component chain a page reaches). A helper that touches a
279
+ client global or self-executes drags the whole page in. Put client-only
280
+ behaviour inside a component; put server-only work in `.server.{js,ts}`
281
+ (it never enters the client closure); and if a util MIXES a pure helper
282
+ with client-global code (the `cn.ts` shape, #619), split the client part
283
+ into its own module so the pure helper does not pin every importer.
284
+
285
+ Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab
286
+ or the boot `<script type="module">`. If one does, something in its
287
+ closure does client work and is not a component; that is the thing to move
288
+ into a component or a `.server.{js,ts}` file.
289
+
290
+ `webjs doctor` names this for you (#646). Its "Page/layout elision (carrier
291
+ hygiene)" check runs the same elision verdict and, for every page/layout
292
+ that ships whole, prints the FIRST client-effecting blocker by name, for
293
+ example `app/page.ts ships whole. Its first client-effecting blocker is
294
+ lib/track.ts, which references a browser global at module scope, runs code
295
+ at module scope, or has a bare side-effect import and is not a component`.
296
+ It names the first blocker only; if a module has several, it stays shipped
297
+ until each is moved out. It is ADVISORY (a `warn`, never a hard fail): a page
298
+ legitimately MAY ship, and the analyser is biased toward shipping by design,
299
+ so this is a "you may not have intended this" hint. When the module's own
300
+ code is the cause, it prints `ships whole because it <reason>`.
301
+
208
302
  ### Elision is what keeps a server import off a display-only page (and `webjs check` guards the seam)
209
303
 
210
304
  Elision is also why a page can call a server-only utility and stay
@@ -1,6 +1,6 @@
1
1
  # Server configuration (the `package.json` `"webjs"` block) + observability
2
2
 
3
- The full reference for the `package.json` `"webjs"` config block (security headers, CSP, redirects, trailing-slash, basePath, ingress caps) plus the observability surfaces. Moved out of `AGENTS.md` to keep it lean. Env vars live in `agent-docs/built-ins.md`.
3
+ The full reference for the `package.json` `"webjs"` config block (security headers, CSP, redirects, trailing-slash, basePath, allowedOrigins, client-router opt-out, ingress caps) plus the observability surfaces. Moved out of `AGENTS.md` to keep it lean. Env vars live in `agent-docs/built-ins.md`.
4
4
 
5
5
  ---
6
6
 
@@ -123,6 +123,34 @@ An app served under a sub-path (`example.com/app/`) behind a proxy that does NOT
123
123
 
124
124
  Mechanism: `normalizeBasePath` / `readBasePath` / `withBasePath` / `stripBasePath` in `packages/server/src/base-path.js`; the ingress strip is in `dev.js`'s `produce()` (before `applyRedirects`), the importmap-target prefix in `importmap.js` (`setBasePath`), the boot / preload / reload prefix in `ssr.js`.
125
125
 
126
+ ## Cross-origin allowlist: `webjs.allowedOrigins` in package.json (#659)
127
+
128
+ Server-action RPC CSRF is an Origin / `Sec-Fetch-Site` check (the Remix 3 / Go 1.25 model, see `agent-docs/built-ins.md` and the docs-site Security page): a state-changing request passes when `Sec-Fetch-Site` is `same-origin` / `none`, or (older browsers) the `Origin` host equals the request host. A legitimate CROSS-site caller (a reverse proxy serving the app under several hostnames, an admin panel on a sibling domain) is rejected unless its origin is allowlisted:
129
+
130
+ ```json
131
+ { "webjs": { "allowedOrigins": ["admin.example.com", "https://studio.example.com"] } }
132
+ ```
133
+
134
+ - A bare host (`admin.example.com`) or a full origin (`https://studio.example.com`) are both accepted; only the host is compared.
135
+ - The check honors `x-forwarded-host`, so it works behind a CDN / reverse proxy (the Cloudflare-in-front-of-Railway setup) without extra config.
136
+ - Default `[]` (same-origin only). This is the CSRF allowlist ONLY; it does not configure CORS (use the `cors()` middleware for cross-origin `route.ts` reads).
137
+
138
+ Because the check needs nothing on the page (no token cookie), SSR HTML carries no `Set-Cookie`, so a visitor-identical page that opts into a public `Cache-Control` (via `metadata.cacheControl`, e.g. on a root layout) is CDN-edge-cacheable. Mechanism: `verifyOrigin` / `readAllowedOrigins` in `packages/server/src/csrf.js`, threaded through `invokeAction` (`actions.js`) from `dev.js`.
139
+
140
+ ---
141
+
142
+ ## Client-router opt-out: `webjs.clientRouter` in package.json (#629)
143
+
144
+ The client router is automatic: it auto-enables in the browser whenever `@webjsdev/core` loads (any page that ships a component), so SPA-style navigation needs no import (#620). `webjs.clientRouter: false` opts the WHOLE app out, for an app that genuinely wants plain full-page (MPA) navigation despite shipping interactive components.
145
+
146
+ ```jsonc
147
+ { "webjs": { "clientRouter": false } } // pure MPA, full-page navigation
148
+ ```
149
+
150
+ Default `true` (and any value other than the literal `false`), so an existing app is byte-identical. When off, the render path emits a tiny inline `<script>window.__WEBJS_CLIENT_ROUTER__=false</script>` BEFORE the deferred boot module; the core bundle's module-end auto-enable reads that flag and skips, so the components on the page still upgrade and stay interactive while link clicks and form submits fall back to native browser navigation. `disableClientRouter()` (from `@webjsdev/core`) stays the per-page programmatic escape hatch, and `enableClientRouter()` turns it back on.
151
+
152
+ Mechanism: `readClientRouterEnabled` (`packages/server/src/dev.js`) reads the key at boot and on every rebuild and calls `setClientRouterEnabled` (`ssr.js`); `wrapHead` emits the flag; the module-end gate lives in `packages/core/src/router-client.js`.
153
+
126
154
  ---
127
155
 
128
156
  ## Request ingress hardening: body-size limit (413) + server timeouts (on by default)
@@ -77,14 +77,14 @@ lit form unless the vanilla API is genuinely unavoidable.
77
77
 
78
78
  | Vanilla muscle memory | Lit-style webjs form |
79
79
  |---|---|
80
- | `this.getAttribute('x')` / `this.hasAttribute('x')` for own config | a reactive prop: `static properties = { x: {...} }` + `declare x`, read `this.x` (the prop rides the `x` attribute) |
80
+ | `this.getAttribute('x')` / `this.hasAttribute('x')` for own config | a reactive prop declared in the factory: `extends WebComponent({ x: String })`, read `this.x` (the prop rides the `x` attribute) |
81
81
  | `this.setAttribute('x', v)` / `removeAttribute` to reflect own state | a reactive prop with `reflect: true`, or for non-attribute state a `signal` |
82
82
  | `state: true` reactive prop for internal state | a `signal` (instance signal in the constructor, or module-scope) |
83
83
  | `this.classList.add/toggle(...)` on self | a `class=${...}` binding in `render()` |
84
84
  | `this.innerHTML = ...` / `appendChild` / `document.createElement` | return the markup from `render()` as `` html`...` `` |
85
85
  | `this.addEventListener('click', ...)` on own/child elements | a `@click=${...}` binding in the template |
86
86
  | `this.querySelector(...)` to reach own rendered DOM | the `ref()` directive + `createRef()`, or read a `<form>` with `new FormData(form)` |
87
- | manual `observedAttributes` + `attributeChangedCallback` | `static properties` (the framework derives both) |
87
+ | manual `observedAttributes` + `attributeChangedCallback` | a factory-declared reactive prop `WebComponent({ ... })` (the framework derives both) |
88
88
  | manual `customElements.define('x', C)` | `C.register('x')` |
89
89
 
90
90
  Emitting an event with `this.dispatchEvent(new CustomEvent(...))` is the
@@ -244,8 +244,8 @@ the event methods (`addEventListener` / `removeEventListener` /
244
244
  `dispatchEvent`), and `this.attachInternals()` ARE backed by a server shim,
245
245
  so reading an attribute in `render()`, wiring a delegated listener in the
246
246
  constructor, or reflecting a property during the SSR update cycle all work.
247
- Reading attributes that drive render through a reactive property
248
- (`static properties` + `declare`) is still the idiomatic path, but
247
+ Reading attributes that drive render through a factory-declared reactive
248
+ property (`WebComponent({ ... })`) is still the idiomatic path, but
249
249
  `this.hasAttribute(...)` no longer crashes.
250
250
 
251
251
  Two guards catch the browser-only cases. `webjs check` flags browser
@@ -278,20 +278,15 @@ connectedCallback() {
278
278
 
279
279
  ### 5. Class-field initializers for reactive properties
280
280
 
281
- This looks fine in TypeScript. It silently breaks the framework's
282
- accessor.
281
+ In Lit, class-field initializers (like `student: Student = { name: '', email: '' }`) are commonly used. In webjs, the base class installs reactive accessors on `this` inside the constructor via `Object.defineProperty` (to support SSR property hydration and signals). Under modern V8 class-field semantics, a class-field initializer compiles to an assignment after `super()`, which uses `[[Define]]` and overwrites the accessor, silently breaking reactivity. The footgun is still live with factory-declared props, so it stays a gotcha.
283
282
 
284
- ```ts
285
- // wrong (the initializer overwrites the framework accessor after super())
286
- class StudentCard extends WebComponent {
287
- static properties = { student: { type: Object } };
288
- student: Student = { name: '', email: '' };
289
- }
283
+ The fix is to declare the prop in the base-class factory `WebComponent({ ... })` (which types it automatically, no `declare` line) and set its default in the constructor after `super()`, never as a class-field initializer:
290
284
 
291
- // right
292
- class StudentCard extends WebComponent {
293
- static properties = { student: { type: Object } };
294
- declare student: Student;
285
+ ```ts
286
+ // set the default in the constructor (fully typed, no declare needed)
287
+ class StudentCard extends WebComponent({
288
+ student: prop<Student>(Object),
289
+ }) {
295
290
  constructor() {
296
291
  super();
297
292
  this.student = { name: '', email: '' };
@@ -299,17 +294,11 @@ class StudentCard extends WebComponent {
299
294
  }
300
295
  ```
301
296
 
302
- `webjs check` flags this via the `reactive-props-use-declare` rule, but
303
- AI agents emit the broken form on autopilot. The convention check is
304
- the safety net, not the primary defense. Authoring code should use
305
- `declare` plus constructor defaults from the start.
297
+ `webjs check` flags a class-field initializer on a factory-declared prop via the `reactive-props-no-class-field` rule.
306
298
 
307
- ### 6. The `@property()` decorator
299
+ ### 6. The `@property()` decorator and a direct `static properties` block
308
300
 
309
- Banned by framework invariant 10 (erasable TS). The replacement is
310
- `static properties = { ... }` plus a matching `declare` for the typed
311
- accessor, as shown above. Decorators are non-erasable, so they would
312
- force the framework to depend on a build step.
301
+ The `@property()` decorator is banned by framework invariant 10 (erasable TS): decorators are non-erasable, so they would force the framework to depend on a build step. A direct `static properties = { ... }` block is also gone, and webjs THROWS at runtime if a class body declares one (flagged by the `no-static-properties` rule). The single replacement for both is the declare-free base-class factory `WebComponent({ ... })` (the `prop()` helper carries options), as shown above.
313
302
 
314
303
  ## Patterns that produce different visual output
315
304
 
@@ -463,8 +452,8 @@ must move with it, use `repeat()` with a stable key.
463
452
  | `Task` for client-time async | `Task` (no change, that's its job) |
464
453
  | `window.X` or `document.X` in constructor or `render()` | Move to `connectedCallback` |
465
454
  | Top-level `import` of browser-only library | Dynamic `import()` inside `connectedCallback` |
466
- | `student: Student = { ... }` field initializer | `declare student: Student` plus constructor default |
467
- | `@property()` decorator | `static properties = { ... }` plus `declare` |
455
+ | `student: Student = { ... }` field initializer | Base-class factory `WebComponent({ student: Object })`, default in the constructor after `super()` |
456
+ | `@property()` decorator / `static properties` block | Base-class factory `WebComponent({ ... })` (the `prop()` helper carries options) |
468
457
  | `static styles = css` / inline `<style>` with semantic class names in a light-DOM component | Tailwind utilities (the default); or `static shadow = true` for genuinely scoped CSS |
469
458
  | Plain `.map()` for an interactive/stateful list | Works (reconciles in place, keeps node identity); use `repeat(items, key, t)` only when the list **reorders** |
470
459
  | `willUpdate` for SSR-visible derived state | Works (runs at SSR); keep it a pure derivation |
@@ -200,6 +200,53 @@ option: `export const POST = route(createPost, { validate })`. A
200
200
  validator that THROWS (the classic `Schema.parse` style) becomes a 400, and a
201
201
  non-envelope return transforms the input.
202
202
 
203
+ ## HTTP verbs, caching, streaming, and cancellation (#488, #489, #490, #492)
204
+
205
+ A `'use server'` action is a POST by default. Reserved sibling exports, read
206
+ statically (the same way a page reads `export const revalidate`), change its
207
+ HTTP semantics WITHOUT changing the call site (you still write `await
208
+ getUser(7)`). One callable function per configured file (a second callable is a
209
+ `webjs check` error).
210
+
211
+ ```ts
212
+ // modules/users/queries/get-user.server.ts: a cached, tagged GET read
213
+ 'use server';
214
+ export const method = 'GET'; // absent = POST
215
+ export const cache = 60; // seconds, or { maxAge, swr, public }
216
+ export const tags = (id: number) => ['user:' + id];
217
+ export async function getUser(id: number) { return db.query.users.findFirst({ where: { id } }); }
218
+ ```
219
+
220
+ ```ts
221
+ // a mutation evicts the tags it touches
222
+ 'use server';
223
+ export const invalidates = (id: number) => ['user:' + id];
224
+ export const middleware = [requireAuth]; // #490: async (ctx, next) => result; actionContext()
225
+ export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
226
+ ```
227
+
228
+ - A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt,
229
+ and carries `Cache-Control` + a weak `ETag` (304 on `If-None-Match`) +
230
+ `X-Webjs-Tags`. A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body
231
+ (DELETE rides the URL), is CSRF-protected, and on success evicts its
232
+ `invalidates` tags and reports them via `X-Webjs-Invalidate`. A method mismatch
233
+ is a `405` + `Allow`.
234
+ - **SAFETY:** `cache` with `public: true` SHARES one response across all users,
235
+ keyed only by URL + args. Use it ONLY for data identical for every visitor (the
236
+ same rule as a page's `export const revalidate`).
237
+ - **Streaming (#489):** return a `ReadableStream` / async iterable / async
238
+ generator (any verb) and the call site does `for await (const c of await
239
+ streamTokens(8))`. Each chunk is rich-serialized as yielded, back-pressured,
240
+ and the source is cancelled on disconnect. A streamed result is never cached /
241
+ ETagged / seeded.
242
+ - **Cancellation (#492):** read the request `AbortSignal` via `actionSignal()`
243
+ (from `@webjsdev/server`) to stop work on disconnect; a superseded client
244
+ `async render()` auto-aborts the previous in-flight fetch.
245
+ - **SSR seeding (#472):** an action result invoked during a non-streamed SSR
246
+ render is serialized into the page; the stub reads that seed on its first client
247
+ call, so a shipping component does not re-fetch on hydration (consume-once,
248
+ fail-open; opt out with `"webjs": { "seed": false }`).
249
+
203
250
  ## Add a component
204
251
 
205
252
  ```ts
@@ -217,9 +264,7 @@ A leaf component fetches its own server data into the first paint, co-located, w
217
264
 
218
265
  ```ts
219
266
  // (a) blocking async render: real data in the first paint, the common case
220
- class UserProfile extends WebComponent {
221
- static properties = { uid: { type: String } };
222
- declare uid: string;
267
+ class UserProfile extends WebComponent({ uid: String }) {
223
268
  async render() {
224
269
  const u = await getUser(this.uid);
225
270
  return html`<h3>${u.name}</h3>`;
@@ -235,9 +280,7 @@ html`
235
280
  `;
236
281
 
237
282
  // (c) renderFallback() = the CLIENT re-fetch loading state (never the first paint)
238
- class UserActivity extends WebComponent {
239
- static properties = { uid: { type: String } };
240
- declare uid: string;
283
+ class UserActivity extends WebComponent({ uid: String }) {
241
284
  renderFallback() { return html`<div class="skeleton h-24"></div>`; }
242
285
  async render() {
243
286
  const items = await getActivity(this.uid);
@@ -0,0 +1,91 @@
1
+ # Runtime: Node and Bun (and future Deno)
2
+
3
+ webjs runs on **Node 24+** or **Bun**. The app source is identical on either; only
4
+ the listener shell, the type stripper, and a few built-ins differ. The selection
5
+ is a runtime-neutral seam in `startServer` (it picks the `node:http` shell on
6
+ Node and the `Bun.serve` shell on Bun), designed to also host a future
7
+ `Deno.serve` / embedded adapter. Deno is planned, not yet supported. The
8
+ user-facing reference is the docs-site page `/docs/runtime`; keep the two in sync.
9
+
10
+ ## Node vs Bun
11
+
12
+ | Area | Node 24+ | Bun |
13
+ |---|---|---|
14
+ | Install | `npm install` (required) | optional (zero-install via Bun auto-install) |
15
+ | Run | `npm run dev` / `npm run start` | `bun run dev` / `bun run start` |
16
+ | Listener | `node:http` shell | native `Bun.serve` (about 1.9x req/s on the listening path) |
17
+ | TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
18
+ | SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
19
+ | Hot reload | `node --watch` | `bun --hot` |
20
+ | WebSocket | the `ws` library | native `Bun.serve` + the `BunWsAdapter` bridge |
21
+ | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
22
+
23
+ The bytes the browser fetches are identical across runtimes. The 103 Early Hints
24
+ gap costs only a small first-load latency edge where an edge forwards 103, never
25
+ correctness (the modulepreload hints still ship in the document head).
26
+
27
+ ## Scaffolding the runtime
28
+
29
+ `webjs create <name>` defaults to Node. `webjs create <name> --runtime bun` (or
30
+ `bun create webjs <name>`, auto-detected from the invoking package manager,
31
+ #541) emits a Bun-flavored app: `bun.lock`, a pure `oven/bun:1` Dockerfile +
32
+ bun-install CI, bun-command agent docs, and the zero-install bootstrap below.
33
+ `--runtime` is orthogonal to `--template`.
34
+
35
+ ## Bun zero-install (#675)
36
+
37
+ A Bun app's `dev` / `start` / `db` scripts run through a generated app-local
38
+ `webjs-bun.mjs` bootstrap under `bun --bun`:
39
+
40
+ ```js
41
+ // webjs-bun.mjs
42
+ await import('@webjsdev/cli/bin/webjs.js');
43
+ ```
44
+
45
+ `bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun;
46
+ importing the CLI by bare specifier lets Bun auto-install resolve `@webjsdev/*`
47
+ and the app's deps on demand, so a fresh app serves with **no `bun install`**.
48
+ The CLI's `start` is in-process and `dev` re-execs via `process.execPath` (which
49
+ is `bun` here), so the server stays on Bun once the CLI does. `bunx
50
+ @webjsdev/cli` is deliberately NOT used (it runs on Node via the shebang AND
51
+ eager-installs the whole tree). The `start.before` migrate also routes through
52
+ the bootstrap, so the boot-time `webjs db migrate` needs no `webjs` bin in
53
+ `node_modules`. `bun install` stays optional: run it for editor type
54
+ intelligence (no `node_modules` means no local type files) or a pinned offline
55
+ install. The Node-targeted tooling scripts (`test` / `check` / `typecheck`)
56
+ stay plain `webjs` on Node and still expect an install.
57
+
58
+ **Version pinning (#685).** Bun's auto-install fetches `latest` for a bare
59
+ `import 'zod'`, ignoring package.json and `bun.lock` (it consults neither the
60
+ manifest, the lockfile, nor an `onResolve` plugin, verified in #684). webjs
61
+ closes that gap with an `onLoad` transform: it rewrites a declared dep's bare
62
+ specifier to an inline-versioned one (`zod` to `zod@3.22.4`), which Bun's
63
+ auto-install DOES honor. The version is the EXACT one from `bun.lock` when
64
+ present, else an exact `package.json` pin. Bun resolves an exact inline version
65
+ but ENOENTs on a range or dist-tag (`zod@^3`, `zod@latest`), so a dep declared
66
+ as a RANGE without a `bun.lock` is left bare and resolves to latest as before:
67
+ commit a `bun.lock` (or pin exact versions) to get pinned zero-install. The
68
+ rewrite is server-runtime only (it shapes what Bun fetches for SSR and server
69
+ actions). The browser is still served bare specifiers resolved via the importmap
70
+ / jspm, unchanged. Only declared deps are rewritten (a transitive dep follows
71
+ from its pinned parent's manifest), and it is a no-op when `node_modules` exists
72
+ (Bun uses the installed copy). Default on. Opt out with `WEBJS_PIN=0` or
73
+ `{ "webjs": { "pin": false } }`.
74
+
75
+ **Reproducibility:** dev resolves on demand (now at the pinned versions), and the
76
+ scaffold's Bun Dockerfile still keeps an explicit `bun install` so a prod image
77
+ is immutable and self-contained with no registry fetch at boot.
78
+
79
+ ## SQLite busy_timeout (#674)
80
+
81
+ Both `node:sqlite` and `bun:sqlite` default `busy_timeout` to 0, so a contended
82
+ write throws `database is locked` immediately (better-sqlite3, used before the
83
+ built-in drivers, defaulted to 5000ms). The generated connection sets `PRAGMA
84
+ busy_timeout = 5000` + `PRAGMA journal_mode = WAL` on the raw client before
85
+ drizzle wraps it, on both runtime branches.
86
+
87
+ ## Future runtimes
88
+
89
+ The listener seam is runtime-neutral, so a `Deno.serve` shell (or an embedded
90
+ adapter) slots in at the same point when added. Edge runtimes with no filesystem
91
+ are a separate, later target. Until then, treat Deno as planned, not supported.
@@ -234,3 +234,46 @@ More files you write, more discipline required, slight rename cost (2
234
234
  textual edits when a route folder moves). In exchange: no browser-
235
235
  runtime script, no `@theme` block, idiomatic CSS, plain cascade you can
236
236
  debug with any tool.
237
+
238
+ ## Gotcha: a sticky header flickers on iOS, use `position: fixed`
239
+
240
+ A `position: sticky` header (the common `sticky top-0` pattern) flickers its
241
+ background for one frame on iOS WebKit (every iOS browser, since they all use
242
+ WebKit) during a client-router FORWARD navigation. The scroll-to-top the router
243
+ runs after the content swap drives a sticky stuck-to-static recompute that
244
+ WebKit mis-repaints. It is iOS-only (fine on desktop and Android, and invisible
245
+ in DevTools device emulation, so it shows only on a real device). #610 confirmed
246
+ on-device that neither compositor promotion (`translateZ(0)` / `will-change`)
247
+ nor changing the swap paint timing fixes it.
248
+
249
+ The fix is `position: fixed` instead of sticky. A fixed header is always pinned
250
+ and never does the scroll-relative recompute, so the repaint bug never fires.
251
+ There is no reliable CSS-only fix that KEEPS `position: sticky` (the GPU-promotion
252
+ hacks `translateZ(0)` / `translate3d` / `will-change` / a wrapper element are all
253
+ variants that do NOT reliably work, confirmed on-device for #610). Preserving the
254
+ header across nav is correct and standard (Next.js, Remix, SvelteKit all preserve
255
+ nested layouts); only the `sticky` positioning is the problem.
256
+
257
+ Because fixed leaves normal flow, reserve the header height on the content below.
258
+ Use a single `--header-height` custom property as the source of truth rather than
259
+ a hardcoded number, and keep it exact with a `ResizeObserver` so it tracks
260
+ responsive / dynamic headers (degrades fine with no JS via the default):
261
+
262
+ ```css
263
+ :root { --header-h: 56px; } /* sane SSR first-paint default */
264
+ header { position: fixed; inset-inline: 0; top: 0; }
265
+ body { padding-top: var(--header-h); }
266
+ ```
267
+ ```js
268
+ // refine the default to the header's real height (and on resize)
269
+ const hdr = document.querySelector('header');
270
+ const apply = () => document.documentElement.style.setProperty('--header-h', hdr.offsetHeight + 'px');
271
+ apply();
272
+ new ResizeObserver(apply).observe(hdr);
273
+ ```
274
+
275
+ The example blog's root layout does exactly this (`examples/blog/app/layout.ts`).
276
+ For an app/dashboard layout, an alternative is an app-shell scroll container (a
277
+ non-scrolling `100dvh` flex column with `<main>` as the internal scroller), which
278
+ needs no offset at all but changes the scroll model (the window no longer
279
+ scrolls, so the iOS URL bar stays visible).
@@ -116,6 +116,17 @@ runtime-sensitive suite under Bun to catch the long tail of cross-runtime
116
116
  incompatibilities (a `node:*` API Bun implements differently, a crypto/stream
117
117
  edge case, an error-message-format quirk).
118
118
 
119
+ **Bun parity is part of the definition of done, not an afterthought.** A change
120
+ to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve`
121
+ listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`,
122
+ the TS stripper, auth / session / cors) is NOT done until you have run the Bun
123
+ matrix green AND added or updated a `test/bun/<feature>.mjs` cross-runtime
124
+ assertion for the surface. This is enforced at commit time by
125
+ `.claude/hooks/require-bun-parity-with-runtime-src.sh`, which BLOCKS a commit
126
+ that stages runtime-sensitive `packages/*/src` with no `test/bun/**` test (the
127
+ escape hatch `WEBJS_BUN_VERIFIED=1` is only for when an existing `test/bun`
128
+ script already covers the surface AND you ran it under Bun).
129
+
119
130
  - `node scripts/run-bun-tests.js` (needs `bun` on PATH; `BUN=…` overrides) runs
120
131
  the `node:test` files under `test/`, `packages/core/test/`, and
121
132
  `packages/server/test/` (excluding `browser/`, `e2e/`, the network-bound
@@ -127,8 +138,10 @@ edge case, an error-message-format quirk).
127
138
  **skip(env)** (needs Redis / a DOM), and **genuine fail** (a real Bun
128
139
  failure, which fails the job). Set `WEBJS_BUN_TESTS=<substr,…>` to scope a
129
140
  local run.
130
- - CI runs it in the `bun` job alongside `test/bun/smoke.mjs` (#508) and
131
- `test/bun/listener.mjs` (#511, the listener-shell parity).
141
+ - CI runs it in the `bun` job alongside `test/bun/smoke.mjs` (#508),
142
+ `test/bun/listener.mjs` (#511, the listener-shell parity), and
143
+ `test/bun/timeouts.mjs` (#663, the `Bun.serve` `idleTimeout` wiring; the pure
144
+ ms-to-seconds mapping is unit-tested under the matrix too).
132
145
  - Two cross-runtime test scripts also run under BOTH runtimes: `test/bun/smoke.mjs`
133
146
  (boot + SSR + TS strip + a server-action RPC) and `test/bun/listener.mjs`
134
147
  (`startServer` over a real socket: SSR + route + SSE + WebSocket). Plain assert
@@ -249,7 +262,7 @@ test exercises the production contract, never a parallel fake).
249
262
 
250
263
  ```js
251
264
  import { createRequestHandler } from '@webjsdev/server';
252
- import { testRequest, getCsrf, invokeActionForTest, loginAndGetCookies, withSessionCookie }
265
+ import { testRequest, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
253
266
  from '@webjsdev/server/testing';
254
267
 
255
268
  const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
@@ -267,12 +280,13 @@ A bare path (`/about`) is prefixed with a dummy origin (the pipeline only reads
267
280
  `pathname` + `search`); a full URL string or a pre-built `Request` works too.
268
281
  The optional third arg is a standard `RequestInit` (method, headers, body).
269
282
 
270
- ### getCsrf + the auth/session helpers
283
+ ### The auth/session helpers
271
284
 
272
- The action RPC endpoint requires a `x-webjs-csrf` header matching the
273
- `webjs_csrf` cookie issued on the first SSR response. `getCsrf(handle)` does the
274
- initial GET and returns `{ token, cookie, header }` so a test can send a
275
- CSRF-valid request. `loginAndGetCookies(handle, { email, password })` drives the
285
+ Server-action CSRF is an Origin / `Sec-Fetch-Site` check, so a test needs no
286
+ CSRF setup: `invokeActionForTest` models a same-origin browser POST and passes
287
+ the check automatically, and `rawActionRequest(app, file, fn, args, {
288
+ crossOrigin: true })` models a cross-site request to assert the 403.
289
+ `loginAndGetCookies(handle, { email, password })` drives the
276
290
  REAL credentials login through `handle()` (the `createAuth` route handler) and
277
291
  captures the genuine signed session `Set-Cookie`, so a follow-up request can hit
278
292
  a protected route as the logged-in user:
@@ -302,8 +316,9 @@ const out = await invokeActionForTest(app, 'modules/posts/actions/create.server.
302
316
 
303
317
  `invokeActionForTest` serializes `args` with the webjs serializer (exactly as
304
318
  the generated client stub does), POSTs them to the REAL
305
- `/__webjs/action/<hash>/<fn>` endpoint with a valid CSRF cookie + header, and
306
- parses the response with the serializer. The action is addressed by the SHA-256
319
+ `/__webjs/action/<hash>/<fn>` endpoint as a same-origin request (so it passes
320
+ the Origin / Sec-Fetch-Site CSRF check), and parses the response with the
321
+ serializer. The action is addressed by the SHA-256
307
322
  hash of its `.server.{js,ts}` file path (absolute or appDir-relative) plus the
308
323
  function name, the same scheme the stub uses (`actionEndpoint(appDir, file, fn)`
309
324
  returns that path if you need it directly).
@@ -352,8 +367,8 @@ test/
352
367
 
353
368
  App-side runners:
354
369
 
355
- - `webjs test` → node tests (everything not under `browser/` or `e2e/`).
356
- - `webjs test --browser` → web-test-runner against `test/**/browser/**`.
370
+ - `webjs test` → the runtime's test runner over everything not under `browser/` or `e2e/` (#570): `node --test` on Node, `bun test` on Bun (dispatched on `process.versions.bun`, since `bun --test` is invalid).
371
+ - `webjs test --browser` → web-test-runner against `test/**/browser/**` (the app's resolved `wtr` bin, spawned with the current runtime, no `npx`).
357
372
  - `WEBJS_E2E=1 webjs test` adds e2e.
358
373
 
359
374
  App AI agents read this convention through the scaffold's
@@ -386,7 +386,7 @@ A single plugin with its OWN in-template intelligence (no Lit dependency; the `w
386
386
  Gives you, inside `` html`` `` templates:
387
387
  - **Go-to-definition** from `<my-counter>` to the class registered via `MyCounter.register('my-counter')`, from an attribute / property / event name to its class member, and from a CSS class in `class="…"` to its `` css`` `` rule.
388
388
  - **Completions**: reachable custom-element tag names after `<`, and binding-aware attribute completions: `.` offers property names, plain / `?` offer hyphenated attribute names (`maxLength` → `max-length`), `@event` is permissive.
389
- - **Diagnostics**: attribute / property value type-checks (`<my-counter .count=${expr}>` assignability-checks `typeof expr` against `declare count: T`; `@click=${fn}` must be callable), unquoted `@`/`.`/`?` bindings (invariant 4), and expressionless `.prop` bindings.
389
+ - **Diagnostics**: attribute / property value type-checks (`<my-counter .count=${expr}>` assignability-checks `typeof expr` against the property's type from the `WebComponent({ count: Number })` factory shape, and `@click=${fn}` must be callable), unquoted `@`/`.`/`?` bindings (invariant 4), and expressionless `.prop` bindings.
390
390
  - **Hover**: a tag shows its component class; an attribute / property / event shows its declared type.
391
391
 
392
392
  Every feature is gated on import-graph reachability: a tag is recognized only if the file registering it is reachable from the file you're editing. There is deliberately no blanket "unknown tag / attribute" diagnostic (webjs has no element type map, so it would false-positive on third-party custom elements).
package/src/mcp-docs.js CHANGED
@@ -365,16 +365,14 @@ const PROMPT_BODIES = {
365
365
  'Tag MUST contain a hyphen (invariant 3). Event/property/boolean holes are',
366
366
  'unquoted (invariant 4). Read `webjs-docs://lit-muscle-memory-gotchas` first:',
367
367
  'a class-field initializer that overwrites a reactive accessor breaks reactivity',
368
- '(use `declare` + `static properties`).',
368
+ '(declare reactive props via the `extends WebComponent({ … })` factory).',
369
369
  ].join('\n'),
370
370
  fetch_data_in_component: [
371
371
  'Fetch server data IN the component that needs it, with an async render() (#469).',
372
372
  'No prop-drilling: SSR awaits the render, so the DATA is in the first paint.',
373
373
  '',
374
374
  '```ts',
375
- 'class UserProfile extends WebComponent {',
376
- ' static properties = { uid: { type: String } };',
377
- ' declare uid: string;',
375
+ 'class UserProfile extends WebComponent({ uid: String }) {',
378
376
  ' async render() {',
379
377
  ' const u = await getUser(this.uid); // a `use server` action: real fn at SSR, RPC stub on the client',
380
378
  ' return html`<h3>${u.name}</h3>`;',
package/src/mcp-source.js CHANGED
@@ -48,8 +48,15 @@ const MAX_FILES = 4000;
48
48
  * reliable. The source dir is `src/`, or `lib/` for the cli. A package that is
49
49
  * not installed is skipped (not every app depends on every `@webjsdev/*`).
50
50
  *
51
+ * Under Bun ZERO-INSTALL (no `node_modules`, #675) the packages live only in
52
+ * Bun's global install cache (`<bunCacheDir>/@webjsdev/<pkg>@<version>@@@<n>/`,
53
+ * which holds the full source). When the `node_modules` walk finds nothing and a
54
+ * `bunCacheDir` + `readdir` are supplied, fall back to scanning that scope dir
55
+ * for the package's versioned entry (highest version wins, best-effort), so the
56
+ * tool works whether the package is installed OR resolved from the Bun cache.
57
+ *
51
58
  * @param {string} cwd
52
- * @param {{ exists: (p: string) => boolean }} fsDeps
59
+ * @param {{ exists: (p: string) => boolean, readdir?: (d: string) => Array<{ name: string, isDir: boolean }>, bunCacheDir?: string | null }} fsDeps
53
60
  * @returns {Array<{ pkg: string, root: string, src: string }>}
54
61
  */
55
62
  export function resolveFrameworkRoots(cwd, fsDeps) {
@@ -69,6 +76,8 @@ export function resolveFrameworkRoots(cwd, fsDeps) {
69
76
  const cand = join(base, '@webjsdev', pkg);
70
77
  if (fsDeps.exists(join(cand, 'package.json'))) { root = cand; break; }
71
78
  }
79
+ // Zero-install fallback: no node_modules, so look in Bun's global cache.
80
+ if (!root) root = resolveFromBunCache(pkg, fsDeps);
72
81
  if (!root) continue;
73
82
  // Most packages keep source in `src/`; the cli keeps it in `lib/`. Use
74
83
  // whichever exists so every framework package's source is reachable.
@@ -82,6 +91,69 @@ export function resolveFrameworkRoots(cwd, fsDeps) {
82
91
  return out;
83
92
  }
84
93
 
94
+ /**
95
+ * Locate `@webjsdev/<pkg>` in Bun's global install cache (the zero-install
96
+ * fallback). Bun caches a scoped package at `<bunCacheDir>/@webjsdev/<pkg>@<ver>@@@<n>/`
97
+ * (the versioned dir holds the full source; a sibling unversioned `<pkg>/` dir is
98
+ * metadata, skipped). Picks the highest cached version (semver-aware, so
99
+ * `0.10.0` beats `0.9.0`), and returns its path only if it carries a
100
+ * `package.json`. Returns '' when there is no cache dir, no `readdir`, or no
101
+ * matching entry.
102
+ *
103
+ * @param {string} pkg
104
+ * @param {{ exists: (p: string) => boolean, readdir?: (d: string) => Array<{ name: string, isDir: boolean }>, bunCacheDir?: string | null }} fsDeps
105
+ * @returns {string}
106
+ */
107
+ function resolveFromBunCache(pkg, fsDeps) {
108
+ const { bunCacheDir, readdir, exists } = fsDeps;
109
+ if (!bunCacheDir || typeof readdir !== 'function') return '';
110
+ const scopeDir = join(bunCacheDir, '@webjsdev');
111
+ if (!exists(scopeDir)) return '';
112
+ let entries;
113
+ try { entries = readdir(scopeDir); } catch { return ''; }
114
+ const prefix = `${pkg}@`;
115
+ // A cache dir is `<pkg>@<version>@@@<n>`; extract <version> (between the name
116
+ // and the `@@@` cache-key suffix) and sort by it, highest first.
117
+ const versioned = entries
118
+ .filter((e) => e.isDir && e.name.startsWith(prefix) && e.name.includes('@@@'))
119
+ .map((e) => ({ name: e.name, version: e.name.slice(prefix.length).split('@@@')[0] }))
120
+ .sort((a, b) => compareVersions(b.version, a.version));
121
+ for (const { name } of versioned) {
122
+ const cand = join(scopeDir, name);
123
+ if (exists(join(cand, 'package.json'))) return cand;
124
+ }
125
+ return '';
126
+ }
127
+
128
+ /**
129
+ * Compare two semver-ish version strings numerically (so `0.10.0` > `0.9.0`),
130
+ * with a release ranking ABOVE its prerelease (`1.0.0` > `1.0.0-rc.1`). Compares
131
+ * the release core (`major.minor.patch`) segment by segment numerically; on a
132
+ * tie, a version WITHOUT a prerelease tag sorts higher, else the prerelease tags
133
+ * compare lexically. The build suffix (`+...`) is ignored, per semver. Returns
134
+ * negative / 0 / positive like a comparator.
135
+ * @param {string} a
136
+ * @param {string} b
137
+ * @returns {number}
138
+ */
139
+ function compareVersions(a, b) {
140
+ const parse = (v) => {
141
+ const [core, pre = ''] = v.split('+')[0].split('-');
142
+ return { nums: core.split('.').map(Number), pre };
143
+ };
144
+ const A = parse(a);
145
+ const B = parse(b);
146
+ for (let i = 0; i < Math.max(A.nums.length, B.nums.length); i++) {
147
+ const x = A.nums[i] || 0;
148
+ const y = B.nums[i] || 0;
149
+ if (x !== y) return x - y;
150
+ }
151
+ if (A.pre === B.pre) return 0;
152
+ if (!A.pre) return 1; // a release outranks any prerelease of the same core
153
+ if (!B.pre) return -1;
154
+ return A.pre < B.pre ? -1 : 1;
155
+ }
156
+
85
157
  /**
86
158
  * Recursively list text-source files under `dir` (absolute paths), skipping
87
159
  * `node_modules` / `dist` and bounded by {@link MAX_FILES}.
package/src/mcp.js CHANGED
@@ -21,7 +21,8 @@
21
21
  */
22
22
 
23
23
  import { createInterface } from 'node:readline';
24
- import { relative } from 'node:path';
24
+ import { relative, join } from 'node:path';
25
+ import { spawnSync } from 'node:child_process';
25
26
 
26
27
  import {
27
28
  resolveDocsLocation,
@@ -36,6 +37,29 @@ import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js';
36
37
 
37
38
  const PROTOCOL_VERSION = '2024-11-05';
38
39
 
40
+ /**
41
+ * Best-effort path to Bun's global install cache, for the zero-install `source`
42
+ * fallback (#687): under Bun zero-install an app has no `node_modules`, so
43
+ * `@webjsdev/*` lives only in this cache. Prefers `bun pm cache` (authoritative),
44
+ * then `$BUN_INSTALL/install/cache`, then `~/.bun/install/cache`. Returns null
45
+ * when none exists (e.g. Bun is not installed), which leaves the tool on the
46
+ * node_modules path with no behavior change.
47
+ * @param {(p: string) => boolean} exists
48
+ * @returns {string | null}
49
+ */
50
+ function resolveBunCacheDir(exists) {
51
+ try {
52
+ const out = spawnSync('bun', ['pm', 'cache'], { encoding: 'utf8' });
53
+ const dir = out && out.status === 0 && out.stdout ? out.stdout.trim() : '';
54
+ if (dir && exists(dir)) return dir;
55
+ } catch { /* bun not on PATH */ }
56
+ const candidates = [];
57
+ if (process.env.BUN_INSTALL) candidates.push(join(process.env.BUN_INSTALL, 'install', 'cache'));
58
+ if (process.env.HOME) candidates.push(join(process.env.HOME, '.bun', 'install', 'cache'));
59
+ for (const c of candidates) { if (exists(c)) return c; }
60
+ return null;
61
+ }
62
+
39
63
  // Mirrors packages/server/src/action-config.js. A drift test in
40
64
  // packages/mcp/test/mcp.test.mjs asserts these stay in sync with the source.
41
65
  /** HTTP verbs an action may declare (mirrors RPC_VERBS in action-config.js). */
@@ -485,7 +509,7 @@ export async function runMcpServer(opts) {
485
509
  const { readdirSync, existsSync, realpathSync } = await import('node:fs');
486
510
  const readdir = (d) => readdirSync(d, { withFileTypes: true }).map((e) => ({ name: e.name, isDir: e.isDirectory() }));
487
511
  sourceDeps = {
488
- roots: resolveFrameworkRoots(cwd, { exists: existsSync }),
512
+ roots: resolveFrameworkRoots(cwd, { exists: existsSync, readdir, bunCacheDir: resolveBunCacheDir(existsSync) }),
489
513
  readFile,
490
514
  readdir,
491
515
  realpath: realpathSync,