feat(editor): complete tooling support for the apis block

Task 6 Step 2 was already performed and confirmed the assertion error
from the apis-block type checks lands on the apis { } block itself
(WRN-TYPE-2344), not on the offending entry, so it already surfaces
usefully and needed no relocation mapping.

That observation surfaced a real pre-existing bug: the virtual
TypeScript document built for type checking declared `server` from
ast.dataApis-adjacent runtime functions but never declared `api`,
so every api.<name>(...) call raised a false 'Cannot find name apis'
plus a knock-on implicit-any on its result. Fixes it by declaring
`api` from ast.dataApis, mirroring the existing `server` declaration:
each entry gets an input parameter shaped from its request
parameters/body fields (optional when the entry declares none) and a
Promise<any> return. The binding is only emitted when the page has an
apis { } block, so pages without one keep the legitimate 'Cannot find
name api' diagnostic and 'state api' stays legal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 18:51:19 +05:30
co-authored by Claude Opus 5
parent 861444b8a3
commit e944fd4496
3 changed files with 171 additions and 1 deletions
+8 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
// WRN editor language server source hash: a8bac4fc444116cc73f898d51989b058370ba9cfd8413b62974a8ce2d841ab6f // WRN editor language server source hash: eca7e58f47277a649cf9f7b2fcf23d2db52c18ff64e3f3ffb0b442d9d5f2024d
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs // @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create; (function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -172995,12 +172995,19 @@ function virtualTypeScriptModule(source, filePath = "component.wrn", appRoot = f
const outputType = ast.outputs.map((output) => `${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void`).join("; "); const outputType = ast.outputs.map((output) => `${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void`).join("; ");
const serverFunctions = ast.runtimeFunctions.filter((fn) => fn.runtime === "server"); const serverFunctions = ast.runtimeFunctions.filter((fn) => fn.runtime === "server");
const serverType = serverFunctions.map((fn) => `${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")}`).join("; "); const serverType = serverFunctions.map((fn) => `${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")}`).join("; ");
const apiType = ast.dataApis.map((block) => {
const fields = block.sections ? [...block.sections.parameters, ...block.sections.body] : [];
const inputType = fields.length ? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }` : "Record<string, never>";
return `${block.name}: (input${fields.length ? "" : "?"}: ${inputType}) => Promise<any>`;
}).join("; ");
append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`); append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`);
for (const output of ast.outputs) for (const output of ast.outputs)
append(`declare const ${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void;`); append(`declare const ${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void;`);
for (const match of source.matchAll(/@event\s+([A-Za-z_$][\w$]*)\s*=\s*function/g)) for (const match of source.matchAll(/@event\s+([A-Za-z_$][\w$]*)\s*=\s*function/g))
append(`declare const ${match[1]}: ((...args: unknown[]) => void) | undefined;`); append(`declare const ${match[1]}: ((...args: unknown[]) => void) | undefined;`);
append(`declare const server: { ${serverType} };`); append(`declare const server: { ${serverType} };`);
if (ast.dataApis.length)
append(`declare const api: { ${apiType} };`);
append(`declare const props: Readonly<${ast.name}Props>;`); append(`declare const props: Readonly<${ast.name}Props>;`);
append("declare const refs: Record<string, Element | null>;"); append("declare const refs: Record<string, Element | null>;");
append(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast)); append(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast));
+17
View File
@@ -280,6 +280,22 @@ export function virtualTypeScriptModule(
`${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")}`, `${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")}`,
) )
.join("; "); .join("; ");
// `apis { }` introduces an `api.<name>(...)` binding, callable from server,
// client, and shared functions alike (see codegen.ts/client-codegen.ts). It
// must be typed here or every `api.<name>()` call raises a false "Cannot
// find name 'api'" plus a knock-on implicit-any on its result. A page with
// no `apis { }` block must NOT get this binding — that would mask the
// legitimate "Cannot find name 'api'" error on a stray reference, and would
// shadow the otherwise-legal `state api`.
const apiType = ast.dataApis
.map((block) => {
const fields = block.sections ? [...block.sections.parameters, ...block.sections.body] : [];
const inputType = fields.length
? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`
: "Record<string, never>";
return `${block.name}: (input${fields.length ? "" : "?"}: ${inputType}) => Promise<any>`;
})
.join("; ");
append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`); append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`);
for (const output of ast.outputs) for (const output of ast.outputs)
append( append(
@@ -288,6 +304,7 @@ export function virtualTypeScriptModule(
for (const match of source.matchAll(/@event\s+([A-Za-z_$][\w$]*)\s*=\s*function/g)) for (const match of source.matchAll(/@event\s+([A-Za-z_$][\w$]*)\s*=\s*function/g))
append(`declare const ${match[1]}: ((...args: unknown[]) => void) | undefined;`); append(`declare const ${match[1]}: ((...args: unknown[]) => void) | undefined;`);
append(`declare const server: { ${serverType} };`); append(`declare const server: { ${serverType} };`);
if (ast.dataApis.length) append(`declare const api: { ${apiType} };`);
append(`declare const props: Readonly<${ast.name}Props>;`); append(`declare const props: Readonly<${ast.name}Props>;`);
append("declare const refs: Record<string, Element | null>;"); append("declare const refs: Record<string, Element | null>;");
append( append(
@@ -0,0 +1,146 @@
import { expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { checkWrnSource } from "../src/index.ts";
function app(): string {
const root = mkdtempSync(join(tmpdir(), "wrn-typecheck-api-"));
mkdirSync(join(root, "app", "types"), { recursive: true });
return root;
}
test("api.<name>(...) in a client function produces no diagnostic", () => {
const root = app();
const source = `page Demo {
apis {
searchDirectory POST /api/directory {
request {
body {
name?: string
}
}
response {
return data.data.users
}
}
}
functions {
client async function search(): Promise<void> {
await api.searchDirectory({ name: "a" })
}
}
view { <div></div> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "pages", "Demo.wrn"),
});
expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(false);
expect(diagnostics).toHaveLength(0);
});
test("the implicit-any knock-on from an undeclared api binding is gone", () => {
const root = app();
const source = `page Demo {
apis {
searchDirectory POST /api/directory {
request {
body {
name?: string
}
}
response {
return data.data.users
}
}
}
functions {
client async function search(): Promise<void> {
const result = await api.searchDirectory({ name: "a" })
}
}
view { <div></div> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "pages", "Demo.wrn"),
});
expect(diagnostics.some((d) => d.code === "WRN-TYPE-7006")).toBe(false);
});
test("a page without an apis {} block still errors on a bare api. reference", () => {
const root = app();
const source = `page Demo {
functions {
client async function search(): Promise<void> {
await api.searchDirectory({ name: "a" })
}
}
view { <div></div> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "pages", "Demo.wrn"),
});
expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(true);
});
test("state api on a page without an apis {} block stays legal", () => {
const root = app();
const source = `page Demo {
state api = "idle"
functions {
client function reset(): void {
api = "idle"
}
}
view { <div>{api}</div> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "pages", "Demo.wrn"),
});
expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(false);
});
test("calling an undeclared block name on api errors", () => {
const root = app();
const source = `page Demo {
apis {
searchDirectory POST /api/directory {
request {
body {
name?: string
}
}
response {
return data.data.users
}
}
}
functions {
client async function search(): Promise<void> {
await api.nope({ name: "a" })
}
}
view { <div></div> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "pages", "Demo.wrn"),
});
expect(diagnostics.some((d) => /Property 'nope' does not exist/.test(d.message))).toBe(true);
});