diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 5623a2c7cd85..5ae901802fed 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -322,6 +322,43 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) }, + async refresh(sessionID: string) { + const directory = sdk.directory + const client = sdk.client + const [, setStore] = globalSync.child(directory) + const key = keyFor(directory, sessionID) + + touch(directory, setStore, sessionID) + + const limit = meta.limit[key] ?? messagePageSize + + const sessionReq = retry(() => client.session.get({ sessionID })).then((session) => { + if (!tracked(directory, sessionID)) return + const data = session.data + if (!data) return + setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (s) => s.id) + if (match.found) { + draft[match.index] = data + return + } + draft.splice(match.index, 0, data) + }), + ) + }) + + const messagesReq = loadMessages({ + directory, + client, + setStore, + sessionID, + limit, + }) + + return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) + }, async diff(sessionID: string) { const directory = sdk.directory const client = sdk.client diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index c62baccba501..7db39747de8a 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -1,6 +1,7 @@ // @refresh reload import { iife } from "@opencode-ai/util/iife" +import { Router, type BaseRouterProps } from "@solidjs/router" import { render } from "solid-js/web" import { AppBaseProviders, AppInterface } from "@/app" import { type Platform, PlatformProvider } from "@/context/platform" @@ -102,7 +103,8 @@ const getCurrentUrl = () => { if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" if (import.meta.env.DEV) return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - return location.origin + const base = (window as Window & { __OPENCODE_BASE_PATH?: string }).__OPENCODE_BASE_PATH ?? "" + return base ? `${location.origin}${base}` : location.origin } const getDefaultUrl = () => { @@ -127,12 +129,14 @@ const platform: Platform = { } if (root instanceof HTMLElement) { + const base = (window as Window & { __OPENCODE_BASE_PATH?: string }).__OPENCODE_BASE_PATH ?? "" + const router = (props: BaseRouterProps) => const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } } render( () => ( - + ), diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1b62b94294ce..daee4373ea97 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -632,6 +632,27 @@ export default function Page() { }), ) + createEffect( + on([() => sdk.directory, () => params.id] as const, ([, id]) => { + if (!id) return + const base = (window as Window & { __OPENCODE_BASE_PATH?: string }).__OPENCODE_BASE_PATH + if (!base) return + + const run = () => { + if (document.visibilityState !== "visible") return + const active = sync.data.session_status[id]?.type !== "idle" + if (!active) return + void sync.session.refresh(id) + void sync.session.todo(id) + } + + run() + const timer = setInterval(run, 4000) + + onCleanup(() => clearInterval(timer)) + }), + ) + createEffect( on( () => visibleUserMessages().at(-1)?.id, diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 0fe056f21f2f..9672d4b2e370 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -28,33 +28,45 @@ function getNetworkIPs() { return results } +function normalizeBasePath(raw: string): string { + const input = raw?.trim() + if (!input) throw new Error("--base-path must not be empty") + const normalized = input.startsWith("/") ? input : `/${input}` + return normalized === "/" ? "/" : normalized.replace(/\/+$/, "") +} + export const WebCommand = cmd({ command: "web", - builder: (yargs) => withNetworkOptions(yargs), + builder: (yargs) => + withNetworkOptions(yargs).option("base-path", { + type: "string", + default: "/", + description: "Base path to serve the app under (e.g. /opencode)", + }), describe: "start opencode server and open web interface", handler: async (args) => { if (!Flag.OPENCODE_SERVER_PASSWORD) { UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) + const base = normalizeBasePath(args["base-path"] as string) + const server = Server.listen({ ...opts, basePath: base }) UI.empty() UI.println(UI.logo(" ")) UI.empty() + const suffix = base === "/" ? "" : base if (opts.hostname === "0.0.0.0") { - // Show localhost for local access - const localhostUrl = `http://localhost:${server.port}` + const localhostUrl = `http://localhost:${server.port}${suffix}` UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) - // Show network IPs for remote access const networkIPs = getNetworkIPs() if (networkIPs.length > 0) { for (const ip of networkIPs) { UI.println( UI.Style.TEXT_INFO_BOLD + " Network access: ", UI.Style.TEXT_NORMAL, - `http://${ip}:${server.port}`, + `http://${ip}:${server.port}${suffix}`, ) } } @@ -63,14 +75,13 @@ export const WebCommand = cmd({ UI.println( UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, - `${opts.mdnsDomain}:${server.port}`, + `${opts.mdnsDomain}:${server.port}${suffix}`, ) } - // Open localhost in browser open(localhostUrl.toString()).catch(() => {}) } else { - const displayUrl = server.url.toString() + const displayUrl = `${server.url.toString().replace(/\/$/, "")}${suffix}` UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl) open(displayUrl).catch(() => {}) } diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index e03fc8a9f301..9a59ff80be44 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -408,6 +408,12 @@ export namespace File { } }) + const fallback = { + dir: "", + at: 0, + dirs: [] as string[], + } + export function init() { state() } @@ -638,7 +644,19 @@ export namespace File { } if (!query) { if (kind === "file") return result.files.slice(0, limit) - return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) + const dirs = sortHiddenLast(result.dirs.toSorted()) + if (dirs.length) return dirs.slice(0, limit) + if (fallback.dir === Instance.directory && Date.now() - fallback.at < 2000) { + return fallback.dirs.slice(0, limit) + } + const root = await list("") + const cached = root + .filter((item) => item.type === "directory") + .map((item) => (item.path.endsWith("/") ? item.path : `${item.path}/`)) + fallback.dir = Instance.directory + fallback.at = Date.now() + fallback.dirs = cached + return cached.slice(0, limit) } const items = diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 30929bd9268f..4ca1bd12000a 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -36,6 +36,8 @@ export namespace Flag { export declare const OPENCODE_CLIENT: string export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"] export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"] + export const OPENCODE_WEB_APP_URL = process.env["OPENCODE_WEB_APP_URL"] + export const OPENCODE_WEB_APP_DIR = process.env["OPENCODE_WEB_APP_DIR"] export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL") // Experimental diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 14c2346a6117..16cf9de6b311 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -44,6 +44,7 @@ import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" +import * as path from "node:path" // @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 globalThis.AI_SDK_LOG_WARNINGS = false @@ -51,11 +52,96 @@ globalThis.AI_SDK_LOG_WARNINGS = false export namespace Server { const log = Log.create({ service: "server" }) + const localFile = new Set([ + "/index.html", + "/oc-theme-preload.js", + "/favicon.ico", + "/favicon.svg", + "/favicon-v3.ico", + "/favicon-v3.svg", + "/favicon-96x96.png", + "/favicon-96x96-v3.png", + "/apple-touch-icon.png", + "/apple-touch-icon-v3.png", + "/social-share.png", + "/social-share-zen.png", + "/site.webmanifest", + "/web-app-manifest-192x192.png", + "/web-app-manifest-512x512.png", + "/_headers", + ]) + + const rootCache = { + shown: false, + } + + function normalizeBasePath(raw?: string) { + if (!raw) return "" + const input = raw.trim() + if (!input) throw new Error("basePath must not be empty") + const prefixed = input.startsWith("/") ? input : `/${input}` + return prefixed === "/" ? "" : prefixed.replace(/\/+$/, "") + } + + function getUIURL() { + const raw = Flag.OPENCODE_WEB_APP_URL ?? "https://app.opencode.ai" + const value = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${raw}` + try { + return new URL(value) + } catch { + throw new Error(`invalid OPENCODE_WEB_APP_URL: ${raw}`) + } + } + + function strip(path: string, base: string) { + if (!base) return path + if (path === base) return "/" + if (path.startsWith(base + "/")) return path.slice(base.length) + return path + } + + function rewrite(input: string, type: string, base: string, js: "assets" | "all" = "all") { + const root = `${base}/` + const fix = (text: string) => + text.replaceAll(`${root}hub/assets/`, `${root}assets/`).replaceAll("/hub/assets/", `${root}assets/`) + if (type.includes("text/html")) { + return fix( + input + .replace(//gi, "") + .replace(/((?:src|href|content)=["'])\/(?!\/)/g, `$1${root}`) + .replace(//i, ``) + .replace(/\s*]*>\s*/g, "\n"), + ) + } + if (type.includes("javascript")) { + const pattern = + js === "all" + ? /([=:(,\[]\s*["'])\/(?=(?:global|project|provider|session|find|file|path|event|assets|oc-theme-preload|favicon|apple-touch-icon|social-share))/g + : /([=:(,\[]\s*["'])\/(?=(?:assets|oc-theme-preload|favicon|apple-touch-icon|social-share))/g + return fix(input.replace(pattern, `$1${root}`)) + } + if (type.includes("text/css")) { + return fix(input.replace(/url\((["']?)\/(?!\/)/g, `url($1${root}`)) + } + return fix(input) + } + + function history(base: string) { + return `;(function(){var b=${JSON.stringify(base)};globalThis.__OPENCODE_BASE_PATH=b;var f=function(u){if(typeof u!=="string")return u;if(!u.startsWith("/")||u.startsWith("//"))return u;if(u===b||u.startsWith(b+"/"))return u;return b+u};var p=history.pushState.bind(history);history.pushState=function(s,t,u){return p(s,t,f(u))};var r=history.replaceState.bind(history);history.replaceState=function(s,t,u){return r(s,t,f(u))}})();\n` + } + export const Default = lazy(() => createApp({})) - export const createApp = (opts: { cors?: string[] }): Hono => { - const app = new Hono() - return app + export const createApp = (opts: { cors?: string[]; basePath?: string }): Hono => { + const base = normalizeBasePath(opts.basePath) + const ui = getUIURL() + const dir = Flag.OPENCODE_WEB_APP_DIR ? path.resolve(Flag.OPENCODE_WEB_APP_DIR) : undefined + if (dir && !rootCache.shown) { + rootCache.shown = true + log.warn("using local web app directory", { dir }) + } + const inner = new Hono() + inner .onError((err, c) => { log.error("failed", { error: err, @@ -84,16 +170,17 @@ export namespace Server { return basicAuth({ username, password })(c, next) }) .use(async (c, next) => { - const skipLogging = c.req.path === "/log" + const route = strip(c.req.path, base) + const skipLogging = route === "/log" if (!skipLogging) { log.info("request", { method: c.req.method, - path: c.req.path, + path: route, }) } const timer = log.time("request", { method: c.req.method, - path: c.req.path, + path: route, }) await next() if (!skipLogging) { @@ -190,7 +277,7 @@ export namespace Server { }, ) .use(async (c, next) => { - if (c.req.path === "/log") return next() + if (strip(c.req.path, base) === "/log") return next() const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() const directory = Filesystem.resolve( @@ -219,7 +306,7 @@ export namespace Server { .use(WorkspaceRouterMiddleware) .get( "/doc", - openAPIRouteHandler(app, { + openAPIRouteHandler(inner, { documentation: { info: { title: "opencode", @@ -554,21 +641,71 @@ export namespace Server { }, ) .all("/*", async (c) => { - const path = c.req.path + const upstream = strip(c.req.path, base) + const target = new URL(upstream, ui) - const response = await proxy(`https://app.opencode.ai${path}`, { - ...c.req, - headers: { - ...c.req.raw.headers, - host: "app.opencode.ai", - }, - }) + const response = dir + ? await (async () => { + const rel = upstream === "/" ? "/index.html" : upstream + const hidden = rel + .split("/") + .filter(Boolean) + .some((part) => part.startsWith(".")) + if (hidden) return new Response("forbidden", { status: 403 }) + + const allowed = rel.startsWith("/assets/") || localFile.has(rel) + if (!allowed && path.extname(rel) !== "") return new Response("not found", { status: 404 }) + + const file = path.resolve(dir, "." + rel) + const inside = file === dir || file.startsWith(dir + path.sep) + if (!inside) return new Response("forbidden", { status: 403 }) + + const asset = Bun.file(file) + if (await asset.exists()) return new Response(asset) + + const route = path.extname(rel) === "" + if (route) { + const index = Bun.file(path.join(dir, "index.html")) + if (await index.exists()) return new Response(index) + } + + return new Response("not found", { status: 404 }) + })() + : await proxy(target.toString(), { + ...c.req, + headers: { + ...c.req.raw.headers, + host: ui.host, + }, + }) + const type = response.headers.get("content-type") ?? "" + const text = type.includes("text/html") || type.includes("javascript") || type.includes("text/css") + if (base && text) { + const mode: "assets" | "all" = dir ? "assets" : "all" + let body = rewrite(await response.text(), type, base, mode) + if (upstream === "/oc-theme-preload.js") body = history(base) + body + const next = new Response(body, response) + next.headers.delete("content-length") + next.headers.delete("etag") + next.headers.delete("last-modified") + next.headers.set("cache-control", "no-store") + next.headers.set( + "Content-Security-Policy", + "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:", + ) + return next + } response.headers.set( "Content-Security-Policy", "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:", ) return response }) + + if (!base) return inner + const app = new Hono() + app.route(base, inner) + return app } export async function openapi() { @@ -595,6 +732,7 @@ export namespace Server { mdns?: boolean mdnsDomain?: string cors?: string[] + basePath?: string }) { url = new URL(`http://${opts.hostname}:${opts.port}`) const app = createApp(opts) diff --git a/packages/opencode/test/server/base-path.test.ts b/packages/opencode/test/server/base-path.test.ts new file mode 100644 index 000000000000..cde0102cf541 --- /dev/null +++ b/packages/opencode/test/server/base-path.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test" +import { Server } from "../../src/server/server" +import { Flag } from "../../src/flag/flag" + +const ui = (() => { + const raw = Flag.OPENCODE_WEB_APP_URL ?? "https://app.opencode.ai" + const value = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${raw}` + const parsed = new URL(value) + return `${parsed.protocol}//${parsed.host}` +})() + +describe("Server.createApp basePath", () => { + test("default (no basePath): API route accessible at root", async () => { + const app = Server.createApp({}) + const res = await app.request("/event") + // /event exists (returns SSE 200); any non-404 means the route matched + expect(res.status).not.toBe(404) + }) + + test("with basePath: prefixed API route returns non-404", async () => { + const app = Server.createApp({ basePath: "/opencode" }) + const res = await app.request("/opencode/event") + expect(res.status).not.toBe(404) + }) + + test("with basePath: unprefixed API route returns 404", async () => { + const app = Server.createApp({ basePath: "/opencode" }) + const res = await app.request("/event") + expect(res.status).toBe(404) + }) + + test("with basePath: UI proxy at prefixed path strips prefix before forwarding", async () => { + const prev = globalThis.fetch + let call = "" + globalThis.fetch = (async (...args: Parameters): Promise => { + const req = args[0] + const url = typeof req === "string" ? req : req instanceof URL ? req.toString() : req.url + if (!url.startsWith(ui)) return prev(...args) + call = url + return new Response("ok", { status: 200, headers: { "content-type": "text/plain" } }) + }) as typeof fetch + + try { + const app = Server.createApp({ basePath: "/foo" }) + const res = await app.request("/foo/index.html") + expect(res.status).toBe(200) + expect(call.endsWith("/index.html")).toBe(true) + } finally { + globalThis.fetch = prev + } + }) + + test("with basePath: UI proxy at unprefixed path returns 404", async () => { + const app = Server.createApp({ basePath: "/foo" }) + const res = await app.request("/index.html") + expect(res.status).toBe(404) + }) + + test("default (no basePath): UI proxy catch-all works at root", async () => { + const prev = globalThis.fetch + globalThis.fetch = (async (...args: Parameters): Promise => { + const req = args[0] + const url = typeof req === "string" ? req : req instanceof URL ? req.toString() : req.url + if (!url.startsWith(ui)) return prev(...args) + return new Response("ok", { status: 200, headers: { "content-type": "text/plain" } }) + }) as typeof fetch + + try { + const app = Server.createApp({}) + const res = await app.request("/index.html") + expect(res.status).toBe(200) + } finally { + globalThis.fetch = prev + } + }) + + test("with basePath: UI proxy rewrites absolute asset urls", async () => { + const prev = globalThis.fetch + const mock = async (...args: Parameters): Promise => { + const req = args[0] + const url = typeof req === "string" ? req : req instanceof URL ? req.toString() : req.url + if (!url.startsWith(ui)) return prev(...args) + return new Response( + ``, + { + status: 200, + headers: { "content-type": "text/html" }, + }, + ) + } + globalThis.fetch = mock as typeof fetch + + try { + const app = Server.createApp({ basePath: "/foo" }) + const res = await app.request("/foo/") + const body = await res.text() + expect(res.status).toBe(200) + expect(body).toContain('href="/foo/assets/app.css"') + expect(body).toContain('src="/foo/assets/app.js"') + expect(body).toContain('content="/foo/social.png"') + expect(body).toContain('href="/foo/assets/hub.css"') + expect(body).not.toContain('rel="manifest"') + } finally { + globalThis.fetch = prev + } + }) + + test("with basePath: JS rewrite keeps absolute filesystem paths", async () => { + const prev = globalThis.fetch + const mock = async (...args: Parameters): Promise => { + const req = args[0] + const url = typeof req === "string" ? req : req instanceof URL ? req.toString() : req.url + if (!url.startsWith(ui)) return prev(...args) + return new Response('const a="/global/health"; const b="/home/jovyan";', { + status: 200, + headers: { "content-type": "application/javascript" }, + }) + } + globalThis.fetch = mock as typeof fetch + + try { + const app = Server.createApp({ basePath: "/foo" }) + const res = await app.request("/foo/assets/index.js") + const body = await res.text() + expect(body).toContain('"/foo/global/health"') + expect(body).toContain('"/home/jovyan"') + expect(body).not.toContain('"/foo/home/jovyan"') + } finally { + globalThis.fetch = prev + } + }) + + test("with basePath: oc-theme-preload patches history navigation", async () => { + const prev = globalThis.fetch + const mock = async (...args: Parameters): Promise => { + const req = args[0] + const url = typeof req === "string" ? req : req instanceof URL ? req.toString() : req.url + if (!url.startsWith(ui)) return prev(...args) + return new Response("console.log('theme')", { + status: 200, + headers: { "content-type": "text/javascript" }, + }) + } + globalThis.fetch = mock as typeof fetch + + try { + const app = Server.createApp({ basePath: "/foo" }) + const res = await app.request("/foo/oc-theme-preload.js") + const body = await res.text() + expect(body).toContain("history.pushState") + expect(body).toContain('var b="/foo"') + } finally { + globalThis.fetch = prev + } + }) +}) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 6b1c3dee57e2..c80f022835a9 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -460,12 +460,13 @@ This starts an HTTP server and opens a web browser to access OpenCode through a #### Flags -| Flag | Description | -| ------------ | ------------------------------------------ | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | -| `--mdns` | Enable mDNS discovery | -| `--cors` | Additional browser origin(s) to allow CORS | +| Flag | Description | +| -------------- | ----------------------------------------------------- | +| `--port` | Port to listen on | +| `--hostname` | Hostname to listen on | +| `--mdns` | Enable mDNS discovery | +| `--cors` | Additional browser origin(s) to allow CORS | +| `--base-path` | Base path to serve under for reverse-proxy sub-paths | --- diff --git a/packages/web/src/content/docs/web.mdx b/packages/web/src/content/docs/web.mdx index 52b97460c49f..0a54c74d34e1 100644 --- a/packages/web/src/content/docs/web.mdx +++ b/packages/web/src/content/docs/web.mdx @@ -70,6 +70,36 @@ You can customize the mDNS domain name to run multiple instances on the same net opencode web --mdns --mdns-domain myproject.local ``` +### Base Path + +To serve OpenCode behind a reverse proxy at a sub-path, use `--base-path`: + +```bash +opencode web --base-path /opencode +``` + +The server and browser URL will include the prefix: + +``` + Web interface: http://localhost:4096/opencode +``` + +All API routes and the UI are served under the prefix. Requests to the unprefixed paths return 404. + +**Example nginx reverse-proxy config:** + +```nginx +location /opencode/ { + proxy_pass http://127.0.0.1:4096/opencode/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; +} +``` + +--- + ### CORS To allow additional domains for CORS (useful for custom frontends):