diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts new file mode 100644 index 0000000000..9dc9bc01ec --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts @@ -0,0 +1,64 @@ +import { deflateSync, gzipSync } from "node:zlib" +import { Effect } from "effect" +import { HttpBody, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" + +// Mirror of Hono's compressible content-type set so wire behavior matches. +const COMPRESSIBLE_CONTENT_TYPE_REGEX = + /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i + +const NO_TRANSFORM_REGEX = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i + +const STREAMING_PATHS = new Set(["/event", "/global/event"]) +const STREAMING_POST_REGEX = /^\/session\/[^/]+\/(?:message|prompt_async)$/ + +const THRESHOLD_BYTES = 1024 + +type Encoding = "gzip" | "deflate" + +function pickEncoding(acceptEncoding: string | undefined): Encoding | undefined { + if (!acceptEncoding) return undefined + const lower = acceptEncoding.toLowerCase() + if (lower.includes("gzip")) return "gzip" + if (lower.includes("deflate")) return "deflate" + return undefined +} + +function pathOf(url: string): string { + const queryIndex = url.indexOf("?") + return queryIndex === -1 ? url : url.slice(0, queryIndex) +} + +export const compressionLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => + Effect.gen(function* () { + const response = yield* effect + const request = yield* HttpServerRequest.HttpServerRequest + + if (request.method === "HEAD") return response + if (response.headers["content-encoding"]) return response + if (response.headers["transfer-encoding"]) return response + + const body = response.body + if (body._tag !== "Uint8Array") return response + if (body.body.byteLength < THRESHOLD_BYTES) return response + + const cacheControl = response.headers["cache-control"] + if (cacheControl && NO_TRANSFORM_REGEX.test(cacheControl)) return response + + const path = pathOf(request.url) + if (STREAMING_PATHS.has(path)) return response + if (request.method === "POST" && STREAMING_POST_REGEX.test(path)) return response + + const contentType = body.contentType + if (!COMPRESSIBLE_CONTENT_TYPE_REGEX.test(contentType)) return response + + const encoding = pickEncoding(request.headers["accept-encoding"]) + 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, + ) + }), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 4e07ab21ba..fd7c3ec110 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -81,6 +81,7 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" +import { compressionLayer } from "./middleware/compression" import { errorLayer } from "./middleware/error" import { fenceLayer } from "./middleware/fence" @@ -174,6 +175,7 @@ export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe( Layer.provide([ errorLayer, + compressionLayer, fenceLayer, cors(corsOptions), runtime, diff --git a/packages/opencode/test/server/httpapi-compression.test.ts b/packages/opencode/test/server/httpapi-compression.test.ts new file mode 100644 index 0000000000..a7e119bd87 --- /dev/null +++ b/packages/opencode/test/server/httpapi-compression.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { gunzipSync, inflateSync } from "node:zlib" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { Server } from "../../src/server/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function app() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + return Server.Default().app +} + +// /config echoes the config back. Padding the config pushes the response body +// well past the 1024 B threshold so we can observe compression behavior. +function fatConfig() { + const instructions: string[] = [] + for (let i = 0; i < 50; i++) { + instructions.push(`padding-instruction-${i}-${"x".repeat(40)}`) + } + return { + formatter: false, + lsp: false, + username: "compression-test-user", + instructions, + } +} + +describe("HttpApi compression", () => { + describe("encodes responses", () => { + test("gzips JSON when Accept-Encoding includes gzip and body exceeds threshold", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBe("gzip") + const compressed = new Uint8Array(await response.arrayBuffer()) + const decompressed = gunzipSync(compressed) + const json = JSON.parse(new TextDecoder().decode(decompressed)) + expect(json).toMatchObject({ username: "compression-test-user" }) + expect(compressed.byteLength).toBeLessThan(decompressed.byteLength) + }) + + test("uses deflate when only deflate is acceptable", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "deflate" }, + }) + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBe("deflate") + const compressed = new Uint8Array(await response.arrayBuffer()) + const decompressed = inflateSync(compressed) + const json = JSON.parse(new TextDecoder().decode(decompressed)) + expect(json).toMatchObject({ username: "compression-test-user" }) + }) + + test("prefers gzip when both gzip and deflate are acceptable", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip, deflate" }, + }) + expect(response.headers.get("content-encoding")).toBe("gzip") + }) + + test("does not include the original Content-Length when compressed", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + const compressed = new Uint8Array(await response.arrayBuffer()) + const declared = response.headers.get("content-length") + // Either absent (transfer-encoding chunked) or matches the compressed length. + if (declared !== null) expect(Number(declared)).toBe(compressed.byteLength) + }) + }) + + describe("skips", () => { + test("when no Accept-Encoding header is present", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("when Accept-Encoding only allows unsupported encodings", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "br" }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("when the response body is below the 1024-byte threshold", async () => { + // A bare config produces a tiny response (~few hundred bytes). + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.status).toBe(200) + const body = new Uint8Array(await response.arrayBuffer()) + expect(body.byteLength).toBeLessThan(1024) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("HEAD requests", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + method: "HEAD", + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + }) + + describe("streaming exclusions", () => { + test("/event SSE is not compressed", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const controller = new AbortController() + const response = await app().request("/event", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + signal: controller.signal, + }) + try { + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBeNull() + } finally { + controller.abort() + await response.body?.cancel().catch(() => {}) + } + }) + + test("/global/event SSE is not compressed", async () => { + const controller = new AbortController() + const response = await app().request("/global/event", { + headers: { "accept-encoding": "gzip" }, + signal: controller.signal, + }) + try { + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBeNull() + } finally { + controller.abort() + await response.body?.cancel().catch(() => {}) + } + }) + }) +})