Skip to content

Commit f52f4b6

Browse files
committed
fix(server): harden embedded web UI serving
Build on anomalyco#19299 by @thdxr with production hardening for the new server architecture (anomalyco#19335): - Add pre-bootstrap static middleware in server.ts before WorkspaceRouterMiddleware to avoid Instance.provide() + DB migration checks on every CSS/JS/image/font request - Add SPA fallback routing — page routes (no extension) get index.html, asset requests (.js, .woff2) fall through to CDN proxy - Add OPENCODE_APP_DIR env var for explicit dev override - Auto-detect packages/app/dist in monorepo dev mode - Use explicit MIME type map for consistent content-type headers - CDN proxy fallback for assets not found locally/embedded
1 parent a5b1dc0 commit f52f4b6

File tree

3 files changed

+129
-33
lines changed

3 files changed

+129
-33
lines changed

packages/opencode/src/flag/flag.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export namespace Flag {
7373
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
7474
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
7575
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
76+
export const OPENCODE_APP_DIR = process.env["OPENCODE_APP_DIR"]
7677
export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")
7778
export const OPENCODE_DB = process.env["OPENCODE_DB"]
7879
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")

packages/opencode/src/server/instance.ts

Lines changed: 112 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,85 @@ import { errorHandler } from "./middleware"
2929

3030
const 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

4040
const 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+
43111
export 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-zA-Z0-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-
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/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+
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/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
})

packages/opencode/src/server/server.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { GlobalRoutes } from "./routes/global"
1515
import { MDNS } from "./mdns"
1616
import { lazy } from "@/util/lazy"
1717
import { errorHandler } from "./middleware"
18-
import { InstanceRoutes } from "./instance"
18+
import { InstanceRoutes, embeddedUIPromise, resolveAppDir, serveEmbedded, serveFile } from "./instance"
1919
import { initProjectors } from "./projectors"
2020

2121
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
@@ -234,6 +234,21 @@ export namespace Server {
234234
return c.json(true)
235235
},
236236
)
237+
.use(async (c, next) => {
238+
// Serve static assets before WorkspaceRouterMiddleware to avoid
239+
// Instance.provide() + InstanceBootstrap on every asset request.
240+
const embedded = await embeddedUIPromise
241+
if (embedded) {
242+
const res = serveEmbedded(c.req.path, embedded)
243+
if (res) return res
244+
}
245+
const dir = resolveAppDir()
246+
if (dir) {
247+
const res = serveFile(c.req.path, dir)
248+
if (res) return res
249+
}
250+
return next()
251+
})
237252
.use(WorkspaceRouterMiddleware)
238253
}
239254

0 commit comments

Comments
 (0)