Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
export const OPENCODE_APP_DIR = process.env["OPENCODE_APP_DIR"]
export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")
export const OPENCODE_DB = process.env["OPENCODE_DB"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
Expand Down
144 changes: 112 additions & 32 deletions packages/opencode/src/server/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,85 @@ import { errorHandler } from "./middleware"

const log = Log.create({ service: "server" })

const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
export 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 =
const 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 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:`

const MIME: Record<string, string> = {
html: "text/html; charset=utf-8",
js: "application/javascript",
mjs: "application/javascript",
css: "text/css",
json: "application/json",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
svg: "image/svg+xml",
ico: "image/x-icon",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
wasm: "application/wasm",
txt: "text/plain",
webmanifest: "application/manifest+json",
}

function mime(path: string) {
return MIME[path.split(".").pop() ?? ""] ?? "application/octet-stream"
}

// Resolve local app directory for dev. Resolution order:
// 1. OPENCODE_APP_DIR env var (explicit override)
// 2. Auto-detect packages/app/dist relative to this file (monorepo dev)
// Embedded assets are handled separately via embeddedUIPromise.
let _appDir: string | false | undefined
export function resolveAppDir(): string | undefined {
if (_appDir !== undefined) return _appDir || undefined
if (Flag.OPENCODE_APP_DIR) {
_appDir = Flag.OPENCODE_APP_DIR
return _appDir
}
try {
const url = new URL("../../../app/dist", import.meta.url)
if (url.protocol === "file:" && Bun.file(url.pathname + "/index.html").size > 0) {
_appDir = url.pathname
return _appDir
}
} catch {}
_appDir = false
return undefined
}

export function serveEmbedded(reqPath: string, manifest: Record<string, string>): Response | undefined {
const key = reqPath === "/" ? "index.html" : reqPath.replace(/^\//, "")
const bunfs = manifest[key]
if (!bunfs) return undefined
const file = Bun.file(bunfs)
if (file.size > 0)
return new Response(file, {
headers: { "Content-Type": mime(key), "Content-Security-Policy": CSP },
})
return undefined
}

export function serveFile(reqPath: string, dir: string): Response | undefined {
const rel = reqPath === "/" ? "/index.html" : reqPath
const file = Bun.file(dir + rel)
if (file.size > 0)
return new Response(file, {
headers: { "Content-Type": mime(rel), "Content-Security-Policy": CSP },
})
return undefined
}

export const InstanceRoutes = (app?: Hono) =>
(app ?? new Hono())
.onError(errorHandler(log))
Expand Down Expand Up @@ -249,37 +317,49 @@ export const InstanceRoutes = (app?: Hono) =>
},
)
.all("/*", async (c) => {
const embeddedWebUI = await embeddedUIPromise
const path = c.req.path
const embedded = await embeddedUIPromise
const reqPath = c.req.path

// Try embedded asset
if (embedded) {
const res = serveEmbedded(reqPath, embedded)
if (res) return res
}

// Try local app dir
const dir = resolveAppDir()
if (dir) {
const res = serveFile(reqPath, dir)
if (res) return res
}

if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return c.json({ error: "Not Found" }, 404)
const file = Bun.file(match)
if (await file.exists()) {
c.header("Content-Type", file.type)
if (file.type.startsWith("text/html")) {
c.header("Content-Security-Policy", DEFAULT_CSP)
}
return c.body(await file.arrayBuffer())
} else {
return c.json({ error: "Not Found" }, 404)
// SPA fallback: return index.html for page routes (no file extension).
// Asset requests (.js, .css, .woff2) skip this and fall through to CDN.
if (!/\.[a-zA-Z0-9]+$/.test(reqPath)) {
if (embedded) {
const idx = serveEmbedded("/index.html", embedded)
if (idx) return idx
}
if (dir) {
const idx = serveFile("/index.html", dir)
if (idx) return idx
}
} else {
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
},
})
const match = response.headers.get("content-type")?.includes("text/html")
? (await response.clone().text()).match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response
}

// CDN proxy fallback for missing assets
const response = await proxy(`https://app.opencode.ai${reqPath}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
},
})
const match = response.headers.get("content-type")?.includes("text/html")
? (await response.clone().text()).match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response
})
17 changes: 16 additions & 1 deletion packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { GlobalRoutes } from "./routes/global"
import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
import { errorHandler } from "./middleware"
import { InstanceRoutes } from "./instance"
import { InstanceRoutes, embeddedUIPromise, resolveAppDir, serveEmbedded, serveFile } from "./instance"
import { initProjectors } from "./projectors"

// @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
Expand Down Expand Up @@ -234,6 +234,21 @@ export namespace Server {
return c.json(true)
},
)
.use(async (c, next) => {
// Serve static assets before WorkspaceRouterMiddleware to avoid
// Instance.provide() + InstanceBootstrap on every asset request.
const embedded = await embeddedUIPromise
if (embedded) {
const res = serveEmbedded(c.req.path, embedded)
if (res) return res
}
const dir = resolveAppDir()
if (dir) {
const res = serveFile(c.req.path, dir)
if (res) return res
}
return next()
})
.use(WorkspaceRouterMiddleware)
}

Expand Down
Loading