1- import { Flag } from "@opencode-ai/core/flag/flag"
1+ import fs from "node:fs/promises"
2+ import { createHash } from "node:crypto"
23import { AppFileSystem } from "@opencode-ai/core/filesystem"
3- import { Effect , Stream } from "effect"
4- import { HttpBody , HttpClient , HttpClientRequest , HttpServerRequest , HttpServerResponse } from "effect/unstable/http"
54import { Hono } from "hono"
65import { proxy } from "hono/proxy"
7- import { getMimeType } from "hono/utils/mime"
8- import { createHash } from "node:crypto"
9- import fs from "node:fs/promises"
106import { 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 ( / < s c r i p t \b (? ! [ ^ > ] * \b s r c \s * = ) [ ^ > ] * \b i d = ( [ ' " ] ) o c - t h e m e - p r e l o a d - s c r i p t \1[ ^ > ] * > ( [ \s \S ] * ?) < \/ s c r i p t > / 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
529export 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-
12739export const UIRoutes = ( ) : Hono => new Hono ( ) . all ( "/*" , ( c ) => serveUI ( c . req . raw ) )
0 commit comments