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:
@@ -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<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]!);
|
||||
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.<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
|
||||
* 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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user