Skip to content

feat: HTTP-verb server actions via config exports (GET/POST/PUT/PATCH/DELETE) [epic] #488

@vivek7405

Description

@vivek7405

Epic. Follow-up to #472 (SSR action-result seeding). Give server actions explicit HTTP-verb semantics with great DX for humans and AI agents, with cacheable GET reads and tag-based invalidation, grounded in a comparison against Next (POST-only, reads are Server Components), tRPC (query/mutation), and TanStack Start (createServerFn({ method: 'GET' | 'POST' })).

Problem

The auto-generated RPC stub for a server action is always POST, so the read path has no HTTP-level caching: a client-initiated read (a refetch on a prop change, a second component, a soft nav) always hits the server. webjs has no RSC server/client split, so reads and writes BOTH flow through the same action mechanism (async render() calls an action for reads, events/forms call one for writes). Unlike Next, the read/write distinction cannot live at the component-type level, so it must live on the action. This is the gap tRPC (query/mutation) and TanStack Start (method) both fill on their RPC layer, and the one webjs should fill too.

Design (decided)

Keep the 'use server' directive and one-function-per-file. The function stays a PLAIN export async function; per-verb behavior comes from RESERVED sibling config exports the framework reads statically, exactly like a page declares export const revalidate / metadata next to export default function Page. No folder automation. Additive and non-breaking: an action with no config exports stays a POST, exactly as today.

// modules/users/queries/get-user.server.ts
'use server';
export const method = 'GET';                 // 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; absent => POST
export const cache = 60;                      // seconds, or { maxAge, swr, private }
export const tags = (id: number) => [`user:${id}`];
export async function getUser(id: number) { return db.user.find(id); }
// modules/users/actions/update-name.server.ts
'use server';
export const method = 'PATCH';
export const invalidates = (id: number) => [`user:${id}`];
export const validate = (input) => schema.parse(input);
export async function updateName(id: number, name: string) { ... }

Reserved config names (special only in a 'use server' file): method, cache, tags, invalidates, validate. The call site is unchanged: a component does await getUser(7) (a cacheable GET stub on the client, a direct in-process call on the server); the config is read statically by the stub generator (no build step).

Full verb matrix (decided)

The complete GET / POST / PUT / PATCH / DELETE set is supported (not just GET/POST). Per-verb defaults:

Verb Transport Cacheable Idempotent CSRF Method enforced
GET args in URL (stable key, 1MB cap -> POST fallback) yes (opt-in cache) yes (safe) exempt yes
POST rich body no no required yes
PUT rich body no yes required yes
PATCH rich body no no required yes
DELETE args in URL no yes required yes

Caching DX (webjs is ahead of TanStack here: TanStack has no caching on server fns)

A GET action emits Cache-Control + an ETag, reusing the conditional-GET funnel (#240) and the server cache() + revalidateTag (#242). A mutation's invalidates tags evict the matching server cache entries AND the matching client cache entries, so the next read refetches fresh. Keep caching at the HTTP layer plus the server cache(); do NOT add a bespoke client query-store (off-thesis).

Improvements adopted from the TanStack research

  • 405 + Allow on a method mismatch, before parsing the payload (TanStack server-functions-handler.ts).
  • GET payload cap (1MB) with documented POST fallback (TanStack MAX_PAYLOAD_SIZE).
  • Compile-time serializable input/output typing: a non-serializable arg or return (a function, a class instance) is a type error, not a runtime surprise (TanStack ValidateSerializableInput/Output).
  • An action may return a raw Response (already true for page-actions; extend to the RPC path).

Remove expose; REST via route.ts

route.ts already is the framework's first-class HTTP surface (file-routed, named exports per verb, middleware), so expose is removed. A public REST endpoint is a route.ts that imports and calls the action. Provide an optional action.route adapter (a Request -> Response handler reading the file config) and action.validate(input) for the common case; the explicit handler is the always-works baseline. validate is a BOUNDARY concern: it runs at the RPC endpoint and at any route.ts boundary, NOT on a direct server-to-server in-process call.

AI-agent + human DX

  • The verbs map onto REST priors every model and human already know.
  • Editor intelligence (@webjsdev/intellisense): hover shows the verb + cache; go-to-definition jumps to the server fn.
  • MCP list_actions (Add a read-only webjs MCP server and webjs check --json #262) reports verb + cache + tags + invalidates so an agent sees the full data contract.
  • A generated typed action surface (like webjs types emits the Route union) is a stretch goal.
  • webjs check enforces the contract (the --json agent loop): a GET whose handler writes, a PUT/DELETE that is not idempotent, an invalidates tag no GET produces (a typo), args too large/rich for a GET URL, a configured file with more than one callable function, and a reserved-name collision. Each violation ships a fix string.

Sub-issues (the epic)

  • Streaming RPC results (return a ReadableStream / AsyncIterable).
  • Per-action middleware with typed context.
  • Deserializer prototype-pollution hardening (security, independent).
  • AbortSignal cancellation for the RPC, wired to async render's supersede token.

Acceptance criteria (core)

  • Reserved config exports (method/cache/tags/invalidates/validate) read statically; 'use server' retained; one-function-per-file enforced for configured files.
  • Full verb matrix: GET/DELETE encode args in the URL with a stable canonical key (1MB cap -> POST fallback + dev warning); POST/PUT/PATCH send the rich body; method absent => POST (non-breaking).
  • 405 + Allow on a method mismatch, before parsing the payload.
  • GET responses carry Cache-Control + ETag; a repeat call within the window is served from the browser cache (network probe); a stale call revalidates via 304.
  • invalidates evicts the matching server cache() entries (via revalidateTag) AND the client cache entries.
  • expose removed; REST is a route.ts calling the action; action.route / action.validate helpers provided; validate is boundary-only (not on direct server-to-server calls).
  • End-to-end types preserved; non-serializable input/output is a compile-time error; an action may return a raw Response.
  • webjs check rules added (one-fn-per-config-file, reserved-name collision, GET-writes, idempotency, invalidates-tag typo, GET-args-too-large), each with a fix string.
  • MCP list_actions reports verb + cache + tags + invalidates.
  • Tests every layer (unit, SSR/integration through createRequestHandler, browser/e2e network probes), plus the four dogfood apps migrated off expose.
  • Docs updated: AGENTS.md (public API, execution model), agent-docs (components/advanced/data-fetching docs page), CONVENTIONS.md, editor + MCP surfaces.

Composes with and follows up #472.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions