From 5518ecaefe69d5c35d9b0cda227f2dba733dba03 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 17:43:18 -0400 Subject: [PATCH] Fix HttpApi web UI fallback (#25163) --- .../server/routes/instance/httpapi/server.ts | 19 +++++++- .../opencode/test/server/httpapi-ui.test.ts | 45 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-ui.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index f53ddb3ec5..f62636bca8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" @@ -38,6 +38,7 @@ import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" import { isAllowedCorsOrigin } from "@/server/cors" +import { UIRoutes } from "@/server/routes/ui" import { InstanceHttpApi, RootHttpApi } from "./api" import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { eventRoute } from "./event" @@ -119,7 +120,21 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe ]), ) -export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( +const uiRoutes = lazy(() => UIRoutes()) +const uiRoute = HttpRouter.add("*", "/*", (request) => + Effect.promise(async () => + uiRoutes().fetch( + request.source instanceof Request + ? request.source + : new Request(new URL(request.originalUrl, "http://localhost"), { + method: request.method, + headers: request.headers, + }), + ), + ).pipe(Effect.map(HttpServerResponse.fromWeb)), +) + +export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe( Layer.provide([ cors, runtime, diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts new file mode 100644 index 0000000000..9ca8b49f1b --- /dev/null +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { Server } from "../../src/server/server" + +void Log.init({ print: false }) + +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + fetch: globalThis.fetch, +} + +afterEach(() => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + globalThis.fetch = original.fetch +}) + +describe("HttpApi UI fallback", () => { + test("serves the web UI through the experimental backend", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + let proxiedUrl: string | undefined + globalThis.fetch = ((input: RequestInfo | URL) => { + proxiedUrl = String(input instanceof Request ? input.url : input) + return Promise.resolve(new Response("opencode", { headers: { "content-type": "text/html" } })) + }) as typeof fetch + + const response = await Server.Default().app.request("/") + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("text/html") + expect(await response.text()).toBe("opencode") + expect(proxiedUrl).toBe("https://app.opencode.ai/") + }) + + test("keeps matched API routes ahead of the UI fallback", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + globalThis.fetch = (() => { + throw new Error("UI fallback should not handle matched API routes") + }) as unknown as typeof fetch + + const response = await Server.Default().app.request("/session/nope") + + expect(response.status).toBe(404) + }) +})