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
9 changes: 7 additions & 2 deletions packages/cli/src/app-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@ 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(
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]!))
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* () {
Expand Down
32 changes: 26 additions & 6 deletions packages/cli/src/services/web-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
}
Expand Down
45 changes: 45 additions & 0 deletions packages/cli/test/web-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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")
Expand Down
Loading