@webjsdev/cli 0.10.48 → 0.10.50

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 (29) hide show
  1. package/README.md +2 -2
  2. package/bin/webjs.js +1 -1
  3. package/lib/create.js +33 -12
  4. package/lib/dev-supervisor.js +5 -1
  5. package/package.json +1 -1
  6. package/templates/.agents/rules/workflow.md +1 -1
  7. package/templates/.agents/skills/webjs/SKILL.md +2 -1
  8. package/templates/.agents/skills/webjs/references/built-ins.md +14 -1
  9. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +12 -1
  10. package/templates/.agents/skills/webjs/references/components.md +7 -1
  11. package/templates/.agents/skills/webjs/references/data-and-actions.md +4 -3
  12. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +61 -0
  13. package/templates/.agents/skills/webjs/references/routing-and-pages.md +3 -1
  14. package/templates/.agents/skills/webjs/references/runtime.md +5 -0
  15. package/templates/.agents/skills/webjs/references/service-worker.md +3 -1
  16. package/templates/.agents/skills/webjs/references/styling.md +29 -0
  17. package/templates/AGENTS.md +1 -1
  18. package/templates/gallery/app/examples/todo/page.ts +2 -1
  19. package/templates/gallery/app/features/caching/page.ts +28 -4
  20. package/templates/gallery/app/features/server-actions/page.ts +43 -0
  21. package/templates/gallery/modules/auth/queries/current-user.server.ts +7 -5
  22. package/templates/gallery/modules/gallery/nav.ts +1 -1
  23. package/templates/gallery/modules/server-actions/actions/bump-clock.server.ts +20 -0
  24. package/templates/gallery/modules/server-actions/components/clock-reader.ts +98 -0
  25. package/templates/gallery/modules/server-actions/queries/read-clock.server.ts +38 -0
  26. package/templates/gallery/modules/server-actions/utils/clock.server.ts +27 -0
  27. package/templates/gallery/modules/todo/queries/list-todos.server.ts +4 -2
  28. package/templates/public/sw.js +11 -2
  29. package/templates/scripts/clear-gallery.mjs +7 -3
package/README.md CHANGED
@@ -50,7 +50,7 @@ webjs vendor pin [--download] # pin client deps to a committable importmap (off
50
50
  webjs db <generate|migrate|push|studio|seed> # drizzle-kit passthrough (+ seed)
51
51
 
52
52
  webjs ui init # initialise @webjsdev/ui in this project
53
- webjs ui add <names...> # copy components from the registry (https://ui.webjs.dev/registry/<name>.json)
53
+ webjs ui add <names...> # copy components from the registry (https://webjs.dev/ui/registry/<name>.json)
54
54
  webjs ui list # list every component available in the registry
55
55
  ```
56
56
 
@@ -60,7 +60,7 @@ helpers (`buttonClass`, `cardClass`, …) for the visual primitives and a small
60
60
  of stateful custom elements (`<ui-dialog>`, `<ui-tabs>`, `<ui-popover>`) where
61
61
  state matters. The package is a hard dependency of `@webjsdev/cli`, so installing
62
62
  the CLI gives you `webjs ui` automatically. See
63
- [https://ui.webjs.dev](https://ui.webjs.dev) for the catalogue.
63
+ [https://webjs.dev/ui](https://webjs.dev/ui) for the catalogue.
64
64
 
65
65
  ## Scaffolded templates
66
66
 
package/bin/webjs.js CHANGED
@@ -875,7 +875,7 @@ components/schema with the actual app the user requested. Use Drizzle +
875
875
  SQLite for persistence (already wired up). Never store app data in JSON
876
876
  files.
877
877
 
878
- Full docs: https://docs.webjs.dev`);
878
+ Full docs: https://webjs.dev/docs`);
879
879
  process.exit(1);
880
880
  }
881
881
  const noInstall = rest.includes('--no-install');
