From 50097ec4b494004a71be22ff6e1d213670a290b6 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 19 Aug 2026 15:15:02 +0530 Subject: [PATCH] docs: implementation plan for typed api blocks Six tasks: parse the sections, add the CSR transport, compile client-mode blocks into the browser module, generate the tsc assertions, support sections in ssr blocks, and verify end to end in a browser. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-19-typed-api-block.md | 1089 +++++++++++++++++ 1 file changed, 1089 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-typed-api-block.md diff --git a/docs/superpowers/plans/2026-08-19-typed-api-block.md b/docs/superpowers/plans/2026-08-19-typed-api-block.md new file mode 100644 index 00000000..183bbfd8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-typed-api-block.md @@ -0,0 +1,1089 @@ +# Typed, Callable `api` Blocks 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:** Let a `.wrn` page declare a typed API call — `request` / `response` / `error` — and invoke it on demand as `await api.searchUsers({ name })`. + +**Architecture:** The parser gains sections inside the existing `api` block. Client-mode blocks compile into the page's browser module as an `api` object whose members call a transport added to the CSR runtime. Type safety is enforced by `tsc` over assertions written into `app/types/wrnexus.generated.d.ts`, not by logic inside the compiler. + +**Tech Stack:** Bun, TypeScript, `bun:test`, happy-dom. + +**Spec:** `docs/superpowers/specs/2026-08-19-typed-api-block-design.md` + +## Global Constraints + +- Targets are restricted to this app's `/api/*` routes. Never relax `isSafeApiPath` in `packages/dev-server/src/runtime.ts`. +- A bare block body keeps its current meaning (the legacy, untyped response body). Sections are the typed form. Existing `.wrn` files must not change behaviour. +- `ssr {}` blocks accept `response` and `error` only. `request` inside an `ssr {}` block is a parse error. +- Absent `error {}` means the call rejects. A block must never 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. +- Author-settable request headers are out of scope. +- `bun run format` before every commit; the repo gate is `bun run check:production`. + +--- + +### Task 1: Parse sectioned `api` blocks + +**Files:** + +- Create: `packages/syntax/src/api-sections.ts` +- Modify: `packages/syntax/src/parser.ts` (the `DataApiBlock` interface at ~line 146; the `api` case inside the mode-block loop at ~line 693) +- Test: `packages/syntax/test/api-block.test.ts` + +**Interfaces:** + +- Consumes: `readBalancedBraces()` from the existing tokenizer. +- Produces: + - `interface ApiFieldDecl { name: string; optional: boolean; type: string }` + - `interface ApiSections { parameters: ApiFieldDecl[]; body: ApiFieldDecl[]; response: string; error: string }` + - `function parseApiSections(source: string): ApiSections | null` — returns `null` when the body has no sections (legacy form). + - `DataApiBlock` gains `sections?: ApiSections`. Its existing `body: string` stays and holds the legacy body. + +- [ ] **Step 1: Write the failing test** + +Create `packages/syntax/test/api-block.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { parse } from "../src/index.ts"; + +const page = (inner: string) => `page Repro { + client { +${inner} + } + + view {
x
} +} +`; + +test("parses a sectioned api block into request, response and error", () => { + const ast = parse( + page(` api searchUsers POST /api/users { + request { + body { + name?: string + age?: number + } + } + + 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.sections?.body).toEqual([ + { name: "name", optional: true, type: "string" }, + { name: "age", optional: true, type: "number" }, + ]); + expect(block.sections?.response.trim()).toBe("return data.users"); + expect(block.sections?.error.trim()).toBe("return []"); +}); + +test("a bare body still parses as the legacy response body", () => { + const ast = parse( + page(` api legacyUsers GET /api/users { + return users.length + }`), + ); + + const block = ast.dataApis[0]!; + expect(block.sections).toBeUndefined(); + expect(block.body.trim()).toBe("return users.length"); +}); + +test("GET parameters are parsed as required when not marked optional", () => { + const ast = parse( + page(` api listUsers GET /api/users { + request { + parameters { + team: string + } + } + + response { + return data.users + } + }`), + ); + + expect(ast.dataApis[0]!.sections?.parameters).toEqual([ + { name: "team", optional: false, type: "string" }, + ]); +}); + +test("request inside an ssr block is rejected with a message naming the restriction", () => { + const source = `page Repro { + ssr { + api ssrUsers GET /api/users { + request { + parameters { + team: string + } + } + + response { + return data.users + } + } + } + + view {
x
} +} +`; + + expect(() => parse(source)).toThrow(/request .*ssr/i); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/syntax/test/api-block.test.ts` +Expected: FAIL — `parse` does not produce `sections`. + +- [ ] **Step 3: Write the section parser** + +Create `packages/syntax/src/api-sections.ts`: + +```ts +/** + * Parse the sectioned form of an `api` block body. + * + * Returns null when no section keyword is present, which is how the legacy + * bare-body form stays valid: the caller keeps treating the body as the + * response expression. + */ +export interface ApiFieldDecl { + name: string; + optional: boolean; + type: string; +} + +export interface ApiSections { + parameters: ApiFieldDecl[]; + body: ApiFieldDecl[]; + response: string; + error: string; +} + +const SECTION_NAMES = ["request", "response", "error"] as const; + +/** Slice the balanced `{ ... }` that follows `keyword`, or null when absent. */ +function sectionBody(source: string, keyword: string): string | null { + const match = new RegExp(`(^|[^A-Za-z0-9_$])${keyword}\\s*\\{`).exec(source); + if (!match) return null; + + const open = source.indexOf("{", match.index + match[1]!.length); + let depth = 0; + + for (let index = open; index < source.length; index++) { + const character = source[index]; + if (character === "{") depth++; + else if (character === "}") { + depth--; + if (depth === 0) return source.slice(open + 1, index); + } + } + + throw new Error(`Unclosed "${keyword}" section in an api block`); +} + +/** `name?: string` -> { name, optional, type }. Blank lines and comments are skipped. */ +function parseFields(source: string): ApiFieldDecl[] { + const fields: ApiFieldDecl[] = []; + + for (const rawLine of source.split("\n")) { + const line = rawLine.trim().replace(/,$/, ""); + if (!line || line.startsWith("//")) continue; + + const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line); + if (!match) { + throw new Error(`Expected "name: type" in an api request section, got "${line}"`); + } + + fields.push({ name: match[1]!, optional: match[2] === "?", type: match[3]!.trim() }); + } + + return fields; +} + +export function parseApiSections(source: string): ApiSections | null { + const present = SECTION_NAMES.some((name) => sectionBody(source, name) !== null); + if (!present) return null; + + const request = sectionBody(source, "request"); + + return { + parameters: request ? parseFields(sectionBody(request, "parameters") ?? "") : [], + body: request ? parseFields(sectionBody(request, "body") ?? "") : [], + response: sectionBody(source, "response") ?? "", + error: sectionBody(source, "error") ?? "", + }; +} + +/** True when the block declares a `request` section. */ +export function hasRequestSection(source: string): boolean { + return sectionBody(source, "request") !== null; +} +``` + +- [ ] **Step 4: Wire it into the parser** + +In `packages/syntax/src/parser.ts`, add the import at the top: + +```ts +import { parseApiSections, hasRequestSection, type ApiSections } from "./api-sections.ts"; +``` + +Extend the interface at ~line 146: + +```ts +export interface DataApiBlock { + mode: DataMode; + name: string; + method: string; + path: string; + /** Legacy bare body. Empty string when `sections` is set. */ + body: string; + /** Present only for the sectioned, typed form. */ + sections?: ApiSections; +} +``` + +Replace the `dataApis.push` at ~line 699: + +```ts +const sections = parseApiSections(body); +if (sections && mode !== "client" && hasRequestSection(body)) { + throw new ParseError( + `An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`, + ); +} +dataApis.push({ + mode, + name, + method, + path, + body: sections ? "" : body, + ...(sections ? { sections } : {}), +}); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun test packages/syntax` +Expected: PASS, including the pre-existing syntax suite (the legacy-body test is the regression guard). + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add packages/syntax/src/api-sections.ts packages/syntax/src/parser.ts packages/syntax/test/api-block.test.ts +git commit -m "feat(syntax): parse sectioned api blocks" +``` + +--- + +### Task 2: `callApi` transport in the CSR runtime + +**Files:** + +- Modify: `packages/csr/src/reactive-runtime.ts` (add the function near `callServerFunction`; expose it on the client context beside `server`) +- Test: `packages/csr/test/api-call.test.ts` + +**Interfaces:** + +- Consumes: the CSRF lookup already in `reactive-runtime.ts` (`wrn-csrf` cookie, `wrnexus-csrf` meta). +- Produces: `context.callApi(path, method, input)` — resolves to the parsed JSON payload for 2xx; rejects with an `Error` carrying `status`, `message`, and `data` otherwise. Task 3 calls this. + +**Note:** `REACTIVE_RUNTIME` is a template literal. Backticks inside it terminate the string — use plain quotes in any code or comment you add. + +- [ ] **Step 1: Write the failing test** + +Create `packages/csr/test/api-call.test.ts`: + +```ts +import { expect, test, beforeEach } from "bun:test"; +import { Window } from "happy-dom"; +import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts"; +import { restoreGlobalsAfterAll } from "./global-restore.ts"; + +const REPLACED_GLOBALS = ["window", "document", "location", "fetch"]; +restoreGlobalsAfterAll(REPLACED_GLOBALS); + +beforeEach(() => { + for (const name of REPLACED_GLOBALS) delete (globalThis as Record)[name]; +}); + +interface Call { + url: string; + init: RequestInit; +} + +/** Mount the runtime with a recording fetch and return its callApi plus the calls made. */ +function harness(response: { status: number; payload: unknown }) { + const win = new Window() as unknown as Window & Record; + win.document.body.innerHTML = `
`; + const calls: Call[] = []; + + (globalThis as Record).window = win; + (globalThis as Record).document = win.document; + (globalThis as Record).location = win.location; + (globalThis as Record).fetch = (url: string, init: RequestInit) => { + calls.push({ url, init }); + return Promise.resolve({ + ok: response.status >= 200 && response.status < 300, + status: response.status, + json: () => Promise.resolve(response.payload), + }); + }; + + (0, eval)(REACTIVE_RUNTIME); + const callApi = (win as unknown as { __wrnexusCallApi: Function }).__wrnexusCallApi; + return { callApi, calls, win }; +} + +test("GET builds a query string and omits undefined fields", async () => { + const { callApi, calls } = harness({ status: 200, payload: { users: [] } }); + + await callApi("/api/users", "GET", { name: "Ajay", age: undefined }); + + expect(calls[0]!.url).toBe("/api/users?name=Ajay"); + expect(calls[0]!.init.method).toBe("GET"); + expect(calls[0]!.init.body).toBeUndefined(); +}); + +test("POST sends a JSON body", async () => { + const { callApi, calls } = harness({ status: 200, payload: { ok: true } }); + + await callApi("/api/users", "POST", { name: "Ajay" }); + + expect(calls[0]!.url).toBe("/api/users"); + expect(calls[0]!.init.body).toBe(JSON.stringify({ name: "Ajay" })); + expect((calls[0]!.init.headers as Record)["content-type"]).toBe( + "application/json", + ); +}); + +test("a non-GET request carries the CSRF token from the cookie", async () => { + const { callApi, calls, win } = harness({ status: 200, payload: {} }); + win.document.cookie = "wrn-csrf=token-123"; + + await callApi("/api/users", "POST", {}); + + expect((calls[0]!.init.headers as Record)["x-csrf-token"]).toBe("token-123"); +}); + +test("a 2xx resolves to the parsed payload", async () => { + const { callApi } = harness({ status: 200, payload: { users: [{ name: "Ajay" }] } }); + + expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] }); +}); + +test("a non-2xx rejects with status, message and data", async () => { + const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } }); + + const failure = await callApi("/api/users", "GET", {}).catch( + (error: Error & { status?: number; data?: unknown }) => error, + ); + + expect(failure.status).toBe(400); + expect(failure.message).toContain("Bad filter"); + expect(failure.data).toEqual({ error: "Bad filter" }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/csr/test/api-call.test.ts` +Expected: FAIL — `__wrnexusCallApi` is undefined. + +- [ ] **Step 3: Implement the transport** + +In `packages/csr/src/reactive-runtime.ts`, add this beside `callServerFunction` (no backticks): + +```js +/* + * Transport for compiled api blocks. + * + * Only the request and the failure shape live here. A block's response and + * error bodies are page code, so they are emitted into the browser module + * and applied by the caller. + */ +function readCsrfToken() { + var meta = document.querySelector('meta[name="wrnexus-csrf"]'); + if (meta) return meta.getAttribute("content") || ""; + var match = /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(document.cookie || ""); + return match ? decodeURIComponent(match[1]) : ""; +} + +function wrnexusCallApi(path, method, input) { + var verb = String(method || "GET").toUpperCase(); + var values = input || {}; + var url = path; + var headers = { accept: "application/json" }; + var init = { method: verb, credentials: "same-origin", headers: headers }; + + if (verb === "GET" || verb === "HEAD") { + var query = []; + Object.keys(values).forEach(function (key) { + var value = values[key]; + // An omitted filter must not become "name=undefined". + if (value === undefined || value === null || value === "") return; + query.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(value))); + }); + if (query.length) url = path + "?" + query.join("&"); + } else { + headers["content-type"] = "application/json"; + headers["x-csrf-token"] = readCsrfToken(); + init.body = JSON.stringify(values); + } + + return fetch(url, init).then(function (response) { + return response.json().then( + function (data) { + if (response.ok) return data; + var message = + data && data.error ? String(data.error) : "Request failed with " + response.status; + var failure = new Error(message); + failure.status = response.status; + failure.data = data; + throw failure; + }, + function () { + var failure = new Error("Response was not valid JSON"); + failure.status = response.status; + failure.data = undefined; + throw failure; + }, + ); + }); +} + +window.__wrnexusCallApi = wrnexusCallApi; +``` + +Then expose it on the client context. Find where `server: serverProxy` is placed on the context object (search for `server: serverProxy`) and add alongside it: + +```js + callApi: wrnexusCallApi, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test packages/csr` +Expected: PASS — the new file plus the existing suite. + +- [ ] **Step 5: Check the runtime size budget** + +Run: `bun scripts/lib/measure-runtime-size.ts` +Expected: `reactive-runtime.ts` under the 50,500 budget in `scripts/security-performance-audit.mjs`. If it exceeds, raise the budget in that file **in this commit** with a one-line note saying what bought the bytes — do not leave the gate red for the next task. + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add packages/csr/src/reactive-runtime.ts packages/csr/test/api-call.test.ts scripts/security-performance-audit.mjs +git commit -m "feat(csr): add the api block transport" +``` + +--- + +### Task 3: Compile client-mode blocks into the browser module + +**Files:** + +- Modify: `packages/compiler/src/client-codegen.ts` (the `__wrnexusCreateClientFunctions` template at ~line 361) +- Test: `packages/compiler/test/api-block-codegen.test.ts` + +**Interfaces:** + +- Consumes: `DataApiBlock.sections` from Task 1; `context.callApi` from Task 2. +- Produces: the emitted module declares `const api = { : async (input) => ... }` inside `__wrnexusCreateClientFunctions`, so client function bodies can call `await api.searchUsers({...})`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/compiler/test/api-block-codegen.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { parse } from "@wrnexus/syntax"; +import { generateTargets } from "../src/targets.ts"; + +function browserModule(inner: string): string { + return generateTargets( + parse(`page Repro { + client { +${inner} + } + + functions { + client async function run(): Promise { + const users = await api.searchUsers({ name: "Ajay" }) + console.log(users) + } + } + + view {
} +} +`), + ).browser; +} + +const BLOCK = ` api searchUsers POST /api/users { + request { + body { + name?: string + age?: number + } + } + + response { + return data.users + } + + error { + return [] + } + }`; + +test("emits an api member that calls the transport with the block's path and method", () => { + const generated = browserModule(BLOCK); + + expect(generated).toContain("const api ="); + expect(generated).toContain("searchUsers"); + expect(generated).toContain('"/api/users"'); + expect(generated).toContain('"POST"'); +}); + +test("declared field types never reach the browser module", () => { + // The artifact is written as .mjs and parsed as JavaScript. + const generated = browserModule(BLOCK); + + expect(generated).not.toContain("name?: string"); + expect(generated).not.toContain("age?: number"); +}); + +test("the emitted module is valid JavaScript", () => { + const generated = browserModule(BLOCK); + + expect(() => { + new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, "")); + }).not.toThrow(); +}); + +test("a block without an error section still emits its response body", () => { + const generated = browserModule(` api plainUsers GET /api/users { + request { + parameters { + team: string + } + } + + response { + return data.users + } + }`); + + expect(generated).toContain("plainUsers"); + expect(generated).toContain("data.users"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/compiler/test/api-block-codegen.test.ts` +Expected: FAIL — no `const api =` in the generated module. + +- [ ] **Step 3: Emit the api object** + +In `packages/compiler/src/client-codegen.ts`, add this helper above `generateBrowserModule`: + +```ts +/** + * Client-mode api blocks become members of an `api` object in client scope. + * + * Only the response and error bodies are emitted; the declared field types are + * type-only and are consumed by the types generator instead. Anything + * TypeScript reaching this module would be a syntax error in the .mjs artifact. + */ +function apiBindings(ast: PageAst): string { + const members = ast.dataApis + .filter((block) => block.mode === "client" && block.sections) + .map((block) => { + const sections = block.sections!; + const response = sections.response.trim() || "return data;"; + const error = sections.error.trim(); + const failure = error + ? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }` + : `(error) => { throw error; }`; + + return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify( + block.path, + )}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }).catch(${failure})`; + }); + + return members.length ? `const api = {\n${members.join(",\n")}\n };` : ""; +} +``` + +Then insert its output into the `__wrnexusCreateClientFunctions` template, immediately after `const refs = context.refs;`: + +```ts + const refs = context.refs; + ${apiBindings(ast)} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test packages/compiler` +Expected: PASS, including the existing compiler suite. + +- [ ] **Step 5: Commit** + +```bash +bun run format +git add packages/compiler/src/client-codegen.ts packages/compiler/test/api-block-codegen.test.ts +git commit -m "feat(compiler): compile client api blocks into the browser module" +``` + +--- + +### Task 4: Generate the type assertions + +**Files:** + +- Modify: `packages/cli/src/types.ts` (the generated namespace at ~line 209-221) +- Test: `packages/cli/test/api-block-types.test.ts` + +**Interfaces:** + +- Consumes: `DataApiBlock.sections` from Task 1; the existing `ApiRoute`, `ApiContracts`, and `ApiContract` types this file already emits. +- Produces: `ApiInput`, `ApiOutput`, `AssertAssignable`, and one `__wrn_api_check_` type per sectioned block, all inside `declare namespace WRNexusGenerated`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/test/api-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 }); +}); + +/** Minimal app with one typed endpoint and one page that calls it. */ +function fixture(block: string): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-api-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 client {\n${block}\n }\n\n view {
x
}\n}\n`, + ); + return root; +} + +const BLOCK = ` api searchUsers POST /api/users { + request { + body { + name?: string + age?: number + } + } + + response { + return data.users + } + }`; + +test("emits the ApiInput, ApiOutput and AssertAssignable helpers", () => { + const root = fixture(BLOCK); + generateApplicationTypes(root); + const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8"); + + expect(generated).toContain("type AssertAssignable<"); + expect(generated).toContain('type ApiInput

= ApiContracts[P][M]["input"]'); + expect(generated).toContain( + 'type ApiOutput

= ApiContracts[P][M]["output"]', + ); +}); + +test("emits one assertion per sectioned block, naming its route and method", () => { + const root = fixture(BLOCK); + generateApplicationTypes(root); + const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8"); + + expect(generated).toContain("__wrn_api_check_searchUsers"); + expect(generated).toContain('ApiInput<"/api/users", "POST">'); + expect(generated).toContain("name?: string"); + expect(generated).toContain("age?: number"); +}); + +test("a legacy bare-body block produces no assertion", () => { + const root = fixture(` api legacyUsers GET /api/users { + return users.length + }`); + generateApplicationTypes(root); + const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8"); + + expect(generated).not.toContain("__wrn_api_check_legacyUsers"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/cli/test/api-block-types.test.ts` +Expected: FAIL — none of the helpers are emitted. + +- [ ] **Step 3: Emit the helpers and assertions** + +In `packages/cli/src/types.ts`, add above the function that builds the namespace: + +```ts +/** + * Type assertions for sectioned api blocks. + * + * Enforcement lives here rather than in the compiler because this file is under + * `app/` and is therefore compiled by the project's own tsc, while generated + * build artifacts are not type-checked at all. + */ +function apiBlockAssertions(pages: { path: string; ast: PageAst }[]): string { + const lines: string[] = []; + + for (const page of pages) { + for (const block of page.ast.dataApis) { + if (!block.sections) continue; + + const fields = [...block.sections.parameters, ...block.sections.body]; + const shape = fields.length + ? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }` + : "Record"; + + lines.push( + ` type __wrn_api_check_${block.name} = AssertAssignable<${shape}, ApiInput<${JSON.stringify( + block.path, + )}, ${JSON.stringify(block.method)}>>;`, + ); + } + } + + return lines.join("\n"); +} +``` + +Add the helper types and the assertions into the generated namespace, next to the existing `generatedContractMap` calls: + +```ts + type AssertAssignable = [Actual] extends [Expected] ? true : never; + type ApiInput

