Skip to content

Commit 5e21c96

Browse files
kitlangtonoleksii-honchar
authored andcommitted
refactor(server): extract Hono-coupled utilities to backend-neutral modules (anomalyco#25542)
1 parent 9ccbb09 commit 5e21c96

15 files changed

Lines changed: 265 additions & 233 deletions

File tree

packages/opencode/script/httpapi-exercise.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ type Runtime = {
182182
Todo: (typeof import("../src/session/todo"))["Todo"]
183183
Worktree: (typeof import("../src/worktree"))["Worktree"]
184184
Project: (typeof import("../src/project/project"))["Project"]
185-
Tui: typeof import("../src/server/routes/instance/tui")
185+
Tui: typeof import("../src/server/shared/tui-control")
186186
disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"]
187187
tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"]
188188
resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"]
@@ -203,7 +203,7 @@ function runtime() {
203203
const todo = await import("../src/session/todo")
204204
const worktree = await import("../src/worktree")
205205
const project = await import("../src/project/project")
206-
const tui = await import("../src/server/routes/instance/tui")
206+
const tui = await import("../src/server/shared/tui-control")
207207
const fixture = await import("../test/fixture/fixture")
208208
const db = await import("../test/fixture/db")
209209
return {

packages/opencode/src/server/fence.ts

Lines changed: 2 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,8 @@
11
import type { MiddlewareHandler } from "hono"
2-
import { Database } from "@/storage/db"
3-
import { inArray } from "drizzle-orm"
4-
import { EventSequenceTable } from "@/sync/event.sql"
5-
import { Workspace } from "@/control-plane/workspace"
6-
import type { WorkspaceID } from "@/control-plane/schema"
72
import * as Log from "@opencode-ai/core/util/log"
8-
import { AppRuntime } from "@/effect/app-runtime"
9-
import { Effect } from "effect"
3+
import { HEADER, diff, load } from "./shared/fence"
104

11-
const HEADER = "x-opencode-sync"
12-
type State = Record<string, number>
13-
const log = Log.create({ service: "fence" })
14-
15-
export function load(ids?: string[]) {
16-
const rows = Database.use((db) => {
17-
if (!ids?.length) {
18-
return db.select().from(EventSequenceTable).all()
19-
}
20-
21-
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
22-
})
23-
24-
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
25-
}
26-
27-
export function diff(prev: State, next: State) {
28-
const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
29-
return Object.fromEntries(
30-
[...ids]
31-
.map((id) => [id, next[id] ?? -1] as const)
32-
.filter(([id, seq]) => {
33-
return (prev[id] ?? -1) !== seq
34-
}),
35-
) as State
36-
}
37-
38-
export function parse(headers: Headers) {
39-
const raw = headers.get(HEADER)
40-
if (!raw) return
41-
42-
let data
43-
44-
try {
45-
data = JSON.parse(raw)
46-
} catch {
47-
return
48-
}
49-
50-
if (!data || typeof data !== "object") return
51-
52-
return Object.fromEntries(
53-
Object.entries(data).filter(([id, seq]) => {
54-
return typeof id === "string" && Number.isInteger(seq)
55-
}),
56-
) as State
57-
}
58-
59-
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
60-
return Effect.gen(function* () {
61-
log.info("waiting for state", {
62-
workspaceID,
63-
state,
64-
})
65-
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
66-
log.info("state fully synced", {
67-
workspaceID,
68-
state,
69-
})
70-
})
71-
}
72-
73-
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
74-
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
75-
}
5+
const log = Log.create({ service: "fence-middleware" })
766

777
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
788
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()

packages/opencode/src/server/proxy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Hono } from "hono"
22
import type { UpgradeWebSocket } from "hono/ws"
33
import * as Log from "@opencode-ai/core/util/log"
4-
import * as Fence from "./fence"
4+
import * as Fence from "./shared/fence"
55
import type { WorkspaceID } from "@/control-plane/schema"
66
import { Workspace } from "@/control-plane/workspace"
77
import { AppRuntime } from "@/effect/app-runtime"

packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as Database from "@/storage/db"
55
import { eq } from "drizzle-orm"
66
import { Effect } from "effect"
77
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
8-
import { nextTuiRequest, submitTuiResponse } from "../../tui"
8+
import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control"
99
import { InstanceHttpApi } from "../api"
1010
import { CommandPayload, TuiPublishPayload } from "../groups/tui"
1111

packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ import { Workspace } from "@/control-plane/workspace"
55
import { EffectBridge } from "@/effect/bridge"
66
import { Session } from "@/session/session"
77
import { HttpApiProxy } from "./proxy"
8-
import * as Fence from "@/server/fence"
9-
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
8+
import * as Fence from "@/server/shared/fence"
9+
import {
10+
getWorkspaceRouteSessionID,
11+
isLocalWorkspaceRoute,
12+
workspaceProxyURL,
13+
} from "@/server/shared/workspace-routing"
1014
import { Flag } from "@opencode-ai/core/flag/flag"
1115
import { Context, Data, Effect, Layer } from "effect"
1216
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import { Vcs } from "@/project/vcs"
4545
import { Worktree } from "@/worktree"
4646
import { Workspace } from "@/control-plane/workspace"
4747
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
48-
import { serveUIEffect } from "@/server/routes/ui"
48+
import { serveUIEffect } from "@/server/shared/ui"
4949
import { InstanceHttpApi, RootHttpApi } from "./api"
5050
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
5151
import { EventApi, eventHandlers } from "./event"

packages/opencode/src/server/routes/instance/tui.ts

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,40 +7,24 @@ import { Session } from "@/session/session"
77
import type { SessionID } from "@/session/schema"
88
import { TuiEvent } from "@/cli/cmd/tui/event"
99
import { zodObject } from "@/util/effect-zod"
10-
import { AsyncQueue } from "@/util/queue"
1110
import { errors } from "../../error"
1211
import { lazy } from "@/util/lazy"
1312
import { runRequest } from "./trace"
14-
15-
export const TuiRequest = z.object({
16-
path: z.string(),
17-
body: z.any(),
18-
})
19-
20-
export type TuiRequest = z.infer<typeof TuiRequest>
21-
22-
const request = new AsyncQueue<TuiRequest>()
23-
const response = new AsyncQueue<unknown>()
24-
25-
export function nextTuiRequest() {
26-
return request.next()
27-
}
28-
29-
export function submitTuiRequest(body: TuiRequest) {
30-
request.push(body)
31-
}
32-
33-
export function submitTuiResponse(body: unknown) {
34-
response.push(body)
35-
}
13+
import {
14+
TuiRequest,
15+
nextTuiRequest,
16+
nextTuiResponse,
17+
submitTuiRequest,
18+
submitTuiResponse,
19+
} from "@/server/shared/tui-control"
3620

3721
export async function callTui(ctx: Context) {
3822
const body = await ctx.req.json()
3923
submitTuiRequest({
4024
path: ctx.req.path,
4125
body,
4226
})
43-
return response.next()
27+
return nextTuiResponse()
4428
}
4529

4630
const TuiControlRoutes = new Hono()
Lines changed: 4 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,10 @@
1-
import { Flag } from "@opencode-ai/core/flag/flag"
1+
import fs from "node:fs/promises"
2+
import { createHash } from "node:crypto"
23
import { AppFileSystem } from "@opencode-ai/core/filesystem"
3-
import { Effect, Stream } from "effect"
4-
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
54
import { Hono } from "hono"
65
import { proxy } from "hono/proxy"
7-
import { getMimeType } from "hono/utils/mime"
8-
import { createHash } from "node:crypto"
9-
import fs from "node:fs/promises"
106
import { ProxyUtil } from "../proxy-util"
11-
12-
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
13-
? Promise.resolve(null)
14-
: // @ts-expect-error - generated file at build time
15-
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
16-
17-
const DEFAULT_CSP =
18-
"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:"
19-
const UI_UPSTREAM = new URL("https://app.opencode.ai")
20-
21-
const csp = (hash = "") =>
22-
`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:`
23-
24-
function themePreloadHash(body: string) {
25-
return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
26-
}
27-
28-
function requestBody(request: HttpServerRequest.HttpServerRequest) {
29-
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
30-
const len = request.headers["content-length"]
31-
return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len))
32-
}
33-
34-
function proxyResponseHeaders(headers: Record<string, string>) {
35-
const result = new Headers(headers)
36-
// FetchHttpClient exposes decoded response bodies, so forwarding upstream
37-
// transfer metadata makes browsers decode already-decoded assets again.
38-
result.delete("content-encoding")
39-
result.delete("content-length")
40-
return result
41-
}
42-
43-
function upstreamURL(path: string) {
44-
return new URL(path, UI_UPSTREAM).toString()
45-
}
46-
47-
function embeddedUI() {
48-
if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
49-
return embeddedUIPromise
50-
}
7+
import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui"
518

