Przeglądaj źródła

refactor(server): extract Hono-coupled utilities to backend-neutral modules (#25542)

Kit Langton 4 miesięcy temu
rodzic
commit
ca75ac6681

+ 2 - 2
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 {

+ 2 - 72
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()

+ 1 - 1
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"

+ 1 - 1
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"
 

+ 6 - 2
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"

+ 1 - 1
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"

+ 8 - 24
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()

+ 4 - 92
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))

+ 74 - 0
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))
+}

+ 28 - 0
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()
+}

+ 91 - 0
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,
+    })
+  })
+}

+ 36 - 0
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
+}

+ 5 - 36
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)

+ 1 - 1
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 })

+ 5 - 1
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", () => {