diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 34e80d71a081..58f001eef09e 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -59,6 +59,26 @@ console.log(`Loaded ${migrations.length} migrations`) const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") const skipInstall = process.argv.includes("--skip-install") +const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui") +const skipAppBuild = process.argv.includes("--skip-app-build") + +const createEmbeddedWebUIBundle = async () => { + console.log("Building Web UI to embed in the binary") + const appDir = path.join(import.meta.dirname, "../../app") + if (!skipAppBuild) await $`bun run --cwd ${appDir} build` + const allFiles = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: path.join(appDir, "dist") })) + const fileMap = ` +// Import all files as file_$i with type: "file" +${allFiles.map((filePath, i) => `import file_${i} from "${path.join(appDir, "dist", filePath)}" with { type: "file" };`).join("\n")} +// Export with original mappings +export default { + ${allFiles.map((filePath, i) => `"${filePath}": file_${i},`).join("\n")} +} +`.trim() + return fileMap +} + +const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle() const allTargets: { os: string @@ -183,7 +203,10 @@ for (const item of targets) { execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"], windows: {}, }, - entrypoints: ["./src/index.ts", parserWorker, workerPath], + files: { + ...(embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}), + }, + entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])], define: { OPENCODE_VERSION: `'${Script.version}'`, OPENCODE_MIGRATIONS: JSON.stringify(migrations), diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 0fe056f21f2f..8f887c70f10f 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -3,9 +3,13 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" +import { Instance } from "../../project/instance" +import { Log } from "../../util/log" import open from "open" import { networkInterfaces } from "os" +const log = Log.create({ service: "web" }) + function getNetworkIPs() { const nets = networkInterfaces() const results: string[] = [] @@ -75,7 +79,18 @@ export const WebCommand = cmd({ open(displayUrl).catch(() => {}) } + // Graceful shutdown: dispose all instances (and their MCP servers) before exiting + async function shutdown(signal: string) { + log.info("received signal, shutting down", { signal }) + await Promise.race([Instance.disposeAll(), new Promise((resolve) => setTimeout(resolve, 5000))]) + server.stop(true) + process.exit(0) + } + + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { + process.on(signal, () => shutdown(signal)) + } + await new Promise(() => {}) - await server.stop() }, }) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index e02f191c709b..2998f912024c 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -55,6 +55,7 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("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_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI") function number(key: string) { const value = process.env[key] diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 98031f18d3f1..fb1c2dd1811f 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -13,6 +13,60 @@ interface Context { } const context = Context.create("instance") const cache = new Map>() +const lastAccess = new Map() + +/** How long an instance can be idle before it is eligible for eviction (ms). */ +const IDLE_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes + +/** How often the idle-eviction sweep runs (ms). */ +const SWEEP_INTERVAL_MS = 60 * 1000 // 1 minute + +const sweep = { + timer: undefined as ReturnType | undefined, + start() { + if (sweep.timer) return + sweep.timer = setInterval(async () => { + const now = Date.now() + for (const [directory, timestamp] of lastAccess) { + if (now - timestamp < IDLE_TIMEOUT_MS) continue + if (!cache.has(directory)) { + lastAccess.delete(directory) + continue + } + + Log.Default.info("evicting idle instance", { + directory, + idleMs: now - timestamp, + }) + + const entry = cache.get(directory) + if (!entry) continue + + const ctx = await entry.catch(() => undefined) + if (!ctx) { + cache.delete(directory) + lastAccess.delete(directory) + continue + } + + // re-check — may have been accessed while awaiting + const current = lastAccess.get(directory) + if (current && now - current < IDLE_TIMEOUT_MS) continue + + await context.provide(ctx, async () => { + await Instance.dispose() + }) + lastAccess.delete(directory) + } + }, SWEEP_INTERVAL_MS) + sweep.timer.unref() + }, + stop() { + if (!sweep.timer) return + clearInterval(sweep.timer) + sweep.timer = undefined + }, +} const disposal = { all: undefined as Promise | undefined, @@ -20,6 +74,9 @@ const disposal = { export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { + lastAccess.set(input.directory, Date.now()) + sweep.start() + let existing = cache.get(input.directory) if (!existing) { Log.Default.info("creating instance", { directory: input.directory }) @@ -70,6 +127,7 @@ export const Instance = { Log.Default.info("disposing instance", { directory: Instance.directory }) await State.dispose(Instance.directory) cache.delete(Instance.directory) + lastAccess.delete(Instance.directory) GlobalBus.emit("event", { directory: Instance.directory, payload: { @@ -85,6 +143,7 @@ export const Instance = { disposal.all = iife(async () => { Log.Default.info("disposing all instances") + sweep.stop() const entries = [...cache.entries()] for (const [key, value] of entries) { if (cache.get(key) !== value) continue @@ -105,6 +164,7 @@ export const Instance = { await Instance.dispose() }) } + lastAccess.clear() }).finally(() => { disposal.all = undefined }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 9fba9c1fe1a0..56cc576b7af9 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -81,6 +81,8 @@ export namespace Server { // Allow CORS preflight requests to succeed without auth. // Browser clients sending Authorization headers will preflight with OPTIONS. if (c.req.method === "OPTIONS") return next() + // Allow the embedded UI through without auth — it handles auth itself via the API password prompt + if (!c.req.path.startsWith("/v1")) return next() const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" @@ -541,8 +543,22 @@ export namespace Server { }, ) .all("/*", async (c) => { - const path = c.req.path + const embeddedWebUI = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI + ? null + // @ts-expect-error - generated file at build time + : await import("opencode-web-ui.gen.ts").then((m) => m.default as Record).catch(() => null) + + if (embeddedWebUI) { + const reqPath = c.req.path.replace(/^\//, "") + const match = embeddedWebUI[reqPath] ?? embeddedWebUI["index.html"] ?? null + if (!match) return c.json({ error: "Not Found" }, 404) + const file = Bun.file(match) + if (!(await file.exists())) return c.json({ error: "Not Found" }, 404) + c.header("Content-Type", file.type) + return c.body(await file.arrayBuffer()) + } + const path = c.req.path const response = await proxy(`https://app.opencode.ai${path}`, { ...c.req, headers: {