From 55fed2177ac0c9594c65565e5a64b8399a9608fc Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 19 Aug 2026 14:03:14 +0530 Subject: [PATCH] fix(compiler): strip TypeScript from client function bodies `wrnexus build` failed on any client function whose body used TypeScript: const requestBody: Record = {} error: Expected ";" but found ":" Codegen copies a client function's body into the browser module verbatim. It removes the types from the function's *signature*, which is what made this easy to miss -- the emitted module looked transpiled, and only bodies carried types through. The artifact is written as .mjs and read back as plain JavaScript, so the failure surfaced as a syntax error in generated code rather than at the .wrn line responsible. Browser modules are now transpiled before they are written, at all three sites that emit one (the production build and both dev-server paths). Reproduced end to end: a page with an annotated body failed the build with the reported errors, and after the fix builds, ships valid minified JS, and runs -- the handler sets its state correctly in a browser. Note: the same body is also embedded as a string for the CSP-safe fallback interpreter, which still receives it untranspiled. The compiled module shadows the fallback, so this is only reachable in the window before that module loads. Left alone here because stripping it lives in codegen, which also runs under Node in the editor bundle where the Bun transpiler is unavailable. Co-Authored-By: Claude Opus 5 --- docs/public-api-0.8.json | 3 +- editors/vscode/src/compiler.cjs | 34 ++++++- editors/vscode/src/extension.bundle.cjs | 2 +- editors/vscode/src/language-server.cjs | 2 +- packages/cli/src/build.ts | 5 +- packages/compiler/src/browser-transpile.ts | 33 +++++++ packages/compiler/src/index.ts | 1 + .../compiler/test/browser-transpile.test.ts | 93 +++++++++++++++++++ packages/dev-server/src/pipeline.ts | 4 +- 9 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/src/browser-transpile.ts create mode 100644 packages/compiler/test/browser-transpile.test.ts diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index aa114d55..b5bba72d 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -1040,7 +1040,8 @@ "rpcManifest", "runtimeCapabilities", "runtimeTypeOf", - "serializeIslandProps" + "serializeIslandProps", + "stripBrowserTypes" ] }, "@wrnexus/content": { diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 128a1b1a..1439485b 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,6 +1,6 @@ "use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. -// WRN editor compiler source hash: 27f13fbc79aedf4736913f268ab4af1f03a236ea44960cffb7caff225d157faf +// WRN editor compiler source hash: f474a2710a8861a4e863af13954e202e41a94b160c43c20297a7dfdd94b9ea8b // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // Generated with TypeScript: 6.0.3 const __nodeRequire = require; @@ -285,6 +285,34 @@ function analyzeRuntimeRequirements(ast, options = {}) { }; } +}, +"packages/compiler/src/browser-transpile.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +/** + * Strip TypeScript from a generated browser module. + * + * A client function's body is emitted verbatim, so anything TypeScript-only + * inside one -- an annotated local, an `as` cast, a local interface -- reaches + * the browser module as TypeScript source. Codegen removes the types from the + * function's *signature*, which is what made this easy to miss: the emitted + * module looked transpiled, and only bodies carried types through. + * + * The artifact is written as `.mjs` and read back as plain JavaScript, so the + * failure surfaced as a syntax error pointing at generated code rather than at + * the `.wrn` line responsible. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.stripBrowserTypes = stripBrowserTypes; +let transpiler = null; +function stripBrowserTypes(code) { + const bun = globalThis.Bun; + if (!bun?.Transpiler) { + throw new Error("WRN-CLIENT-TS: emitting a browser module needs the Bun transpiler to remove TypeScript from client function bodies."); + } + transpiler ??= new bun.Transpiler({ loader: "ts", target: "browser" }); + return transpiler.transformSync(code); +} + }, "packages/compiler/src/cache.ts": function (module, exports, require, __filename, __dirname) { "use strict"; @@ -3339,7 +3367,7 @@ function resolveWrnImports(declarations, importer, options) { * `@wrnexus/syntax` package. This package owns platform-specific codegen. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.routeNeedsIslands = exports.generateIslandEntry = exports.buildIslands = exports.assertReactAvailable = exports.serializeIslandProps = exports.renderIslandMarker = exports.parseIslandStrategy = exports.islandPropValue = exports.islandNamesFrom = exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0; +exports.routeNeedsIslands = exports.generateIslandEntry = exports.buildIslands = exports.assertReactAvailable = exports.serializeIslandProps = exports.renderIslandMarker = exports.parseIslandStrategy = exports.islandPropValue = exports.islandNamesFrom = exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.stripBrowserTypes = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0; exports.compileNativeWrnFile = compileNativeWrnFile; exports.compileWrnFile = compileWrnFile; exports.compile = compile; @@ -3361,6 +3389,8 @@ var targets_ts_1 = require("./targets.js"); Object.defineProperty(exports, "generateTargets", { enumerable: true, get: function () { return targets_ts_1.generateTargets; } }); var client_codegen_ts_1 = require("./client-codegen.js"); Object.defineProperty(exports, "generateBrowserModule", { enumerable: true, get: function () { return client_codegen_ts_1.generateBrowserModule; } }); +var browser_transpile_ts_1 = require("./browser-transpile.js"); +Object.defineProperty(exports, "stripBrowserTypes", { enumerable: true, get: function () { return browser_transpile_ts_1.stripBrowserTypes; } }); var server_codegen_ts_1 = require("./server-codegen.js"); Object.defineProperty(exports, "generateServerFunctionsModule", { enumerable: true, get: function () { return server_codegen_ts_1.generateServerFunctionsModule; } }); Object.defineProperty(exports, "rpcManifest", { enumerable: true, get: function () { return server_codegen_ts_1.rpcManifest; } }); diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index b7cf1aa8..8af09d2b 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: 63bce75e2686c7586a3a08b8811ebc681d2265fdfe54e984630614e3dcef21f5 +// WRN editor extension source hash: 0a678785f34a594b64f06ca6e39cf7bea49127ddf344d585e52cc1b3eda9a82a // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 43cc361b..55dbad09 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// WRN editor language server source hash: 8555261bb7933ee73d08cee279600fa64d2f35b35c5144ceba516e6929593d37 +// WRN editor language server source hash: afce4195f664e656dc8f38283bda8118637d977cd2eee8ba08c80906681510cd // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // @bun @bun-cjs (function(exports, require, module, __filename, __dirname) {var __create = Object.create; diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 2246584b..289e95ec 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -39,6 +39,7 @@ import { type DeploymentRuntime, runtimeCapabilities, resolveWrnImports, + stripBrowserTypes, } from "@wrnexus/compiler"; import { loadAppConfig, @@ -324,7 +325,9 @@ export async function runBuild(appRoot: string): Promise { } writeFileSync(out, code, "utf8"); - writeFileSync(clientEntry, browserCode, "utf8"); + // The entry is .mjs, so anything TypeScript left in a client function + // body would be read back as JavaScript and fail to parse. + writeFileSync(clientEntry, stripBrowserTypes(browserCode), "utf8"); const browserResult = await Bun.build({ entrypoints: [clientEntry], target: "browser", diff --git a/packages/compiler/src/browser-transpile.ts b/packages/compiler/src/browser-transpile.ts new file mode 100644 index 00000000..a7d2d1a7 --- /dev/null +++ b/packages/compiler/src/browser-transpile.ts @@ -0,0 +1,33 @@ +/** + * Strip TypeScript from a generated browser module. + * + * A client function's body is emitted verbatim, so anything TypeScript-only + * inside one -- an annotated local, an `as` cast, a local interface -- reaches + * the browser module as TypeScript source. Codegen removes the types from the + * function's *signature*, which is what made this easy to miss: the emitted + * module looked transpiled, and only bodies carried types through. + * + * The artifact is written as `.mjs` and read back as plain JavaScript, so the + * failure surfaced as a syntax error pointing at generated code rather than at + * the `.wrn` line responsible. + */ + +let transpiler: { transformSync(code: string): string } | null = null; + +export function stripBrowserTypes(code: string): string { + const bun = ( + globalThis as unknown as { + Bun?: { Transpiler: new (options: unknown) => { transformSync(code: string): string } }; + } + ).Bun; + + if (!bun?.Transpiler) { + throw new Error( + "WRN-CLIENT-TS: emitting a browser module needs the Bun transpiler to remove TypeScript from client function bodies.", + ); + } + + transpiler ??= new bun.Transpiler({ loader: "ts", target: "browser" }); + + return transpiler.transformSync(code); +} diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index f9cad94f..29fd26f7 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -31,6 +31,7 @@ export { export { generate } from "./codegen.ts"; export { generateTargets } from "./targets.ts"; export { generateBrowserModule } from "./client-codegen.ts"; +export { stripBrowserTypes } from "./browser-transpile.ts"; export { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts"; export { generateDeclarations } from "./type-codegen.ts"; export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen.ts"; diff --git a/packages/compiler/test/browser-transpile.test.ts b/packages/compiler/test/browser-transpile.test.ts new file mode 100644 index 00000000..089e831c --- /dev/null +++ b/packages/compiler/test/browser-transpile.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; +import { parse } from "@wrnexus/syntax"; +import { generateTargets } from "../src/targets.ts"; +import { stripBrowserTypes } from "../src/browser-transpile.ts"; + +/** Build the browser module for a page whose client function body is TypeScript. */ +function browserModuleFor(body: string): string { + const source = `page Repro { + functions { + client async function run(): Promise { +${body} + } + } + + view { +
+ } +} +`; + return generateTargets(parse(source)).browser; +} + +/** The artifact is written as .mjs, so this is how the runtime reads it back. */ +function parsesAsJavaScript(code: string): boolean { + try { + new Function(code.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, "")); + return true; + } catch { + return false; + } +} + +test("a client function body keeps its TypeScript in the generated module", () => { + // Codegen strips the signature's types but copies the body verbatim, which is + // what made this easy to miss. Guarding the premise the fix rests on. + const generated = browserModuleFor(` const requestBody: Record = {}`); + + expect(generated).toContain("const requestBody: Record"); + expect(parsesAsJavaScript(generated)).toBe(false); +}); + +test("stripping types makes an annotated client function body valid JavaScript", () => { + const stripped = stripBrowserTypes( + browserModuleFor(` const requestBody: Record = {} + requestBody.q = "x"`), + ); + + expect(parsesAsJavaScript(stripped)).toBe(true); + expect(stripped).not.toContain("Record"); + expect(stripped).toContain("requestBody.q"); +}); + +test("casts, generics and local interfaces survive stripping", () => { + const stripped = stripBrowserTypes( + browserModuleFor(` interface Local { a: string } + const names: string[] = ["a"] + const typed = { a: "x" } as Local + const total = (1 as number) + names.length + console.log(typed.a, total)`), + ); + + expect(parsesAsJavaScript(stripped)).toBe(true); + expect(stripped).toContain("console.log"); + expect(stripped).not.toContain("interface Local"); +}); + +test("the module's exported bindings are preserved", () => { + // A transpile that dropped one of these would break hydration silently. + const stripped = stripBrowserTypes( + browserModuleFor(` const value: number = 1 + console.log(value)`), + ); + + for (const binding of [ + "__wrnexusClientFunctions", + "__wrnexusClientState", + "__wrnexusOutputs", + "__wrnexusImportedBindings", + "bindClientScope", + ]) { + expect(stripped).toContain(binding); + } +}); + +test("a body with no TypeScript is left working", () => { + const stripped = stripBrowserTypes( + browserModuleFor(` const plain = { a: 1 } + console.log(plain.a)`), + ); + + expect(parsesAsJavaScript(stripped)).toBe(true); + expect(stripped).toContain("console.log"); +}); diff --git a/packages/dev-server/src/pipeline.ts b/packages/dev-server/src/pipeline.ts index 67889bf5..b4ec5573 100644 --- a/packages/dev-server/src/pipeline.ts +++ b/packages/dev-server/src/pipeline.ts @@ -22,6 +22,7 @@ import { buildIslands, islandNamesFrom, resolveWrnImports, + stripBrowserTypes, type PageAst, type ViewNode, } from "@wrnexus/compiler"; @@ -630,6 +631,7 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise