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