docs: specs for apis blocks, migration, and editor tooling

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>
This commit is contained in:
2026-08-19 22:52:31 +05:30
co-authored by Claude Opus 5
parent e40d8319a6
commit 43652c14af
3 changed files with 521 additions and 0 deletions
@@ -0,0 +1,250 @@
# 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.
@@ -0,0 +1,131 @@
# Editor support for the new syntax — Design
**Date:** 2026-08-19
**Status:** Approved for implementation
**Scope:** Language server and VS Code extension updated for `apis { }`, the `api.<name>()` call, and
the removal of the mode data blocks.
**Depends on:** `2026-08-19-apis-block-design.md`. The syntax must exist before the editor can
describe it.
## Goal
A syntax change that the editor does not know about is worse than no change: valid code is
red-underlined, removed constructs still autocomplete, and the new block gets no highlighting. This
spec keeps the tooling level with the language.
It also settles a question left open by earlier work: whether type errors from the generated
assertions actually appear inside the `.wrn` file, or only in the generated file. That was never
verified — it was inferred from reading source — and inference is not good enough for the thing
developers rely on to tell them their code is wrong.
### Non-goals
- New editor features unrelated to this syntax change.
- Editors other than VS Code beyond what standard LSP provides.
- HTML formatting — `formatWrn` still owns markup.
## What exists
- **Grammar:** `editors/vscode/syntaxes/wrn.tmLanguage.json` names block keywords directly —
`api` appears 4 times, `client` 6, `server` 5, `ssr` once.
- **Keyword list:** `WRN_KEYWORDS` in `packages/language-server/src/index.ts` drives completion and
includes `api` but not `apis`.
- **Extension completions:** `editors/vscode/src/completion.js` carries block snippets.
- **Server features:** completion, hover, folding, linked editing, tag completion, and TypeScript
diagnostics over a virtual document.
## Changes by surface
### Grammar
Add `apis` as a block keyword. Remove the `ssr` / `client` **data block** patterns, keeping `client`
and `server` where they mean other things — `client state { }`, the `runtime` values, and the
function modifiers in `functions { }`. This is the change most likely to over-reach: `client` is one
word with several jobs in this language, and blanket removal would un-highlight constructs that
still exist.
Highlight an `apis` entry's shape — name, method, path — so a declaration reads as a declaration
rather than as loose identifiers.
### Keyword and block completion
- `apis` joins `WRN_KEYWORDS`.
- A snippet for the container and a snippet for an entry, including the `request` / `response` /
`error` sections, so the shape is discoverable without the docs.
- `ssr` and `client` data-block snippets are removed from `completion.js`. Offering a construct the
compiler rejects is worse than offering nothing.
### Call completion — the feature worth building
Inside a function body, `api.` should complete to the names declared in that page's `apis { }`
block, with the method and path as detail. The server already indexes the document to build
completions, and the block names are in the AST.
This is where the syntax pays off in the editor: the set of legal calls is knowable, so the editor
should know it. Without it, `api.` is an empty namespace and every call is typed from memory.
Hovering a name inside `api.<name>()` shows its method, path, and declared request fields.
### The `api=` attribute in markup
`api="searchUsers({ name: nameFilter })"` is an attribute whose value is a call expression. The HTML
service must not flag it as an unknown attribute, and the expression must not be treated as plain
text. Completion inside the quotes offers the page's block names, matching the `api.` behaviour.
### Diagnostics for removed constructs
An `ssr { api … }` or `client { api … }` block gets a diagnostic naming `apis { }` as the
replacement, positioned on the block keyword. The compiler already rejects these; the editor should
say so while typing rather than at build time, and it should say what to do instead.
## Inline type errors — verifying, not assuming
The `apis` spec generates assertions into `app/types/wrnexus.generated.api-checks.ts`, and `tsc`
fails when a block declares a field its endpoint rejects. Whether that failure surfaces **inside the
`.wrn` file** has never been confirmed.
This spec resolves it in two steps, in order:
1. **Observe the current behaviour.** With a deliberately wrong field in place, open the page in VS
Code and record where the error appears: on the block, only in the generated file, or nowhere.
2. **Act on what is observed.** If the error already surfaces usefully, document it and stop. If it
appears only in the generated file, map the diagnostic back to the block that produced it — the
generator knows which page and block each assertion came from, so the mapping is available if it
is recorded rather than discarded.
If step 2 proves larger than this spec can hold, it becomes its own work, and the spec says so
plainly rather than leaving an unfinished feature implied. **Nothing here should claim inline
diagnostics work until someone has seen them work.**
## Testing
**Grammar** — a fixture page using `apis { }`, `client state { }`, `functions { shared function }`,
and `runtime = "client"` tokenizes correctly; `client` keeps its highlighting everywhere it is still
valid. This is the guard against over-reaching removal.
**Completion**
- `apis` is offered at page level; `ssr` / `client` data blocks are not.
- `api.` inside a function body offers the page's declared names with method and path.
- Inside `api="…"` in markup, the same names are offered.
- Outside those contexts, completion is unchanged — the guard that non-API editing is undisturbed.
**Hover** — a name inside `api.<name>()` reports its method, path, and request fields.
**Diagnostics** — an `ssr { api … }` block produces a diagnostic naming `apis { }`, positioned on
the block keyword.
**Bundles**`check:editor-compiler`, `check:editor-language-server`, and
`check:editor-extension` pass. These embed the compiler and language server, so they must be rebuilt
after the syntax change; a stale bundle fails the gate.
**Manual, and recorded in the implementation notes** — open the migrated `examples/basic-app` in VS
Code: `apis { }` highlights, `api.` completes, a removed construct is flagged, and the inline
type-error question above is answered by observation.
## Deferred
- Mapping generated assertion diagnostics back into `.wrn`, if step 2 above proves too large.
- Moving the remaining component intelligence out of `completion.js` and into the server.
- Editors other than VS Code.
@@ -0,0 +1,140 @@
# Carrying projects to the new syntax with `wrnexus update` — Design
**Date:** 2026-08-19
**Status:** Approved for implementation
**Scope:** Migrations that take an existing project from today's syntax to the syntax left by the
cleanup and `apis { }` specs.
**Depends on:** `2026-08-19-legacy-and-config-cleanup-design.md` and
`2026-08-19-apis-block-design.md`. Both define the target this migrates to, so both must land first.
## Goal
After the two preceding specs, every existing project is written in a syntax the framework no longer
accepts. One `wrnexus update` should carry a project across — config keys removed, `api` blocks
moved into `apis { }`, mode-scoped helpers relocated — and, where it cannot do that safely, say so
precisely instead of guessing.
## What already exists
This is an extension of working machinery, not a new subsystem:
- `Migration { version, id, description, apply(ctx) }`, run when `from < version <= to`.
- `MigrationCtx` carries `appRoot`, `from`, `to`, **`dryRun`**, a report, and a logger.
- `MigrationReport` already separates `changedAutomatically`, `needsReview`, `parseFailures`,
`ambiguousFunctions`, and `unresolvedImports`.
- `update.ts` already imports `parse` and `formatWrn` from `@wrnexus/syntax`, so parsing a `.wrn`
file, transforming it, and re-emitting formatted source is an established pattern here.
The report's shape matters: it was built around the idea that some changes are safe to make and
others must be handed back to a human. That distinction is the backbone of this spec.
### Non-goals
- Migrating projects below `0.8.0`. The cleanup spec removes those migrations; no such project
exists.
- Rewriting application logic. Only the constructs these specs changed.
## The safety contract
**A file is transformed correctly, or it is left untouched and reported.** There is no third
outcome. Concretely:
1. Parse the file. A parse failure records the path in `parseFailures` and moves on — the file is
never partially rewritten.
2. Transform, then re-emit through `formatWrn`.
3. If any part of a file's transform cannot be completed, **the whole file is skipped** and recorded
in `needsReview` with the reason and the construct involved.
Running the migration twice must be a no-op: every transform detects already-migrated input and
does nothing. Dry-run must report exactly what a real run would change.
## The migrations
### 1. Remove the dead config keys
Delete `compatibility`, `functions`, `compatibilityDate`, and `frameworkBehaviour` from
`wrnexus.config.ts`. Mechanical, fully automatic, and safe because none of them was ever read.
### 2. `ssr { api … }` / `client { api … }` → `apis { … }`
Move each `api` entry into a page-level `apis { }` block, dropping the mode. Sectioned bodies —
those already using `request` / `response` / `error` — carry across unchanged, because the payload
is already bound to `data`.
Fully automatic. If a page has entries in both an `ssr` and a `client` block sharing a name, that is
a duplicate under the new rules and the **file is skipped and reported**, since choosing which one
survives is a decision about intent.
### 3. `ssr { functions { … } }` → `functions { shared function … }`
Relocate mode-scoped helpers to the page-level `functions { }` block with the `shared` modifier.
Automatic. If the page already has a function of the same name, the file is skipped and reported.
### 4. Legacy bare-body `api` blocks — **needs review, not automatic**
This is the one transform that cannot be done safely, and the spec is explicit about it rather than
attempting a best effort.
A legacy bare body is evaluated inside `with ($data ?? {})`, so it references payload fields as bare
identifiers:
```wrn
api ssrUsers GET /api/users/ssr {
return userNames(users)
}
```
The sectioned form binds the payload to `data`, so this must become `data.users`. But **which free
identifiers are payload fields is not knowable from the source.** In the example, `users` comes from
the response and `userNames` is a page helper — and nothing in the file distinguishes them. The
response shape belongs to the route, and the route may not even be typed.
A migration that guessed would produce code that compiles and is wrong: `data.userNames(...)` or an
untouched `users` that silently resolves to `undefined`. That is precisely the silent-wrong-answer
failure this project keeps paying for.
So: legacy bare-body blocks are **detected, reported in `needsReview` with the file, the block name,
and the free identifiers found**, and left untouched. The report tells the author exactly what to
decide. `wrnexus update` prints a short explanation of why this one is manual.
### 5. Deprecated `@wrnexus/auth` options
Only if the cleanup spec's optional auth section is included. Rename call sites of the superseded
options. Automatic where the rename is unambiguous; reported otherwise.
## Version
All of these attach to the release that ships the breaking change. After the cleanup spec the
migration floor is `0.8.0`, so the list is short and every entry is reachable.
## Output
At the end of a run the command prints, in this order: what it changed, what needs review and why,
and what failed to parse. A run with anything in `needsReview` or `parseFailures` exits non-zero, so
a scripted upgrade cannot appear to succeed while leaving a project half-migrated.
## Testing
Each migration gets a fixture project and three assertions: the transform produces the expected
source, running it a second time changes nothing, and a dry run reports the same set without writing.
- **Config removal** — keys gone, rest of the config untouched.
- **`api` relocation** — a page with both `ssr` and `client` api blocks lands in one `apis { }`;
entries keep their names, methods, paths, and sections.
- **Name collision across modes** — the file is skipped and reported, not silently merged.
- **Mode functions** — relocated with the `shared` modifier; a name collision skips and reports.
- **Legacy bare body** — reported in `needsReview` with the block name and free identifiers, and the
file is byte-identical afterwards. This is the most important test in the spec: it pins that the
migration does _not_ attempt the rewrite.
- **Parse failure** — a malformed `.wrn` is recorded in `parseFailures` and left untouched.
- **Exit code** — non-zero when anything needs review.
- **End to end** — `examples/basic-app` migrated by the command alone, then built and tested. If the
framework's own example cannot be migrated by the tool, the tool is not finished.
## What this does not promise
Automated source rewriting cannot be promised as "perfect". What is promised is bounded: every file
is either correctly transformed or untouched and named in the report, with the reason. Nothing is
half-rewritten, and nothing is guessed. The legacy bare-body case is deliberately manual because a
correct automatic answer does not exist.