@webjsdev/mcp 0.1.4 → 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.4",
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`) |
@@ -61,8 +62,8 @@ When interactive approval is disabled, never block on questions. Auto-decide: on
61
62
 
62
63
  Every code change MUST include, automatically:
63
64
 
64
- 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`).
65
- 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`).
66
67
  3. **Convention validation.** Run `webjs check` and fix violations.
67
68
 
68
69
  ### Git workflow (mandatory)
@@ -89,7 +90,7 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, per-f
89
90
 
90
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 })`.
91
92
 
92
- - **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).
93
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.
94
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.
95
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`.
@@ -108,7 +109,13 @@ webjs has **no server/client component split.** There is no RSC render tree, no
108
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.**
109
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.
110
111
 
111
- 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.
112
119
 
113
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.
114
121
 
@@ -230,7 +237,7 @@ MyThing.register('my-thing');
230
237
 
231
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.
232
239
 
233
- **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 via the `default` option (`prop(Number, { default: 0 })`, a function default for a fresh object / array per instance) OR in the constructor after `super()`. 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`. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`.
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`.
234
241
 
235
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).
236
243
 
@@ -287,7 +294,7 @@ Server actions export named async functions whose args + returns round-trip thro
287
294
 
288
295
  ### RPC + REST endpoint security
289
296
 
