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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 15:09:30 +05:30
co-authored by Claude Opus 5
parent 2cbc3e43e1
commit f026415ba6
@@ -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, Expected> = Actual extends Expected ? true : never;
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
type ApiOutput<P extends ApiRoute, M> = 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.