From a90ad8566db586c3da606af322263bc78ea67a86 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:35:20 +1000 Subject: [PATCH] fix(cli): serve the embedded web UI brotli-compressed (#53645) --- packages/cli/src/app-assets.ts | 9 ++++-- packages/cli/src/services/web-ui.ts | 32 ++++++++++++++++---- packages/cli/test/web-ui.test.ts | 45 +++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/app-assets.ts b/packages/cli/src/app-assets.ts index 7209f58bb2b..ef4c926d406 100644 --- a/packages/cli/src/app-assets.ts +++ b/packages/cli/src/app-assets.ts @@ -6,6 +6,8 @@ import { AppArchive } from "./app-archive" import { OPENCODE_LOCAL } from "./version" export type AssetMap = Readonly> +/** Each asset's brotli-compressed bytes, when the build embedded them. */ +export type BrotliMap = Readonly> export const load = Effect.fn("cli.app-assets.load")(function* () { const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe( @@ -13,9 +15,12 @@ export const load = Effect.fn("cli.app-assets.load")(function* () { Effect.option, ) if (Option.isSome(embedded) && (Object.keys(embedded.value).length > 0 || !OPENCODE_LOCAL)) - return lazy(embedded.value, (key) => brotliDecompressSync(embedded.value[key]!)) + return { + files: lazy(embedded.value, (key) => brotliDecompressSync(embedded.value[key]!)), + brotli: embedded.value, + } if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build")) - return yield* sourceAssets() + return { files: yield* sourceAssets(), brotli: undefined } }) const sourceAssets = Effect.fnUntraced(function* () { diff --git a/packages/cli/src/services/web-ui.ts b/packages/cli/src/services/web-ui.ts index 4d2c872f9e9..c00448a0b8f 100644 --- a/packages/cli/src/services/web-ui.ts +++ b/packages/cli/src/services/web-ui.ts @@ -2,12 +2,15 @@ import { FSUtil } from "@opencode/util/fs-util" import { Effect, FileSystem } from "effect" import { HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { createHash } from "node:crypto" -import { load, type AssetMap } from "../app-assets" +import { load, type AssetMap, type BrotliMap } from "../app-assets" -export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { readonly assets?: AssetMap }) { +export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { + readonly assets?: AssetMap + readonly brotli?: BrotliMap +}) { const fileSystem = yield* FileSystem.FileSystem const assets = options?.assets - ? Effect.succeed(options.assets) + ? Effect.succeed({ files: options.assets, brotli: options.brotli }) : yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem))) return (api: Effect.Effect) => Effect.gen(function* () { @@ -23,13 +26,21 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re return yield* api.pipe( Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))), ) - return yield* assets.pipe(Effect.flatMap((files) => serveUI(request, url, files))) + return yield* assets.pipe(Effect.flatMap((loaded) => serveUI(request, url, loaded.files, loaded.brotli))) }) }) -function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) { +function serveUI( + request: HttpServerRequest.HttpServerRequest, + url: URL, + assets: AssetMap, + brotli: BrotliMap | undefined, +) { const key = url.pathname.replace(/^\//, "") - const requested = assets[key] + // A browser that takes brotli gets the embedded bytes as they are, so the server decompresses nothing. The HTML stays + // decoded, because its CSP hashes the inline theme script. + const encoded = key !== "index.html" && acceptsBrotli(request.headers["accept-encoding"]) ? brotli?.[key] : undefined + const requested = encoded ?? 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 = requested !== undefined ? key : "index.html" @@ -46,6 +57,8 @@ function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: ? cspForHtml(typeof file === "string" ? file : Buffer.from(file).toString()) : csp(), "x-content-type-options": "nosniff", + ...(!html && brotli?.[name] !== undefined ? { vary: "accept-encoding" } : {}), + ...(encoded ? { "content-encoding": "br" } : {}), } return Effect.succeed( request.method === "HEAD" @@ -54,6 +67,13 @@ function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: ) } +function acceptsBrotli(header: string | undefined) { + return (header ?? "").split(",").some((entry) => { + const parts = entry.split(";").map((part) => part.trim().toLowerCase()) + return parts[0] === "br" && !parts.slice(1).some((part) => /^q=0(?:\.0*)?$/.test(part)) + }) +} + function isRouteNotFound(error: unknown) { return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound" } diff --git a/packages/cli/test/web-ui.test.ts b/packages/cli/test/web-ui.test.ts index 5db5ca3fdb9..92d48ad8bc6 100644 --- a/packages/cli/test/web-ui.test.ts +++ b/packages/cli/test/web-ui.test.ts @@ -7,6 +7,7 @@ import { createServer } from "node:http" import { mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" +import { brotliCompressSync } from "node:zlib" import { WebUi } from "../src/services/web-ui" import { it } from "../../core/test/lib/effect" @@ -89,6 +90,50 @@ describe("web UI", () => { }).pipe(Effect.provide(NodeFileSystem.layer)), ) + test("sends embedded brotli bytes as they are to browsers that accept them", async () => { + const script = "console.log('compressed')".repeat(50) + const html = "compressed" + const brotli = { "_assets/app.js": brotliCompressSync(script), "index.html": brotliCompressSync(html) } + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const transform = yield* WebUi.handler({ assets: { "_assets/app.js": script, "index.html": html }, brotli }) + const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 }) + yield* http.serve(transform(Effect.succeed(HttpServerResponse.empty({ status: 404 })))) + const origin = HttpServer.formatAddress(http.address) + const get = (pathname: string, encoding: string) => + Effect.promise(async () => { + const response = await fetch(`${origin}${pathname}`, { + headers: { "accept-encoding": encoding }, + decompress: false, + }) + return { headers: response.headers, body: new Uint8Array(await response.arrayBuffer()) } + }) + + const encoded = yield* get("/_assets/app.js", "gzip, deflate, br") + expect(encoded.headers.get("content-encoding")).toBe("br") + expect(encoded.headers.get("vary")).toBe("accept-encoding") + expect(encoded.headers.get("content-type")).toContain("javascript") + expect(encoded.body).toEqual(new Uint8Array(brotli["_assets/app.js"])) + + yield* Effect.forEach(["gzip", "br;q=0"], (encoding) => + Effect.gen(function* () { + const plain = yield* get("/_assets/app.js", encoding) + expect(plain.headers.get("content-encoding")).toBeNull() + expect(plain.headers.get("vary")).toBe("accept-encoding") + expect(new TextDecoder().decode(plain.body)).toBe(script) + }), + ) + + const page = yield* get("/", "br") + expect(page.headers.get("content-encoding")).toBeNull() + expect(new TextDecoder().decode(page.body)).toBe(html) + }), + ).pipe(Effect.provide(NodeFileSystem.layer)), + ) + }) + test("falls back from API routes to assets and the SPA index", async () => { const index = path.join(root, "index.html") const asset = path.join(root, "app.js")