mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
fix(cli): serve the embedded web UI brotli-compressed (#53645)
This commit is contained in:
committed by
GitHub
Unverified
parent
a7f3d570ae
commit
a90ad8566d
@@ -6,6 +6,8 @@ import { AppArchive } from "./app-archive"
|
||||
import { OPENCODE_LOCAL } from "./version"
|
||||
|
||||
export type AssetMap = Readonly<Record<string, string | Uint8Array>>
|
||||
/** Each asset's brotli-compressed bytes, when the build embedded them. */
|
||||
export type BrotliMap = Readonly<Record<string, Uint8Array>>
|
||||
|
||||
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* () {
|
||||
|
||||
@@ -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 <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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 = "<html><body>compressed</body></html>"
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user