From f026415ba6812fe67b1beabb28d271706d4f618f Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 19 Aug 2026 15:09:30 +0530 Subject: [PATCH] docs: design for typed, callable api blocks in .wrn files A sectioned `api` block -- request / response / error -- callable on demand from client code, typed against the API route contracts the types generator already emits. Records the constraints that shaped it: the current block cannot carry a query string (isSafeApiPath rejects "?"), cannot interpolate (readPath stops at "{"), has nowhere to put a body, and fetches once. And the one that decides the type-safety mechanism -- generated build artifacts are not type-checked, so enforcement goes into the generated .d.ts, which the project's own tsc already compiles. Co-Authored-By: Claude Opus 5 --- .../2026-08-19-typed-api-block-design.md | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-typed-api-block-design.md diff --git a/docs/superpowers/specs/2026-08-19-typed-api-block-design.md b/docs/superpowers/specs/2026-08-19-typed-api-block-design.md new file mode 100644 index 00000000..3a6b980f --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-typed-api-block-design.md @@ -0,0 +1,252 @@ +# Typed, callable `api` blocks for `.wrn` files — Design + +**Date:** 2026-08-19 +**Status:** Approved for implementation +**Scope:** A sectioned `api` block that declares a typed request, transforms the response, and +handles failure — callable on demand from client code. + +## Goal + +Calling this application's own API routes from a `.wrn` page should be declarative and +type-checked. Today it is neither: the `api` block takes no parameters at all, so anything +carrying a value from the page is written as a hand-rolled `fetch` — query-string assembly, +JSON headers, CSRF, status checks, and a `try/catch` repeated at every call site. + +### What the current block cannot do + +These are implementation facts, not gaps in documentation: + +- **No query string.** `isSafeApiPath` (`packages/dev-server/src/runtime.ts`) rejects any path + containing `?` or `#`. +- **No interpolation.** `readPath()` reads until whitespace or `{`, so `/api/users?name={filter}` + ends the path at the brace and the remainder is parsed as the block body. +- **No request body.** The caller builds `new Request(apiUrl, { method, headers })` — there is no + parameter a payload could occupy, whatever method is named. +- **Fetch-once.** `setupCsrFetch` sets an `__wrnexusCsrFetch` flag and returns early on any later + pass, so a binding cannot be re-run. + +### Non-goals + +- External or third-party APIs. Targets are restricted to this app's `/api/*` routes, preserving + the existing `isSafeApiPath` guarantee. +- Replacing `server function`. That remains the way to run arbitrary server logic over RPC. +- Author-settable headers. See "Why `headers` is excluded". +- Parameterised server-render fetching. See "The SSR boundary". + +## Decisions + +| Question | Decision | +| ---------------- | ------------------------------------------------------------------------ | +| Trigger | `client {}` blocks are callable on demand; `ssr {}` stays render-time | +| Targets | This app's `/api/*` routes only | +| Execution | Decided by the enclosing mode, not a modifier | +| Request values | Declared fields, supplied at the call site | +| Type source | Route contract when available, declared types otherwise (with a warning) | +| Type enforcement | `tsc`, via assertions generated into `wrnexus.generated.d.ts` | +| Failure | `error {}` converts a failure to a value; without it, the call rejects | + +## Syntax + +```wrn +client { + api searchUsers POST /api/users { + request { + body { + name?: string + age?: number + designation?: string + } + } + + response { + return data.users + } + + error { + return [] + } + } +} +``` + +Called as `const users = await api.searchUsers({ name: nameFilter.trim() })`. The `api` namespace +joins those already in client scope (`server`, `output`, `props`, `refs`), so it reads the same way +as `server.searchUsers()`. + +`GET` blocks declare `parameters` rather than `body`; the compiler appends them as a query string at +call time. The path in source stays a plain literal, so `isSafeApiPath` is satisfied without +relaxing it. + +### Backward compatibility + +A bare body keeps meaning "this is the response block", unchanged: + +```wrn +ssr { + api ssrUsers GET /api/users/ssr { + return users.map((user) => user.name).join(", ") + } +} +``` + +The rule is **bare body = legacy untyped block; sections = typed block.** + +The two forms reach the payload differently, and the reason is load-bearing rather than cosmetic. +The legacy form injects the response with `with ($data ?? {})`, which is why bare `users` resolves. +**`with` is untypeable** — TypeScript cannot see through it — so a typed `response` block is +impossible in that form. Sectioned blocks therefore bind the payload to `data`, specifically so +`tsc` can check `data.users` against the route's contract. + +### Why `headers` is excluded + +Own-route calls are same-origin, so cookies are already attached; `content-type` and `accept` follow +from whether the block has a body; and CSRF is attached by the runtime (below). What remains for an +author to set is mostly credentials, which do not belong in page source. Excluded from v1 pending a +concrete case. + +## Type safety + +### The constraint that shapes this + +`examples/basic-app/tsconfig.json` uses `include: ["app"]` and excludes `.wrnexus-*`, and +`wrnexus build` never invokes `tsc`. **Generated build artifacts are not type-checked.** Compiling +the block into a typed client and expecting `tsc` to catch mismatches would therefore check nothing. + +What _is_ type-checked is `app/types/wrnexus.generated.d.ts`, which lives under `app/`. Enforcement +goes there. + +### Three pieces + +**1. Helper types**, extending what the generator already emits (`ApiRoute`, `ApiContracts`, +`ApiContract`): + +```ts +type AssertAssignable = Actual extends Expected ? true : never; +type ApiInput

= ApiContracts[P][M]["input"]; +type ApiOutput

= ApiContracts[P][M]["output"]; +``` + +**2. Per-block assertions**, generated into the same file. `wrnexus generate types` already parses +`.wrn` sources to build the route list, so it can read each block's declared fields and emit: + +```ts +type __wrn_check_searchUsers = AssertAssignable< + { name?: string; age?: number }, + ApiInput<"/api/users", "POST"> +>; +``` + +This is what makes the safety real. It sits in a file the project's own `tsc` already compiles, so +`bun run typecheck` fails when a block sends a field the endpoint rejects. No bespoke type +comparison inside the WRNexus compiler, and no need to type-check build output. The language server +already runs TypeScript diagnostics on `.wrn` documents, so the same error appears inline. + +**3. A generic runtime**, `callApi(path, method, input)`, typed by those contracts, so the fetch, +JSON handling, and failure branch live in one tested place instead of being re-emitted per block. + +### Routes without a contract + +A plain handler returning `Response.json` has no `defineEndpoint` contract, so `ApiInput` resolves +to `unknown`. The block's declared types are used directly and the generator emits a warning naming +the route. Untyped endpoints stay visible rather than silently passing. + +### Staleness + +Checking is only as current as the generated file, so this stays wired into the existing +`check:generated-types` gate, which already verifies those artifacts match their sources. + +## Compilation and runtime + +### Client mode + +Each `client { api name ... }` becomes an entry on an `api` namespace in the generated browser +module, beside the existing `__wrnexusClientFunctions`, with `const api = context.api` added to +client scope exactly as `server` is today. + +Declared field types are **type-only**. The generator uses them for the `.d.ts` assertions and +codegen drops them before emit. A client function body that carried TypeScript into a `.mjs` +artifact is a bug this repository has already shipped once (fixed 2026-08-19, `55fed217`); the same +discipline applies here. + +`setupCsrFetch` is untouched. Callable blocks are a separate mechanism, so the existing render-time +binding needs no rework. + +### Request assembly + +`callApi` builds the request: + +- **GET** — declared `parameters` become a query string; `undefined` fields are omitted, which + removes the `if (filter.trim())` ladder authors write by hand. +- **Everything else** — a JSON body with `content-type: application/json`. +- Always `credentials: "same-origin"` and `accept: application/json`. +- **Non-GET requests attach `x-csrf-token`**, read from the `wrn-csrf` cookie or the + `wrnexus-csrf` meta tag, reusing the logic already at `packages/csr/src/reactive-runtime.ts:4221` + for RPC. Hand-written `fetch` calls in application code generally omit this, so it is a + correctness gain rather than only less typing. + +### Failure + +**`error {}` converts a failure into a value; without it, the call rejects.** + +- 2xx — the JSON is parsed and bound as `data`, `response {}` runs, and its return value is the + call's result. With no `response` block, `data` is returned unchanged. +- Non-2xx, network failure, or an unparseable body — `error {}` runs with `status`, `message`, and + `data` in scope. `return []` yields an empty list and no exception. +- No `error {}` block — the promise rejects, so `try/catch` at the call site keeps working. + +A block must never quietly return `undefined` on failure. Success-shaped failure is the defect class +this design is most concerned with, so the absence of an `error` block means throw, never swallow. + +### The SSR boundary + +`ssr {}` blocks accept `response {}` and `error {}`, but **not** `request {}`. There is no caller at +render time to supply arguments, and inferring an implicit source — page state, query parameters — +would be a guess. Parameterised requests are a client-mode feature; parameterised server-side +fetching stays with `server function`. + +## Testing + +### Parser (`packages/syntax/test/`) + +- A sectioned block parses into `request`/`response`/`error` parts. +- A bare body still parses as the response block (the backward-compatibility guarantee). +- `request` inside an `ssr {}` block is a parse error naming the restriction. +- A malformed section reports the offending offset rather than failing later in codegen. + +### Type generation (`packages/cli/test/` or the types generator's suite) + +- A block targeting a `defineEndpoint` route emits an assertion referencing that contract. +- A field the endpoint does not accept makes `bun run typecheck` fail — asserted by running `tsc` + over a fixture, not by string-matching the generated file. +- A block targeting a contract-less route emits the warning and falls back to declared types. +- `check:generated-types` still passes with blocks present. + +### Codegen (`packages/compiler/test/`) + +- A client-mode block emits an `api` namespace entry and valid JavaScript — no TypeScript survives + into the browser module (the guard for the `55fed217` defect class). +- Declared types do not appear in the emitted module. +- An `ssr` block's output is unchanged from today for a bare body. + +### Runtime (`packages/csr/test/`) + +- GET omits `undefined` parameters and includes the rest. +- Non-GET attaches `x-csrf-token` from cookie and from meta. +- 2xx runs `response`; its return value is the result. +- Non-2xx runs `error`; its return value is the result. +- With no `error` block, a non-2xx rejects rather than resolving to `undefined`. + +### End to end (`examples/basic-app`) + +A page calling a typed block against a real route, driven in a browser: the request carries the +declared fields, the response block's value reaches page state, and a deliberately failing call +takes the `error` path. Tests that pass while the feature does not work have been a recurring +failure in this repository, so browser verification is part of the definition of done. + +## Deferred + +- External and third-party API targets, with the allowlist and credential handling they require. +- Author-settable request headers. +- Re-runnable `ssr` bindings — `setupCsrFetch`'s fetch-once guard stays as it is. +- Parameterised server-render fetching. +- Response caching and request de-duplication.