@webjsdev/mcp 0.1.2 → 0.1.3

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