diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 6cbba544cdc..1e9dcf6e090 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -60,20 +60,11 @@ export default defineConfig(async ({ command }) => { minify: command === "build", rolldownOptions: { input: { index: "src/main/index.ts" }, - // Keep this identical to electron-vite's Node 20.11+ shim. Its regex insertion can - // corrupt bundled TypeScript, while an output banner places the shim safely. output: { format: "es", // DesktopPaths resolves resources from the main output directory, // including when the lazy desktop entry shares it with other chunks. chunkFileNames: "[name]-[hash].js", - banner: ` -// -- CommonJS Shims -- -import __cjs_mod__ from 'node:module'; -const __filename = import.meta.filename; -const __dirname = import.meta.dirname; -const require = __cjs_mod__.createRequire(import.meta.url); -`, }, }, externalizeDeps: { @@ -92,6 +83,17 @@ const require = __cjs_mod__.createRequire(import.meta.url); return undefined }, }, + { + // Rolldown emits its own scoped createRequire runtime for CommonJS dependencies. + // electron-vite's post-bundle regex shim injects a top-level `const require` that collides + // with bundled ESM modules declaring their own `require` (such as @effect/platform-node). + name: "opencode:disable-esm-shim", + configResolved(config) { + const shim = config.plugins.find((plugin) => plugin.name === "vite:esm-shim") + + if (shim) delete shim.renderChunk + }, + }, ], }, preload: { diff --git a/packages/desktop/script/build.ts b/packages/desktop/script/build.ts index e46e7580e10..6d55837c170 100644 --- a/packages/desktop/script/build.ts +++ b/packages/desktop/script/build.ts @@ -3,6 +3,7 @@ import { $ } from "bun" import { exists } from "node:fs/promises" import path from "node:path" import { copyBuiltCliToResources, downloadCliToResources, getCurrentCli, resolveChannel } from "./utils" +import { verifyBundles } from "./verify-bundles" // Prepares resources/ (icons, metainfo, the bundled CLI) for OPENCODE_CHANNEL and builds the app into out/. // The CLI comes from ../cli/dist when it holds the target; otherwise dev downloads the published dev CLI, @@ -29,3 +30,5 @@ if (built) await copyBuiltCliToResources(cli) if (!built) await downloadCliToResources(channel === "beta" ? "beta" : undefined) await $`bunx electron-vite build` + +await verifyBundles(path.join(dir, "out")) diff --git a/packages/desktop/script/verify-bundles.ts b/packages/desktop/script/verify-bundles.ts new file mode 100644 index 00000000000..a545ee70291 --- /dev/null +++ b/packages/desktop/script/verify-bundles.ts @@ -0,0 +1,51 @@ +import path from "node:path" +import { Script, SourceTextModule } from "node:vm" + +const targets = ["main", "preload"] as const + +// Parses every built main and preload bundle without executing it, catching syntax and early +// redeclaration errors even in chunks that the app loads lazily at runtime. +export async function verifyBundles(outDir: string) { + const glob = new Bun.Glob("**/*.{js,cjs,mjs}") + const failures: string[] = [] + + for (const target of targets) { + const dir = path.join(outDir, target) + const files = await Array.fromAsync(glob.scan({ cwd: dir, onlyFiles: true })).catch(() => []) + + if (files.length === 0) throw new Error(`Expected built chunks in ${dir}, found none`) + + const errors = await Promise.all( + files.sort().map(async (file) => { + const label = `${target}/${file.replaceAll("\\", "/")}` + const error = parseError(await Bun.file(path.join(dir, file)).text(), label) + + return error ? `${label}: ${error}` : undefined + }), + ) + + failures.push(...errors.filter((item): item is string => item !== undefined)) + } + + if (failures.length > 0) throw new Error(`Desktop bundle syntax check failed:\n${failures.join("\n")}`) +} + +function parseError(code: string, identifier: string) { + const source = code.replace(/^#!.*\r?\n/, "\n") + + try { + if (identifier.endsWith(".cjs")) { + new Script(`(function (exports, require, module, __filename, __dirname) { ${source}\n})`, { + filename: identifier, + }) + + return undefined + } + + new SourceTextModule(source, { identifier }) + + return undefined + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +}