feat(compiler): emit browser api bindings only where the client calls them

This commit is contained in:
2026-08-20 07:19:55 +05:30
parent 9dec811069
commit 712a6d3d8c
4 changed files with 136 additions and 8 deletions
+41 -3
View File
@@ -308,15 +308,51 @@ function _functionEntry(
}
/**
* Client-mode api blocks become members of an `api` object in client scope.
* 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;
}
/**
* A block is emitted into the browser module when it is authored as
* client-only, or when it is mode "any" and a client function actually calls
* it. `hasClientApi` below must use this exact predicate so the `api`
* reserved-binding exclusion and the emitted object can never disagree.
*/
function isClientEmittedApiBlock(block: PageAst["dataApis"][number], called: Set<string>): boolean {
return (
Boolean(block.sections) &&
(block.mode === "client" || (block.mode === "any" && called.has(block.name)))
);
}
/**
* Client-mode and client-called any-mode api blocks become members of an
* `api` object in client scope.
*
* Only the response and error bodies are emitted; the declared field types are
* type-only and are consumed by the types generator instead. Anything
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
*/
function apiBindings(ast: PageAst): string {
const called = clientCalledApiNames(ast);
const members = ast.dataApis
.filter((block) => block.mode === "client" && block.sections)
.filter((block) => isClientEmittedApiBlock(block, called))
.map((block) => {
const sections = block.sections!;
const response = eraseFunctionTypes(sections.response).trim() || "return data;";
@@ -345,7 +381,9 @@ export function generateBrowserModule(ast: PageAst): string {
// `state api` without any client api blocks must keep reading/writing that
// state as before, so only exclude the "api" name from destructuring when
// there is a real `api` binding to shadow it.
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
const hasClientApi = ast.dataApis.some((block) =>
isClientEmittedApiBlock(block, clientCalledApiNames(ast)),
);
const localRuntimeBindings = hasClientApi
? RUNTIME_BINDINGS
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
@@ -0,0 +1,60 @@
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");
});