refactor(server): extract Hono-coupled utilities to backend-neutral modules (#25542)
This commit is contained in:
@@ -182,7 +182,7 @@ type Runtime = {
|
|||||||
Todo: (typeof import("../src/session/todo"))["Todo"]
|
Todo: (typeof import("../src/session/todo"))["Todo"]
|
||||||
Worktree: (typeof import("../src/worktree"))["Worktree"]
|
Worktree: (typeof import("../src/worktree"))["Worktree"]
|
||||||
Project: (typeof import("../src/project/project"))["Project"]
|
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"]
|
disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"]
|
||||||
tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"]
|
tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"]
|
||||||
resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"]
|
resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"]
|
||||||
@@ -203,7 +203,7 @@ function runtime() {
|
|||||||
const todo = await import("../src/session/todo")
|
const todo = await import("../src/session/todo")
|
||||||
const worktree = await import("../src/worktree")
|
const worktree = await import("../src/worktree")
|
||||||
const project = await import("../src/project/project")
|
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 fixture = await import("../test/fixture/fixture")
|
||||||
const db = await import("../test/fixture/db")
|
const db = await import("../test/fixture/db")
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,78 +1,8 @@
|
|||||||
import type { MiddlewareHandler } from "hono"
|
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 * as Log from "@opencode-ai/core/util/log"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { HEADER, diff, load } from "./shared/fence"
|
||||||
import { Effect } from "effect"
|
|
||||||
|
|
||||||
const HEADER = "x-opencode-sync"
|
const log = Log.create({ service: "fence-middleware" })
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
|
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()
|
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
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 type { WorkspaceID } from "@/control-plane/schema"
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as Database from "@/storage/db"
|
|||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
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 { InstanceHttpApi } from "../api"
|
||||||
import { CommandPayload, TuiPublishPayload } from "../groups/tui"
|
import { CommandPayload, TuiPublishPayload } from "../groups/tui"
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -5,8 +5,12 @@ import { Workspace } from "@/control-plane/workspace"
|
|||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { HttpApiProxy } from "./proxy"
|
import { HttpApiProxy } from "./proxy"
|
||||||
import * as Fence from "@/server/fence"
|
import * as Fence from "@/server/shared/fence"
|
||||||
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
|
import {
|
||||||
|
getWorkspaceRouteSessionID,
|
||||||
|
isLocalWorkspaceRoute,
|
||||||
|
workspaceProxyURL,
|
||||||
|
} from "@/server/shared/workspace-routing"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Context, Data, Effect, Layer } from "effect"
|
import { Context, Data, Effect, Layer } from "effect"
|
||||||
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import { Vcs } from "@/project/vcs"
|
|||||||
import { Worktree } from "@/worktree"
|
import { Worktree } from "@/worktree"
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
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 { InstanceHttpApi, RootHttpApi } from "./api"
|
||||||
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
||||||
import { EventApi, eventHandlers } from "./event"
|
import { EventApi, eventHandlers } from "./event"
|
||||||
|
|||||||
@@ -7,32 +7,16 @@ import { Session } from "@/session/session"
|
|||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||||
import { zodObject } from "@/util/effect-zod"
|
import { zodObject } from "@/util/effect-zod"
|
||||||
import { AsyncQueue } from "@/util/queue"
|
|
||||||
import { errors } from "../../error"
|
import { errors } from "../../error"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { runRequest } from "./trace"
|
import { runRequest } from "./trace"
|
||||||
|
import {
|
||||||
export const TuiRequest = z.object({
|
TuiRequest,
|
||||||
path: z.string(),
|
nextTuiRequest,
|
||||||
body: z.any(),
|
nextTuiResponse,
|
||||||
})
|
submitTuiRequest,
|
||||||
|
submitTuiResponse,
|
||||||
export type TuiRequest = z.infer<typeof TuiRequest>
|
} from "@/server/shared/tui-control"
|
||||||
|
|
||||||
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 async function callTui(ctx: Context) {
|
export async function callTui(ctx: Context) {
|
||||||
const body = await ctx.req.json()
|
const body = await ctx.req.json()
|
||||||
@@ -40,7 +24,7 @@ export async function callTui(ctx: Context) {
|
|||||||
path: ctx.req.path,
|
path: ctx.req.path,
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
return response.next()
|
return nextTuiResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const TuiControlRoutes = new Hono()
|
const TuiControlRoutes = new Hono()
|
||||||
|
|||||||
@@ -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 { 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 { Hono } from "hono"
|
||||||
import { proxy } from "hono/proxy"
|
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"
|
import { ProxyUtil } from "../proxy-util"
|
||||||
|
import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serveUI(request: Request) {
|
export async function serveUI(request: Request) {
|
||||||
const embeddedWebUI = await embeddedUI()
|
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 (!match) return Response.json({ error: "Not Found" }, { status: 404 })
|
||||||
|
|
||||||
if (await fs.exists(match)) {
|
if (await fs.exists(match)) {
|
||||||
const mime = getMimeType(match) ?? "text/plain"
|
const mime = AppFileSystem.mimeType(match)
|
||||||
const headers = new Headers({ "content-type": mime })
|
const headers = new Headers({ "content-type": mime })
|
||||||
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
|
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
|
||||||
return new Response(new Uint8Array(await fs.readFile(match)), { headers })
|
return new Response(new Uint8Array(await fs.readFile(match)), { headers })
|
||||||
@@ -79,49 +36,4 @@ export async function serveUI(request: Request) {
|
|||||||
return response
|
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))
|
export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -8,45 +8,14 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
|||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { WithInstance } from "@/project/with-instance"
|
import { WithInstance } from "@/project/with-instance"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { SessionID } from "@/session/schema"
|
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { ServerProxy } from "./proxy"
|
import { ServerProxy } from "./proxy"
|
||||||
|
import {
|
||||||
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
getWorkspaceRouteSessionID,
|
||||||
|
isLocalWorkspaceRoute,
|
||||||
const RULES: Array<Rule> = [
|
workspaceProxyURL,
|
||||||
{ path: "/experimental/workspace", action: "local" },
|
} from "./shared/workspace-routing"
|
||||||
{ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSessionWorkspace(url: URL) {
|
async function getSessionWorkspace(url: URL) {
|
||||||
const id = getWorkspaceRouteSessionID(url)
|
const id = getWorkspaceRouteSessionID(url)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
authorizationRouterMiddleware,
|
authorizationRouterMiddleware,
|
||||||
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
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"
|
import { Server } from "../../src/server/server"
|
||||||
|
|
||||||
void Log.init({ print: false })
|
void Log.init({ print: false })
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
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"
|
import { SessionID } from "../../src/session/schema"
|
||||||
|
|
||||||
describe("isLocalWorkspaceRoute", () => {
|
describe("isLocalWorkspaceRoute", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user