@webjsdev/mcp 0.1.5 → 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/README.md +1 -3
- package/package.json +1 -1
- package/resources/AGENTS.md +97 -16
- 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 +17 -43
- 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 +12 -74
- package/src/mcp.js +2 -26
|
@@ -59,7 +59,7 @@ class Dialog extends WebComponent({
|
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
Set defaults in the constructor after `super()`. A declarative `default` option also exists (lit-parity, a function default runs per instance for a fresh object / array), but the constructor is the recommended way. **Never** use a class-field initializer (e.g., `count = 0
|
|
62
|
+
Set defaults in the constructor after `super()`. A declarative `default` option also exists (lit-parity, a function default runs per instance for a fresh object / array), but the constructor is the recommended way. **Never** use a class-field declaration OR initializer (e.g., `count = 0`, `student: Student = { ... }`, or `todos!: Todo[]`): even a type-only declaration like `todos!: Todo[]` compiles under modern TS configurations (with `useDefineForClassFields: true`) to define a property on the instance after `super()`, which shadows and clobbers the prototype's reactive accessor, silently breaking reactivity. Only declare properties in the factory, and read/write them directly off `this`. The `reactive-props-no-class-field` rule catches this.
|
|
63
63
|
|
|
64
64
|
```ts
|
|
65
65
|
// set defaults in the constructor after super()
|
|
@@ -96,7 +96,7 @@ The SSR pipeline runs the **pre-render value-deriving hooks** before `render()`:
|
|
|
96
96
|
|
|
97
97
|
For component-local state, create an instance signal in the constructor and call `signal.set(...)` to mutate. The built-in `SignalWatcher` re-runs `render()` on the next microtask; the same lifecycle hooks fire as for reactive-property changes.
|
|
98
98
|
|
|
99
|
-
See [`/docs/lifecycle`](https://docs.webjs.
|
|
99
|
+
See [`/docs/lifecycle`](https://docs.webjs.dev/docs/lifecycle) for per-hook usage examples.
|
|
100
100
|
|
|
101
101
|
## Async render: bare-await data fetch (#469)
|
|
102
102
|
|
|
@@ -244,10 +244,23 @@ patterns, treat the component as interactive.
|
|
|
244
244
|
|
|
245
245
|
The detection lists live in `packages/server/src/component-elision.js`
|
|
246
246
|
and are the single source of truth. They are kept in lockstep with the
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
247
|
+
framework's interactivity surface by two drift guards, which fail the build
|
|
248
|
+
if a new interactivity feature ships without teaching the analyser about it
|
|
249
|
+
(a silent over-elision otherwise). `packages/server/test/elision/lifecycle-coverage.test.js`
|
|
250
|
+
introspects the live `WebComponent` prototype and covers hooks and methods.
|
|
251
|
+
`packages/server/test/elision/sigil-coverage.test.js` covers the two surfaces
|
|
252
|
+
that are not prototype members: template binding sigils and interactivity
|
|
253
|
+
static fields. The renderers' sigil set is single-sourced in core's
|
|
254
|
+
`BINDING_PREFIXES` (`packages/core/src/binding-prefixes.js`); the analyser
|
|
255
|
+
classifies each sigil as a client-behaviour ship signal (`SSR_DROPPED_PREFIXES`,
|
|
256
|
+
`@event`, drops at SSR) or an SSR-safe round-trip (`ROUND_TRIP_PREFIXES`,
|
|
257
|
+
`.prop` / `?bool`, survives into the served HTML). The guard asserts that
|
|
258
|
+
classification partitions `BINDING_PREFIXES` exactly, so a new sigil cannot be
|
|
259
|
+
added without a deliberate ship-or-round-trip decision. Interactivity static
|
|
260
|
+
fields live in the `INTERACTIVITY_STATIC_FIELDS` registry (`shadow`, `refresh`).
|
|
261
|
+
If you add an interactivity feature to the framework, update the matching list
|
|
262
|
+
in `component-elision.js` (and add a new static convention to both the
|
|
263
|
+
`INTERACTIVITY_STATIC_FIELDS` registry and this lifecycle table).
|
|
251
264
|
|
|
252
265
|
## Pages and layouts: keep them carriers, out of the network tab
|
|
253
266
|
|
|
@@ -325,19 +338,28 @@ suggests is the three legitimate shapes: gate the route in
|
|
|
325
338
|
`'use server'` ACTION (its browser stub is a working RPC, so it is
|
|
326
339
|
exempt), or register the component in a `layout.{ts,js}` so the page
|
|
327
340
|
elides again. Server-to-server imports (`.server.ts` importing
|
|
328
|
-
`.server.ts`) and `middleware.ts` / `route.ts` are never flagged.
|
|
341
|
+
`.server.ts`) and `middleware.ts` / `route.ts` are never flagged. A
|
|
342
|
+
TYPE-ONLY `import type { Row } from './x.server.ts'` is exempt too,
|
|
343
|
+
because the stripper erases it before it reaches the browser, so sharing
|
|
344
|
+
a derived row type from a `.server.ts` is safe (a mixed
|
|
345
|
+
`import { type Row, value }` still ships the runtime `value` binding, so
|
|
346
|
+
it is still flagged).
|
|
329
347
|
|
|
330
348
|
The rule covers every module the build ships, not just pages: a shipping
|
|
331
349
|
component, and `error.{ts,js}` / `loading.{ts,js}` / `not-found.{ts,js}`
|
|
332
350
|
modules, are checked too. Those three boundaries always ship and are
|
|
333
351
|
never elided (only an elidable component import is ever stripped), so a
|
|
334
352
|
personalized 404 that does `await auth()` is the same throw-at-load crash
|
|
335
|
-
and is flagged.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
353
|
+
and is flagged. Scope note for dynamic imports: a string-literal
|
|
354
|
+
`import('./widget.ts')` IS tracked by the authorization gate (#751), so a
|
|
355
|
+
lazily-imported app module is servable instead of 404ing, but it is kept
|
|
356
|
+
out of elision and the modulepreload set (a dynamic import is lazy by
|
|
357
|
+
author intent, fetched at call time). The `no-server-import-in-browser-module`
|
|
358
|
+
rule still operates on STATIC edges only, so a dynamic
|
|
359
|
+
`import('./x.server.ts')` of a no-`'use server'` utility is not flagged at
|
|
360
|
+
check time, its throw-at-load is deferred to call time; and a computed
|
|
361
|
+
`import(expr)` cannot be resolved statically, so `webjs check` warns on it
|
|
362
|
+
in a shipping module (it would 404 if it targets an app module).
|
|
341
363
|
|
|
342
364
|
### Turning elision off
|
|
343
365
|
|
|
@@ -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
|
|
@@ -11,9 +11,9 @@ user-facing reference is the docs-site page `/docs/runtime`; keep the two in syn
|
|
|
11
11
|
|
|
12
12
|
| Area | Node 24+ | Bun |
|
|
13
13
|
|---|---|---|
|
|
14
|
-
| Install | `npm install` (required) |
|
|
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` |
|
|
@@ -29,52 +29,26 @@ correctness (the modulepreload hints still ship in the document head).
|
|
|
29
29
|
`webjs create <name>` defaults to Node. `webjs create <name> --runtime bun` (or
|
|
30
30
|
`bun create webjs <name>`, auto-detected from the invoking package manager,
|
|
31
31
|
#541) emits a Bun-flavored app: `bun.lock`, a pure `oven/bun:1` Dockerfile +
|
|
32
|
-
bun-install CI, bun-command agent docs
|
|
33
|
-
`--
|
|
32
|
+
bun-install CI, and bun-command agent docs. `--runtime` is orthogonal to
|
|
33
|
+
`--template`.
|
|
34
34
|
|
|
35
|
-
## Bun
|
|
35
|
+
## Bun
|
|
36
36
|
|
|
37
|
-
A Bun app
|
|
38
|
-
`
|
|
37
|
+
A Bun app installs with `bun install` (like Node), then its `dev` / `start` /
|
|
38
|
+
`db` scripts force `bun --bun` so the server runs on Bun:
|
|
39
39
|
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
```sh
|
|
41
|
+
bun install
|
|
42
|
+
bun run dev # or: bun run start
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
`bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
the bootstrap, so the boot-time `webjs db migrate` needs no `webjs` bin in
|
|
53
|
-
`node_modules`. `bun install` stays optional: run it for editor type
|
|
54
|
-
intelligence (no `node_modules` means no local type files) or a pinned offline
|
|
55
|
-
install. The Node-targeted tooling scripts (`test` / `check` / `typecheck`)
|
|
56
|
-
stay plain `webjs` on Node and still expect an install.
|
|
57
|
-
|
|
58
|
-
**Version pinning (#685).** Bun's auto-install fetches `latest` for a bare
|
|
59
|
-
`import 'zod'`, ignoring package.json and `bun.lock` (it consults neither the
|
|
60
|
-
manifest, the lockfile, nor an `onResolve` plugin, verified in #684). webjs
|
|
61
|
-
closes that gap with an `onLoad` transform: it rewrites a declared dep's bare
|
|
62
|
-
specifier to an inline-versioned one (`zod` to `zod@3.22.4`), which Bun's
|
|
63
|
-
auto-install DOES honor. The version is the EXACT one from `bun.lock` when
|
|
64
|
-
present, else an exact `package.json` pin. Bun resolves an exact inline version
|
|
65
|
-
but ENOENTs on a range or dist-tag (`zod@^3`, `zod@latest`), so a dep declared
|
|
66
|
-
as a RANGE without a `bun.lock` is left bare and resolves to latest as before:
|
|
67
|
-
commit a `bun.lock` (or pin exact versions) to get pinned zero-install. The
|
|
68
|
-
rewrite is server-runtime only (it shapes what Bun fetches for SSR and server
|
|
69
|
-
actions). The browser is still served bare specifiers resolved via the importmap
|
|
70
|
-
/ jspm, unchanged. Only declared deps are rewritten (a transitive dep follows
|
|
71
|
-
from its pinned parent's manifest), and it is a no-op when `node_modules` exists
|
|
72
|
-
(Bun uses the installed copy). Default on. Opt out with `WEBJS_PIN=0` or
|
|
73
|
-
`{ "webjs": { "pin": false } }`.
|
|
74
|
-
|
|
75
|
-
**Reproducibility:** dev resolves on demand (now at the pinned versions), and the
|
|
76
|
-
scaffold's Bun Dockerfile still keeps an explicit `bun install` so a prod image
|
|
77
|
-
is immutable and self-contained with no registry fetch at boot.
|
|
45
|
+
`bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun
|
|
46
|
+
(selecting the native `Bun.serve` listener and `amaro` type stripping); the app's
|
|
47
|
+
deps resolve from `node_modules`, the same as Node. The `start.before` migrate
|
|
48
|
+
(`webjs db migrate`) runs under Bun too. Commit a `bun.lock` (the Bun analog of
|
|
49
|
+
`package-lock.json`) for reproducible, offline installs; the scaffold's Bun
|
|
50
|
+
Dockerfile runs `bun install` and serves via `CMD ["bun", "--bun", "run",
|
|
51
|
+
"start"]`.
|
|
78
52
|
|
|
79
53
|
## SQLite busy_timeout (#674)
|
|
80
54
|
|
|
@@ -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.**
|