529
export async function serveUI(request: Request) {
5310
const embeddedWebUI = await embeddedUI()
@@ -58,7 +15,7 @@ export async function serveUI(request: Request) {
5815
if (!match) return Response.json({ error: "Not Found" }, { status: 404 })
5916

6017
if (await fs.exists(match)) {
61-
const mime = getMimeType(match) ?? "text/plain"
18+
const mime = AppFileSystem.mimeType(match)
6219
const headers = new Headers({ "content-type": mime })
6320
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
6421
return new Response(new Uint8Array(await fs.readFile(match)), { headers })
@@ -79,49 +36,4 @@ export async function serveUI(request: Request) {
7936
return response
8037
}
8138

82-
export function serveUIEffect(
83-
request: HttpServerRequest.HttpServerRequest,
84-
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
85-
) {
86-
return Effect.gen(function* () {
87-
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
88-
const path = new URL(request.url, "http://localhost").pathname
89-
90-
if (embeddedWebUI) {
91-
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
92-
if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
93-
94-
if (yield* services.fs.existsSafe(match)) {
95-
const mime = getMimeType(match) ?? "text/plain"
96-
const headers = new Headers({ "content-type": mime })
97-
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
98-
return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers })
99-
}
100-
101-
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
102-
}
103-
104-
const response = yield* services.client.execute(
105-
HttpClientRequest.make(request.method)(upstreamURL(path), {
106-
headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
107-
body: requestBody(request),
108-
}),
109-
)
110-
const headers = proxyResponseHeaders(response.headers)
111-
112-
if (response.headers["content-type"]?.includes("text/html")) {
113-
const body = yield* response.text
114-
const match = themePreloadHash(body)
115-
headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""))
116-
return HttpServerResponse.text(body, { status: response.status, headers })
117-
}
118-
119-
headers.set("Content-Security-Policy", csp())
120-
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
121-
status: response.status,
122-
headers,
123-
})
124-
})
125-
}
126-
12739
export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw))
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Database } from "@/storage/db"
2+
import { inArray } from "drizzle-orm"
3+
import { EventSequenceTable } from "@/sync/event.sql"
4+
import { Workspace } from "@/control-plane/workspace"
5+
import type { WorkspaceID } from "@/control-plane/schema"
6+
import * as Log from "@opencode-ai/core/util/log"
7+
import { AppRuntime } from "@/effect/app-runtime"
8+
import { Effect } from "effect"
9+
10+
export const HEADER = "x-opencode-sync"
11+
export type State = Record<string, number>
12+
const log = Log.create({ service: "fence" })
13+
14+
export function load(ids?: string[]) {
15+
const rows = Database.use((db) => {
16+
if (!ids?.length) {
17+
return db.select().from(EventSequenceTable).all()
18+
}
19+
20+
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
21+
})
22+
23+
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
24+
}
25+
26+
export function diff(prev: State, next: State) {
27+
const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
28+
return Object.fromEntries(
29+
[...ids]
30+
.map((id) => [id, next[id] ?? -1] as const)
31+
.filter(([id, seq]) => {
32+
return (prev[id] ?? -1) !== seq
33+
}),
34+
) as State
35+
}
36+
37+
export function parse(headers: Headers) {
38+
const raw = headers.get(HEADER)
39+
if (!raw) return
40+
41+
let data
42+
43+
try {
44+
data = JSON.parse(raw)
45+
} catch {
46+
return
47+
}
48+
49+
if (!data || typeof data !== "object") return
50+
51+
return Object.fromEntries(
52+
Object.entries(data).filter(([id, seq]) => {
53+
return typeof id === "string" && Number.isInteger(seq)
54+
}),
55+
) as State
56+
}
57+
58+
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
59+
return Effect.gen(function* () {
60+
log.info("waiting for state", {
61+
workspaceID,
62+
state,
63+
})
64+
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
65+
log.info("state fully synced", {
66+
workspaceID,
67+
state,
68+
})
69+
})
70+
}
71+
72+
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
73+
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
74+
}

0 commit comments

Comments
 (0)