@webjsdev/cli 0.10.40 → 0.10.41

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 (69) hide show
  1. package/bin/webjs.js +4 -46
  2. package/lib/create.js +282 -479
  3. package/lib/doctor.js +1 -38
  4. package/package.json +5 -1
  5. package/templates/.agents/rules/workflow.md +61 -271
  6. package/templates/.agents/skills/webjs/SKILL.md +226 -0
  7. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +220 -0
  8. package/templates/.agents/skills/webjs/references/built-ins.md +200 -0
  9. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +204 -0
  10. package/templates/.agents/skills/webjs/references/components.md +167 -0
  11. package/templates/.agents/skills/webjs/references/data-and-actions.md +187 -0
  12. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +170 -0
  13. package/templates/.agents/skills/webjs/references/optimistic-ui.md +128 -0
  14. package/templates/.agents/skills/webjs/references/routing-and-pages.md +158 -0
  15. package/templates/.agents/skills/webjs/references/runtime.md +80 -0
  16. package/templates/.agents/skills/webjs/references/service-worker.md +78 -0
  17. package/templates/.agents/skills/webjs/references/styling.md +123 -0
  18. package/templates/.agents/skills/webjs/references/testing.md +125 -0
  19. package/templates/.agents/skills/webjs/references/typescript.md +148 -0
  20. package/templates/.claude/hooks/check-server-imports.mjs +1 -1
  21. package/templates/.claude/hooks/require-tests-with-src.sh +1 -1
  22. package/templates/.claude/settings.json +0 -14
  23. package/templates/.cursorrules +21 -189
  24. package/templates/.github/copilot-instructions.md +7 -185
  25. package/templates/.github/pull_request_template.md +1 -1
  26. package/templates/AGENTS.md +59 -1494
  27. package/templates/CLAUDE.md +0 -1
  28. package/templates/CONVENTIONS.md +32 -1383
  29. package/templates/GEMINI.md +11 -0
  30. package/templates/gallery/app/apple-icon.ts +0 -1
  31. package/templates/gallery/app/examples/todo/page.ts +0 -1
  32. package/templates/gallery/app/features/async-render/page.ts +0 -1
  33. package/templates/gallery/app/features/boundaries/page.ts +0 -1
  34. package/templates/gallery/app/features/broadcast/page.ts +0 -1
  35. package/templates/gallery/app/features/caching/page.ts +0 -1
  36. package/templates/gallery/app/features/client-router/page.ts +0 -1
  37. package/templates/gallery/app/features/client-router/second/page.ts +0 -1
  38. package/templates/gallery/app/features/components/page.ts +0 -1
  39. package/templates/gallery/app/features/directives/page.ts +0 -1
  40. package/templates/gallery/app/features/env/page.ts +0 -1
  41. package/templates/gallery/app/features/file-storage/page.ts +0 -1
  42. package/templates/gallery/app/features/forms/page.ts +0 -1
  43. package/templates/gallery/app/features/metadata/page.ts +0 -1
  44. package/templates/gallery/app/features/optimistic-ui/page.ts +0 -1
  45. package/templates/gallery/app/features/rate-limit/page.ts +0 -1
  46. package/templates/gallery/app/features/route-handler/page.ts +0 -1
  47. package/templates/gallery/app/features/routing/page.ts +0 -1
  48. package/templates/gallery/app/features/server-actions/page.ts +0 -1
  49. package/templates/gallery/app/features/service-worker/page.ts +0 -1
  50. package/templates/gallery/app/features/sessions/page.ts +0 -1
  51. package/templates/gallery/app/features/websockets/page.ts +0 -1
  52. package/templates/gallery/app/global-error.ts +0 -1
  53. package/templates/gallery/app/global-not-found.ts +0 -1
  54. package/templates/gallery/app/icon.ts +0 -1
  55. package/templates/gallery/app/manifest.ts +0 -1
  56. package/templates/gallery/app/opengraph-image.ts +0 -1
  57. package/templates/gallery/app/robots.ts +0 -1
  58. package/templates/gallery/app/sitemap.ts +0 -1
  59. package/templates/gallery/app/twitter-image.ts +0 -1
  60. package/templates/public/favicon.svg +5 -0
  61. package/templates/public/sw.js +1 -1
  62. package/templates/scripts/clear-gallery.mjs +95 -0
  63. package/lib/clear-placeholders.js +0 -98
  64. package/lib/design-bar.js +0 -67
  65. package/templates/.claude/hooks/design-review-before-stop.sh +0 -36
  66. package/templates/.claude/hooks/route-skills.sh +0 -35
  67. package/templates/.claude/skills/webjs-design-review/SKILL.md +0 -84
  68. package/templates/LAYOUT-REFERENCE.md +0 -96
  69. package/templates/lib/utils/ui.ts +0 -83
