fix(compiler): strip TypeScript from client function bodies
Quality / quality (ubuntu-latest) (push) Failing after 9m53s
Quality / quality (windows-latest) (push) Canceled after 0s

`wrnexus build` failed on any client function whose body used TypeScript:

    const requestBody: Record<string, unknown> = {}
    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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 14:03:14 +05:30
co-authored by Claude Opus 5
parent 8c609edd32
commit 55fed2177a
9 changed files with 170 additions and 7 deletions
+2 -1
View File
@@ -1040,7 +1040,8 @@
"rpcManifest",
"runtimeCapabilities",
"runtimeTypeOf",
"serializeIslandProps"
"serializeIslandProps",
"stripBrowserTypes"
]
},
"@wrnexus/content": {
+32 -2
View File
@@ -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; } });
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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;
+4 -1
View File
@@ -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<void> {
}
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",
@@ -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);
}
+1
View File
@@ -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";
@@ -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<void> {
${body}
}
}
view {
<main><button @click="run()">go</button></main>
}
}
`;
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<string, unknown> = {}`);
expect(generated).toContain("const requestBody: Record<string, unknown>");
expect(parsesAsJavaScript(generated)).toBe(false);
});
test("stripping types makes an annotated client function body valid JavaScript", () => {
const stripped = stripBrowserTypes(
browserModuleFor(` const requestBody: Record<string, unknown> = {}
requestBody.q = "x"`),
);
expect(parsesAsJavaScript(stripped)).toBe(true);
expect(stripped).not.toContain("Record<string, unknown>");
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");
});
+3 -1
View File
@@ -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<Wrn
const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target);
let transformed = await devCompilerPipeline!.transformCode(rewritten, file);
if (target === "browser") {
transformed = stripBrowserTypes(transformed);
transformed = await bundleBrowserArtifact(transformed, file, cacheDir, stem);
}
writeFileSync(artifacts[target], transformed, "utf8");
@@ -731,7 +733,7 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
writeFileSync(artifacts.main, mainCode, "utf8");
writeFileSync(
artifacts.browser,
rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
stripBrowserTypes(rewriteArtifactImports(targets.browser, result.ast, file, "browser")),
"utf8",
);
rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser);