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,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.<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
|
||||
* 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");
|
||||
|
||||
Reference in New Issue
Block a user