Three specs completing the set, each depending on the one before it:
- apis {}: one container, mode-less declarations, api.<name>() callable
anywhere with build-time dispatch, AsyncLocalStorage for server context,
usage-driven emission, three render-binding forms. Replaces the ssr {} /
client {} data blocks and the untypeable with($data) legacy body.
- update: migrations to the new syntax. The legacy bare-body rewrite is
deliberately manual -- which free identifiers are payload fields is not
knowable from the source, so an automatic guess would compile and be wrong.
- editor tooling: grammar, completions for api. and the api= attribute,
diagnostics for removed constructs, and resolving by observation whether
generated type errors surface inline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
251 lines
11 KiB
Markdown
251 lines
11 KiB
Markdown
# The `apis { }` block and location-transparent dispatch — Design
|
|
|
|
**Date:** 2026-08-19
|
|
**Status:** Approved for implementation
|
|
**Scope:** One container block for API declarations, callable from anywhere as `api.<name>(input)`,
|
|
dispatched in-process on the server and over `fetch` in the browser.
|
|
|
|
**Depends on:** `2026-08-19-legacy-and-config-cleanup-design.md`. That spec removes the
|
|
compatibility surface and the `"legacy"` function runtime; this one removes the legacy `api` forms
|
|
by replacing them.
|
|
|
|
## Goal
|
|
|
|
Today a `.wrn` page has several unrelated ways to reach data: an `api` block inside `ssr {}`, an
|
|
`api` block inside `client {}`, a `server function` over RPC, a `load server` block, and hand-written
|
|
`fetch`. Each has different placement, different capabilities, and a different call shape. The
|
|
result is that "how do I fetch this?" has no single answer, and the answer that is right depends on
|
|
where the code happens to sit.
|
|
|
|
This collapses the API half of that into one declaration and one call, and draws a line a developer
|
|
can hold in their head:
|
|
|
|
- **`api.<name>()`** — call an API route that exists as a real HTTP endpoint.
|
|
- **`server.<name>()`** — call server-side logic that has no public surface.
|
|
|
|
The question becomes "is there a route?", not "where am I running?". `server.<name>()` is unchanged
|
|
by this spec.
|
|
|
|
### Non-goals
|
|
|
|
- Changing `server function`, actions, or `load` blocks. They keep working exactly as they do.
|
|
- External or third-party API targets. Still this app's `/api/*` routes only, preserving
|
|
`isSafeApiPath`.
|
|
- Any new configuration key. This spec adds none.
|
|
- Author-settable request headers, still excluded.
|
|
|
|
## Decisions
|
|
|
|
| Question | Decision |
|
|
| ---------------------- | ---------------------------------------------------------------------- |
|
|
| Container | Page-level `apis { }`, matching `functions { }` |
|
|
| Declaration | Mode-less — no `ssr` / `client` prefix |
|
|
| Call | `api.<name>(input)` from any context |
|
|
| Dispatch | Chosen at build time: in-process on the server, `fetch` in the browser |
|
|
| Server request context | `AsyncLocalStorage` |
|
|
| Browser emission | Only blocks the client actually calls |
|
|
| Render binding | `api="name"`, `api="name()"`, `api="name({ … })"` |
|
|
| Old forms | Removed and replaced |
|
|
|
|
## Syntax
|
|
|
|
```wrn
|
|
apis {
|
|
searchUsers POST /api/users {
|
|
request {
|
|
body {
|
|
name?: string
|
|
age?: number
|
|
}
|
|
}
|
|
|
|
response { return data.data.users }
|
|
error { return [] }
|
|
}
|
|
|
|
listTeams GET /api/teams {
|
|
response { return data.data.teams }
|
|
}
|
|
}
|
|
```
|
|
|
|
`GET` and `HEAD` declare `parameters`, which become a query string; other methods declare `body`,
|
|
sent as JSON. The path stays a plain literal so `isSafeApiPath` is satisfied without relaxing it.
|
|
|
|
Names are unique per page — `codegen.ts` already rejects duplicates, and that stays.
|
|
|
|
### Why the container
|
|
|
|
`functions { }` puts the modifier first inside a container named for the concept:
|
|
`functions { client function x() }`. Today's api form inverts that — `client { api x … }` — and is
|
|
the only construct in the language shaped that way, which is why APIs are hard to find in a page.
|
|
`apis` (plural) is the container; `api` remains the call namespace and the binding attribute.
|
|
|
|
## Calling
|
|
|
|
```wrn
|
|
functions {
|
|
client async function search(): Promise<void> {
|
|
users = await api.searchUsers({ name: nameFilter })
|
|
}
|
|
}
|
|
|
|
load server directory {
|
|
return await api.searchUsers({ name: ctx.url.searchParams.get("name") ?? "" })
|
|
}
|
|
```
|
|
|
|
The same call works in client functions, `load` blocks, actions, and server functions. Nothing at
|
|
the call site says where it runs.
|
|
|
|
### Dispatch
|
|
|
|
Dispatch is decided at build time, not by a runtime check. The compiler emits an `api` object into
|
|
each execution context, with the same member names and different transports behind them:
|
|
|
|
- **Browser** — the existing `callApi` transport: query string or JSON body, `credentials:
|
|
"same-origin"`, `x-csrf-token` on non-GET, the established failure contract.
|
|
- **Server** — `callApiFromContext`, which dispatches to the route in-process. No network hop, no
|
|
serialisation round trip, and the caller's cookies, session, and locals are already forwarded.
|
|
|
|
Two objects that never meet, so nothing ships to the browser that only the server uses, and the
|
|
runtime never branches on `typeof window` for something known at compile time.
|
|
|
|
### `callApiFromContext` must learn to carry input
|
|
|
|
It currently builds `new Request(apiUrl, { method, headers })` — no body, no query string. It has to
|
|
assemble the request the same way the browser transport does, from the same rules, or the two sides
|
|
will disagree about what an identical call sends. **The assembly rules must be shared, not
|
|
reimplemented**: a second copy will drift, and the drift will be silent because each side is tested
|
|
separately.
|
|
|
|
### The request context
|
|
|
|
A server-side call needs `ctx` to resolve the URL and forward cookies and session. `ctx` is not
|
|
uniformly available: `load` blocks have it, schema actions have it as a second parameter, plain
|
|
actions and server functions have neither.
|
|
|
|
The server stores the request context in an `AsyncLocalStorage` at request entry, and the server
|
|
`api` object reads it. This is new machinery — the framework uses none today — and it must be
|
|
established in both the dev server and the production server, or a call that works in development
|
|
fails in production.
|
|
|
|
When no context is present, the call throws with a message naming the block and explaining that an
|
|
API call needs a request context — never a silent `undefined`.
|
|
|
|
## Emission
|
|
|
|
A block's `response` and `error` bodies are page code. They ship to the browser **only when a
|
|
client-side call to that block exists**. The compiler already knows which `api.<name>()` calls
|
|
appear in client functions.
|
|
|
|
This keeps server-only transforms off the wire and the client bundle proportional to what it uses.
|
|
The consequence to know: adding the first client call to a block starts shipping that block's
|
|
bodies. Anything secret belongs in the endpoint, not in a `response` body.
|
|
|
|
## Render binding
|
|
|
|
A block can be bound into markup, which calls it during render and substitutes the result:
|
|
|
|
```wrn
|
|
<p api="listTeams">loading…</p>
|
|
<ul api="searchUsers({ name: nameFilter })">
|
|
{#each searchUsers as person}<li>{person.name}</li>{/each}
|
|
</ul>
|
|
```
|
|
|
|
Three accepted forms: `api="name"`, `api="name()"` — equivalent — and `api="name({ … })"`, which
|
|
passes arguments. The argument expression is evaluated in the same scope as other view expressions
|
|
at render time. This mirrors `@click="search()"`, so it introduces no new escaping or naming rules.
|
|
|
|
### The edge this creates, stated plainly
|
|
|
|
A block that is both render-bound and called from code **runs twice** — once for the binding, once
|
|
for the call. They are two different lifecycles wearing one name, and no deduplication is attempted:
|
|
a render-time fetch and a user-triggered fetch are usually meant to be different requests, and
|
|
silently collapsing them would be worse than the duplication. Authors binding a block _and_ calling
|
|
it should expect two requests.
|
|
|
|
## Replacing the old forms
|
|
|
|
`ssr { … }` and `client { … }` data blocks are removed. Each could contain only two things, and both
|
|
have a home:
|
|
|
|
| Old | New |
|
|
| ------------------------------------------------ | ------------------------------------------------------ |
|
|
| `ssr { api x … }` / `client { api x … }` | `apis { x … }` |
|
|
| `ssr { functions { function helper() } }` | `functions { shared function helper() }` |
|
|
| legacy bare-body `api x GET /p { return users }` | `apis { x GET /p { response { return data.users } } }` |
|
|
|
|
The legacy bare body injected the payload with `with ($data ?? {})`, which is **untypeable** —
|
|
TypeScript cannot see through `with`, and that is the entire reason sectioned blocks bind a named
|
|
`data`. Removing the legacy form removes that fork: one payload binding, typed.
|
|
|
|
`client state { }` is a different construct that shares the keyword and is **not** affected.
|
|
|
|
`examples/basic-app/app/pages/hello.wrn` uses both old forms and is the migration's worked example.
|
|
|
|
## Type safety
|
|
|
|
Assertions are generated into `app/types/wrnexus.generated.api-checks.ts` and checked by the
|
|
project's own `tsc`, as established. Two changes follow from mode-less declarations:
|
|
|
|
- The current generator skips blocks whose `mode !== "client"`. That skip exists because an `ssr`
|
|
block could never declare a `request`. Mode-less blocks invalidate the reasoning, so **every block
|
|
with declared fields gets an assertion**.
|
|
- The zero-field skip stays: a block with no declared fields has nothing to check, and asserting
|
|
`Record<string, never>` against a contract fails spuriously.
|
|
|
|
## Known edges
|
|
|
|
- **`state api` stops working.** The name is currently excluded from destructuring only when a page
|
|
has client api blocks, so pages without them keep using it. Once `api` is universal that
|
|
protection goes, and a page with `state api` breaks. It is a build-time failure, not silent.
|
|
- **Every block is browser-reachable in principle.** The routes were already publicly reachable by
|
|
`fetch`, so this exposes no new surface — but a block is no longer implicitly server-only by
|
|
virtue of its placement.
|
|
|
|
## Testing
|
|
|
|
**Parser**
|
|
|
|
- `apis { }` parses multiple mode-less entries; duplicate names are rejected.
|
|
- `ssr { api … }` and `client { api … }` are rejected with a message naming the replacement.
|
|
- All three binding forms parse, including an argument expression containing a nested object.
|
|
|
|
**Dispatch**
|
|
|
|
- A client-side call issues one `fetch` with the expected URL, method, body, and CSRF header.
|
|
- A server-side call dispatches in-process and issues **no** network request — asserted by observing
|
|
that no fetch occurs, not merely that the result is right.
|
|
- A server-side call with no request context throws a message naming the block.
|
|
- The same declared input produces the same request on both sides — the shared-assembly guard.
|
|
|
|
**Emission**
|
|
|
|
- A block called only from server code does not appear in the browser module.
|
|
- A block called from a client function does.
|
|
|
|
**Render binding**
|
|
|
|
- Each of the three forms renders the resolved value.
|
|
- A bound block that is also called issues two requests, pinning the documented edge.
|
|
|
|
**Type safety**
|
|
|
|
- A block declaring a field its endpoint rejects fails `bun run typecheck`, asserted by running
|
|
`tsc` and reading its diagnostics — not by matching generated text.
|
|
|
|
**End to end**
|
|
|
|
- `examples/basic-app` migrated to `apis { }`, driven in a browser: a client call updates state, a
|
|
render-bound block appears in the served HTML, and a deliberately failing call takes the `error`
|
|
path.
|
|
|
|
## Deferred
|
|
|
|
- External API targets, and the allowlist and credential handling they need.
|
|
- Author-settable headers.
|
|
- Deduplicating a render-bound block against a code call.
|
|
- Response caching and request de-duplication.
|