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(); 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 { const called = new Set(); 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 sectioned api 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. 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.(...)". ` + `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): 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); } `; }