From 8dca3cf28549a2060c64e52811f5fb26c131e8b0 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 28 Jul 2026 13:03:53 +0530 Subject: [PATCH 1/8] feat: teach HTTP verbs and GET caching on the server-actions card The full-stack gallery used `method = 'GET'` incidentally in two query files and never showed `cache`, `tags`, or `invalidates` at all, so a scaffolded app only learned the verb surface by accident. The api template already taught it, which left the template most agents generate as the one missing it. Extend the existing server-actions card rather than adding a route: verbs are a property of an action, not a separate feature, and the card already explains the other config export (`middleware`). --- .../gallery/app/features/caching/page.ts | 4 +- .../app/features/server-actions/page.ts | 33 +++++++++ .../templates/gallery/modules/gallery/nav.ts | 2 +- .../actions/bump-clock.server.ts | 18 +++++ .../server-actions/components/clock-reader.ts | 73 +++++++++++++++++++ .../queries/read-clock.server.ts | 25 +++++++ .../server-actions/utils/clock.server.ts | 13 ++++ 7 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 packages/cli/templates/gallery/modules/server-actions/actions/bump-clock.server.ts create mode 100644 packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts create mode 100644 packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts create mode 100644 packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts diff --git a/packages/cli/templates/gallery/app/features/caching/page.ts b/packages/cli/templates/gallery/app/features/caching/page.ts index 9a80952a7..841ed7ad1 100644 --- a/packages/cli/templates/gallery/app/features/caching/page.ts +++ b/packages/cli/templates/gallery/app/features/caching/page.ts @@ -34,7 +34,9 @@ export default function CachingExample() { Only for pages identical for every visitor. For per-user or per-query data use cache() with tags and revalidateTag, or a GET action's - export const cache. + export const cache, which the + server actions card + demonstrates end to end.

A mutation evicts the cache on demand. Click below (it calls diff --git a/packages/cli/templates/gallery/app/features/server-actions/page.ts b/packages/cli/templates/gallery/app/features/server-actions/page.ts index 2068970c1..c40c209e3 100644 --- a/packages/cli/templates/gallery/app/features/server-actions/page.ts +++ b/packages/cli/templates/gallery/app/features/server-actions/page.ts @@ -2,6 +2,7 @@ import { html } from '@webjsdev/core'; import type { Metadata } from '@webjsdev/core'; import { pageHeading, lede } from '#lib/utils/ui.ts'; import '#modules/server-actions/components/greeter.ts'; +import '#modules/server-actions/components/clock-reader.ts'; export const metadata: Metadata = { title: 'Server actions (.server vs use server) | features' }; @@ -20,5 +21,37 @@ export default function ServerActionsExample() {

Signed out, the greeter returns a real 401. Sign in first to see it succeed. (This card depends on the auth card; prune both together.)

+ +

HTTP verbs and caching

+

+ An action declares its HTTP semantics through reserved sibling exports the + framework reads statically, the same way a page declares + export const revalidate. The read below sets + method = 'GET', so its args ride the URL, it is + CSRF-exempt, it carries Cache-Control plus a weak + ETag (a revalidation answers 304), and its result is SSR-seeded so a first paint + costs no hydration round-trip. An action with no + method export is a POST mutation. +

+

+ cache = 10 is the max-age in seconds, and it is + private by default. Reach for + { public: true } only when the data is identical + for every visitor, because a shared cache keys the entry on the URL and args + alone. That is the same safety rule as a page's + export const revalidate. Add + swr to keep serving an expired entry while the + browser revalidates it in the background. +

+

+ tags labels the cached entry and + invalidates on the mutation evicts it by name. + Press Read twice inside ten seconds: the server value stays frozen while the read + time keeps moving, which is the browser cache answering. Then bump the counter and + read again. The value changes immediately, because the mutation reported its + invalidated tag and the next read bypassed the stale entry instead of waiting out + the window. +

+ `; } diff --git a/packages/cli/templates/gallery/modules/gallery/nav.ts b/packages/cli/templates/gallery/modules/gallery/nav.ts index 02dda71ca..c1905ef6c 100644 --- a/packages/cli/templates/gallery/modules/gallery/nav.ts +++ b/packages/cli/templates/gallery/modules/gallery/nav.ts @@ -27,7 +27,7 @@ export const FEATURE_GROUPS: NavGroup[] = [ { label: 'Data & actions', items: [ - { 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.' }, + { 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.' }, { 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.' }, { href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form posting to the page action, with server-side validation errors.' }, { href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' }, diff --git a/packages/cli/templates/gallery/modules/server-actions/actions/bump-clock.server.ts b/packages/cli/templates/gallery/modules/server-actions/actions/bump-clock.server.ts new file mode 100644 index 000000000..ae7312155 --- /dev/null +++ b/packages/cli/templates/gallery/modules/server-actions/actions/bump-clock.server.ts @@ -0,0 +1,18 @@ +'use server'; +// A mutation paired with the cached GET in ../queries/read-clock.server.ts. With +// no `method` export it defaults to POST (CSRF-protected, rich request body). +// +// `invalidates` lists the cache tags to evict when the action completes. The +// server drops those tags from its own cache() entries and reports them on the +// response, so the client coordinator marks them stale and the NEXT readClock() +// bypasses its browser-cached copy instead of serving a value the mutation just +// made wrong. Without this export the read would keep answering from cache until +// its max-age elapsed. +import { bumpReading } from '../utils/clock.server.ts'; + +export const invalidates = () => ['clock']; + +export async function bumpClock(): Promise<{ ok: true }> { + bumpReading(); + return { ok: true }; +} diff --git a/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts b/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts new file mode 100644 index 000000000..65d00f1db --- /dev/null +++ b/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts @@ -0,0 +1,73 @@ +// Drives the GET-versus-mutation pair. Both are imported normally (the client +// import is rewritten to a typed RPC stub); the verbs are declared on the action +// files, so nothing here changes between a GET and a POST call site. +// +// The reads are click-driven on purpose. A read issued during SSR would resolve +// from the action seed on its first client call, so "watch it hit the network" +// would be wrong for the first paint. +import { WebComponent, signal, html } from '@webjsdev/core'; +import { cardClass } from '#components/ui/card.ts'; +import { buttonClass } from '#components/ui/button.ts'; +import { readClock } from '../queries/read-clock.server.ts'; +import { bumpClock } from '../actions/bump-clock.server.ts'; + +interface Row { + reading: number; + at: string; + readAt: string; +} + +export class ClockReader extends WebComponent { + private rows = signal([]); + private busy = signal(false); + + private now(): string { + return new Date().toLocaleTimeString('en-US', { hour12: false }); + } + + async read() { + this.busy.set(true); + try { + const r = await readClock(); + this.rows.set([{ ...r, readAt: this.now() }, ...this.rows.get()].slice(0, 6)); + } finally { + this.busy.set(false); + } + } + + async bump() { + this.busy.set(true); + try { + await bumpClock(); + } finally { + this.busy.set(false); + } + } + + render() { + const rows = this.rows.get(); + return html` +
+
+ + +
+ ${rows.length + ? html` +
    + ${rows.map((r) => html` +
  • + reading #${r.reading}, server ${r.at} + read at ${r.readAt} +
  • + `)} +
+ ` + : html`

Press Read twice in a row, then bump and read again.

`} +
+ `; + } +} +ClockReader.register('clock-reader'); diff --git a/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts b/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts new file mode 100644 index 000000000..eda33bf22 --- /dev/null +++ b/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts @@ -0,0 +1,25 @@ +'use server'; +// A GET server action. An action declares its HTTP semantics through reserved +// sibling exports the framework reads statically, the same way a page declares +// `export const revalidate`. +// +// method 'GET' rides the args in the URL, is CSRF-exempt, carries +// Cache-Control plus a weak ETag (a revalidation answers 304), and +// is SSR-seeded so a first paint costs no hydration round-trip. +// With no `method` export an action is a POST mutation. +// cache the max-age in seconds. PRIVATE by default. Only pass +// { public: true } for data identical for EVERY visitor, since a +// shared cache keys the entry on the URL and args alone. Same +// safety rule as a page's `export const revalidate`. +// tags labels this cached entry so a mutation can evict it by name. +// +// One function per file is required once a file carries these config exports. +import { currentReading } from '../utils/clock.server.ts'; + +export const method = 'GET'; +export const cache = 10; +export const tags = () => ['clock']; + +export async function readClock(): Promise<{ reading: number; at: string }> { + return { reading: currentReading(), at: new Date().toLocaleTimeString('en-US', { hour12: false }) }; +} diff --git a/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts b/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts new file mode 100644 index 000000000..39356f1d3 --- /dev/null +++ b/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts @@ -0,0 +1,13 @@ +// A server-only utility (no 'use server'), like format.server.ts next to it: a +// tiny in-process counter shared by the cached GET read and the mutation that +// invalidates it. Not RPC-callable, so a browser import would throw at load. +// A real app keeps this state in the database. +let reading = 1; + +export function currentReading(): number { + return reading; +} + +export function bumpReading(): void { + reading += 1; +} From 412f47f4ff1961fc22a9e6b512a4ce4814ffa14e Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 28 Jul 2026 13:09:23 +0530 Subject: [PATCH 2/8] test: assert the gallery demonstrates every action config export The reserved sibling exports are config, not module exports, so gallery-coverage.json cannot gate them. This guards the case that let the gap exist: `method`, `cache`, `tags`, and `invalidates` silently absent from the scaffold while every other surface stayed green. Also asserts the one-function-per-configured-file rule and that no gallery action ships `{ public: true }` in code an agent would paste, since the card teaches that flag as a hazard. --- .../app/features/server-actions/page.ts | 7 ++-- .../server-actions/components/clock-reader.ts | 5 ++- .../queries/read-clock.server.ts | 8 ++-- .../server-actions/utils/clock.server.ts | 20 ++++++--- test/scaffolds/scaffold-gallery.test.js | 42 +++++++++++++++++++ 5 files changed, 68 insertions(+), 14 deletions(-) diff --git a/packages/cli/templates/gallery/app/features/server-actions/page.ts b/packages/cli/templates/gallery/app/features/server-actions/page.ts index c40c209e3..660375079 100644 --- a/packages/cli/templates/gallery/app/features/server-actions/page.ts +++ b/packages/cli/templates/gallery/app/features/server-actions/page.ts @@ -46,9 +46,10 @@ export default function ServerActionsExample() {

tags labels the cached entry and invalidates on the mutation evicts it by name. - Press Read twice inside ten seconds: the server value stays frozen while the read - time keeps moving, which is the browser cache answering. Then bump the counter and - read again. The value changes immediately, because the mutation reported its + The read reports how many times it actually ran on the server, so press Read twice + inside ten seconds and that count does not move: the second answer came from the + browser cache without reaching the server. Then bump the counter and read again. + The count moves and the value is fresh, because the mutation reported its invalidated tag and the next read bypassed the stale entry instead of waiting out the window.

diff --git a/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts b/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts index 65d00f1db..9c66f6b49 100644 --- a/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts +++ b/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts @@ -13,6 +13,7 @@ import { bumpClock } from '../actions/bump-clock.server.ts'; interface Row { reading: number; + serving: number; at: string; readAt: string; } @@ -59,8 +60,8 @@ export class ClockReader extends WebComponent {
    ${rows.map((r) => html`
  • - reading #${r.reading}, server ${r.at} - read at ${r.readAt} + reading #${r.reading}, served ${r.serving} at ${r.at} + clicked ${r.readAt}
  • `)}
diff --git a/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts b/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts index eda33bf22..892382611 100644 --- a/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts +++ b/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts @@ -14,12 +14,14 @@ // tags labels this cached entry so a mutation can evict it by name. // // One function per file is required once a file carries these config exports. -import { currentReading } from '../utils/clock.server.ts'; +import { serveReading } from '../utils/clock.server.ts'; export const method = 'GET'; export const cache = 10; export const tags = () => ['clock']; -export async function readClock(): Promise<{ reading: number; at: string }> { - return { reading: currentReading(), at: new Date().toLocaleTimeString('en-US', { hour12: false }) }; +export async function readClock(): Promise<{ reading: number; serving: number; at: string }> { + // `serving` counts the times this body actually ran, so a repeat call answered + // from the browser cache is visible: the number does not move. + return { ...serveReading(), at: new Date().toLocaleTimeString('en-US', { hour12: false }) }; } diff --git a/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts b/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts index 39356f1d3..1c77696e2 100644 --- a/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts +++ b/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts @@ -1,11 +1,19 @@ -// A server-only utility (no 'use server'), like format.server.ts next to it: a -// tiny in-process counter shared by the cached GET read and the mutation that -// invalidates it. Not RPC-callable, so a browser import would throw at load. -// A real app keeps this state in the database. +// A server-only utility (no 'use server'), like format.server.ts next to it: the +// tiny bit of state the cached GET read and the mutation that invalidates it +// share. Not RPC-callable, so a browser import would throw at load. A real app +// keeps this in the database. +// +// Two counters, because they show different things. `reading` is the domain +// value the mutation changes. `servings` counts how many times the read actually +// EXECUTED on the server, which is what makes a browser-cache hit visible: a +// response served from cache does not run this function, so the number does not +// move. let reading = 1; +let servings = 0; -export function currentReading(): number { - return reading; +export function serveReading(): { reading: number; serving: number } { + servings += 1; + return { reading, serving: servings }; } export function bumpReading(): void { diff --git a/test/scaffolds/scaffold-gallery.test.js b/test/scaffolds/scaffold-gallery.test.js index c3226381d..5927459e7 100644 --- a/test/scaffolds/scaffold-gallery.test.js +++ b/test/scaffolds/scaffold-gallery.test.js @@ -388,3 +388,45 @@ test('the full-stack auth card wires a real, protected auth baseline', async () await rm(cwd, { recursive: true, force: true }); } }); + +test('the server-actions card demonstrates every HTTP-verb config export', async () => { + // The reserved sibling exports (#488) are config, not module exports, so the + // gallery-coverage manifest cannot gate them. Assert the card ships a working + // demo of each: without this, `method` / `cache` / `tags` / `invalidates` can + // silently drop out of the scaffold the way they were missing before #1151. + const cwd = await tempCwd(); + try { + await scaffoldApp('demo', cwd, { template: 'full-stack' }); + const mod = join(cwd, 'demo', 'modules', 'server-actions'); + + const read = await readFile(join(mod, 'queries', 'read-clock.server.ts'), 'utf8'); + assert.match(read, /^'use server';/, 'the cached read is an RPC action'); + assert.match(read, /export const method = 'GET'/, 'declares the GET verb'); + assert.match(read, /export const cache = \d+/, 'declares a cache window'); + assert.match(read, /export const tags = /, 'tags the cached entry'); + + const mutation = await readFile(join(mod, 'actions', 'bump-clock.server.ts'), 'utf8'); + assert.match(mutation, /export const invalidates = /, 'the mutation evicts the tag'); + assert.doesNotMatch(mutation, /export const method/, 'a mutation is POST by default'); + + // The GET and the mutation each own their file: a configured file with more + // than one callable function is a `webjs check` error. + for (const [label, src] of [['read', read], ['mutation', mutation]]) { + assert.equal([...src.matchAll(/^export async function /gm)].length, 1, `${label} keeps one function per configured file`); + } + + // Teaching surface: a copied `{ public: true }` on a per-user read is a real + // cross-user leak, so the gallery names the flag in prose (comments and card + // copy) but never in code an agent would paste. + for (const [f, src] of [['queries/read-clock.server.ts', read], ['actions/bump-clock.server.ts', mutation]]) { + const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n'); + assert.doesNotMatch(code, /public:\s*true/, `${f} does not ship a shared-cache read`); + } + + const card = await readFile(join(cwd, 'demo', 'app', 'features', 'server-actions', 'page.ts'), 'utf8'); + assert.match(card, //, 'the card mounts the demo'); + assert.match(card, /public: true/, 'the card states the shared-cache safety rule'); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); From 0ba55011614e2c243d9159f785dc136649b0214a Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 28 Jul 2026 13:27:27 +0530 Subject: [PATCH 3/8] fix: correct the seeding claim and give the demo an error path Three fixes from review. The card credited SSR seeding to the GET verb, but seeding facades every 'use server' export whatever its method, so an agent could conclude a read must be a GET to skip the hydration round-trip. The new mutation returned a bare object while every other gallery mutation returns the ActionResult envelope, which would have taught two contracts on the one card that explains actions. Neither handler caught a rejected call, so a failed RPC left a silently inert UI next to a greeter that models the opposite. The 304 wording is also narrowed: this read carries a per-execution counter, so its ETag differs every run and it never answers 304. A read whose result is stable does, which is what the copy now says. Adds a behaviour test that boots a generated app and asserts the wire the card describes, since source assertions alone cannot tell whether the framework honours the config exports. --- .../app/features/server-actions/page.ts | 11 ++- .../actions/bump-clock.server.ts | 8 +- .../server-actions/components/clock-reader.ts | 14 ++- .../queries/read-clock.server.ts | 13 ++- .../server-actions/utils/clock.server.ts | 3 +- .../scaffold-gallery-actions.test.js | 93 +++++++++++++++++++ test/scaffolds/scaffold-gallery.test.js | 7 ++ 7 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 test/scaffolds/scaffold-gallery-actions.test.js diff --git a/packages/cli/templates/gallery/app/features/server-actions/page.ts b/packages/cli/templates/gallery/app/features/server-actions/page.ts index 660375079..a689deccb 100644 --- a/packages/cli/templates/gallery/app/features/server-actions/page.ts +++ b/packages/cli/templates/gallery/app/features/server-actions/page.ts @@ -28,10 +28,13 @@ export default function ServerActionsExample() { framework reads statically, the same way a page declares export const revalidate. The read below sets method = 'GET', so its args ride the URL, it is - CSRF-exempt, it carries Cache-Control plus a weak - ETag (a revalidation answers 304), and its result is SSR-seeded so a first paint - costs no hydration round-trip. An action with no - method export is a POST mutation. + CSRF-exempt, and it carries Cache-Control plus a + weak ETag, so a revalidated read whose result has not changed answers 304. An + action with no + method export is a POST mutation. Seeding is a + separate mechanism and needs no verb: any action invoked during SSR has its + result serialized into the page, so the first client call reads that seed + instead of a hydration round-trip.

cache = 10 is the max-age in seconds, and it is diff --git a/packages/cli/templates/gallery/modules/server-actions/actions/bump-clock.server.ts b/packages/cli/templates/gallery/modules/server-actions/actions/bump-clock.server.ts index ae7312155..cca9c4467 100644 --- a/packages/cli/templates/gallery/modules/server-actions/actions/bump-clock.server.ts +++ b/packages/cli/templates/gallery/modules/server-actions/actions/bump-clock.server.ts @@ -8,11 +8,13 @@ // bypasses its browser-cached copy instead of serving a value the mutation just // made wrong. Without this export the read would keep answering from cache until // its max-age elapsed. +import type { ActionResult } from '@webjsdev/server'; import { bumpReading } from '../utils/clock.server.ts'; export const invalidates = () => ['clock']; -export async function bumpClock(): Promise<{ ok: true }> { - bumpReading(); - return { ok: true }; +export async function bumpClock(): Promise> { + // Mutations return the ActionResult envelope, so a caller narrows one shape + // whether the write succeeded or failed. + return { success: true, data: { reading: bumpReading() } }; } diff --git a/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts b/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts index 9c66f6b49..c691078fb 100644 --- a/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts +++ b/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts @@ -21,6 +21,7 @@ interface Row { export class ClockReader extends WebComponent { private rows = signal([]); private busy = signal(false); + private error = signal(''); private now(): string { return new Date().toLocaleTimeString('en-US', { hour12: false }); @@ -28,9 +29,14 @@ export class ClockReader extends WebComponent { async read() { this.busy.set(true); + this.error.set(''); try { + // A GET action returns its value directly. The stub THROWS on a transport + // failure, so the call is guarded the same way the envelope is narrowed. const r = await readClock(); this.rows.set([{ ...r, readAt: this.now() }, ...this.rows.get()].slice(0, 6)); + } catch { + this.error.set('The read failed. Is the server still running?'); } finally { this.busy.set(false); } @@ -38,8 +44,13 @@ export class ClockReader extends WebComponent { async bump() { this.busy.set(true); + this.error.set(''); try { - await bumpClock(); + // A mutation returns the ActionResult envelope, so narrow on success. + const r = await bumpClock(); + if (!r.success) this.error.set(r.error ?? 'The bump failed.'); + } catch { + this.error.set('The bump failed. Is the server still running?'); } finally { this.busy.set(false); } @@ -67,6 +78,7 @@ export class ClockReader extends WebComponent { ` : html`

Press Read twice in a row, then bump and read again.

`} + ${this.error.get() ? html`

${this.error.get()}

` : ''} `; } diff --git a/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts b/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts index 892382611..b54fc747b 100644 --- a/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts +++ b/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts @@ -3,10 +3,11 @@ // sibling exports the framework reads statically, the same way a page declares // `export const revalidate`. // -// method 'GET' rides the args in the URL, is CSRF-exempt, carries -// Cache-Control plus a weak ETag (a revalidation answers 304), and -// is SSR-seeded so a first paint costs no hydration round-trip. -// With no `method` export an action is a POST mutation. +// method 'GET' rides the args in the URL, is CSRF-exempt, and carries +// Cache-Control plus a weak ETag (a revalidation answers 304). With +// no `method` export an action is a POST mutation. (SSR seeding is +// NOT a GET feature: every action invoked during an SSR render is +// seeded into the page, whatever its verb.) // cache the max-age in seconds. PRIVATE by default. Only pass // { public: true } for data identical for EVERY visitor, since a // shared cache keys the entry on the URL and args alone. Same @@ -22,6 +23,8 @@ export const tags = () => ['clock']; export async function readClock(): Promise<{ reading: number; serving: number; at: string }> { // `serving` counts the times this body actually ran, so a repeat call answered - // from the browser cache is visible: the number does not move. + // from the browser cache is visible: the number does not move. It is also why + // this particular read never answers a 304, since a per-execution counter gives + // every response a different ETag. A read whose result is stable does. return { ...serveReading(), at: new Date().toLocaleTimeString('en-US', { hour12: false }) }; } diff --git a/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts b/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts index 1c77696e2..6d674d3e9 100644 --- a/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts +++ b/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts @@ -16,6 +16,7 @@ export function serveReading(): { reading: number; serving: number } { return { reading, serving: servings }; } -export function bumpReading(): void { +export function bumpReading(): number { reading += 1; + return reading; } diff --git a/test/scaffolds/scaffold-gallery-actions.test.js b/test/scaffolds/scaffold-gallery-actions.test.js new file mode 100644 index 000000000..a6e935308 --- /dev/null +++ b/test/scaffolds/scaffold-gallery-actions.test.js @@ -0,0 +1,93 @@ +/** + * Behaviour test for the gallery's HTTP-verb card (#1151): the scaffold-gallery + * suite next door asserts the demo's SOURCE declares `method` / `cache` / `tags` + * / `invalidates`, which cannot tell whether the framework actually honours them. + * This boots a freshly generated app through the in-process handler and asserts + * the WIRE the card describes: a private, max-age'd, tagged, ETagged GET that + * answers 304, and a mutation that reports its invalidated tag. + * + * That wire IS the browser-cache behaviour the card demonstrates (a repeat click + * inside the window is served by the browser from the `Cache-Control` entry, and + * the invalidation header is what makes the next read bypass it), so a copy claim + * cannot go stale here without a red test. + * + * The generated app is symlinked to the repo's node_modules so its bare + * `@webjsdev/*` imports resolve; a scaffolded app in a bare tmpdir cannot boot. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, symlink } from 'node:fs/promises'; +import { join, resolve, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +import { createRequestHandler } from '@webjsdev/server'; +import { hashFile } from '../../packages/server/src/actions.js'; +import { scaffoldApp } from '../../packages/cli/lib/create.js'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +function mute() { + const log = console.log, err = console.error; + console.log = () => {}; console.error = () => {}; + return () => { console.log = log; console.error = err; }; +} + +test('the gallery GET action is cached, tagged, and invalidated on the wire', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'webjs-gallery-actions-')); + const restore = mute(); + try { + await scaffoldApp('demo', cwd, { template: 'full-stack' }); + const appDir = join(cwd, 'demo'); + await symlink(join(ROOT, 'node_modules'), join(appDir, 'node_modules')); + + const h = await createRequestHandler({ appDir, dev: false }); + if (h.warmup) await h.warmup(); + const get = (url, init) => h.handle(new Request('http://localhost' + url, init)); + const same = { 'sec-fetch-site': 'same-origin' }; + + // The card renders and ships the demo module, so the element upgrades. An + // unimported component would still emit its tag, hence the module assertion. + const page = await get('/features/server-actions'); + assert.equal(page.status, 200); + const html = await page.text(); + assert.match(html, / firstBody.serving, 'the read executed again rather than replaying a cached body'); + } finally { + restore(); + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/test/scaffolds/scaffold-gallery.test.js b/test/scaffolds/scaffold-gallery.test.js index 5927459e7..8c73c0995 100644 --- a/test/scaffolds/scaffold-gallery.test.js +++ b/test/scaffolds/scaffold-gallery.test.js @@ -425,7 +425,14 @@ test('the server-actions card demonstrates every HTTP-verb config export', async const card = await readFile(join(cwd, 'demo', 'app', 'features', 'server-actions', 'page.ts'), 'utf8'); assert.match(card, //, 'the card mounts the demo'); + // The tag alone is not enough: without the module import the element never + // registers and the demo renders as an empty tag with everything still green. + assert.match(card, /import '#modules\/server-actions\/components\/clock-reader\.ts'/, 'the card imports the demo module'); assert.match(card, /public: true/, 'the card states the shared-cache safety rule'); + + // A mutation returns the ActionResult envelope, like every other gallery + // mutation, so the card does not teach two contracts side by side. + assert.match(mutation, /ActionResult Date: Tue, 28 Jul 2026 13:39:10 +0530 Subject: [PATCH 4/8] docs: stop crediting SSR seeding to the GET verb The api template and AGENTS.md both listed seed-reading among the things `method = 'GET'` buys, which the gallery card now contradicts in print. Seeding facades every 'use server' export whatever its verb, so the GET enumeration is the wrong home for it. AGENTS.md already describes the mechanism correctly under async render. Also corrects the api template's claim that `invalidates` evicts "on success": eviction is gated on the action running without throwing, so a returned failure envelope still evicts. --- AGENTS.md | 2 +- packages/cli/lib/create.js | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 480939f8e..5f3d5b5eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,7 +305,7 @@ The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap: Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **REST over HTTP is a `route.ts`** that imports and calls the action (optionally via the `route(action, opts?)` adapter from `@webjsdev/server`, which merges query + route params + JSON body into one input object and JSON-responds). **Input validation (#245)** is declared via the `export const validate` config export (read on the RPC boundary); a `route()` endpoint passes the same validator as its `{ validate }` option. The framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `references/data-and-actions.md`. -**HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`, where `swr` appends `stale-while-revalidate=` seconds so an expired response is still served instantly while the browser revalidates it in the background; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The declared middleware runs automatically on the RPC boundary. On the `route.ts` boundary it applies when the `route()` adapter is given the action's MODULE NAMESPACE (`import * as postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`, and reads the SSR seed (#472) first; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why WebJs needs this and Next does not: WebJs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod. +**HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`, where `swr` appends `stale-while-revalidate=` seconds so an expired response is still served instantly while the browser revalidates it in the background; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The declared middleware runs automatically on the RPC boundary. On the `route.ts` boundary it applies when the `route()` adapter is given the action's MODULE NAMESPACE (`import * as postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why WebJs needs this and Next does not: WebJs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod. ### RPC + REST endpoint security diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index 505f23ac6..acbea7896 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -947,7 +947,7 @@ export default cors({ // A GET server action (#488): a read declares its HTTP semantics via reserved // sibling exports the framework reads statically. 'method' makes the call ride -// the URL (cacheable, ETag/304-aware, SSR-seeded on first paint); 'cache' is the +// the URL (cacheable, ETag/304-aware); 'cache' is the // max-age in seconds (private by default, do NOT add { public: true } unless the // data is identical for EVERY visitor); 'tags' label the cached entry so a // mutation can evict it. One function per file. @@ -966,8 +966,10 @@ export async function listUsers() { // A mutation server action (#488). With no 'method' export it defaults to POST // (CSRF-protected, rich request body). 'invalidates' lists the cache tags to -// evict on success, so the next listUsers() read refetches fresh instead of -// serving a stale browser-cached value. One function per file. +// evict once the action completes without throwing, so the next listUsers() read +// refetches fresh instead of serving a stale browser-cached value. (A returned +// { success: false } envelope still evicts, since the action ran.) One function +// per file. export const invalidates = () => ['users']; export async function createUser(input: { name: string; email: string }) { // TODO: validate input, persist to database From 67237c28137c52ee826ce5aa83dc2f27113c2301 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 28 Jul 2026 13:54:15 +0530 Subject: [PATCH 5/8] fix: guard the demo against re-entrant clicks, label the click time Three defects in the demo itself. `busy` was set without a re-entrancy check, so `?disabled` (which only lands on the next render commit) let three clicks in one task fire three concurrent reads and prepend three duplicate rows; the signal is now the guard, matching the rate-limit probe. The client column rendered as "clicked" was sampled after the await, so it was the response time on a card whose whole point is contrasting that against the server value. And the shared counters never said they are per-process, which matters on the deployed gallery where another visitor's bump moves your reading. Two other gallery queries still credited SSR seeding to the GET verb, one directory from the card that now denies it. The behaviour test stops claiming coverage its seam cannot provide (no browser, and no server-side cache of a GET action, so nothing there can prove a bypass), and pins max-age to the ten seconds the copy promises. --- .../auth/queries/current-user.server.ts | 5 +++-- .../server-actions/components/clock-reader.ts | 11 +++++++--- .../server-actions/utils/clock.server.ts | 5 +++++ .../modules/todo/queries/list-todos.server.ts | 5 +++-- .../scaffold-gallery-actions.test.js | 22 +++++++++++++------ 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts b/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts index 0a30891ad..af06a56ab 100644 --- a/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts +++ b/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts @@ -3,10 +3,11 @@ import { getCurrentUser } from '../auth.server.ts'; // This read deliberately stays POST-default (no 'method' export). A GET server -// action (a cacheable, SSR-seeded read) is wrong for a per-session lookup: the +// action is a CACHEABLE read, which is wrong for a per-session lookup: the // result differs per user and changes on sign-in / sign-out, so it must never be // browser-cached or shared. Reserve GET + cache + tags for data identical for -// every visitor. +// every visitor. (Staying POST costs nothing on the first paint, since SSR +// seeding applies to an action of any verb.) export async function currentUser() { return getCurrentUser(); } diff --git a/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts b/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts index c691078fb..50d9fd16c 100644 --- a/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts +++ b/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts @@ -15,7 +15,7 @@ interface Row { reading: number; serving: number; at: string; - readAt: string; + clickedAt: string; } export class ClockReader extends WebComponent { @@ -28,13 +28,17 @@ export class ClockReader extends WebComponent { } async read() { + // `?disabled` only lands on the next render commit, so a second click in the + // same task would fire a second request. The signal is the real guard. + if (this.busy.get()) return; this.busy.set(true); this.error.set(''); + const clickedAt = this.now(); try { // A GET action returns its value directly. The stub THROWS on a transport // failure, so the call is guarded the same way the envelope is narrowed. const r = await readClock(); - this.rows.set([{ ...r, readAt: this.now() }, ...this.rows.get()].slice(0, 6)); + this.rows.set([{ ...r, clickedAt }, ...this.rows.get()].slice(0, 6)); } catch { this.error.set('The read failed. Is the server still running?'); } finally { @@ -43,6 +47,7 @@ export class ClockReader extends WebComponent { } async bump() { + if (this.busy.get()) return; this.busy.set(true); this.error.set(''); try { @@ -72,7 +77,7 @@ export class ClockReader extends WebComponent { ${rows.map((r) => html`
  • reading #${r.reading}, served ${r.serving} at ${r.at} - clicked ${r.readAt} + clicked ${r.clickedAt}
  • `)} diff --git a/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts b/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts index 6d674d3e9..ac7d32baa 100644 --- a/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts +++ b/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts @@ -8,6 +8,11 @@ // EXECUTED on the server, which is what makes a browser-cache hit visible: a // response served from cache does not run this function, so the number does not // move. +// +// Both are per-PROCESS and shared by every visitor, which is fine for a demo but +// is exactly why a real app puts this in the database. On a deployed gallery +// someone else's bump moves your reading, and your first read opens at whatever +// serving number the process is on. let reading = 1; let servings = 0; diff --git a/packages/cli/templates/gallery/modules/todo/queries/list-todos.server.ts b/packages/cli/templates/gallery/modules/todo/queries/list-todos.server.ts index 18ba3f805..7c0f7229c 100644 --- a/packages/cli/templates/gallery/modules/todo/queries/list-todos.server.ts +++ b/packages/cli/templates/gallery/modules/todo/queries/list-todos.server.ts @@ -1,8 +1,9 @@ 'use server'; // A READ is a `'use server'` action so the client (and SSR) can call it via the // normal import (rewritten to a typed RPC stub). `method = 'GET'` rides args in -// the URL, is CSRF-exempt, and its result is SSR-seeded so the component does -// not re-fetch on hydration. +// the URL and is CSRF-exempt. (Its result is also SSR-seeded so the component +// does not re-fetch on hydration, but that happens for an action of any verb, +// not because this one is a GET.) import { db } from '#db/connection.server.ts'; import type { Todo } from '../types.ts'; diff --git a/test/scaffolds/scaffold-gallery-actions.test.js b/test/scaffolds/scaffold-gallery-actions.test.js index a6e935308..50a2d81f4 100644 --- a/test/scaffolds/scaffold-gallery-actions.test.js +++ b/test/scaffolds/scaffold-gallery-actions.test.js @@ -3,13 +3,16 @@ * suite next door asserts the demo's SOURCE declares `method` / `cache` / `tags` * / `invalidates`, which cannot tell whether the framework actually honours them. * This boots a freshly generated app through the in-process handler and asserts - * the WIRE the card describes: a private, max-age'd, tagged, ETagged GET that - * answers 304, and a mutation that reports its invalidated tag. + * the WIRE the card describes: a private, max-age'd, tagged, ETagged GET, and a + * mutation that reports its invalidated tag. * * That wire IS the browser-cache behaviour the card demonstrates (a repeat click * inside the window is served by the browser from the `Cache-Control` entry, and - * the invalidation header is what makes the next read bypass it), so a copy claim - * cannot go stale here without a red test. + * the invalidation header is what makes the next read bypass it). What this seam + * CANNOT observe is the cache itself: there is no browser here, and WebJs never + * caches a GET action server-side, so every call through the handler executes. + * The headers are therefore the load-bearing assertions, and the demo's own + * cache behaviour is verified in a browser. * * The generated app is symlinked to the repo's node_modules so its bare * `@webjsdev/*` imports resolve; a scaffolded app in a bare tmpdir cannot boot. @@ -61,7 +64,9 @@ test('the gallery GET action is cached, tagged, and invalidated on the wire', as const first = await get(`/__webjs/action/${readHash}/readClock`, { headers: same }); assert.equal(first.status, 200); assert.match(first.headers.get('cache-control') || '', /private/, 'the cache window is private, never shared'); - assert.match(first.headers.get('cache-control') || '', /max-age=\d+/, 'a max-age is set'); + // Pinned to 10, not any number: the card copy tells the visitor to read twice + // "inside ten seconds", so a changed window makes the page wrong. + assert.match(first.headers.get('cache-control') || '', /max-age=10\b/, 'the window matches the ten seconds the card promises'); assert.equal(first.headers.get('x-webjs-tags'), 'clock', 'the entry is tagged'); assert.ok(first.headers.get('etag'), 'a weak ETag rides the response'); const firstBody = await first.json(); @@ -81,11 +86,14 @@ test('the gallery GET action is cached, tagged, and invalidated on the wire', as const envelope = await bump.json(); assert.equal(envelope.success, true, 'the mutation returns the ActionResult envelope'); - // The invalidated read really is different, so the demo has something to show. + // The read really does return something different afterwards, so the demo has + // a visible change to show once the invalidation lands. const after = await get(`/__webjs/action/${readHash}/readClock`, { headers: same }); const afterBody = await after.json(); assert.equal(afterBody.reading, firstBody.reading + 1, 'the mutation moved the value the read returns'); - assert.ok(afterBody.serving > firstBody.serving, 'the read executed again rather than replaying a cached body'); + // Not evidence of a cache bypass (nothing is cached at this seam); it asserts + // the counter the card points at as the cache tell actually advances per run. + assert.ok(afterBody.serving > firstBody.serving, 'the serving counter advances on each execution'); } finally { restore(); await rm(cwd, { recursive: true, force: true }); From 86253b4f4c9accf903063d70d0af5a8cc58a1aab Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 28 Jul 2026 14:05:54 +0530 Subject: [PATCH 6/8] fix: caching is opted into by cache, not granted by the verb Review found the card and its neighbours crediting the GET verb with things `cache` actually does. A GET with no `cache` export is `no-store`, and `X-Webjs-Tags` rides only a cached response, so "a GET is a cacheable read" was wrong in the auth counter-example and in the api template. Two more overstatements corrected. Seeding needs a fully buffered render, so a streamed page emits no seed block at all, which the gallery's own async-render card demonstrates. And `swr` is a key of the cache object, never a sibling export, so the copy now shows the object form rather than leaving an agent to invent `export const swr`. The api route.ts calls its actions in-process, which skips every config export. The template now says so instead of promising cache behaviour its own wiring cannot produce. --- .../webjs/references/data-and-actions.md | 2 +- packages/cli/lib/create.js | 23 ++++++++++++------ .../app/features/server-actions/page.ts | 24 ++++++++++++------- .../auth/queries/current-user.server.ts | 14 ++++++----- .../queries/read-clock.server.ts | 15 ++++++++---- 5 files changed, 50 insertions(+), 28 deletions(-) diff --git a/.agents/skills/webjs/references/data-and-actions.md b/.agents/skills/webjs/references/data-and-actions.md index 8a7249fff..81cf1d6c7 100644 --- a/.agents/skills/webjs/references/data-and-actions.md +++ b/.agents/skills/webjs/references/data-and-actions.md @@ -139,7 +139,7 @@ export const middleware = [requireAuth]; // async (ctx, next) => resul export async function updateUser(id: number, patch: Partial) { /* ... */ } ``` -- 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`. +- 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`. - 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=`), `swr` is a stale-while-revalidate grace window in seconds (`stale-while-revalidate=`, 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`. - **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. - 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.` (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. diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index acbea7896..cd7619e6c 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -947,9 +947,9 @@ export default cors({ // A GET server action (#488): a read declares its HTTP semantics via reserved // sibling exports the framework reads statically. 'method' makes the call ride -// the URL (cacheable, ETag/304-aware); 'cache' is the -// max-age in seconds (private by default, do NOT add { public: true } unless the -// data is identical for EVERY visitor); 'tags' label the cached entry so a +// the URL and be ETag/304-aware; 'cache' is what makes the response cacheable at +// all (the max-age in seconds, private by default, do NOT add { public: true } +// unless the data is identical for EVERY visitor); 'tags' label the cached entry so a // mutation can evict it. One function per file. export const method = 'GET'; export const cache = 30; @@ -966,10 +966,13 @@ export async function listUsers() { // A mutation server action (#488). With no 'method' export it defaults to POST // (CSRF-protected, rich request body). 'invalidates' lists the cache tags to -// evict once the action completes without throwing, so the next listUsers() read -// refetches fresh instead of serving a stale browser-cached value. (A returned -// { success: false } envelope still evicts, since the action ran.) One function -// per file. +// evict once the action completes without throwing, so a client that read +// listUsers() refetches fresh instead of serving a stale browser-cached value. +// (A returned { success: false } envelope still evicts, since the action ran.) +// Like every config export, it applies on a BOUNDARY: the RPC endpoint a browser +// import hits, or a route() adapter given the module namespace. The route.ts in +// this template calls the function directly, which is in-process and skips them. +// One function per file. export const invalidates = () => ['users']; export async function createUser(input: { name: string; email: string }) { // TODO: validate input, persist to database @@ -979,6 +982,12 @@ export async function createUser(input: { name: string; email: string }) { await writeFile(join(appDir, 'app', 'api', 'users', 'route.ts'), `/** * /api/users: thin route wrapper over typed server actions. * Business logic lives in modules/users/, not here. + * + * These calls are in-process, so the actions' config exports (method / cache / + * tags / invalidates / validate / middleware) do NOT apply here: they run on a + * boundary. Swap to the namespace form of the route() adapter from + * \@webjsdev/server (import * as q; export const GET = route(q)) to have this + * endpoint honour them. */ import { listUsers } from '#modules/users/queries/list-users.server.ts'; import { createUser } from '#modules/users/actions/create-user.server.ts'; diff --git a/packages/cli/templates/gallery/app/features/server-actions/page.ts b/packages/cli/templates/gallery/app/features/server-actions/page.ts index a689deccb..10e86cdaf 100644 --- a/packages/cli/templates/gallery/app/features/server-actions/page.ts +++ b/packages/cli/templates/gallery/app/features/server-actions/page.ts @@ -28,13 +28,17 @@ export default function ServerActionsExample() { framework reads statically, the same way a page declares export const revalidate. The read below sets method = 'GET', so its args ride the URL, it is - CSRF-exempt, and it carries Cache-Control plus a - weak ETag, so a revalidated read whose result has not changed answers 304. An - action with no + CSRF-exempt, and it carries a weak ETag, so a revalidated read whose result has + not changed answers 304. Caching itself is opted into by the + cache export below, not by the verb: a GET without + one is no-store. An action with no method export is a POST mutation. Seeding is a - separate mechanism and needs no verb: any action invoked during SSR has its - result serialized into the page, so the first client call reads that seed - instead of a hydration round-trip. + separate mechanism and needs no verb: an action invoked during a fully + buffered SSR render has its result serialized into the page, so the first + client call reads that seed instead of making a hydration round-trip. A page + that streams (a Suspense or + <webjs-suspense> boundary) emits no seed + block, so its actions do call out on hydration.

    cache = 10 is the max-age in seconds, and it is @@ -42,9 +46,11 @@ export default function ServerActionsExample() { { public: true } only when the data is identical for every visitor, because a shared cache keys the entry on the URL and args alone. That is the same safety rule as a page's - export const revalidate. Add - swr to keep serving an expired entry while the - browser revalidates it in the background. + export const revalidate. The number is shorthand + for the object form, so + cache = { maxAge: 10, swr: 30 } keeps serving an + expired entry for another thirty seconds while the browser revalidates it in + the background. There is no separate swr export.

    tags labels the cached entry and diff --git a/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts b/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts index af06a56ab..599e8126a 100644 --- a/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts +++ b/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts @@ -2,12 +2,14 @@ import { getCurrentUser } from '../auth.server.ts'; -// This read deliberately stays POST-default (no 'method' export). A GET server -// action is a CACHEABLE read, which is wrong for a per-session lookup: the -// result differs per user and changes on sign-in / sign-out, so it must never be -// browser-cached or shared. Reserve GET + cache + tags for data identical for -// every visitor. (Staying POST costs nothing on the first paint, since SSR -// seeding applies to an action of any verb.) +// This read deliberately stays POST-default (no 'method' export). GET is the +// verb you pair with a `cache` window, and a per-session result like this one +// differs per user and changes on sign-in / sign-out, so there is no window +// worth caching it for. +// Reach for GET + cache + tags on a read that is stable enough to serve twice, +// and for `{ public: true }` only when the data is identical for every visitor. +// (Staying POST costs nothing on the first paint, since SSR seeding applies to +// an action of any verb.) export async function currentUser() { return getCurrentUser(); } diff --git a/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts b/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts index b54fc747b..7f12d9bb1 100644 --- a/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts +++ b/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts @@ -3,12 +3,17 @@ // sibling exports the framework reads statically, the same way a page declares // `export const revalidate`. // -// method 'GET' rides the args in the URL, is CSRF-exempt, and carries -// Cache-Control plus a weak ETag (a revalidation answers 304). With +// method 'GET' rides the args in the URL, is CSRF-exempt, and carries a weak +// ETag (a revalidation answers 304). It does NOT cache on its own: a +// GET with no `cache` export is `no-store`. With // no `method` export an action is a POST mutation. (SSR seeding is -// NOT a GET feature: every action invoked during an SSR render is -// seeded into the page, whatever its verb.) -// cache the max-age in seconds. PRIVATE by default. Only pass +// NOT a GET feature: an action invoked during a fully buffered SSR +// render is seeded into the page whatever its verb. A streamed page +// emits no seed block at all.) +// cache the max-age in seconds, and what makes the response cacheable at +// all. The number is shorthand for the object form, so +// { maxAge: 10, swr: 30 } adds a stale-while-revalidate grace +// window. PRIVATE by default. Only pass // { public: true } for data identical for EVERY visitor, since a // shared cache keys the entry on the URL and args alone. Same // safety rule as a page's `export const revalidate`. From 78d930258d08fccbc99d0466b2497dec4e4bd217 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 28 Jul 2026 14:20:00 +0530 Subject: [PATCH 7/8] fix: route() honours validate and middleware, not the cache exports The previous commit's own correction over-claimed. route() reads only `middleware` and `validate` off a module namespace; method, cache, tags, and invalidates are applied by the RPC endpoint and nowhere else, so telling an agent to swap in the adapter to "honour them" was wrong for four of the six. AGENTS.md had it right all along. Also stops the auth counter-example arguing from a rule the gallery breaks twice (two GET reads ship with no cache window), drops a seeding claim from the todo query that its own wiring never exercises, since the page awaits it server-side and passes rows down as a property, and brings the docs-site data-fetching page in line with the rest. --- packages/cli/lib/create.js | 23 +++++++++++-------- .../auth/queries/current-user.server.ts | 15 ++++++------ .../modules/todo/queries/list-todos.server.ts | 7 +++--- website/app/docs/data-fetching/page.ts | 2 +- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index cd7619e6c..c54dafe19 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -950,7 +950,10 @@ export default cors({ // the URL and be ETag/304-aware; 'cache' is what makes the response cacheable at // all (the max-age in seconds, private by default, do NOT add { public: true } // unless the data is identical for EVERY visitor); 'tags' label the cached entry so a -// mutation can evict it. One function per file. +// mutation can evict it. All three shape the RPC endpoint a browser import hits, +// so they do nothing for the in-process call in app/api/users/route.ts; they are +// here as the idiom to carry into a client that imports the action. One function +// per file. export const method = 'GET'; export const cache = 30; export const tags = () => ['users']; @@ -969,10 +972,9 @@ export async function listUsers() { // evict once the action completes without throwing, so a client that read // listUsers() refetches fresh instead of serving a stale browser-cached value. // (A returned { success: false } envelope still evicts, since the action ran.) -// Like every config export, it applies on a BOUNDARY: the RPC endpoint a browser -// import hits, or a route() adapter given the module namespace. The route.ts in -// this template calls the function directly, which is in-process and skips them. -// One function per file. +// It applies on the RPC endpoint a browser import hits. The route.ts in this +// template calls the function directly, which is in-process, so the config +// exports do not fire there. One function per file. export const invalidates = () => ['users']; export async function createUser(input: { name: string; email: string }) { // TODO: validate input, persist to database @@ -983,11 +985,12 @@ export async function createUser(input: { name: string; email: string }) { * /api/users: thin route wrapper over typed server actions. * Business logic lives in modules/users/, not here. * - * These calls are in-process, so the actions' config exports (method / cache / - * tags / invalidates / validate / middleware) do NOT apply here: they run on a - * boundary. Swap to the namespace form of the route() adapter from - * \@webjsdev/server (import * as q; export const GET = route(q)) to have this - * endpoint honour them. + * These calls are in-process, so the actions' config exports do NOT apply here. + * method / cache / tags / invalidates shape the RPC endpoint a browser import + * hits, and nothing else applies them, so this endpoint sets its own headers if + * it wants caching. The namespace form of the route() adapter from + * \@webjsdev/server (import * as q; export const GET = route(q)) picks up the + * declared validate and middleware, which are the two an endpoint can reuse. */ import { listUsers } from '#modules/users/queries/list-users.server.ts'; import { createUser } from '#modules/users/actions/create-user.server.ts'; diff --git a/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts b/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts index 599e8126a..c9de8f1ef 100644 --- a/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts +++ b/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts @@ -2,14 +2,13 @@ import { getCurrentUser } from '../auth.server.ts'; -// This read deliberately stays POST-default (no 'method' export). GET is the -// verb you pair with a `cache` window, and a per-session result like this one -// differs per user and changes on sign-in / sign-out, so there is no window -// worth caching it for. -// Reach for GET + cache + tags on a read that is stable enough to serve twice, -// and for `{ public: true }` only when the data is identical for every visitor. -// (Staying POST costs nothing on the first paint, since SSR seeding applies to -// an action of any verb.) +// This read deliberately stays POST-default (no 'method' export). A per-session +// result differs per user and changes on sign-in / sign-out, so it must never +// end up in a cache, and GET is the verb a `cache` window would later be added +// to. Keeping it POST keeps that door shut. Reach for GET + cache + tags on a +// read stable enough to serve twice, and for `{ public: true }` only when the +// data is identical for every visitor. (Staying POST costs nothing on the first +// paint, since SSR seeding applies to an action of any verb.) export async function currentUser() { return getCurrentUser(); } diff --git a/packages/cli/templates/gallery/modules/todo/queries/list-todos.server.ts b/packages/cli/templates/gallery/modules/todo/queries/list-todos.server.ts index 7c0f7229c..5d13a08a6 100644 --- a/packages/cli/templates/gallery/modules/todo/queries/list-todos.server.ts +++ b/packages/cli/templates/gallery/modules/todo/queries/list-todos.server.ts @@ -1,9 +1,10 @@ 'use server'; // A READ is a `'use server'` action so the client (and SSR) can call it via the // normal import (rewritten to a typed RPC stub). `method = 'GET'` rides args in -// the URL and is CSRF-exempt. (Its result is also SSR-seeded so the component -// does not re-fetch on hydration, but that happens for an action of any verb, -// not because this one is a GET.) +// the URL and is CSRF-exempt. It declares no `cache`, so the response is +// `no-store`: the verb marks the read as safe, the `cache` export is what makes +// it cacheable. The todo page awaits this server-side and hands the rows down as +// a `.todos=${...}` property, so nothing re-fetches it on the client. import { db } from '#db/connection.server.ts'; import type { Todo } from '../types.ts'; diff --git a/website/app/docs/data-fetching/page.ts b/website/app/docs/data-fetching/page.ts index 45ff6c424..1b475807f 100644 --- a/website/app/docs/data-fetching/page.ts +++ b/website/app/docs/data-fetching/page.ts @@ -81,7 +81,7 @@ export async function getUser(id) { return db.user.find(id); } 'use server'; export const invalidates = (id) => ['user:' + id]; export async function updateUser(id, data) { /* ... */ } -

    The call site never changes (await getUser(7)). A GET rides its args in the URL, is CSRF-exempt, and is served with Cache-Control + an ETag, so a repeat read within the window comes from the browser cache and a stale one revalidates with a 304. A mutation sends a body, is CSRF-protected, and on completion its invalidates tags evict the matching server cache and tell the client to refetch the affected reads. A wrong request method is a 405. It is additive: an action with no method stays a POST, exactly as before. The cache defaults to private; { public: true } shares the response across users keyed only by URL, so use it only for data identical for every visitor, never a per-user read. In the object form maxAge is the freshness window in seconds and swr adds a stale-while-revalidate grace window (also seconds), during which an expired response is still served instantly while the browser refreshes it in the background. The full header reference lives on the server actions page.

    +

    The call site never changes (await getUser(7)). A GET rides its args in the URL, is CSRF-exempt, and carries an ETag; add a cache export and it is also served with Cache-Control, so a repeat read within the window comes from the browser cache and a stale one revalidates with a 304. Without cache a GET is no-store, since the verb marks the read as safe and cache is what makes it cacheable. A mutation sends a body, is CSRF-protected, and on completion its invalidates tags evict the matching server cache and tell the client to refetch the affected reads. A wrong request method is a 405. It is additive: an action with no method stays a POST, exactly as before. The cache defaults to private; { public: true } shares the response across users keyed only by URL, so use it only for data identical for every visitor, never a per-user read. In the object form maxAge is the freshness window in seconds and swr adds a stale-while-revalidate grace window (also seconds), during which an expired response is still served instantly while the browser refreshes it in the background. The full header reference lives on the server actions page.

    A public REST endpoint is a route.ts that imports and calls the action; validate is a boundary concern (the RPC endpoint and the route handler), not a direct server-to-server call.

    Cancellation is automatic: a superseded async render() (a newer prop or signal change while a fetch is in flight) aborts the previous render's in-flight action fetch, and on the server an action can read the request's AbortSignal via actionSignal() to stop expensive work when the client disconnects.

    An action can declare export const middleware = [mw1, mw2] (each async (ctx, next) => result): the chain runs around the action on the RPC and REST boundaries, short-circuits (an auth middleware returning an ActionResult instead of calling next()), and accumulates context the action reads via actionContext().

    From 56ad9d9ef2e48198048c5b3949c6cd4268e59bcb Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 28 Jul 2026 14:33:25 +0530 Subject: [PATCH 8/8] fix: send the server instant, and sweep the last stale claims The demo sat a server-formatted local time next to a browser one, so on any deploy where the two timezones differ the columns it asks you to compare are hours apart. The action now returns an ISO instant and the component formats both, in the visitor's timezone. The results region gains role="status" so a screen-reader user hears the number the card is entirely about, and a failed call is an alert rather than silence. The rest is the same two claims, hunted down wherever they still lived: AGENTS.md still granted Cache-Control and X-Webjs-Tags to the verb, the todo example still credited its first paint to seeding, the blog's get-post still promised a route() boundary that applies the cache exports (and that the blog does not even use), delete-post still said eviction happens on success while its own 401 path proves otherwise, and the docs site described seeding as suppressed per region when the gate is page-wide. --- AGENTS.md | 2 +- .../posts/actions/delete-post.server.ts | 6 ++- .../modules/posts/queries/get-post.server.ts | 9 +++-- .../gallery/app/examples/todo/page.ts | 3 +- .../server-actions/components/clock-reader.ts | 39 +++++++++++-------- .../queries/read-clock.server.ts | 5 ++- website/app/docs/data-fetching/page.ts | 2 +- 7 files changed, 41 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5f3d5b5eb..d39d9333c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,7 +305,7 @@ The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap: Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **REST over HTTP is a `route.ts`** that imports and calls the action (optionally via the `route(action, opts?)` adapter from `@webjsdev/server`, which merges query + route params + JSON body into one input object and JSON-responds). **Input validation (#245)** is declared via the `export const validate` config export (read on the RPC boundary); a `route()` endpoint passes the same validator as its `{ validate }` option. The framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `references/data-and-actions.md`. -**HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`, where `swr` appends `stale-while-revalidate=` seconds so an expired response is still served instantly while the browser revalidates it in the background; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The declared middleware runs automatically on the RPC boundary. On the `route.ts` boundary it applies when the `route()` adapter is given the action's MODULE NAMESPACE (`import * as postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why WebJs needs this and Next does not: WebJs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod. +**HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`, where `swr` appends `stale-while-revalidate=` seconds so an expired response is still served instantly while the browser revalidates it in the background; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The declared middleware runs automatically on the RPC boundary. On the `route.ts` boundary it applies when the `route()` adapter is given the action's MODULE NAMESPACE (`import * as postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, and carries a weak ETag (answering `If-None-Match` with a 304); with a `cache` export it also carries `Cache-Control` + `X-Webjs-Tags`, and without one it is `no-store` (caching is opted into by `cache`, never granted by the verb); a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why WebJs needs this and Next does not: WebJs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod. ### RPC + REST endpoint security diff --git a/examples/blog/modules/posts/actions/delete-post.server.ts b/examples/blog/modules/posts/actions/delete-post.server.ts index 353c64c79..56ebb0386 100644 --- a/examples/blog/modules/posts/actions/delete-post.server.ts +++ b/examples/blog/modules/posts/actions/delete-post.server.ts @@ -8,8 +8,10 @@ import { listPosts } from '#modules/posts/queries/list-posts.server.ts'; import type { ActionResult } from '#modules/auth/types.ts'; // A mutation (#488, POST by default). `invalidates` lists the cache tags to -// evict on success, so a later getPost GET for this slug refetches instead of -// serving a stale browser-cached value. (The listPosts.invalidate() call below +// evict once the action completes without throwing, so a later getPost GET for +// this slug refetches instead of serving a stale browser-cached value. Note the +// 401 path below still evicts: eviction is gated on the action having RUN, so a +// returned { success: false } envelope counts. (The listPosts.invalidate() call below // is the separate server-side query cache; this is the HTTP-boundary tag set.) export const invalidates = (input: { slug: string }) => ['posts', `post:${input.slug}`]; export async function deletePost( diff --git a/examples/blog/modules/posts/queries/get-post.server.ts b/examples/blog/modules/posts/queries/get-post.server.ts index d09f3396b..95ad937f4 100644 --- a/examples/blog/modules/posts/queries/get-post.server.ts +++ b/examples/blog/modules/posts/queries/get-post.server.ts @@ -5,9 +5,12 @@ import { formatPost } from '#modules/posts/utils/slugify.ts'; import type { PostFormatted } from '#modules/posts/types.ts'; // A GET server action (#488): a single-post read declares its HTTP semantics -// via reserved sibling exports. Calls from a page / route.ts run the function -// directly, but exposed at a route() boundary the GET is cacheable, ETag-aware -// and SSR-seeded. The per-post `post:` tag (resolved from the `slug` arg) is +// via reserved sibling exports. They shape the RPC endpoint a browser import +// hits, where the GET is cacheable and ETag-aware. A call from a page or a +// route.ts runs the function in-process and skips all of it (route() picks up +// only a declared validate and middleware, never the cache exports), so an +// endpoint that wants caching sets its own headers. The per-post `post:` tag +// (resolved from the `slug` arg) is // what `deletePost` evicts. Cache stays private (the default); a public blog // post is identical for everyone, but private is the safe baseline. export const method = 'GET'; diff --git a/packages/cli/templates/gallery/app/examples/todo/page.ts b/packages/cli/templates/gallery/app/examples/todo/page.ts index 8eb3726b0..92bf5c74f 100644 --- a/packages/cli/templates/gallery/app/examples/todo/page.ts +++ b/packages/cli/templates/gallery/app/examples/todo/page.ts @@ -14,7 +14,8 @@ import '#modules/todo/components/todo-app.ts'; export const metadata: Metadata = { title: 'Todo (optimistic UI) | examples' }; export default async function TodoExample() { - // SSR-fetched and seeded, so paints the real list on first byte. + // Fetched here on the server and handed down as a property, so + // paints the real list on the first byte with nothing to fetch on hydration. const todos = await listTodos(); return html` ${pageHeading('Optimistic todo')} diff --git a/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts b/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts index 50d9fd16c..9dcedddda 100644 --- a/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts +++ b/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts @@ -23,8 +23,10 @@ export class ClockReader extends WebComponent { private busy = signal(false); private error = signal(''); - private now(): string { - return new Date().toLocaleTimeString('en-US', { hour12: false }); + // Both timestamps are formatted here, in the visitor's timezone, so the server + // instant and the click time are directly comparable wherever this is deployed. + private clock(value: Date | string): string { + return new Date(value).toLocaleTimeString('en-US', { hour12: false }); } async read() { @@ -33,7 +35,7 @@ export class ClockReader extends WebComponent { if (this.busy.get()) return; this.busy.set(true); this.error.set(''); - const clickedAt = this.now(); + const clickedAt = this.clock(new Date()); try { // A GET action returns its value directly. The stub THROWS on a transport // failure, so the call is guarded the same way the envelope is narrowed. @@ -67,23 +69,28 @@ export class ClockReader extends WebComponent {
    - ${rows.length - ? html` -
      - ${rows.map((r) => html` -
    • - reading #${r.reading}, served ${r.serving} at ${r.at} - clicked ${r.clickedAt} -
    • - `)} -
    - ` - : html`

    Press Read twice in a row, then bump and read again.

    `} - ${this.error.get() ? html`

    ${this.error.get()}

    ` : ''} + +
    + ${rows.length + ? html` +
      + ${rows.map((r) => html` +
    • + reading #${r.reading}, served ${r.serving} at ${this.clock(r.at)} + clicked ${r.clickedAt} +
    • + `)} +
    + ` + : html`

    Press Read twice in a row, then bump and read again.

    `} +
    + ${this.error.get() ? html`` : ''}
    `; } diff --git a/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts b/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts index 7f12d9bb1..b256235d2 100644 --- a/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts +++ b/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts @@ -27,9 +27,12 @@ export const cache = 10; export const tags = () => ['clock']; export async function readClock(): Promise<{ reading: number; serving: number; at: string }> { + // `at` goes over the wire as an ISO instant, not a formatted local time: the + // card sits it next to a browser-side timestamp, and a server in another + // timezone would otherwise put the two columns hours apart. // `serving` counts the times this body actually ran, so a repeat call answered // from the browser cache is visible: the number does not move. It is also why // this particular read never answers a 304, since a per-execution counter gives // every response a different ETag. A read whose result is stable does. - return { ...serveReading(), at: new Date().toLocaleTimeString('en-US', { hour12: false }) }; + return { ...serveReading(), at: new Date().toISOString() }; } diff --git a/website/app/docs/data-fetching/page.ts b/website/app/docs/data-fetching/page.ts index 1b475807f..82054930d 100644 --- a/website/app/docs/data-fetching/page.ts +++ b/website/app/docs/data-fetching/page.ts @@ -67,7 +67,7 @@ class Report extends WebComponent {

    A shipping async component does not re-fetch on hydration (seeding)

    When an async component DOES ship (it has an interactivity signal, so it cannot be elided), WebJs still avoids the redundant hydration fetch. Each 'use server' action result invoked during the SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call. So const u = await getUser(this.id) runs once, on the server, and the client's first render reuses the result with no network round-trip. A later refetch (a prop or signal change, a new argument) misses the seed and goes to the server as normal, so the seed never serves stale data.

    -

    It is automatic and needs no code: the same async render() you already wrote. There is no source transform and no build step (the capture is a transparent server-side facade over the action module), so what you write is what you see in the browser source tab. It is on by default; disable it with "webjs": { "seed": false } in package.json or WEBJS_SEED=0, in which case the client re-fetches on hydration (the stale-while-revalidate default hides the flicker). Streamed <webjs-suspense> regions are not seeded, since their data resolves after the first byte.

    +

    It is automatic and needs no code: the same async render() you already wrote. There is no source transform and no build step (the capture is a transparent server-side facade over the action module), so what you write is what you see in the browser source tab. It is on by default; disable it with "webjs": { "seed": false } in package.json or WEBJS_SEED=0, in which case the client re-fetches on hydration (the stale-while-revalidate default hides the flicker). Seeding also needs a fully buffered render: a page carrying a Suspense or <webjs-suspense> boundary emits no seed block at all, so every action on that page calls out on hydration, not just the streamed region.

    HTTP-verb actions: cacheable reads and tag invalidation

    An action declares its HTTP semantics through reserved sibling exports, the same way a page declares export const revalidate. The function stays a plain export async function (one per file); a method export picks the verb, and a GET can be cached.