@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.
- package/package.json +1 -1
- package/resources/AGENTS.md +94 -13
- package/resources/agent-docs/advanced.md +97 -14
- package/resources/agent-docs/built-ins.md +17 -7
- package/resources/agent-docs/components-shadowing.md +87 -0
- package/resources/agent-docs/components.md +35 -13
- package/resources/agent-docs/configuration.md +1 -1
- package/resources/agent-docs/framework-dev.md +8 -0
- package/resources/agent-docs/orm.md +198 -0
- package/resources/agent-docs/recipes.md +136 -0
- package/resources/agent-docs/runtime.md +1 -1
- package/resources/agent-docs/service-worker.md +5 -1
- package/resources/agent-docs/testing.md +47 -6
- package/resources/agent-docs/types-and-mutations.md +171 -0
- package/src/mcp-source.js +11 -1
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
334
|
-
message
|
|
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`.
|