290
- 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`.
291
298
 
292
299
  ### Components (`components/*.{js,ts}`)
293
300
 
@@ -317,15 +324,15 @@ type ActionResult<T> =
317
324
 
318
325
  ## Styling: Tailwind-first
319
326
 
320
- **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`.
321
328
 
322
329
  ---
323
330
 
324
331
  ## Client navigation: automatic, nothing to opt into
325
332
 
326
- 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.
327
334
 
328
- 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), 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).
329
336
 
330
337
  ---
331
338
 
@@ -333,7 +340,7 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
333
340
 
334
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.
335
342
 
336
- 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.
337
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.
338
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).
339
346
  4. **Event (`@`), property (`.`), boolean (`?`) holes in `html` must be unquoted**, e.g. `@click=${fn}`, never `@click="${fn}"`.
@@ -350,7 +357,7 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
350
357
 
351
358
  ## Scaffolding
352
359
 
353
- Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite), `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR), `webjs create <name> --template saas` (auth + login/signup + protected dashboard + User model). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors any of the three for Bun (`bun --bun` dev/start scripts so the SERVER runs on Bun, `trustedDependencies`, `bun.lock`, a pure `oven/bun:1` Dockerfile (#595; safe since cli@0.10.20's npx-free `webjs db migrate` (#570) needs no Node in the image) + bun-install CI, bun-command agent docs; the test/db/check tooling stays on Node); `bun create webjs <name>` auto-detects it. Pick from the request: default for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce), `api` for an HTTP/JSON API with no UI, `saas` for accounts/login/signup; default to full-stack when ambiguous.
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.
354
361
 
355
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.
356
363
 
@@ -364,7 +371,7 @@ webjs start [--port N] # prod server; source IS the runtime, plain H
364
371
  webjs test [--server] [--browser] [--watch]
365
372
  webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop
366
373
  webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check
367
- 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
368
375
  webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258)
369
376
  webjs typecheck [tsc args...] # the project's own tsc --noEmit
370
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.
@@ -868,6 +868,17 @@ revalidation fetch that follows does **not** scroll, so the restored
868
868
  position survives the refresh. Cache miss → browser-native scroll
869
869
  restoration takes over.
870
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
+
871
882
  Inner scroll containers (e.g. `.docs-sidenav`) are preserved
872
883
  automatically by the outer-layout-DOM-identity invariant. They stay
873
884
  mounted across nav and keep their `scrollTop` natively.
@@ -1111,7 +1122,7 @@ more parts in a `Response` with the stream content type. A page `action` may
1111
1122
  return `streamResponse(...)` directly (it is honored verbatim); on the no-JS
1112
1123
  branch return a normal `ActionResult` instead. `renderStream` is auto-registered
1113
1124
  by the client router, so it (and the `<webjs-stream>` element) is available
1114
- wherever a layout imports `@webjsdev/core/client-router`.
1125
+ on any page that loads `@webjsdev/core` (the router auto-enables there).
1115
1126
 
1116
1127
  ### Streaming RPC results: an action returns a stream (#489)
1117
1128
 
@@ -1175,7 +1186,23 @@ Use `data-no-router` for:
1175
1186
 
1176
1187
  ### Loading indicator
1177
1188
 
1178
- `<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.
1179
1206
 
1180
1207
  ## WebSockets
1181
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
 
@@ -20,6 +20,13 @@ Built-in constructors (`String`, `Number`, `Boolean`, `Array`, `Object`) feed
20
20
  the default attribute coercion. For anything the default can't parse correctly
21
21
  (Date, Map, Set, discriminated unions) supply a custom `converter`.
22
22
 
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.
29
+
23
30
  ## Declaring reactive properties: the base-class factory
24
31
 
25
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).
@@ -52,14 +59,20 @@ class Dialog extends WebComponent({
52
59
  }
53
60
  ```
54
61
 
55
- Set defaults via the `default` option (a function default runs per instance for a fresh object / array) or in the constructor after `super()`. **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.
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.
56
63
 
57
64
  ```ts
58
- // default via the option (no constructor needed)
65
+ // set defaults in the constructor after super()
59
66
  class Counter extends WebComponent({
60
- count: prop(Number, { default: 0 }),
61
- items: prop(Array, { default: () => [] }), // function default = fresh array per instance
62
- }) {}
67
+ count: prop(Number, { reflect: true }),
68
+ items: prop(Array),
69
+ }) {
70
+ constructor() {
71
+ super();
72
+ this.count = 0;
73
+ this.items = [];
74
+ }
75
+ }
63
76
  ```
64
77
 
65
78
  ## Lifecycle hooks (lit-aligned)
@@ -188,7 +201,7 @@ as it has none of the following.
188
201
  - An overridden lifecycle hook (anything in the table above), as a method or an arrow class field.
189
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.
190
203
  - An `addController(...)` or `requestUpdate()` call.
191
- - 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.
192
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.
193
206
  - Being rendered or imported by a component that itself ships (an interactive parent can re-create the child on the client).
194
207
 
@@ -236,6 +249,56 @@ which fails if a new `WebComponent` hook is added without teaching the
236
249
  analyser about it. If you add an interactivity feature to the framework,
237
250
  update that file.
238
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
+
239
302
  ### Elision is what keeps a server import off a display-only page (and `webjs check` guards the seam)
240
303
 
241
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)
@@ -280,15 +280,10 @@ connectedCallback() {
280
280
 
281
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.
282
282
 
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 via the `default` option or in the constructor after `super()`, never as a class-field initializer:
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:
284
284
 
285
285
  ```ts
286
- // default via the option (a function default = fresh object per instance)
287
- class StudentCard extends WebComponent({
288
- student: prop<Student>(Object, { default: () => ({ name: '', email: '' }) }),
289
- }) {}
290
-
291
- // or set the default in the constructor (fully typed, no declare needed)
286
+ // set the default in the constructor (fully typed, no declare needed)
292
287
  class StudentCard extends WebComponent({
293
288
  student: prop<Student>(Object),
294
289
  }) {
@@ -457,7 +452,7 @@ must move with it, use `repeat()` with a stable key.
457
452
  | `Task` for client-time async | `Task` (no change, that's its job) |
458
453
  | `window.X` or `document.X` in constructor or `render()` | Move to `connectedCallback` |
459
454
  | Top-level `import` of browser-only library | Dynamic `import()` inside `connectedCallback` |
460
- | `student: Student = { ... }` field initializer | Base-class factory `WebComponent({ student: Object })`, default via the `default` option or the constructor |
455
+ | `student: Student = { ... }` field initializer | Base-class factory `WebComponent({ student: Object })`, default in the constructor after `super()` |
461
456
  | `@property()` decorator / `static properties` block | Base-class factory `WebComponent({ ... })` (the `prop()` helper carries options) |
462
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 |
463
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** |
@@ -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).
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,