@webjsdev/cli 0.10.50 → 0.10.51
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 +3 -1
- package/bin/webjs.js +133 -30
- package/lib/api-gallery.js +6 -7
- package/lib/app-name.js +208 -0
- package/lib/create.js +37 -9
- package/lib/doctor.js +479 -7
- package/package.json +2 -2
- package/templates/.agents/rules/workflow.md +9 -1
- package/templates/.agents/skills/webjs/SKILL.md +25 -11
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +25 -6
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +6 -2
- package/templates/.agents/skills/webjs/references/components.md +9 -1
- package/templates/.agents/skills/webjs/references/data-and-actions.md +40 -7
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +54 -18
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
- package/templates/.agents/skills/webjs/references/runtime.md +5 -1
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +61 -3
- package/templates/.agents/skills/webjs/references/typescript.md +71 -2
- package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
- package/templates/.github/pull_request_template.md +1 -0
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/AGENTS.md +31 -5
- package/templates/CONVENTIONS.md +4 -1
- package/templates/gallery/app/examples/layout.ts +2 -1
- package/templates/gallery/app/examples/todo/page.ts +3 -16
- package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
- package/templates/gallery/app/features/auth/signup/page.ts +4 -23
- package/templates/gallery/app/features/caching/page.ts +6 -6
- package/templates/gallery/app/features/file-storage/page.ts +8 -19
- package/templates/gallery/app/features/forms/page.ts +12 -38
- package/templates/gallery/app/features/layout.ts +6 -2
- package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
- package/templates/gallery/app/features/view-transitions/page.ts +1 -1
- package/templates/gallery/app/global-error.ts +7 -4
- package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
- package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
- package/templates/gallery/modules/gallery/nav.ts +1 -1
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +31 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
- package/templates/gallery/modules/todo/types.ts +15 -10
- package/templates/gallery/test/auth/auth.test.ts +31 -16
- package/templates/partials/agents-playbook-api.md +5 -0
- package/templates/partials/agents-playbook-fullstack.md +5 -0
- package/templates/scripts/clear-gallery.mjs +5 -4
- package/templates/test/hello/e2e/hello.test.ts +18 -1
|
@@ -17,6 +17,8 @@ Read this when a mutation should feel instant, when the client can predict the r
|
|
|
17
17
|
|
|
18
18
|
`optimistic(host, { source, update })` returns an `OptimisticState<State, Action>` with a `.value` getter and an `.add(payload, promise?)` method. The `source` reads the authoritative state (usually a reactive prop). The `update` reducer transforms that state with each payload. Calling `.add()` pushes an update and schedules a re-render, so `.value` reflects the optimistic state on the next paint.
|
|
19
19
|
|
|
20
|
+
**The reducer must be pure.** `.value` re-folds the whole queue on every read, so `update` runs again on each render rather than once per `.add()`. Anything it MINTS is therefore minted per render. Mint a temp id in the handler and pass it in the payload. A `crypto.randomUUID()` inside the reducer gives the pending row a different id on every read, which breaks a keyed list and anything else treating that id as stable.
|
|
21
|
+
|
|
20
22
|
```ts
|
|
21
23
|
import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
|
|
22
24
|
import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
|
|
@@ -26,12 +28,18 @@ class TodoList extends WebComponent({
|
|
|
26
28
|
}) {
|
|
27
29
|
private optimisticTodos = optimistic(this, {
|
|
28
30
|
source: () => this.todos,
|
|
29
|
-
|
|
31
|
+
// KEEP THIS REDUCER PURE. `.value` re-folds every queued update on EVERY
|
|
32
|
+
// read, not once per `.add()`, so anything minted in here is minted again
|
|
33
|
+
// on each render. A `crypto.randomUUID()` here would hand the pending row
|
|
34
|
+
// a NEW id per render, and a keyed list (`repeat(todos, t => t.id, ...)`)
|
|
35
|
+
// would tear the row down and rebuild it every update, losing focus, any
|
|
36
|
+
// in-progress transition, and DOM state. Mint the temp id in the handler
|
|
37
|
+
// and carry it in the payload, so it is stable for the life of the row.
|
|
38
|
+
update: (state, add: { tempId: string; title: string }) => [
|
|
30
39
|
...state,
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
{ id: crypto.randomUUID() as any, title, completed: false, pending: true },
|
|
40
|
+
// `createdAt` is rebuilt per read too. That is tolerable only because
|
|
41
|
+
// nothing keys on it; put it in the payload as well if anything does.
|
|
42
|
+
{ id: add.tempId, title: add.title, completed: false, createdAt: new Date(), pending: true },
|
|
35
43
|
],
|
|
36
44
|
});
|
|
37
45
|
|
|
@@ -41,14 +49,22 @@ class TodoList extends WebComponent({
|
|
|
41
49
|
if (!title) return;
|
|
42
50
|
(e.target as HTMLFormElement).reset();
|
|
43
51
|
|
|
52
|
+
// Minted ONCE here, not in the reducer. `Todo['id']` is a string (a uuid
|
|
53
|
+
// primary key), so no cast is needed. Against an auto-increment integer
|
|
54
|
+
// id there is no honest client-side value, so model the temp row instead
|
|
55
|
+
// (an optional id, or a `tempId` the row keys on) rather than casting.
|
|
56
|
+
const tempId = crypto.randomUUID();
|
|
44
57
|
const promise = createTodo({ title });
|
|
45
|
-
this.optimisticTodos.add(title, promise);
|
|
58
|
+
this.optimisticTodos.add({ tempId, title }, promise);
|
|
46
59
|
|
|
47
60
|
const result = await promise;
|
|
48
61
|
if (result.success && result.data) {
|
|
49
|
-
// Reconcile: the optimistic
|
|
50
|
-
//
|
|
51
|
-
//
|
|
62
|
+
// Reconcile: the optimistic row is never written to `this.todos`. The
|
|
63
|
+
// overlay holds only the PAYLOAD, and `update` rebuilds the row from it
|
|
64
|
+
// on each `.value` read, so this prop holds only confirmed rows. Append
|
|
65
|
+
// the server's canonical row, matching the order the `update` reducer
|
|
66
|
+
// used. (The overlay entry auto-released when the promise settled, so
|
|
67
|
+
// `.value` does not double-count it on the next paint.)
|
|
52
68
|
this.todos = [...this.todos, result.data];
|
|
53
69
|
}
|
|
54
70
|
}
|
|
@@ -62,20 +78,23 @@ class TodoList extends WebComponent({
|
|
|
62
78
|
TodoList.register('todo-list');
|
|
63
79
|
```
|
|
64
80
|
|
|
65
|
-
**Auto-release is the whole point.** Pass the action's promise as the second argument to `.add(payload, promise)`, and the update auto-releases the moment that promise settles (resolve OR reject). It uses `.finally()`, with a `.then()` fallback for thenables that lack `.finally`. No try-catch, no manual rollback, no temp
|
|
81
|
+
**Auto-release is the whole point.** Pass the action's promise as the second argument to `.add(payload, promise)`, and the update auto-releases the moment that promise settles (resolve OR reject). It uses `.finally()`, with a `.then()` fallback for thenables that lack `.finally`. No try-catch, no manual rollback, no reconciling a temp id against the real one. The handler mints a temp id for the pending row, but nothing tracks it afterwards: the overlay drops whole when the promise settles, and the authoritative row arrives from `result.data`. On failure the optimistic entry simply drops when the promise rejects.
|
|
66
82
|
|
|
67
83
|
- Multiple `.add()` calls stack independently. Each carries its own release by ID, so overlapping in-flight mutations do not clobber one another.
|
|
68
84
|
- When `update` is omitted, the payload REPLACES the state directly (`Action = State`), matching the simple `useOptimistic(setState)` pattern.
|
|
69
85
|
|
|
70
86
|
### Author the optimistic mutation as a degrade-first form
|
|
71
87
|
|
|
72
|
-
Wrap the mutation in a REAL `<form
|
|
88
|
+
Wrap the mutation in a REAL `<form>` bound to the action, then intercept it for the optimistic path. One form serves both: with JS off the browser submits and the server dispatches to that action (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler.
|
|
89
|
+
|
|
90
|
+
The SAME imported function is the form binding and the optimistic path's callee, which is what makes a degrade-first form cheap to write: there is no second wiring to keep in step.
|
|
73
91
|
|
|
74
92
|
```ts
|
|
93
|
+
import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
|
|
94
|
+
|
|
75
95
|
render() {
|
|
76
96
|
return html`
|
|
77
|
-
<form
|
|
78
|
-
<input type="hidden" name="intent" value="create"> <!-- one page action dispatches on intent -->
|
|
97
|
+
<form action=${createTodo} @submit=${this.handleSubmit}>
|
|
79
98
|
<input name="title" required>
|
|
80
99
|
<button>Add</button>
|
|
81
100
|
</form>
|
|
@@ -83,7 +102,9 @@ render() {
|
|
|
83
102
|
}
|
|
84
103
|
```
|
|
85
104
|
|
|
86
|
-
|
|
105
|
+
`method` and the enctype are supplied by the renderer, and the hidden identity field is re-inserted as the form's first child on every client render, so there is nothing to manage by hand.
|
|
106
|
+
|
|
107
|
+
When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`), or use per-button submitter server action bindings via `formaction=${action}` on submitter buttons inside a bound form (#1207). Alternatively, a form that dispatches dynamically can bind ONE action and inspect a submit button's `name="intent"`.
|
|
87
108
|
|
|
88
109
|
## Seed the list from the server for SSR plus optimistic
|
|
89
110
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
- `route.ts` HTTP handlers and `middleware.ts`, the route-handler toolkit (`json` / `readBody` / `clientIp` / the no-arg accessors), and calling one from the client with `richFetch`
|
|
8
8
|
- `metadata` and `generateMetadata` (folded in here), including image metadata routes that return a `Response`
|
|
9
9
|
- Control-flow throws: `notFound()`, `redirect()`, `forbidden()`, `unauthorized()`
|
|
10
|
-
- The no-JS
|
|
10
|
+
- The no-JS write path: a `<form>` bound to a `'use server'` action
|
|
11
11
|
- Boundaries: `error.ts`, `loading.ts`, `not-found.ts`, `forbidden.ts`, `unauthorized.ts`, and the two root-only ones
|
|
12
12
|
|
|
13
13
|
Read this when a task touches the route contract, a URL, a `<head>` tag, a redirect, a 404, or a form POST that a page owns. Sibling refs: `components.md` (anything interactive), `data-and-actions.md` (server actions, queries, validation, the `ActionResult` envelope), `auth-and-sessions.md` (`forbidden()` / `unauthorized()` flows).
|
|
@@ -32,7 +32,7 @@ export default function About() {
|
|
|
32
32
|
|
|
33
33
|
`params` and `searchParams` are awaitable AND synchronously readable (`params.id` and `await params` both work, Next.js 15/16 parity). Throw `notFound()` or `redirect(url)` to short-circuit. Reach data through a `.server.ts` query; never import the DB driver into a page.
|
|
34
34
|
|
|
35
|
-
Optional named exports: `metadata` / `generateMetadata` (below)
|
|
35
|
+
Optional named exports: `metadata` / `generateMetadata` (below) and `export const revalidate` (seconds, opts into the HTML response cache, only for a page identical for every visitor). There is no `action` export; the write path is a form bound to a `'use server'` action (below).
|
|
36
36
|
|
|
37
37
|
## Layouts (`app/**/layout.ts`)
|
|
38
38
|
|
|
@@ -41,7 +41,8 @@ The default export receives `{ children, params, searchParams, url }` and must e
|
|
|
41
41
|
```ts
|
|
42
42
|
// app/layout.ts (root)
|
|
43
43
|
import { html } from '@webjsdev/core';
|
|
44
|
-
|
|
44
|
+
import type { LayoutProps } from '@webjsdev/core';
|
|
45
|
+
export default function RootLayout({ children }: LayoutProps) {
|
|
45
46
|
return html`<html lang="en"><head></head><body>${children}</body></html>`;
|
|
46
47
|
}
|
|
47
48
|
```
|
|
@@ -114,24 +115,26 @@ Common fields: `title` (string or `{ template, default, absolute }`), `descripti
|
|
|
114
115
|
|
|
115
116
|
## Control-flow throws
|
|
116
117
|
|
|
117
|
-
From `@webjsdev/core`: throw to short-circuit a page / layout render or a
|
|
118
|
+
From `@webjsdev/core`: throw to short-circuit a page / layout render or a form-bound action.
|
|
118
119
|
|
|
119
120
|
- `notFound()` renders the nearest `not-found.ts` (nearest wins from the throwing chain).
|
|
120
|
-
- `redirect(url[, status])`. The no-status default is convention-picked at the catch site: `302` for a GET page render, `307` (method-preserving) for a
|
|
121
|
+
- `redirect(url[, status])`. The no-status default is convention-picked at the catch site: `302` for a GET page render, `307` (method-preserving) for a form-bound action. Override with `redirect(url, 308)` or `redirect(url, { status })`.
|
|
121
122
|
- `forbidden()` renders the nearest `forbidden.ts` (authenticated user lacking permission); `unauthorized()` renders the nearest `unauthorized.ts` (request not authenticated).
|
|
122
123
|
|
|
123
124
|
None of these belong in a `route.ts` (return a `Response` there). Inside a `'use server'` RPC action, return an `ActionResult` for an auth failure rather than throwing (`data-and-actions.md`).
|
|
124
125
|
|
|
125
|
-
## The no-JS write path (a
|
|
126
|
+
## The no-JS write path (a form-bound action)
|
|
126
127
|
|
|
127
|
-
A `page.
|
|
128
|
+
A page imports a `'use server'` action and binds it into the form: `<form action=${sendMessage}>`. There is no page `action` export; the binding IS the wiring. The renderer omits the `action` attribute (so the form posts to the page's own url), supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity. A non-GET/HEAD submission carrying that identity runs the action, wrapped in the segment middleware of the URL it was submitted to AND the action's own declared `middleware`. It works with JS off; with JS on the client router posts the same body to the same url and applies the response in place.
|
|
128
129
|
|
|
129
|
-
|
|
130
|
-
// app/contact/page.ts
|
|
131
|
-
import { html } from '@webjsdev/core';
|
|
132
|
-
import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
|
|
130
|
+
**Do not treat the page's segment middleware as the action's authorization gate.** An identity names the action, not the page that rendered it, so the same action is reachable by submitting to any page path (and, being a `'use server'` export, at its RPC endpoint too, where no segment middleware runs at all). That is the same reachability every server action has always had, and the removed page `action` export was the one shape where the URL really did scope the handler. Authorize the action itself: an `export const middleware = [requireAdmin]` on the action, or a check in its body, both of which run on either transport.
|
|
133
131
|
|
|
134
|
-
|
|
132
|
+
A form-bound action always receives the `FormData` as its first argument, which is where it differs from the same action called over RPC. `validate` is the typing seam: it receives that `FormData`, and its transform-return becomes the action's typed input.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// modules/contact/actions/send-message.server.ts
|
|
136
|
+
'use server';
|
|
137
|
+
export async function sendMessage(formData: FormData) {
|
|
135
138
|
const email = String(formData.get('email') || '').trim();
|
|
136
139
|
const body = String(formData.get('body') || '').trim();
|
|
137
140
|
const values = { email, body };
|
|
@@ -139,9 +142,15 @@ export async function action({ formData }: { formData: FormData }) {
|
|
|
139
142
|
if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
|
|
140
143
|
if (body.length < 10) fieldErrors.body = 'Message is too short';
|
|
141
144
|
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };
|
|
142
|
-
await
|
|
145
|
+
await deliver({ email, body });
|
|
143
146
|
return { success: true, redirect: '/contact/thanks' };
|
|
144
147
|
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
// app/contact/page.ts
|
|
152
|
+
import { html } from '@webjsdev/core';
|
|
153
|
+
import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
|
|
145
154
|
|
|
146
155
|
export default function Contact({ actionData }: {
|
|
147
156
|
actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> };
|
|
@@ -149,7 +158,7 @@ export default function Contact({ actionData }: {
|
|
|
149
158
|
const errors = actionData?.fieldErrors || {};
|
|
150
159
|
const values = actionData?.values || {};
|
|
151
160
|
return html`
|
|
152
|
-
<form
|
|
161
|
+
<form action=${sendMessage} class="flex flex-col gap-3">
|
|
153
162
|
<input name="email" type="email" value=${values.email || ''} required>
|
|
154
163
|
${errors.email ? html`<p class="text-sm text-red-600">${errors.email}</p>` : ''}
|
|
155
164
|
<textarea name="body" required>${values.body || ''}</textarea>
|
|
@@ -160,7 +169,17 @@ export default function Contact({ actionData }: {
|
|
|
160
169
|
}
|
|
161
170
|
```
|
|
162
171
|
|
|
163
|
-
How the result is read (server side): a success PRG-redirects with `303` (to a same-site `redirect` path if present, else the page's own URL); a failure re-SSRs the SAME page with `status` (default `422`) and the result on `ctx.actionData`. Failure is detected robustly (`success === false`, OR `fieldErrors` present, OR `error` present with `success !== true`), so an error is never swallowed. `result.redirect` must be a same-site local path (a single leading `/`); for a real external redirect, throw `redirect(absoluteUrl)` instead. On a plain GET render `actionData` is `undefined`. Prefer a `<form>`
|
|
172
|
+
How the result is read (server side): a success PRG-redirects with `303` (to a same-site `redirect` path if present, else the page's own URL); a failure re-SSRs the SAME page with `status` (default `422`) and the result on `ctx.actionData`. Failure is detected robustly (`success === false`, OR `fieldErrors` present, OR `error` present with `success !== true`), so an error is never swallowed. `result.redirect` must be a same-site local path (a single leading `/`); for a real external redirect, throw `redirect(absoluteUrl)` instead. On a plain GET render `actionData` is `undefined`. Prefer a bound `<form>` over `fetch` in a `@click` for any write a form can express.
|
|
173
|
+
|
|
174
|
+
Three responses that are not the happy path:
|
|
175
|
+
|
|
176
|
+
- **A submission carrying no identity is a `405` + `Allow: GET, HEAD`.** A bare `<form method="post">` binds nothing, and the page path exists but only renders, so the method is what is wrong rather than the url.
|
|
177
|
+
- **An identity whose hash no longer resolves re-renders at `422`** with a resubmit message and the submitted values on `actionData`. That is a form held open across a deploy; a 404 would discard what was typed and a silent no-op would show success for a write that never happened.
|
|
178
|
+
- **A bound action declaring `export const method = 'GET'` is a `405`.** A GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. `webjs check`'s `form-action-not-a-get-action` catches it at edit time.
|
|
179
|
+
|
|
180
|
+
The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field.
|
|
181
|
+
|
|
182
|
+
Refusals worth knowing: `formaction=${fn}` is supported only on a `<button>` inside a bound form, and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.
|
|
164
183
|
|
|
165
184
|
## Error, loading, and 404 boundaries
|
|
166
185
|
|
|
@@ -41,7 +41,11 @@ The 103 Early Hints gap costs only a small first-load latency edge where an edge
|
|
|
41
41
|
|
|
42
42
|
Behind a TLS-terminating proxy (Railway, Fly, Render, Cloudflare, nginx), both shells rewrite the request URL from `X-Forwarded-Proto` / `X-Forwarded-Host`, so `ctx.url` in a page, `req.url` in a `route.{js,ts}` handler, and every absolute URL you build from either carry the ORIGINAL scheme and host rather than the internal `http://container` hop. A comma-separated chain (a CDN in front of a load balancer) takes the value closest to the client, only `http` and `https` are accepted as a scheme, and a malformed host is ignored rather than failing the request. This was Bun-only broken before #1090, which shipped an `http://` `og:image` on an HTTPS site.
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
`WEBJS_NO_TRUST_PROXY=1` is the global switch for "nothing trusted sits in front of this container, stop believing these headers". It is read in ONE place and is ABSOLUTE across every forwarded-header read: the URL rewrite, the HSTS scheme check, the CSRF host resolution, and `clientIp` (`x-forwarded-for` / `cf-connecting-ip` / `x-real-ip`) alike. So `rateLimit({ trustProxy: true })` is inert while the flag is set, falling back to the framework-stamped peer address (its default with no option) and warning once per process. The two CAN be set in contradiction, and the direction rule is what resolves it: the flag only ever SUBTRACTS trust and the per-call option can only add it, so the flag wins. Setting both is a misconfiguration that buckets every visitor behind the proxy onto one key, which is what the warning is for. It used to miss the CSRF host, which read `X-Forwarded-Host` regardless; that gap closed in #1104. Setting it on a genuinely proxied deploy is a misconfiguration, and it now shows up as one, since the legacy no-`Sec-Fetch-Site` CSRF fallback compares `Origin` against the raw `Host` and rejects a legitimate cross-host request. The primary `Sec-Fetch-Site` path, which nearly every real browser request takes, never consults the host at all and is unaffected either way.
|
|
45
|
+
|
|
46
|
+
One limit worth knowing: the rewrite belongs to `startServer`. An app embedded through `createRequestHandler` gets the `Request` its host adapter built, so that adapter owns the correction, the same boundary that already applies to the trusted client IP.
|
|
47
|
+
|
|
48
|
+
**Anything SHARED that you derive from `ctx.url` must be keyed by the origin.** Neither Cloudflare nor Railway strips a client-supplied `X-Forwarded-Host`, they forward it, so a hostile value reaches the container through the proxy rather than around it. That is fine for a per-request response (the attacker only poisons their own), and a real problem the moment a response is shared. WebJs handles the cache it owns: the server HTML response cache (`export const revalidate`) folds `url.origin` into every cache key (#1097), so a poisoned body is unreachable from any origin the attacker does not already control. Apply the same rule to anything you cache yourself off `ctx.url`, and note the rule reaches caches WebJs does not own either. A page served `Cache-Control: public` is stored by a CDN under the CDN's OWN key, which does not include `X-Forwarded-Host`, so keying the server cache cannot protect that copy. Derive nothing origin-dependent into a page you make publicly cacheable, or pin the origin from config rather than from `ctx.url`.
|
|
45
49
|
|
|
46
50
|
## Scaffolding a Bun app
|
|
47
51
|
|
|
@@ -70,7 +70,7 @@ Avoid `@apply`: it hides which utilities a class uses and creates a second sourc
|
|
|
70
70
|
|
|
71
71
|
### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
|
|
72
72
|
|
|
73
|
-
An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
|
|
73
|
+
An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so some prefixes are still grouped coarsely and a less common pair can collide (`bg-clip-text` against `bg-primary`, `shadow-lg` against `shadow-red-500`). When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
|
|
74
74
|
|
|
75
75
|
```ts
|
|
76
76
|
// components/ui/button.ts (npx webjsdev ui add button, themed to your app)
|
|
@@ -24,6 +24,35 @@ Feature folders are primary, and the test kind is a subfolder inside the feature
|
|
|
24
24
|
|
|
25
25
|
Assert only on what the layer needs. A block that inspects only the HTTP response, the SSR HTML string, headers, or the importmap does NOT need a browser. Keep in the browser suite only blocks that genuinely need a DOM (live state via `page.evaluate`, hydration, client-router nav, slots, view transitions, streaming into the DOM, custom-element upgrade).
|
|
26
26
|
|
|
27
|
+
### A browser test that clicks a real link or submits a real form MUST cancel the default
|
|
28
|
+
|
|
29
|
+
web-test-runner aborts the whole SESSION, not one file, when the page navigates. So a test that clicks a real `<a href>` or submits a real `<form>` is a single point of failure for every browser test file you have: whenever the client router loses the race to intercept, the browser performs the real navigation and the run dies reporting `0 failed` and then exiting non-zero, which reads as an infrastructure blip rather than a test problem. Cancel the default so an interception gap fails ONE test on its own assertion.
|
|
30
|
+
|
|
31
|
+
Register the canceling listener on `window` in the BUBBLE phase. That is the last step of the propagation path, so it runs after the router's own document-level listeners, and `preventDefault()` still cancels the default action because that action runs only once dispatch completes.
|
|
32
|
+
|
|
33
|
+
**Never use the capture phase.** Capture sets `defaultPrevented` before the router ever sees the event, and the router returns immediately on that flag (the same guard that lets a component's `@click` opt out). Every guarded router test then passes while testing nothing. Capture is correct only when suppressing the router is the actual goal, for a test that exercises a menu or a drawer rather than navigation; say so in a comment when you do it, because it looks identical to the mistake. Do not reach for `stopPropagation` either, which hides the click from the router and turns the assertion into a tautology.
|
|
34
|
+
|
|
35
|
+
Cancel a form on its `submit` event, not on the submit control's `click`. The form's default action fires on submit, so canceling the click stops the form from ever submitting and the router never sees it.
|
|
36
|
+
|
|
37
|
+
Resolve the anchor from `e.composedPath()`, not `e.target.closest('a[href]')`. The listener is on `window`, so a click inside a shadow root (a `static shadow = true` component) arrives retargeted to the host, and `closest()` walks only light-tree ancestors and never finds the link, which fails open exactly where the router itself handles the case. A pure-fragment `href="#x"` link needs no guard at all, since it never navigates the page away.
|
|
38
|
+
|
|
39
|
+
There is a SECOND channel a click guard cannot reach: the router assigns `location.href` when it degrades a soft navigation, and `preventDefault` does not cancel a script assignment. Do not try to intercept `location.href` itself, which is non-configurable on Chromium, Firefox, and WebKit alike, so its setter cannot be redefined on any of them. Listen for `webjs:navigation-fallback` on `document` and assert none fired; its `cause` is the diagnosis.
|
|
40
|
+
|
|
41
|
+
It is a few lines, so write it in your suite's setup and detach it in teardown:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const onClick = (e) => {
|
|
45
|
+
for (const el of e.composedPath()) {
|
|
46
|
+
if (el instanceof HTMLAnchorElement && el.hasAttribute('href')) { e.preventDefault(); return; }
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const onSubmit = (e) => e.preventDefault();
|
|
50
|
+
window.addEventListener('click', onClick); // bubble, never capture
|
|
51
|
+
window.addEventListener('submit', onSubmit);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
(The WebJs framework repo keeps its own copy of exactly this in one shared module, `test/browser-nav-guard.js`, whose `installNavGuard()` returns `{ fallbacks, hardNavigations, remove }`. That module is framework-repo infrastructure and is not part of a scaffolded app.)
|
|
55
|
+
|
|
27
56
|
## App runners (`webjs test`)
|
|
28
57
|
|
|
29
58
|
```sh
|
|
@@ -48,11 +77,11 @@ test/
|
|
|
48
77
|
|
|
49
78
|
## The `handle()` harness (`@webjsdev/server/testing`)
|
|
50
79
|
|
|
51
|
-
`createRequestHandler({ appDir }).handle(request)` drives the FULL request pipeline (middleware, routing, SSR,
|
|
80
|
+
`createRequestHandler({ appDir }).handle(request)` drives the FULL request pipeline (middleware, routing, SSR, form-dispatched actions, server-action RPC, auth, CSRF) and returns a native `Response`. It is the same entry the framework's own suite uses, so the most realistic way to test an app is to fire a `Request` through it and assert on the `Response`, with no spawned process and no network. `@webjsdev/server/testing` ships thin builders over that `handle()`, each a few lines over native `Request` / `Response` that reuse the REAL cookie names, header names, and wire serializer. For a browser test that needs to drive the app in a real DOM, `createBrowserTestHandler()` from `@webjsdev/server/testing` exposes the same `handle()` pipeline to the WTR Chromium session.
|
|
52
81
|
|
|
53
82
|
```js
|
|
54
83
|
import { createRequestHandler } from '@webjsdev/server';
|
|
55
|
-
import { testRequest, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
|
|
84
|
+
import { testRequest, submitForm, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
|
|
56
85
|
from '@webjsdev/server/testing';
|
|
57
86
|
|
|
58
87
|
const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
|
|
@@ -77,6 +106,28 @@ const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, c
|
|
|
77
106
|
assert.equal(dash.status, 200);
|
|
78
107
|
```
|
|
79
108
|
|
|
109
|
+
### `submitForm`: the no-JS write path
|
|
110
|
+
|
|
111
|
+
Use it for any page whose form binds an action (`<form action=${myAction}>`). A bound form carries a hidden `__webjs_action` field holding the action's identity, and that field is what tells the dispatcher which action to run, so a hand-written `POST` that omits it is not a form submission at all and is answered **405**. `submitForm` renders the page, reuses the identity the server put there, and posts it back, which is exactly what a browser with JS off does.
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
import { submitForm } from '@webjsdev/server/testing';
|
|
115
|
+
|
|
116
|
+
// success is a 303 PRG
|
|
117
|
+
const ok = await submitForm(app.handle, '/signup', { name: 'Ada', email: 'ada@example.com', password: 'hunter2' });
|
|
118
|
+
assert.equal(ok.status, 303);
|
|
119
|
+
assert.equal(ok.headers.get('location'), '/dashboard');
|
|
120
|
+
|
|
121
|
+
// a failure result re-renders the SAME page at 422 with the result on actionData
|
|
122
|
+
const bad = await submitForm(app.handle, '/signup', { email: 'not-an-email' });
|
|
123
|
+
assert.equal(bad.status, 422);
|
|
124
|
+
assert.match(await bad.text(), /Enter a valid email/);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Options: `cookies` (submit as a logged-in user, paired with `loginAndGetCookies`), `match` (a string or RegExp the form's markup must contain, for a page with several forms), `index` (pick by position instead), `submitPath` (render one page, submit to another), and `headers`.
|
|
128
|
+
|
|
129
|
+
Do not hand-roll the identity scrape. When it is wrong the symptom is a 405, an assertion fails, and a surrounding `catch` reports it as a database that was never migrated, so the test goes quiet rather than red.
|
|
130
|
+
|
|
80
131
|
### `invokeActionForTest`: round-trip an action through the REAL endpoint
|
|
81
132
|
|
|
82
133
|
```js
|
|
@@ -112,9 +163,16 @@ WebJs runs on Node 24+ or Bun. The Node suite is the source of truth; an additiv
|
|
|
112
163
|
|
|
113
164
|
If your app targets Bun, Bun parity is part of the definition of done. A change to a runtime-sensitive surface (the serializer, the `node:http` vs `Bun.serve` listener and request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) is NOT done until you also run your suite under the Bun runtime (needs `bun` installed) and add a cross-runtime assertion for the touched surface.
|
|
114
165
|
|
|
166
|
+
### Writing a plain proof script that boots a server
|
|
167
|
+
|
|
168
|
+
A cross-runtime proof is often a plain assert script rather than a test file, so the SAME file runs under `node script.mjs` and `bun script.mjs`. If it boots a real server through `startServer`, know what carries its verdict: the process EXIT CODE, since nothing is collecting results for you. WebJs makes that code trustworthy: a fatal shutdown, which is where a failed top-level assertion lands on both runtimes, exits 1, and so does a drain that rejects or is still hanging at the 10s deadline. The process exits 0 in one case only, an operator-requested stop that drained cleanly with no crash on the way out, so a proof script that finishes green really did finish green. Two habits keep it that way:
|
|
169
|
+
|
|
170
|
+
- **Assert the exit code, never the logs.** These scripts conventionally pass a `quiet` logger, so anything that only logs is invisible. If your script catches its own failure, report it with an explicit `process.exit(1)` rather than a `console.error` alone, guarded as `if (import.meta.main) process.exit(1); else throw failure;`. The guard matters when a `.test.mjs` wrapper imports the script under `node --test`: an unguarded exit kills the whole single-process run and hides every other file's results, while the throw lets the harness report one failed test.
|
|
171
|
+
- **Prove the script can FAIL before you trust it passing.** Break one assertion on purpose and confirm the run exits non-zero. A proof that cannot go red is worse than no proof: it reports success forever.
|
|
172
|
+
|
|
115
173
|
## Convention validation (`webjs check`)
|
|
116
174
|
|
|
117
|
-
`npm run check` is the correctness validator. Every rule catches code that is wrong to ship
|
|
175
|
+
`npm run check` is the correctness validator. Every rule catches code that is wrong to ship, a crash, a security leak, a reactive prop that silently stops re-rendering, or a type-strip failure. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
|
|
118
176
|
|
|
119
177
|
## What NOT to do
|
|
120
178
|
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
- TypeScript at runtime with **no build step**: `.ts` / `.mts` is stripped in place, not compiled.
|
|
6
6
|
- **Erasable syntax only** (`erasableSyntaxOnly: true`) and the exact list of banned constructs, with their allowed rewrites.
|
|
7
7
|
- The **pluggable stripper** (Node 24+ built-in vs `amaro` on Bun) and how the browser gets stripped source.
|
|
8
|
-
- **Full-stack type safety**: server-action types
|
|
9
|
-
- **Typing pages, layouts, and route handlers** with `PageProps` / `LayoutProps` / `RouteHandlerContext` and the generated route union (`
|
|
8
|
+
- **Full-stack type safety**: the rule (derive the type at every boundary, never `unknown` / `any`), server-action types flowing to the call site, and the `import type` carrier rule across the `.server` boundary.
|
|
9
|
+
- **Typing pages, layouts, and route handlers** with `PageProps` / `LayoutProps` / `RouteHandlerContext` and the generated route union (`npx webjsdev types`).
|
|
10
10
|
|
|
11
11
|
Read this when you are writing `.ts` in a WebJs app, hit a strip-time 500, or want typed params and hrefs. For action signatures and the serializer wire see `data-and-actions.md`. For typing reactive props see `components.md`.
|
|
12
12
|
|
|
@@ -77,6 +77,75 @@ Prefer explicit `.ts` extensions in imports. A `.js` specifier pointing at a `.t
|
|
|
77
77
|
|
|
78
78
|
## Full-stack type safety
|
|
79
79
|
|
|
80
|
+
### The rule: derive the type, never `unknown` or `any`
|
|
81
|
+
|
|
82
|
+
Every value crossing an app boundary in WebJs already has a type you can reach, so reach for it. Writing `unknown` or `any` at a boundary throws away the framework's central guarantee, and it does so silently: the app still runs, the checker just stops helping. Nothing catches this for you. Both are valid TypeScript, so `webjs check` will not flag them (it is a correctness tool, and this is a convention), and `tsc --noEmit` passes happily.
|
|
83
|
+
|
|
84
|
+
`unknown` is the more dangerous of the two, because it reads as the safe choice. It is safe only in the sense that it forces a narrow at the read site. As a boundary type it is exactly as uninformative as `any`, and it pushes a cast into every consumer.
|
|
85
|
+
|
|
86
|
+
The ladder, in the order to climb it:
|
|
87
|
+
|
|
88
|
+
| Boundary | Write this | Not this |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| A database row | `export type Todo = typeof todos.$inferSelect` in `db/schema.server.ts` (`$inferInsert` for a write) | a hand-written interface that drifts from the schema |
|
|
91
|
+
| That row inside a shipping component | `import type { Todo } from '#db/schema.server.ts'` | `any[]`, or re-declaring the shape by hand |
|
|
92
|
+
| A server action's input | a named `interface CreatePostInput` | `input: unknown` / `input: any` |
|
|
93
|
+
| A server action's result | `Promise<ActionResult<Post>>` | `Promise<any>` |
|
|
94
|
+
| `export const validate` | `(input: unknown)`, narrowed in the body, returning `data` that `satisfies` the action's input type | `(input: any)`, which un-types the returned `data` too |
|
|
95
|
+
| A page | `PageProps<'/blog/[slug]'>` | `{ params: Record<string, any> }` |
|
|
96
|
+
| A layout | `LayoutProps` (whose `children` is a `TemplateResult`) | `{ children: unknown }` |
|
|
97
|
+
| A route handler's 2nd argument | `RouteHandlerContext<'/api/users/[id]'>` | `{ params: any }` |
|
|
98
|
+
| A client-router href | the generated `Route` union (`npx webjsdev types`; `npm run dev` also emits it) | a bare `string` |
|
|
99
|
+
| A reactive property | `prop<Student>(Object)`, `prop<Tag[]>(Array)` | `prop(Object)` plus a cast at every read |
|
|
100
|
+
| An optimistic temp row | the pending shape on the row type (`pending?: boolean`) | `as any` on the temp id |
|
|
101
|
+
|
|
102
|
+
One flow, end to end, with no escape hatch anywhere in it:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// db/schema.server.ts
|
|
106
|
+
export const posts = table('posts', { id: uuidPk(), title: text().notNull(), body: text().notNull() });
|
|
107
|
+
export type Post = typeof posts.$inferSelect; // derived, never hand-written
|
|
108
|
+
|
|
109
|
+
// modules/posts/actions/create-post.server.ts
|
|
110
|
+
'use server';
|
|
111
|
+
import type { Post } from '#db/schema.server.ts'; // type-only: erased before the browser sees it
|
|
112
|
+
export interface CreatePostInput { title: string; body: string }
|
|
113
|
+
export const validate = (input: unknown) => { /* narrows, returns data satisfying CreatePostInput */ };
|
|
114
|
+
export async function createPost(input: CreatePostInput): Promise<ActionResult<Post>> { /* ... */ }
|
|
115
|
+
|
|
116
|
+
// modules/posts/components/new-post.ts
|
|
117
|
+
import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
118
|
+
const r = await createPost({ title, body });
|
|
119
|
+
if (r.success && r.data) r.data.title; // Post.title: string, checked at the call site
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The payoff is not stylistic. A typo in `r.data.titel`, a renamed column, a changed action signature, and a page reading `params.slugg` are all compile errors in that version and all silent runtime `undefined` in the `unknown` version.
|
|
123
|
+
|
|
124
|
+
### Where `unknown` is still the right type
|
|
125
|
+
|
|
126
|
+
There are two cases, and only the first is about narrowing.
|
|
127
|
+
|
|
128
|
+
**Case one: a value that genuinely has no type yet, at the moment before it is narrowed.**
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
// app/api/webhook/route.ts
|
|
132
|
+
export async function POST(req: Request) {
|
|
133
|
+
const body: unknown = await req.json(); // correct: nothing has vouched for this yet
|
|
134
|
+
const parsed = parseWebhook(body); // narrowed on the very next line
|
|
135
|
+
return Response.json({ ok: parsed.kind });
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The test is what the next line does. Correct `unknown` here is narrowed immediately by a parse, a validator, or a type guard, and the narrowed type is what the rest of the function sees. Anything standing at that boundary qualifies: a `route.ts` handler's `await req.json()` (above), a `catch (e)` binding (already `unknown` under `strict`), an action's `export const validate`, and any validator function those delegate to. In a validator, returning `data` that `satisfies` the action's input type is what carries a real type into the action body. See `data-and-actions.md`.
|
|
140
|
+
|
|
141
|
+
**Case two: a parameter of YOUR OWN helper that forwards its argument into an `html` template hole, which is NOT narrowed.** A hole renders a string, a number, a `TemplateResult`, an array of those, a directive result, or nothing, so a helper like `lede(content: unknown)` is correctly typed and `TemplateResult` alone would be too narrow. Narrow it only when the helper genuinely accepts one shape (`backLink(href: string, ...)`).
|
|
142
|
+
|
|
143
|
+
This case is about a value YOU accept, never one the framework hands you. A layout's `children` also ends up in a hole, but the framework already types it (`LayoutProps.children` is a `TemplateResult`), so `{ children: unknown }` is a discarded type, not this carve-out.
|
|
144
|
+
|
|
145
|
+
Everywhere else `unknown` is a missing type, not a safe one: surviving into a return type, a component prop, a layout's `children`, or an action signature is the shape to fix.
|
|
146
|
+
|
|
147
|
+
`any` gets no carve-out at all in app code. It does not defer checking, it disables it, so a validator typed `(input: any)` un-types everything downstream of the call.
|
|
148
|
+
|
|
80
149
|
### Server actions type-check automatically
|
|
81
150
|
|
|
82
151
|
Calling a server action from a client component resolves at type-check time to the action's real source file. The runtime stub swap is invisible to the checker, and the RPC serializer makes runtime match the types (`Date` stays `Date`, `Map` stays `Map`, `BigInt` stays `BigInt`; see `data-and-actions.md` for the full supported set).
|
|
@@ -40,10 +40,13 @@ So the loop is: `add` the component, then query `ui <name>` (MCP) or
|
|
|
40
40
|
|
|
41
41
|
## Setup and resolution
|
|
42
42
|
|
|
43
|
-
- `npx webjsdev ui init` writes `components.json`, `lib/utils.ts`, and the CSS design
|
|
43
|
+
- `npx webjsdev ui init` writes `components.json`, `lib/utils/cn.ts`, and the CSS design
|
|
44
44
|
tokens the helpers render against (`--background`, `--foreground`,
|
|
45
45
|
`--destructive`, ...). It HARD-FAILS if the tokens cannot be written, so a
|
|
46
|
-
clean exit means the kit is styled.
|
|
46
|
+
clean exit means the kit is styled. Re-running it is safe on an existing
|
|
47
|
+
project: it keeps the aliases, stylesheet path, and base color already in
|
|
48
|
+
`components.json` and leaves an edited `cn.ts` / `dom.ts` alone, so it only
|
|
49
|
+
fills in what is missing (`--overwrite` resets them instead). `add` self-heals the tokens if they go
|
|
47
50
|
missing.
|
|
48
51
|
- Resolution is LOCAL-FIRST: `init` / `add` / `list` / `view` read the registry
|
|
49
52
|
that ships inside the installed `@webjsdev/ui`, with no network. This pins you
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
- [ ] Unit tests added/updated (`webjs test` passes)
|
|
8
8
|
- [ ] E2E tests added/updated for user-facing changes (`webjs test --e2e` passes)
|
|
9
9
|
- [ ] `webjs check` passes (no convention violations)
|
|
10
|
+
- [ ] `webjs doctor` passes (project health; it fails on whatever `webjs.doctor.gate` marks `error`, plus the hard `NODE_VERSION` / `TSCONFIG_ERASABLE` checks)
|
|
10
11
|
|
|
11
12
|
## Definition of done
|
|
12
13
|
|
|
@@ -34,6 +34,19 @@ jobs:
|
|
|
34
34
|
cache: npm
|
|
35
35
|
- run: npm ci
|
|
36
36
|
- run: npm run check
|
|
37
|
+
# Project health, on top of the correctness checks. WHICH findings are
|
|
38
|
+
# fatal is your call, declared in package.json under
|
|
39
|
+
# "webjs": { "doctor": { "gate": { "<CODE>": "off" | "warn" | "error" } } },
|
|
40
|
+
# so this step and a local `npm run doctor` always agree. The scaffold
|
|
41
|
+
# starts with UNMARKED_ASSET_LINKS at error (an un-versioned /public url
|
|
42
|
+
# is a real deploy-staleness bug). Two checks fail with no gate entry at
|
|
43
|
+
# all, NODE_VERSION and TSCONFIG_ERASABLE, because either would 500 the
|
|
44
|
+
# app at runtime; everything else stays a warn and cannot fail this job.
|
|
45
|
+
# Widen or narrow the gate in package.json, not
|
|
46
|
+
# here. Deliberately not --strict: the git-hook, env-drift, vendor-pin,
|
|
47
|
+
# and framework-resolve checks are environment-shaped and would fail a
|
|
48
|
+
# perfectly healthy runner.
|
|
49
|
+
- run: npm run doctor
|
|
37
50
|
|
|
38
51
|
unit:
|
|
39
52
|
name: Unit + integration (node --test)
|
package/templates/AGENTS.md
CHANGED
|
@@ -31,11 +31,37 @@ This is what separates a working app from a broken one.
|
|
|
31
31
|
|
|
32
32
|
## Type everything (all templates)
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
Full-stack type safety is what the `.server.ts` boundary buys you: a client
|
|
35
|
+
component importing a server action resolves to that action's real signature at
|
|
36
|
+
type-check time, with no build step and no code generation in between. So
|
|
37
|
+
DERIVE the type at every boundary instead of widening it:
|
|
38
|
+
|
|
39
|
+
- A database row: `export type Todo = typeof todos.$inferSelect` in
|
|
40
|
+
`db/schema.server.ts` (`$inferInsert` for a write), carried into a
|
|
41
|
+
browser-shipped component with `import type` (erased before it reaches the
|
|
42
|
+
browser, so it does not trip the server-import boundary).
|
|
43
|
+
- An action's input: a named `interface`. Its result: `ActionResult<T>`.
|
|
44
|
+
Narrow with `if (result.success && result.data)`.
|
|
45
|
+
- Routing files: `PageProps<'/blog/[slug]'>`, `LayoutProps`,
|
|
46
|
+
`RouteHandlerContext`, all from `@webjsdev/core`. Run `npx webjsdev types`
|
|
47
|
+
for the typed `Route` union and per-route `params`.
|
|
48
|
+
- A reactive property: `prop<Student>(Object)`, `prop<Tag[]>(Array)`.
|
|
49
|
+
|
|
50
|
+
Never reach for `any` or a loose `as any` cast, and do not reach for `unknown`
|
|
51
|
+
either just because it looks safer. `unknown` is right for a payload nothing
|
|
52
|
+
has vouched for yet, narrowed on the very next line (a `route.ts` `await
|
|
53
|
+
req.json()`, an action's `export const validate` or a validator it delegates
|
|
54
|
+
to, a `catch` binding), and for a parameter of YOUR OWN helper that forwards
|
|
55
|
+
into an `html` template hole (a hole renders a string, a number, a
|
|
56
|
+
`TemplateResult`, or an array of those, so `TemplateResult` alone is too
|
|
57
|
+
narrow). That second case is about a value you accept, never one the framework
|
|
58
|
+
already types. Everywhere else it is a missing type, not a safe one: `unknown`
|
|
59
|
+
that survives into a return type, a component prop, a layout's `children`, or
|
|
60
|
+
an action signature is the shape to fix.
|
|
61
|
+
Nothing enforces this (both are valid TypeScript, so `webjs check` and `tsc`
|
|
62
|
+
pass either way), which is exactly why it is written down. The full ladder,
|
|
63
|
+
with an end-to-end example, is in
|
|
64
|
+
`.agents/skills/webjs/references/typescript.md`.
|
|
39
65
|
|
|
40
66
|
Keep server-only code (database drivers, secrets, `node:*` builtins) in
|
|
41
67
|
`.server.ts` modules. There are exactly two kinds:
|
package/templates/CONVENTIONS.md
CHANGED
|
@@ -24,8 +24,11 @@ is the short version.
|
|
|
24
24
|
teaches the same and survives the clear), run `npm run gallery:clear` to shed
|
|
25
25
|
the showcase, then grow the app in place. `AGENTS.md` has the full
|
|
26
26
|
template-specific playbook.
|
|
27
|
+
- **Derive types at every boundary.** Rows from `$inferSelect`, action inputs
|
|
28
|
+
from an `interface`, routing files from `PageProps` / `LayoutProps`. Never
|
|
29
|
+
`any`, and never `unknown` where a real type exists.
|
|
27
30
|
- **Progressive enhancement is the default.** Pages render as HTML, `<a>`
|
|
28
|
-
navigates, `<form
|
|
31
|
+
navigates, a `<form action=${importedAction}>` submits, all with JavaScript off; opt into
|
|
29
32
|
interactivity per behaviour inside a component.
|
|
30
33
|
- **Commit per logical unit** as soon as it is complete, and never push to `main`.
|
|
31
34
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { html } from '@webjsdev/core';
|
|
2
|
+
import type { LayoutProps } from '@webjsdev/core';
|
|
2
3
|
import { backLink } from '#lib/utils/ui.ts';
|
|
3
4
|
|
|
4
5
|
// Shared layout for every gallery example app under /examples/*. It adds the same
|
|
5
6
|
// slim "back to the gallery" link the feature demos get, so an example is never a
|
|
6
7
|
// dead end. A non-root layout, so it never writes the document shell.
|
|
7
|
-
export default function ExamplesLayout({ children }:
|
|
8
|
+
export default function ExamplesLayout({ children }: LayoutProps) {
|
|
8
9
|
// An example app has no sidebar, so center it in a focused reading column
|
|
9
10
|
// (the root centers the whole page; this narrows the example within it).
|
|
10
11
|
return html`
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
// A THIN route adapter: app/ is routing only. It fetches the initial data
|
|
2
2
|
// (server-side) via the 'use server' query and renders the interactive
|
|
3
|
-
// component
|
|
4
|
-
//
|
|
3
|
+
// component. All the real logic lives in modules/todo/, including the action
|
|
4
|
+
// the component's forms bind to. This is the idiomatic app-thin +
|
|
5
|
+
// modules-logic split.
|
|
5
6
|
import { html } from '@webjsdev/core';
|
|
6
7
|
import type { Metadata } from '@webjsdev/core'; // Metadata is a @webjsdev/core type
|
|
7
8
|
import { pageHeading } from '#lib/utils/ui.ts';
|
|
8
9
|
import { listTodos } from '#modules/todo/queries/list-todos.server.ts';
|
|
9
|
-
import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
|
|
10
|
-
import { toggleTodo } from '#modules/todo/actions/toggle-todo.server.ts';
|
|
11
|
-
import { deleteTodo } from '#modules/todo/actions/delete-todo.server.ts';
|
|
12
10
|
import '#modules/todo/components/todo-app.ts';
|
|
13
11
|
|
|
14
12
|
export const metadata: Metadata = { title: 'Todo (optimistic UI) | examples' };
|
|
@@ -22,14 +20,3 @@ export default async function TodoExample() {
|
|
|
22
20
|
<todo-app .todos=${todos}></todo-app>
|
|
23
21
|
`;
|
|
24
22
|
}
|
|
25
|
-
|
|
26
|
-
// No-JS write path: the component's <form>s post here; with JS the component
|
|
27
|
-
// intercepts and mutates optimistically instead. Success is a 303 PRG.
|
|
28
|
-
export async function action({ formData }: { formData: FormData }) {
|
|
29
|
-
const intent = String(formData.get('intent') ?? '');
|
|
30
|
-
const id = String(formData.get('id') ?? '');
|
|
31
|
-
if (intent === 'create') return createTodo({ title: String(formData.get('title') ?? '') });
|
|
32
|
-
if (intent === 'toggle') return toggleTodo({ id });
|
|
33
|
-
if (intent === 'delete') return deleteTodo({ id });
|
|
34
|
-
return { success: false as const, error: 'Unknown action.', status: 400 };
|
|
35
|
-
}
|