@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.
- package/README.md +2 -2
- package/bin/webjs.js +1 -1
- package/lib/create.js +33 -12
- package/lib/dev-supervisor.js +5 -1
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +1 -1
- package/templates/.agents/skills/webjs/SKILL.md +2 -1
- package/templates/.agents/skills/webjs/references/built-ins.md +14 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +12 -1
- package/templates/.agents/skills/webjs/references/components.md +7 -1
- package/templates/.agents/skills/webjs/references/data-and-actions.md +4 -3
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +61 -0
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +3 -1
- package/templates/.agents/skills/webjs/references/runtime.md +5 -0
- package/templates/.agents/skills/webjs/references/service-worker.md +3 -1
- package/templates/.agents/skills/webjs/references/styling.md +29 -0
- package/templates/AGENTS.md +1 -1
- package/templates/gallery/app/examples/todo/page.ts +2 -1
- package/templates/gallery/app/features/caching/page.ts +28 -4
- package/templates/gallery/app/features/server-actions/page.ts +43 -0
- package/templates/gallery/modules/auth/queries/current-user.server.ts +7 -5
- package/templates/gallery/modules/gallery/nav.ts +1 -1
- package/templates/gallery/modules/server-actions/actions/bump-clock.server.ts +20 -0
- package/templates/gallery/modules/server-actions/components/clock-reader.ts +98 -0
- package/templates/gallery/modules/server-actions/queries/read-clock.server.ts +38 -0
- package/templates/gallery/modules/server-actions/utils/clock.server.ts +27 -0
- package/templates/gallery/modules/todo/queries/list-todos.server.ts +4 -2
- package/templates/public/sw.js +11 -2
- 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://
|
|
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://
|
|
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://
|
|
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
|
|
951
|
-
// max-age in seconds
|
|
952
|
-
// data is identical for EVERY visitor); 'tags' label the cached entry so a
|
|
953
|
-
// mutation can evict it.
|
|
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
|
|
970
|
-
// serving a stale browser-cached value.
|
|
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
|
|
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://
|
|
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://
|
|
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://
|
|
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
|
package/lib/dev-supervisor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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://
|
|
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://
|
|
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
|
|
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://
|
|
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
|
|
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://
|
|
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
|
|
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
|
|
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.
|
package/templates/AGENTS.md
CHANGED
|
@@ -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://
|
|
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
|
-
//
|
|
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
|
|
7
|
-
// use
|
|
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><link rel="stylesheet" href=\${asset('/public/tailwind.css')}></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><link rel="preload"></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"><webjs-suspense></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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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,
|
|
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
|
|
5
|
-
//
|
|
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
|
|
package/templates/public/sw.js
CHANGED
|
@@ -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
|
-
*
|
|
19
|
-
* a
|
|
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://
|
|
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
|
-
|
|
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 {
|