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/AGENTS.md b/AGENTS.md index 480939f8e..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`, 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, 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/lib/create.js b/packages/cli/lib/create.js index 505f23ac6..c54dafe19 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -947,10 +947,13 @@ 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 -// 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. +// 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. 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']; @@ -966,8 +969,12 @@ 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 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.) +// 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 @@ -977,6 +984,13 @@ 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 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/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/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..10e86cdaf 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,47 @@ 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, 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: 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 + 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. 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 + invalidates on the mutation evicts it by name. + 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/auth/queries/current-user.server.ts b/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts index 0a30891ad..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,11 +2,13 @@ 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 -// 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. +// 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/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..cca9c4467 --- /dev/null +++ b/packages/cli/templates/gallery/modules/server-actions/actions/bump-clock.server.ts @@ -0,0 +1,20 @@ +'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 type { ActionResult } from '@webjsdev/server'; +import { bumpReading } from '../utils/clock.server.ts'; + +export const invalidates = () => ['clock']; + +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 new file mode 100644 index 000000000..9dcedddda --- /dev/null +++ b/packages/cli/templates/gallery/modules/server-actions/components/clock-reader.ts @@ -0,0 +1,98 @@ +// 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; + serving: number; + at: string; + clickedAt: string; +} + +export class ClockReader extends WebComponent { + private rows = signal([]); + private busy = signal(false); + private error = signal(''); + + // 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() { + // `?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.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. + const r = await readClock(); + this.rows.set([{ ...r, clickedAt }, ...this.rows.get()].slice(0, 6)); + } catch { + this.error.set('The read failed. Is the server still running?'); + } finally { + this.busy.set(false); + } + } + + async bump() { + if (this.busy.get()) return; + this.busy.set(true); + this.error.set(''); + try { + // 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); + } + } + + render() { + const rows = this.rows.get(); + return html` +
+
+ + +
+ +
+ ${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`` : ''} +
+ `; + } +} +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..b256235d2 --- /dev/null +++ b/packages/cli/templates/gallery/modules/server-actions/queries/read-clock.server.ts @@ -0,0 +1,38 @@ +'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, 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: 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`. +// 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 { 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; 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().toISOString() }; +} 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..ac7d32baa --- /dev/null +++ b/packages/cli/templates/gallery/modules/server-actions/utils/clock.server.ts @@ -0,0 +1,27 @@ +// 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. +// +// 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; + +export function serveReading(): { reading: number; serving: number } { + servings += 1; + return { reading, serving: servings }; +} + +export function bumpReading(): number { + reading += 1; + return reading; +} 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..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,8 +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, is CSRF-exempt, and its result is SSR-seeded so the component does -// not re-fetch on hydration. +// 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/test/scaffolds/scaffold-gallery-actions.test.js b/test/scaffolds/scaffold-gallery-actions.test.js new file mode 100644 index 000000000..50a2d81f4 --- /dev/null +++ b/test/scaffolds/scaffold-gallery-actions.test.js @@ -0,0 +1,101 @@ +/** + * 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, 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). 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. + */ +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 serving counter advances on each execution'); + } 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 c3226381d..8c73c0995 100644 --- a/test/scaffolds/scaffold-gallery.test.js +++ b/test/scaffolds/scaffold-gallery.test.js @@ -388,3 +388,52 @@ 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'); + // 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, /ActionResultA 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.

@@ -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().