fix(compiler): reject dynamic api access in client functions

Usage-driven emission can only see api.<name> 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.
This commit is contained in:
2026-08-20 07:25:02 +05:30
parent 712a6d3d8c
commit a08dfa5322
6 changed files with 202 additions and 17 deletions
+78 -8
View File
@@ -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: 6c7feefb98f5f19d3156a5953ddce47910f3c1572395bf4f39b1a23771b648b1 // WRN editor compiler source hash: 3b1bf12fc638343d15eb8502a2b10df4ad909529de8c94b612cb567959324f5d
// 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;
@@ -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. * Block names the page's client functions actually call.
* *
@@ -721,15 +755,49 @@ function _functionEntry(ast, fn, availableFunctions) {
*/ */
function clientCalledApiNames(ast) { function clientCalledApiNames(ast) {
const called = new Set(); const called = new Set();
const bodies = ast.runtimeFunctions for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
.filter((fn) => ["client", "shared"].includes(fn.runtime)) const masked = maskStringsAndComments(fn.body);
.map((fn) => fn.body) for (const match of masked.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
.join("\n"); called.add(match[1]);
for (const match of bodies.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) { }
called.add(match[1]);
} }
return called; return called;
} }
/**
* Refuse to compile a client/shared function whose body references `api` in
* any form other than `api.<identifier>` e.g. `api["searchUsers"]()`, or
* passing `api` to a helper. Usage-driven emission (see `clientCalledApiNames`
* above) can only see `api.<identifier>` 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.<name> 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.<identifier>` 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.<name>`, 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.<name>(...)". ` +
`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 * 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 * 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 };` : ""; return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
} }
function generateBrowserModule(ast) { function generateBrowserModule(ast) {
assertNoDynamicApiAccess(ast);
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime)); const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
const functionNames = functions.map((fn) => fn.name); const functionNames = functions.map((fn) => fn.name);
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.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); for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
}; };
Object.defineProperty(exports, "__esModule", { value: true }); 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"); var tokenizer_ts_1 = require("./tokenizer.js");
Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return tokenizer_ts_1.Lexer; } }); 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, "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"); var formatter_ts_1 = require("./formatter.js");
Object.defineProperty(exports, "formatWrn", { enumerable: true, get: function () { return formatter_ts_1.formatWrn; } }); Object.defineProperty(exports, "formatWrn", { enumerable: true, get: function () { return formatter_ts_1.formatWrn; } });
var parser_ts_1 = require("./parser.js"); var parser_ts_1 = require("./parser.js");
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 1791876dd8fecb7b56caf3cbb629ecdd96b84a1c0e46d3cfbb5515a8c30e281d // WRN editor extension source hash: 42ceb9645e98cbc79abd3104e8426da535affc52327fb9e42ce658888d1b9941
// 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);
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/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 // 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;
+81 -6
View File
@@ -1,5 +1,6 @@
import { import {
eraseFunctionTypes, eraseFunctionTypes,
skipLiteralOrComment,
type PageAst, type PageAst,
type RuntimeFunctionDecl, type RuntimeFunctionDecl,
type StructuredImportDecl, 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. * Block names the page's client functions actually call.
* *
@@ -316,18 +351,57 @@ function _functionEntry(
*/ */
function clientCalledApiNames(ast: PageAst): Set<string> { function clientCalledApiNames(ast: PageAst): Set<string> {
const called = new 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)) { for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
called.add(match[1]!); 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; return called;
} }
/**
* Refuse to compile a client/shared function whose body references `api` in
* any form other than `api.<identifier>` — e.g. `api["searchUsers"]()`, or
* passing `api` to a helper. Usage-driven emission (see `clientCalledApiNames`
* above) can only see `api.<identifier>` 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.<name> 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.<identifier>` 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.<name>`, 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.<name>(...)". ` +
`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 * 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 * 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 { export function generateBrowserModule(ast: PageAst): string {
assertNoDynamicApiAccess(ast);
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime)); const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
const functionNames = functions.map((fn) => fn.name); const functionNames = functions.map((fn) => fn.name);
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name); const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
@@ -58,3 +58,43 @@ test("declared field types never reach the browser module", () => {
expect(browser).not.toContain("name?: string"); 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();
});
+1 -1
View File
@@ -1,4 +1,4 @@
export { Lexer, LexError } from "./tokenizer.ts"; export { Lexer, LexError, skipLiteralOrComment } from "./tokenizer.ts";
export { formatWrn } from "./formatter.ts"; export { formatWrn } from "./formatter.ts";
export type { FormatWrnOptions } from "./formatter.ts"; export type { FormatWrnOptions } from "./formatter.ts";
export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts"; export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts";