From a08dfa53229939e86f89b9aeb344fcd170d74c45 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 20 Aug 2026 07:25:02 +0530 Subject: [PATCH] fix(compiler): reject dynamic api access in client functions Usage-driven emission can only see api. calls. api["name"]() or passing api to a helper is invisible to it, silently drops the block from the browser bundle, and fails at runtime instead of build time. Detect that dynamic/indirect use (masking strings and comments first, reusing the tokenizer's skipLiteralOrComment) and refuse to compile instead, naming the offending function. --- editors/vscode/src/compiler.cjs | 86 ++++++++++++++++-- editors/vscode/src/extension.bundle.cjs | 2 +- editors/vscode/src/language-server.cjs | 2 +- packages/compiler/src/client-codegen.ts | 87 +++++++++++++++++-- .../compiler/test/apis-client-emit.test.ts | 40 +++++++++ packages/syntax/src/index.ts | 2 +- 6 files changed, 202 insertions(+), 17 deletions(-) diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 00cf5a14..7e430886 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: 6c7feefb98f5f19d3156a5953ddce47910f3c1572395bf4f39b1a23771b648b1 +// WRN editor compiler source hash: 3b1bf12fc638343d15eb8502a2b10df4ad909529de8c94b612cb567959324f5d // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // Generated with TypeScript: 6.0.3 const __nodeRequire = require; @@ -712,6 +712,40 @@ function _functionEntry(ast, fn, availableFunctions) { } }`; } +/** + * Blank out string/template literals and comments in a raw JS body, preserving + * length and newlines, so a scanner walking the result never mistakes text + * inside a string or comment for real code. Reuses the tokenizer's + * comment/string-skipping rules (`skipLiteralOrComment`) instead of + * reimplementing them — a second hand-rolled scanner is how apostrophes in + * prose used to swallow braces elsewhere in this codebase. + */ +function maskStringsAndComments(src) { + let out = ""; + let i = 0; + let atLineStart = true; + while (i < src.length) { + const c = src[i]; + if (c === "\n") { + out += c; + atLineStart = true; + i++; + continue; + } + const skipped = (0, syntax_1.skipLiteralOrComment)(src, i, atLineStart); + if (skipped !== null) { + out += src.slice(i, skipped).replace(/[^\n]/g, " "); + i = skipped; + atLineStart = false; + continue; + } + if (c !== " " && c !== "\t" && c !== "\r") + atLineStart = false; + out += c; + i++; + } + return out; +} /** * Block names the page's client functions actually call. * @@ -721,15 +755,49 @@ function _functionEntry(ast, fn, availableFunctions) { */ 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]); + for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) { + const masked = maskStringsAndComments(fn.body); + for (const match of masked.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) { + called.add(match[1]); + } } return called; } +/** + * Refuse to compile a client/shared function whose body references `api` in + * any form other than `api.` — e.g. `api["searchUsers"]()`, or + * passing `api` to a helper. Usage-driven emission (see `clientCalledApiNames` + * above) can only see `api.` calls; a dynamic or indirect + * reference is invisible to it, so the referenced block would be silently + * dropped from the browser bundle and the call would fail at runtime with + * "api. is not a function". That failure direction is worse than a + * loud compile error, so it is caught here instead. + * + * Strings and comments are masked out first so `api` appearing in prose or in + * a quoted value never trips this check, and every `api.` access + * is stripped before the standalone-word scan so a real, well-formed call + * never does either. + */ +function assertNoDynamicApiAccess(ast) { + // Only pages with a mode "any" block have anything at stake here: those + // blocks are emitted solely because usage detection saw `api.`, so a + // dynamic reference this scan can't see is the one that silently drops a + // block from the bundle. Mode "client" blocks always ship regardless of + // usage, and a page with no api blocks at all may still declare an + // ordinary `state api` (see the B5 regression test) where a bare "api" + // identifier is just that state, not a missed block reference. + if (!ast.dataApis.some((block) => block.mode === "any" && block.sections)) + return; + for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) { + const masked = maskStringsAndComments(fn.body); + const withoutCalls = masked.replace(/\bapi\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*/g, (match) => match.replace(/[^\n]/g, " ")); + if (/\bapi\b/.test(withoutCalls)) { + throw new Error(`.wrn ${fn.runtime} function "${fn.name}" in page "${ast.name}" references "api" in a form other than "api.(...)". ` + + `API blocks must be called as api.name(...) so the compiler can tell which ones the browser needs to receive; ` + + `dynamic or indirect access (e.g. api["name"](), or passing api to a helper) cannot be detected and would silently drop the block from the browser bundle.`); + } + } +} /** * 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 @@ -764,6 +832,7 @@ function apiBindings(ast) { return members.length ? `const api = {\n${members.join(",\n")}\n };` : ""; } function generateBrowserModule(ast) { + assertNoDynamicApiAccess(ast); const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime)); const functionNames = functions.map((fn) => fn.name); const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name); @@ -6217,10 +6286,11 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.stripRuntimeFunctionModifiers = exports.parseStructuredImports = exports.parseStoreLifecycle = exports.parseStateDeclarations = exports.parseRuntimeFunctions = exports.parsePersist = exports.parseOutputs = exports.parseComputedDeclarations = exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.containsReadonlyPropMutation = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.formatWrn = exports.LexError = exports.Lexer = void 0; +exports.stripRuntimeFunctionModifiers = exports.parseStructuredImports = exports.parseStoreLifecycle = exports.parseStateDeclarations = exports.parseRuntimeFunctions = exports.parsePersist = exports.parseOutputs = exports.parseComputedDeclarations = exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.containsReadonlyPropMutation = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.formatWrn = exports.skipLiteralOrComment = exports.LexError = exports.Lexer = void 0; var tokenizer_ts_1 = require("./tokenizer.js"); Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return tokenizer_ts_1.Lexer; } }); Object.defineProperty(exports, "LexError", { enumerable: true, get: function () { return tokenizer_ts_1.LexError; } }); +Object.defineProperty(exports, "skipLiteralOrComment", { enumerable: true, get: function () { return tokenizer_ts_1.skipLiteralOrComment; } }); var formatter_ts_1 = require("./formatter.js"); Object.defineProperty(exports, "formatWrn", { enumerable: true, get: function () { return formatter_ts_1.formatWrn; } }); var parser_ts_1 = require("./parser.js"); diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index 2452af85..e09285a1 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: 1791876dd8fecb7b56caf3cbb629ecdd96b84a1c0e46d3cfbb5515a8c30e281d +// WRN editor extension source hash: 42ceb9645e98cbc79abd3104e8426da535affc52327fb9e42ce658888d1b9941 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 15f3d320..9db289e5 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// WRN editor language server source hash: 7084da58f35fcb02c387b392d78660fa3b59f2060c765c3e982dd1918edab78a +// WRN editor language server source hash: d42dc4f557c0cb990f1d164708ca5d9ed8ddeabca3270f1b99f175b1f342572e // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // @bun @bun-cjs (function(exports, require, module, __filename, __dirname) {var __create = Object.create; diff --git a/packages/compiler/src/client-codegen.ts b/packages/compiler/src/client-codegen.ts index c06df2cc..2bd5eb57 100644 --- a/packages/compiler/src/client-codegen.ts +++ b/packages/compiler/src/client-codegen.ts @@ -1,5 +1,6 @@ import { eraseFunctionTypes, + skipLiteralOrComment, type PageAst, type RuntimeFunctionDecl, type StructuredImportDecl, @@ -307,6 +308,40 @@ function _functionEntry( }`; } +/** + * Blank out string/template literals and comments in a raw JS body, preserving + * length and newlines, so a scanner walking the result never mistakes text + * inside a string or comment for real code. Reuses the tokenizer's + * comment/string-skipping rules (`skipLiteralOrComment`) instead of + * reimplementing them — a second hand-rolled scanner is how apostrophes in + * prose used to swallow braces elsewhere in this codebase. + */ +function maskStringsAndComments(src: string): string { + let out = ""; + let i = 0; + let atLineStart = true; + while (i < src.length) { + const c = src[i]!; + if (c === "\n") { + out += c; + atLineStart = true; + i++; + continue; + } + const skipped = skipLiteralOrComment(src, i, atLineStart); + if (skipped !== null) { + out += src.slice(i, skipped).replace(/[^\n]/g, " "); + i = skipped; + atLineStart = false; + continue; + } + if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false; + out += c; + i++; + } + return out; +} + /** * Block names the page's client functions actually call. * @@ -316,18 +351,57 @@ function _functionEntry( */ 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]!); + for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) { + const masked = maskStringsAndComments(fn.body); + for (const match of masked.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) { + called.add(match[1]!); + } } return called; } +/** + * Refuse to compile a client/shared function whose body references `api` in + * any form other than `api.` — e.g. `api["searchUsers"]()`, or + * passing `api` to a helper. Usage-driven emission (see `clientCalledApiNames` + * above) can only see `api.` calls; a dynamic or indirect + * reference is invisible to it, so the referenced block would be silently + * dropped from the browser bundle and the call would fail at runtime with + * "api. is not a function". That failure direction is worse than a + * loud compile error, so it is caught here instead. + * + * Strings and comments are masked out first so `api` appearing in prose or in + * a quoted value never trips this check, and every `api.` access + * is stripped before the standalone-word scan so a real, well-formed call + * never does either. + */ +function assertNoDynamicApiAccess(ast: PageAst): void { + // Only pages with a mode "any" block have anything at stake here: those + // blocks are emitted solely because usage detection saw `api.`, so a + // dynamic reference this scan can't see is the one that silently drops a + // block from the bundle. Mode "client" blocks always ship regardless of + // usage, and a page with no api blocks at all may still declare an + // ordinary `state api` (see the B5 regression test) where a bare "api" + // identifier is just that state, not a missed block reference. + if (!ast.dataApis.some((block) => block.mode === "any" && block.sections)) return; + + for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) { + const masked = maskStringsAndComments(fn.body); + const withoutCalls = masked.replace(/\bapi\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*/g, (match) => + match.replace(/[^\n]/g, " "), + ); + if (/\bapi\b/.test(withoutCalls)) { + throw new Error( + `.wrn ${fn.runtime} function "${fn.name}" in page "${ast.name}" references "api" in a form other than "api.(...)". ` + + `API blocks must be called as api.name(...) so the compiler can tell which ones the browser needs to receive; ` + + `dynamic or indirect access (e.g. api["name"](), or passing api to a helper) cannot be detected and would silently drop the block from the browser bundle.`, + ); + } + } +} + /** * 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 @@ -370,6 +444,7 @@ function apiBindings(ast: PageAst): string { } export function generateBrowserModule(ast: PageAst): string { + assertNoDynamicApiAccess(ast); const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime)); const functionNames = functions.map((fn) => fn.name); const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name); diff --git a/packages/compiler/test/apis-client-emit.test.ts b/packages/compiler/test/apis-client-emit.test.ts index ff08ec62..8d4607f0 100644 --- a/packages/compiler/test/apis-client-emit.test.ts +++ b/packages/compiler/test/apis-client-emit.test.ts @@ -58,3 +58,43 @@ test("declared field types never reach the browser module", () => { expect(browser).not.toContain("name?: string"); }); + +test("dynamic bracket access on api is a compile error naming the construct", () => { + expect(() => generateTargets(parse(withCalls(` await api["used"]({ name: "a" })`)))).toThrow( + /api/, + ); +}); + +test("passing api to a helper is a compile error", () => { + expect(() => generateTargets(parse(withCalls(` callHelper(api)`)))).toThrow(/api/); +}); + +test("a plain api.name(...) call still compiles", () => { + expect(() => + generateTargets(parse(withCalls(` await api.used({ name: "a" })`))), + ).not.toThrow(); +}); + +test("an identifier that merely contains 'api' does not trigger the dynamic-access error", () => { + expect(() => + generateTargets( + parse( + withCalls( + ` const rapidCheck = 1; this.apiary = rapidCheck; await api.used({ name: "a" })`, + ), + ), + ), + ).not.toThrow(); +}); + +test("the word 'api' inside a comment or string literal does not trigger the dynamic-access error", () => { + expect(() => + generateTargets( + parse( + withCalls( + ` // this mentions api in a comment\n const note = "the api is great";\n await api.used({ name: "a" })`, + ), + ), + ), + ).not.toThrow(); +}); diff --git a/packages/syntax/src/index.ts b/packages/syntax/src/index.ts index f5f3341b..cfe3ce36 100644 --- a/packages/syntax/src/index.ts +++ b/packages/syntax/src/index.ts @@ -1,4 +1,4 @@ -export { Lexer, LexError } from "./tokenizer.ts"; +export { Lexer, LexError, skipLiteralOrComment } from "./tokenizer.ts"; export { formatWrn } from "./formatter.ts"; export type { FormatWrnOptions } from "./formatter.ts"; export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts";