@webjsdev/cli 0.10.49 → 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.
Files changed (59) hide show
  1. package/README.md +5 -3
  2. package/bin/webjs.js +133 -30
  3. package/lib/api-gallery.js +6 -7
  4. package/lib/app-name.js +208 -0
  5. package/lib/create.js +67 -18
  6. package/lib/doctor.js +479 -7
  7. package/package.json +2 -2
  8. package/templates/.agents/rules/workflow.md +9 -1
  9. package/templates/.agents/skills/webjs/SKILL.md +26 -11
  10. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
  11. package/templates/.agents/skills/webjs/references/built-ins.md +38 -6
  12. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +17 -2
  13. package/templates/.agents/skills/webjs/references/components.md +16 -2
  14. package/templates/.agents/skills/webjs/references/data-and-actions.md +42 -8
  15. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +98 -1
  16. package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
  17. package/templates/.agents/skills/webjs/references/routing-and-pages.md +35 -16
  18. package/templates/.agents/skills/webjs/references/runtime.md +5 -1
  19. package/templates/.agents/skills/webjs/references/service-worker.md +3 -1
  20. package/templates/.agents/skills/webjs/references/styling.md +30 -1
  21. package/templates/.agents/skills/webjs/references/testing.md +61 -3
  22. package/templates/.agents/skills/webjs/references/typescript.md +71 -2
  23. package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
  24. package/templates/.github/pull_request_template.md +1 -0
  25. package/templates/.github/workflows/ci.yml +13 -0
  26. package/templates/AGENTS.md +31 -5
  27. package/templates/CONVENTIONS.md +4 -1
  28. package/templates/gallery/app/examples/layout.ts +2 -1
  29. package/templates/gallery/app/examples/todo/page.ts +5 -17
  30. package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
  31. package/templates/gallery/app/features/auth/signup/page.ts +4 -23
  32. package/templates/gallery/app/features/caching/page.ts +33 -9
  33. package/templates/gallery/app/features/file-storage/page.ts +8 -19
  34. package/templates/gallery/app/features/forms/page.ts +12 -38
  35. package/templates/gallery/app/features/layout.ts +6 -2
  36. package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
  37. package/templates/gallery/app/features/server-actions/page.ts +43 -0
  38. package/templates/gallery/app/features/view-transitions/page.ts +1 -1
  39. package/templates/gallery/app/global-error.ts +7 -4
  40. package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
  41. package/templates/gallery/modules/auth/queries/current-user.server.ts +7 -5
  42. package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
  43. package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
  44. package/templates/gallery/modules/gallery/nav.ts +2 -2
  45. package/templates/gallery/modules/server-actions/actions/bump-clock.server.ts +20 -0
  46. package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
  47. package/templates/gallery/modules/server-actions/components/clock-reader.ts +98 -0
  48. package/templates/gallery/modules/server-actions/queries/read-clock.server.ts +38 -0
  49. package/templates/gallery/modules/server-actions/utils/clock.server.ts +27 -0
  50. package/templates/gallery/modules/todo/actions/submit-todo.server.ts +31 -0
  51. package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
  52. package/templates/gallery/modules/todo/queries/list-todos.server.ts +4 -2
  53. package/templates/gallery/modules/todo/types.ts +15 -10
  54. package/templates/gallery/test/auth/auth.test.ts +31 -16
  55. package/templates/partials/agents-playbook-api.md +5 -0
  56. package/templates/partials/agents-playbook-fullstack.md +5 -0
  57. package/templates/public/sw.js +11 -2
  58. package/templates/scripts/clear-gallery.mjs +11 -6
  59. package/templates/test/hello/e2e/hello.test.ts +18 -1
