diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 27190f2eb24e..6246cd86bdae 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -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") diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance.ts index 4bb6efaf9b05..4c3a63a54391 100644 --- a/packages/opencode/src/server/instance.ts +++ b/packages/opencode/src/server/instance.ts @@ -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).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 = { + 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): 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)) @@ -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( - /]*\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( + /]*\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 }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ec245ed59f29..60978acc1499 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -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 @@ -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) }