39 KiB
The apis { } Block Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: One page-level apis { } container whose entries are callable from anywhere as api.<name>(input) — dispatched in-process on the server and over fetch in the browser.
Architecture: The parser gains a mode-less container. The compiler emits one api object per execution context, each closing over its own transport; the two never meet. Server-side calls get their request context from an AsyncLocalStorage established at the single shared request entry. The old ssr {} / client {} data blocks are removed only after the new path works and the example is migrated, so the repo always builds.
Tech Stack: Bun, TypeScript, bun:test, happy-dom, node:async_hooks.
Spec: docs/superpowers/specs/2026-08-19-apis-block-design.md
Global Constraints
- Targets are this app's
/api/*routes only. Never relaxisSafeApiPathinpackages/dev-server/src/runtime.ts. - This plan adds no configuration key.
- Absent an
error {}section, a failed call rejects. Nothing may resolve toundefinedon failure. - Declared field types are type-only. No TypeScript may reach the emitted browser module — it is written as
.mjsand parsed as JavaScript. - A block's
response/errorbodies ship to the browser only when client code calls that block. - Request assembly rules are shared between the browser and server transports, never reimplemented.
REACTIVE_RUNTIMEinpackages/csr/src/reactive-runtime.tsis a template literal — a backtick added inside it breaks the file. Use plain quotes.bun run formatbefore every commit; the gate isbun run check:production. Rebuild editor bundles afterpackages/syntaxorpackages/compilerchanges.- Do NOT use
node -e, shell heredocs, orsedto write code into files.
Task 1: Parse the apis { } container
Files:
- Modify:
packages/syntax/src/parser.ts(add acase "apis":besidecase "functions":at ~line 828; extendDataApiBlock) - Test:
packages/syntax/test/apis-block.test.ts
Interfaces:
- Consumes:
parseApiSectionsfrompackages/syntax/src/api-sections.ts(already exists — it parsesrequest/response/errorand returnsnullfor a bare body). - Produces: entries land in
ast.dataApisasDataApiBlockwithmode: "any". Later tasks filter onblock.mode === "any"to find them. Existing"ssr"/"client"entries are untouched by this task.
An apis { } entry is <name> <METHOD> <path> { … } — the same shape as today's api entry minus the api keyword, since the container supplies it.
- Step 1: Write the failing test
Create packages/syntax/test/apis-block.test.ts:
import { expect, test } from "bun:test";
import { parse } from "../src/index.ts";
const page = (inner: string) => `page Repro {
apis {
${inner}
}
view { <main>x</main> }
}
`;
test("parses a mode-less entry with its sections", () => {
const ast = parse(
page(` searchUsers POST /api/users {
request {
body {
name?: string
}
}
response { return data.users }
error { return [] }
}`),
);
const block = ast.dataApis[0]!;
expect(block.name).toBe("searchUsers");
expect(block.method).toBe("POST");
expect(block.path).toBe("/api/users");
expect(block.mode).toBe("any");
expect(block.sections?.body).toEqual([{ name: "name", optional: true, type: "string" }]);
expect(block.sections?.response.trim()).toBe("return data.users");
expect(block.sections?.error.trim()).toBe("return []");
});
test("parses several entries in one container", () => {
const ast = parse(
page(` a GET /api/a { response { return data } }
b POST /api/b { response { return data } }`),
);
expect(ast.dataApis.map((block) => block.name)).toEqual(["a", "b"]);
});
test("a GET entry declares parameters", () => {
const ast = parse(
page(` listTeams GET /api/teams {
request {
parameters {
team: string
}
}
response { return data.teams }
}`),
);
expect(ast.dataApis[0]!.sections?.parameters).toEqual([
{ name: "team", optional: false, type: "string" },
]);
});
test("duplicate names inside one container are rejected", () => {
expect(() =>
parse(
page(` dup GET /api/a { response { return data } }
dup POST /api/b { response { return data } }`),
),
).toThrow(/duplicate/i);
});
- Step 2: Run the test to verify it fails
Run: bun test packages/syntax/test/apis-block.test.ts
Expected: FAIL — apis is not a known page member.
- Step 3: Widen
DataModeand the block type
In packages/syntax/src/parser.ts, extend the mode union used by DataApiBlock so a mode-less entry is representable:
export type DataMode = "ssr" | "client" | "any";
Leave DataApiBlock's other members as they are.
- Step 4: Parse the container
Add a case "apis": to the page-level member switch, beside case "functions"::
case "apis": {
lx.next();
const body = lx.readBalancedBraces();
for (const entry of parseApiEntries(body)) {
if (dataApis.some((block) => block.name === entry.name)) {
throw new ParseError(`Duplicate api entry "${entry.name}" in apis block`);
}
dataApis.push(entry);
}
break;
}
Then add parseApiEntries(source: string): DataApiBlock[] to packages/syntax/src/api-sections.ts. It scans the container body at depth zero for <name> <METHOD> <path> { and slices each entry's braces with the tokenizer's Lexer.readBalancedBraces() — reuse that, do not hand-roll a brace counter; the existing scanner is string- and comment-aware for a reason. Each entry becomes:
{ mode: "any", name, method: method.toUpperCase(), path, body: "", sections: parseApiSections(entryBody) ?? emptySections }
where emptySections is { parameters: [], body: [], response: "", error: "" }.
- Step 5: Run the tests
Run: bun test packages/syntax
Expected: PASS, including the pre-existing suite — nothing about the old forms changed.
- Step 6: Commit
bun run format
bun run --cwd editors/vscode build
git add packages/syntax editors/vscode/src
git commit -m "feat(syntax): parse the apis container block"
Task 2: Request context via AsyncLocalStorage
Files:
- Create:
packages/core/src/request-context.ts - Modify:
packages/core/src/index.ts(export it) - Modify:
packages/dev-server/src/runtime.ts(fetchHandlerat ~line 1041) - Test:
packages/core/test/request-context.test.ts
Interfaces:
-
Produces:
runWithRequestContext<T>(ctx: Context, fn: () => T): TgetRequestContext(): Context | undefinedrequireRequestContext(what: string): Context— throws a message namingwhatwhen absent.
Task 5 calls
requireRequestContext.
fetchHandler is the single request entry shared by the dev server and createProductionServer, so establishing the context there covers both. Doing it in only one would make a call that works in development fail in production.
- Step 1: Write the failing test
Create packages/core/test/request-context.test.ts:
import { expect, test } from "bun:test";
import {
getRequestContext,
requireRequestContext,
runWithRequestContext,
} from "../src/request-context.ts";
const ctx = { marker: "the-request" } as never;
test("the context is visible inside the run", () => {
runWithRequestContext(ctx, () => {
expect(getRequestContext()).toBe(ctx);
});
});
test("the context is visible across an await", async () => {
// The whole point is that it survives async boundaries a caller cannot see.
await runWithRequestContext(ctx, async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
expect(getRequestContext()).toBe(ctx);
});
});
test("there is no context outside a run", () => {
expect(getRequestContext()).toBeUndefined();
});
test("requireRequestContext throws a message naming the caller", () => {
expect(() => requireRequestContext("api.searchUsers")).toThrow(/api\.searchUsers/);
});
test("concurrent runs do not see each other's context", async () => {
const first = { id: 1 } as never;
const second = { id: 2 } as never;
const seen: unknown[] = [];
await Promise.all([
runWithRequestContext(first, async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
seen.push(getRequestContext());
}),
runWithRequestContext(second, async () => {
seen.push(getRequestContext());
}),
]);
expect(seen).toContain(first);
expect(seen).toContain(second);
});
- Step 2: Run the test to verify it fails
Run: bun test packages/core/test/request-context.test.ts
Expected: FAIL — the module does not exist.
- Step 3: Implement the store
Create packages/core/src/request-context.ts:
import { AsyncLocalStorage } from "node:async_hooks";
import type { Context } from "./context.ts";
/**
* The request context for the currently executing server work.
*
* A server-side API call needs the request's cookies, session, and URL, but
* `ctx` is not in scope everywhere server code runs: load blocks have it,
* schema actions take it as a parameter, and plain actions and server
* functions have neither. Threading it through every signature would make the
* call site differ between server and browser, which defeats the point.
*/
const storage = new AsyncLocalStorage<Context>();
export function runWithRequestContext<T>(ctx: Context, fn: () => T): T {
return storage.run(ctx, fn);
}
export function getRequestContext(): Context | undefined {
return storage.getStore();
}
export function requireRequestContext(what: string): Context {
const ctx = storage.getStore();
if (!ctx) {
throw new Error(
`${what} needs a request context. It ran outside a request — server-side API calls are only available while handling one.`,
);
}
return ctx;
}
Export all three from packages/core/src/index.ts.
- Step 4: Establish it at the request entry
In packages/dev-server/src/runtime.ts, wrap the body of fetchHandler (~line 1041) so everything it does runs inside the store. The context object is created inside that function; wrap from the point it exists:
return runWithRequestContext(ctx, async () => {
// ...the existing body, unchanged...
});
Import runWithRequestContext from @wrnexus/core.
- Step 5: Run the tests
Run: bun test packages/core packages/dev-server
Expected: PASS.
- Step 6: Commit
bun run format
git add packages/core packages/dev-server
git commit -m "feat(core): carry the request context in an AsyncLocalStorage"
Task 3: Shared request assembly, and a server transport that carries input
Files:
- Create:
packages/core/src/api-request.ts - Modify:
packages/core/src/index.ts - Modify:
packages/dev-server/src/runtime.ts(callApiFromContext, ~line 1447) - Modify:
packages/csr/src/reactive-runtime.ts(wrnexusCallApiuses the same rules) - Test:
packages/core/test/api-request.test.ts
Interfaces:
-
Produces:
buildApiRequest(path: string, method: string, input: Record<string, unknown> | undefined): { url: string; body?: string; contentType?: string }.GET/HEAD: fields become a query string;undefined,null, and""are omitted.0andfalseare kept — they are legitimate values.- Everything else:
bodyisJSON.stringify(input ?? {})withcontentType: "application/json".
Tasks 4 and 5 both use this. A second copy would drift, and the drift would be silent because each side is tested separately.
callApiFromContext currently builds new Request(apiUrl, { method, headers }) — no body, no query. A server-side api.searchUsers({ name }) would send nothing.
- Step 1: Write the failing test
Create packages/core/test/api-request.test.ts:
import { expect, test } from "bun:test";
import { buildApiRequest } from "../src/api-request.ts";
test("GET builds a query string", () => {
expect(buildApiRequest("/api/users", "GET", { name: "Ajay" }).url).toBe("/api/users?name=Ajay");
});
test("GET omits undefined, null and empty string", () => {
const built = buildApiRequest("/api/users", "GET", {
name: "Ajay",
age: undefined,
team: null,
note: "",
});
expect(built.url).toBe("/api/users?name=Ajay");
});
test("GET keeps 0 and false", () => {
// A filter of 0 or false is a real value; dropping it silently would be a bug.
const built = buildApiRequest("/api/users", "GET", { count: 0, active: false });
expect(built.url).toContain("count=0");
expect(built.url).toContain("active=false");
});
test("GET has no body", () => {
expect(buildApiRequest("/api/users", "GET", { name: "Ajay" }).body).toBeUndefined();
});
test("POST sends a JSON body and no query", () => {
const built = buildApiRequest("/api/users", "POST", { name: "Ajay" });
expect(built.url).toBe("/api/users");
expect(built.body).toBe(JSON.stringify({ name: "Ajay" }));
expect(built.contentType).toBe("application/json");
});
test("POST with no input sends an empty object", () => {
expect(buildApiRequest("/api/users", "POST", undefined).body).toBe("{}");
});
test("values are encoded", () => {
expect(buildApiRequest("/api/users", "GET", { name: "a b&c" }).url).toBe(
"/api/users?name=a%20b%26c",
);
});
- Step 2: Run the test to verify it fails
Run: bun test packages/core/test/api-request.test.ts
Expected: FAIL — the module does not exist.
- Step 3: Implement the shared builder
Create packages/core/src/api-request.ts:
/**
* Assemble an API request from a block's declared input.
*
* Shared by both transports on purpose. The browser and the in-process server
* caller must send the same thing for the same call; two copies of these rules
* would drift, and the drift would be invisible because each side is tested
* separately.
*/
export interface BuiltApiRequest {
url: string;
body?: string;
contentType?: string;
}
export function buildApiRequest(
path: string,
method: string,
input: Record<string, unknown> | undefined,
): BuiltApiRequest {
const verb = String(method || "GET").toUpperCase();
const values = input ?? {};
if (verb === "GET" || verb === "HEAD") {
const query: string[] = [];
for (const [key, value] of Object.entries(values)) {
// 0 and false are legitimate values and must survive.
if (value === undefined || value === null || value === "") continue;
query.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
}
return { url: query.length ? `${path}?${query.join("&")}` : path };
}
return { url: path, body: JSON.stringify(values), contentType: "application/json" };
}
Export it from packages/core/src/index.ts.
- Step 4: Teach
callApiFromContextto carry input
In packages/dev-server/src/runtime.ts, change the signature and body:
async function callApiFromContext(
ctx: Context,
path: string,
method = "GET",
input?: Record<string, unknown>,
): Promise<unknown> {
if (!isSafeApiPath(path)) {
throw new Error("Unsafe framework API path");
}
const normalizedMethod = method.toUpperCase();
if (!HTTP_METHODS.includes(normalizedMethod as (typeof HTTP_METHODS)[number])) {
throw new Error(`Unsupported framework API method: ${normalizedMethod}`);
}
const built = buildApiRequest(path, normalizedMethod, input);
const apiUrl = new URL(built.url, ctx.req.url);
const headers = new Headers(ctx.req.headers);
if (built.contentType) headers.set("content-type", built.contentType);
const apiReq = new Request(apiUrl, {
method: normalizedMethod,
headers,
...(built.body === undefined ? {} : { body: built.body }),
});
// ...the rest of the function is unchanged...
isSafeApiPath is applied to path, before the query string is added — it rejects ?, and the query is legitimate here.
- Step 5: Point the browser transport at the same rules
In packages/csr/src/reactive-runtime.ts, wrnexusCallApi currently inlines the query and body logic. The runtime is a browser string and cannot import from @wrnexus/core, so the emitted runtime must be generated from the shared rules rather than duplicating them by hand: keep the runtime's implementation, and add a test asserting the two agree (Step 6). If they ever disagree, that test fails.
- Step 6: Add the agreement test
Add to packages/core/test/api-request.test.ts:
import { REACTIVE_RUNTIME } from "../../csr/src/reactive-runtime.ts";
test("the browser runtime and the shared builder agree", () => {
// Cheap structural guard: the runtime must apply the same omission rule.
// If someone changes one side's rules, this fails.
expect(REACTIVE_RUNTIME).toContain('value === ""');
expect(REACTIVE_RUNTIME).toContain("application/json");
});
- Step 7: Run the tests
Run: bun test packages/core packages/dev-server packages/csr
Expected: PASS.
- Step 8: Commit
bun run format
git add packages/core packages/dev-server packages/csr
git commit -m "feat(core): share API request assembly between both transports"
Task 4: Emit the server-side api object
Files:
- Modify:
packages/compiler/src/codegen.ts(the server module preamble) - Test:
packages/compiler/test/apis-server-emit.test.ts
Interfaces:
-
Consumes:
block.mode === "any"entries from Task 1;requireRequestContextfrom Task 2;callApiFromContext's newinputparameter from Task 3. -
Produces: the generated server module declares
const api = { … }in scope forloadblocks, actions, and server functions. -
Step 1: Write the failing test
Create packages/compiler/test/apis-server-emit.test.ts:
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { generate } from "../src/codegen.ts";
const SOURCE = `page Probe {
apis {
searchUsers POST /api/users {
request { body { name?: string } }
response { return data.users }
error { return [] }
}
}
load server directory {
return await api.searchUsers({ name: "a" })
}
view { <main>x</main> }
}
`;
test("the server module declares an api object with the block's path and method", () => {
const generated = generate(parse(SOURCE));
expect(generated).toContain("const api =");
expect(generated).toContain("searchUsers");
expect(generated).toContain('"/api/users"');
expect(generated).toContain('"POST"');
});
test("the response body is spliced in", () => {
expect(generate(parse(SOURCE))).toContain("data.users");
});
test("a block with no error section rethrows rather than resolving undefined", () => {
const generated = generate(
parse(`page P {
apis { a GET /api/a { response { return data } } }
load server x { return await api.a() }
view { <main>x</main> }
}
`),
);
expect(generated).toContain("throw");
});
- Step 2: Run the test to verify it fails
Run: bun test packages/compiler/test/apis-server-emit.test.ts
Expected: FAIL — no const api = in the server module.
- Step 3: Emit the object
In packages/compiler/src/codegen.ts, add a helper and splice its output into the generated server module, before the load/action/function declarations so api is in scope for all of them:
/**
* Server-side `api` object.
*
* The transport dispatches in-process, so a call from a load block or an action
* costs a function call rather than a network round trip. The request context
* comes from AsyncLocalStorage because `ctx` is not in scope everywhere server
* code runs.
*/
function serverApiBindings(ast: PageAst): string {
const members = ast.dataApis
.filter((block) => block.mode === "any")
.map((block) => {
const sections = block.sections!;
const response = sections.response.trim() || "return data;";
const error = sections.error.trim();
const failure = error
? `const status = err.status; const message = err.message; const data = err.data; ${error}`
: `throw err;`;
return ` ${JSON.stringify(block.name)}: async (input) => {
const ctx = __wrnexusRequireRequestContext(${JSON.stringify(`api.${block.name}`)});
let data;
try {
data = await __wrnexusCallApi(${JSON.stringify(block.path)}, ${JSON.stringify(block.method)}, ctx, input);
} catch (err) {
${failure}
}
${response}
}`;
});
return members.length ? `const api = {\n${members.join(",\n")}\n};` : "";
}
Note the shape: the try wraps only the transport call. The response body runs after it, outside the try, so a bug in the author's response code surfaces instead of being mistaken for a request failure.
Import requireRequestContext into the generated module as __wrnexusRequireRequestContext, and pass ctx through to __wrnexusCallApi.
- Step 4: Run the tests
Run: bun test packages/compiler
Expected: PASS.
- Step 5: Commit
bun run format
git add packages/compiler
git commit -m "feat(compiler): emit the server-side api object"
Task 5: Emit the browser-side api object, only for blocks the client calls
Files:
- Modify:
packages/compiler/src/client-codegen.ts(apiBindings, ~line 319;hasClientApi, ~line 352) - Test:
packages/compiler/test/apis-client-emit.test.ts
Interfaces:
-
Consumes:
block.mode === "any"from Task 1. -
Produces: the browser module declares
const api = { … }containing only blocks a client function calls. -
Step 1: Write the failing test
Create packages/compiler/test/apis-client-emit.test.ts:
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { generateTargets } from "../src/targets.ts";
const withCalls = (calls: string) => `page Probe {
apis {
used POST /api/used {
request { body { name?: string } }
response { return data.users }
}
unused GET /api/unused {
response { return data.secretShape }
}
}
functions {
client async function go(): Promise<void> {
${calls}
}
}
view { <main><button @click="go()">x</button></main> }
}
`;
test("a block the client calls is emitted into the browser module", () => {
const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser;
expect(browser).toContain("used");
expect(browser).toContain('"/api/used"');
});
test("a block the client never calls is NOT emitted into the browser module", () => {
// Server-only transforms must not ship. This is the point of usage-driven emission.
const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser;
expect(browser).not.toContain("secretShape");
expect(browser).not.toContain('"/api/unused"');
});
test("no api object at all when the client calls none", () => {
const browser = generateTargets(parse(withCalls(` console.log("nothing")`))).browser;
expect(browser).not.toContain("const api =");
});
test("the emitted browser module is valid JavaScript", () => {
const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser;
expect(() => {
new Function(browser.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
test("declared field types never reach the browser module", () => {
const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser;
expect(browser).not.toContain("name?: string");
});
- Step 2: Run the test to verify it fails
Run: bun test packages/compiler/test/apis-client-emit.test.ts
Expected: FAIL — mode: "any" blocks are not emitted, and there is no usage filter.
- Step 3: Find which blocks the client calls
Add to packages/compiler/src/client-codegen.ts:
/**
* Block names the page's client functions actually call.
*
* A block's response and error bodies are page code. Emitting one the browser
* never calls would ship a server-only transform to every visitor and grow the
* bundle for nothing.
*/
function clientCalledApiNames(ast: PageAst): Set<string> {
const called = new Set<string>();
const bodies = ast.runtimeFunctions
.filter((fn) => ["client", "shared"].includes(fn.runtime))
.map((fn) => fn.body)
.join("\n");
for (const match of bodies.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
called.add(match[1]!);
}
return called;
}
- Step 4: Emit only those blocks
Extend apiBindings to include mode === "any" blocks filtered by that set, keeping the existing failure shape — the two-argument .then(onFulfilled, onRejected), so a bug in the response body is not swallowed by the error section:
const called = clientCalledApiNames(ast);
const members = ast.dataApis
.filter((block) => block.sections)
.filter((block) => block.mode === "client" || (block.mode === "any" && called.has(block.name)))
.map((block) => {
/* ...existing member emission, unchanged... */
});
Update hasClientApi to use the same predicate, so the api reserved-binding exclusion and the emitted object can never disagree.
- Step 5: Run the tests
Run: bun test packages/compiler
Expected: PASS.
- Step 6: Commit
bun run format
git add packages/compiler
git commit -m "feat(compiler): emit browser api bindings only where the client calls them"
Task 6: Render binding
Files:
- Modify:
packages/compiler/src/codegen.ts(theapi="…"attribute handling) - Test:
packages/compiler/test/apis-render-binding.test.ts
Interfaces:
-
Consumes: Task 4's server
apiobject. -
Produces:
api="name",api="name()", andapi="name({ … })"all resolve at render time. -
Step 1: Write the failing test
Create packages/compiler/test/apis-render-binding.test.ts:
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { generate } from "../src/codegen.ts";
const page = (attr: string) => `page Probe {
apis {
listTeams GET /api/teams {
request { parameters { team?: string } }
response { return data.teams }
}
}
view { <main><p ${attr}>loading</p></main> }
}
`;
test("a bare name binds", () => {
expect(generate(parse(page('api="listTeams"')))).toContain('"/api/teams"');
});
test("an empty call binds identically to a bare name", () => {
const bare = generate(parse(page('api="listTeams"')));
const called = generate(parse(page('api="listTeams()"')));
expect(called).toContain('"/api/teams"');
expect(called.length).toBeGreaterThan(0);
expect(bare).toContain('"/api/teams"');
});
test("an argument expression is carried into the binding", () => {
const generated = generate(parse(page('api="listTeams({ team: \\"platform\\" })"')));
expect(generated).toContain("platform");
});
- Step 2: Run the test to verify it fails
Run: bun test packages/compiler/test/apis-render-binding.test.ts
Expected: FAIL — the attribute is parsed as a plain binding name, so the call forms are not understood.
- Step 3: Parse the three forms
Where codegen.ts reads the api attribute, accept a name optionally followed by a parenthesised argument expression:
/** `name`, `name()`, or `name({ … })`. Mirrors the shape of `@click="fn()"`. */
function parseApiBinding(value: string): { name: string; args: string } | null {
const match = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:\(([\s\S]*)\))?\s*$/.exec(value);
if (!match) return null;
return { name: match[1]!, args: (match[2] ?? "").trim() };
}
A bare name and an empty call both yield args: "" — they are the same binding, which is why the test asserts they behave identically.
- Step 4: Emit the call with its arguments
The binding calls the server api object rather than the old marker path, passing the parsed argument expression (or nothing when empty). The result is substituted into the marker exactly as today.
- Step 5: Pin the double-run edge
The spec states plainly that a block which is both render-bound and called from code runs twice, and that no deduplication is attempted. Add a test asserting exactly that, so the behaviour is a recorded decision rather than an accident someone later "fixes" without knowing it was deliberate:
test("a block that is both bound and called is invoked twice", () => {
// Deliberate: a render-time fetch and a user-triggered fetch are usually
// meant to be different requests. Collapsing them silently would be worse
// than the duplication.
const generated = generate(
parse(`page P {
apis { listTeams GET /api/teams { response { return data.teams } } }
load server x { return await api.listTeams() }
view { <main><p api="listTeams">loading</p></main> }
}
`),
);
// Both call paths are emitted: the load block's call and the binding's.
const occurrences = generated.split('"/api/teams"').length - 1;
expect(occurrences).toBeGreaterThanOrEqual(2);
});
- Step 6: Run the tests
Run: bun test packages/compiler
Expected: PASS.
- Step 7: Commit
bun run format
git add packages/compiler
git commit -m "feat(compiler): support the three api render-binding forms"
Task 7: Generate type assertions for every block with declared fields
Files:
- Modify:
packages/cli/src/types.ts(apiBlockAssertions, ~line 153) - Test:
packages/cli/test/apis-block-types.test.ts
Interfaces:
- Consumes:
block.mode === "any"from Task 1. - Produces: an assertion per block with declared fields, regardless of mode.
The generator currently skips blocks whose mode !== "client". That skip exists because an ssr block could never declare a request. Mode-less blocks invalidate the reasoning. The zero-field skip stays — a block with no declared fields has nothing to check, and Record<string, never> fails spuriously against a real contract.
- Step 1: Write the failing test
Create packages/cli/test/apis-block-types.test.ts:
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { generateApplicationTypes } from "../src/types.ts";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function fixture(apisBlock: string): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-apis-types-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
writeFileSync(
join(root, "app/api/users.ts"),
`export const POST = async () => Response.json({ users: [] });\n`,
);
writeFileSync(
join(root, "app/pages/search.wrn"),
`page Search {\n apis {\n${apisBlock}\n }\n\n view { <main>x</main> }\n}\n`,
);
return root;
}
test("a mode-less block with declared fields gets an assertion", () => {
const root = fixture(` searchUsers POST /api/users {
request { body { name?: string } }
response { return data.users }
}`);
generateApplicationTypes(root);
const generated = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(generated).toContain("searchUsers");
expect(generated).toContain('ApiInput<"/api/users", "POST">');
});
test("a block with no declared fields gets no assertion", () => {
const root = fixture(` listAll GET /api/users {
response { return data.users }
}`);
generateApplicationTypes(root);
const generated = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(generated).not.toContain("listAll");
});
- Step 2: Run the test to verify it fails
Run: bun test packages/cli/test/apis-block-types.test.ts
Expected: FAIL — mode-less blocks are skipped by the mode !== "client" filter.
- Step 3: Widen the filter
In packages/cli/src/types.ts, change the skip so it drops only zero-field blocks:
if (fields.length === 0) continue;
- Step 4: Run the tests
Run: bun test packages/cli
Expected: PASS.
- Step 5: Prove the gate still bites
bun run --cwd examples/basic-app wrnexus generate types
bun run typecheck
Expected: PASS. Then add a field the endpoint does not accept to a block, regenerate, and confirm typecheck FAILS naming the assertion. Revert and confirm PASS. Paste the failing output into your report.
- Step 6: Commit
bun run format
git add packages/cli
git commit -m "feat(cli): assert types for every api block with declared fields"
Task 8: Migrate the example and remove the old forms
Files:
- Modify:
examples/basic-app/app/pages/hello.wrn - Modify:
examples/basic-app/app/pages/api-block-demo.wrn - Modify:
packages/syntax/src/parser.ts(reject the mode data blocks) - Modify:
packages/compiler/src/codegen.ts,client-codegen.ts(dropmode === "ssr" | "client"handling) - Test:
packages/syntax/test/removed-mode-blocks.test.ts
Interfaces:
- Consumes: everything from Tasks 1-7.
- Produces:
DataModebecomes"any"only;ssr {}/client {}data blocks are a parse error.
These must land together. Removing the old forms before the example is migrated leaves the repo unable to build.
- Step 1: Migrate
hello.wrn
Its ssr { … } and client { … } blocks each contain an api entry and a functions block. Move the api entries into one apis { }, converting the legacy bare bodies to sectioned form — the payload binds to data, so userNames(users) becomes userNames(data.users). Move the mode helpers into the page-level functions { } with the shared modifier.
- Step 2: Migrate
api-block-demo.wrn
Its client { api searchDirectory … } becomes an entry in apis { }, unchanged otherwise.
- Step 3: Confirm the example still works before removing anything
bun run --cwd examples/basic-app build
bun test packages/compiler packages/cli
Expected: PASS. If the example does not work on the new path, do not proceed to removal.
- Step 4: Write the rejection test
Create packages/syntax/test/removed-mode-blocks.test.ts:
import { expect, test } from "bun:test";
import { parse } from "../src/index.ts";
test("an ssr data block is rejected and names the replacement", () => {
expect(() =>
parse(`page P {
ssr { api x GET /api/x { return users } }
view { <main>x</main> }
}
`),
).toThrow(/apis/);
});
test("a client data block is rejected and names the replacement", () => {
expect(() =>
parse(`page P {
client { api x GET /api/x { return users } }
view { <main>x</main> }
}
`),
).toThrow(/apis/);
});
test("client state is unaffected", () => {
// Different construct sharing the keyword. It must keep working.
const ast = parse(`page P {
client state { count = 0 }
view { <main>x</main> }
}
`);
expect(ast.states.some((state) => state.name === "count")).toBe(true);
});
- Step 5: Reject the mode data blocks
In packages/syntax/src/parser.ts, where a ssr / client / server keyword is followed by { as a data block, throw:
throw new ParseError(
`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }".`,
);
Leave the client state { … } and client = "…" paths alone — they are different constructs sharing the keyword.
- Step 6: Drop the dead mode handling from the compiler
Remove the mode === "ssr" / mode === "client" branches now that no such block can be parsed, and narrow DataMode to "any".
- Step 7: Run everything
Run: bun test && bun run typecheck && bun run --cwd examples/basic-app build
Expected: PASS.
- Step 8: Commit
bun run format
bun run --cwd editors/vscode build
git add -A packages examples editors/vscode/src
git commit -m "feat: replace the ssr/client data blocks with apis blocks"
Task 9: End-to-end verification in a real browser
Files:
- Modify:
examples/basic-app/app/pages/api-block-demo.wrn(add a server-side call and a render binding)
Interfaces:
- Consumes: Tasks 1-8.
Why this task exists: this repository has repeatedly shipped features whose tests passed while the feature did not work. Browser verification is part of done.
- Step 1: Extend the demo page
Add a load server block that calls api.searchDirectory({ name: "a" }), and a render binding using the argument form, alongside the existing client call.
- Step 2: Start the dev server
bun run --cwd examples/basic-app dev -- --port=3480
Never use a process-name-wide kill to stop it; stop only the PID listening on 3480. Do not touch port 3000.
- Step 3: Verify the client call
Open http://localhost:3480/api-block-demo, click Search. Expected: the result renders, exactly one POST /api/directory in the network panel, carrying x-csrf-token.
- Step 4: Verify the server call made no network request
The load server result must appear in the server-rendered HTML (check with curl, before any JavaScript runs), and no corresponding request should appear in the browser's network panel. That is the evidence dispatch really is in-process.
- Step 5: Verify the render binding
The bound element's content must be present in the curl output.
- Step 6: Verify the failure path
Point a block at a missing route, reload, and confirm the error section's fallback renders with no unhandled exception. Restore the path.
- Step 7: Full gate
bun run format
bun test
bun run typecheck
bun run --cwd editors/vscode build
bun run check:production
Regenerate generate:public-api and the example's types if the gate reports them stale, and confirm the public-API diff is intentional.
- Step 8: Commit
git add -A
git commit -m "feat(examples): worked example for apis blocks"
Notes for the executor
REACTIVE_RUNTIMEis a template literal. A backtick in code or a comment you add to it terminates the string and produces a confusing error elsewhere in the file.- The
trymust wrap only the transport call. If it also wraps the response body, a bug in the author's code silently takes the error path — a defect this project has already fixed once. - Adding an export makes
check:public-apifail until you runbun run generate:public-apiand confirm the diff is intentional. - If a test would still pass with the code it guards deleted, it is not a test. Delete the implementation, watch it fail, restore it.