summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2026-04-11 16:55:17 -0400
committerGitHub <[email protected]>2026-04-11 16:55:17 -0400
commitca5f08675955ac5d10129f44afda36e006ae8d44 (patch)
tree02cbf0edbd5d17a356fc36737339ca9bcc041049
parent57c40eb7c29c33308ab7fce5384ea141a538bce6 (diff)
downloadopencode-ca5f08675955ac5d10129f44afda36e006ae8d44.tar.gz
opencode-ca5f08675955ac5d10129f44afda36e006ae8d44.zip
refactor(server): simplify router middleware with next() (#21720)
-rw-r--r--packages/opencode/package.json5
-rw-r--r--packages/opencode/src/agent/agent.ts12
-rw-r--r--packages/opencode/src/cli/cmd/tui/thread.ts10
-rw-r--r--packages/opencode/src/config/config.ts1
-rw-r--r--packages/opencode/src/plugin/index.ts18
-rw-r--r--packages/opencode/src/server/adapter.bun.ts40
-rw-r--r--packages/opencode/src/server/adapter.node.ts66
-rw-r--r--packages/opencode/src/server/adapter.ts21
-rw-r--r--packages/opencode/src/server/control/index.ts150
-rw-r--r--packages/opencode/src/server/instance/config.ts (renamed from packages/opencode/src/server/routes/config.ts)0
-rw-r--r--packages/opencode/src/server/instance/event.ts (renamed from packages/opencode/src/server/routes/event.ts)0
-rw-r--r--packages/opencode/src/server/instance/experimental.ts (renamed from packages/opencode/src/server/routes/experimental.ts)0
-rw-r--r--packages/opencode/src/server/instance/file.ts (renamed from packages/opencode/src/server/routes/file.ts)0
-rw-r--r--packages/opencode/src/server/instance/global.ts (renamed from packages/opencode/src/server/routes/global.ts)0
-rw-r--r--packages/opencode/src/server/instance/index.ts (renamed from packages/opencode/src/server/instance.ts)104
-rw-r--r--packages/opencode/src/server/instance/mcp.ts (renamed from packages/opencode/src/server/routes/mcp.ts)0
-rw-r--r--packages/opencode/src/server/instance/middleware.ts (renamed from packages/opencode/src/server/router.ts)16
-rw-r--r--packages/opencode/src/server/instance/permission.ts (renamed from packages/opencode/src/server/routes/permission.ts)0
-rw-r--r--packages/opencode/src/server/instance/project.ts (renamed from packages/opencode/src/server/routes/project.ts)0
-rw-r--r--packages/opencode/src/server/instance/provider.ts (renamed from packages/opencode/src/server/routes/provider.ts)0
-rw-r--r--packages/opencode/src/server/instance/pty.ts (renamed from packages/opencode/src/server/routes/pty.ts)0
-rw-r--r--packages/opencode/src/server/instance/question.ts (renamed from packages/opencode/src/server/routes/question.ts)0
-rw-r--r--packages/opencode/src/server/instance/session.ts (renamed from packages/opencode/src/server/routes/session.ts)0
-rw-r--r--packages/opencode/src/server/instance/tui.ts (renamed from packages/opencode/src/server/routes/tui.ts)0
-rw-r--r--packages/opencode/src/server/instance/workspace.ts (renamed from packages/opencode/src/server/routes/workspace.ts)0
-rw-r--r--packages/opencode/src/server/middleware.ts105
-rw-r--r--packages/opencode/src/server/server.ts303
-rw-r--r--packages/opencode/src/server/ui/index.ts55
-rw-r--r--packages/opencode/test/memory/abort-leak-webfetch.ts49
-rw-r--r--packages/opencode/test/memory/abort-leak.test.ts127
-rw-r--r--packages/opencode/test/plugin/loader-shared.test.ts150
-rw-r--r--packages/opencode/test/server/session-messages.test.ts2
32 files changed, 767 insertions, 467 deletions
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 99b60d1e9..18feb4675 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 93b393f13..fd9ac43e8 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 0534b147a..972e67d10 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<typeof rpc>(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 086d51abd..ecce8fb8f 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 5de77aee3..e0478e0b3 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 000000000..3e70b97e8
--- /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 000000000..9c2a41cce
--- /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<ServerType>((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<void> | 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 000000000..272521d7d
--- /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<void>
+}
+
+export interface Runtime {
+ upgradeWebSocket: UpgradeWebSocket
+ listen(opts: Opts): Promise<Listener>
+}
+
+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 000000000..aae77f2f0
--- /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
index 85d28f6aa..85d28f6aa 100644
--- a/packages/opencode/src/server/routes/config.ts
+++ b/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
index 989b85771..989b85771 100644
--- a/packages/opencode/src/server/routes/event.ts
+++ b/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
index 464617c69..464617c69 100644
--- a/packages/opencode/src/server/routes/experimental.ts
+++ b/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
index 60789ef4b..60789ef4b 100644
--- a/packages/opencode/src/server/routes/file.ts
+++ b/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
index 6b0a9a164..6b0a9a164 100644
--- a/packages/opencode/src/server/routes/global.ts
+++ b/packages/opencode/src/server/instance/global.ts
diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance/index.ts
index 6525d2ded..2acc424e4 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<string, string>).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(
- /<script\b(?![^>]*\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
index 1e604c991..1e604c991 100644
--- a/packages/opencode/src/server/routes/mcp.ts
+++ b/packages/opencode/src/server/instance/mcp.ts
diff --git a/packages/opencode/src/server/router.ts b/packages/opencode/src/server/instance/middleware.ts
index f97724c2e..1a5011477 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
index aae9a9c3a..aae9a9c3a 100644
--- a/packages/opencode/src/server/routes/permission.ts
+++ b/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
index e5dd5782d..e5dd5782d 100644
--- a/packages/opencode/src/server/routes/project.ts
+++ b/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
index efd126ea0..efd126ea0 100644
--- a/packages/opencode/src/server/routes/provider.ts
+++ b/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
index c333f4dd6..c333f4dd6 100644
--- a/packages/opencode/src/server/routes/pty.ts
+++ b/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
index 3fff895fa..3fff895fa 100644
--- a/packages/opencode/src/server/routes/question.ts
+++ b/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
index a2a15d59e..a2a15d59e 100644
--- a/packages/opencode/src/server/routes/session.ts
+++ b/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
index 8650a0ccc..8650a0ccc 100644
--- a/packages/opencode/src/server/routes/tui.ts
+++ b/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
index 419321654..419321654 100644
--- a/packages/opencode/src/server/routes/workspace.ts
+++ b/packages/opencode/src/server/instance/workspace.ts
diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts
index 278740c57..a51ba602b 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 c4f2a931b..02ec7356e 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<void>
}
- 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<Listener> {
const built = create(opts)
- const start = (port: number) =>
- new Promise<ServerType>((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<void> | 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 000000000..afe6e510f
--- /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<string, string>).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(
+ /<script\b(?![^>]*\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 000000000..1286d5f0b
--- /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 000000000..d30ad45e4
--- /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<string, () => 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 c01a02ef4..32b6c601d 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: ["[email protected]"] }, 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: ["[email protected]", 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 [email protected]") && x.includes("boom"))).toBe(
- true,
- )
+ await load(tmp.path)
+ expect(install).toHaveBeenCalledWith("[email protected]")
+ 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 7ba95f3b1..fac336837 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)