Build HttpApi UI route from services (#25177)

This commit is contained in:
Kit Langton
2026-04-30 19:24:10 -04:00
committed by GitHub
parent 3aaac0098e
commit fc155e9fc5
3 changed files with 57 additions and 36 deletions
@@ -1,6 +1,6 @@
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { FetchHttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket" import * as Socket from "effect/unstable/socket/Socket"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Account } from "@/account/account" import { Account } from "@/account/account"
@@ -121,9 +121,16 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
]), ]),
) )
const uiRoute = HttpRouter.add("*", "/*", (request) => const uiRoute = Layer.effectDiscard(
serveUIEffect(request).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.provide(FetchHttpClient.layer)), Effect.gen(function* () {
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) const fs = yield* AppFileSystem.Service
const client = yield* HttpClient.HttpClient
const router = yield* HttpRouter.HttpRouter
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
}),
).pipe(
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))),
)
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe( export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe(
Layer.provide([ Layer.provide([
@@ -162,6 +169,8 @@ export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pip
Workspace.defaultLayer, Workspace.defaultLayer,
Worktree.defaultLayer, Worktree.defaultLayer,
Bus.layer, Bus.layer,
AppFileSystem.defaultLayer,
FetchHttpClient.layer,
HttpServer.layerServices, HttpServer.layerServices,
]), ]),
Layer.provideMerge(Observability.layer), Layer.provideMerge(Observability.layer),
+7 -5
View File
@@ -79,7 +79,10 @@ export async function serveUI(request: Request) {
return response return response
} }
export function serveUIEffect(request: HttpServerRequest.HttpServerRequest) { export function serveUIEffect(
request: HttpServerRequest.HttpServerRequest,
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
) {
return Effect.gen(function* () { return Effect.gen(function* () {
const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
const path = new URL(request.url, "http://localhost").pathname const path = new URL(request.url, "http://localhost").pathname
@@ -88,18 +91,17 @@ export function serveUIEffect(request: HttpServerRequest.HttpServerRequest) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
const fs = yield* AppFileSystem.Service if (yield* services.fs.existsSafe(match)) {
if (yield* fs.existsSafe(match)) {
const mime = getMimeType(match) ?? "text/plain" const mime = getMimeType(match) ?? "text/plain"
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 HttpServerResponse.raw(yield* fs.readFile(match), { headers }) return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers })
} }
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
} }
const response = yield* HttpClient.execute( const response = yield* services.client.execute(
HttpClientRequest.make(request.method)(upstreamURL(path), { HttpClientRequest.make(request.method)(upstreamURL(path), {
headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
body: requestBody(request), body: requestBody(request),
@@ -74,22 +74,26 @@ function app(input?: { password?: string; username?: string }) {
function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer<HttpClient.HttpClient> }) { function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer<HttpClient.HttpClient> }) {
const handler = HttpRouter.toWebHandler( const handler = HttpRouter.toWebHandler(
HttpRouter.add("*", "/*", (request) => Layer.effectDiscard(
serveUIEffect(request).pipe( Effect.gen(function* () {
Effect.provide(AppFileSystem.defaultLayer), const fs = yield* AppFileSystem.Service
Effect.provide(input?.client ?? httpClient(new Response("ui"))), const client = yield* HttpClient.HttpClient
), const router = yield* HttpRouter.HttpRouter
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
}),
).pipe( ).pipe(
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))),
Layer.provide(HttpServer.layerServices), Layer.provide([
Layer.provide( AppFileSystem.defaultLayer,
input?.client ?? httpClient(new Response("ui")),
HttpServer.layerServices,
ConfigProvider.layer( ConfigProvider.layer(
ConfigProvider.fromUnknown({ ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password, OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username, OPENCODE_SERVER_USERNAME: input?.username,
}), }),
), ),
), ]),
), ),
{ disableLogger: true }, { disableLogger: true },
).handler ).handler
@@ -140,26 +144,32 @@ describe("HttpApi UI fallback", () => {
let proxiedUrl: string | undefined let proxiedUrl: string | undefined
const response = await Effect.runPromise( const response = await Effect.runPromise(
serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js"))).pipe( Effect.gen(function* () {
Effect.provide(AppFileSystem.defaultLayer), const fs = yield* AppFileSystem.Service
const client = yield* HttpClient.HttpClient
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), { fs, client })
}).pipe(
Effect.provide( Effect.provide(
Layer.succeed( Layer.mergeAll(
HttpClient.HttpClient, AppFileSystem.defaultLayer,
HttpClient.make((request) => { Layer.succeed(
proxiedUrl = request.url HttpClient.HttpClient,
return Effect.succeed( HttpClient.make((request) => {
HttpClientResponse.fromWeb( proxiedUrl = request.url
request, return Effect.succeed(
new Response("console.log('ok')", { HttpClientResponse.fromWeb(
headers: { request,
"content-encoding": "br", new Response("console.log('ok')", {
"content-length": "999", headers: {
"content-type": "text/javascript", "content-encoding": "br",
}, "content-length": "999",
}), "content-type": "text/javascript",
), },
) }),
}), ),
)
}),
),
), ),
), ),
Effect.map(HttpServerResponse.toWeb), Effect.map(HttpServerResponse.toWeb),