Files
WRNexusJS/packages/compiler/src/client-codegen.ts
T

545 lines
19 KiB
TypeScript

import {
eraseFunctionTypes,
skipLiteralOrComment,
type PageAst,
type RuntimeFunctionDecl,
type StructuredImportDecl,
type ViewNode,
} from "@wrnexus/syntax";
const RESERVED_BINDINGS = new Set([
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"enum",
"export",
"extends",
"false",
"finally",
"for",
"function",
"if",
"implements",
"import",
"in",
"instanceof",
"interface",
"let",
"new",
"null",
"package",
"private",
"protected",
"public",
"return",
"static",
"super",
"switch",
"this",
"throw",
"true",
"try",
"typeof",
"var",
"void",
"while",
"with",
"yield",
]);
const RUNTIME_BINDINGS = new Set([
"context",
"state",
"output",
"server",
"props",
"refs",
"api",
"event",
"payload",
]);
function safeIdentifier(name: string): boolean {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name);
}
function identifierReferenced(source: string, name: string): boolean {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(?:^|[^A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(source);
}
function viewReferenceSource(nodes: ViewNode[]): string[] {
return nodes.flatMap((node): string[] => {
if (node.type === "text") return [node.value];
if (node.type === "each") {
return [
node.list,
node.key ?? "",
...viewReferenceSource(node.body),
...viewReferenceSource(node.empty),
];
}
if (node.type === "if") {
return node.branches.flatMap((branch) => [
branch.cond ?? "",
...viewReferenceSource(branch.body),
]);
}
return [...node.attrs.map((attr) => attr.value), ...viewReferenceSource(node.children)];
});
}
function browserReferenceSource(ast: PageAst, functions: RuntimeFunctionDecl[]): string {
return [
...functions.flatMap((fn) => [fn.source, fn.body]),
...ast.computed.map((entry) => entry.expr),
...ast.effects,
ast.lifecycle.mount ?? "",
ast.lifecycle.update ?? "",
ast.lifecycle.unmount ?? "",
...viewReferenceSource(ast.view),
].join("\n");
}
function renderSelectedImport(
entry: StructuredImportDecl,
source: string,
): { code: string; bindings: string[] } | null {
if (entry.typeOnly) return null;
const isStore = entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source);
if (entry.source.endsWith(".wrn") && !isStore) return null;
const defaultImport =
entry.defaultImport && (isStore || identifierReferenced(source, entry.defaultImport))
? entry.defaultImport
: undefined;
const namespaceImport =
entry.namespaceImport && (isStore || identifierReferenced(source, entry.namespaceImport))
? entry.namespaceImport
: undefined;
const namedImports = entry.namedImports.filter(
(item) => !item.typeOnly && (isStore || identifierReferenced(source, item.local)),
);
const bindings = [
...(defaultImport ? [defaultImport] : []),
...(namespaceImport ? [namespaceImport] : []),
...namedImports.map((item) => item.local),
].filter(safeIdentifier);
const hasBindings = Boolean(defaultImport || namespaceImport || namedImports.length);
const sideEffectOnly =
!entry.defaultImport && !entry.namespaceImport && entry.namedImports.length === 0;
if (!hasBindings && !sideEffectOnly) return null;
if (sideEffectOnly) return { code: entry.raw, bindings: [] };
const clauses: string[] = [];
if (defaultImport) clauses.push(defaultImport);
if (namespaceImport) clauses.push(`* as ${namespaceImport}`);
if (namedImports.length) {
clauses.push(
`{ ${namedImports
.map((item) =>
item.imported === item.local ? item.imported : `${item.imported} as ${item.local}`,
)
.join(", ")} }`,
);
}
return {
code: `import ${clauses.join(", ")} from ${JSON.stringify(entry.source)};`,
bindings,
};
}
function selectedBrowserImports(
ast: PageAst,
functions: RuntimeFunctionDecl[],
): { code: string; bindings: string[] }[] {
const referenceSource = browserReferenceSource(ast, functions);
return ast.structuredImports
.map((entry) => renderSelectedImport(entry, referenceSource))
.filter((entry): entry is { code: string; bindings: string[] } => entry !== null);
}
export function browserModuleRequired(ast: PageAst): boolean {
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0;
}
function _functionEntry(
ast: PageAst,
fn: RuntimeFunctionDecl,
availableFunctions: string[],
): string {
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
/*
* Names the function body declares for itself.
*
* Props and state are destructured into the SAME scope as the body, so a
* body that declares `var size` when `size` is also a prop produced
* "Identifier 'size' has already been declared" and the entire module
* failed to parse -- taking every function in the component down with it,
* with nothing to point at the one line responsible. Skipping the alias for
* a shadowed name is also what plain JavaScript does: inside that function
* the local wins.
*/
const declaredLocals = new Set<string>();
for (const match of fn.body.matchAll(
/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)|\bfunction\s+([A-Za-z_$][\w$]*)/g,
)) {
const name = match[1] ?? match[2];
if (name) declaredLocals.add(name);
}
const stateNames = ast.states
.filter(
(state) =>
state.runtime !== "server" &&
safeIdentifier(state.name) &&
!RUNTIME_BINDINGS.has(state.name) &&
!parameterNames.has(state.name) &&
!declaredLocals.has(state.name),
)
.map((state) => state.name);
const stateSet = new Set(stateNames);
const propNames = ast.props
.filter(
(prop) =>
safeIdentifier(prop.name) &&
!RUNTIME_BINDINGS.has(prop.name) &&
!parameterNames.has(prop.name) &&
!stateSet.has(prop.name) &&
!declaredLocals.has(prop.name),
)
.map((prop) => prop.name);
const functionAliases = availableFunctions.filter(
(name) =>
safeIdentifier(name) &&
!RUNTIME_BINDINGS.has(name) &&
!parameterNames.has(name) &&
!stateSet.has(name) &&
!propNames.includes(name) &&
!declaredLocals.has(name),
);
const parameters = fn.parameters.map((parameter) => parameter.name).join(", ");
const initialStateSnapshot = stateNames.length
? `const __wrnexusInitialState = { ${stateNames.map((name) => `${JSON.stringify(name)}: context.state.${name}`).join(", ")} };`
: "";
const stateAliases = stateNames.length ? `let { ${stateNames.join(", ")} } = context.state;` : "";
const propAliases = propNames.length ? `const { ${propNames.join(", ")} } = context.props;` : "";
const syncStateToContext = stateNames.map((name) => `context.state.${name} = ${name};`).join(" ");
const syncStateFromContext = stateNames
.map((name) => `${name} = context.state.${name};`)
.join(" ");
const peerAliases = !stateNames.length
? functionAliases
.map(
(name) =>
`const ${name} = (...__wrnexusPeerArgs) => context.functions[${JSON.stringify(name)}](...__wrnexusPeerArgs);`,
)
.join("\n")
: `const __wrnexusFlush = () => { ${syncStateToContext} };
const __wrnexusRestore = () => { ${syncStateFromContext} };
const __wrnexusPeer = (name, args) => {
__wrnexusFlush();
let result;
try {
result = context.functions[name](...args);
} catch (error) {
__wrnexusRestore();
throw error;
}
if (result && typeof result.then === "function") {
return Promise.resolve(result).finally(__wrnexusRestore);
}
__wrnexusRestore();
return result;
};
${functionAliases.map((name) => `const ${name} = (...__wrnexusPeerArgs) => __wrnexusPeer(${JSON.stringify(name)}, __wrnexusPeerArgs);`).join("\n")}`;
const copyBack = stateNames
.map(
(name) =>
`if (!Object.is(${name}, __wrnexusInitialState[${JSON.stringify(name)}])) context.state.${name} = ${name};`,
)
.join("\n");
const commitBinding = stateNames.length
? `const __wrnexusCommit = () => { ${copyBack} };
${!parameterNames.has("commit") && !declaredLocals.has("commit") && !functionAliases.includes("commit") ? "const commit = __wrnexusCommit;" : ""}
${
!parameterNames.has("setTimeout") &&
!declaredLocals.has("setTimeout") &&
!functionAliases.includes("setTimeout")
? `const setTimeout = (callback, delay, ...args) => globalThis.setTimeout(() => {
try { return callback(...args); } finally { __wrnexusCommit(); }
}, delay);`
: ""
}`
: "";
const body = eraseFunctionTypes(fn.body);
const runtimeBindings = [
!parameterNames.has("output") ? "const output = context.output;" : "",
!parameterNames.has("server") ? "const server = context.server;" : "",
!parameterNames.has("props") ? "const props = context.props;" : "",
!parameterNames.has("refs") ? "const refs = context.refs;" : "",
]
.filter(Boolean)
.join("\n ");
return `${JSON.stringify(fn.name)}: ${fn.async ? "async " : ""}function(context${parameters ? `, ${parameters}` : ""}) {
${initialStateSnapshot}
${stateAliases}
${propAliases}
${commitBinding}
${peerAliases}
${runtimeBindings}
try {
${body}
} finally {
${stateNames.length ? "__wrnexusCommit();" : ""}
}
}`;
}
/**
* 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.
*
* 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>();
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 sectioned api 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. 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.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 declares typed sections
* 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) && 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
* type-only and are consumed by the types generator instead. Anything
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
*/
function apiBindings(ast: PageAst): string {
const called = clientCalledApiNames(ast);
const members = ast.dataApis
.filter((block) => isClientEmittedApiBlock(block, called))
.map((block) => {
const sections = block.sections!;
const response = eraseFunctionTypes(sections.response).trim() || "return data;";
const error = eraseFunctionTypes(sections.error).trim();
const failure = error
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
: `(error) => { throw error; }`;
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(
block.path,
)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }, ${failure})`;
});
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
}
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);
const selectedImports = selectedBrowserImports(ast, functions);
const imports = selectedImports.map((entry) => entry.code).join("\n");
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
// `api` is only defined as a client-scope binding when the page actually has
// client-mode api blocks (see apiBindings below). A page that declares
// `state api` without any client api blocks must keep reading/writing that
// state as before, so only exclude the "api" name from destructuring when
// there is a real `api` binding to shadow it.
const hasClientApi = ast.dataApis.some((block) =>
isClientEmittedApiBlock(block, clientCalledApiNames(ast)),
);
const localRuntimeBindings = hasClientApi
? RUNTIME_BINDINGS
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
const sharedState = state.filter(
(name) => safeIdentifier(name) && !localRuntimeBindings.has(name),
);
const sharedProps = ast.props
.map((entry) => entry.name)
.filter(
(name) =>
safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name),
);
const callableAliases = functionNames.filter(
(name) =>
safeIdentifier(name) &&
!RUNTIME_BINDINGS.has(name) &&
!sharedState.includes(name) &&
!sharedProps.includes(name),
);
const sharedCommit = sharedState.map((name) => `context.state.${name} = ${name};`).join(" ");
const sharedRestore = [
...sharedState.map((name) => `${name} = context.state.${name};`),
...sharedProps.map((name) => `${name} = context.props.${name};`),
].join(" ");
const hasAuthoredCommit = callableAliases.includes("commit");
const hasAuthoredSetTimeout = callableAliases.includes("setTimeout");
const implementations = functions
.map((fn) => {
const parameters = fn.parameters.map((parameter) => parameter.name).join(", ");
return `${JSON.stringify(fn.name)}: ${fn.async ? "async " : ""}function(${parameters}) {
try { ${eraseFunctionTypes(fn.body)} } finally { __wrnexusCommit(); }
}`;
})
.join(",\n");
return `// generated WRNexusJS browser module for ${ast.name}
${imports}
export const __wrnexusClientFunctions = {
${functions
.map(
(fn) =>
` ${JSON.stringify(fn.name)}: (context, ...args) => __wrnexusBindings(context)[${JSON.stringify(fn.name)}](...args)`,
)
.join(",\n")}
};
export const __wrnexusClientState = ${JSON.stringify(state)};
export const __wrnexusOutputs = ${JSON.stringify(ast.outputs)};
export const __wrnexusImportedBindings = { ${importedBindings.join(", ")} };
function __wrnexusCreateClientFunctions(context) {
${sharedState.length ? `let { ${sharedState.join(", ")} } = context.state;` : ""}
${sharedProps.length ? `let { ${sharedProps.join(", ")} } = context.props;` : ""}
const output = context.output;
const server = context.server;
const props = context.props;
const refs = context.refs;
${apiBindings(ast)}
const __wrnexusCommit = () => { ${sharedCommit} };
const __wrnexusRestore = () => { ${sharedRestore} };
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
${
!hasAuthoredSetTimeout
? `const setTimeout = (callback, delay, ...args) => globalThis.setTimeout(() => {
try { return callback(...args); } finally { __wrnexusCommit(); }
}, delay);`
: ""
}
const implementations = {
${implementations}
};
${callableAliases.map((name) => `const ${name} = (...args) => implementations[${JSON.stringify(name)}](...args);`).join("\n ")}
const functions = {};
for (const name of Object.keys(implementations)) {
functions[name] = (...args) => {
__wrnexusRestore();
return implementations[name](...args);
};
}
return functions;
}
function __wrnexusBindings(context) {
return context.__wrnexusBoundFunctions ||
(context.__wrnexusBoundFunctions = __wrnexusCreateClientFunctions(context));
}
export function bindClientScope(context) {
return __wrnexusBindings(context);
}
`;
}