summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-05-03 08:58:34 -0400
committerGitHub <[email protected]>2026-05-03 12:58:34 +0000
commitca75ac668103730bab0f0fef382982dd79693c52 (patch)
tree452cb64154f435fe6f4b3140269a2c3f2eb7d771 /packages
parentd1f597b5b5abfe330aa30ca3c33ca043bf9b9a83 (diff)
downloadopencode-ca75ac668103730bab0f0fef382982dd79693c52.tar.gz
opencode-ca75ac668103730bab0f0fef382982dd79693c52.zip
refactor(server): extract Hono-coupled utilities to backend-neutral modules (#25542)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/script/httpapi-exercise.ts4
-rw-r--r--packages/opencode/src/server/fence.ts74
-rw-r--r--packages/opencode/src/server/proxy.ts2
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts2
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts8
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts2
-rw-r--r--packages/opencode/src/server/routes/instance/tui.ts32
-rw-r--r--packages/opencode/src/server/routes/ui.ts96
-rw-r--r--packages/opencode/src/server/shared/fence.ts74
-rw-r--r--packages/opencode/src/server/shared/tui-control.ts28
-rw-r--r--packages/opencode/src/server/shared/ui.ts91
-rw-r--r--packages/opencode/src/server/shared/workspace-routing.ts36
-rw-r--r--packages/opencode/src/server/workspace.ts41
-rw-r--r--packages/opencode/test/server/httpapi-ui.test.ts2
-rw-r--r--packages/opencode/test/server/workspace-routing.test.ts6
15 files changed, 265 insertions, 233 deletions
diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts
index 1681f2e21..5bfcae14e 100644
--- a/packages/opencode/script/httpapi-exercise.ts
+++ b/packages/opencode/script/httpapi-exercise.ts
@@ -182,7 +182,7 @@ type Runtime = {
Todo: (typeof import("../src/session/todo"))["Todo"]
Worktree: (typeof import("../src/worktree"))["Worktree"]
Project: (typeof import("../src/project/project"))["Project"]
- Tui: typeof import("../src/server/routes/instance/tui")
+ Tui: typeof import("../src/server/shared/tui-control")
disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"]
tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"]
resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"]
@@ -203,7 +203,7 @@ function runtime() {
const todo = await import("../src/session/todo")
const worktree = await import("../src/worktree")
const project = await import("../src/project/project")
- const tui = await import("../src/server/routes/instance/tui")
+ const tui = await import("../src/server/shared/tui-control")
const fixture = await import("../test/fixture/fixture")
const db = await import("../test/fixture/db")
return {
diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts
index aa784c90d..1b8c42c89 100644
--- a/packages/opencode/src/server/fence.ts
+++ b/packages/opencode/src/server/fence.ts
@@ -1,78 +1,8 @@
import type { MiddlewareHandler } from "hono"
-import { Database } from "@/storage/db"
-import { inArray } from "drizzle-orm"
-import { EventSequenceTable } from "@/sync/event.sql"
-import { Workspace } from "@/control-plane/workspace"
-import type { WorkspaceID } from "@/control-plane/schema"
import * as Log from "@opencode-ai/core/util/log"
-import { AppRuntime } from "@/effect/app-runtime"
-import { Effect } from "effect"
+import { HEADER, diff, load } from "./shared/fence"
-const HEADER = "x-opencode-sync"
-type State = Record<string, number>
-const log = Log.create({ service: "fence" })
-
-export function load(ids?: string[]) {
- const rows = Database.use((db) => {
- if (!ids?.length) {
- return db.select().from(EventSequenceTable).all()
- }
-
- return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
- })
-
- return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
-}
-
-export function diff(prev: State, next: State) {
- const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
- return Object.fromEntries(
- [...ids]
- .map((id) => [id, next[id] ?? -1] as const)
- .filter(([id, seq]) => {
- return (prev[id] ?? -1) !== seq
- }),
- ) as State
-}
-
-export function parse(headers: Headers) {
- const raw = headers.get(HEADER)
- if (!raw) return
-
- let data
-
- try {
- data = JSON.parse(raw)
- } catch {
- return
- }
-
- if (!data || typeof data !== "object") return
-
- return Object.fromEntries(
- Object.entries(data).filter(([id, seq]) => {
- return typeof id === "string" && Number.isInteger(seq)
- }),
- ) as State
-}
-
-export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
- return Effect.gen(function* () {
- log.info("waiting for state", {
- workspaceID,
- state,
- })
- yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
- log.info("state fully synced", {
- workspaceID,
- state,
- })
- })
-}
-
-export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
- await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
-}
+const log = Log.create({ service: "fence-middleware" })
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()
diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts
index 051d64c24..069f30851 100644
--- a/packages/opencode/src/server/proxy.ts
+++ b/packages/opencode/src/server/proxy.ts
@@ -1,7 +1,7 @@
import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import * as Log from "@opencode-ai/core/util/log"
-import * as Fence from "./fence"
+import * as Fence from "./shared/fence"
import type { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts
index c7c447ce8..cc8532168 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts
@@ -5,7 +5,7 @@ import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
-import { nextTuiRequest, submitTuiResponse } from "../../tui"
+import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control"
import { InstanceHttpApi } from "../api"
import { CommandPayload, TuiPublishPayload } from "../groups/tui"
diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts
index 4a07aaf11..caa520f7c 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts
@@ -5,8 +5,12 @@ import { Workspace } from "@/control-plane/workspace"
import { EffectBridge } from "@/effect/bridge"
import { Session } from "@/session/session"
import { HttpApiProxy } from "./proxy"
-import * as Fence from "@/server/fence"
-import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
+import * as Fence from "@/server/shared/fence"
+import {
+ getWorkspaceRouteSessionID,
+ isLocalWorkspaceRoute,
+ workspaceProxyURL,
+} from "@/server/shared/workspace-routing"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer } from "effect"
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index e53eca3ef..650efe2b0 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -45,7 +45,7 @@ import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace"
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
-import { serveUIEffect } from "@/server/routes/ui"
+import { serveUIEffect } from "@/server/shared/ui"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
import { EventApi, eventHandlers } from "./event"
diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts
index d2be01521..a7a0c9cbd 100644
--- a/packages/opencode/src/server/routes/instance/tui.ts
+++ b/packages/opencode/src/server/routes/instance/tui.ts
@@ -7,32 +7,16 @@ import { Session } from "@/session/session"
import type { SessionID } from "@/session/schema"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { zodObject } from "@/util/effect-zod"
-import { AsyncQueue } from "@/util/queue"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { runRequest } from "./trace"
-
-export const TuiRequest = z.object({
- path: z.string(),
- body: z.any(),
-})
-
-export type TuiRequest = z.infer<typeof TuiRequest>
-
-const request = new AsyncQueue<TuiRequest>()
-const response = new AsyncQueue<unknown>()
-
-export function nextTuiRequest() {
- return request.next()
-}
-
-export function submitTuiRequest(body: TuiRequest) {
- request.push(body)
-}
-
-export function submitTuiResponse(body: unknown) {
- response.push(body)
-}
+import {
+ TuiRequest,
+ nextTuiRequest,
+ nextTuiResponse,
+ submitTuiRequest,
+ submitTuiResponse,
+} from "@/server/shared/tui-control"
export async function callTui(ctx: Context) {
const body = await ctx.req.json()
@@ -40,7 +24,7 @@ export async function callTui(ctx: Context) {
path: ctx.req.path,
body,
})
- return response.next()
+ return nextTuiResponse()
}
const TuiControlRoutes = new Hono()
diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts
index 403d85d66..ce06b2b35 100644
--- a/packages/opencode/src/server/routes/ui.ts
+++ b/packages/opencode/src/server/routes/ui.ts
@@ -1,53 +1,10 @@
-import { Flag } from "@opencode-ai/core/flag/flag"
+import fs from "node:fs/promises"
+import { createHash } from "node:crypto"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
-import { Effect, Stream } from "effect"
-import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { Hono } from "hono"
import { proxy } from "hono/proxy"
-import { getMimeType } from "hono/utils/mime"
-import { createHash } from "node:crypto"
-import fs from "node:fs/promises"
import { ProxyUtil } from "../proxy-util"
-
-const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
- ? Promise.resolve(null)
- : // @ts-expect-error - generated file at build time
- import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
-
-const DEFAULT_CSP =
- "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
-const UI_UPSTREAM = new URL("https://app.opencode.ai")
-
-const csp = (hash = "") =>
- `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
-
-function themePreloadHash(body: string) {
- return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
-}
-
-function requestBody(request: HttpServerRequest.HttpServerRequest) {
- if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
- const len = request.headers["content-length"]
- return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len))
-}
-
-function proxyResponseHeaders(headers: Record<string, string>) {
- const result = new Headers(headers)
- // FetchHttpClient exposes decoded response bodies, so forwarding upstream
- // transfer metadata makes browsers decode already-decoded assets again.
- result.delete("content-encoding")
- result.delete("content-length")
- return result
-}
-
-function upstreamURL(path: string) {
- return new URL(path, UI_UPSTREAM).toString()
-}
-
-function embeddedUI() {
- if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
- return embeddedUIPromise
-}
+import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui"
export async function serveUI(request: Request) {
const embeddedWebUI = await embeddedUI()
@@ -58,7 +15,7 @@ export async function serveUI(request: Request) {
if (!match) return Response.json({ error: "Not Found" }, { status: 404 })
if (await fs.exists(match)) {
- const mime = getMimeType(match) ?? "text/plain"
+ const mime = AppFileSystem.mimeType(match)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return new Response(new Uint8Array(await fs.readFile(match)), { headers })
@@ -79,49 +36,4 @@ export async function serveUI(request: Request) {
return response
}
-export function serveUIEffect(
- request: HttpServerRequest.HttpServerRequest,
- services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
-) {
- return Effect.gen(function* () {
- const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
- const path = new URL(request.url, "http://localhost").pathname
-
- if (embeddedWebUI) {
- const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
- if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
-
- if (yield* services.fs.existsSafe(match)) {
- const mime = getMimeType(match) ?? "text/plain"
- const headers = new Headers({ "content-type": mime })
- if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
- return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers })
- }
-
- return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
- }
-
- const response = yield* services.client.execute(
- HttpClientRequest.make(request.method)(upstreamURL(path), {
- headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
- body: requestBody(request),
- }),
- )
- const headers = proxyResponseHeaders(response.headers)
-
- if (response.headers["content-type"]?.includes("text/html")) {
- const body = yield* response.text
- const match = themePreloadHash(body)
- headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""))
- return HttpServerResponse.text(body, { status: response.status, headers })
- }
-
- headers.set("Content-Security-Policy", csp())
- return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
- status: response.status,
- headers,
- })
- })
-}
-
export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw))
diff --git a/packages/opencode/src/server/shared/fence.ts b/packages/opencode/src/server/shared/fence.ts
new file mode 100644
index 000000000..659764970
--- /dev/null
+++ b/packages/opencode/src/server/shared/fence.ts
@@ -0,0 +1,74 @@
+import { Database } from "@/storage/db"
+import { inArray } from "drizzle-orm"
+import { EventSequenceTable } from "@/sync/event.sql"
+import { Workspace } from "@/control-plane/workspace"
+import type { WorkspaceID } from "@/control-plane/schema"
+import * as Log from "@opencode-ai/core/util/log"
+import { AppRuntime } from "@/effect/app-runtime"
+import { Effect } from "effect"
+
+export const HEADER = "x-opencode-sync"
+export type State = Record<string, number>
+const log = Log.create({ service: "fence" })
+
+export function load(ids?: string[]) {
+ const rows = Database.use((db) => {
+ if (!ids?.length) {
+ return db.select().from(EventSequenceTable).all()
+ }
+
+ return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
+ })
+
+ return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
+}
+
+export function diff(prev: State, next: State) {
+ const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
+ return Object.fromEntries(
+ [...ids]
+ .map((id) => [id, next[id] ?? -1] as const)
+ .filter(([id, seq]) => {
+ return (prev[id] ?? -1) !== seq
+ }),
+ ) as State
+}
+
+export function parse(headers: Headers) {
+ const raw = headers.get(HEADER)
+ if (!raw) return
+
+ let data
+
+ try {
+ data = JSON.parse(raw)
+ } catch {
+ return
+ }
+
+ if (!data || typeof data !== "object") return
+
+ return Object.fromEntries(
+ Object.entries(data).filter(([id, seq]) => {
+ return typeof id === "string" && Number.isInteger(seq)
+ }),
+ ) as State
+}
+
+export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
+ return Effect.gen(function* () {
+ log.info("waiting for state", {
+ workspaceID,
+ state,
+ })
+ yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
+ log.info("state fully synced", {
+ workspaceID,
+ state,
+ })
+ })
+}
+
+export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
+ await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
+}
diff --git a/packages/opencode/src/server/shared/tui-control.ts b/packages/opencode/src/server/shared/tui-control.ts
new file mode 100644
index 000000000..40aaf04a9
--- /dev/null
+++ b/packages/opencode/src/server/shared/tui-control.ts
@@ -0,0 +1,28 @@
+import z from "zod"
+import { AsyncQueue } from "@/util/queue"
+
+export const TuiRequest = z.object({
+ path: z.string(),
+ body: z.any(),
+})
+
+export type TuiRequest = z.infer<typeof TuiRequest>
+
+const request = new AsyncQueue<TuiRequest>()
+const response = new AsyncQueue<unknown>()
+
+export function nextTuiRequest() {
+ return request.next()
+}
+
+export function submitTuiRequest(body: TuiRequest) {
+ request.push(body)
+}
+
+export function submitTuiResponse(body: unknown) {
+ response.push(body)
+}
+
+export function nextTuiResponse() {
+ return response.next()
+}
diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts
new file mode 100644
index 000000000..db67749e0
--- /dev/null
+++ b/packages/opencode/src/server/shared/ui.ts
@@ -0,0 +1,91 @@
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { Effect, Stream } from "effect"
+import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
+import { createHash } from "node:crypto"
+import { ProxyUtil } from "../proxy-util"
+
+const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
+ ? Promise.resolve(null)
+ : // @ts-expect-error - generated file at build time
+ import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
+
+export const DEFAULT_CSP =
+ "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
+export const UI_UPSTREAM = new URL("https://app.opencode.ai")
+
+export const csp = (hash = "") =>
+ `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
+
+export function themePreloadHash(body: string) {
+ return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
+}
+
+function requestBody(request: HttpServerRequest.HttpServerRequest) {
+ if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
+ const len = request.headers["content-length"]
+ return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len))
+}
+
+function proxyResponseHeaders(headers: Record<string, string>) {
+ const result = new Headers(headers)
+ // FetchHttpClient exposes decoded response bodies, so forwarding upstream
+ // transfer metadata makes browsers decode already-decoded assets again.
+ result.delete("content-encoding")
+ result.delete("content-length")
+ return result
+}
+
+export function upstreamURL(path: string) {
+ return new URL(path, UI_UPSTREAM).toString()
+}
+
+export function embeddedUI() {
+ if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
+ return embeddedUIPromise
+}
+
+export function serveUIEffect(
+ request: HttpServerRequest.HttpServerRequest,
+ services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
+) {
+ return Effect.gen(function* () {
+ const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
+ const path = new URL(request.url, "http://localhost").pathname
+
+ if (embeddedWebUI) {
+ const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
+ if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
+
+ if (yield* services.fs.existsSafe(match)) {
+ const mime = AppFileSystem.mimeType(match)
+ const headers = new Headers({ "content-type": mime })
+ if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
+ return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers })
+ }
+
+ return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
+ }
+
+ const response = yield* services.client.execute(
+ HttpClientRequest.make(request.method)(upstreamURL(path), {
+ headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
+ body: requestBody(request),
+ }),
+ )
+ const headers = proxyResponseHeaders(response.headers)
+
+ if (response.headers["content-type"]?.includes("text/html")) {
+ const body = yield* response.text
+ const match = themePreloadHash(body)
+ headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""))
+ return HttpServerResponse.text(body, { status: response.status, headers })
+ }
+
+ headers.set("Content-Security-Policy", csp())
+ return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
+ status: response.status,
+ headers,
+ })
+ })
+}
diff --git a/packages/opencode/src/server/shared/workspace-routing.ts b/packages/opencode/src/server/shared/workspace-routing.ts
new file mode 100644
index 000000000..366c455dd
--- /dev/null
+++ b/packages/opencode/src/server/shared/workspace-routing.ts
@@ -0,0 +1,36 @@
+import { SessionID } from "@/session/schema"
+
+type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
+
+const RULES: Array<Rule> = [
+ { path: "/experimental/workspace", action: "local" },
+ { path: "/session/status", action: "forward" },
+ { method: "GET", path: "/session", action: "local" },
+]
+
+export function isLocalWorkspaceRoute(method: string, path: string) {
+ for (const rule of RULES) {
+ if (rule.method && rule.method !== method) continue
+ const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
+ if (match) return rule.action === "local"
+ }
+ return false
+}
+
+export function getWorkspaceRouteSessionID(url: URL) {
+ if (url.pathname === "/session/status") return null
+
+ const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
+ if (!id) return null
+
+ return SessionID.make(id)
+}
+
+export function workspaceProxyURL(target: string | URL, requestURL: URL) {
+ const proxyURL = new URL(target)
+ proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}`
+ proxyURL.search = requestURL.search
+ proxyURL.hash = requestURL.hash
+ proxyURL.searchParams.delete("workspace")
+ return proxyURL
+}
diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts
index f5f667222..6d4cae807 100644
--- a/packages/opencode/src/server/workspace.ts
+++ b/packages/opencode/src/server/workspace.ts
@@ -8,45 +8,14 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { AppRuntime } from "@/effect/app-runtime"
import { WithInstance } from "@/project/with-instance"
import { Session } from "@/session/session"
-import { SessionID } from "@/session/schema"
import { Effect } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { ServerProxy } from "./proxy"
-
-type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
-
-const RULES: Array<Rule> = [
- { path: "/experimental/workspace", action: "local" },
- { path: "/session/status", action: "forward" },
- { method: "GET", path: "/session", action: "local" },
-]
-
-export function isLocalWorkspaceRoute(method: string, path: string) {
- for (const rule of RULES) {
- if (rule.method && rule.method !== method) continue
- const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
- if (match) return rule.action === "local"
- }
- return false
-}
-
-export function getWorkspaceRouteSessionID(url: URL) {
- if (url.pathname === "/session/status") return null
-
- const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
- if (!id) return null
-
- return SessionID.make(id)
-}
-
-export function workspaceProxyURL(target: string | URL, requestURL: URL) {
- const proxyURL = new URL(target)
- proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}`
- proxyURL.search = requestURL.search
- proxyURL.hash = requestURL.hash
- proxyURL.searchParams.delete("workspace")
- return proxyURL
-}
+import {
+ getWorkspaceRouteSessionID,
+ isLocalWorkspaceRoute,
+ workspaceProxyURL,
+} from "./shared/workspace-routing"
async function getSessionWorkspace(url: URL) {
const id = getWorkspaceRouteSessionID(url)
diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts
index 7c9739f51..09b234bde 100644
--- a/packages/opencode/test/server/httpapi-ui.test.ts
+++ b/packages/opencode/test/server/httpapi-ui.test.ts
@@ -17,7 +17,7 @@ import {
authorizationRouterMiddleware,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
-import { serveUIEffect } from "../../src/server/routes/ui"
+import { serveUIEffect } from "../../src/server/shared/ui"
import { Server } from "../../src/server/server"
void Log.init({ print: false })
diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts
index 22c44a6df..a921ae277 100644
--- a/packages/opencode/test/server/workspace-routing.test.ts
+++ b/packages/opencode/test/server/workspace-routing.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test"
-import { isLocalWorkspaceRoute, getWorkspaceRouteSessionID, workspaceProxyURL } from "../../src/server/workspace"
+import {
+ isLocalWorkspaceRoute,
+ getWorkspaceRouteSessionID,
+ workspaceProxyURL,
+} from "../../src/server/shared/workspace-routing"
import { SessionID } from "../../src/session/schema"
describe("isLocalWorkspaceRoute", () => {