From 7db9e88d40c41bbe9ed99964c3f194c2c8dcb19d Mon Sep 17 00:00:00 2001 From: G626 <103378730+Krablante@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:37:38 +0000 Subject: [PATCH] feat(web): cache and compress embedded assets --- docs/opencodez.md | 39 ++++-- .../httpapi/middleware/compression.ts | 19 ++- packages/opencode/src/server/shared/ui.ts | 111 ++++++++++++++++-- .../opencode/test/server/ui-assets.test.ts | 35 ++++++ 4 files changed, 180 insertions(+), 24 deletions(-) create mode 100644 packages/opencode/test/server/ui-assets.test.ts diff --git a/docs/opencodez.md b/docs/opencodez.md index 9de31a83d6..a87c118036 100644 --- a/docs/opencodez.md +++ b/docs/opencodez.md @@ -320,14 +320,14 @@ Example: "gpt-5.3-codex-spark": "codex_gpt_5_3_codex", "gpt-5.4": "codex_gpt_5_4", "gpt-5.4-mini": "codex_gpt_5_4_mini", - "deepseek": "default" + "deepseek": "default", }, "tone": { "default": "codex_pragmatic", - "anthropic": "codex_friendly" - } - } - } + "anthropic": "codex_friendly", + }, + }, + }, } ``` @@ -415,9 +415,7 @@ OPENCODE_WEB_SERVERS_JSON_B64 The JSON value is an array of entries. Two shapes are accepted: ```json -[ - { "name": "ser", "url": "http://192.168.1.215:4096" } -] +[{ "name": "ser", "url": "http://192.168.1.215:4096" }] ``` or the internal OpenCode Web connection shape: @@ -441,6 +439,23 @@ When multiple web origins are used, the remote `opencodez serve` processes also need matching `--cors` origins so browser health checks and API calls can reach the selected server. +## Web Asset Delivery + +OpenCodez serves the embedded web UI with caching and compression enabled by +default for hashed static assets. This keeps remote private links and mobile VPN +loads from downloading the same JavaScript and CSS repeatedly. + +The runtime files stay uncached: `index.html`, `opencode-web-servers.js`, API +responses, and event streams are not treated as immutable assets. + +Operators can turn the behavior off without changing code: + +```text +OPENCODE_UI_ASSET_CACHE=0 +OPENCODE_UI_ASSET_COMPRESSION=0 +OPENCODE_UI_ASSET_CACHE_MAX_AGE=31536000 +``` + ## Compact Indicator The TUI shows the active System and Tone while the user works. @@ -488,11 +503,11 @@ Config lives in `~/.config/opencodez/opencode.jsonc` under: "pruning_size": 20000, "prune": { "reasoning": true, - "tool": true + "tool": true, }, - "preserve_tools": [] - } - } + "preserve_tools": [], + }, + }, } ``` diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts index 9187bfea34..24a50247bc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts @@ -34,7 +34,7 @@ export const compressionLayer = HttpRouter.middleware<{ handles: unknown }>()((e const request = yield* HttpServerRequest.HttpServerRequest if (request.method === "HEAD") return response - if (response.headers["content-encoding"]) return response + if (response.headers["content-encoding"]) return varyAcceptEncoding(response) if (response.headers["transfer-encoding"]) return response const body = response.body @@ -55,10 +55,19 @@ export const compressionLayer = HttpRouter.middleware<{ handles: unknown }>()((e if (!encoding) return response const compressed = encoding === "gzip" ? gzipSync(body.body) : deflateSync(body.body) - return HttpServerResponse.setHeader( - HttpServerResponse.setBody(response, HttpBody.uint8Array(compressed, contentType)), - "content-encoding", - encoding, + return varyAcceptEncoding( + HttpServerResponse.setHeader( + HttpServerResponse.setBody(response, HttpBody.uint8Array(compressed, contentType)), + "content-encoding", + encoding, + ), ) }), ).layer + +function varyAcceptEncoding(response: HttpServerResponse.HttpServerResponse) { + const vary = response.headers["vary"] + const tokens = vary?.split(",").map((s) => s.trim().toLowerCase()) ?? [] + if (tokens.includes("*") || tokens.includes("accept-encoding")) return response + return HttpServerResponse.setHeader(response, "vary", [vary, "Accept-Encoding"].filter(Boolean).join(", ")) +} diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index a7d9e338f6..9bd9aa1308 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -2,6 +2,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect, Stream } from "effect" import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { createHash } from "node:crypto" +import { brotliCompressSync, gzipSync } from "node:zlib" import { ProxyUtil } from "../proxy-util" let embeddedUIPromise: Promise | null> | undefined @@ -13,6 +14,51 @@ export const csp = (hash = "") => export const DEFAULT_CSP = csp() const WEB_SERVERS_SCRIPT_PATH = "/opencode-web-servers.js" +const DEFAULT_UI_ASSET_CACHE_MAX_AGE_SECONDS = 31_536_000 + +type UIAssetSettings = { + cache: boolean + compression: boolean + cacheMaxAge: number +} + +export function uiAssetSettings(env: NodeJS.ProcessEnv = process.env): UIAssetSettings { + return { + cache: envBool(env.OPENCODE_UI_ASSET_CACHE, true), + compression: envBool(env.OPENCODE_UI_ASSET_COMPRESSION, true), + cacheMaxAge: envPositiveInt(env.OPENCODE_UI_ASSET_CACHE_MAX_AGE, DEFAULT_UI_ASSET_CACHE_MAX_AGE_SECONDS), + } +} + +export function uiAssetHeaders(requestPath: string, mime: string, settings = uiAssetSettings()) { + const headers = new Headers({ "content-type": mime }) + if (!settings.cache) return headers + + if (mime.startsWith("text/html") || requestPath === WEB_SERVERS_SCRIPT_PATH) { + headers.set("cache-control", "no-cache") + return headers + } + + if (isImmutableUIAsset(requestPath)) { + headers.set("cache-control", `public, max-age=${settings.cacheMaxAge}, immutable`) + } + + return headers +} + +export function compressUIAsset( + requestPath: string, + mime: string, + body: Uint8Array, + acceptEncoding = "", + settings = uiAssetSettings(), +) { + if (!settings.compression || !isCompressibleUIAsset(requestPath, mime, body)) return { body, encoding: undefined } + + if (acceptsEncoding(acceptEncoding, "br")) return { body: brotliCompressSync(body), encoding: "br" } + if (acceptsEncoding(acceptEncoding, "gzip")) return { body: gzipSync(body), encoding: "gzip" } + return { body, encoding: undefined } +} function configuredWebServers() { const raw = @@ -26,7 +72,12 @@ function configuredWebServers() { const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return [] return parsed.flatMap((server) => { - if (server && server.type === "http" && typeof server.displayName === "string" && typeof server.http?.url === "string") { + if ( + server && + server.type === "http" && + typeof server.displayName === "string" && + typeof server.http?.url === "string" + ) { return [{ type: "http", displayName: server.displayName, http: { url: server.http.url } }] } if (server && typeof server.name === "string" && typeof server.url === "string") { @@ -112,27 +163,34 @@ function notFound() { return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) } -function embeddedUIResponse(file: string, body: Uint8Array) { +function embeddedUIResponse(requestPath: string, file: string, body: Uint8Array, acceptEncoding = "") { const mime = FSUtil.mimeType(file) - const headers = new Headers({ "content-type": mime }) + const headers = uiAssetHeaders(requestPath, mime) if (mime.startsWith("text/html")) { const text = injectWebServersScript(new TextDecoder().decode(body)) headers.set("content-security-policy", cspForHtml(text)) return HttpServerResponse.text(text, { headers }) } - return HttpServerResponse.raw(body, { headers }) + const compressed = compressUIAsset(requestPath, mime, body, acceptEncoding) + if (compressed.encoding) { + headers.set("content-encoding", compressed.encoding) + headers.set("vary", "Accept-Encoding") + headers.set("content-length", String(compressed.body.byteLength)) + } + return HttpServerResponse.raw(compressed.body, { headers }) } export function serveEmbeddedUIEffect( requestPath: string, fs: FSUtil.Interface, embeddedWebUI: Record, + acceptEncoding = "", ) { const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null if (!file) return Effect.succeed(notFound()) return fs.readFile(file).pipe( - Effect.map((body) => embeddedUIResponse(file, body)), + Effect.map((body) => embeddedUIResponse(requestPath, file, body, acceptEncoding)), Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())), ) } @@ -147,11 +205,12 @@ export function serveUIEffect( if (path === WEB_SERVERS_SCRIPT_PATH) { return HttpServerResponse.text(webServersScript(), { - headers: new Headers({ "content-type": "text/javascript; charset=utf-8" }), + headers: uiAssetHeaders(WEB_SERVERS_SCRIPT_PATH, "text/javascript; charset=utf-8"), }) } - if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI) + if (embeddedWebUI) + return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI, request.headers["accept-encoding"]) const response = yield* services.client.execute( HttpClientRequest.make(request.method)(upstreamURL(path), { @@ -174,3 +233,41 @@ export function serveUIEffect( }) }) } + +function isImmutableUIAsset(requestPath: string) { + return requestPath.startsWith("/assets/") +} + +function isCompressibleUIAsset(requestPath: string, mime: string, body: Uint8Array) { + if (!isImmutableUIAsset(requestPath) || body.byteLength < 1024) return false + if (mime.startsWith("text/")) return true + return [ + "application/javascript", + "text/javascript", + "application/json", + "application/wasm", + "image/svg+xml", + "font/ttf", + "font/otf", + "font/woff", + "font/woff2", + ].some((type) => mime.includes(type)) +} + +function acceptsEncoding(acceptEncoding: string, encoding: "br" | "gzip") { + return acceptEncoding + .split(",") + .map((item) => item.trim().toLowerCase()) + .some((item) => item === encoding || item.startsWith(`${encoding};`)) +} + +function envBool(value: string | undefined, fallback: boolean) { + if (value === undefined || value === "") return fallback + return !["0", "false", "no", "off", "disabled"].includes(value.toLowerCase()) +} + +function envPositiveInt(value: string | undefined, fallback: number) { + if (!value) return fallback + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback +} diff --git a/packages/opencode/test/server/ui-assets.test.ts b/packages/opencode/test/server/ui-assets.test.ts new file mode 100644 index 0000000000..345e176c93 --- /dev/null +++ b/packages/opencode/test/server/ui-assets.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { gunzipSync } from "node:zlib" +import { compressUIAsset, uiAssetHeaders, uiAssetSettings } from "../../src/server/shared/ui" + +describe("UI asset delivery", () => { + test("caches hashed assets and leaves runtime files uncached", () => { + const asset = uiAssetHeaders("/assets/index-abc123.js", "text/javascript", uiAssetSettings({})) + expect(asset.get("cache-control")).toBe("public, max-age=31536000, immutable") + + const html = uiAssetHeaders("/", "text/html", uiAssetSettings({})) + expect(html.get("cache-control")).toBe("no-cache") + + const servers = uiAssetHeaders("/opencode-web-servers.js", "text/javascript", uiAssetSettings({})) + expect(servers.get("cache-control")).toBe("no-cache") + }) + + test("compresses text-like assets by default", () => { + const body = new TextEncoder().encode("const value = 1;\n".repeat(200)) + const result = compressUIAsset("/assets/index-abc123.js", "text/javascript", body, "gzip") + + expect(result.encoding).toBe("gzip") + expect(gunzipSync(result.body).toString()).toBe(new TextDecoder().decode(body)) + }) + + test("allows operators to disable cache and compression", () => { + const settings = uiAssetSettings({ OPENCODE_UI_ASSET_CACHE: "0", OPENCODE_UI_ASSET_COMPRESSION: "false" }) + const headers = uiAssetHeaders("/assets/index-abc123.js", "text/javascript", settings) + expect(headers.get("cache-control")).toBeNull() + + const body = new TextEncoder().encode("const value = 1;\n".repeat(200)) + const result = compressUIAsset("/assets/index-abc123.js", "text/javascript", body, "gzip", settings) + expect(result.encoding).toBeUndefined() + expect(result.body).toBe(body) + }) +})