diff --git a/packages/opencode/package.json b/packages/opencode/package.json index eeabdb4023..50e7a57775 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -88,6 +88,8 @@ "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/standard-validator": "0.1.5", + "@hono/node-server": "1.19.11", + "@hono/node-ws": "1.3.0", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", "@npmcli/arborist": "9.4.0", diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 99a9a81ab9..2fb9038b0f 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -23,7 +23,7 @@ export const AcpCommand = cmd({ process.env.OPENCODE_CLIENT = "acp" await bootstrap(process.cwd(), async () => { const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) + const server = await Server.listen(opts) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index ab51fe8c3e..73e7a18a70 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -15,7 +15,7 @@ export const ServeCommand = cmd({ console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) + const server = await Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) await new Promise(() => {}) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 408350c520..511182fe85 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" import { GlobalBus } from "@/bus/global" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" -import type { BunWebSocketData } from "hono/bun" import { Flag } from "@/flag/flag" import { setTimeout as sleep } from "node:timers/promises" @@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => { Rpc.emit("global.event", event) }) -let server: Bun.Server | undefined +let server: Awaited> | undefined const eventStream = { abort: undefined as AbortController | undefined, @@ -120,7 +119,7 @@ export const rpc = { }, async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { if (server) await server.stop(true) - server = Server.listen(input) + server = await Server.listen(input) return { url: server.url.toString() } }, async checkUpgrade(input: { directory: string }) { @@ -143,7 +142,7 @@ export const rpc = { Log.Default.info("worker shutting down") if (eventStream.abort) eventStream.abort.abort() await Instance.disposeAll() - if (server) server.stop(true) + if (server) await server.stop(true) }, } diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 0fe056f21f..e656c83d9a 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -37,7 +37,7 @@ export const WebCommand = cmd({ UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) + const server = await Server.listen(opts) UI.empty() UI.println(UI.logo(" ")) UI.empty() diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 1c27d5b847..791062aaff 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -23,6 +23,8 @@ export namespace Pty { close: (code?: number, reason?: string) => void } + const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws) + // WebSocket control frame: 0x00 + UTF-8 JSON. const meta = (cursor: number) => { const json = JSON.stringify({ cursor }) @@ -97,9 +99,9 @@ export namespace Pty { try { session.process.kill() } catch {} - for (const [key, ws] of session.subscribers.entries()) { + for (const [id, ws] of session.subscribers.entries()) { try { - if (ws.data === key) ws.close() + if (key(ws) === id) ws.close() } catch { // ignore } @@ -170,21 +172,21 @@ export namespace Pty { ptyProcess.onData((chunk) => { session.cursor += chunk.length - for (const [key, ws] of session.subscribers.entries()) { + for (const [id, ws] of session.subscribers.entries()) { if (ws.readyState !== 1) { - session.subscribers.delete(key) + session.subscribers.delete(id) continue } - if (ws.data !== key) { - session.subscribers.delete(key) + if (key(ws) !== id) { + session.subscribers.delete(id) continue } try { ws.send(chunk) } catch { - session.subscribers.delete(key) + session.subscribers.delete(id) } } @@ -226,9 +228,9 @@ export namespace Pty { try { session.process.kill() } catch {} - for (const [key, ws] of session.subscribers.entries()) { + for (const [id, ws] of session.subscribers.entries()) { try { - if (ws.data === key) ws.close() + if (key(ws) === id) ws.close() } catch { // ignore } @@ -259,16 +261,13 @@ export namespace Pty { } log.info("client connected to session", { id }) - // Use ws.data as the unique key for this connection lifecycle. - // If ws.data is undefined, fallback to ws object. - const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws + const sub = key(ws) - // Optionally cleanup if the key somehow exists - session.subscribers.delete(connectionKey) - session.subscribers.set(connectionKey, ws) + session.subscribers.delete(sub) + session.subscribers.set(sub, ws) const cleanup = () => { - session.subscribers.delete(connectionKey) + session.subscribers.delete(sub) } const start = session.bufferCursor diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts index 368c9612bf..4e74fc9277 100644 --- a/packages/opencode/src/server/routes/pty.ts +++ b/packages/opencode/src/server/routes/pty.ts @@ -1,14 +1,13 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" -import { upgradeWebSocket } from "hono/bun" +import type { UpgradeWebSocket } from "hono/ws" import z from "zod" import { Pty } from "@/pty" import { NotFoundError } from "../../storage/db" import { errors } from "../error" -import { lazy } from "../../util/lazy" -export const PtyRoutes = lazy(() => - new Hono() +export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { + return new Hono() .get( "/", describeRoute({ @@ -196,5 +195,5 @@ export const PtyRoutes = lazy(() => }, } }), - ), -) + ) +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 180d8258c6..601dc85a8f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -25,6 +25,7 @@ import { WorkspaceContext } from "../control-plane/workspace-context" import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware" 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" @@ -33,7 +34,8 @@ import { ProviderRoutes } from "./routes/provider" import { InstanceBootstrap } from "../project/bootstrap" import { NotFoundError } from "../storage/db" import type { ContentfulStatusCode } from "hono/utils/http-status" -import { websocket } from "hono/bun" +import { createAdaptorServer, type ServerType } from "@hono/node-server" +import { createNodeWebSocket } from "@hono/node-ws" import { HTTPException } from "hono/http-exception" import { errors } from "./error" import { Filesystem } from "@/util/filesystem" @@ -42,536 +44,543 @@ import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" -// import { PtyRoutes } from "./routes/pty" // @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 export namespace Server { - const log = Log.create({ service: "server" }) + export type Listener = { + hostname: string + port: number + url: URL + stop: (close?: boolean) => Promise + } - export const Default = lazy(() => createApp({})) + export const Default = lazy(() => create({}).app) - export const createApp = (opts: { cors?: string[] }): Hono => { + function create(opts: { cors?: string[] }) { + const log = Log.create({ service: "server" }) const app = new Hono() - return ( - app - .onError((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.startsWith("Worktree")) status = 400 - else status = 500 - return c.json(err.toObject(), { status }) - } - 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 ws = createNodeWebSocket({ app }) + const route = app + .onError((err, c) => { + log.error("failed", { + error: err, }) - .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" - return basicAuth({ username, password })(c, next) + 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.startsWith("Worktree")) status = 400 + else status = 500 + return c.json(err.toObject(), { status }) + } + 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, }) - .use(async (c, next) => { - const skipLogging = c.req.path === "/log" - if (!skipLogging) { - log.info("request", { - method: c.req.method, - path: c.req.path, - }) - } - const timer = log.time("request", { + }) + .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" + return basicAuth({ username, password })(c, next) + }) + .use(async (c, next) => { + const skipLogging = c.req.path === "/log" + if (!skipLogging) { + log.info("request", { method: c.req.method, path: c.req.path, }) - await next() - if (!skipLogging) { - timer.stop() - } + } + const timer = log.time("request", { + method: c.req.method, + path: c.req.path, }) - .use( - cors({ - origin(input) { - if (!input) return + await next() + if (!skipLogging) { + timer.stop() + } + }) + .use( + cors({ + 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 (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 - // *.opencode.ai (https only, adjust if needed) - if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { - return input - } - if (opts?.cors?.includes(input)) { - return input - } - - return - }, - }), - ) - .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: z.string(), - }), - ), - validator("json", Auth.Info), - 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: z.string(), - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - await Auth.remove(providerID) - return c.json(true) - }, - ) - .use(async (c, next) => { - if (c.req.path === "/log") return next() - const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") - const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = Filesystem.resolve( - (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })(), - ) - - return WorkspaceContext.provide({ - workspaceID, - async fn() { - return Instance.provide({ - directory, - init: InstanceBootstrap, - async fn() { - return next() - }, - }) - }, - }) - }) - .use(WorkspaceRouterMiddleware) - .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(), - }), - ), - ) - .route("/project", ProjectRoutes()) - // .route("/pty", PtyRoutes()) - .route("/config", ConfigRoutes()) - .route("/experimental", ExperimentalRoutes()) - .route("/session", SessionRoutes()) - .route("/permission", PermissionRoutes()) - .route("/question", QuestionRoutes()) - .route("/provider", ProviderRoutes()) - .route("/", FileRoutes()) - .route("/mcp", McpRoutes()) - .route("/tui", TuiRoutes()) - .post( - "/instance/dispose", - describeRoute({ - summary: "Dispose instance", - description: "Clean up and dispose the current OpenCode instance, releasing all resources.", - operationId: "instance.dispose", - responses: { - 200: { - description: "Instance disposed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Instance.dispose() - return c.json(true) - }, - ) - .get( - "/path", - describeRoute({ - summary: "Get paths", - description: - "Retrieve the current working directory and related path information for the OpenCode instance.", - operationId: "path.get", - responses: { - 200: { - description: "Path", - content: { - "application/json": { - schema: resolver( - z - .object({ - home: z.string(), - state: z.string(), - config: z.string(), - worktree: z.string(), - directory: z.string(), - }) - .meta({ - ref: "Path", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - return c.json({ - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: Instance.worktree, - directory: Instance.directory, - }) - }, - ) - .get( - "/vcs", - describeRoute({ - summary: "Get VCS info", - description: - "Retrieve version control system (VCS) information for the current project, such as git branch.", - operationId: "vcs.get", - responses: { - 200: { - description: "VCS info", - content: { - "application/json": { - schema: resolver(Vcs.Info), - }, - }, - }, - }, - }), - async (c) => { - const branch = await Vcs.branch() - return c.json({ - branch, - }) - }, - ) - .get( - "/command", - describeRoute({ - summary: "List commands", - description: "Get a list of all available commands in the OpenCode system.", - operationId: "command.list", - responses: { - 200: { - description: "List of commands", - content: { - "application/json": { - schema: resolver(Command.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const commands = await Command.list() - return c.json(commands) - }, - ) - .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 + // *.opencode.ai (https only, adjust if needed) + if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { + return input + } + if (opts?.cors?.includes(input)) { + return input } - return c.json(true) + return }, - ) - .get( - "/agent", - describeRoute({ - summary: "List agents", - description: "Get a list of all available AI agents in the OpenCode system.", - operationId: "app.agents", - responses: { - 200: { - description: "List of agents", - content: { - "application/json": { - schema: resolver(Agent.Info.array()), - }, + }), + ) + .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()), }, }, }, - }), - async (c) => { - const modes = await Agent.list() - return c.json(modes) + ...errors(400), }, - ) - .get( - "/skill", - describeRoute({ - summary: "List skills", - description: "Get a list of all available skills in the OpenCode system.", - operationId: "app.skills", - responses: { - 200: { - description: "List of skills", - content: { - "application/json": { - schema: resolver(Skill.Info.array()), - }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + validator("json", Auth.Info), + 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()), }, }, }, - }), - async (c) => { - const skills = await Skill.all() - return c.json(skills) + ...errors(400), }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + await Auth.remove(providerID) + return c.json(true) + }, + ) + .use(async (c, next) => { + if (c.req.path === "/log") return next() + const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") + const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + const directory = Filesystem.resolve( + (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })(), ) - .get( - "/lsp", - describeRoute({ - summary: "Get LSP status", - description: "Get LSP server status", - operationId: "lsp.status", - responses: { - 200: { - description: "LSP server status", - content: { - "application/json": { - schema: resolver(LSP.Status.array()), - }, + + return WorkspaceContext.provide({ + workspaceID, + async fn() { + return Instance.provide({ + directory, + init: InstanceBootstrap, + async fn() { + return next() + }, + }) + }, + }) + }) + .use(WorkspaceRouterMiddleware) + .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(), + }), + ), + ) + .route("/project", ProjectRoutes()) + .route("/config", ConfigRoutes()) + .route("/experimental", ExperimentalRoutes()) + .route("/session", SessionRoutes()) + .route("/permission", PermissionRoutes()) + .route("/question", QuestionRoutes()) + .route("/provider", ProviderRoutes()) + .route("/", FileRoutes()) + .route("/mcp", McpRoutes()) + .route("/tui", TuiRoutes()) + .post( + "/instance/dispose", + describeRoute({ + summary: "Dispose instance", + description: "Clean up and dispose the current OpenCode instance, releasing all resources.", + operationId: "instance.dispose", + responses: { + 200: { + description: "Instance disposed", + content: { + "application/json": { + schema: resolver(z.boolean()), }, }, }, - }), - async (c) => { - return c.json(await LSP.status()) }, - ) - .get( - "/formatter", - describeRoute({ - summary: "Get formatter status", - description: "Get formatter status", - operationId: "formatter.status", - responses: { - 200: { - description: "Formatter status", - content: { - "application/json": { - schema: resolver(Format.Status.array()), - }, + }), + async (c) => { + await Instance.dispose() + return c.json(true) + }, + ) + .get( + "/path", + describeRoute({ + summary: "Get paths", + description: "Retrieve the current working directory and related path information for the OpenCode instance.", + operationId: "path.get", + responses: { + 200: { + description: "Path", + content: { + "application/json": { + schema: resolver( + z + .object({ + home: z.string(), + state: z.string(), + config: z.string(), + worktree: z.string(), + directory: z.string(), + }) + .meta({ + ref: "Path", + }), + ), }, }, }, - }), - async (c) => { - return c.json(await Format.status()) }, - ) - .get( - "/event", - describeRoute({ - summary: "Subscribe to events", - description: "Get events", - operationId: "event.subscribe", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver(BusEvent.payloads()), - }, + }), + async (c) => { + return c.json({ + home: Global.Path.home, + state: Global.Path.state, + config: Global.Path.config, + worktree: Instance.worktree, + directory: Instance.directory, + }) + }, + ) + .get( + "/vcs", + describeRoute({ + summary: "Get VCS info", + description: "Retrieve version control system (VCS) information for the current project, such as git branch.", + operationId: "vcs.get", + responses: { + 200: { + description: "VCS info", + content: { + "application/json": { + schema: resolver(Vcs.Info), }, }, }, + }, + }), + async (c) => { + const branch = await Vcs.branch() + return c.json({ + branch, + }) + }, + ) + .get( + "/command", + describeRoute({ + summary: "List commands", + description: "Get a list of all available commands in the OpenCode system.", + operationId: "command.list", + responses: { + 200: { + description: "List of commands", + content: { + "application/json": { + schema: resolver(Command.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const commands = await Command.list() + return c.json(commands) + }, + ) + .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) => { - log.info("event connected") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - return streamSSE(c, async (stream) => { + ), + 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) + }, + ) + .get( + "/agent", + describeRoute({ + summary: "List agents", + description: "Get a list of all available AI agents in the OpenCode system.", + operationId: "app.agents", + responses: { + 200: { + description: "List of agents", + content: { + "application/json": { + schema: resolver(Agent.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const modes = await Agent.list() + return c.json(modes) + }, + ) + .get( + "/skill", + describeRoute({ + summary: "List skills", + description: "Get a list of all available skills in the OpenCode system.", + operationId: "app.skills", + responses: { + 200: { + description: "List of skills", + content: { + "application/json": { + schema: resolver(Skill.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const skills = await Skill.all() + return c.json(skills) + }, + ) + .get( + "/lsp", + describeRoute({ + summary: "Get LSP status", + description: "Get LSP server status", + operationId: "lsp.status", + responses: { + 200: { + description: "LSP server status", + content: { + "application/json": { + schema: resolver(LSP.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await LSP.status()) + }, + ) + .get( + "/formatter", + describeRoute({ + summary: "Get formatter status", + description: "Get formatter status", + operationId: "formatter.status", + responses: { + 200: { + description: "Formatter status", + content: { + "application/json": { + schema: resolver(Format.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Format.status()) + }, + ) + .get( + "/event", + describeRoute({ + summary: "Subscribe to events", + description: "Get events", + operationId: "event.subscribe", + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: resolver(BusEvent.payloads()), + }, + }, + }, + }, + }), + async (c) => { + log.info("event connected") + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + return streamSSE(c, async (stream) => { + stream.writeSSE({ + data: JSON.stringify({ + type: "server.connected", + properties: {}, + }), + }) + const unsub = Bus.subscribeAll(async (event) => { + await stream.writeSSE({ + data: JSON.stringify(event), + }) + if (event.type === Bus.InstanceDisposed.type) { + stream.close() + } + }) + + // Send heartbeat every 10s to prevent stalled proxy streams. + const heartbeat = setInterval(() => { stream.writeSSE({ data: JSON.stringify({ - type: "server.connected", + type: "server.heartbeat", properties: {}, }), }) - const unsub = Bus.subscribeAll(async (event) => { - await stream.writeSSE({ - data: JSON.stringify(event), - }) - if (event.type === Bus.InstanceDisposed.type) { - stream.close() - } - }) + }, 10_000) - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { - stream.writeSSE({ - data: JSON.stringify({ - type: "server.heartbeat", - properties: {}, - }), - }) - }, 10_000) - - await new Promise((resolve) => { - stream.onAbort(() => { - clearInterval(heartbeat) - unsub() - resolve() - log.info("event disconnected") - }) + await new Promise((resolve) => { + stream.onAbort(() => { + clearInterval(heartbeat) + unsub() + resolve() + log.info("event disconnected") }) }) - }, - ) - .all("/*", async (c) => { - const path = c.req.path - - const response = await proxy(`https://app.opencode.ai${path}`, { - ...c.req, - headers: { - ...c.req.raw.headers, - host: "app.opencode.ai", - }, }) - response.headers.set( - "Content-Security-Policy", - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:", - ) - return response + }, + ) + .route("/pty", PtyRoutes(ws.upgradeWebSocket)) + .all("/*", async (c) => { + const path = c.req.path + + const response = await proxy(`https://app.opencode.ai${path}`, { + ...c.req, + headers: { + ...c.req.raw.headers, + host: "app.opencode.ai", + }, }) - ) + response.headers.set( + "Content-Security-Policy", + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:", + ) + return response + }) + + return { + app: route as Hono, + ws, + } } export async function openapi() { @@ -589,48 +598,86 @@ export namespace Server { return result } - export function listen(opts: { + export async function listen(opts: { port: number hostname: string mdns?: boolean mdnsDomain?: string cors?: string[] - }) { - const app = createApp(opts) - const args = { - hostname: opts.hostname, - idleTimeout: 0, - fetch: app.fetch, - websocket: websocket, - } as const - const tryServe = (port: number) => { - try { - return Bun.serve({ ...args, port }) - } catch { - return undefined - } + }): Promise { + const log = Log.create({ service: "server" }) + 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 = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) - if (!server) throw new Error(`Failed to start server on port ${opts.port}`) + + const url = new URL("http://localhost") + url.hostname = opts.hostname + url.port = String(addr.port) const shouldPublishMDNS = opts.mdns && - server.port && + addr.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!, opts.mdnsDomain) + MDNS.publish(addr.port, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } - const originalStop = server.stop.bind(server) - server.stop = async (closeActiveConnections?: boolean) => { - if (shouldPublishMDNS) MDNS.unpublish() - return originalStop(closeActiveConnections) + let closing: Promise | undefined + return { + hostname: opts.hostname, + port: addr.port, + url, + stop(close?: boolean) { + closing ??= new Promise((resolve, reject) => { + if (shouldPublishMDNS) 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() + } + } + }) + return closing + }, } - - return server } }