1187 lines
39 KiB
Markdown
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.
|