Files
WRNexusJS/docs/superpowers/specs/2026-08-19-apis-block-design.md
T
ClintchizandClaude Opus 5 43652c14af 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>
2026-08-19 22:52:31 +05:30

11 KiB

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

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

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.
  • ServercallApiFromContext, 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:

<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.