feat(compiler): emit browser api bindings only where the client calls them
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
// 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
|
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||||
// Generated with TypeScript: 6.0.3
|
// Generated with TypeScript: 6.0.3
|
||||||
const __nodeRequire = require;
|
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
|
* Only the response and error bodies are emitted; the declared field types are
|
||||||
* type-only and are consumed by the types generator instead. Anything
|
* type-only and are consumed by the types generator instead. Anything
|
||||||
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
||||||
*/
|
*/
|
||||||
function apiBindings(ast) {
|
function apiBindings(ast) {
|
||||||
|
const called = clientCalledApiNames(ast);
|
||||||
const members = ast.dataApis
|
const members = ast.dataApis
|
||||||
.filter((block) => block.mode === "client" && block.sections)
|
.filter((block) => isClientEmittedApiBlock(block, called))
|
||||||
.map((block) => {
|
.map((block) => {
|
||||||
const sections = block.sections;
|
const sections = block.sections;
|
||||||
const response = (0, syntax_1.eraseFunctionTypes)(sections.response).trim() || "return data;";
|
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 api` without any client api blocks must keep reading/writing that
|
||||||
// state as before, so only exclude the "api" name from destructuring when
|
// state as before, so only exclude the "api" name from destructuring when
|
||||||
// there is a real `api` binding to shadow it.
|
// 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
|
const localRuntimeBindings = hasClientApi
|
||||||
? RUNTIME_BINDINGS
|
? RUNTIME_BINDINGS
|
||||||
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
|
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// WRN editor extension source hash: 52dfa294ba556cb26807ce112cc6826a690aa4a80721520d0c39bd7cfd8aa33e
|
// WRN editor extension source hash: 1791876dd8fecb7b56caf3cbb629ecdd96b84a1c0e46d3cfbb5515a8c30e281d
|
||||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||||
"use strict";
|
"use strict";
|
||||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||||
|
|||||||
@@ -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
|
* Only the response and error bodies are emitted; the declared field types are
|
||||||
* type-only and are consumed by the types generator instead. Anything
|
* type-only and are consumed by the types generator instead. Anything
|
||||||
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
||||||
*/
|
*/
|
||||||
function apiBindings(ast: PageAst): string {
|
function apiBindings(ast: PageAst): string {
|
||||||
|
const called = clientCalledApiNames(ast);
|
||||||
const members = ast.dataApis
|
const members = ast.dataApis
|
||||||
.filter((block) => block.mode === "client" && block.sections)
|
.filter((block) => isClientEmittedApiBlock(block, called))
|
||||||
.map((block) => {
|
.map((block) => {
|
||||||
const sections = block.sections!;
|
const sections = block.sections!;
|
||||||
const response = eraseFunctionTypes(sections.response).trim() || "return data;";
|
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 api` without any client api blocks must keep reading/writing that
|
||||||
// state as before, so only exclude the "api" name from destructuring when
|
// state as before, so only exclude the "api" name from destructuring when
|
||||||
// there is a real `api` binding to shadow it.
|
// 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
|
const localRuntimeBindings = hasClientApi
|
||||||
? RUNTIME_BINDINGS
|
? RUNTIME_BINDINGS
|
||||||
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
|
: 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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user