From 712a6d3d8c1961b81f9abf4d8acffe65003d625c Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 20 Aug 2026 07:19:55 +0530 Subject: [PATCH] feat(compiler): emit browser api bindings only where the client calls them --- editors/vscode/src/compiler.cjs | 38 ++++++++++-- editors/vscode/src/extension.bundle.cjs | 2 +- packages/compiler/src/client-codegen.ts | 44 +++++++++++++- .../compiler/test/apis-client-emit.test.ts | 60 +++++++++++++++++++ 4 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/apis-client-emit.test.ts diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 0286a5e7..00cf5a14 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,6 +1,6 @@ "use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. -// WRN editor compiler source hash: 843d73711698bc31e874370cb28bf0cb6940d3d4a6c761d00921b5f35806ab98 +// WRN editor compiler source hash: 6c7feefb98f5f19d3156a5953ddce47910f3c1572395bf4f39b1a23771b648b1 // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // Generated with TypeScript: 6.0.3 const __nodeRequire = require; @@ -713,15 +713,45 @@ function _functionEntry(ast, fn, availableFunctions) { }`; } /** - * 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) { + const called = new Set(); + 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, called) { + 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) { + 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 = (0, syntax_1.eraseFunctionTypes)(sections.response).trim() || "return data;"; @@ -745,7 +775,7 @@ function generateBrowserModule(ast) { // `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")); diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index 2bb266e7..2452af85 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: 52dfa294ba556cb26807ce112cc6826a690aa4a80721520d0c39bd7cfd8aa33e +// WRN editor extension source hash: 1791876dd8fecb7b56caf3cbb629ecdd96b84a1c0e46d3cfbb5515a8c30e281d // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); diff --git a/packages/compiler/src/client-codegen.ts b/packages/compiler/src/client-codegen.ts index 86a711df..c06df2cc 100644 --- a/packages/compiler/src/client-codegen.ts +++ b/packages/compiler/src/client-codegen.ts @@ -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 { + const called = new Set(); + 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): 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")); diff --git a/packages/compiler/test/apis-client-emit.test.ts b/packages/compiler/test/apis-client-emit.test.ts new file mode 100644 index 00000000..ff08ec62 --- /dev/null +++ b/packages/compiler/test/apis-client-emit.test.ts @@ -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 { +${calls} + } + } + + view {
} +} +`; + +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"); +});