Files
WRNexusJS/docs/superpowers/plans/2026-08-19-apis-block.md
T

1187 lines
39 KiB
Markdown

# 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 relax `isSafeApiPath` in `packages/dev-server/src/runtime.ts`.
- This plan adds no configuration key.
- Absent an `error {}` section, a failed call rejects. Nothing may resolve to `undefined` on failure.
- Declared field types are type-only. No TypeScript may reach the emitted browser module — it is written as `.mjs` and parsed as JavaScript.
- A block's `response` / `error` bodies 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_RUNTIME` in `packages/csr/src/reactive-runtime.ts` is a template literal — a backtick added inside it breaks the file. Use plain quotes.
- `bun run format` before every commit; the gate is `bun run check:production`. Rebuild editor bundles after `packages/syntax` or `packages/compiler` changes.
- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files.
---
### Task 1: Parse the `apis { }` container
**Files:**
- Modify: `packages/syntax/src/parser.ts` (add a `case "apis":` beside `case "functions":` at ~line 828; extend `DataApiBlock`)
- Test: `packages/syntax/test/apis-block.test.ts`
**Interfaces:**
- Consumes: `parseApiSections` from `packages/syntax/src/api-sections.ts` (already exists — it parses `request` / `response` / `error` and returns `null` for a bare body).
- Produces: entries land in `ast.dataApis` as `DataApiBlock` with `mode: "any"`. Later tasks filter on `block.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`:
```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 `DataMode` and the block type**
In `packages/syntax/src/parser.ts`, extend the mode union used by `DataApiBlock` so a mode-less entry is representable:
```ts
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":`:
```ts
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:
```ts
{ 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**
```bash
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` (`fetchHandler` at ~line 1041)
- Test: `packages/core/test/request-context.test.ts`
**Interfaces:**
- Produces:
- `runWithRequestContext<T>(ctx: Context, fn: () => T): T`
- `getRequestContext(): Context | undefined`
- `requireRequestContext(what: string): Context` — throws a message naming `what` when 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`:
```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`:
```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:
```ts
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**
```bash
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` (`wrnexusCallApi` uses 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. `0` and `false` are **kept** — they are legitimate values.
- Everything else: `body` is `JSON.stringify(input ?? {})` with `contentType: "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`:
```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`:
```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 `callApiFromContext` to carry input**
In `packages/dev-server/src/runtime.ts`, change the signature and body:
```ts
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`:
```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**
```bash
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; `requireRequestContext` from Task 2; `callApiFromContext`'s new `input` parameter from Task 3.
- Produces: the generated server module declares `const api = { … }` in scope for `load` blocks, actions, and server functions.
- [ ] **Step 1: Write the failing test**
Create `packages/compiler/test/apis-server-emit.test.ts`:
```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:
```ts
/**
* 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**
```bash
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`:
```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`:
```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:
```ts
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**
```bash
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` (the `api="…"` attribute handling)
- Test: `packages/compiler/test/apis-render-binding.test.ts`
**Interfaces:**
- Consumes: Task 4's server `api` object.
- Produces: `api="name"`, `api="name()"`, and `api="name({ … })"` all resolve at render time.
- [ ] **Step 1: Write the failing test**
Create `packages/compiler/test/apis-render-binding.test.ts`:
```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:
```ts
/** `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:
```ts
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**
```bash
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`:
```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:
```ts
if (fields.length === 0) continue;
```
- [ ] **Step 4: Run the tests**
Run: `bun test packages/cli`
Expected: PASS.
- [ ] **Step 5: Prove the gate still bites**
```bash
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**
```bash
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` (drop `mode === "ssr" | "client"` handling)
- Test: `packages/syntax/test/removed-mode-blocks.test.ts`
**Interfaces:**
- Consumes: everything from Tasks 1-7.
- Produces: `DataMode` becomes `"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**
```bash
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`:
```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:
```ts
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**
```bash
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**
```bash
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**
```bash
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**
```bash
git add -A
git commit -m "feat(examples): worked example for apis blocks"
```
---
## Notes for the executor
- **`REACTIVE_RUNTIME` is 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 `try` must 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-api` fail** until you run `bun run generate:public-api` and 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.