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