@@ -29,17 +29,85 @@ import { errorHandler } from "./middleware"
2929
3030const log = Log . create ( { service : "server" } )
3131
32- const embeddedUIPromise = Flag . OPENCODE_DISABLE_EMBEDDED_WEB_UI
32+ export const embeddedUIPromise = Flag . OPENCODE_DISABLE_EMBEDDED_WEB_UI
3333 ? Promise . resolve ( null )
3434 : // @ts -expect-error - generated file at build time
3535 import ( "opencode-web-ui.gen.ts" ) . then ( ( module ) => module . default as Record < string , string > ) . catch ( ( ) => null )
3636
37- const DEFAULT_CSP =
37+ const CSP =
3838 "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:"
3939
4040const csp = ( hash = "" ) =>
4141 `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:`
4242
43+ const MIME : Record < string , string > = {
44+ html : "text/html; charset=utf-8" ,
45+ js : "application/javascript" ,
46+ mjs : "application/javascript" ,
47+ css : "text/css" ,
48+ json : "application/json" ,
49+ png : "image/png" ,
50+ jpg : "image/jpeg" ,
51+ jpeg : "image/jpeg" ,
52+ gif : "image/gif" ,
53+ svg : "image/svg+xml" ,
54+ ico : "image/x-icon" ,
55+ woff : "font/woff" ,
56+ woff2 : "font/woff2" ,
57+ ttf : "font/ttf" ,
58+ wasm : "application/wasm" ,
59+ txt : "text/plain" ,
60+ webmanifest : "application/manifest+json" ,
61+ }
62+
63+ function mime ( path : string ) {
64+ return MIME [ path . split ( "." ) . pop ( ) ?? "" ] ?? "application/octet-stream"
65+ }
66+
67+ // Resolve local app directory for dev. Resolution order:
68+ // 1. OPENCODE_APP_DIR env var (explicit override)
69+ // 2. Auto-detect packages/app/dist relative to this file (monorepo dev)
70+ // Embedded assets are handled separately via embeddedUIPromise.
71+ let _appDir : string | false | undefined
72+ export function resolveAppDir ( ) : string | undefined {
73+ if ( _appDir !== undefined ) return _appDir || undefined
74+ if ( Flag . OPENCODE_APP_DIR ) {
75+ _appDir = Flag . OPENCODE_APP_DIR
76+ return _appDir
77+ }
78+ try {
79+ const url = new URL ( "../../../app/dist" , import . meta. url )
80+ if ( url . protocol === "file:" && Bun . file ( url . pathname + "/index.html" ) . size > 0 ) {
81+ _appDir = url . pathname
82+ return _appDir
83+ }
84+ } catch { }
85+ _appDir = false
86+ return undefined
87+ }
88+
89+ export function serveEmbedded ( reqPath : string , manifest : Record < string , string > ) : Response | undefined {
90+ const key = reqPath === "/" ? "index.html" : reqPath . replace ( / ^ \/ / , "" )
91+ const bunfs = manifest [ key ]
92+ if ( ! bunfs ) return undefined
93+ const file = Bun . file ( bunfs )
94+ if ( file . size > 0 )
95+ return new Response ( file , {
96+ headers : { "Content-Type" : mime ( key ) , "Content-Security-Policy" : CSP } ,
97+ } )
98+ return undefined
99+ }
100+
101+ export function serveFile ( reqPath : string , dir : string ) : Response | undefined {
102+ const rel = reqPath === "/" ? "/index.html" : reqPath
103+ const file = Bun . file ( dir + rel )
104+ if ( file . size > 0 )
105+ return new Response ( file , {
106+ headers : { "Content-Type" : mime ( rel ) , "Content-Security-Policy" : CSP } ,
107+ } )
108+ return undefined
109+ }
110+
43111export const InstanceRoutes = ( app ?: Hono ) =>
44112 ( app ?? new Hono ( ) )
45113 . onError ( errorHandler ( log ) )
@@ -249,37 +317,49 @@ export const InstanceRoutes = (app?: Hono) =>
249317 } ,
250318 )
251319 . all ( "/*" , async ( c ) => {
252- const embeddedWebUI = await embeddedUIPromise
253- const path = c . req . path
320+ const embedded = await embeddedUIPromise
321+ const reqPath = c . req . path
322+
323+ // Try embedded asset
324+ if ( embedded ) {
325+ const res = serveEmbedded ( reqPath , embedded )
326+ if ( res ) return res
327+ }
328+
329+ // Try local app dir
330+ const dir = resolveAppDir ( )
331+ if ( dir ) {
332+ const res = serveFile ( reqPath , dir )
333+ if ( res ) return res
334+ }
254335
255- if ( embeddedWebUI ) {
256- const match = embeddedWebUI [ path . replace ( / ^ \/ / , "" ) ] ?? embeddedWebUI [ "index.html" ] ?? null
257- if ( ! match ) return c . json ( { error : "Not Found" } , 404 )
258- const file = Bun . file ( match )
259- if ( await file . exists ( ) ) {
260- c . header ( "Content-Type" , file . type )
261- if ( file . type . startsWith ( "text/html" ) ) {
262- c . header ( "Content-Security-Policy" , DEFAULT_CSP )
263- }
264- return c . body ( await file . arrayBuffer ( ) )
265- } else {
266- return c . json ( { error : "Not Found" } , 404 )
336+ // SPA fallback: return index.html for page routes (no file extension).
337+ // Asset requests (.js, .css, .woff2) skip this and fall through to CDN.
338+ if ( ! / \. [ a - z A - Z 0 - 9 ] + $ / . test ( reqPath ) ) {
339+ if ( embedded ) {
340+ const idx = serveEmbedded ( "/index.html" , embedded )
341+ if ( idx ) return idx
342+ }
343+ if ( dir ) {
344+ const idx = serveFile ( "/index.html" , dir )
345+ if ( idx ) return idx
267346 }
268- } else {
269- const response = await proxy ( `https://app.opencode.ai${ path } ` , {
270- ...c . req ,
271- headers : {
272- ...c . req . raw . headers ,
273- host : "app.opencode.ai" ,
274- } ,
275- } )
276- const match = response . headers . get ( "content-type" ) ?. includes ( "text/html" )
277- ? ( await response . clone ( ) . text ( ) ) . match (
278- / < 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,
279- )
280- : undefined
281- const hash = match ? createHash ( "sha256" ) . update ( match [ 2 ] ) . digest ( "base64" ) : ""
282- response . headers . set ( "Content-Security-Policy" , csp ( hash ) )
283- return response
284347 }
348+
349+ // CDN proxy fallback for missing assets
350+ const response = await proxy ( `https://app.opencode.ai${ reqPath } ` , {
351+ ...c . req ,
352+ headers : {
353+ ...c . req . raw . headers ,
354+ host : "app.opencode.ai" ,
355+ } ,
356+ } )
357+ const match = response . headers . get ( "content-type" ) ?. includes ( "text/html" )
358+ ? ( await response . clone ( ) . text ( ) ) . match (
359+ / < 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,
360+ )
361+ : undefined
362+ const hash = match ? createHash ( "sha256" ) . update ( match [ 2 ] ) . digest ( "base64" ) : ""
363+ response . headers . set ( "Content-Security-Policy" , csp ( hash ) )
364+ return response
285365 } )
0 commit comments