From 2315b1f70ab43f2959dbc85e94783241518d2013 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 15 Sep 2026 21:14:10 -0400 Subject: [PATCH] fix(cli): load web assets on demand --- packages/cli/script/app-assets.ts | 39 +++++++++++++---------------- packages/cli/script/build.ts | 2 +- packages/cli/src/app-assets.ts | 39 ++++++++++++++++------------- packages/cli/src/services/web-ui.ts | 9 ++++--- packages/cli/src/virtual.d.ts | 2 +- packages/cli/vite.node.config.ts | 4 +-- 6 files changed, 48 insertions(+), 47 deletions(-) diff --git a/packages/cli/script/app-assets.ts b/packages/cli/script/app-assets.ts index b57843f943df..e41431be14fb 100644 --- a/packages/cli/script/app-assets.ts +++ b/packages/cli/script/app-assets.ts @@ -4,34 +4,31 @@ import { brotliCompressSync, constants } from "node:zlib" import { collectFiles } from "./files" export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) { - if (options?.skipBuild) return compress({}) + if (options?.skipBuild) return "{}" 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" }) - const assets = 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()) - const encoding = isText(key) ? "utf8" : "base64" - return [key, { encoding, content: body.toString(encoding) }] as const - }), + 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 compress(assets) } -function compress(assets: object) { - return brotliCompressSync(JSON.stringify(assets), { - params: { [constants.BROTLI_PARAM_QUALITY]: 11 }, +function compress(body: Buffer) { + return brotliCompressSync(body, { + params: { [constants.BROTLI_PARAM_QUALITY]: 6 }, }).toString("base64") } - -function isText(key: string) { - return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key) -} diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index 01900d1f7459..f55d8905e676 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -74,7 +74,7 @@ const appAssetsPlugin: BunPlugin = { })) build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({ loader: "js", - contents: `export default ${JSON.stringify(appArchive)}`, + contents: `export default ${appArchive}`, })) }, } diff --git a/packages/cli/src/app-assets.ts b/packages/cli/src/app-assets.ts index 22c48492730b..6586773a93af 100644 --- a/packages/cli/src/app-assets.ts +++ b/packages/cli/src/app-assets.ts @@ -1,48 +1,51 @@ import { Effect, FileSystem, Option } from "effect" +import { readFileSync } from "node:fs" import path from "node:path" import { brotliDecompressSync } from "node:zlib" import { OPENCODE_LOCAL } from "./version" export type AssetMap = Readonly> -type EncodedAssetMap = 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) && embedded.value.default.length > 0) return decodeArchive(embedded.value.default) + 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")), + ) if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build")) - return decode(yield* sourceAssets()) + return yield* sourceAssets() }) -function decodeArchive(archive: string) { - const body = brotliDecompressSync(Buffer.from(archive, "base64")).toString() - return decode(JSON.parse(body) as EncodedAssetMap) -} - const sourceAssets = Effect.fnUntraced(function* () { const fs = yield* FileSystem.FileSystem const root = path.resolve(import.meta.dirname, "../../app/dist") const files = yield* fs.readDirectory(root, { recursive: true }) - return Object.fromEntries( + const assets = Object.fromEntries( (yield* Effect.forEach( files.filter((file) => !file.endsWith(".map")), Effect.fnUntraced(function* (file) { const target = path.join(root, file) if ((yield* fs.stat(target)).type === "Directory") return - const body = Buffer.from(yield* fs.readFile(target)) - const encoding = isText(file) ? "utf8" : "base64" - return [file, { encoding, content: body.toString(encoding) }] as const + return [file, target] as const }), { concurrency: "unbounded" }, )).filter((asset) => asset !== undefined), ) + return lazy(assets, (key) => readFileSync(assets[key]!)) }) -function decode(assets: EncodedAssetMap): AssetMap { - return Object.fromEntries( - Object.entries(assets).map(([key, asset]) => [ - key, - asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"), - ]), +function lazy(assets: EncodedAssetMap, load: (key: string) => Uint8Array): AssetMap { + // Immutable browser caching makes retaining decompressed copies in the server unnecessary. + return new Proxy( + {}, + { + get: (_, key) => { + if (typeof key !== "string" || assets[key] === undefined) return + const body = load(key) + return isText(key) ? Buffer.from(body).toString() : body + }, + }, ) } diff --git a/packages/cli/src/services/web-ui.ts b/packages/cli/src/services/web-ui.ts index 56c55991a2c5..4378dd8d0751 100644 --- a/packages/cli/src/services/web-ui.ts +++ b/packages/cli/src/services/web-ui.ts @@ -26,11 +26,12 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) { const key = url.pathname.replace(/^\//, "") - if ((key.startsWith("_assets/") || key.startsWith("icons/")) && assets[key] === undefined) + const requested = assets[key] + if ((key.startsWith("_assets/") || key.startsWith("icons/")) && requested === undefined) return Effect.succeed(HttpServerResponse.empty({ status: 404, headers: { "cache-control": "no-store" } })) - const name = assets[key] !== undefined ? key : "index.html" - const file = assets[name] - if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 })) + const name = requested !== undefined ? key : "index.html" + const file = requested ?? assets["index.html"] + if (file === undefined) return Effect.succeed(HttpServerResponse.empty({ status: 404 })) if (request.method !== "GET" && request.method !== "HEAD") return Effect.succeed(HttpServerResponse.empty({ status: 405 })) const html = name === "index.html" diff --git a/packages/cli/src/virtual.d.ts b/packages/cli/src/virtual.d.ts index bb55d97cc9b5..ffe592a18f34 100644 --- a/packages/cli/src/virtual.d.ts +++ b/packages/cli/src/virtual.d.ts @@ -1,4 +1,4 @@ declare module "virtual:opencode-app-assets" { - const archive: string + const archive: Readonly> export default archive } diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts index 62993b9f39ce..7554259f3c00 100644 --- a/packages/cli/vite.node.config.ts +++ b/packages/cli/vite.node.config.ts @@ -31,7 +31,7 @@ function appAssetsPlugin(archive: string): Plugin { }, load(id) { if (id !== "\0virtual:opencode-app-assets") return - return `export default ${JSON.stringify(archive)}` + return `export default ${archive}` }, } } @@ -292,5 +292,5 @@ export default mainConfig({ channel: process.env.OPENCODE_CHANNEL ?? "local", assetHash: "local", target: nodeTarget(process.platform, process.arch), - appArchive: "", + appArchive: "{}", })