= ApiContracts[P][M]["input"]; + type ApiOutput

= ApiContracts[P][M]["output"]; +${apiBlockAssertions(pages)} +``` + +`generateApplicationTypes` does not retain page ASTs — line ~111 parses component `.wrn` files +only. Collect them with the `files()` helper this module already uses (see its use at ~line 296): + +```ts +const pageAsts = files(join(root, "app"), (path) => extname(path) === ".wrn").map((file) => ({ + path: file, + ast: parse(readFileSync(file, "utf8")), +})); +``` + +`parse`, `files`, `join`, `extname`, and `readFileSync` are all already imported by this module. +`PageAst` comes from `@wrnexus/syntax`; add it to the existing type import if absent. + +- [ ] **Step 4: Warn for routes with no contract** + +Where a block's path has no entry in `apiContracts`, print once per route: + +```ts +console.warn( + `[wrnexus] api block "${block.name}" targets ${block.path}, which has no defineEndpoint contract — its declared types are not checked.`, +); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun test packages/cli` +Expected: PASS. + +- [ ] **Step 6: Verify the assertion actually bites** + +Regenerate types for the example app and confirm a wrong field fails `tsc`, rather than trusting the string match: + +```bash +bun run --cwd examples/basic-app wrnexus generate types +bun run typecheck +``` + +Expected: PASS. Then temporarily add a field the endpoint does not accept to a block in `examples/basic-app`, regenerate, and confirm `bun run typecheck` FAILS. Revert the temporary change. + +- [ ] **Step 7: Commit** + +```bash +bun run format +git add packages/cli/src/types.ts packages/cli/test/api-block-types.test.ts +git commit -m "feat(cli): generate type assertions for api blocks" +``` + +--- + +### Task 5: SSR-mode `response` and `error` sections + +**Files:** + +- Modify: `packages/compiler/src/codegen.ts` (`apiBindingMap` at ~line 932, and `dataBody`) +- Test: `packages/compiler/test/api-block-ssr.test.ts` + +**Interfaces:** + +- Consumes: `DataApiBlock.sections` from Task 1. +- Produces: no new exports. An `ssr` block with sections evaluates `response` with the payload bound to `data`; a failure runs `error`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/compiler/test/api-block-ssr.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { parse } from "@wrnexus/syntax"; +import { generate } from "../src/codegen.ts"; + +function serverModule(inner: string): string { + return generate( + parse(`page Repro { + ssr { +${inner} + } + + view {

loading

} +} +`), + ); +} + +test("a sectioned ssr block binds the payload to data", () => { + const generated = serverModule(` api ssrUsers GET /api/users { + response { + return data.users.length + } + }`); + + expect(generated).toContain("data.users.length"); +}); + +test("a legacy ssr block is unchanged", () => { + const generated = serverModule(` api ssrUsers GET /api/users { + return users.length + }`); + + expect(generated).toContain("users.length"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/compiler/test/api-block-ssr.test.ts` +Expected: FAIL — the sectioned body is not read. + +- [ ] **Step 3: Read sections in `apiBindingMap`** + +In `packages/compiler/src/codegen.ts`, change the `body` assignment inside `apiBindingMap`: + +```ts +const sectioned = block.sections; +bindings.set(block.name, { + mode: block.mode, + method: block.method, + path: apiRoutePath(block.path), + // A sectioned block binds the payload to `data`; the legacy form keeps + // the `with ($data)` injection, which cannot be typed. + body: sectioned + ? `const data = $data; ${sectioned.response.trim() || "return data;"}` + : dataBody(block.body), + helpers: modeHelpers(ast, block.mode, sharedHelpers), +}); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test packages/compiler` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +bun run format +git add packages/compiler/src/codegen.ts packages/compiler/test/api-block-ssr.test.ts +git commit -m "feat(compiler): support sections in ssr api blocks" +``` + +--- + +### Task 6: End-to-end verification in a real browser + +**Files:** + +- Create: `examples/basic-app/app/api/directory.ts` +- Create: `examples/basic-app/app/pages/api-block-demo.wrn` +- Test: exercised through the running dev server; no unit test file + +**Interfaces:** + +- Consumes: everything from Tasks 1-5. +- Produces: a demo page that stays in the repo as the worked example. + +**Why this task exists:** this repository has repeatedly shipped features whose tests passed while the feature did not work — the island runtime that 404'd, the JSX pragma test asserting generated text, three tests that would have survived deleting the code they guarded. Browser verification is part of done. + +- [ ] **Step 1: Add the endpoint** + +Create `examples/basic-app/app/api/directory.ts`: + +```ts +import { defineEndpoint } from "@wrnexus/core"; +import type { Context } from "@wrnexus/core"; + +const ALL = [ + { name: "Ajay", designation: "UI" }, + { name: "Asha", designation: "Backend" }, + { name: "Chen", designation: "UI" }, +]; + +export const POST = async (ctx: Context) => { + const body = (await ctx.req.json().catch(() => ({}))) as { name?: string }; + const needle = String(body.name ?? "").toLowerCase(); + return Response.json({ users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) }); +}; +``` + +- [ ] **Step 2: Add the page** + +Create `examples/basic-app/app/pages/api-block-demo.wrn`: + +```wrn +page ApiBlockDemo { + state nameFilter = "a" + state found = "" + state failed = "" + + client { + api searchDirectory POST /api/directory { + request { + body { + name?: string + } + } + + response { + return data.users + } + + error { + return [] + } + } + } + + functions { + client async function search(): Promise { + const users = await api.searchDirectory({ name: nameFilter }) + found = users.map((user) => user.name).join(", ") + } + } + + view { +
+ +

{found}

+

{failed}

+
+ } +} +``` + +- [ ] **Step 3: Start the dev server** + +```bash +bun run --cwd examples/basic-app dev -- --port=3480 +``` + +Wait for `WrNexus — http://localhost:3480`, then confirm the page responds: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3480/api-block-demo +``` + +Expected: `200`. + +- [ ] **Step 4: Drive it in the browser** + +Open `http://localhost:3480/api-block-demo`, click Search, and read the page. Expected: `.found` contains `Ajay, Asha` (both match "a"; Chen does not). Confirm in the network panel that exactly one `POST /api/directory` was made and that it carried an `x-csrf-token` header. + +- [ ] **Step 5: Verify the failure path** + +Temporarily change the block's path to `/api/directory-missing`, reload, and click Search. Expected: no exception in the console and `.found` empty, because the `error` section returned `[]`. Restore the path. + +- [ ] **Step 6: Verify the type gate** + +```bash +bun run --cwd examples/basic-app wrnexus generate types +bun run typecheck +``` + +Expected: PASS. Then add `nope?: string` to the block's `body`, regenerate, and confirm `typecheck` FAILS naming `__wrn_api_check_searchDirectory`. Remove it. + +- [ ] **Step 7: Check whether errors appear inline in the editor** + +The spec assumes the language server surfaces the generated assertion's failure inside the `.wrn` +file. That is an assumption, not a requirement. With a deliberately wrong field in place, open the +page in VS Code and note whether the error appears on the block or only in +`wrnexus.generated.d.ts`. If only the latter, record it as follow-up work — do not expand this +plan's scope to fix it. + +- [ ] **Step 8: Run the full gate** + +```bash +bun run format +bun test +bun run typecheck +bun run --cwd editors/vscode build +bun run check:production +``` + +Expected: all pass. The editor bundles must be rebuilt because `packages/compiler` and `packages/syntax` changed; `check:editor-compiler` fails on a stale bundle. + +- [ ] **Step 9: Commit** + +```bash +git add examples/basic-app editors/vscode/src +git commit -m "feat(examples): worked example for typed api 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 parse error elsewhere in the file. Use plain quotes. +- **Do not use `node -e` or shell heredocs to write regexes** into these files; escaping mangles them silently. Use the editing tools. +- **The public API surface is gated.** Adding an export to a package makes `check:public-api` fail until you run `bun run generate:public-api` and confirm the diff is additive only. +- **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.