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
39 changes: 27 additions & 12 deletions docs/opencodez.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
},
}
```

Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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": [],
},
},
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(", "))
}
111 changes: 104 additions & 7 deletions packages/opencode/src/server/shared/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string> | null> | undefined
Expand All @@ -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 =
Expand All @@ -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") {
Expand Down Expand Up @@ -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<string, string>,
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())),
)
}
Expand All @@ -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), {
Expand All @@ -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
}
35 changes: 35 additions & 0 deletions packages/opencode/test/server/ui-assets.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading