diff --git a/packages/cli/script/app-assets.ts b/packages/cli/script/app-assets.ts index e41431be14fb..4af206447b6e 100644 --- a/packages/cli/script/app-assets.ts +++ b/packages/cli/script/app-assets.ts @@ -1,28 +1,26 @@ import { $ } from "bun" import path from "node:path" import { brotliCompressSync, constants } from "node:zlib" +import { AppArchive } from "../src/app-archive" import { collectFiles } from "./files" export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) { - if (options?.skipBuild) return "{}" + if (options?.skipBuild) return AppArchive.encode([]) const root = path.resolve(import.meta.dirname, "../../app") await $`bun run build` .cwd(root) .env({ ...process.env, OPENCODE_CHANNEL: channel, VITE_OPENCODE_SERVER_MODE: "origin" }) - return JSON.stringify( - Object.fromEntries( - await Promise.all( - (await collectFiles(path.join(root, "dist"))) - .map((key) => key.replaceAll(path.sep, "/")) - .filter((key) => !key.endsWith(".map")) - .toSorted() - .map(async (key) => { - const source = path.join(root, "dist", key) - const body = Buffer.from(await Bun.file(source).arrayBuffer()) - // Independent entries let the server materialize only assets the browser requests. - return [key, compress(body)] as const - }), - ), + return AppArchive.encode( + await Promise.all( + (await collectFiles(path.join(root, "dist"))) + .map((key) => key.replaceAll(path.sep, "/")) + .filter((key) => !key.endsWith(".map")) + .toSorted() + .map(async (key) => { + const body = Buffer.from(await Bun.file(path.join(root, "dist", key)).arrayBuffer()) + // Independent entries let the server materialize only assets the browser requests. + return [key, compress(body)] as const + }), ), ) } @@ -30,5 +28,5 @@ export async function buildAppArchive(channel: string, options?: { skipBuild?: b function compress(body: Buffer) { return brotliCompressSync(body, { params: { [constants.BROTLI_PARAM_QUALITY]: 6 }, - }).toString("base64") + }) } diff --git a/packages/cli/script/build-node.ts b/packages/cli/script/build-node.ts index f8dafdf3a777..f4f9025b0b63 100644 --- a/packages/cli/script/build-node.ts +++ b/packages/cli/script/build-node.ts @@ -9,7 +9,7 @@ import { build } from "vite" import { Script } from "@opencode/script" import pkg from "../package.json" import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets" -import { mainConfig } from "../vite.node.config" +import { appArchiveAsset, mainConfig } from "../vite.node.config" import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target" import { buildAppArchive } from "./app-assets" import { verifyArtifact } from "./verify-artifact" @@ -59,7 +59,7 @@ if (targets.length === 0) { if (!bundleOnly && targets.some((target) => target.platform === "darwin" && target.arch === "x64")) { throw new Error("Node 26.4 SEA does not support macOS x64") } -const appArchive = archivePath ? (await Bun.file(archivePath).text()).trim() : await buildAppArchive(Script.channel) +const appArchive = archivePath ? await Bun.file(archivePath).bytes() : await buildAppArchive(Script.channel) if (!bundleOnly) await rm(outdir, { recursive: true, force: true }) const builder = !bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch) @@ -95,9 +95,9 @@ for (const target of targets) { channel: Script.channel, assetHash, target, - appArchive, } await copyNodeAssets(assets) + await writeFile(path.join("dist-node", "assets", appArchiveAsset), appArchive) await build(mainConfig(input)) await assertTextImportsInlined("dist-node/opencode.mjs") if (bundleOnly) await verifyArtifact("dist-node/opencode.mjs") diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index f55d8905e676..f0197be59d1e 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -64,7 +64,8 @@ if (!targets.length) throw new Error(`Unknown build target: ${requestedTarget}`) if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]} @opencode-ai/pty@${pkg.dependencies["@opencode-ai/pty"]}` -const appArchive = await buildAppArchive(Script.channel, { skipBuild: skipWebUi }) +const appArchive = path.join(dir, ".cache", "bun-app-archive.bin") +await Bun.write(appArchive, await buildAppArchive(Script.channel, { skipBuild: skipWebUi })) const appAssetsPlugin: BunPlugin = { name: "opencode-app-assets", setup(build) { @@ -74,7 +75,10 @@ const appAssetsPlugin: BunPlugin = { })) build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({ loader: "js", - contents: `export default ${appArchive}`, + // Embedded as a file, not a string: its bytes go into the executable once and unencoded. + contents: `import { readFileSync } from "node:fs" +import archive from ${JSON.stringify(appArchive.replaceAll("\\", "/"))} with { type: "file" } +export default () => readFileSync(archive)`, })) }, } diff --git a/packages/cli/script/service-smoke.ts b/packages/cli/script/service-smoke.ts index 75ab379441b2..cf2d11c3a350 100644 --- a/packages/cli/script/service-smoke.ts +++ b/packages/cli/script/service-smoke.ts @@ -52,6 +52,7 @@ try { signal: AbortSignal.timeout(5_000), }) if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication") + await verifyWebUi(info.url) if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation") const plugin = path.join(root, ".opencode", "plugins", "smoke.ts") await fs.writeFile(plugin, pluginSource()) @@ -79,9 +80,7 @@ try { if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner") if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit") - await Effect.runPromise( - Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)), - ) + await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer))) if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop") for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25) if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed") @@ -125,6 +124,18 @@ async function waitForRegistration() { throw new Error("Compiled service did not publish registration") } +// Reads the web UI from the binary's embedded archive: the shell, and the entry script it names. +async function verifyWebUi(url: string) { + const shell = await fetch(url, { signal: AbortSignal.timeout(5_000) }) + const html = await shell.text() + if (shell.status !== 200 || !html.includes("]*\bsrc="(\/_assets\/[^"]+\.js)"/)?.[1] + if (!script) throw new Error("Compiled web UI names no entry script") + const entry = await fetch(new URL(script, url), { signal: AbortSignal.timeout(5_000) }) + if (entry.status !== 200 || (await entry.text()).length === 0) + throw new Error(`Compiled service did not serve ${script}`) +} + async function waitForReady(url: string, headers: HeadersInit) { const deadline = Date.now() + 20_000 while (Date.now() < deadline) { diff --git a/packages/cli/src/app-archive.ts b/packages/cli/src/app-archive.ts new file mode 100644 index 000000000000..1dea316f968a --- /dev/null +++ b/packages/cli/src/app-archive.ts @@ -0,0 +1,38 @@ +import { Schema } from "effect" + +// The web UI the CLI serves, embedded as one binary file: a little-endian u32 byte length of a JSON index, the index of +// each asset's [offset, length] in the body, then the body of brotli-compressed assets. Raw bytes keep the archive the +// size of its compressed assets; a JavaScript string would add base64's third, and Bun's bytecode stores it twice. + +const decodeIndex = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Tuple([Schema.Number, Schema.Number]))), +) + +export function encode(entries: ReadonlyArray) { + const offsets = entries.reduce<{ at: number; index: Record }>( + (state, [key, body]) => { + state.index[key] = [state.at, body.length] + state.at += body.length + return state + }, + { at: 0, index: {} }, + ) + const index = new TextEncoder().encode(JSON.stringify(offsets.index)) + const header = new Uint8Array(4) + new DataView(header.buffer).setUint32(0, index.length, true) + return Buffer.concat([header, index, ...entries.map((entry) => entry[1])]) +} + +/** Each asset's compressed bytes, as views into the archive. */ +export function decode(archive: Uint8Array): Readonly> { + const length = new DataView(archive.buffer, archive.byteOffset, archive.byteLength).getUint32(0, true) + const body = 4 + length + return Object.fromEntries( + Object.entries(decodeIndex(new TextDecoder().decode(archive.subarray(4, body)))).map(([key, [offset, size]]) => [ + key, + archive.subarray(body + offset, body + offset + size), + ]), + ) +} + +export * as AppArchive from "./app-archive" diff --git a/packages/cli/src/app-assets.ts b/packages/cli/src/app-assets.ts index 6586773a93af..7209f58bb2be 100644 --- a/packages/cli/src/app-assets.ts +++ b/packages/cli/src/app-assets.ts @@ -2,17 +2,18 @@ import { Effect, FileSystem, Option } from "effect" import { readFileSync } from "node:fs" import path from "node:path" import { brotliDecompressSync } from "node:zlib" +import { AppArchive } from "./app-archive" import { OPENCODE_LOCAL } from "./version" export type AssetMap = Readonly> -type EncodedAssetMap = Readonly> export const load = Effect.fn("cli.app-assets.load")(function* () { - const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option) - if (Option.isSome(embedded) && (Object.keys(embedded.value.default).length > 0 || !OPENCODE_LOCAL)) - return lazy(embedded.value.default, (key) => - brotliDecompressSync(Buffer.from(embedded.value.default[key]!, "base64")), - ) + const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe( + Effect.map((module) => AppArchive.decode(module.default())), + Effect.option, + ) + if (Option.isSome(embedded) && (Object.keys(embedded.value).length > 0 || !OPENCODE_LOCAL)) + return lazy(embedded.value, (key) => brotliDecompressSync(embedded.value[key]!)) if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build")) return yield* sourceAssets() }) @@ -35,7 +36,7 @@ const sourceAssets = Effect.fnUntraced(function* () { return lazy(assets, (key) => readFileSync(assets[key]!)) }) -function lazy(assets: EncodedAssetMap, load: (key: string) => Uint8Array): AssetMap { +function lazy(assets: Readonly>, load: (key: string) => Uint8Array): AssetMap { // Immutable browser caching makes retaining decompressed copies in the server unnecessary. return new Proxy( {}, diff --git a/packages/cli/src/virtual.d.ts b/packages/cli/src/virtual.d.ts index ffe592a18f34..86b6464eb9e8 100644 --- a/packages/cli/src/virtual.d.ts +++ b/packages/cli/src/virtual.d.ts @@ -1,4 +1,5 @@ declare module "virtual:opencode-app-assets" { - const archive: Readonly> - export default archive + /** Reads the embedded web UI archive (see `app-archive.ts`); each build supplies it from its own embedded files. */ + const read: () => Uint8Array + export default read } diff --git a/packages/cli/test/app-archive.test.ts b/packages/cli/test/app-archive.test.ts new file mode 100644 index 000000000000..dc1dfee33cd2 --- /dev/null +++ b/packages/cli/test/app-archive.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { AppArchive } from "../src/app-archive" + +describe("app archive", () => { + test("reads back every asset's bytes by key", () => { + const entries = [ + ["index.html", new TextEncoder().encode("")], + ["_assets/empty.js", new Uint8Array()], + ["_assets/engine.wasm", Uint8Array.from({ length: 70_000 }, (_, index) => index % 251)], + ] as const + + const assets = AppArchive.decode(AppArchive.encode(entries)) + + expect(Object.keys(assets)).toEqual(entries.map((entry) => entry[0])) + entries.forEach(([key, body]) => expect(assets[key]).toEqual(body)) + }) + + test("reads an archive that starts partway into its buffer", () => { + const archive = AppArchive.encode([["sw.js", new Uint8Array([1, 2, 3])]]) + const shared = new Uint8Array(archive.length + 7) + shared.set(archive, 7) + + expect(AppArchive.decode(shared.subarray(7))["sw.js"]).toEqual(new Uint8Array([1, 2, 3])) + }) + + test("reads an empty archive as no assets", () => { + expect(AppArchive.decode(AppArchive.encode([]))).toEqual({}) + }) +}) diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts index 7554259f3c00..16a9c57c4d03 100644 --- a/packages/cli/vite.node.config.ts +++ b/packages/cli/vite.node.config.ts @@ -23,7 +23,10 @@ function rawTextPlugin(): Plugin { } } -function appAssetsPlugin(archive: string): Plugin { +/** The web UI archive's SEA asset key. The prelude leaves it in place, and the server reads it from SEA memory. */ +export const appArchiveAsset = "app-archive.bin" + +function appAssetsPlugin(): Plugin { return { name: "opencode:app-assets", resolveId(id) { @@ -31,7 +34,13 @@ function appAssetsPlugin(archive: string): Plugin { }, load(id) { if (id !== "\0virtual:opencode-app-assets") return - return `export default ${archive}` + return `import { readFileSync } from "node:fs" +import path from "node:path" +import { getRawAsset, isSea } from "node:sea" +export default () => + isSea() + ? new Uint8Array(getRawAsset(${JSON.stringify(appArchiveAsset)})) + : readFileSync(path.join(process.env.OPENCODE_NODE_ASSETS_DIR, ${JSON.stringify(appArchiveAsset)}))` }, } } @@ -205,6 +214,7 @@ const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)} if (__ocIsSea()) { const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)} for (const __ocKey of __ocAssetKeys()) { + if (__ocKey === ${JSON.stringify(appArchiveAsset)}) continue const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey) if (__ocExists(__ocTarget)) continue __ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true }) @@ -243,14 +253,13 @@ export type NodeBuildInput = { readonly channel: string readonly assetHash: string readonly target: NodeTarget - readonly appArchive: string } export function mainConfig(input: NodeBuildInput): UserConfig { return defineConfig({ root: dir, plugins: [ - appAssetsPlugin(input.appArchive), + appAssetsPlugin(), rawTextPlugin(), runtimeRequirePlugin(), fffNodePlugin(), @@ -292,5 +301,4 @@ export default mainConfig({ channel: process.env.OPENCODE_CHANNEL ?? "local", assetHash: "local", target: nodeTarget(process.platform, process.arch), - appArchive: "{}", })