package/lib/create.js CHANGED
@@ -947,10 +947,13 @@ export default cors({
947
947
 
948
948
  // A GET server action (#488): a read declares its HTTP semantics via reserved
949
949
  // sibling exports the framework reads statically. 'method' makes the call ride
950
- // the URL (cacheable, ETag/304-aware, SSR-seeded on first paint); 'cache' is the
951
- // max-age in seconds (private by default, do NOT add { public: true } unless the
952
- // data is identical for EVERY visitor); 'tags' label the cached entry so a
953
- // mutation can evict it. One function per file.
950
+ // the URL and be ETag/304-aware; 'cache' is what makes the response cacheable at
951
+ // all (the max-age in seconds, private by default, do NOT add { public: true }
952
+ // unless the data is identical for EVERY visitor); 'tags' label the cached entry so a
953
+ // mutation can evict it. All three shape the RPC endpoint a browser import hits,
954
+ // so they do nothing for the in-process call in app/api/users/route.ts; they are
955
+ // here as the idiom to carry into a client that imports the action. One function
956
+ // per file.
954
957
  export const method = 'GET';
955
958
  export const cache = 30;
956
959
  export const tags = () => ['users'];
@@ -966,8 +969,12 @@ export async function listUsers() {
966
969
 
967
970
  // A mutation server action (#488). With no 'method' export it defaults to POST
968
971
  // (CSRF-protected, rich request body). 'invalidates' lists the cache tags to
969
- // evict on success, so the next listUsers() read refetches fresh instead of
970
- // serving a stale browser-cached value. One function per file.
972
+ // evict once the action completes without throwing, so a client that read
973
+ // listUsers() refetches fresh instead of serving a stale browser-cached value.
974
+ // (A returned { success: false } envelope still evicts, since the action ran.)
975
+ // It applies on the RPC endpoint a browser import hits. The route.ts in this
976
+ // template calls the function directly, which is in-process, so the config
977
+ // exports do not fire there. One function per file.
971
978
  export const invalidates = () => ['users'];
972
979
  export async function createUser(input: { name: string; email: string }) {
973
980
  // TODO: validate input, persist to database
@@ -977,6 +984,13 @@ export async function createUser(input: { name: string; email: string }) {
977
984
  await writeFile(join(appDir, 'app', 'api', 'users', 'route.ts'), `/**
978
985
  * /api/users: thin route wrapper over typed server actions.
979
986
  * Business logic lives in modules/users/, not here.
987
+ *
988
+ * These calls are in-process, so the actions' config exports do NOT apply here.
989
+ * method / cache / tags / invalidates shape the RPC endpoint a browser import
990
+ * hits, and nothing else applies them, so this endpoint sets its own headers if
991
+ * it wants caching. The namespace form of the route() adapter from
992
+ * \@webjsdev/server (import * as q; export const GET = route(q)) picks up the
993
+ * declared validate and middleware, which are the two an endpoint can reuse.
980
994
  */
981
995
  import { listUsers } from '#modules/users/queries/list-users.server.ts';
982
996
  import { createUser } from '#modules/users/actions/create-user.server.ts';
@@ -1134,7 +1148,7 @@ ${uiThemeRaw}
1134
1148
  // or shed the whole gallery at once with `gallery:clear`.
1135
1149
  await copyGallery(appDir);
1136
1150
 
1137
- await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce } from '@webjsdev/core';
1151
+ await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce, asset } from '@webjsdev/core';
1138
1152
  import '#components/theme-toggle.ts';
1139
1153
 
1140
1154
  /**
@@ -1212,9 +1226,16 @@ export default function RootLayout({ children }: { children: unknown }) {
1212
1226
  public/tailwind.css by css:build (run automatically by the dev and start
1213
1227
  tasks; in dev it is also recompiled on request when a source changes, so
1214
1228
  it never goes stale). A real stylesheet, so the app is fully styled with
1215
- JavaScript DISABLED (no in-browser compile). -->
1229
+ JavaScript DISABLED (no in-browser compile).
1230
+
1231
+ asset() adds a content hash in production (/public/tailwind.css?v=...)
1232
+ and the framework then serves it immutable for a year, so a deploy that
1233
+ changes the CSS changes the url and no browser or CDN can serve the old
1234
+ bytes. Mark the thing that FETCHES: do not wrap a <link rel="preload">
1235
+ whose asset is really fetched by an @font-face url() in the CSS, or the
1236
+ preload can never match the request and the file downloads twice. -->
1216
1237
 
1217
- <link rel="stylesheet" href="/public/tailwind.css">
1238
+ <link rel="stylesheet" href=\${asset('/public/tailwind.css')}>
1218
1239
  <style>
1219
1240
  /* Design tokens: ONE definition per colour via light-dark(LIGHT, DARK), so
1220
1241
  a palette change lands in a single place (DRY). The token NAMES are
@@ -1292,7 +1313,7 @@ export default function RootLayout({ children }: { children: unknown }) {
1292
1313
  WebJs Gallery
1293
1314
  </a>
1294
1315
  <nav class="flex items-center gap-4 text-sm" aria-label="Primary">
1295
- <a href="https://docs.webjs.dev" target="_blank" rel="noopener" class="hidden sm:inline text-muted-foreground hover:text-foreground no-underline transition-colors">Docs</a>
1316
+ <a href="https://webjs.dev/docs" target="_blank" rel="noopener" class="hidden sm:inline text-muted-foreground hover:text-foreground no-underline transition-colors">Docs</a>
1296
1317
  <a href="https://github.com/webjsdev/webjs" target="_blank" rel="noopener" class="hidden sm:inline text-muted-foreground hover:text-foreground no-underline transition-colors">GitHub</a>
1297
1318
  <theme-toggle></theme-toggle>
1298
1319
  </nav>
@@ -1363,7 +1384,7 @@ export default function Home() {
1363
1384
  <!-- Footer: docs + source -->
1364
1385
  <footer class="flex flex-col items-center gap-3">
1365
1386
  <nav class="flex items-center gap-6 text-sm text-muted-foreground" aria-label="WebJs links">
1366
- <a href="https://docs.webjs.dev" target="_blank" rel="noopener" class="inline-flex items-center gap-2 hover:text-foreground transition-colors no-underline">\${iconBook()}<span>Docs</span></a>
1387
+ <a href="https://webjs.dev/docs" target="_blank" rel="noopener" class="inline-flex items-center gap-2 hover:text-foreground transition-colors no-underline">\${iconBook()}<span>Docs</span></a>
1367
1388
  <a href="https://github.com/webjsdev/webjs" target="_blank" rel="noopener" class="inline-flex items-center gap-2 hover:text-foreground transition-colors no-underline">\${iconGithub()}<span>GitHub</span></a>
1368
1389
  </nav>
1369
1390
  <p class="text-[0.7rem] uppercase tracking-[0.15em] text-muted-foreground m-0 text-center">
@@ -1497,7 +1518,7 @@ ThemeToggle.register('theme-toggle');
1497
1518
  • Use the wired-up database (Drizzle): define real models in
1498
1519
  db/schema.server.ts, then run 'npm run db:generate' and 'npm run db:migrate'.
1499
1520
  Never store app data in JSON files, in-memory arrays, or localStorage.
1500
- • Full hosted docs are at https://docs.webjs.dev.
1521
+ • Full hosted docs are at https://webjs.dev/docs.
1501
1522
  `);
1502
1523
 
1503
1524
  // Auto-install (default). Detect the package manager from the env so
@@ -51,7 +51,11 @@ export function planDevSupervisor({ isBun, argv, noHot, exists }) {
51
51
  for (const dir of ['app', 'components', 'modules', 'lib', 'actions']) {
52
52
  if (exists(dir)) watchPaths.push('--watch-path', dir);
53
53
  }
54
- for (const f of ['middleware.ts', 'middleware.js']) {
54
+ // Every extension the server's root-middleware lookup accepts, in the same
55
+ // order. If these two lists diverge, an app gets a middleware that loads but
56
+ // never restarts the dev server when edited, which is the quiet half of the
57
+ // bug where a `middleware.ts` was loaded by neither.
58
+ for (const f of ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs']) {
55
59
  if (exists(f)) watchPaths.push('--watch-path', f);
56
60
  }
57
61
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.48",
3
+ "version": "0.10.50",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -4,7 +4,7 @@ You are working on a WebJs app (AI-first, no-build, web-components-first). This
4
4
  file is the WORKFLOW contract (git, tests, review). For HOW to build (routing,
5
5
  components, actions, styling, the framework API), read
6
6
  `.agents/skills/webjs/SKILL.md`, which routes to focused references on demand.
7
- Read `AGENTS.md` first. Full hosted docs are at https://docs.webjs.dev.
7
+ Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs.
8
8
 
9
9
  ## Grow the app in place (non-negotiable)
10
10
 
@@ -9,7 +9,7 @@ Use this skill for end-to-end WebJs app work. It helps you choose the right laye
9
9
 
10
10
  ## Full Documentation
11
11
 
12
- This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). The complete hosted docs live at https://docs.webjs.dev.
12
+ This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). The complete hosted docs live at https://webjs.dev/docs.
13
13
 
14
14
  ## What WebJs Is
15
15
 
@@ -116,6 +116,7 @@ Find the right export fast. Load the linked reference for full examples.
116
116
  - `notFound()` / `redirect(url[, status])` control-flow throws (page/layout/action only, NOT `route.ts`). `forbidden()` / `unauthorized()` render the nearest boundary.
117
117
  - `Suspense({fallback, children})` page-level streaming; `<webjs-suspense>` component-level streaming.
118
118
  - `optimistic()` optimistic UI; `navigate(url)` / `revalidate(url?)` client-router control; `connectWS` / `richFetch`.
119
+ - `asset(path)` content-hashes a `public/` url so a deploy cannot serve stale bytes (`href=${asset('/public/app.css')}`), served `immutable` for a year. Page / layout / metadata route only, inside the render function. See `references/built-ins.md`.
119
120
  - Types: `Metadata`, `PageProps<R>`, `LayoutProps<R>`, `RouteHandlerContext<R>`, `WebjsConfig`.
120
121
  - `@webjsdev/core/server`: `renderToString` / `renderToStream` (Node side).
121
122
  - `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`, `asyncAppend` / `asyncReplace`, `templateContent`. `Task` / `TaskStatus` live at `@webjsdev/core/task`, context (`createContext` / `ContextProvider` / `ContextConsumer`) at `/context`. See `references/components.md` for the directive table + Task + context.
@@ -82,7 +82,20 @@ export const revalidate = 60; // cache this page's HTML for 60s
82
82
 
83
83
  ### Content-hash asset URLs and conditional GET
84
84
 
85
- Both are automatic, prod-focused, and need no config. In production every served module and `public/` asset gets a per-file `?v=<hash>` and `Cache-Control: public, max-age=31536000, immutable`, so a returning client fetches a changed file only when its bytes change. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Private (`no-store` / `private`) and streamed responses are excluded from the ETag path (no cross-session 304). Dev is byte-faithful (no hashing).
85
+ Both are automatic, prod-focused, and need no config. In production every served MODULE gets a per-file `?v=<hash>`, and a `?v=`-carrying request is answered `Cache-Control: public, max-age=31536000, immutable`, so a returning client fetches a changed file only when its bytes change.
86
+
87
+ A `public/` asset you reference yourself is opt-in, via `asset()` (#1194):
88
+
89
+ ```ts
90
+ import { html, asset } from '@webjsdev/core';
91
+ html`<link rel="stylesheet" href=${asset('/public/app.css')}>`
92
+ ```
93
+
94
+ That emits `/public/app.css?v=<hash>` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, which is why this is a convention rather than a check rule. Under `webjs.basePath`, include the prefix yourself (`asset('/app/public/x.css')`): the framework base-path-prefixes only the urls it emits, so an author-written url is already yours to prefix. Two more constraints: call it INSIDE the render function, because a module-scope call is a side effect the elision analyser reads as client work and it ships the whole module; and mark only files that change with a DEPLOY, because the hash is memoized for the process lifetime, so a `public/` file rewritten in place at runtime would keep its old url while being served `immutable` for a year. Off in dev, so dev output is byte-identical. Only `public/` paths resolve; anything else (and a path that fails to resolve) is returned untouched.
95
+
96
+ It is opt-in rather than automatic because only the author knows which urls are the REQUEST. Do NOT mark a `rel="preload"` hint whose asset is actually fetched by CSS `url()`: the preload cache is keyed on the full url, so a versioned hint can never satisfy the unversioned request the stylesheet makes, and the file is fetched twice. Mark the thing that fetches, not the hint. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing).
97
+
98
+ **A page's ETag is only useful if the page renders the same bytes twice.** The ETag is a hash of the response body, so any per-render-varying value anywhere in the document defeats it: a `Date.now()`, a `Math.random()`, an id from a module-scope counter (which never resets in a long-lived server), or a CSP nonce. The failure is silent and total. The page renders correctly, every content assertion still passes, the header is still present, and the only symptom is that no `If-None-Match` ever matches, so every revalidation ships the whole document instead of an empty 304. A page under CSP is excluded from the server HTML cache for exactly this reason (the nonce must differ per response). If a page opts into a public `Cache-Control`, guard it with a test that renders the page twice, through its layout, and asserts the two outputs are byte-identical.
86
99
 
87
100
  ## Rate limiting
88
101
 
@@ -65,11 +65,20 @@ document.addEventListener('webjs:navigation-error', (e) => {
65
65
  });
66
66
  ```
67
67
 
68
+ **Observing a degradation.** Some conditions make a soft nav impossible, and the router then degrades to a full page load rather than risk a corrupt DOM (the #1015 integrity model). Every such path dispatches `webjs:navigation-fallback` on `document`, in ALL environments including production, with `detail { cause, href, willReload }`. Causes: `no-shared-boundary`, `live-boundaries-malformed`, `incoming-boundaries-malformed`, `readyState-loading`, `deploy-mismatch`, `deploy-mismatch-reload-suppressed`, `navigation-error-unrecoverable`, `revalidation-discarded`. `willReload` is false for a degradation that does NOT reload (a dropped background revalidation), so a listener can tell "this click became a document load" from "a background op was skipped". Not cancelable: by the time it fires the degradation is the only safe option. In dev a deduped console warning also prints.
69
+
70
+ ```ts
71
+ document.addEventListener('webjs:navigation-fallback', (e) => {
72
+ // A full document load on a click is a UX regression worth knowing about in prod.
73
+ if (e.detail.willReload) analytics.track('router_full_load', e.detail);
74
+ });
75
+ ```
76
+
68
77
  **Form state.** A form submitting through the router gets `aria-busy="true"` for the in-flight duration, plus bubbling `webjs:submit-start` and `webjs:submit-end` (detail `{ form, url, ok }`) events. Style `form[aria-busy="true"]` in pure CSS or listen for the events.
69
78
 
70
79
  ## Link Prefetch
71
80
 
72
- Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff.
81
+ Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. A reduced fragment is served `private`, so no shared cache can store it even if the CDN ignores `Vary` (Cloudflare honours only `Accept-Encoding`); the `Vary: X-Webjs-Have` marking stays as belt-and-braces rather than as the guarantee (#1140). A full document keeps whatever `metadata.cacheControl` declared, so page-level edge caching is unaffected. Router fetches (navigation and prefetch alike) are sent with `cache: 'no-cache'`, so a page cached in the browser with a `max-age` is revalidated rather than replayed: the deploy check reads `x-webjs-build` / `x-webjs-src` off these responses, and a cached response would hand it pre-deploy ids and hide a deploy for the whole freshness window (#1131). The revalidation is answered with a cheap 304, so the cost is a conditional round-trip rather than a re-download: on a page that opted into caching a fragment is `private` but still carries a validator, since `private` forbids only SHARED storage and has no bearing on whether a response can be validated (#1140); a default `no-store` page has nothing to validate either way. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff.
73
82
 
74
83
  Override per link with the `data-prefetch` attribute.
75
84
 
@@ -85,6 +94,8 @@ Next-style aliases work (`true` = `render`, `auto` = `viewport`, `false` = `none
85
94
 
86
95
  A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `<form>` submission (which the router never prefetches), never a GET link. A `webjs:prefetch` event fires on `document` when a fragment lands in the cache.
87
96
 
97
+ **The cache is ANCHOR-VALIDATED, not just URL-keyed (#1114).** A prefetched fragment is a reduced response: the request carries `X-Webjs-Have` (the boundaries the client already holds) and the server returns only the divergent part from the deepest boundary it short-circuited on. That boundary is the fragment's ANCHOR, and the fragment applies to any live DOM that still offers it with the same route-key. So on consume the router checks the anchor, not the whole `have` string: a root-anchored fragment survives an unrelated navigation and stays a cache hit, while one anchored at `/docs` is discarded once you leave /docs, because applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip. The router also never prefetches the page it is already on (#1106), since that request cannot serve any later navigation and only occupies a capped cache slot; a hover's intent timer routinely fires after the click it belongs to has already swapped, which is when that happens. Both behaviours are internal; nothing to configure.
98
+
88
99
  ## `<webjs-frame>` Partial-Swap Regions
89
100
 
90
101
  `<webjs-frame>` is WebJs's take on Turbo Frames, so most `<turbo-frame>` muscle memory transfers. It is a lazy, URL-addressable region that swaps on its own, driven by a link or form targeting its id, and it ships zero component JS. Use it for a region that loads or refreshes INDEPENDENTLY of a full-page navigation (a marketing widget, tabbed UI, a filtered results panel), which a page or layout cannot express.
@@ -120,6 +120,12 @@ class Panel extends WebComponent({ label: String }) {
120
120
 
121
121
  The full `<slot>` surface works in light DOM with shadow-DOM parity; migrating modes never requires a template rewrite. A forwarded slot projects its content everywhere (client, SSR, hydration).
122
122
 
123
+ **A tag name inside an HTML comment is not instantiated.** `<!-- <my-card> is the wrapper -->` documents the template and renders no component, the same as in a browser. That holds for component tags, `<slot>`, and `<webjs-suspense>`. It extends to attribute values and to every element whose content the HTML parser reads as text rather than markup: `<script>`, `<style>`, `<iframe>`, `<xmp>`, `<noembed>`, `<noframes>`, `<plaintext>`, `<textarea>`, `<title>`. (Before #1128 a commented tag was constructed as a real element and ate the rest of the comment along with the markup after it, so an ordinary explanatory comment could silently delete part of the page.)
124
+
125
+ Two elements are deliberately excluded, and the second matters more. `<template>` content IS parsed and legitimately carries components, which is what Declarative Shadow DOM and streamed swaps rely on. `<noscript>` content is also parsed, because a browser with scripting disabled reads it as markup, and that is the case a progressive-enhancement framework exists to serve, so components inside `<noscript>` render normally.
126
+
127
+ Element nesting respects comments too: a comment holding the open or close tag of the element it sits inside (`<my-card>kid<!-- </my-card> --></my-card>`) rides along as content, and the element still ends at its real close tag. Script bodies follow the parser's double-escape rule, so the legacy `<!-- <script>... -->` wrapper pattern stays text to its true end.
128
+
123
129
  ```ts
124
130
  class MyCard extends WebComponent {
125
131
  render() {
@@ -169,7 +175,7 @@ Three decoupled concerns, do not conflate them.
169
175
  2. **The client re-fetch default is stale-while-revalidate.** When a prop or dependency change re-runs `async render()`, the previous content stays until the new render resolves. No blank, no flash, no user code.
170
176
  3. **`renderFallback()` is the OPTIONAL re-fetch loading UI.** Shown ONLY during a client re-fetch, NEVER on first paint, and it does NOT create a server-streaming boundary.
171
177
 
172
- Errors are isolated per component by default (no user code): a thrown `await` renders a component-scoped error state while siblings render, never bubbling to the route `error.ts`. Override `renderError(error)` only to customize it (dev shows the message, prod stays silent).
178
+ Errors are isolated per component by default (no user code): a thrown `await` renders a component-scoped error state while siblings render, never bubbling to the route `error.ts`. Override `renderError(error)` only to customize it (dev shows the message, prod stays silent). The boundary covers the COMMIT as well as the fetch, so a template that throws while being applied (a refused binding, a value whose `toString` throws) reaches `renderError()` too, and `updateComplete` still settles. Those two halves used to disagree: a fetch rejection was contained and a commit throw escaped as an unhandled rejection that also left `updateComplete` pending forever.
173
179
 
174
180
  Decision rules. Use `async render()` for request-time server data that should be in the first paint (the default). Add `renderFallback()` when a client re-fetch's stale content would mislead. Use `Task` / signals for genuinely client-only data (a click, viewport, live updates). For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`Loading...\`}>` to stream it (the only way to show a first-paint fallback; see `client-router-and-streaming.md`). Do NOT fetch in `connectedCallback` for data knowable server-side, and do NOT prop-drill what a leaf can fetch itself.
175
181
 
@@ -96,7 +96,7 @@ const [updated] = await db.update(posts).set({ title }).where(eq(posts.id, id)).
96
96
  await db.delete(posts).where(eq(posts.id, id));
97
97
  ```
98
98
 
99
- A `.returning()` row is the table's own columns only, never `with` relations. When the caller wants a joined shape, re-read with `db.query.*` or splice the already-known related value in by hand. Full surface at https://docs.webjs.dev.
99
+ A `.returning()` row is the table's own columns only, never `with` relations. When the caller wants a joined shape, re-read with `db.query.*` or splice the already-known related value in by hand. Full surface at https://webjs.dev/docs.
100
100
 
101
101
  ## Input validation at the boundary
102
102
 
@@ -139,7 +139,8 @@ export const middleware = [requireAuth]; // async (ctx, next) => resul
139
139
  export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
140
140
  ```
141
141
 
142
- - A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries `Cache-Control` + a weak `ETag` (304 on `If-None-Match`) + `X-Webjs-Tags`. A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on success evicts its `invalidates` tags and reports them via `X-Webjs-Invalidate`. A method mismatch is a `405` + `Allow`.
142
+ - A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries a weak `ETag` (304 on `If-None-Match`); with a `cache` export it also carries `Cache-Control` + `X-Webjs-Tags` (without one it is `no-store`, so caching is opted into by `cache`, not by the verb). A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and once it completes without throwing evicts its `invalidates` tags and reports them via `X-Webjs-Invalidate` (a returned `{ success: false }` envelope still evicts, since the action ran). A method mismatch is a `405` + `Allow`.
143
+ - The `cache` object maps onto the `Cache-Control` header. The number shorthand `cache = 60` means `{ maxAge: 60 }`. `maxAge` is the freshness window in seconds (`max-age=<n>`), `swr` is a stale-while-revalidate grace window in seconds (`stale-while-revalidate=<n>`, an expired response is still served instantly while the browser revalidates in the background, usually a bodyless 304 thanks to the ETag), and `public` flips the scope from the default `private`. So `{ maxAge: 60, swr: 300 }` emits `private, max-age=60, stale-while-revalidate=300`.
143
144
  - **SAFETY.** `cache` with `public: true` SHARES one response across ALL users, keyed only by URL + args. Use it ONLY for data identical for every visitor (the same rule as a page's `export const revalidate`), never for a session or per-user read.
144
145
  - Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`. Each middleware is `async (ctx, next) => result` where `ctx` is `{ request, args, signal, context }`. It writes to the shared bag `ctx.context.<key>` (for example `ctx.context.user = user`), which is exactly what `actionContext().user` reads back in the action. A direct server-to-server call skips the RPC boundary (so its middleware does NOT run), so the action must guard rather than assume a middleware-set value is present.
145
146
 
@@ -201,4 +202,4 @@ import type { Post } from '#db/schema.server.ts';
201
202
  import { posts } from '#db/schema.server.ts';
202
203
  ```
203
204
 
204
- Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://docs.webjs.dev.
205
+ Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://webjs.dev/docs.
@@ -43,6 +43,67 @@ const users = await getUsers();
43
43
 
44
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
45
 
46
+ ### A function in `<form action=${fn}>` is refused, not stringified
47
+
48
+ Next binds a Server Action with `<form action={createTodo}>` and React serializes the binding into hidden fields. WebJs does not read that shape. A function interpolated into `action=` is a hard render error.
49
+
50
+ The reason is a source leak. During SSR a `.server.ts` import is the ACTUAL function (the RPC stub exists only in the browser), and `action=` is an ordinary attribute hole, so stringifying it would write the function's body into the HTML every visitor downloads, including any literal inside it. The renderer throws instead, on the server and on the client, for `action=` and `formaction=` alike.
51
+
52
+ What escapes is the SOURCE the runtime reports, and how much that includes depends on the runtime. The body always goes: your query shapes, your table and column names, your internal paths, and any credential written inline.
53
+
54
+ Whether an OUTER value goes with it is not something to rely on either way. `Function.prototype.toString` returns source text, so on Node a module-scope `const` the body reads appears as its identifier. Bun transpiles the module before the engine sees it and can fold that literal into the body, so the same action reports the VALUE:
55
+
56
+ ```
57
+ // const VENDOR_API_KEY = 'sk_live_…'; then used as `Bearer ${VENDOR_API_KEY}`
58
+ node 26 Authorization: `Bearer ${VENDOR_API_KEY}` identifier only
59
+ bun 1.3 Authorization: "Bearer sk_live_…" the key itself
60
+ ```
61
+
62
+ Do not go looking for the rule that decides when it folds. Export status, read count, declaration position, and whether the module has an import have each been measured as the deciding factor and each produced a counterexample on the same bun version, so whatever the optimizer keys on is finer than any of them. The two rows above are one measurement on two specific versions, not a per-runtime guarantee: read them as proof that the boundary moves, never as a promise that Node keeps an outer binding private.
63
+
64
+ So treat everything reachable from the action as exposed. That is the assumption the refusal is built on, it is the only one that holds across runtimes, and it is the only one that stays true when the transpiler changes.
65
+
66
+ The refusal covers the shape, not one spelling of it. Every hole form is refused (`action=${fn}`, `action="${fn}"`, the mixed `action="/x/${fn}"`), and so is a function wrapped in an array (`action=${[fn]}`), since an array stringifies each element through `String()` and leaks identically. Casing does not help either: `formAction=` and `ACTION=` fold to the same rule.
67
+
68
+ **Commenting the form out does not disable the hole.** A comment is HTML, the interpolation is JavaScript, and the renderer emits a comment's holes raw, so `<!-- <form action=${createTodo}> -->` still ships the whole action body with no throw and no log. Delete the binding instead of commenting around it. This is the one shape in this section that leaks silently, which is exactly why it is worth knowing.
69
+
70
+ It is not special to comments. `String(fn)` returns source text wherever it runs, so a bare function in a text child (`<div>${fn}</div>`) or any unclaimed attribute (`title=${fn}`) writes the same body out. Only the two form-action attribute names are refused today; treat a function anywhere else in a template as a mistake that ships, and reach for `@event=${fn}` or a custom element's `.prop=${fn}`, neither of which stringifies.
71
+
72
+ The refused and allowed shapes in full. Every "no" row is a binding that stringifies nothing, so refusing it would break working code rather than close a leak:
73
+
74
+ | Written as | Refused? | Why |
75
+ |---|---|---|
76
+ | `action=` / `formaction=` | yes | ordinary attribute, stringified into the HTML |
77
+ | `.action=` on a native form | yes | the property reflects, so the source lands in the DOM on the client |
78
+ | `.formAction=` on a button or input | yes | same reason, that is where `formAction` reflects |
79
+ | `.action=` on any other native tag | **no** | a plain expando (`<div .action=${fn}>`, `<button .action=${fn}>`), reflecting nothing, so nothing reaches the markup |
80
+ | `.action=` on a custom element | **no** | an author-defined property, not a reflected IDL attribute; a function is a legitimate value. Holds for a PLAIN prop: one declared `reflect: true` writes `String(value)` to the attribute on a path outside these commit sites, so it still emits the source |
81
+ | `?action=` | yes | never leaked, but it is meaningless, so it is refused rather than silently emitting a bare `action=""` |
82
+ | `@action=` unquoted | **no** | an event listener, and a function is exactly what one takes |
83
+ | `@action="${fn}"` quoted | yes | quoting makes it an ordinary attribute again, so it leaks |
84
+
85
+ That last row is the one to remember: quoting a binding hole turns it back into a plain attribute, which is why invariant 4 requires `@`, `.` and `?` holes to be unquoted.
86
+
87
+ `.action=${fn}` on a native form is refused during SSR too, even though the property is dropped there and nothing could leak, so a page cannot render clean on the server and then throw on hydration.
88
+
89
+ **Inside a component you may never see the error.** Per-component SSR error isolation contains the throw, so development shows an error box in place of the component and production renders it empty with the page still returning 200. A form that has silently vanished in production is this bug wearing a disguise; the message is in the server log. Nothing leaks either way.
90
+
91
+ Two things that "renders it empty" understates, both worth knowing before you go looking:
92
+
93
+ - **Anything slotted into the failing component goes with it.** The isolation replaces the element from its opening tag through its matching close, so a shell or layout component whose template holds the bad form takes the page's whole authored body with it. Put `action=${fn}` in a shared header and every page renders a 200 with an empty body, not one missing header.
94
+ - **On a route with a `loading.{js,ts}`, there is no log line either.** That wraps the page in a `Suspense` boundary, so the page body renders AFTER the 200 and the shell have been flushed, and a boundary that throws there is currently swallowed with no server log, no `onError`, and no error boundary. The visitor gets chrome and an empty body; with JS off the skeleton simply stays. That silence is a known framework gap rather than intended behaviour, so do not read the missing log line as evidence the render succeeded.
95
+
96
+ ```ts
97
+ // WRONG: throws at render; it would have leaked the action's body.
98
+ import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts';
99
+ html`<form method="post" action=${submitFeedback}>`;
100
+ // RIGHT: omit action entirely to post to the page's own url, and handle the
101
+ // submission in that page's `action` export.
102
+ html`<form method="post">`;
103
+ ```
104
+
105
+ A string stays a string: `action="/search"` and `action=${'/search'}` are unchanged. Other attributes keep their existing stringify behaviour; only `action` and `formaction` are claimed.
106
+
46
107
  ### `params` and `searchParams` are awaitable AND synchronously readable
47
108
 
48
109
  Next 15/16 made `params` / `searchParams` Promises. WebJs supports BOTH, so either muscle memory is correct.
@@ -94,6 +94,8 @@ export async function POST(req: Request) {
94
94
 
95
95
  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.
96
96
 
97
+ The root file sits beside `app/`, not inside it, and may be `middleware.ts` / `.js` / `.mts` / `.mjs` (`.ts` wins if more than one exists). A per-segment `app/<segment>/middleware.*` takes any of the same extensions.
98
+
97
99
  ## Metadata and `generateMetadata`
98
100
 
99
101
  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.
@@ -108,7 +110,7 @@ export async function generateMetadata(ctx: MetadataContext): Promise<Metadata>
108
110
  }
109
111
  ```
110
112
 
111
- 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.
113
+ 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 any other value enables conditional GET (a weak `ETag` + `304`), `private` included. See https://webjs.dev/docs for the full field list.
112
114
 
113
115
  ## Control-flow throws
114
116
 
@@ -35,9 +35,14 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
35
35
  | Hot reload | `node --watch` | `bun --hot` |
36
36
  | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
37
37
  | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
38
+ | Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
38
39
 
39
40
  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
 
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
+
44
+ Two limits worth knowing. `WEBJS_NO_TRUST_PROXY=1` stops the URL rewrite (and the HSTS scheme check) from trusting the headers when the container is directly exposed, but it is not a global switch: the CSRF host check still reads `X-Forwarded-Host` regardless. And this 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.
45
+
41
46
  ## Scaffolding a Bun app
42
47
 
43
48
  `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):
@@ -40,7 +40,9 @@ With WebJs's CSP enabled (`webjs.csp` in package.json), stamp the nonce on the s
40
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
41
 
42
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.
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 the framework-emitted URLs (modules, the core runtime, vendor bundles) carry a `?v=<hash>` content fingerprint automatically, so a changed file gets a new URL and the cache cannot serve stale bytes for those.
44
+
45
+ A `public/` asset is the exception, and it matters most here. Its URL is fingerprinted only when you mark it with `asset()` (`href=${asset('/public/app.css')}`, see `built-ins.md`). An un-marked `public/` URL is stable across deploys, so the worker keeps serving the CACHED copy and revalidates in the background, which means the first load after a deploy renders the OLD bytes. Do not rely on the worker's cache version to save you: it is derived from the deploy build id, which is a deploy fingerprint rather than a per-file content hash, so a deploy that only changes a `public/` file need not change it. Mark any `public/` asset whose bytes change with `asset()`.
44
46
 
45
47
  **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
48
 
@@ -206,3 +206,32 @@ new ResizeObserver(apply).observe(hdr);
206
206
  ```
207
207
 
208
208
  For a dashboard, an alternative is an app-shell scroll container (a non-scrolling `100dvh` flex column with `<main>` as the internal scroller), which needs no offset but changes the scroll model.
209
+
210
+ ### A fixed header and a modal that locks scroll
211
+
212
+ Anything that locks page scroll (a modal, a drawer, an off-canvas menu) hides the page scrollbar, and a classic scrollbar takes real layout width, so hiding it widens the viewport. The usual compensation is padding the body, which holds in-flow content still. **It does nothing for a fixed header**, because a fixed box lays out against the initial containing block, never against the body's padding box, so the header widens with the viewport and its centred content slides right by half the scrollbar width.
213
+
214
+ `@webjsdev/ui`'s `<ui-dialog>` / `<ui-alert-dialog>` handle this for you (#1144). Their scroll lock reserves the scrollbar gutter for its duration, so the viewport width never changes and nothing moves. It leaves the gutter alone if your page already declared its own `scrollbar-gutter`, on the assumption that a page which made that choice meant it.
215
+
216
+ Engines differ, and the lock does not need to know which one it is on. Where the gutter is honoured (measured on Chromium) nothing moves and the lock does nothing else. Where it is ignored (measured on WebKit) the viewport does widen, so the lock measures how much, pads `<html>` by it to hold in-flow content still (added to any padding you already had, and restored on close), and publishes the amount as `--wj-scrollbar-compensation` so a fixed element can opt in with one line:
217
+
218
+ ```css
219
+ header { position: fixed; inset-inline: 0; top: 0; border-right: var(--wj-scrollbar-compensation, 0px) solid transparent; }
220
+ ```
221
+
222
+ A transparent border rather than `padding-right`, for two reasons. It composes with whatever padding the element already has, where a padding form has to restate that base value and restate it again per responsive variant. And a background still paints across a border, so a header that carries its own background stays full bleed instead of ending short of the edge. Declare it after your Tailwind link if you use the `border-*` utilities, since those set `border-right-color` too.
223
+
224
+ The property is only set while a lock is active AND the viewport actually widened, so the `0px` fallback covers every other moment and the two mechanisms never double-compensate. If you find inline `padding-right` on your `<html>` while a modal is open, that is this, and it comes off on close.
225
+
226
+ **Put it on the element that is both viewport-width and painting.** Both halves are load-bearing, and getting either wrong fails quietly.
227
+
228
+ - **Viewport-width**, or a left-aligned child still moves. Insetting a `max-width` container holds its CENTRED children still but not its leading ones, because the container's own box is not what widened. This is easy to miss: measure a left-aligned child, not just a centred one.
229
+ - **Painting**, or the background stops short of the widened edge. Insetting a wrapper that paints nothing insets the child that does, which leaves an unpainted strip.
230
+
231
+ In this repo `examples/blog` has one element that is both (the fixed header paints its own chrome, so the border goes straight on it), and `website` has a non-painting fixed wrapper around a painting header around a centring bar, where the header is the one that qualifies.
232
+
233
+ Rolling your own scroll lock? Three things the kit learned the hard way, and the first is that almost every implementation of this (including Next's own dev overlay and Radix) gets the fixed case wrong by design.
234
+
235
+ 1. Reserve the gutter rather than relying on padding alone, or a fixed element will jump however carefully you compensate the body.
236
+ 2. When you do pad, pad `<html>`, not `<body>`. A `max-width` body does not widen when the viewport does, so padding it misses the shift entirely, while padding the root holds in-flow content whatever the body's width is.
237
+ 3. Measure the root's own border box to decide the amount. `documentElement.clientWidth` can grow while nothing actually moved (Chromium reports exactly that under a reserved gutter), and a `position: fixed` probe reads its pre-lock box on WebKit until the next rendering update, so both mislead.
@@ -25,7 +25,7 @@ This is what separates a working app from a broken one.
25
25
  native ES modules, so the source you run IS the source you read. When you
26
26
  need a precise API signature or behavior, open the package source under
27
27
  `node_modules/@webjsdev/*` directly (each package ships its own `AGENTS.md`).
28
- The full hosted docs are at https://docs.webjs.dev.
28
+ The full hosted docs are at https://webjs.dev/docs.
29
29
 
30
30
  {{PLAYBOOK}}
31
31
 
@@ -14,7 +14,8 @@ import '#modules/todo/components/todo-app.ts';
14
14
  export const metadata: Metadata = { title: 'Todo (optimistic UI) | examples' };
15
15
 
16
16
  export default async function TodoExample() {
17
- // SSR-fetched and seeded, so <todo-app> paints the real list on first byte.
17
+ // Fetched here on the server and handed down as a property, so <todo-app>
18
+ // paints the real list on the first byte with nothing to fetch on hydration.
18
19
  const todos = await listTodos();
19
20
  return html`
20
21
  ${pageHeading('Optimistic todo')}
@@ -3,9 +3,9 @@
3
3
  // changes once per window: reload inside 10s and it is identical, reload after
4
4
  // and it refreshes. SAFETY: only cache a page that is identical for every
5
5
  // visitor (no cookies(), no session, no per-user data), since the key is the URL
6
- // alone. For per-query reads use cache() + tags with revalidateTag; for assets
7
- // use HTTP Cache-Control + ETag (conditional GET).
8
- import { html } from '@webjsdev/core';
6
+ // alone. For per-query reads use cache() + tags with revalidateTag; for a
7
+ // public/ asset use asset(), demonstrated at the bottom of this page.
8
+ import { html, asset } from '@webjsdev/core';
9
9
  import type { Metadata } from '@webjsdev/core';
10
10
  import { pageHeading, lede } from '#lib/utils/ui.ts';
11
11
  import '#modules/caching/components/cache-buster.ts';
@@ -34,7 +34,9 @@ export default function CachingExample() {
34
34
  Only for pages identical for every visitor. For per-user or per-query data
35
35
  use <code>cache()</code> with <code>tags</code> and
36
36
  <code>revalidateTag</code>, or a GET action's
37
- <code>export const cache</code>.
37
+ <code>export const cache</code>, which the
38
+ <a class="text-primary underline underline-offset-2" href="/features/server-actions">server actions card</a>
39
+ demonstrates end to end.
38
40
  </p>
39
41
  <p class="text-muted-foreground text-sm">
40
42
  A mutation evicts the cache on demand. Click below (it calls
@@ -44,5 +46,27 @@ export default function CachingExample() {
44
46
  copy until the window elapses.
45
47
  </p>
46
48
  <cache-buster></cache-buster>
49
+
50
+ <h2 class="mt-8 mb-2 font-semibold">Caching a public/ asset</h2>
51
+ <p class="mb-2">
52
+ A file in <code>public/</code> sits at a stable url, so after a deploy a
53
+ browser or CDN can keep serving the PREVIOUS bytes until its cache
54
+ expires. Wrap the url in <code>asset()</code> and it gains a content hash,
55
+ which the framework then serves <code>immutable</code> for a year:
56
+ </p>
57
+ <pre class="mb-2 overflow-x-auto rounded-md bg-muted p-3 text-sm"><code>&lt;link rel="stylesheet" href=\${asset('/public/tailwind.css')}&gt;</code></pre>
58
+ <p class="mb-2">
59
+ This app's stylesheet resolves to
60
+ <code class="font-mono text-primary">${asset('/public/tailwind.css')}</code>
61
+ (the hash appears in production only, so dev output stays byte-identical).
62
+ New bytes mean a new url, so a stale copy can never be served.
63
+ </p>
64
+ <p class="text-muted-foreground text-sm">
65
+ Mark the thing that FETCHES, not a hint. Wrapping a
66
+ <code>&lt;link rel="preload"&gt;</code> whose asset is really fetched by an
67
+ <code>@font-face url()</code> in your CSS would version the hint but not
68
+ the request, so the preload could never match and the file would download
69
+ twice.
70
+ </p>
47
71
  `;
48
72
  }
@@ -2,6 +2,7 @@ import { html } from '@webjsdev/core';
2
2
  import type { Metadata } from '@webjsdev/core';
3
3
  import { pageHeading, lede } from '#lib/utils/ui.ts';
4
4
  import '#modules/server-actions/components/greeter.ts';
5
+ import '#modules/server-actions/components/clock-reader.ts';
5
6
 
6
7
  export const metadata: Metadata = { title: 'Server actions (.server vs use server) | features' };
7
8
 
@@ -20,5 +21,47 @@ export default function ServerActionsExample() {
20
21
  </p>
21
22
  <p class="text-muted-foreground mb-4 text-sm">Signed out, the greeter returns a real 401. <a class="text-primary underline underline-offset-2" href="/features/auth/login">Sign in</a> first to see it succeed. (This card depends on the auth card; prune both together.)</p>
22
23
  <server-greeter></server-greeter>
24
+
25
+ <h2 class="text-xl font-semibold mt-10 mb-3">HTTP verbs and caching</h2>
26
+ <p class="text-muted-foreground mb-4">
27
+ An action declares its HTTP semantics through reserved sibling exports the
28
+ framework reads statically, the same way a page declares
29
+ <code class="font-mono">export const revalidate</code>. The read below sets
30
+ <code class="font-mono">method = 'GET'</code>, so its args ride the URL, it is
31
+ CSRF-exempt, and it carries a weak ETag, so a revalidated read whose result has
32
+ not changed answers 304. Caching itself is opted into by the
33
+ <code class="font-mono">cache</code> export below, not by the verb: a GET without
34
+ one is <code class="font-mono">no-store</code>. An action with no
35
+ <code class="font-mono">method</code> export is a POST mutation. Seeding is a
36
+ separate mechanism and needs no verb: an action invoked during a fully
37
+ buffered SSR render has its result serialized into the page, so the first
38
+ client call reads that seed instead of making a hydration round-trip. A page
39
+ that streams (a <code class="font-mono">Suspense</code> or
40
+ <code class="font-mono">&lt;webjs-suspense&gt;</code> boundary) emits no seed
41
+ block, so its actions do call out on hydration.
42
+ </p>
43
+ <p class="text-muted-foreground mb-4">
44
+ <code class="font-mono">cache = 10</code> is the max-age in seconds, and it is
45
+ <strong class="text-foreground">private</strong> by default. Reach for
46
+ <code class="font-mono">{ public: true }</code> only when the data is identical
47
+ for every visitor, because a shared cache keys the entry on the URL and args
48
+ alone. That is the same safety rule as a page's
49
+ <code class="font-mono">export const revalidate</code>. The number is shorthand
50
+ for the object form, so
51
+ <code class="font-mono">cache = { maxAge: 10, swr: 30 }</code> keeps serving an
52
+ expired entry for another thirty seconds while the browser revalidates it in
53
+ the background. There is no separate <code class="font-mono">swr</code> export.
54
+ </p>
55
+ <p class="text-muted-foreground mb-4">
56
+ <code class="font-mono">tags</code> labels the cached entry and
57
+ <code class="font-mono">invalidates</code> on the mutation evicts it by name.
58
+ The read reports how many times it actually ran on the server, so press Read twice
59
+ inside ten seconds and that count does not move: the second answer came from the
60
+ browser cache without reaching the server. Then bump the counter and read again.
61
+ The count moves and the value is fresh, because the mutation reported its
62
+ invalidated tag and the next read bypassed the stale entry instead of waiting out
63
+ the window.
64
+ </p>
65
+ <clock-reader></clock-reader>
23
66
  `;
24
67
  }
@@ -2,11 +2,13 @@
2
2
 
3
3
  import { getCurrentUser } from '../auth.server.ts';
4
4
 
5
- // This read deliberately stays POST-default (no 'method' export). A GET server
6
- // action (a cacheable, SSR-seeded read) is wrong for a per-session lookup: the
7
- // result differs per user and changes on sign-in / sign-out, so it must never be
8
- // browser-cached or shared. Reserve GET + cache + tags for data identical for
9
- // every visitor.
5
+ // This read deliberately stays POST-default (no 'method' export). A per-session
6
+ // result differs per user and changes on sign-in / sign-out, so it must never
7
+ // end up in a cache, and GET is the verb a `cache` window would later be added
8
+ // to. Keeping it POST keeps that door shut. Reach for GET + cache + tags on a
9
+ // read stable enough to serve twice, and for `{ public: true }` only when the
10
+ // data is identical for every visitor. (Staying POST costs nothing on the first
11
+ // paint, since SSR seeding applies to an action of any verb.)
10
12
  export async function currentUser() {
11
13
  return getCurrentUser();
12
14
  }
@@ -27,7 +27,7 @@ export const FEATURE_GROUPS: NavGroup[] = [
27
27
  {
28
28
  label: 'Data & actions',
29
29
  items: [
30
- { href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, and why the boundary matters.' },
30
+ { href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, plus the HTTP-verb config exports that make a read a cached GET.' },
31
31
  { href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts HTTP endpoint returning JSON, the WebJs equivalent of a Next route handler.' },
32
32
  { href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form posting to the page action, with server-side validation errors.' },
33
33
  { href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
@@ -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
+ }
@@ -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
+ }
@@ -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
 
@@ -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
@@ -163,7 +163,7 @@ export default function Home() {
163
163
  app from here. The guide is <code class="text-[0.9em]">.agents/skills/webjs/SKILL.md</code>.
164
164
  </p>
165
165
  <nav class="flex items-center gap-5 text-sm opacity-70">
166
- <a href="https://docs.webjs.dev" target="_blank" rel="noopener" class="hover:opacity-100 transition-opacity no-underline">Docs</a>
166
+ <a href="https://webjs.dev/docs" target="_blank" rel="noopener" class="hover:opacity-100 transition-opacity no-underline">Docs</a>
167
167
  <a href="https://github.com/webjsdev/webjs" target="_blank" rel="noopener" class="hover:opacity-100 transition-opacity no-underline">GitHub</a>
168
168
  </nav>
169
169
  </div>
@@ -173,7 +173,7 @@ 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
177
 
178
178
  /**
179
179
  * Root layout: the ONLY file that writes the document shell. It links the
@@ -194,7 +194,11 @@ export const metadata = { icons: '/public/favicon.svg' };
194
194
  export default function RootLayout({ children }: { children: unknown }) {
195
195
  return html\`
196
196
  <meta name="color-scheme" content="light dark">
197
- <link rel="stylesheet" href="/public/tailwind.css">
197
+ <!-- asset() content-hashes the url in production, so a deploy that changes
198
+ the CSS changes the url and the framework serves it immutable for a
199
+ year. Without it this stable url can serve the PREVIOUS stylesheet
200
+ from a CDN or a service-worker cache after a deploy. -->
201
+ <link rel="stylesheet" href=\${asset('/public/tailwind.css')}>
198
202
  <style>
199
203
  html, body { margin: 0; }
200
204
  body {