diff --git a/packages/cli/src/services/web-ui.ts b/packages/cli/src/services/web-ui.ts index 368e6eb48e9..c7306a6bc2a 100644 --- a/packages/cli/src/services/web-ui.ts +++ b/packages/cli/src/services/web-ui.ts @@ -42,7 +42,9 @@ function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: "x-content-type-options": "nosniff", } return Effect.succeed( - request.method === "HEAD" ? HttpServerResponse.empty({ headers }) : HttpServerResponse.raw(file, { headers }), + request.method === "HEAD" + ? HttpServerResponse.empty({ headers }) + : HttpServerResponse.raw(file, { headers, contentType: headers["content-type"] }), ) } diff --git a/packages/cli/test/web-ui.test.ts b/packages/cli/test/web-ui.test.ts index 1aab6239785..773d4fbb8e2 100644 --- a/packages/cli/test/web-ui.test.ts +++ b/packages/cli/test/web-ui.test.ts @@ -55,6 +55,7 @@ describe("web UI", () => { const script = yield* Effect.promise(() => fetch(`${origin}/app.js`)) expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')") + expect(script.headers.get("content-type")).toContain("javascript") expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable") const worker = yield* Effect.promise(() => fetch(`${origin}/sw.js`)) @@ -64,6 +65,7 @@ describe("web UI", () => { expect(registration.headers.get("cache-control")).toBe("no-cache") const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`)) + expect(font.headers.get("content-type")).toBe("font/woff2") expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual( new Uint8Array([0, 1, 2, 255]), ) diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index fd2f2a7549e..05814cfe7a9 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -4,7 +4,14 @@ import { NodeHttpServer } from "@effect/platform-node" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect" -import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { + HttpMiddleware, + HttpPlatform, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" import { createServer } from "node:http" import { ServerAuth } from "./auth" import { isAllowedCorsOrigin } from "./cors" @@ -90,7 +97,7 @@ export const start = Effect.fn("ServerProcess.start")(function* ( const host = address.family === "IPv6" ? `[${address.address}]` : address.address return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname) }, - ).pipe(Layer.provide(NodeHttpServer.layerHttpServices)), + ).pipe(Layer.provideMerge(NodeHttpServer.layerHttpServices)), applicationScope, ) if (lifecycle) { @@ -98,7 +105,12 @@ export const start = Effect.fn("ServerProcess.start")(function* ( Effect.provideService(Scope.Scope, applicationScope), ) } - const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect() + const app = Context.get(context, HttpRouter.HttpRouter) + .asHttpEffect() + .pipe( + HttpMiddleware.compression(), + Effect.provideService(HttpPlatform.HttpPlatform, Context.get(context, HttpPlatform.HttpPlatform)), + ) yield* Ref.set(application, Option.some(transform ? transform(app) : app)) yield* status.ready return { address: bound.http.address, shutdown: shutdown.await } diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index 71125a1d998..9deabc24ea0 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -6,6 +6,7 @@ import { ServerProcess } from "../src/process" it.live("allows browser preflight requests without credentials", () => Effect.gen(function* () { + const fallback = "fallback".repeat(256) const server = yield* ServerProcess.start( { hostname: "127.0.0.1", @@ -19,7 +20,7 @@ it.live("allows browser preflight requests without credentials", () => api.pipe( Effect.catchIf( (error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound", - () => Effect.succeed(HttpServerResponse.text("fallback")), + () => Effect.succeed(HttpServerResponse.raw(fallback, { contentType: "text/plain" })), ), ), ) @@ -51,12 +52,30 @@ it.live("allows browser preflight requests without credentials", () => expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" }) + const event = yield* Effect.promise(() => + fetch(new URL("/api/event", HttpServer.formatAddress(server.address)), { + headers: { + "accept-encoding": "br", + authorization: `Basic ${btoa("opencode:secret")}`, + }, + }), + ) + expect(event.status).toBe(200) + expect(event.headers.get("content-encoding")).toBeNull() + yield* Effect.promise(() => event.body?.cancel() ?? Promise.resolve()) + const missing = yield* Effect.promise(() => fetch(new URL("/missing", HttpServer.formatAddress(server.address)), { - headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + headers: { + "accept-encoding": "br", + authorization: `Basic ${btoa("opencode:secret")}`, + }, }), ) expect(missing.status).toBe(200) - expect(yield* Effect.promise(() => missing.text())).toBe("fallback") + expect(missing.headers.get("content-encoding")).toBe("br") + expect(missing.headers.get("content-type")).toBe("text/plain") + expect(missing.headers.get("vary")?.toLowerCase()).toContain("accept-encoding") + expect(yield* Effect.promise(() => missing.text())).toBe(fallback) }), )