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:
@@ -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.
|
||||
Reference in New Issue
Block a user