@@ -0,0 +1,170 @@
1
+ # Muscle-Memory Gotchas
2
+
3
+ ## What This Covers
4
+
5
+ - The Next.js patterns that LOOK right in WebJs but break, because WebJs borrows Next's file-based routing shape but not its execution model (no RSC, no `'use client'` split): `redirect()` in a route handler, `fetch()` in a page, `<Link>`, `NEXT_PUBLIC_`, `await params`.
6
+ - The Lit patterns that break WebJs SSR or reactivity, because WebJs is HTML-first (real HTML first paint, JS opt-in per behaviour) not JS-first: `static properties` / the `@property()` decorator, class-field initializers, browser globals in `render()`, fetching in `connectedCallback`, interpolation into `<style>`.
7
+ - The WebJs-shaped fix for each, with short code.
8
+
9
+ Read this when a pattern feels familiar from Next.js or Lit but you are not sure it transfers. For the component runtime see `components.md`; for the routing surface see `routing-and-pages.md`. The one difference underneath everything: pages and layouts render server-only and never hydrate, and the one client boundary is a `WebComponent` custom element.
10
+
11
+ ---
12
+
13
+ ## Coming from Next.js
14
+
15
+ ### `'use client'` does nothing; `'use server'` is a file boundary, not a component annotation
16
+
17
+ There is no RSC render tree and no server/client component split. Interactivity lives in a `WebComponent` island that hydrates per element. A page or layout cannot be interactive in its own markup (an `@click` in a page template is dropped at SSR). `'use server'` is real, but it is the RPC plus source-protection directive at the top of a `*.server.ts` file, not a component annotation. Apply it to an action file, never to a component or page.
18
+
19
+ ### `redirect()` throws, and it is illegal in a route handler
20
+
21
+ In Next, `redirect()` works in Server Components, Actions, and Route Handlers alike. In WebJs, `redirect()` and `notFound()` throw a control-flow sentinel that the SSR page pipeline and the action pipeline catch. They are valid in page functions, layouts, and server actions. They are NOT valid in a `route.ts` handler, where the throw goes uncaught and returns a 500 (the `no-redirect-in-api-route` check flags this).
22
+
23
+ ```ts
24
+ // route.ts WRONG: redirect() is uncaught here.
25
+ export async function GET() { redirect('/login'); }
26
+ // route.ts RIGHT: return a real redirect Response.
27
+ export async function GET() { return Response.redirect(new URL('/login', req.url), 303); }
28
+ ```
29
+
30
+ Do NOT throw `redirect()` from a page `action` to bounce a form POST either. The method-preserving 307 default re-POSTs the body and re-runs the mutation. Return an `ActionResult` with a `redirect` field instead (a 303 PRG), or throw only for a real external redirect.
31
+
32
+ ### Reads are server actions, not `fetch()` in a Server Component
33
+
34
+ Next fetches by calling `fetch()` or an ORM directly inside an async Server Component. WebJs has no Server Components, so fetch server data in the page function (server-only) and pass it down, or fetch in a component via an async `render()` (the resolved data is in the first paint), or a `'use server'` GET action.
35
+
36
+ ```ts
37
+ // WRONG: hand-written fetch to your own endpoint.
38
+ const res = await fetch('/api/users');
39
+ // RIGHT: importing a 'use server' action IS the API (the import becomes an RPC stub).
40
+ import { getUsers } from '#modules/users/queries/get-users.server.ts';
41
+ const users = await getUsers();
42
+ ```
43
+
44
+ There is no React `cache()`, `use()`, or `unstable_cache`. Caching is the `cache()` query helper, `export const revalidate` on a page, or `export const cache` on a GET action.
45
+
46
+ ### `params` and `searchParams` are awaitable AND synchronously readable
47
+
48
+ Next 15/16 made `params` / `searchParams` Promises. WebJs supports BOTH, so either muscle memory is correct.
49
+
50
+ ```ts
51
+ export default async function User({ params, searchParams }: PageProps<'/users/[id]'>) {
52
+ const id = params.id; // sync read, works
53
+ const { id: id2 } = await params; // Next 15/16 await, also works
54
+ const tab = (await searchParams).tab;
55
+ }
56
+ ```
57
+
58
+ The runtime hands a plain object with a non-enumerable `then`, so a spread, `JSON.stringify`, and `Object.keys` see only the data keys. This holds for pages, layouts, and `route.ts` handler context alike.
59
+
60
+ ### The page default export returns a template and runs server-only
61
+
62
+ A Next page returns JSX and may embed client interactivity directly. A WebJs page default export returns a `TemplateResult` from `html` and runs only on the server. It is never re-invoked in the browser, so a signal read or `@click` in a page body does nothing after load. Put interactivity in a `WebComponent` and render its tag from the page.
63
+
64
+ ### Route handlers: named method exports, value returns auto-JSON
65
+
66
+ Export `GET` / `POST` / etc. as named async functions `(request, { params }) => Response | value` (a non-Response value is auto-JSON'd). A folder cannot have both `page` and `route`. There is no `NextRequest` / `NextResponse`; use the platform `Request` / `Response`. A WebSocket endpoint is a `WS(ws, req, { params })` export from the same file.
67
+
68
+ ### `middleware.ts` is per-segment and chainable, not one matcher config
69
+
70
+ The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middleware is in-process, chainable, and per-segment (the Remix / Koa model). There is no `export const config = { matcher }` and no single-file restriction. The default export is `async (req, next) => Response`: return a Response to short-circuit, or call `next()` and post-process. Colocate `app/admin/middleware.ts` next to the admin routes and it runs for that subtree only. An optional root `middleware.ts` runs on every request, outermost to innermost.
71
+
72
+ ### No `<Link>`, no `next/navigation`, no `next/*` libraries
73
+
74
+ Navigation is automatic. The client router auto-enables when `@webjsdev/core` loads (any page with a component), so a plain `<a href>` gets soft navigation for free. There is no `<Link>` to import and no `useRouter`. For programmatic navigation import `navigate()` / `revalidate()` from `@webjsdev/core`. There is no `next/image`, `next/font`, `next/script`, or `next/dynamic`. WebJs is no-build: use a plain `<img>`, a `<link>` / `@font-face`, a component's `static lazy = true` for viewport lazy-loading, and a dynamic `import()` where code should load lazily.
75
+
76
+ ### Server-only code: the `.server.ts` boundary, not a `server-only` package
77
+
78
+ Next poisons a client-imported module with the `server-only` package. WebJs uses the file extension: `*.server.ts` is the path-level boundary (the file router refuses to serve the source). A `'use server'` file's exports are RPC-callable; a `.server.ts` file WITHOUT `'use server'` is a server-only utility whose browser import throws at load. Reach a no-`'use server'` utility through a `'use server'` action, `route.ts`, or `middleware`, never by direct import into a shipping page or component.
79
+
80
+ ### Public env vars use `WEBJS_PUBLIC_`, not `NEXT_PUBLIC_`
81
+
82
+ `process.env.X` is server-only. To expose a value to the browser, prefix it `WEBJS_PUBLIC_` (inlined via an inline `<script>`, no build step). `NODE_ENV` is defined both sides. Reading a non-public server env var in a component is flagged by `no-server-env-in-components` (it would leak into SSR'd HTML or read as undefined after hydration).
83
+
84
+ ---
85
+
86
+ ## Coming from Lit
87
+
88
+ The disagreement underneath: Lit is JS-first (hydration is the API), WebJs is HTML-first (first paint is real HTML, JS is opt-in per interactive behaviour). JS is requested by the specific interactive holes you write: a `@click`, a `signal.set(...)`, a `.data=${richObject}` property binding, a `Task`. A plain `<a href>`, a `<form action>`, and a display-only component request no JS. The SSR contract: the pipeline runs the constructor, applies attributes, runs `willUpdate` and controllers' `hostUpdate`, reflects `reflect: true` props, then calls `render()`. Nothing past render fires server-side (not `connectedCallback`, `firstUpdated`, `updated`).
89
+
90
+ ### Fetching in `connectedCallback` or `firstUpdated`
91
+
92
+ Neither hook runs server-side, so the first paint is empty and content pops in after hydration with a layout shift. Fetch in the page function and pass the data down as props or attributes.
93
+
94
+ ```ts
95
+ // app/users/[id]/page.ts (correct)
96
+ export default async function User({ params }) {
97
+ const user = await fetchUser(params.id); // via a *.server.ts query
98
+ return html`<user-card .user=${user}></user-card>`;
99
+ }
100
+ ```
101
+
102
+ ### `Task` for initial-paint data
103
+
104
+ `Task` deliberately does not auto-run at SSR: it keeps its `INITIAL` state and runs only on hydration, so the client renders the resolved state after a flash. `Task` stays right for client-time async (interaction-triggered mutations, polling, websocket reactions). For initial-paint data, fetch in the page function, or use an async `render()` (which Lit does not have): write `const u = await getUser(this.id)` directly in the component and SSR bakes the resolved data into the first paint. A bare async `render()` blocks SSR and renders real data with no fallback. To STREAM slow data wrap the region in `<webjs-suspense .fallback=${html`...`}>`. `renderFallback()` is the OPTIONAL client re-fetch UI, never a first-paint concern.
105
+
106
+ ### Browser globals in the constructor or `render()`
107
+
108
+ `window.matchMedia`, `localStorage`, `navigator`, `document.querySelector`, and layout reads crash SSR (the instance has no DOM). The constructor is for pure-JS init. Browser APIs belong in `connectedCallback` or later (client-only by construction). Flagged by `no-browser-globals-in-render`.
109
+
110
+ ```ts
111
+ // wrong
112
+ constructor() { super(); this.dark = window.matchMedia('(prefers-color-scheme: dark)').matches; }
113
+ // right
114
+ constructor() { super(); this.dark = false; }
115
+ connectedCallback() {
116
+ super.connectedCallback();
117
+ this.dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
118
+ }
119
+ ```
120
+
121
+ The attribute methods (`getAttribute` / `setAttribute` / `hasAttribute`), the event methods, and `attachInternals()` ARE backed by a server shim, so reading an attribute in `render()` is safe. Only the genuinely DOM-backed members (`classList`, `querySelector`, `attachShadow`, `getBoundingClientRect`, `focus`) throw.
122
+
123
+ ### Top-level imports of browser-only libraries
124
+
125
+ `import Chart from 'chart.js'` or any library that touches `window` at import time crashes SSR, because the page module loads on the server. Use a dynamic `import()` inside `connectedCallback` for client-only behaviour, or wrap server work in a `.server.ts` file.
126
+
127
+ ```ts
128
+ connectedCallback() {
129
+ super.connectedCallback();
130
+ import('chart.js').then(({ Chart }) => { this.chart = new Chart(this.canvas, this.config); });
131
+ }
132
+ ```
133
+
134
+ ### Class-field initializers for reactive properties
135
+
136
+ A class-field initializer (`student: Student = { ... }`) compiles to an assignment after `super()` that uses `[[Define]]` and overwrites the reactive accessor the base class installed, silently breaking reactivity. Declare the prop in the factory and set its default in the constructor after `super()`. Flagged by `reactive-props-no-class-field`.
137
+
138
+ ```ts
139
+ class StudentCard extends WebComponent({ student: prop<Student>(Object) }) {
140
+ constructor() { super(); this.student = { name: '', email: '' }; }
141
+ }
142
+ ```
143
+
144
+ ### The `@property()` decorator and a `static properties` block
145
+
146
+ The `@property()` decorator is banned by the erasable-TS invariant (decorators are non-erasable, they would force a build step). A `static properties = { ... }` block THROWS at runtime (`no-static-properties`). The single replacement for both is the declare-free base-class factory `WebComponent({ ... })`, with the `prop()` helper carrying options.
147
+
148
+ ### Expecting shadow DOM and reaching for scoped CSS
149
+
150
+ Lit defaults to shadow DOM, so `static styles = css` scopes automatically. WebJs defaults to light DOM. A `static styles` block without `static shadow = true` does nothing useful and any inline `<style>` with bare class names leaks globally. The webjs-shaped fix is Tailwind utilities, which apply directly in light DOM. Reach for `static shadow = true` plus `static styles` only when scoped CSS genuinely belongs in a shadow root, or prefix every selector with the tag name if authoring vanilla light-DOM CSS.
151
+
152
+ ### `:host { display: block }` on a light-DOM component
153
+
154
+ A custom element is `display: inline` by default, so a block container collapses. In Lit you fix this with `:host { display: block }`, which works because Lit is shadow-DOM-first. A light-DOM WebJs component has no shadow root, so there is no `:host` to write. There is nothing to do: the framework already defaults every light-DOM host to `display: block` via a low-priority `@layer webjs-host` rule, overridable by any Tailwind utility (`class="flex"` wins). A shadow-DOM component (`static shadow = true`) still sets `:host { display: block }` in `static styles` itself, exactly like Lit.
155
+
156
+ ### Interpolating into a `<style>` or `<script>` inside a component
157
+
158
+ In Lit a binding inside `<style>` works. In a WebJs component it fails silently after hydration: the server emits the interpolated content (first paint looks right), but the client drops the raw-text hole and rebuilds the element EMPTY, so the styles vanish. Use `static styles` (shadow) or Tailwind (light DOM). A fully static `<style>` with no `${}` is fine. Flagged by `no-interpolation-in-raw-text-element`. Note the exception: pages and layouts never hydrate, so a page's `<style>${STYLES}</style>` is a legitimate pattern.
159
+
160
+ ### Reordering a `.map()` list needs a keyed `repeat()`
161
+
162
+ A plain `.map()` list reconciles in place and preserves node identity on item-level updates (drag-and-drop, focus, caret, and input state all survive), so it is fine for append-only or update-in-place lists. What it does NOT do is keyed reordering: reconciliation is positional, so on a middle insert or a reorder the nodes stay put and their contents are rewritten. When a list reorders or splices in the middle and each item owns DOM state that must move with it, use `repeat(items, (i) => i.id, template)` from `@webjsdev/core/directives`, exactly as in Lit.
163
+
164
+ ### `ContextProvider` for server-known data
165
+
166
+ Context providers publish on connect via `hostConnected`, which does not run at SSR, so descendants read the default (or undefined) during SSR and re-render on hydration with a content shift. For server-known data (session, user, theme, locale, feature flags), pass it through props from the page function. Reserve `ContextProvider` for client-time concerns (interaction state, focus management, transient UI).
167
+
168
+ ### Vanilla DOM instead of Lit idioms
169
+
170
+ WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `signal` over a `state: true` prop for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule.
@@ -0,0 +1,128 @@
1
+ # Optimistic UI
2
+
3
+ ## What This Covers
4
+
5
+ - The declarative `optimistic(host, { source, update })` API (preferred) with `.add(payload, promise)` auto-release
6
+ - The imperative `optimistic(signal, value, action)` API for simple boolean flips
7
+ - When optimistic UI is appropriate, and when to skip it
8
+ - Why you never hand-roll try-catch, cache-and-restore, or temp-ID reconciliation
9
+
10
+ Read this when a mutation should feel instant, when the client can predict the result of a create/update/delete/like/toggle/reorder before the server confirms it. Sibling refs: `data-and-actions.md` (the server actions and the `ActionResult` envelope these calls invoke), `components.md` (the `WebComponent` host, reactive props, and signals these APIs attach to).
11
+
12
+ ## The Idea
13
+
14
+ `optimistic()` from `@webjsdev/core` shows a mutation's expected result IMMEDIATELY (the UI feels instant), runs the real server action, and ROLLS BACK automatically on failure. It is the default for every user-facing mutation where the client can construct the expected result from the input. **Never write manual try-catch, cache-and-restore, or temp-ID reconciliation** when `optimistic()` covers the pattern.
15
+
16
+ ## Declarative API (preferred)
17
+
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
+
20
+ ```ts
21
+ import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
22
+ import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
23
+
24
+ class TodoList extends WebComponent({
25
+ todos: prop<Todo[]>(Array),
26
+ }) {
27
+ private optimisticTodos = optimistic(this, {
28
+ source: () => this.todos,
29
+ update: (state, title: string) => [
30
+ ...state,
31
+ // A client-only placeholder id for the pending row; the real id arrives
32
+ // from the server on reconcile, so the `as any` cast on this temp row is
33
+ // fine (the row is dropped when the promise settles).
34
+ { id: crypto.randomUUID() as any, title, completed: false, pending: true },
35
+ ],
36
+ });
37
+
38
+ async handleSubmit(e: SubmitEvent) {
39
+ e.preventDefault();
40
+ const title = new FormData(e.target as HTMLFormElement).get('title') as string;
41
+ if (!title) return;
42
+ (e.target as HTMLFormElement).reset();
43
+
44
+ const promise = createTodo({ title });
45
+ this.optimisticTodos.add(title, promise);
46
+
47
+ const result = await promise;
48
+ if (result.success && result.data) {
49
+ // Reconcile: the optimistic entry has ALREADY auto-released (the promise
50
+ // settled), so `this.todos` holds only confirmed rows here. Append the
51
+ // server's canonical row, matching the order the `update` reducer used.
52
+ this.todos = [...this.todos, result.data];
53
+ }
54
+ }
55
+
56
+ render() {
57
+ return html`<ul>${this.optimisticTodos.value.map(todo => html`
58
+ <li class=${todo.pending ? 'opacity-50' : ''}>${todo.title}</li>
59
+ `)}</ul>`;
60
+ }
61
+ }
62
+ TodoList.register('todo-list');
63
+ ```
64
+
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-ID bookkeeping. On success you reconcile the authoritative row from `result.data` (as above); on failure the optimistic entry simply drops when the promise rejects.
66
+
67
+ - Multiple `.add()` calls stack independently. Each carries its own release by ID, so overlapping in-flight mutations do not clobber one another.
68
+ - When `update` is omitted, the payload REPLACES the state directly (`Action = State`), matching the simple `useOptimistic(setState)` pattern.
69
+
70
+ ## Seed the list from the server for SSR plus optimistic
71
+
72
+ For a page that server-renders a list AND lets the user add to it optimistically, let ONE component own both the list and the form, and seed it from the page through a `.prop` hole (a DOM property that round-trips through SSR on custom elements). The list is then fully server-rendered on first paint (readable with JS off) and re-renders optimistically on each add. A separate static list in the page would not update on an optimistic add.
73
+
74
+ ```ts
75
+ // app/notes/page.ts (runs server-only; awaits the data so it is in the first paint)
76
+ import { html } from '@webjsdev/core';
77
+ import '#modules/notes/components/note-composer.ts'; // registers <note-composer>
78
+ import { listNotes } from '#modules/notes/queries/list-notes.server.ts';
79
+
80
+ export default async function NotesPage() {
81
+ const notes = await listNotes();
82
+ // .notes=${notes} seeds the component; the list SSRs through the component.
83
+ return html`<note-composer .notes=${notes}></note-composer>`;
84
+ }
85
+ ```
86
+
87
+ The component reads that seeded prop as its `optimistic()` `source`, so `source: () => this.notes` is both the SSR list and the base for optimistic additions.
88
+
89
+ ## Imperative API (simple boolean flips)
90
+
91
+ For a boolean toggle where the value itself is the mutation (like, follow, pin), `optimistic(signal, value, action)` is a thin wrapper over the signal primitive.
92
+
93
+ ```ts
94
+ import { signal, optimistic } from '@webjsdev/core';
95
+ import { likePost } from '#modules/posts/actions/like-post.server.ts';
96
+
97
+ const liked = signal(false);
98
+ // in an @click handler:
99
+ const result = await optimistic(liked, true, () => likePost(postId));
100
+ // `liked` flips to true instantly. If likePost THROWS or returns
101
+ // { success: false }, `liked` rolls back to its prior value: the throw
102
+ // re-throws, and the { success: false } result is returned so you can
103
+ // read its error / fieldErrors. On success the optimistic value stays;
104
+ // reconcile to the authoritative value from `result` if you need it.
105
+ ```
106
+
107
+ It rolls back on a thrown error OR an `ActionResult` `{ success: false }` envelope, and never on success. It is client-only (it mutates a signal), so a component importing it is never elided as a display-only component.
108
+
109
+ ## When Optimistic UI Is Appropriate
110
+
111
+ - Todo items, comments, posts, likes, follows, toggles, reorders, renames, status changes.
112
+ - Any mutation where the client can construct the expected result from the input.
113
+ - CRUD operations where the server returns the same shape the client already has.
114
+
115
+ ## When To Skip It
116
+
117
+ - The result is unpredictable (AI-generated content, server-computed values the client cannot guess).
118
+ - The mutation has side effects the user must wait for (payment processing, email sending, OAuth).
119
+ - The action validates against data that may have changed server-side (unique constraints, race conditions).
120
+ - The mutation is destructive and irreversible with no undo (confirm-first UX is better).
121
+
122
+ ## Rules
123
+
124
+ 1. Default to `optimistic()` for every predictable user-facing mutation. Instant UI, automatic rollback.
125
+ 2. Prefer the declarative `.add(payload, promise)` form for list mutations. Pass the promise so release is automatic.
126
+ 3. Use the imperative `optimistic(signal, value, action)` form only for a boolean flip whose value is the mutation.
127
+ 4. Never hand-roll try-catch, cache-and-restore, or temp-ID reconciliation when one of these APIs covers the pattern.
128
+ 5. Reconcile the authoritative result from the returned `ActionResult` after the promise settles when you need the server's canonical row.
@@ -0,0 +1,158 @@
1
+ # Routing and Pages
2
+
3
+ ## What This Covers
4
+
5
+ - Pages, layouts, and where the HTML shell comes from
6
+ - Dynamic (`[param]`), catch-all (`[...rest]`), and optional catch-all (`[[...rest]]`) segments, route groups, private folders
7
+ - `route.ts` HTTP handlers and `middleware.ts`
8
+ - `metadata` and `generateMetadata` (folded in here)
9
+ - Control-flow throws: `notFound()`, `redirect()`, `forbidden()`, `unauthorized()`
10
+ - The no-JS page `action` write path
11
+ - Boundaries: `error.ts`, `loading.ts`, `not-found.ts`, `forbidden.ts`, `unauthorized.ts`, and the two root-only ones
12
+
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).
14
+
15
+ ## The Execution Model (read this first)
16
+
17
+ Pages and layouts run **only on the server** to produce HTML. They do NOT hydrate, so their own markup cannot be interactive (an `@click` in a page template is dropped at SSR, a signal read in a page body never re-renders). They still LOAD in the browser so imported components register. Put every interactive behaviour in a component.
18
+
19
+ `route.ts` is the one routing file that is NOT isomorphic: a server-only HTTP handler, never shipped to the client.
20
+
21
+ ## Pages (`app/**/page.ts`)
22
+
23
+ The default export is a possibly-async function receiving `{ params, searchParams, url, actionData }`. It returns a `TemplateResult`; it never calls `render()` itself.
24
+
25
+ ```ts
26
+ // app/about/page.ts
27
+ import { html } from '@webjsdev/core';
28
+ export default function About() {
29
+ return html`<h1>About</h1>`;
30
+ }
31
+ ```
32
+
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
+
35
+ Optional named exports: `metadata` / `generateMetadata` (below), `export const revalidate` (seconds, opts into the HTML response cache, only for a page identical for every visitor), and `export const action` (the write path, below).
36
+
37
+ ## Layouts (`app/**/layout.ts`)
38
+
39
+ The default export receives `{ children, params, searchParams, url }` and must embed `children`. Layouts nest by folder; `metadata` merges with the deepest winning.
40
+
41
+ ```ts
42
+ // app/layout.ts (root)
43
+ import { html } from '@webjsdev/core';
44
+ export default function RootLayout({ children }: { children: unknown }) {
45
+ return html`<html lang="en"><head></head><body>${children}</body></html>`;
46
+ }
47
+ ```
48
+
49
+ Only the **root layout** (`app/layout.ts` exactly) MAY write `<!doctype>` / `<html>` / `<head>` / `<body>`; the framework splices in the importmap, modulepreload, title, and meta. Non-root layouts and pages MUST NOT write the shell (the `shell-in-non-root-layout` rule). If the root layout omits the shell, the framework auto-emits `<!doctype><html lang="en"><head></head><body>`.
50
+
51
+ ## Dynamic and catch-all routes
52
+
53
+ ```ts
54
+ // app/users/[id]/page.ts
55
+ import { html } from '@webjsdev/core';
56
+ import { getUser } from '#modules/users/queries/get-user.server.ts';
57
+ export default async function User({ params }: { params: { id: string } }) {
58
+ const user = await getUser(params.id); // via a server query, never the DB directly
59
+ return html`<h1>${user.name}</h1>`;
60
+ }
61
+ ```
62
+
63
+ - `[param]/page.ts` dynamic segment, read via `params.param`.
64
+ - `[...rest]/page.ts` catch-all, `[[...rest]]/page.ts` optional catch-all.
65
+ - `(group)/...` route group: the folder is NOT in the URL but still scopes layout / error.
66
+ - `_private/...` private folder: ignored by the router.
67
+
68
+ ## Route handlers (`app/**/route.ts`)
69
+
70
+ Named async exports per HTTP method, each `(Request, { params }) => Response | value` (a non-Response value auto-JSONs). A folder cannot have both `page.ts` and `route.ts`.
71
+
72
+ ```ts
73
+ // app/api/health/route.ts
74
+ export async function GET() {
75
+ return { ok: true };
76
+ }
77
+ ```
78
+
79
+ **NEVER throw `redirect()` / `notFound()` / `forbidden()` inside a `route.ts` handler** (an uncaught throw is a generic 500). Return a real response instead: `return Response.redirect(url, 303)` for a redirect, `return new Response('Not Found', { status: 404 })` for a 404. A `route.ts` is also NOT covered by the action CSRF check, so authenticate every mutating endpoint, validate, and rate-limit. Export `WS(ws, req, { params })` from the same file for a WebSocket endpoint.
80
+
81
+ ## Middleware (`middleware.ts`)
82
+
83
+ Optional root-level plus per-segment. The default export is `async (req, next) => Response`. Return a Response to short-circuit, or call `next()` and post-process. Per-segment middleware applies to its subtree, outermost to innermost.
84
+
85
+ ## Metadata and `generateMetadata`
86
+
87
+ A page exports `metadata` (static) or `generateMetadata(ctx)` (request-scoped, takes precedence). Values flow into `<head>` at SSR and merge across nested layouts (deeper wins). Type both with `Metadata`; `MetadataContext` types the argument. The surface is Next.js-compatible.
88
+
89
+ ```ts
90
+ import type { Metadata, MetadataContext } from '@webjsdev/core';
91
+
92
+ export const metadata: Metadata = { title: 'Home', description: 'Welcome' };
93
+
94
+ export async function generateMetadata(ctx: MetadataContext): Promise<Metadata> {
95
+ return { title: `Post: ${ctx.params.slug}`, metadataBase: new URL(ctx.url).origin };
96
+ }
97
+ ```
98
+
99
+ Common fields: `title` (string or `{ template, default, absolute }`), `description`, `keywords`, `metadataBase` (resolves relative URLs in `openGraph` / `twitter` / `alternates` / `icons`), `openGraph`, `twitter`, `robots`, `alternates.canonical`, `icons`, `manifest`, and `jsonLd` (schema.org structured data, single object or array, HTML-safe-escaped automatically). `viewport`, `themeColor`, and `colorScheme` may also be set via a split `export const viewport = { ... }`. `cacheControl` is emitted as a response HEADER (not a `<meta>`); pages default to `no-store`, and a `public` value enables conditional GET (a weak `ETag` + `304`). See https://docs.webjs.dev for the full field list.
100
+
101
+ ## Control-flow throws
102
+
103
+ From `@webjsdev/core`: throw to short-circuit a page / layout render or a page `action`.
104
+
105
+ - `notFound()` renders the nearest `not-found.ts` (nearest wins from the throwing chain).
106
+ - `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 page action. Override with `redirect(url, 308)` or `redirect(url, { status })`.
107
+ - `forbidden()` renders the nearest `forbidden.ts` (authenticated user lacking permission); `unauthorized()` renders the nearest `unauthorized.ts` (request not authenticated).
108
+
109
+ 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`).
110
+
111
+ ## The no-JS write path (a page `action`)
112
+
113
+ A `page.ts` may export an `action` beside its default render function. A non-GET/HEAD submission to the page's own URL runs it, wrapped in the page's segment middleware. It works with JS off; with JS on the client router applies the response in place.
114
+
115
+ ```ts
116
+ // app/contact/page.ts
117
+ import { html } from '@webjsdev/core';
118
+ import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
119
+
120
+ export async function action({ formData }: { formData: FormData }) {
121
+ const email = String(formData.get('email') || '').trim();
122
+ const body = String(formData.get('body') || '').trim();
123
+ const values = { email, body };
124
+ const fieldErrors: Record<string, string> = {};
125
+ if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
126
+ if (body.length < 10) fieldErrors.body = 'Message is too short';
127
+ if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };
128
+ await sendMessage({ email, body });
129
+ return { success: true, redirect: '/contact/thanks' };
130
+ }
131
+
132
+ export default function Contact({ actionData }: {
133
+ actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> };
134
+ }) {
135
+ const errors = actionData?.fieldErrors || {};
136
+ const values = actionData?.values || {};
137
+ return html`
138
+ <form method="POST" class="flex flex-col gap-3">
139
+ <input name="email" type="email" value=${values.email || ''} required>
140
+ ${errors.email ? html`<p class="text-sm text-red-600">${errors.email}</p>` : ''}
141
+ <textarea name="body" required>${values.body || ''}</textarea>
142
+ ${errors.body ? html`<p class="text-sm text-red-600">${errors.body}</p>` : ''}
143
+ <button type="submit">Send</button>
144
+ </form>
145
+ `;
146
+ }
147
+ ```
148
+
149
+ 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>` + page action over `fetch` in a `@click` for any write a form can express.
150
+
151
+ ## Error, loading, and 404 boundaries
152
+
153
+ - `error.ts` default-exports `({ error, ...ctx }) => TemplateResult`; catches sibling-page and deeper render errors, innermost wins (prod sends only `error.message`).
154
+ - `loading.ts` wraps the sibling page in `Suspense` with an immediately-flushed fallback.
155
+ - `not-found.ts` / `forbidden.ts` / `unauthorized.ts` render the nearest matching boundary for the thrown control-flow signal.
156
+ - Root-only (in `app/` exactly): `global-error.ts` is the app-wide catch-all after nested `error` boundaries are exhausted and renders its OWN `<!doctype><html><body>` (returned verbatim, so keep it static HTML with no components or hydration). `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.
157
+
158
+ Metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `apple-icon.ts`, `opengraph-image.ts`, `twitter-image.ts`) live at app root or static segments and default-export a possibly-async function; `sitemap()` / `sitemapIndex()` from `@webjsdev/server` serialize spec-valid XML.
@@ -0,0 +1,80 @@
1
+ # Runtime: Node and Bun
2
+
3
+ ## What This Covers
4
+
5
+ - Running a WebJs app on **Node 24+** or **Bun**, and why the app source you write is identical on either.
6
+ - The three things that actually differ under the hood (the listener shell, the TypeScript stripper, a handful of built-ins), and the one feature gap (103 Early Hints on Bun).
7
+ - Scaffolding a Bun-flavored app and the `bun --bun run dev` / `start` commands.
8
+ - Where Deno fits (planned, not yet supported).
9
+
10
+ Read this when you are choosing a runtime, deploying, debugging a runtime-specific difference, or a scaffold emitted `bun.lock` and you want to know what changed. For the TypeScript stripping mechanics see `typescript.md`. For SQLite, caching, and other built-ins see `built-ins.md`. For the cross-runtime test matrix see `testing.md`.
11
+
12
+ ## The app source is identical
13
+
14
+ There is nothing runtime-specific in the app you write. The same `app/`, `modules/`, `components/`, and `db/` files run byte-for-byte on Node and Bun, and the bytes the browser fetches are identical either way. WebJs picks the runtime shell at boot through a runtime-neutral seam inside its server (`node:http` on Node, `Bun.serve` on Bun), so nothing in your code branches on the runtime.
15
+
16
+ Pick a runtime from the deploy target, not the code. Default to Node unless you specifically want Bun's faster listener or a Bun-native deploy image. You do not import a runtime adapter, set a flag in your pages, or handle Node and Bun differently anywhere in application code. The one place the runtime is chosen is the scaffold (`--runtime`) plus the run command (`bun --bun` versus `npm`), covered below.
17
+
18
+ ### Where the difference actually lives
19
+
20
+ Three seams pick a runtime-specific implementation, all inside the framework, none in your app:
21
+
22
+ - **The listener.** `startServer` selects the `node:http` request shell on Node and a native `Bun.serve` shell on Bun. Both parse the request, run middleware, dispatch to your routes, and stream the response through the same downstream pipeline, so an SSR page, a server action RPC, and a route handler behave identically.
23
+ - **The type stripper.** WebJs serves `.ts` / `.tsx` as ES modules by erasing the types in place with no bundler. On Node that is the built-in `module.stripTypeScriptTypes`; on Bun it is `amaro` (the same engine, byte-identical and position-preserving so stack traces still point at the right line). Either way your TypeScript must be erasable (see `typescript.md`).
24
+ - **A few built-ins.** SQLite, hot reload, and WebSockets each bind to the runtime's native primitive (see the table).
25
+
26
+ ## Node vs Bun at a glance
27
+
28
+ | Area | Node 24+ | Bun |
29
+ |---|---|---|
30
+ | Install | `npm install` | `bun install` |
31
+ | Run | `npm run dev` / `npm run start` | `bun run dev` / `bun run start` |
32
+ | Listener | `node:http` shell | native `Bun.serve` (faster on the listening path only, not end-to-end, because SSR render dominates a real page) |
33
+ | TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
34
+ | SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
35
+ | Hot reload | `node --watch` | `bun --hot` |
36
+ | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
37
+ | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
38
+
39
+ The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes.
40
+
41
+ ## Scaffolding a Bun app
42
+
43
+ `webjs create <name>` defaults to Node. Add `--runtime bun` for a Bun-flavored app (or run `bun create webjs <name>`, which auto-detects Bun from the invoking package manager):
44
+
45
+ ```sh
46
+ webjs create my-app --runtime bun
47
+ ```
48
+
49
+ `--runtime` is orthogonal to `--template`, so it re-flavors any of full-stack, saas, or api. A Bun scaffold emits a `bun.lock`, a pure `oven/bun:1` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node.
50
+
51
+ ## Running on Bun
52
+
53
+ A Bun app installs with `bun install` like Node, then its `dev` / `start` / `db` scripts force `bun --bun` so the server itself runs on Bun:
54
+
55
+ ```sh
56
+ bun install
57
+ bun run dev # or: bun run start
58
+ ```
59
+
60
+ `bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun, selecting the native `Bun.serve` listener and `amaro` type stripping. The app's dependencies resolve from `node_modules` exactly as on Node. The `start.before` migrate step (`webjs db migrate`) runs under Bun too. Commit the `bun.lock` for reproducible, offline installs. The scaffold's Bun Dockerfile runs `bun install` and serves via `CMD ["bun", "--bun", "run", "start"]`.
61
+
62
+ ## Deploying either runtime
63
+
64
+ Production runs `npm run start` (Node) or `bun run start` (Bun), which serves the source directly with no build step. Both speak plain HTTP/1.1, so put a reverse proxy or platform edge in front for TLS and HTTP/2 (production perf leans on HTTP/2 multiplexing plus `modulepreload` hints, not a bundle). A `start.before` migrate runs first on both runtimes.
65
+
66
+ The scaffold ships a matching Dockerfile per runtime: a Node image for the default, a pure `oven/bun:1` image for `--runtime bun`. Commit the lockfile the runtime uses (`package-lock.json` for Node, `bun.lock` for Bun) so the deploy install is reproducible and offline.
67
+
68
+ ## SQLite busy_timeout
69
+
70
+ Both `node:sqlite` and `bun:sqlite` default `busy_timeout` to 0, so a contended write throws `database is locked` immediately. The generated connection sets `PRAGMA busy_timeout = 5000` plus `PRAGMA journal_mode = WAL` on the raw client before Drizzle wraps it, on both runtime branches, so you get a sane 5-second wait instead of an instant failure. This is already wired in the scaffold's `db/connection.server.ts`.
71
+
72
+ ## Verifying a runtime-sensitive change
73
+
74
+ Most app code needs no runtime-specific testing, because it does not touch a runtime seam. If you DO change something runtime-sensitive (the serializer, a stream, `node:crypto`, low-level request handling, anything that behaves differently under `Bun.serve` versus `node:http`), prove it on both runtimes. The Node suite is the source of truth, and an additive Bun matrix re-runs the runtime-sensitive tests under Bun. See `testing.md` for the cross-runtime matrix and the `test/bun/**` assertions.
75
+
76
+ For an ordinary feature (a page, an action, a component) a single-runtime test is enough, since the source is identical on either runtime.
77
+
78
+ ## Future runtimes
79
+
80
+ The listener seam is runtime-neutral, so a `Deno.serve` shell (or an embedded adapter) slots in at the same point when added. Edge runtimes with no filesystem are a separate, later target. Until then, treat Deno as planned, not supported, and build on Node or Bun.
@@ -0,0 +1,78 @@
1
+ # Service Worker (offline, opt-in)
2
+
3
+ ## What This Covers
4
+
5
+ - The opt-in progressive-enhancement service worker that UI scaffolds ship dormant (`public/sw.js` plus `public/offline.html`).
6
+ - Why it is progressive-enhancement-safe: the worker registers only from JS, so the JavaScript-disabled baseline is unchanged.
7
+ - How to enable it (the registration snippet in the root layout), what it caches, and the offline fallback.
8
+ - Cache versioning tied to the deploy, updating the worker, and removing it.
9
+
10
+ Read this when you want an offline experience or an asset cache in a WebJs app, or you see `public/sw.js` in a scaffold and want to know what it does. For CSP nonces see `built-ins.md`. For the root layout see `routing-and-pages.md`. For the content-hash `?v=` asset URLs it relies on see `built-ins.md`.
11
+
12
+ ## What ships and why it is safe
13
+
14
+ WebJs's UI scaffolds (full-stack and saas, not the api template) ship a hand-authored service worker at `public/sw.js` and an offline fallback at `public/offline.html`. Both ship **dormant**: they do nothing until the app registers the worker, and the worker only ever registers from JavaScript. So with JS off no worker exists, and pages, links, and forms behave exactly as before. It is opt-in and adds an offline experience plus an asset cache without changing the no-JS baseline.
15
+
16
+ This is a thin, hand-readable worker built directly on the native Service Worker and Cache Storage APIs. There is no Workbox, no precache framework, and no bundler step, matching WebJs's no-build, close-to-web-standards posture. The file is yours to edit, not a framework internal.
17
+
18
+ ## Enabling it
19
+
20
+ Add this inline script to the root layout's `<head>` (`app/layout.ts`). It registers after load and only when JS is present, so it stays progressive-enhancement-safe:
21
+
22
+ ```html
23
+ <script>
24
+ if ('serviceWorker' in navigator) {
25
+ addEventListener('load', () => {
26
+ // Tie the worker version to the deploy: read the importmap build id and
27
+ // register /sw.js?v=<build>, so a new deploy registers a "new" worker.
28
+ const tag = document.querySelector('script[type="importmap"]');
29
+ const build = (tag && tag.dataset.webjsBuild) || '';
30
+ navigator.serviceWorker.register('/sw.js' + (build ? '?v=' + build : ''));
31
+ });
32
+ }
33
+ </script>
34
+ ```
35
+
36
+ With WebJs's CSP enabled (`webjs.csp` in package.json), stamp the nonce on the script. Read it in the layout with `import { cspNonce } from '@webjsdev/core'` and emit `<script nonce="${cspNonce()}">...`.
37
+
38
+ ## Scope and caching strategy
39
+
40
+ Registered from `/sw.js`, the worker's scope is the site root (`/`), so it sees every navigation and same-origin asset request. Your worker file lives at `public/sw.js`, and although most `public/*` assets serve at `/public/<name>`, the framework serves this one (and `public/offline.html`) at the SITE ROOT with a `Service-Worker-Allowed: /` header, so `register('/sw.js')` resolves to a 200 and the worker controls the whole origin.
41
+
42
+ - **Navigations are network-first.** The worker tries the network first, so the user sees fresh server-rendered HTML, and it caches each successful page (the SSR shell). When the network fails, it serves the cached page if you have visited it, otherwise `/offline.html`. Network-first means the cache never makes a page go stale; it is purely an offline safety net.
43
+ - **Static assets are stale-while-revalidate.** Same-origin modules (the per-file ESM the no-build runtime serves), the framework runtime under `/__webjs/core/`, vendor bundles under `/__webjs/vendor/`, and `public/` assets are served from cache when present and refreshed in the background. In production these URLs carry a `?v=<hash>` content fingerprint, so a changed file gets a new URL and the cache can never serve stale bytes.
44
+
45
+ **Never cached:** non-GET requests (writes), cross-origin requests, the action RPC endpoint (`/__webjs/action/`), and the dev-only `/__webjs/events` (SSE) and `/__webjs/reload.js`. Keeping writes and RPC off the cache means the worker can never serve a stale mutation result or replay a POST, so correctness is unaffected whether the worker is active or not.
46
+
47
+ ## The offline fallback page
48
+
49
+ `public/offline.html` is a plain, self-contained HTML page the worker serves only when a navigation fails and no cached copy of that URL exists. Treat it as the app's offline chrome and edit it to match the app's branding. It ships as a minimal placeholder in the scaffold. Because it renders with no network and no module system, keep it static: no component tags, no importmap dependency, inline any styles it needs.
50
+
51
+ ## Versioning ties to the deploy
52
+
53
+ The cache name is `webjs-<build>`, where `<build>` is the `?v=` query the registration passes (the importmap build id from `data-webjs-build`). When a deploy changes the build id:
54
+
55
+ 1. the page registers `/sw.js?v=<new-build>`, a different worker URL, so the browser fetches and installs the new worker;
56
+ 2. the new worker's `activate` deletes every cache whose name is not the current `webjs-<new-build>`, evicting the prior deploy's cache.
57
+
58
+ So a deploy refreshes the offline cache automatically, with no manual cache busting. Without a `?v=` (a dev registration, say), the cache name is `webjs-dev`.
59
+
60
+ ## Updating the worker
61
+
62
+ The browser re-checks `/sw.js` on navigation and replaces the worker when its bytes change. Because the registration URL carries the build id, a deploy always changes that URL and triggers the update. The worker calls `skipWaiting()` plus `clients.claim()`, so a new version takes control promptly. To change the caching strategy, edit `public/sw.js`.
63
+
64
+ ## Removing it
65
+
66
+ Delete the registration snippet (and optionally `public/sw.js` and `public/offline.html`). To also un-register an already-installed worker on clients, ship this for one release:
67
+
68
+ ```js
69
+ navigator.serviceWorker.getRegistrations().then(rs => rs.forEach(r => r.unregister()));
70
+ ```
71
+
72
+ Or rely on the worker's own update lifecycle to phase it out.
73
+
74
+ ## Verifying it
75
+
76
+ After adding the registration snippet, load the app, then open the browser devtools Application panel and confirm a worker is registered and activated for the origin. To exercise the offline path, visit a page (so it caches), then toggle offline in devtools and reload: a previously visited page should serve from cache, and an unvisited URL should render `public/offline.html`. Confirm the JS-off baseline is unchanged by disabling JavaScript and checking that no worker registers and navigation still works as a plain server-rendered app.
77
+
78
+ Do not register the worker until the offline experience is something you actually want, because a registered worker keeps serving cached shells to returning visitors until its cache is evicted by a new deploy build id.