@@ -0,0 +1,20 @@
1
+ 'use server';
2
+ // A mutation paired with the cached GET in ../queries/read-clock.server.ts. With
3
+ // no `method` export it defaults to POST (CSRF-protected, rich request body).
4
+ //
5
+ // `invalidates` lists the cache tags to evict when the action completes. The
6
+ // server drops those tags from its own cache() entries and reports them on the
7
+ // response, so the client coordinator marks them stale and the NEXT readClock()
8
+ // bypasses its browser-cached copy instead of serving a value the mutation just
9
+ // made wrong. Without this export the read would keep answering from cache until
10
+ // its max-age elapsed.
11
+ import type { ActionResult } from '@webjsdev/server';
12
+ import { bumpReading } from '../utils/clock.server.ts';
13
+
14
+ export const invalidates = () => ['clock'];
15
+
16
+ export async function bumpClock(): Promise<ActionResult<{ reading: number }>> {
17
+ // Mutations return the ActionResult envelope, so a caller narrows one shape
18
+ // whether the write succeeded or failed.
19
+ return { success: true, data: { reading: bumpReading() } };
20
+ }
@@ -13,7 +13,7 @@
13
13
  import { test } from 'node:test';
14
14
  import assert from 'node:assert/strict';
15
15
  import { createRequestHandler, buildRouteTable, matchPage, matchApi, invokeActionForTest } from '@webjsdev/server';
16
- import { testRequest, loginAndGetCookies } from '@webjsdev/server/testing';
16
+ import { submitForm, loginAndGetCookies } from '@webjsdev/server/testing';
17
17
 
18
18
  const appDir = process.cwd();
19
19
  process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
@@ -54,10 +54,11 @@ test('an authenticated greet reads the caller off the session via actionContext(
54
54
  // migrated (run db:generate + db:migrate) rather than fail misleadingly.
55
55
  const email = `greet+${Date.now()}@example.com`;
56
56
  const password = 'password123';
57
- const signupRes = await testRequest(app.handle, '/features/auth/signup', {
58
- method: 'POST',
59
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
60
- body: new URLSearchParams({ name: 'Ada', email, password }).toString(),
57
+ // `submitForm` renders the signup page and reuses the identity the server put
58
+ // in the form's hidden `__webjs_action` field, which is what tells the
59
+ // dispatcher which action to run; a POST without it is answered 405.
60
+ const signupRes = await submitForm(app.handle, '/features/auth/signup', {
61
+ name: 'Ada', email, password,
61
62
  });
62
63
  if (signupRes.status !== 302) { t.skip('app deps/db not ready; run db:generate + db:migrate'); return; }
63
64
  const { cookies } = await loginAndGetCookies(app.handle, { email, password });
@@ -0,0 +1,98 @@
1
+ // Drives the GET-versus-mutation pair. Both are imported normally (the client
2
+ // import is rewritten to a typed RPC stub); the verbs are declared on the action
3
+ // files, so nothing here changes between a GET and a POST call site.
4
+ //
5
+ // The reads are click-driven on purpose. A read issued during SSR would resolve
6
+ // from the action seed on its first client call, so "watch it hit the network"
7
+ // would be wrong for the first paint.
8
+ import { WebComponent, signal, html } from '@webjsdev/core';
9
+ import { cardClass } from '#components/ui/card.ts';
10
+ import { buttonClass } from '#components/ui/button.ts';
11
+ import { readClock } from '../queries/read-clock.server.ts';
12
+ import { bumpClock } from '../actions/bump-clock.server.ts';
13
+
14
+ interface Row {
15
+ reading: number;
16
+ serving: number;
17
+ at: string;
18
+ clickedAt: string;
19
+ }
20
+
21
+ export class ClockReader extends WebComponent {
22
+ private rows = signal<Row[]>([]);
23
+ private busy = signal(false);
24
+ private error = signal('');
25
+
26
+ // Both timestamps are formatted here, in the visitor's timezone, so the server
27
+ // instant and the click time are directly comparable wherever this is deployed.
28
+ private clock(value: Date | string): string {
29
+ return new Date(value).toLocaleTimeString('en-US', { hour12: false });
30
+ }
31
+
32
+ async read() {
33
+ // `?disabled` only lands on the next render commit, so a second click in the
34
+ // same task would fire a second request. The signal is the real guard.
35
+ if (this.busy.get()) return;
36
+ this.busy.set(true);
37
+ this.error.set('');
38
+ const clickedAt = this.clock(new Date());
39
+ try {
40
+ // A GET action returns its value directly. The stub THROWS on a transport
41
+ // failure, so the call is guarded the same way the envelope is narrowed.
42
+ const r = await readClock();
43
+ this.rows.set([{ ...r, clickedAt }, ...this.rows.get()].slice(0, 6));
44
+ } catch {
45
+ this.error.set('The read failed. Is the server still running?');
46
+ } finally {
47
+ this.busy.set(false);
48
+ }
49
+ }
50
+
51
+ async bump() {
52
+ if (this.busy.get()) return;
53
+ this.busy.set(true);
54
+ this.error.set('');
55
+ try {
56
+ // A mutation returns the ActionResult envelope, so narrow on success.
57
+ const r = await bumpClock();
58
+ if (!r.success) this.error.set(r.error ?? 'The bump failed.');
59
+ } catch {
60
+ this.error.set('The bump failed. Is the server still running?');
61
+ } finally {
62
+ this.busy.set(false);
63
+ }
64
+ }
65
+
66
+ render() {
67
+ const rows = this.rows.get();
68
+ return html`
69
+ <div class="${cardClass()} grid gap-4 p-5 max-w-[520px]">
70
+ <div class="flex flex-wrap gap-2">
71
+ <button type="button" @click=${() => this.read()} ?disabled=${this.busy.get()}
72
+ aria-busy=${this.busy.get() ? 'true' : 'false'}
73
+ class=${buttonClass()}>Read</button>
74
+ <button type="button" @click=${() => this.bump()} ?disabled=${this.busy.get()}
75
+ aria-busy=${this.busy.get() ? 'true' : 'false'}
76
+ class=${buttonClass({ variant: 'secondary' })}>Bump the counter</button>
77
+ </div>
78
+ <!-- The results are swapped in after a click, so they are announced. -->
79
+ <div role="status" aria-live="polite">
80
+ ${rows.length
81
+ ? html`
82
+ <ul class="m-0 grid gap-1 list-none p-0 font-mono text-sm">
83
+ ${rows.map((r) => html`
84
+ <li class="flex justify-between gap-4">
85
+ <span class="text-foreground">reading #${r.reading}, served ${r.serving} at ${this.clock(r.at)}</span>
86
+ <span class="text-muted-foreground">clicked ${r.clickedAt}</span>
87
+ </li>
88
+ `)}
89
+ </ul>
90
+ `
91
+ : html`<p class="m-0 text-sm text-muted-foreground">Press Read twice in a row, then bump and read again.</p>`}
92
+ </div>
93
+ ${this.error.get() ? html`<p role="alert" class="m-0 text-sm text-destructive">${this.error.get()}</p>` : ''}
94
+ </div>
95
+ `;
96
+ }
97
+ }
98
+ ClockReader.register('clock-reader');
@@ -0,0 +1,38 @@
1
+ 'use server';
2
+ // A GET server action. An action declares its HTTP semantics through reserved
3
+ // sibling exports the framework reads statically, the same way a page declares
4
+ // `export const revalidate`.
5
+ //
6
+ // method 'GET' rides the args in the URL, is CSRF-exempt, and carries a weak
7
+ // ETag (a revalidation answers 304). It does NOT cache on its own: a
8
+ // GET with no `cache` export is `no-store`. With
9
+ // no `method` export an action is a POST mutation. (SSR seeding is
10
+ // NOT a GET feature: an action invoked during a fully buffered SSR
11
+ // render is seeded into the page whatever its verb. A streamed page
12
+ // emits no seed block at all.)
13
+ // cache the max-age in seconds, and what makes the response cacheable at
14
+ // all. The number is shorthand for the object form, so
15
+ // { maxAge: 10, swr: 30 } adds a stale-while-revalidate grace
16
+ // window. PRIVATE by default. Only pass
17
+ // { public: true } for data identical for EVERY visitor, since a
18
+ // shared cache keys the entry on the URL and args alone. Same
19
+ // safety rule as a page's `export const revalidate`.
20
+ // tags labels this cached entry so a mutation can evict it by name.
21
+ //
22
+ // One function per file is required once a file carries these config exports.
23
+ import { serveReading } from '../utils/clock.server.ts';
24
+
25
+ export const method = 'GET';
26
+ export const cache = 10;
27
+ export const tags = () => ['clock'];
28
+
29
+ export async function readClock(): Promise<{ reading: number; serving: number; at: string }> {
30
+ // `at` goes over the wire as an ISO instant, not a formatted local time: the
31
+ // card sits it next to a browser-side timestamp, and a server in another
32
+ // timezone would otherwise put the two columns hours apart.
33
+ // `serving` counts the times this body actually ran, so a repeat call answered
34
+ // from the browser cache is visible: the number does not move. It is also why
35
+ // this particular read never answers a 304, since a per-execution counter gives
36
+ // every response a different ETag. A read whose result is stable does.
37
+ return { ...serveReading(), at: new Date().toISOString() };
38
+ }
@@ -0,0 +1,27 @@
1
+ // A server-only utility (no 'use server'), like format.server.ts next to it: the
2
+ // tiny bit of state the cached GET read and the mutation that invalidates it
3
+ // share. Not RPC-callable, so a browser import would throw at load. A real app
4
+ // keeps this in the database.
5
+ //
6
+ // Two counters, because they show different things. `reading` is the domain
7
+ // value the mutation changes. `servings` counts how many times the read actually
8
+ // EXECUTED on the server, which is what makes a browser-cache hit visible: a
9
+ // response served from cache does not run this function, so the number does not
10
+ // move.
11
+ //
12
+ // Both are per-PROCESS and shared by every visitor, which is fine for a demo but
13
+ // is exactly why a real app puts this in the database. On a deployed gallery
14
+ // someone else's bump moves your reading, and your first read opens at whatever
15
+ // serving number the process is on.
16
+ let reading = 1;
17
+ let servings = 0;
18
+
19
+ export function serveReading(): { reading: number; serving: number } {
20
+ servings += 1;
21
+ return { reading, serving: servings };
22
+ }
23
+
24
+ export function bumpReading(): number {
25
+ reading += 1;
26
+ return reading;
27
+ }
@@ -0,0 +1,31 @@
1
+ 'use server';
2
+
3
+ import { createTodo } from './create-todo.server.ts';
4
+ import { toggleTodo } from './toggle-todo.server.ts';
5
+ import { deleteTodo } from './delete-todo.server.ts';
6
+
7
+ // The no-JS write path for the todo forms. Every <form> in <todo-app> binds
8
+ // THIS action, and the submit button's own `name="intent"` says which mutation
9
+ // to run, which is how one form serves several buttons.
10
+ //
11
+ // Why an intent dispatcher here rather than binding each button to its own
12
+ // action: this form carries the todo's `id` on a hidden input and needs the
13
+ // SAME id for whichever mutation runs, so one action reading both fields is the
14
+ // simpler shape. When the buttons need no shared payload, bind each one
15
+ // directly instead, with `formaction=${action}` on a <button> inside the bound
16
+ // form. The identity then rides that button's own name/value pair, so it works
17
+ // with JS off too. Two things to know: it must be a <button> (on an
18
+ // <input type="submit"> the identity would occupy `value`, which is also that
19
+ // control's visible label), and a bound submitter cannot carry its own
20
+ // `name`/`value`, which is exactly the channel `name="intent"` uses below.
21
+ //
22
+ // With JS the component intercepts the submit and calls the underlying action
23
+ // directly for the optimistic path, so this runs only with JS off.
24
+ export async function submitTodo(formData: FormData) {
25
+ const intent = String(formData.get('intent') ?? '');
26
+ const id = String(formData.get('id') ?? '');
27
+ if (intent === 'create') return createTodo({ title: String(formData.get('title') ?? '') });
28
+ if (intent === 'toggle') return toggleTodo({ id });
29
+ if (intent === 'delete') return deleteTodo({ id });
30
+ return { success: false as const, error: 'Unknown action.', status: 400 };
31
+ }
@@ -1,6 +1,6 @@
1
1
  // The interactive surface. Demonstrates: the WebComponent factory + reactive
2
2
  // prop, the DECLARATIVE optimistic() API (instant update, auto-rollback), and
3
- // progressive enhancement (each mutation is a <form> posting to the page action,
3
+ // progressive enhancement (each mutation is a <form action=${submitTodo}> bound to a server action,
4
4
  // intercepted by JS for the optimistic path). All interactivity lives in a
5
5
  // component; a page/layout cannot be interactive in its own markup.
6
6
  //
@@ -17,6 +17,7 @@ import { cn } from '#lib/utils/cn.ts';
17
17
  import { createTodo } from '../actions/create-todo.server.ts';
18
18
  import { toggleTodo } from '../actions/toggle-todo.server.ts';
19
19
  import { deleteTodo } from '../actions/delete-todo.server.ts';
20
+ import { submitTodo } from '../actions/submit-todo.server.ts';
20
21
  import type { Todo } from '../types.ts';
21
22
 
22
23
  type Op =
@@ -95,9 +96,9 @@ export class TodoApp extends WebComponent({
95
96
  </div>
96
97
  </header>
97
98
 
98
- <!-- Add: a real <form> so it works with JS off (posts to the page action);
99
- with JS, @submit intercepts and runs the optimistic path. -->
100
- <form method="post" action="" @submit=${(e: SubmitEvent) => this.add(e)}
99
+ <!-- Add: a real <form> bound to the server action, so it works with JS
100
+ off; with JS, @submit intercepts and runs the optimistic path. -->
101
+ <form action=${submitTodo} @submit=${(e: SubmitEvent) => this.add(e)}
101
102
  class="${cardClass()} flex items-center gap-2 p-2 pl-4 shadow-[0_1px_0_0_color-mix(in_oklch,var(--foreground)_5%,transparent)]">
102
103
  <input type="hidden" name="intent" value="create" />
103
104
  <input name="title" required maxlength="280" autocomplete="off" placeholder="What needs doing?"
@@ -109,7 +110,9 @@ export class TodoApp extends WebComponent({
109
110
  <ul class="list-none m-0 p-0 grid gap-2">
110
111
  ${list.length ? list.map((todo) => html`
111
112
  <li>
112
- <form method="post" action=""
113
+ <!-- One form, two submit buttons: each carries its own
114
+ name="intent", which the bound action dispatches on. -->
115
+ <form action=${submitTodo}
113
116
  class="group flex items-center gap-3 px-3 py-2.5 rounded-xl bg-card border border-border transition-colors hover:border-border-strong ${todo.pending ? 'opacity-55' : ''}">
114
117
  <input type="hidden" name="id" value=${todo.id} />
115
118
  <!-- Toggle is a submit button (degrades to a form POST no-JS); with JS
@@ -1,8 +1,10 @@
1
1
  'use server';
2
2
  // A READ is a `'use server'` action so the client (and SSR) can call it via the
3
3
  // normal import (rewritten to a typed RPC stub). `method = 'GET'` rides args in
4
- // the URL, is CSRF-exempt, and its result is SSR-seeded so the component does
5
- // not re-fetch on hydration.
4
+ // the URL and is CSRF-exempt. It declares no `cache`, so the response is
5
+ // `no-store`: the verb marks the read as safe, the `cache` export is what makes
6
+ // it cacheable. The todo page awaits this server-side and hands the rows down as
7
+ // a `.todos=${...}` property, so nothing re-fetches it on the client.
6
8
  import { db } from '#db/connection.server.ts';
7
9
  import type { Todo } from '../types.ts';
8
10
 
@@ -1,12 +1,17 @@
1
- // WHY a hand-written interface (not `typeof todos.$inferSelect` from the schema):
2
- // this type is imported by the browser-shipped <todo-app> component. A VALUE
3
- // import from `db/*.server.ts` would pin the component to a server module and
4
- // crash it at load (webjs check flags it). A browser-safe shape here is safe.
5
- // See the server-actions section in this app's AGENTS.md.
6
- export interface Todo {
7
- id: string;
8
- title: string;
9
- completed: boolean;
10
- createdAt: Date;
1
+ // The row shape is DERIVED from the schema, never re-declared: rename a column
2
+ // in db/schema.server.ts and every consumer of this type is a compile error
3
+ // instead of a silent `undefined` at runtime.
4
+ //
5
+ // `import type` is what makes that safe here. This type is imported by the
6
+ // browser-shipped <todo-app> component, and a VALUE import from a
7
+ // `db/*.server.ts` file would pin the component to a server module and crash it
8
+ // at load (webjs check's no-server-import-in-browser-module flags it). A
9
+ // type-only import is erased by the TypeScript stripper before it can reach the
10
+ // browser, so it is exempt from that rule and costs the client nothing.
11
+ import type { todos } from '#db/schema.server.ts';
12
+
13
+ type TodoRow = typeof todos.$inferSelect;
14
+
15
+ export interface Todo extends TodoRow {
11
16
  pending?: boolean; // client-only: true while an optimistic create is in flight
12
17
  }
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { dirname, resolve } from 'node:path';
5
5
 
6
6
  import { createRequestHandler } from '@webjsdev/server';
7
- import { testRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';
7
+ import { testRequest, submitForm, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';
8
8
 
9
9
  const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
10
10
 
@@ -14,7 +14,11 @@ const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
14
14
  // we detect that at the RESPONSE level (a 5xx on the dashboard) and SKIP with a
15
15
  // clear message rather than report a misleading failure. After the db is set up
16
16
  // every assertion runs for real.
17
- process.env.DATABASE_URL ||= 'file:./dev.db';
17
+ // The SAME path `.env.example` and `drizzle.config.ts` use, so `db:migrate`
18
+ // prepares the database this test connects to. Pointing somewhere else made the
19
+ // skip below permanent: it told you to run `db:migrate`, and running it
20
+ // migrated a different file.
21
+ process.env.DATABASE_URL ||= 'file:./db/dev.db';
18
22
  process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
19
23
 
20
24
  function makeHandler() {
@@ -47,24 +51,35 @@ test('signup -> login -> dashboard renders for the authenticated user', async (t
47
51
  const email = `harness+${Date.now()}@example.com`;
48
52
  const password = 'password123';
49
53
 
50
- // Real signup through the page server action (the no-JS form write-path).
51
- let canSignup = true;
54
+ // Real signup through the bound server action (the no-JS form write-path).
55
+ // `submitForm` renders the page and reuses the identity the server put in the
56
+ // form's hidden field, exactly as a browser with JS off submits it; a POST
57
+ // without that field is not a form submission and is answered 405.
58
+ // Only the REQUEST is guarded: an unmigrated table makes the action throw, and
59
+ // that is the one condition worth skipping for. The assertions below stay
60
+ // outside the try on purpose, so a genuine regression fails loudly instead of
61
+ // being caught and reported as a database that was never set up.
62
+ let signupRes: Response | null = null;
52
63
  try {
53
- const signupRes = await testRequest(app.handle, '/features/auth/signup', {
54
- method: 'POST',
55
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
56
- body: new URLSearchParams({ name: 'Harness', email, password }).toString(),
64
+ signupRes = await submitForm(app.handle, '/features/auth/signup', {
65
+ name: 'Harness', email, password,
57
66
  });
58
- // Success auto-logs-in and 302s to the dashboard (carrying the session
59
- // cookie); a 422 means validation failed. Either way the action ran.
60
- assert.ok([302, 422].includes(signupRes.status), 'signup action ran');
61
- if (signupRes.status === 302) assert.equal(signupRes.headers.get('location'), '/features/auth/dashboard', 'signup lands on the dashboard');
62
- if (signupRes.status !== 302) canSignup = false;
63
67
  } catch {
64
- // No migrated DB table -> the action throws. Skip the DB-backed assertions.
65
- canSignup = false;
68
+ signupRes = null;
69
+ }
70
+ if (!signupRes || signupRes.status >= 500) {
71
+ t.skip('no migrated DB; run db:migrate to enable the full flow');
72
+ return;
73
+ }
74
+ // Success auto-logs-in and 302s to the dashboard (carrying the session
75
+ // cookie); a 422 means validation failed. Either way the action ran.
76
+ assert.ok([302, 422].includes(signupRes.status), 'signup action ran');
77
+ if (signupRes.status === 302) {
78
+ assert.equal(signupRes.headers.get('location'), '/features/auth/dashboard', 'signup lands on the dashboard');
79
+ } else {
80
+ t.skip('signup was rejected by validation; run db:migrate to enable the full flow');
81
+ return;
66
82
  }
67
- if (!canSignup) { t.skip('no migrated DB; run db:migrate to enable the full flow'); return; }
68
83
 
69
84
  // Real login captures the genuine signed session cookie.
70
85
  const { cookies } = await loginAndGetCookies(app.handle, { email, password });
@@ -47,6 +47,10 @@ cross-origin access use the `cors()` middleware from `@webjsdev/server`; with
47
47
  Run each of these and fix what it reports, in order:
48
48
 
49
49
  - `npm run check` (correctness: no browser-import or boundary violation).
50
+ - `npm run doctor` (project health; CI runs it too). It fails on whatever
51
+ `package.json` `webjs.doctor.gate` marks `error`, plus the two hard toolchain
52
+ checks that are fatal with no gate entry, `NODE_VERSION` and
53
+ `TSCONFIG_ERASABLE`.
50
54
  - `npm run typecheck` (zero type errors).
51
55
  - `npm test` (unit tests for the endpoints and modules you built).
52
56
 
@@ -63,5 +67,6 @@ npm run start # production server
63
67
  npm test # unit + browser tests
64
68
  npm run typecheck
65
69
  npm run check # correctness checks
70
+ npm run doctor # project health (severity per check: webjs.doctor.gate)
66
71
  npm run db:generate && npm run db:migrate
67
72
  ```
@@ -98,6 +98,10 @@ accessor). Use the shorthand for primitives
98
98
  Run each of these and fix what it reports, in order:
99
99
 
100
100
  - `npm run check` (correctness: no browser-import or boundary violation).
101
+ - `npm run doctor` (project health; CI runs it too). It fails on whatever
102
+ `package.json` `webjs.doctor.gate` marks `error`, plus the two hard toolchain
103
+ checks that are fatal with no gate entry, `NODE_VERSION` and
104
+ `TSCONFIG_ERASABLE`.
101
105
  - `npm run typecheck` (zero type errors).
102
106
  - `npm test` (unit and browser tests for the features you built).
103
107
  - `npm run css:build` (compile Tailwind).
@@ -118,6 +122,7 @@ npm test # unit + browser tests
118
122
  npm run typecheck
119
123
  npm run css:build # compile Tailwind
120
124
  npm run check # correctness checks
125
+ npm run doctor # project health (severity per check: webjs.doctor.gate)
121
126
  npx webjsdev ui add <name> # copy a ui primitive into components/ui/
122
127
  npx webjsdev ui view <name> # inspect a primitive's exact signature
123
128
  npm run db:generate && npm run db:migrate
@@ -15,8 +15,17 @@
15
15
  * - Same-origin static assets (the per-file ESM modules, the framework
16
16
  * runtime under /__webjs/core/, vendor bundles, public assets) are
17
17
  * stale-while-revalidate, so a repeat visit works offline. In production
18
- * these URLs carry a ?v=<hash> content fingerprint, so a changed file gets
19
- * a new URL and the cache can never serve stale bytes.
18
+ * the FRAMEWORK-emitted URLs (modules, the core runtime, vendor bundles)
19
+ * carry a ?v=<hash> content fingerprint automatically, so a changed file
20
+ * gets a new URL and the cache cannot serve stale bytes for those.
21
+ *
22
+ * A public/ asset is the exception: its URL is fingerprinted only when you
23
+ * mark it with asset() (href=${asset('/public/app.css')}). An un-marked
24
+ * public/ URL is stable across deploys, so this worker serves the CACHED
25
+ * copy and revalidates behind it, and the first load after a deploy shows
26
+ * the OLD bytes. The cache version below will not necessarily rescue you,
27
+ * because the build id it derives from is a deploy fingerprint rather than
28
+ * a per-file content hash. Mark any public/ asset whose bytes change.
20
29
  *
21
30
  * Versioning ties to the deploy. The page registers this worker as
22
31
  * `/sw.js?v=<data-webjs-build>` (the importmap build id), so a new deploy
@@ -79,9 +79,9 @@ const galleryPaths = [
79
79
  // query), pruned with the rest of the card.
80
80
  const galleryModules = [
81
81
  'async-render', 'auth', 'broadcast', 'caching', 'client-router', 'components',
82
- 'directives', 'file-storage', 'frames', 'gallery', 'optimistic-ui', 'rate-limit',
83
- 'route-handler', 'server-actions', 'sessions', 'stream', 'streaming', 'suspense',
84
- 'todo', 'websockets',
82
+ 'directives', 'file-storage', 'forms', 'frames', 'gallery', 'optimistic-ui',
83
+ 'rate-limit', 'route-handler', 'server-actions', 'sessions', 'stream',
84
+ 'streaming', 'suspense', 'todo', 'websockets',
85
85
  ].map((m) => `modules/${m}`);
86
86
 
87
87
  let removed = 0;
@@ -173,7 +173,8 @@ export default function Home() {
173
173
  }
174
174
 
175
175
  function MINIMAL_LAYOUT() {
176
- return `import { html } from '@webjsdev/core';
176
+ return `import { html, asset } from '@webjsdev/core';
177
+ import type { LayoutProps } from '@webjsdev/core';
177
178
 
178
179
  /**
179
180
  * Root layout: the ONLY file that writes the document shell. It links the
@@ -191,10 +192,14 @@ function MINIMAL_LAYOUT() {
191
192
  // hand-written <link> in the template body is ignored by browsers).
192
193
  export const metadata = { icons: '/public/favicon.svg' };
193
194
 
194
- export default function RootLayout({ children }: { children: unknown }) {
195
+ export default function RootLayout({ children }: LayoutProps) {
195
196
  return html\`
196
197
  <meta name="color-scheme" content="light dark">
197
- <link rel="stylesheet" href="/public/tailwind.css">
198
+ <!-- asset() content-hashes the url in production, so a deploy that changes
199
+ the CSS changes the url and the framework serves it immutable for a
200
+ year. Without it this stable url can serve the PREVIOUS stylesheet
201
+ from a CDN or a service-worker cache after a deploy. -->
202
+ <link rel="stylesheet" href=\${asset('/public/tailwind.css')}>
198
203
  <style>
199
204
  html, body { margin: 0; }
200
205
  body {
@@ -12,9 +12,26 @@
12
12
  import { test, describe, before, after } from 'node:test';
13
13
  import assert from 'node:assert/strict';
14
14
  import { spawn } from 'node:child_process';
15
+ import type { ChildProcess } from 'node:child_process';
15
16
  import { createServer } from 'node:net';
16
17
 
17
- let browser: any, page: any, serverProcess: any, baseUrl: string;
18
+ // puppeteer-core is an optional dev dependency, so `import type { Browser,
19
+ // Page } from 'puppeteer-core'` does not resolve until you install it. These
20
+ // minimal structural types keep the file typed in the meantime; swap them for
21
+ // the real imports once puppeteer-core is in package.json. Reaching for `any`
22
+ // here would silently un-type every call below.
23
+ type Page = {
24
+ // goto resolves an HTTPResponse this file never reads, and modelling that
25
+ // type would mean re-declaring puppeteer's. Returning void is the honest
26
+ // narrow shape for the surface actually used.
27
+ goto(url: string, opts?: { waitUntil?: string; timeout?: number }): Promise<void>;
28
+ title(): Promise<string>;
29
+ on(event: string, handler: (e: Error) => void): void;
30
+ removeAllListeners(event: string): void;
31
+ };
32
+ type Browser = { newPage(): Promise<Page>; close(): Promise<void> };
33
+
34
+ let browser: Browser, page: Page, serverProcess: ChildProcess, baseUrl: string;
18
35
 
19
36
  function freePort(): Promise<number> {
20
37
  return new Promise((resolve, reject) => {