@webjsdev/mcp 0.1.6 → 0.1.7

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.
@@ -213,7 +213,7 @@ export async function GET() {
213
213
 
214
214
  ### `onError` hook (APM / Sentry integration point)
215
215
 
216
- Register an error sink via `createRequestHandler({ onError })` (and `startServer({ onError })`). It is called with `(error, { request, requestId, phase })` whenever the request pipeline catches an unhandled error: the 500 path (a thrown route handler / middleware / page render, labeled phase `handle` / `middleware` / `ssr` / `metadata`) or a server action that throws unexpectedly (phase `action`). **The contract is best-effort:** a throwing `onError` is caught and ignored so it can never crash the response, and the hook is purely additive (webjs's existing sanitized 500, with only `error.message` in prod and never the stack, is unchanged). The hook fires BEFORE the sanitized response is sent, so the sink sees the original error. The `requestId` ties the report to the access-log line.
216
+ Register an error sink via `createRequestHandler({ onError })` (and `startServer({ onError })`). It is called with `(error, { request, requestId, phase })` whenever the request pipeline catches an unhandled error: the 500 path (a thrown route handler / middleware / page render, labeled phase `handle` / `middleware` / `ssr` / `metadata`) or a server action that throws unexpectedly (phase `action`). **The contract is best-effort:** a throwing `onError` is caught and ignored so it can never crash the response, and the hook is purely additive (webjs's existing sanitized response is unchanged: a thrown route handler / page render returns a generic 500, and a thrown server action returns a generic message + a correlation `digest` (#749), never the raw message or the stack in prod). The hook fires BEFORE the sanitized response is sent, so the sink sees the original error. The `requestId` ties the report to the access-log line.
217
217
 
218
218
  ```ts
219
219
  const app = await createRequestHandler({
@@ -28,6 +28,12 @@ This repo uses git worktrees (the review subagents spawn throwaway ones under `.
28
28
 
29
29
  Because the pin lives in the main worktree's `config.worktree`, `git worktree add` copies it into each linked worktree, so a commit made inside a throwaway review worktree also runs the framework `.hooks/pre-commit`. That is harmless (the hook only blocks main and auto-generates a changelog on a version bump), and review subagents are read-only so they do not commit; the inheritance is noted here only so the behavior is not surprising.
30
30
 
31
+ ### Merged worktrees are auto-removed (`cleanup-merged-worktree.sh`)
32
+
33
+ Per-task worktrees pile up when a session merges its PR but never runs `git worktree remove` (a skipped step, or a crash mid-task). The `.claude/hooks/cleanup-merged-worktree.sh` PostToolUse hook (matcher `Bash`, wired in `.claude/settings.json`) closes that gap: after any `gh pr merge`, it sweeps every linked worktree and removes the ones that are safe to drop, so cleanup is deterministic rather than a thing an agent has to remember.
34
+
35
+ It is conservative. A worktree is removed ONLY when it is a linked (non-primary) checkout, on a non-`main`/`master` branch, whose branch is MERGED (an ancestor of `origin/main`, OR a merged GitHub PR for that head branch, which is how squash-merges are detected via `gh`) AND whose working tree is clean apart from untracked `node_modules` / `.webjs`. It KEEPS anything dirty, unmerged, the primary checkout, or the worktree the merge was run from (you cannot remove your current directory; `cd` out and remove it manually), reporting each kept/removed worktree back to the model via `hookSpecificOutput`. It never blocks the tool (always exits 0). Escape hatch: `WEBJS_NO_WORKTREE_CLEANUP=1`. Regression test: `test/hooks/cleanup-merged-worktree.test.mjs`.
36
+
31
37
  The fix only repairs the LOCAL checkout. Commits and branches are always safe on GitHub regardless.
32
38
 
33
39
  ---
@@ -56,6 +62,8 @@ npm runs first; if it fails (auth, network, transient registry error), the GitHu
56
62
 
57
63
  The workflow uses `NPM_TOKEN` (repo secret) and the auto-provisioned `GITHUB_TOKEN`. Free for public repos.
58
64
 
65
+ **Update the global CLI after the publish lands.** The maintainer scaffolds and dogfoods with the globally installed `webjs` CLI, which lags a release until refreshed. So once `release.yml` has published (verify `npm view @webjsdev/cli version` matches the released version), run BOTH `npm update -g webjsdev` and `bun add -g webjsdev`. Run them AFTER the publish, never at merge time (they pull the LATEST PUBLISHED version). This is reminded automatically by the `.claude/hooks/release-global-update.sh` PostToolUse hook, which fires when a `chore/release-*` PR merges (escape hatch `WEBJS_NO_RELEASE_GLOBAL_UPDATE=1`, regression test `test/hooks/release-global-update.test.mjs`).
66
+
59
67
  ---
60
68
 
61
69
  ## Dev error overlay: rich, pushed live over SSE (dev-only) (#264)
@@ -0,0 +1,198 @@
1
+ # ORM: the Drizzle query surface (rc.3)
2
+
3
+ The scaffold pins **`drizzle-orm@^1.0.0-rc.3`** (`db/columns.server.ts` header
4
+ records this, research #562). rc.3 is the 1.0 release candidate with the new
5
+ Relational Query Builder v2 (RQBv2) wired up in `db/connection.server.ts` via
6
+ `drizzle({ client, relations })`. Several method overloads that older Drizzle
7
+ tutorials rely on do NOT exist in rc.3, so training-data patterns from
8
+ Drizzle 0.29 through 0.36 trip real TypeScript errors here. This page codifies
9
+ the shapes that compile against the installed version.
10
+
11
+ Ground rules for this version.
12
+
13
+ 1. **Reads go through the relational query API** (`db.query.<table>.findFirst`
14
+ / `.findMany`), not `db.select().from()`. Relations are pre-registered in
15
+ `db/connection.server.ts`, so `with` joins work with zero boilerplate.
16
+ 2. **`db.select()` takes no argument in rc.3.** The projection overload
17
+ (`db.select({ ... })`) was removed. Passing a projection object is a
18
+ `TS2554` compile error.
19
+ 3. **`.returning()` takes no argument in rc.3.** It always returns the full
20
+ inserted / updated / deleted rows (columns only, no relations). Passing a
21
+ field object is a `TS2554` compile error.
22
+
23
+ All examples assume the scaffold imports.
24
+
25
+ ```ts
26
+ import { eq, and, inArray, desc } from 'drizzle-orm';
27
+ import { db } from '#db/connection.server.ts';
28
+ import { posts, users } from '#db/schema.server.ts';
29
+ ```
30
+
31
+ These belong in a `*.server.ts` query or action (never a page or component,
32
+ invariant 1). Reads live in `modules/<feature>/queries/*.server.ts`, mutations
33
+ in `modules/<feature>/actions/*.server.ts`, one function per file.
34
+
35
+ ---
36
+
37
+ ## Reads: use the relational query API
38
+
39
+ `db.query.<table>` reads a whole row by default, filters with a **plain object**
40
+ `where` (the RQBv2 shape, not a `sql` expression), orders with an `orderBy`
41
+ object, and pulls relations with `with`. This is the shape every scaffold query
42
+ uses.
43
+
44
+ ```ts
45
+ // RIGHT: single row by a unique column
46
+ const post = await db.query.posts.findFirst({
47
+ where: { slug: input.slug },
48
+ with: { author: { columns: { name: true, email: true } } },
49
+ });
50
+
51
+ // RIGHT: a list, newest first, with a projected column set
52
+ const rows = await db.query.posts.findMany({
53
+ orderBy: { createdAt: 'desc' },
54
+ where: { authorId: me.id },
55
+ columns: { id: true, slug: true, title: true },
56
+ with: { author: { columns: { name: true } } },
57
+ });
58
+
59
+ // RIGHT: existence check, pull only the id
60
+ const exists = await db.query.posts.findFirst({
61
+ where: { slug },
62
+ columns: { id: true },
63
+ });
64
+ ```
65
+
66
+ The `where` object supports equality directly (`{ slug }`), nested operators
67
+ (`{ createdAt: { gt: cutoff } }`), and `AND` by listing multiple keys. Reach for
68
+ the imported `eq` / `and` / `inArray` operators only on the `db.select` /
69
+ `db.delete` / `db.update` builders below, where the `.where()` clause is an SQL
70
+ expression rather than the RQBv2 object.
71
+
72
+ ### Pitfall: the `db.select({ ... })` projection overload is gone
73
+
74
+ Older Drizzle let you project columns by passing an object to `select`. rc.3
75
+ removed that overload, so the argument no longer type-checks.
76
+
77
+ ```ts
78
+ // WRONG (rc.3): TS2554 "Expected 0 arguments, but got 1"
79
+ const rows = await db
80
+ .select({ id: posts.id, title: posts.title })
81
+ .from(posts);
82
+ ```
83
+
84
+ If you genuinely need the query-builder (a manual join, a `groupBy`, an
85
+ aggregate the RQB does not express), call `select` with **no arguments** to get
86
+ the full row, then narrow in JS.
87
+
88
+ ```ts
89
+ // RIGHT: full-row select, no projection argument
90
+ const rows = await db.select().from(posts).where(eq(posts.authorId, me.id));
91
+ ```
92
+
93
+ For anything the relational API covers (by-id lookups, ordered lists, relation
94
+ joins, column subsets), prefer `db.query.*`. It is the shorter, typed,
95
+ relation-aware path and it sidesteps the removed overload entirely.
96
+
97
+ ---
98
+
99
+ ## Mutations: `insert` / `update` / `delete` with a no-arg `.returning()`
100
+
101
+ Mutations use the query-builder, filter with the imported SQL operators, and
102
+ read back rows with `.returning()`. In rc.3 `.returning()` has **only the
103
+ no-argument overload**, so it yields the full row set every time.
104
+
105
+ ```ts
106
+ // RIGHT: insert, read back the created row
107
+ const [row] = await db
108
+ .insert(posts)
109
+ .values({ title, body, slug, authorId: me.id })
110
+ .returning();
111
+
112
+ // RIGHT: update by id, read back the updated row
113
+ const [row] = await db
114
+ .update(posts)
115
+ .set({ title, body })
116
+ .where(eq(posts.id, id))
117
+ .returning();
118
+
119
+ // RIGHT: delete by id (no read-back needed)
120
+ await db.delete(posts).where(eq(posts.id, id));
121
+ ```
122
+
123
+ ### Pitfall: `.returning({ ... })` with a field argument
124
+
125
+ The older field-selecting `.returning({ id: posts.id })` overload does not exist
126
+ in rc.3.
127
+
128
+ ```ts
129
+ // WRONG (rc.3): TS2554 "Expected 0 arguments, but got 1"
130
+ const [row] = await db
131
+ .insert(posts)
132
+ .values({ title, body, slug, authorId: me.id })
133
+ .returning({ id: posts.id, slug: posts.slug });
134
+ ```
135
+
136
+ Call `.returning()` bare and destructure or map the fields you need in JS.
137
+
138
+ ```ts
139
+ // RIGHT: full row back, then pick fields
140
+ const [row] = await db.insert(posts).values({ ... }).returning();
141
+ const created = { id: row.id, slug: row.slug };
142
+ ```
143
+
144
+ ### `.returning()` yields columns only, never relations
145
+
146
+ A `.returning()` row is the table's own columns. It does NOT carry `with`
147
+ relations the way a `db.query.*` read does. When the caller expects a joined
148
+ shape (an author name alongside the post), insert then re-read, or splice the
149
+ already-known related value in by hand.
150
+
151
+ ```ts
152
+ // RIGHT: insert returns columns; add the known author locally
153
+ const [row] = await db.insert(posts).values({ title, body, slug, authorId: me.id }).returning();
154
+ return { success: true, data: { ...formatPost(row), authorName: me.name } };
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Column helpers live in `db/columns.server.ts` (the one dialect seam)
160
+
161
+ The schema is written against helpers (`table`, `pk`, `uuidPk`, `uuid`, `bool`,
162
+ `timestamp`, `createdAt`, `updatedAt`, `index`) rather than raw drizzle
163
+ builders, so the same `db/schema.server.ts` runs on SQLite and Postgres. Only
164
+ `db/columns.server.ts` differs per dialect. Two of those helpers exist because
165
+ rc.3 has not yet exposed a stable no-arg overload.
166
+
167
+ - **`table`** wraps `sqliteTableCreator((name) => name, 'snake_case')`. In rc.3
168
+ the snake_case casing lives on the table factory, so column keys map to
169
+ snake_case SQL names automatically. Do not restate a `snake_case` casing
170
+ option on `drizzle()`.
171
+ - **`index(...cols)`** wraps rc.3's `index(name)`, which still requires a name
172
+ argument the runtime auto-fills. The helper synthesizes drizzle-kit's own
173
+ collision-free name, so schema authors call `index(t.createdAt)` with no name.
174
+ Replace it with a bare `index()` once 1.0 stable ships the no-arg overload.
175
+
176
+ When you add a table, use the helpers (`pk()` for an integer autoincrement id,
177
+ `uuidPk()` for an app-generated uuid string id, `createdAt()` for a
178
+ default-to-now timestamp) rather than reaching for raw `integer(...).primaryKey(...)`
179
+ call sites, so the Postgres swap stays a one-file change.
180
+
181
+ ---
182
+
183
+ ## Where each query shape belongs
184
+
185
+ | Task | API | File |
186
+ |---|---|---|
187
+ | Row by unique column | `db.query.t.findFirst({ where })` | `queries/*.server.ts` |
188
+ | Ordered / filtered list | `db.query.t.findMany({ orderBy, where, with })` | `queries/*.server.ts` |
189
+ | Manual join / aggregate | `db.select().from(t)` (no projection arg) | `queries/*.server.ts` |
190
+ | Create | `db.insert(t).values(...).returning()` | `actions/*.server.ts` |
191
+ | Update | `db.update(t).set(...).where(eq(...)).returning()` | `actions/*.server.ts` |
192
+ | Delete | `db.delete(t).where(eq(...))` | `actions/*.server.ts` |
193
+
194
+ Cross-references. Derived row TYPES and the `ActionResult<T>` envelope belong in
195
+ a browser-safe `modules/<feature>/types.ts` (see `agent-docs/types-and-mutations.md`).
196
+ Full page / action / query recipes live in `agent-docs/recipes.md`. The
197
+ server-only boundary that keeps `db` off the client is in the root `AGENTS.md`
198
+ (invariant 1).
@@ -407,6 +407,142 @@ for `fetch` + a JS submit handler would break the no-JS baseline. Use a
407
407
  See `agent-docs/advanced.md` for the client-router side (how the enhanced
408
408
  303/422 swap works) and the rest of the form-submission behavior.
409
409
 
410
+ ## Snappy UX: Form mutation with Optimistic UI updates
411
+
412
+ When building interactive applications (like a Todo app, chat app, or likes toggle), using Optimistic UI creates a Snappy UX by immediately updating the UI state in the browser before the server confirms the operation. **Default to `optimistic()` from `@webjsdev/core` for every mutation where the client can predict the result.** Only skip it when the result is unpredictable (AI-generated content), the user must wait for side effects (payment, email), or the mutation is destructive with no undo.
413
+
414
+ To support progressive enhancement, write a baseline `<form>` that works without JS, then enhance the component in the browser using JS.
415
+
416
+ ### Optimistic UI Recipe
417
+
418
+ 1. **The Server Action**:
419
+ Create a standard server action that performs the database write and returns the new database entity.
420
+
421
+ ```ts
422
+ // modules/todos/actions/create-todo.server.ts
423
+ 'use server';
424
+ import { db } from '../../../db/connection.server.ts';
425
+ import { todos } from '../../../db/schema.server.ts';
426
+
427
+ export async function createTodo(input: { title: string }) {
428
+ const title = String(input.title || '').trim();
429
+ if (!title) return { success: false, error: 'Title is required', status: 400 };
430
+
431
+ const [todo] = await db.insert(todos).values({ title, completed: false }).returning();
432
+ return { success: true, data: todo };
433
+ }
434
+ ```
435
+
436
+ 2. **The Component with `optimistic()` (preferred)**:
437
+ Use the declarative `optimistic(host, { source, update })` API. It tracks a queue of pending updates, computes the combined value via a reducer, and auto-releases when the promise settles. No try-catch, no manual rollback, no temp-ID bookkeeping.
438
+
439
+ ```ts
440
+ // components/todo-list.ts
441
+ import { html, WebComponent, prop, optimistic } from '@webjsdev/core';
442
+ import { createTodo } from '../modules/todos/actions/create-todo.server.ts';
443
+ import { Todo } from '../modules/todos/types.ts';
444
+
445
+ export class TodoList extends WebComponent({
446
+ todos: prop<Todo[]>(Array),
447
+ }) {
448
+ private optimisticTodos = optimistic(this, {
449
+ source: () => this.todos,
450
+ update: (state, title: string) => [
451
+ ...state,
452
+ { id: crypto.randomUUID() as any, title, completed: false, pending: true },
453
+ ],
454
+ });
455
+
456
+ async handleSubmit(e: SubmitEvent) {
457
+ e.preventDefault();
458
+ const form = e.target as HTMLFormElement;
459
+ const title = String(new FormData(form).get('title') || '').trim();
460
+ if (!title) return;
461
+
462
+ form.reset();
463
+
464
+ const promise = createTodo({ title });
465
+ this.optimisticTodos.add(title, promise);
466
+
467
+ const result = await promise;
468
+ if (result.success && result.data) {
469
+ this.todos = [
470
+ ...this.todos.filter(t => t.pending),
471
+ result.data,
472
+ ...this.todos.filter(t => !t.pending),
473
+ ];
474
+ }
475
+ }
476
+
477
+ render() {
478
+ return html`
479
+ <form @submit=${this.handleSubmit}>
480
+ <input type="text" name="title" placeholder="New todo..." required />
481
+ <button type="submit">Add</button>
482
+ </form>
483
+
484
+ <ul>
485
+ ${this.optimisticTodos.value.map(
486
+ (todo) => html`
487
+ <li class=${todo.pending ? 'opacity-50 pointer-events-none' : ''}>
488
+ ${todo.title}
489
+ </li>
490
+ `
491
+ )}
492
+ </ul>
493
+ `;
494
+ }
495
+ }
496
+ TodoList.register('todo-list');
497
+ ```
498
+
499
+ For simple boolean flips, the imperative `optimistic(signal, value, action)` is cleaner:
500
+
501
+ ```ts
502
+ import { signal, optimistic } from '@webjsdev/core';
503
+ import { likePost } from '#actions/like-post.server.ts';
504
+
505
+ const liked = signal(false);
506
+ // in an @click handler:
507
+ await optimistic(liked, true, () => likePost(postId));
508
+ ```
509
+
510
+ 3. **Manual optimistic (when you cannot use `optimistic()`)**:
511
+ If you need fine-grained control (custom loading states, partial rollbacks, multi-step reconciliation), write the manual pattern:
512
+
513
+ - Declare a reactive property to hold the list.
514
+ - Generate a temporary ID, construct the optimistic item, update state immediately.
515
+ - Run the server action.
516
+ - Reconcile: replace the optimistic item on success, revert on failure.
517
+
518
+ ```ts
519
+ async handleSubmit(e: SubmitEvent) {
520
+ e.preventDefault();
521
+ const form = e.target as HTMLFormElement;
522
+ const title = String(new FormData(form).get('title') || '').trim();
523
+ if (!title) return;
524
+ form.reset();
525
+
526
+ const tempId = `temp-${crypto.randomUUID()}`;
527
+ const previousTodos = this.todos;
528
+ this.todos = [
529
+ { id: tempId as any, title, completed: false, pending: true },
530
+ ...this.todos,
531
+ ];
532
+
533
+ try {
534
+ const result = await createTodo({ title });
535
+ if (result.success && result.data) {
536
+ this.todos = this.todos.map((t) => (t.id === tempId ? result.data! : t));
537
+ } else {
538
+ this.todos = previousTodos;
539
+ }
540
+ } catch {
541
+ this.todos = previousTodos;
542
+ }
543
+ }
544
+ ```
545
+
410
546
  ## Receive and persist an uploaded file
411
547
 
412
548
  A file upload is just a `<form enctype="multipart/form-data">` posting to a page
@@ -13,7 +13,7 @@ user-facing reference is the docs-site page `/docs/runtime`; keep the two in syn
13
13
  |---|---|---|
14
14
  | Install | `npm install` (required) | `bun install` (required, like Node) |
15
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) |
16
+ | Listener | `node:http` shell | native `Bun.serve` (about 1.9x req/s on the LISTENING PATH only, a trivial-handler microbenchmark, NOT end-to-end: for a real SSR page the render dominates and the delta is small, see `scripts/bench-listener.mjs`) |
17
17
  | TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
18
18
  | SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
19
19
  | Hot reload | `node --watch` | `bun --hot` |
@@ -39,7 +39,11 @@ and emit `<script nonce="${cspNonce()}">…`.
39
39
  ## What the worker does (scope + strategy)
40
40
 
41
41
  Registered from `/sw.js`, the worker's scope is the site root (`/`), so it sees
42
- every navigation and same-origin asset request.
42
+ every navigation and same-origin asset request. Your worker file lives at
43
+ `public/sw.js`, and although most `public/*` assets serve at `/public/<name>`,
44
+ the framework serves this one (and `public/offline.html`) at the SITE ROOT with a
45
+ `Service-Worker-Allowed: /` header, so `register('/sw.js')` resolves to a 200 and
46
+ the worker controls the whole origin (#830).
43
47
 
44
48
  - **Navigations are network-first.** The worker always tries the network first,
45
49
  so the user sees fresh server-rendered HTML, and caches each successful page
@@ -24,7 +24,7 @@ app work.
24
24
  | Kind | Location | What it does | Runner |
25
25
  |---|---|---|---|
26
26
  | **unit + integration** (node) | `test/<feature>/<name>.test.{js,ts,mjs}` | Imports modules and asserts. No spawned process, no network. Includes both true unit tests and in-process integration. | `node --test` |
27
- | **browser** | `test/<feature>/browser/<name>.test.js` | Runs in real Chromium with Playwright. Real DOM, events, `adoptedStyleSheets`, `IntersectionObserver`, shadow / light DOM. | web-test-runner (`wtr`) |
27
+ | **browser** | `test/<feature>/browser/<name>.test.js` | Runs in real Chromium, Firefox, AND WebKit via Playwright (#774). Real DOM, events, `adoptedStyleSheets`, `IntersectionObserver`, shadow / light DOM. WebKit is the engine behind the iOS-only repaint bugs (the #610 class), so it runs in CI now, not just by hand on-device. | web-test-runner (`wtr`) |
28
28
  | **e2e** | `test/<feature>/e2e/<name>.test.{ts,mjs}` | Boots a real process (dev server, CLI binary) and drives it through its public interface (HTTP, browser, stdout). Opt in with `WEBJS_E2E=1`. | `node --test` (driver) |
29
29
  | **smoke** | `test/<feature>/smoke/<name>.test.{js,ts}` | Fast deploy-time sanity check. A subset of e2e in spirit, kept separate so it runs on every deploy. | `node --test` |
30
30
 
@@ -33,6 +33,35 @@ Some packages will only have one or two of these kinds (e.g.
33
33
  a kind subfolder when there's at least one test of that kind.
34
34
  Empty `e2e/` folders are anti-patterns.
35
35
 
36
+ ### App browser tests import your REAL components (#806)
37
+
38
+ In a scaffolded app, a browser test (`test/<feature>/browser/<name>.test.js`)
39
+ can `import` your real `.ts` components, **including ones that call a
40
+ `'use server'` action**, and they load in a real browser. `webjs test --browser`
41
+ runs each test through the same pipeline as `webjs dev`: the `.ts` is type-
42
+ stripped, a `.server.ts` action import is rewritten to its RPC stub, `#` aliases
43
+ and `@webjsdev/core` resolve via the injected importmap. So you write:
44
+
45
+ ```js
46
+ // test/todos/browser/todo-list.test.js
47
+ import { ssrFixture } from '@webjsdev/core/testing';
48
+ import { html } from '@webjsdev/core';
49
+
50
+ suite('todo-list', () => {
51
+ test('adds a row', async () => {
52
+ await import('../../../components/todo-list.ts'); // imports create-todo.server.ts
53
+ const el = await ssrFixture(html`<todo-list></todo-list>`);
54
+ el.querySelector('button')?.click(); // fires the action RPC
55
+ // assert on the DOM...
56
+ });
57
+ });
58
+ ```
59
+
60
+ The wiring lives in the scaffolded `web-test-runner.config.js` (you don't set it
61
+ up). Keep the browser test file itself `.js`; the components + modules it imports
62
+ are `.ts` and are served transformed. This is why a component that talks to the
63
+ server can be tested in a real browser, not just in a node test.
64
+
36
65
  ---
37
66
 
38
67
  ## Framework layout (this repo)
@@ -99,8 +128,18 @@ package, it goes in that package's `test/`.
99
128
  - `npm run test:browser` → `wtr`: globs
100
129
  `packages/*/test/**/browser/**/*.test.js` and
101
130
  `test/**/browser/**/*.test.js`. The blog browser e2e is
102
- excluded (it needs the blog dev server up first).
103
- - `npm run test:e2e` → `WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs`.
131
+ excluded (it needs the blog dev server up first). Runs on Chromium,
132
+ Firefox, and WebKit concurrently; narrow to one engine for a fast local
133
+ loop with `WEBJS_BROWSERS=chromium npx wtr` (comma-separate for a subset).
134
+ - `npm run test:e2e` → `WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs`. In CI
135
+ this runs as two PARALLEL jobs (#774): the Node-served suite (the required
136
+ merge-gate check) and a Bun-served run (`WEBJS_E2E_RUNTIME=bun`) in a
137
+ separate `e2e-bun` job, so the cross-runtime e2e no longer serializes behind
138
+ the Node run.
139
+ - `npm run test:coverage` → `WEBJS_COVERAGE=1 npm test`: the node suite with
140
+ Node's built-in coverage reporter (#774). Opt-in, kept off the default
141
+ `npm test` so the common path stays fast; excludes test files and the
142
+ `scripts/` harness so the numbers reflect shipped source.
104
143
  - `npm run test:all` runs node + browser (not e2e).
105
144
 
106
145
  The root drivers above ONLY discover the framework packages and the root
@@ -195,7 +234,7 @@ ui-website ship no test suite yet, so they are not in the job.
195
234
 
196
235
  ## Component test helpers (`@webjsdev/core/testing`)
197
236
 
198
- `import { fixture, ssrFixture, waitForUpdate, assertNoA11yViolations, click, shadowQuery, shadowQueryAll } from '@webjsdev/core/testing'`. The mount + hydrate + a11y helpers run in the WTR Chromium session (real DOM), thin wrappers over the browser already running.
237
+ `import { fixture, ssrFixture, waitForUpdate, assertNoA11yViolations, click, shadowQuery, shadowQueryAll } from '@webjsdev/core/testing'`. The mount + hydrate + a11y helpers run in the WTR browser session (real DOM, on each of Chromium / Firefox / WebKit), thin wrappers over the browser already running.
199
238
 
200
239
  ### `fixture()` vs `ssrFixture()`
201
240
 
@@ -330,8 +369,9 @@ enforces:
330
369
  - **the wire serializer** (a `Date` / `Map` / `BigInt` arg or return is
331
370
  genuinely encoded + decoded, not passed by reference),
332
371
  - **CSRF** (a missing token is a 403),
333
- - **prod error sanitization** (a thrown error surfaces as a sanitized
334
- message-only payload, never the stack or extra error fields).
372
+ - **prod error sanitization** (#749: a thrown error surfaces as a GENERIC
373
+ message plus a correlation `digest`, never the raw message, stack, or extra
374
+ error fields; the thrown error carries `.digest`).
335
375
 
336
376
  So `invokeActionForTest` catches a serializer / CSRF / error-sanitization
337
377
  regression a direct import cannot see. For the negative cases (assert a 403 on
@@ -406,6 +446,7 @@ a feature, the test is probably testing too many things at once.
406
446
  Kind is a child of feature, not the other way around.
407
447
  - **Don't create empty kind folders.** If `e2e/` has no tests
408
448
  yet, leave it absent.
449
+ - **Don't attempt to run full browser-based tests (WTR/Playwright) inside headless VM sandboxes** if they lack compiler transformation plugins (e.g. for TypeScript components or RPC action stubbing) or have missing native browser OS libraries. Instead, extract components' reconciliation or optimistic UI update logic into pure, browser-safe utilities and cover them using server-side Node.js unit tests.
409
450
  - **Don't put package-only tests under root `test/`.** It hurts
410
451
  the per-package `npm test --workspace=…` workflow.
411
452
  - **Don't import from another package's `test/` directory.**
@@ -0,0 +1,171 @@
1
+ # Types and mutations: sharing server-derived shapes with the browser
2
+
3
+ An interactive component that ships to the browser almost always needs the
4
+ shape of the data it renders (`Todo`, `Post`, the `ActionResult<T>` envelope a
5
+ mutation returns). Those shapes are DERIVED from server-only modules (the
6
+ Drizzle schema, the action files), so the reflex is to import them straight from
7
+ `db/schema.server.ts` or a `*.server.ts` action. That reflex pins a server
8
+ module to a browser closure and breaks the build. This page codifies where the
9
+ types live so a shipping component consumes them safely.
10
+
11
+ The one rule. **A type crossing to the browser is a TYPE, never a runtime
12
+ value.** Keep the shape in a browser-safe `modules/<feature>/types.ts` with no
13
+ runtime import from a `.server.ts` file or from `db/`. A `import type { ... }`
14
+ is erased by the TypeScript stripper before the module reaches the browser, so
15
+ it is safe. A plain `import { ... }` (a value import) is NOT erased, so it pins
16
+ the server module and trips the `no-server-import-in-browser-module` check.
17
+
18
+ ---
19
+
20
+ ## Why a value import breaks and a type import does not
21
+
22
+ webjs strips TypeScript at runtime with no bundler. The stripper erases every
23
+ `import type` and every type-only specifier, so those imports never exist in the
24
+ served JavaScript. A value import survives stripping and becomes a real browser
25
+ `import`. When the target is a `.server.ts` module, its browser stub throws at
26
+ module load (invariant 1), and the `no-server-import-in-browser-module` check
27
+ flags it statically for any module the framework determines actually ships.
28
+
29
+ ```ts
30
+ // SAFE: type-only, erased before it reaches the browser.
31
+ import type { Post } from '#db/schema.server.ts';
32
+
33
+ // UNSAFE: a runtime value import of the server schema. Survives stripping,
34
+ // pins db/schema.server.ts into the browser closure, throws at load.
35
+ import { posts } from '#db/schema.server.ts';
36
+ ```
37
+
38
+ The subtle trap is deriving a type from a value with a value import. `typeof
39
+ posts.$inferSelect` needs the `posts` binding, and `import { posts }` is a value
40
+ import even though you only use it at the type level. Write `import type` (rc.3
41
+ schemas already `export type Post = typeof posts.$inferSelect`, so import the
42
+ ready-made type), or hand-write the DTO in `types.ts`.
43
+
44
+ ---
45
+
46
+ ## The wrong chain: a types module re-exporting a runtime value
47
+
48
+ The classic failure is a `types.ts` that looks type-only but carries a runtime
49
+ re-export. A browser-shipped page or component imports it, and the whole server
50
+ schema is dragged along.
51
+
52
+ ```ts
53
+ // modules/posts/types.ts WRONG
54
+ // A star or value re-export carries the runtime `posts`, `users` table
55
+ // bindings, not just their types.
56
+ export * from '#db/schema.server.ts'; // re-exports VALUES
57
+ export { posts } from '#db/schema.server.ts'; // re-exports a VALUE
58
+ ```
59
+
60
+ ```ts
61
+ // components/post-card.ts (an interactive component, ships to the browser)
62
+ import type { Post } from '#modules/posts/types.ts';
63
+ // Because types.ts value-re-exports the schema, the browser module graph now
64
+ // reaches db/schema.server.ts. no-server-import-in-browser-module fails, and
65
+ // at runtime the server stub throws the moment post-card.ts loads.
66
+ ```
67
+
68
+ The check fires on the component (a shipping browser module), even though the
69
+ mistake is in `types.ts`. The re-export made a server value reachable from a
70
+ browser closure.
71
+
72
+ ---
73
+
74
+ ## The right chain: a browser-safe DTO in `types.ts`
75
+
76
+ Define the wire shape as a plain interface (or a `type`) in `types.ts` with no
77
+ runtime import. This is the DTO the query / action returns and the component
78
+ consumes. It has zero server dependency, so it crosses the boundary freely.
79
+
80
+ ```ts
81
+ // modules/posts/types.ts RIGHT
82
+ // A hand-written DTO. No import from db/ or any *.server.ts. Pure types.
83
+ export type PostFormatted = {
84
+ id: number;
85
+ slug: string;
86
+ title: string;
87
+ body: string;
88
+ authorId: number;
89
+ authorName: string | null;
90
+ createdAt: string; // serialized for the wire
91
+ };
92
+
93
+ export type CreatePostInput = { title: string; body: string };
94
+
95
+ // The mutation envelope every action returns. Browser-safe, no server import.
96
+ export type ActionResult<T> =
97
+ | { success: true; data?: T; redirect?: string }
98
+ | { success: false; error?: string; fieldErrors?: Record<string, string>;
99
+ values?: Record<string, string>; status?: number };
100
+ ```
101
+
102
+ ```ts
103
+ // modules/posts/queries/list-posts.server.ts (server: may touch db)
104
+ import { db } from '#db/connection.server.ts';
105
+ import type { PostFormatted } from '#modules/posts/types.ts';
106
+ export async function listPosts(): Promise<PostFormatted[]> {
107
+ const rows = await db.query.posts.findMany({ orderBy: { createdAt: 'desc' } });
108
+ return rows.map(formatPost); // maps the row to the DTO
109
+ }
110
+ ```
111
+
112
+ ```ts
113
+ // components/post-card.ts (browser: consumes the DTO type only)
114
+ import type { PostFormatted } from '#modules/posts/types.ts';
115
+ ```
116
+
117
+ If you want the DTO to track the schema automatically rather than restating it,
118
+ use a **type-only** derivation and keep it in `types.ts`.
119
+
120
+ ```ts
121
+ // modules/posts/types.ts RIGHT (type-only derivation, still erased)
122
+ import type { Post } from '#db/schema.server.ts'; // type-only, erased
123
+ export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string };
124
+ ```
125
+
126
+ Either form ships nothing server-side to the browser. The `import type` is the
127
+ seam. A value import is the leak.
128
+
129
+ ---
130
+
131
+ ## Carrier choice: register an interactive component in `layout.ts`
132
+
133
+ An interactive component reaches the browser because SOME module imports it and
134
+ that module ships. A `page.ts` and a `layout.ts` never hydrate, but they DO load
135
+ in the browser to register the components they import. When a page's ONLY reason
136
+ to ship is registering a component, prefer registering that component in
137
+ `app/layout.ts` (or a section layout), not `app/page.ts`.
138
+
139
+ The reason is elision economics. A `page.ts` whose sole client job is
140
+ registering a component is dropped by the framework, and the browser fetches the
141
+ component leaf directly (the import-only page optimization). But when you have a
142
+ component shared across a section (a header search box, a theme toggle, a cart
143
+ button), the layout is the natural single carrier. Register it once there and
144
+ every page under it gets the upgraded element with no per-page import.
145
+
146
+ ```ts
147
+ // app/layout.ts RIGHT: the section carrier registers shared interactive UI
148
+ import '#components/theme-toggle.ts'; // registers <theme-toggle> for every page
149
+ import '#components/ui/dialog.ts'; // registers the Tier-2 <ui-dialog> element
150
+
151
+ export default function RootLayout({ children }) {
152
+ return html`... <theme-toggle></theme-toggle> ${children} ...`;
153
+ }
154
+ ```
155
+
156
+ ```ts
157
+ // app/page.ts RIGHT: page stays a pure server function, no client import
158
+ export default function Home() {
159
+ return html`<h1>Home</h1><theme-toggle></theme-toggle>`; // tag already registered by the layout
160
+ }
161
+ ```
162
+
163
+ Register in `page.ts` only when the component is page-specific (used on that one
164
+ route). A shared element registered per page duplicates the import intent across
165
+ routes and muddies which module is the carrier. Keep pages pure server functions
166
+ where you can, and let the nearest layout carry the shared interactive tags.
167
+
168
+ Cross-references. The server-only boundary and the type-only-import exception
169
+ are invariant 1 in the root `AGENTS.md`. The Drizzle query surface that produces
170
+ these rows is in `agent-docs/orm.md`. The optimistic mutation flow that consumes
171
+ `ActionResult<T>` on the client is in `agent-docs/advanced.md`.