Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 14 additions & 16 deletions packages/cli/script/app-assets.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
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
}),
),
)
}

function compress(body: Buffer) {
return brotliCompressSync(body, {
params: { [constants.BROTLI_PARAM_QUALITY]: 6 },
}).toString("base64")
})
}
6 changes: 3 additions & 3 deletions packages/cli/script/build-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)`,
}))
},
}
Expand Down
17 changes: 14 additions & 3 deletions packages/cli/script/service-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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("<html")) throw new Error("Compiled service did not serve the web UI")
const script = html.match(/<script[^>]*\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) {
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/app-archive.ts
Original file line number Diff line number Diff line change
@@ -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<readonly [string, Uint8Array]>) {
const offsets = entries.reduce<{ at: number; index: Record<string, [number, number]> }>(
(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<Record<string, Uint8Array>> {
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"
15 changes: 8 additions & 7 deletions packages/cli/src/app-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string | Uint8Array>>
type EncodedAssetMap = Readonly<Record<string, string>>

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()
})
Expand All @@ -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<Record<string, unknown>>, load: (key: string) => Uint8Array): AssetMap {
// Immutable browser caching makes retaining decompressed copies in the server unnecessary.
return new Proxy(
{},
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/virtual.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
declare module "virtual:opencode-app-assets" {
const archive: Readonly<Record<string, string>>
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
}
29 changes: 29 additions & 0 deletions packages/cli/test/app-archive.test.ts
Original file line number Diff line number Diff line change
@@ -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("<html></html>")],
["_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({})
})
})
18 changes: 13 additions & 5 deletions packages/cli/vite.node.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,24 @@ 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) {
if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets"
},
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)}))`
},
}
}
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -292,5 +301,4 @@ export default mainConfig({
channel: process.env.OPENCODE_CHANNEL ?? "local",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
appArchive: "{}",
})
Loading