diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 99b60d1e96..18feb46757 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -39,6 +39,11 @@ "bun": "./src/pty/pty.bun.ts", "node": "./src/pty/pty.node.ts", "default": "./src/pty/pty.bun.ts" + }, + "#hono": { + "bun": "./src/server/adapter.bun.ts", + "node": "./src/server/adapter.node.ts", + "default": "./src/server/adapter.bun.ts" } }, "devDependencies": { diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 93b393f137..fd9ac43e8b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -398,13 +398,11 @@ export namespace Agent { }), ) - export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Provider.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Skill.defaultLayer), - ), + export const defaultLayer = layer.pipe( + Layer.provide(Provider.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Skill.defaultLayer), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 0534b147a5..972e67d103 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -137,12 +137,18 @@ export const TuiThreadCommand = cmd({ ), }) worker.onerror = (e) => { - Log.Default.error(e) + Log.Default.error("thread error", { + message: e.message, + filename: e.filename, + lineno: e.lineno, + colno: e.colno, + error: e.error, + }) } const client = Rpc.client(worker) const error = (e: unknown) => { - Log.Default.error(e) + Log.Default.error("process error", { error: errorMessage(e) }) } const reload = () => { client.call("reload", undefined).catch((err) => { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 086d51abd8..ecce8fb8f8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -4,7 +4,6 @@ import { pathToFileURL } from "url" import os from "os" import { Process } from "../util/process" import z from "zod" -import { ModelsDev } from "../provider/models" import { mergeDeep, pipe, unique } from "remeda" import { Global } from "../global" import fsNode from "fs/promises" diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 5de77aee39..e0478e0b3c 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -124,7 +124,7 @@ export namespace Plugin { Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, } : undefined, - fetch: async (...args) => Server.Default().app.fetch(...args), + fetch: async (...args) => (await Server.Default()).app.fetch(...args), }) const cfg = yield* config.get() const input: PluginInput = { @@ -210,13 +210,15 @@ export namespace Plugin { return message }, }).pipe( - Effect.catch((message) => - bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to load plugin ${load.spec}: ${message}`, - }).toObject(), - }), - ), + Effect.catch(() => { + // TODO: make proper events for this + // bus.publish(Session.Event.Error, { + // error: new NamedError.Unknown({ + // message: `Failed to load plugin ${load.spec}: ${message}`, + // }).toObject(), + // }) + return Effect.void + }), ) } diff --git a/packages/opencode/src/server/adapter.bun.ts b/packages/opencode/src/server/adapter.bun.ts new file mode 100644 index 0000000000..3e70b97e8a --- /dev/null +++ b/packages/opencode/src/server/adapter.bun.ts @@ -0,0 +1,40 @@ +import type { Hono } from "hono" +import { createBunWebSocket } from "hono/bun" +import type { Adapter } from "./adapter" + +export const adapter: Adapter = { + create(app: Hono) { + const ws = createBunWebSocket() + return { + upgradeWebSocket: ws.upgradeWebSocket, + async listen(opts) { + const args = { + fetch: app.fetch, + hostname: opts.hostname, + idleTimeout: 0, + websocket: ws.websocket, + } as const + const start = (port: number) => { + try { + return Bun.serve({ ...args, port }) + } catch { + return + } + } + const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port) + if (!server) { + throw new Error(`Failed to start server on port ${opts.port}`) + } + if (!server.port) { + throw new Error(`Failed to resolve server address for port ${opts.port}`) + } + return { + port: server.port, + stop(close?: boolean) { + return Promise.resolve(server.stop(close)) + }, + } + }, + } + }, +} diff --git a/packages/opencode/src/server/adapter.node.ts b/packages/opencode/src/server/adapter.node.ts new file mode 100644 index 0000000000..9c2a41cce2 --- /dev/null +++ b/packages/opencode/src/server/adapter.node.ts @@ -0,0 +1,66 @@ +import { createAdaptorServer, type ServerType } from "@hono/node-server" +import { createNodeWebSocket } from "@hono/node-ws" +import type { Hono } from "hono" +import type { Adapter } from "./adapter" + +export const adapter: Adapter = { + create(app: Hono) { + const ws = createNodeWebSocket({ app }) + return { + upgradeWebSocket: ws.upgradeWebSocket, + async listen(opts) { + const start = (port: number) => + new Promise((resolve, reject) => { + const server = createAdaptorServer({ fetch: app.fetch }) + ws.injectWebSocket(server) + const fail = (err: Error) => { + cleanup() + reject(err) + } + const ready = () => { + cleanup() + resolve(server) + } + const cleanup = () => { + server.off("error", fail) + server.off("listening", ready) + } + server.once("error", fail) + server.once("listening", ready) + server.listen(port, opts.hostname) + }) + + const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) + const addr = server.address() + if (!addr || typeof addr === "string") { + throw new Error(`Failed to resolve server address for port ${opts.port}`) + } + + let closing: Promise | undefined + return { + port: addr.port, + stop(close?: boolean) { + closing ??= new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err) + return + } + resolve() + }) + if (close) { + if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") { + server.closeAllConnections() + } + if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") { + server.closeIdleConnections() + } + } + }) + return closing + }, + } + }, + } + }, +} diff --git a/packages/opencode/src/server/adapter.ts b/packages/opencode/src/server/adapter.ts new file mode 100644 index 0000000000..272521d7d3 --- /dev/null +++ b/packages/opencode/src/server/adapter.ts @@ -0,0 +1,21 @@ +import type { Hono } from "hono" +import type { UpgradeWebSocket } from "hono/ws" + +export type Opts = { + port: number + hostname: string +} + +export type Listener = { + port: number + stop: (close?: boolean) => Promise +} + +export interface Runtime { + upgradeWebSocket: UpgradeWebSocket + listen(opts: Opts): Promise +} + +export interface Adapter { + create(app: Hono): Runtime +} diff --git a/packages/opencode/src/server/control/index.ts b/packages/opencode/src/server/control/index.ts new file mode 100644 index 0000000000..aae77f2f05 --- /dev/null +++ b/packages/opencode/src/server/control/index.ts @@ -0,0 +1,150 @@ +import { Auth } from "@/auth" +import { Log } from "@/util/log" +import { ProviderID } from "@/provider/schema" +import { Hono } from "hono" +import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" +import z from "zod" +import { errors } from "../error" +import { GlobalRoutes } from "../instance/global" + +export function ControlPlaneRoutes(): Hono { + const app = new Hono() + return app + .route("/global", GlobalRoutes()) + .put( + "/auth/:providerID", + describeRoute({ + summary: "Set auth credentials", + description: "Set authentication credentials", + operationId: "auth.set", + responses: { + 200: { + description: "Successfully set authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod, + }), + ), + validator("json", Auth.Info.zod), + async (c) => { + const providerID = c.req.valid("param").providerID + const info = c.req.valid("json") + await Auth.set(providerID, info) + return c.json(true) + }, + ) + .delete( + "/auth/:providerID", + describeRoute({ + summary: "Remove auth credentials", + description: "Remove authentication credentials", + operationId: "auth.remove", + responses: { + 200: { + description: "Successfully removed authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod, + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + await Auth.remove(providerID) + return c.json(true) + }, + ) + .get( + "/doc", + openAPIRouteHandler(app, { + documentation: { + info: { + title: "opencode", + version: "0.0.3", + description: "opencode api", + }, + openapi: "3.1.1", + }, + }), + ) + .use( + validator( + "query", + z.object({ + directory: z.string().optional(), + workspace: z.string().optional(), + }), + ), + ) + .post( + "/log", + describeRoute({ + summary: "Write log", + description: "Write a log entry to the server logs with specified level and metadata.", + operationId: "app.log", + responses: { + 200: { + description: "Log entry written successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + service: z.string().meta({ description: "Service name for the log entry" }), + level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), + message: z.string().meta({ description: "Log message" }), + extra: z + .record(z.string(), z.any()) + .optional() + .meta({ description: "Additional metadata for the log entry" }), + }), + ), + async (c) => { + const { service, level, message, extra } = c.req.valid("json") + const logger = Log.create({ service }) + + switch (level) { + case "debug": + logger.debug(message, extra) + break + case "info": + logger.info(message, extra) + break + case "error": + logger.error(message, extra) + break + case "warn": + logger.warn(message, extra) + break + } + + return c.json(true) + }, + ) +} diff --git a/packages/opencode/src/server/routes/config.ts b/packages/opencode/src/server/instance/config.ts similarity index 100% rename from packages/opencode/src/server/routes/config.ts rename to packages/opencode/src/server/instance/config.ts diff --git a/packages/opencode/src/server/routes/event.ts b/packages/opencode/src/server/instance/event.ts similarity index 100% rename from packages/opencode/src/server/routes/event.ts rename to packages/opencode/src/server/instance/event.ts diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/instance/experimental.ts similarity index 100% rename from packages/opencode/src/server/routes/experimental.ts rename to packages/opencode/src/server/instance/experimental.ts diff --git a/packages/opencode/src/server/routes/file.ts b/packages/opencode/src/server/instance/file.ts similarity index 100% rename from packages/opencode/src/server/routes/file.ts rename to packages/opencode/src/server/instance/file.ts diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/instance/global.ts similarity index 100% rename from packages/opencode/src/server/routes/global.ts rename to packages/opencode/src/server/instance/global.ts diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance/index.ts similarity index 65% rename from packages/opencode/src/server/instance.ts rename to packages/opencode/src/server/instance/index.ts index 6525d2ded7..2acc424e4e 100644 --- a/packages/opencode/src/server/instance.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -1,53 +1,33 @@ import { describeRoute, resolver, validator } from "hono-openapi" import { Hono } from "hono" -import { proxy } from "hono/proxy" import type { UpgradeWebSocket } from "hono/ws" import z from "zod" -import { createHash } from "node:crypto" -import * as fs from "node:fs/promises" -import { Log } from "../util/log" -import { Format } from "../format" -import { TuiRoutes } from "./routes/tui" -import { Instance } from "../project/instance" -import { Vcs } from "../project/vcs" -import { Agent } from "../agent/agent" -import { Skill } from "../skill" -import { Global } from "../global" -import { LSP } from "../lsp" -import { Command } from "../command" -import { Flag } from "../flag/flag" -import { QuestionRoutes } from "./routes/question" -import { PermissionRoutes } from "./routes/permission" -import { Snapshot } from "@/snapshot" -import { ProjectRoutes } from "./routes/project" -import { SessionRoutes } from "./routes/session" -import { PtyRoutes } from "./routes/pty" -import { McpRoutes } from "./routes/mcp" -import { FileRoutes } from "./routes/file" -import { ConfigRoutes } from "./routes/config" -import { ExperimentalRoutes } from "./routes/experimental" -import { ProviderRoutes } from "./routes/provider" -import { EventRoutes } from "./routes/event" -import { errorHandler } from "./middleware" -import { getMimeType } from "hono/utils/mime" +import { Format } from "../../format" +import { TuiRoutes } from "./tui" +import { Instance } from "../../project/instance" +import { Vcs } from "../../project/vcs" +import { Agent } from "../../agent/agent" +import { Skill } from "../../skill" +import { Global } from "../../global" +import { LSP } from "../../lsp" +import { Command } from "../../command" +import { QuestionRoutes } from "./question" +import { PermissionRoutes } from "./permission" +import { ProjectRoutes } from "./project" +import { SessionRoutes } from "./session" +import { PtyRoutes } from "./pty" +import { McpRoutes } from "./mcp" +import { FileRoutes } from "./file" +import { ConfigRoutes } from "./config" +import { ExperimentalRoutes } from "./experimental" +import { ProviderRoutes } from "./provider" +import { EventRoutes } from "./event" +import { WorkspaceRouterMiddleware } from "./middleware" import { AppRuntime } from "@/effect/app-runtime" -const log = Log.create({ service: "server" }) - -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 = - "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:` - -export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) => - app - .onError(errorHandler(log)) +export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => + new Hono() + .use(WorkspaceRouterMiddleware(upgrade)) .route("/project", ProjectRoutes()) .route("/pty", PtyRoutes(upgrade)) .route("/config", ConfigRoutes()) @@ -281,39 +261,3 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono() return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status()))) }, ) - .all("/*", async (c) => { - const embeddedWebUI = await embeddedUIPromise - const path = c.req.path - - if (embeddedWebUI) { - const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return c.json({ error: "Not Found" }, 404) - - if (await fs.exists(match)) { - const mime = getMimeType(match) ?? "text/plain" - c.header("Content-Type", mime) - if (mime.startsWith("text/html")) { - c.header("Content-Security-Policy", DEFAULT_CSP) - } - return c.body(new Uint8Array(await fs.readFile(match))) - } else { - return c.json({ error: "Not Found" }, 404) - } - } 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 - } - }) diff --git a/packages/opencode/src/server/routes/mcp.ts b/packages/opencode/src/server/instance/mcp.ts similarity index 100% rename from packages/opencode/src/server/routes/mcp.ts rename to packages/opencode/src/server/instance/mcp.ts diff --git a/packages/opencode/src/server/router.ts b/packages/opencode/src/server/instance/middleware.ts similarity index 90% rename from packages/opencode/src/server/router.ts rename to packages/opencode/src/server/instance/middleware.ts index f97724c2ec..1a5011477e 100644 --- a/packages/opencode/src/server/router.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -3,12 +3,10 @@ import type { UpgradeWebSocket } from "hono/ws" import { getAdaptor } from "@/control-plane/adaptors" import { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" -import { ServerProxy } from "./proxy" -import { lazy } from "@/util/lazy" +import { ServerProxy } from "../proxy" import { Filesystem } from "@/util/filesystem" import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" -import { InstanceRoutes } from "./instance" import { Session } from "@/session" import { SessionID } from "@/session/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -47,9 +45,7 @@ async function getSessionWorkspace(url: URL) { } export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { - const routes = lazy(() => InstanceRoutes(upgrade)) - - return async (c) => { + return async (c, next) => { const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() const directory = Filesystem.resolve( (() => { @@ -72,7 +68,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware directory, init: InstanceBootstrap, async fn() { - return routes().fetch(c.req.raw, c.env) + return next() }, }) } @@ -87,7 +83,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware // The lets the `DELETE /session/:id` endpoint through and we've // made sure that it will run without an instance if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") { - return routes().fetch(c.req.raw, c.env) + return next() } return new Response(`Workspace not found: ${workspaceID}`, { @@ -109,7 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware directory: target.directory, init: InstanceBootstrap, async fn() { - return routes().fetch(c.req.raw, c.env) + return next() }, }), }) @@ -118,7 +114,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware if (local(c.req.method, url.pathname)) { // No instance provided because we are serving cached data; there // is no instance to work with - return routes().fetch(c.req.raw, c.env) + return next() } if (c.req.header("upgrade")?.toLowerCase() === "websocket") { diff --git a/packages/opencode/src/server/routes/permission.ts b/packages/opencode/src/server/instance/permission.ts similarity index 100% rename from packages/opencode/src/server/routes/permission.ts rename to packages/opencode/src/server/instance/permission.ts diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/instance/project.ts similarity index 100% rename from packages/opencode/src/server/routes/project.ts rename to packages/opencode/src/server/instance/project.ts diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/instance/provider.ts similarity index 100% rename from packages/opencode/src/server/routes/provider.ts rename to packages/opencode/src/server/instance/provider.ts diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/instance/pty.ts similarity index 100% rename from packages/opencode/src/server/routes/pty.ts rename to packages/opencode/src/server/instance/pty.ts diff --git a/packages/opencode/src/server/routes/question.ts b/packages/opencode/src/server/instance/question.ts similarity index 100% rename from packages/opencode/src/server/routes/question.ts rename to packages/opencode/src/server/instance/question.ts diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/instance/session.ts similarity index 100% rename from packages/opencode/src/server/routes/session.ts rename to packages/opencode/src/server/instance/session.ts diff --git a/packages/opencode/src/server/routes/tui.ts b/packages/opencode/src/server/instance/tui.ts similarity index 100% rename from packages/opencode/src/server/routes/tui.ts rename to packages/opencode/src/server/instance/tui.ts diff --git a/packages/opencode/src/server/routes/workspace.ts b/packages/opencode/src/server/instance/workspace.ts similarity index 100% rename from packages/opencode/src/server/routes/workspace.ts rename to packages/opencode/src/server/instance/workspace.ts diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 278740c57d..a51ba602b5 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -3,31 +3,90 @@ import { NamedError } from "@opencode-ai/util/error" import { NotFoundError } from "../storage/db" import { Session } from "../session" import type { ContentfulStatusCode } from "hono/utils/http-status" -import type { ErrorHandler } from "hono" +import type { ErrorHandler, MiddlewareHandler } from "hono" import { HTTPException } from "hono/http-exception" -import type { Log } from "../util/log" +import { Log } from "../util/log" +import { Flag } from "@/flag/flag" +import { basicAuth } from "hono/basic-auth" +import { cors } from "hono/cors" +import { compress } from "hono/compress" -export function errorHandler(log: Log.Logger): ErrorHandler { - return (err, c) => { - log.error("failed", { - error: err, - }) - if (err instanceof NamedError) { - let status: ContentfulStatusCode - if (err instanceof NotFoundError) status = 404 - else if (err instanceof Provider.ModelNotFoundError) status = 400 - else if (err.name === "ProviderAuthValidationFailed") status = 400 - else if (err.name.startsWith("Worktree")) status = 400 - else status = 500 - return c.json(err.toObject(), { status }) - } - if (err instanceof Session.BusyError) { - return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 }) - } - if (err instanceof HTTPException) return err.getResponse() - const message = err instanceof Error && err.stack ? err.stack : err.toString() - return c.json(new NamedError.Unknown({ message }).toObject(), { - status: 500, +const log = Log.create({ service: "server" }) + +export const ErrorMiddleware: ErrorHandler = (err, c) => { + log.error("failed", { + error: err, + }) + if (err instanceof NamedError) { + let status: ContentfulStatusCode + if (err instanceof NotFoundError) status = 404 + else if (err instanceof Provider.ModelNotFoundError) status = 400 + else if (err.name === "ProviderAuthValidationFailed") status = 400 + else if (err.name.startsWith("Worktree")) status = 400 + else status = 500 + return c.json(err.toObject(), { status }) + } + if (err instanceof Session.BusyError) { + return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 }) + } + if (err instanceof HTTPException) return err.getResponse() + const message = err instanceof Error && err.stack ? err.stack : err.toString() + return c.json(new NamedError.Unknown({ message }).toObject(), { + status: 500, + }) +} + +export const AuthMiddleware: MiddlewareHandler = (c, next) => { + // Allow CORS preflight requests to succeed without auth. + // Browser clients sending Authorization headers will preflight with OPTIONS. + if (c.req.method === "OPTIONS") return next() + const password = Flag.OPENCODE_SERVER_PASSWORD + if (!password) return next() + const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + + if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) + + return basicAuth({ username, password })(c, next) +} + +export const LoggerMiddleware: MiddlewareHandler = async (c, next) => { + const skip = c.req.path === "/log" + if (!skip) { + log.info("request", { + method: c.req.method, + path: c.req.path, }) } + const timer = log.time("request", { + method: c.req.method, + path: c.req.path, + }) + await next() + if (!skip) timer.stop() +} + +export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler { + return cors({ + maxAge: 86_400, + origin(input) { + if (!input) return + + if (input.startsWith("http://localhost:")) return input + if (input.startsWith("http://127.0.0.1:")) return input + if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost") + return input + + if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input + if (opts?.cors?.includes(input)) return input + }, + }) +} + +const zipped = compress() +export const CompressionMiddleware: MiddlewareHandler = (c, next) => { + const path = c.req.path + const method = c.req.method + if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next() + if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next() + return zipped(c, next) } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c4f2a931b0..02ec7356ec 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,24 +1,14 @@ -import { Log } from "../util/log" -import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi" +import { generateSpecs } from "hono-openapi" import { Hono } from "hono" -import { compress } from "hono/compress" -import { createNodeWebSocket } from "@hono/node-ws" -import { cors } from "hono/cors" -import { basicAuth } from "hono/basic-auth" -import type { UpgradeWebSocket } from "hono/ws" -import z from "zod" -import { Auth } from "../auth" -import { Flag } from "../flag/flag" -import { ProviderID } from "../provider/schema" -import { WorkspaceRouterMiddleware } from "./router" -import { errors } from "./error" -import { GlobalRoutes } from "./routes/global" +import { adapter } from "#hono" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" -import { errorHandler } from "./middleware" +import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { InstanceRoutes } from "./instance" import { initProjectors } from "./projectors" -import { createAdaptorServer, type ServerType } from "@hono/node-server" +import { Log } from "@/util/log" +import { ControlPlaneRoutes } from "./control" +import { UIRoutes } from "./ui" // @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 @@ -26,6 +16,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false initProjectors() export namespace Server { + const log = Log.create({ service: "server" }) + export type Listener = { hostname: string port: number @@ -33,231 +25,31 @@ export namespace Server { stop: (close?: boolean) => Promise } - const log = Log.create({ service: "server" }) - const zipped = compress() - - const skipCompress = (path: string, method: string) => { - if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return true - if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return true - return false - } - export const Default = lazy(() => create({})) - export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono { - return app - .onError(errorHandler(log)) - .use((c, next) => { - // Allow CORS preflight requests to succeed without auth. - // Browser clients sending Authorization headers will preflight with OPTIONS. - if (c.req.method === "OPTIONS") return next() - const password = Flag.OPENCODE_SERVER_PASSWORD - if (!password) return next() - const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" - - if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) - - return basicAuth({ username, password })(c, next) - }) - .use(async (c, next) => { - const skip = c.req.path === "/log" - if (!skip) { - log.info("request", { - method: c.req.method, - path: c.req.path, - }) - } - const timer = log.time("request", { - method: c.req.method, - path: c.req.path, - }) - await next() - if (!skip) timer.stop() - }) - .use( - cors({ - maxAge: 86_400, - origin(input) { - if (!input) return - - if (input.startsWith("http://localhost:")) return input - if (input.startsWith("http://127.0.0.1:")) return input - if ( - input === "tauri://localhost" || - input === "http://tauri.localhost" || - input === "https://tauri.localhost" - ) - return input - - if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input - if (opts?.cors?.includes(input)) return input - }, - }), - ) - .use((c, next) => { - if (skipCompress(c.req.path, c.req.method)) return next() - return zipped(c, next) - }) - .route("/global", GlobalRoutes()) - .put( - "/auth/:providerID", - describeRoute({ - summary: "Set auth credentials", - description: "Set authentication credentials", - operationId: "auth.set", - responses: { - 200: { - description: "Successfully set authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod, - }), - ), - validator("json", Auth.Info.zod), - async (c) => { - const providerID = c.req.valid("param").providerID - const info = c.req.valid("json") - await Auth.set(providerID, info) - return c.json(true) - }, - ) - .delete( - "/auth/:providerID", - describeRoute({ - summary: "Remove auth credentials", - description: "Remove authentication credentials", - operationId: "auth.remove", - responses: { - 200: { - description: "Successfully removed authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod, - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - await Auth.remove(providerID) - return c.json(true) - }, - ) - .get( - "/doc", - openAPIRouteHandler(app, { - documentation: { - info: { - title: "opencode", - version: "0.0.3", - description: "opencode api", - }, - openapi: "3.1.1", - }, - }), - ) - .use( - validator( - "query", - z.object({ - directory: z.string().optional(), - workspace: z.string().optional(), - }), - ), - ) - .post( - "/log", - describeRoute({ - summary: "Write log", - description: "Write a log entry to the server logs with specified level and metadata.", - operationId: "app.log", - responses: { - 200: { - description: "Log entry written successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - service: z.string().meta({ description: "Service name for the log entry" }), - level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), - message: z.string().meta({ description: "Log message" }), - extra: z - .record(z.string(), z.any()) - .optional() - .meta({ description: "Additional metadata for the log entry" }), - }), - ), - async (c) => { - const { service, level, message, extra } = c.req.valid("json") - const logger = Log.create({ service }) - - switch (level) { - case "debug": - logger.debug(message, extra) - break - case "info": - logger.info(message, extra) - break - case "error": - logger.error(message, extra) - break - case "warn": - logger.warn(message, extra) - break - } - - return c.json(true) - }, - ) - .use(WorkspaceRouterMiddleware(upgrade)) - } - function create(opts: { cors?: string[] }) { const app = new Hono() - const ws = createNodeWebSocket({ app }) + const runtime = adapter.create(app) return { - app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts), - ws, + app: app + .onError(ErrorMiddleware) + .use(AuthMiddleware) + .use(LoggerMiddleware) + .use(CompressionMiddleware) + .use(CorsMiddleware(opts)) + .route("/", ControlPlaneRoutes()) + .route("/", InstanceRoutes(runtime.upgradeWebSocket)) + .route("/", UIRoutes()), + runtime, } } - export function createApp(opts: { cors?: string[] }) { - return create(opts).app - } - export async function openapi() { // Build a fresh app with all routes registered directly so // hono-openapi can see describeRoute metadata (`.route()` wraps // handlers when the sub-app has a custom errorHandler, which // strips the metadata symbol). - const { app, ws } = create({}) - InstanceRoutes(ws.upgradeWebSocket, app) + const { app } = create({}) const result = await generateSpecs(app, { documentation: { info: { @@ -281,46 +73,21 @@ export namespace Server { cors?: string[] }): Promise { const built = create(opts) - const start = (port: number) => - new Promise((resolve, reject) => { - const server = createAdaptorServer({ fetch: built.app.fetch }) - built.ws.injectWebSocket(server) - const fail = (err: Error) => { - cleanup() - reject(err) - } - const ready = () => { - cleanup() - resolve(server) - } - const cleanup = () => { - server.off("error", fail) - server.off("listening", ready) - } - server.once("error", fail) - server.once("listening", ready) - server.listen(port, opts.hostname) - }) - - const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) - const addr = server.address() - if (!addr || typeof addr === "string") { - throw new Error(`Failed to resolve server address for port ${opts.port}`) - } + const server = await built.runtime.listen(opts) const next = new URL("http://localhost") next.hostname = opts.hostname - next.port = String(addr.port) + next.port = String(server.port) url = next const mdns = opts.mdns && - addr.port && + server.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (mdns) { - MDNS.publish(addr.port, opts.mdnsDomain) + MDNS.publish(server.port, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } @@ -328,27 +95,13 @@ export namespace Server { let closing: Promise | undefined return { hostname: opts.hostname, - port: addr.port, + port: server.port, url: next, stop(close?: boolean) { - closing ??= new Promise((resolve, reject) => { + closing ??= (async () => { if (mdns) MDNS.unpublish() - server.close((err) => { - if (err) { - reject(err) - return - } - resolve() - }) - if (close) { - if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") { - server.closeAllConnections() - } - if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") { - server.closeIdleConnections() - } - } - }) + await server.stop(close) + })() return closing }, } diff --git a/packages/opencode/src/server/ui/index.ts b/packages/opencode/src/server/ui/index.ts new file mode 100644 index 0000000000..afe6e510f1 --- /dev/null +++ b/packages/opencode/src/server/ui/index.ts @@ -0,0 +1,55 @@ +import { Flag } from "@/flag/flag" +import { Hono } from "hono" +import { proxy } from "hono/proxy" +import { getMimeType } from "hono/utils/mime" +import { createHash } from "node:crypto" +import fs from "node:fs/promises" + +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 = + "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:` + +export const UIRoutes = (): Hono => + new Hono().all("/*", async (c) => { + const embeddedWebUI = await embeddedUIPromise + const path = c.req.path + + if (embeddedWebUI) { + const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null + if (!match) return c.json({ error: "Not Found" }, 404) + + if (await fs.exists(match)) { + const mime = getMimeType(match) ?? "text/plain" + c.header("Content-Type", mime) + if (mime.startsWith("text/html")) { + c.header("Content-Security-Policy", DEFAULT_CSP) + } + return c.body(new Uint8Array(await fs.readFile(match))) + } else { + return c.json({ error: "Not Found" }, 404) + } + } 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 + } + }) diff --git a/packages/opencode/test/memory/abort-leak-webfetch.ts b/packages/opencode/test/memory/abort-leak-webfetch.ts new file mode 100644 index 0000000000..1286d5f0b3 --- /dev/null +++ b/packages/opencode/test/memory/abort-leak-webfetch.ts @@ -0,0 +1,49 @@ +import { abortAfterAny } from "../../src/util/abort" + +const MB = 1024 * 1024 +const ITERATIONS = 50 + +const heap = () => { + Bun.gc(true) + return process.memoryUsage().heapUsed / MB +} + +const server = Bun.serve({ + port: 0, + fetch() { + return new Response("hello from local", { + headers: { + "content-type": "text/plain", + }, + }) + }, +}) + +const url = `http://127.0.0.1:${server.port}` + +async function run() { + const { signal, clearTimeout } = abortAfterAny(30000, new AbortController().signal) + try { + const response = await fetch(url, { signal }) + await response.text() + } finally { + clearTimeout() + } +} + +try { + await run() + Bun.sleepSync(100) + const baseline = heap() + + for (let i = 0; i < ITERATIONS; i++) { + await run() + } + + Bun.sleepSync(100) + const after = heap() + process.stdout.write(JSON.stringify({ baseline, after, growth: after - baseline })) +} finally { + server.stop(true) + process.exit(0) +} diff --git a/packages/opencode/test/memory/abort-leak.test.ts b/packages/opencode/test/memory/abort-leak.test.ts new file mode 100644 index 0000000000..d30ad45e46 --- /dev/null +++ b/packages/opencode/test/memory/abort-leak.test.ts @@ -0,0 +1,127 @@ +import { describe, test, expect } from "bun:test" +import path from "path" + +const projectRoot = path.join(import.meta.dir, "../..") +const worker = path.join(import.meta.dir, "abort-leak-webfetch.ts") + +const MB = 1024 * 1024 +const ITERATIONS = 50 + +const getHeapMB = () => { + Bun.gc(true) + return process.memoryUsage().heapUsed / MB +} + +describe("memory: abort controller leak", () => { + test("webfetch does not leak memory over many invocations", async () => { + // Measure the abort-timed fetch path in a fresh process so shared tool + // runtime state does not dominate the heap signal. + const proc = Bun.spawn({ + cmd: [process.execPath, worker], + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + env: process.env, + }) + + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + if (code !== 0) { + throw new Error(stderr.trim() || stdout.trim() || `worker exited with code ${code}`) + } + + const result = JSON.parse(stdout.trim()) as { + baseline: number + after: number + growth: number + } + + console.log(`Baseline: ${result.baseline.toFixed(2)} MB`) + console.log(`After ${ITERATIONS} fetches: ${result.after.toFixed(2)} MB`) + console.log(`Growth: ${result.growth.toFixed(2)} MB`) + + // Memory growth should be minimal - less than 1MB per 10 requests. + expect(result.growth).toBeLessThan(ITERATIONS / 10) + }, 60000) + + test("compare closure vs bind pattern directly", async () => { + const ITERATIONS = 500 + + // Test OLD pattern: arrow function closure + // Store closures in a map keyed by content to force retention + const closureMap = new Map void>() + const timers: Timer[] = [] + const controllers: AbortController[] = [] + + Bun.gc(true) + Bun.sleepSync(100) + const baseline = getHeapMB() + + for (let i = 0; i < ITERATIONS; i++) { + // Simulate large response body like webfetch would have + const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration + const controller = new AbortController() + controllers.push(controller) + + // OLD pattern - closure captures `content` + const handler = () => { + // Actually use content so it can't be optimized away + if (content.length > 1000000000) controller.abort() + } + closureMap.set(content, handler) + const timeoutId = setTimeout(handler, 30000) + timers.push(timeoutId) + } + + Bun.gc(true) + Bun.sleepSync(100) + const after = getHeapMB() + const oldGrowth = after - baseline + + console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`) + + // Cleanup after measuring + timers.forEach(clearTimeout) + controllers.forEach((c) => c.abort()) + closureMap.clear() + + // Test NEW pattern: bind + Bun.gc(true) + Bun.sleepSync(100) + const baseline2 = getHeapMB() + const handlers2: (() => void)[] = [] + const timers2: Timer[] = [] + const controllers2: AbortController[] = [] + + for (let i = 0; i < ITERATIONS; i++) { + const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured + const controller = new AbortController() + controllers2.push(controller) + + // NEW pattern - bind doesn't capture surrounding scope + const handler = controller.abort.bind(controller) + handlers2.push(handler) + const timeoutId = setTimeout(handler, 30000) + timers2.push(timeoutId) + } + + Bun.gc(true) + Bun.sleepSync(100) + const after2 = getHeapMB() + const newGrowth = after2 - baseline2 + + // Cleanup after measuring + timers2.forEach(clearTimeout) + controllers2.forEach((c) => c.abort()) + handlers2.length = 0 + + console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`) + console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`) + + expect(newGrowth).toBeLessThanOrEqual(oldGrowth) + }) +}) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index c01a02ef41..32b6c601d1 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -13,8 +13,6 @@ const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") const { Instance } = await import("../../src/project/instance") const { Npm } = await import("../../src/npm") -const { Bus } = await import("../../src/bus") -const { Session } = await import("../../src/session") afterAll(() => { if (disableDefault === undefined) { @@ -37,27 +35,6 @@ async function load(dir: string) { }) } -async function errs(dir: string) { - return Instance.provide({ - directory: dir, - fn: async () => { - const errors: string[] = [] - const off = Bus.subscribe(Session.Event.Error, (evt) => { - const error = evt.properties.error - if (!error || typeof error !== "object") return - if (!("data" in error)) return - if (!error.data || typeof error.data !== "object") return - if (!("message" in error.data)) return - if (typeof error.data.message !== "string") return - errors.push(error.data.message) - }) - await Plugin.list() - off() - return errors - }, - }) -} - describe("plugin.loader.shared", () => { test("loads a file:// plugin function export", async () => { await using tmp = await tmpdir({ @@ -184,14 +161,13 @@ describe("plugin.loader.shared", () => { }, }) - const errors = await errs(tmp.path) + await load(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) - expect(errors.some((x) => x.includes("must export id"))).toBe(true) }) test("rejects v1 plugin that exports server and tui together", async () => { @@ -223,14 +199,13 @@ describe("plugin.loader.shared", () => { }, }) - const errors = await errs(tmp.path) + await load(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) - expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true) }) test("resolves npm plugin specs with explicit and default versions", async () => { @@ -383,8 +358,7 @@ describe("plugin.loader.shared", () => { const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - const errors = await errs(tmp.path) - expect(errors).toHaveLength(0) + await load(tmp.path) expect(await Bun.file(tmp.extra.mark).text()).toBe("called") } finally { install.mockRestore() @@ -436,8 +410,7 @@ describe("plugin.loader.shared", () => { const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - const errors = await errs(tmp.path) - expect(errors).toHaveLength(0) + await load(tmp.path) expect(await Bun.file(tmp.extra.mark).text()).toBe("called") } finally { install.mockRestore() @@ -482,14 +455,13 @@ describe("plugin.loader.shared", () => { const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - const errors = await errs(tmp.path) + await load(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) - expect(errors).toHaveLength(0) } finally { install.mockRestore() } @@ -546,13 +518,12 @@ describe("plugin.loader.shared", () => { const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - const errors = await errs(tmp.path) + await load(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) - expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true) } finally { install.mockRestore() } @@ -588,30 +559,49 @@ describe("plugin.loader.shared", () => { } }) - test("publishes session.error when install fails", async () => { + test("skips broken plugin when install fails", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2)) + const ok = path.join(dir, "ok.ts") + const mark = path.join(dir, "ok.txt") + await Bun.write( + ok, + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2), + ) + return { mark } }, }) const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) try { - const errors = await errs(tmp.path) - - expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe( - true, - ) + await load(tmp.path) + expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9") + expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") } finally { install.mockRestore() } }) - test("publishes session.error when plugin init throws", async () => { + test("continues loading plugins when plugin init throws", async () => { await using tmp = await tmpdir({ init: async (dir) => { const file = pathToFileURL(path.join(dir, "throws.ts")).href + const ok = pathToFileURL(path.join(dir, "ok.ts")).href + const mark = path.join(dir, "ok.txt") await Bun.write( path.join(dir, "throws.ts"), [ @@ -624,51 +614,91 @@ describe("plugin.loader.shared", () => { "", ].join("\n"), ) + await Bun.write( + path.join(dir, "ok.ts"), + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) - await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2)) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2)) - return { file } + return { mark } }, }) - const errors = await errs(tmp.path) - - expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true) + await load(tmp.path) + expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") }) - test("publishes session.error when plugin module has invalid export", async () => { + test("continues loading plugins when plugin module has invalid export", async () => { await using tmp = await tmpdir({ init: async (dir) => { const file = pathToFileURL(path.join(dir, "invalid.ts")).href + const ok = pathToFileURL(path.join(dir, "ok.ts")).href + const mark = path.join(dir, "ok.txt") await Bun.write( path.join(dir, "invalid.ts"), ["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"), ) + await Bun.write( + path.join(dir, "ok.ts"), + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) - await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2)) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2)) - return { file } + return { mark } }, }) - const errors = await errs(tmp.path) - - expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true) + await load(tmp.path) + expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") }) - test("publishes session.error when plugin import fails", async () => { + test("continues loading plugins when plugin import fails", async () => { await using tmp = await tmpdir({ init: async (dir) => { const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href - await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2)) + const ok = pathToFileURL(path.join(dir, "ok.ts")).href + const mark = path.join(dir, "ok.txt") + await Bun.write( + path.join(dir, "ok.ts"), + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2)) - return { missing } + return { mark } }, }) - const errors = await errs(tmp.path) - - expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true) + await load(tmp.path) + expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") }) test("loads object plugin via plugin.server", async () => { diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 7ba95f3b1e..fac3368376 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -147,7 +147,7 @@ describe("session messages endpoint", () => { describe("session.prompt_async error handling", () => { test("prompt_async route has error handler for detached prompt call", async () => { - const src = await Bun.file(new URL("../../src/server/routes/session.ts", import.meta.url)).text() + const src = await Bun.file(new URL("../../src/server/instance/session.ts", import.meta.url)).text() const start = src.indexOf('"/:sessionID/prompt_async"') const end = src.indexOf('"/:sessionID/command"', start) expect(start).toBeGreaterThan(-1)