|
|
@@ -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))
|