diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 30929bd9268..9a2f9048bd1 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -61,6 +61,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_WEB_URL = process.env["OPENCODE_WEB_URL"] export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB") export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS") diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 55bcf2dfce1..f264e5a542d 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -556,12 +556,50 @@ export namespace Server { ) .all("/*", async (c) => { const path = c.req.path + const appDir = Flag.OPENCODE_WEB_URL - const response = await proxy(`https://app.opencode.ai${path}`, { + // Serve from local dist directory if OPENCODE_WEB_URL is a file path + if (appDir && !appDir.startsWith("http")) { + const fs = await import("fs") + const nodePath = await import("path") + const mimeTypes: Record = { + ".html": "text/html", + ".js": "application/javascript", + ".mjs": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".wasm": "application/wasm", + ".onnx": "application/octet-stream", + } + const getMime = (p: string) => mimeTypes[nodePath.default.extname(p)] || "application/octet-stream" + const filePath = nodePath.default.join(appDir, path === "/" ? "index.html" : path) + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + return new Response(Bun.file(filePath), { headers: { "Content-Type": getMime(filePath) } }) + } + // SPA fallback — only for paths that look like client-side routes + const apiPrefixes = ["/global", "/project", "/pty", "/config", "/experimental", "/session", "/permission", "/question", "/provider", "/mcp", "/tui", "/voice", "/tts"] + const isApiPath = apiPrefixes.some((prefix) => path.startsWith(prefix)) + if (!isApiPath) { + const indexPath = nodePath.default.join(appDir, "index.html") + if (fs.existsSync(indexPath)) { + return new Response(Bun.file(indexPath), { headers: { "Content-Type": "text/html" } }) + } + } + return c.notFound() + } + + const appHost = appDir || "https://app.opencode.ai" + const appHostname = new URL(appHost).hostname + + const response = await proxy(`${appHost}${path}`, { ...c.req, headers: { ...c.req.raw.headers, - host: "app.opencode.ai", + host: appHostname, }, }) response.headers.set(