6c80e2662c
This enables running the opencode server on standard Node.js runtimes without requiring Bun-specific APIs. Users can now deploy the server in more environments including standard Node.js containers and cloud platforms that don't support Bun.
689 lines
22 KiB
TypeScript
689 lines
22 KiB
TypeScript
import { BusEvent } from "@/bus/bus-event"
|
|
import { Bus } from "@/bus"
|
|
import { Log } from "../util/log"
|
|
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
|
import { Hono } from "hono"
|
|
import { cors } from "hono/cors"
|
|
import { streamSSE } from "hono/streaming"
|
|
import { proxy } from "hono/proxy"
|
|
import { basicAuth } from "hono/basic-auth"
|
|
import z from "zod"
|
|
import { Provider } from "../provider/provider"
|
|
import { NamedError } from "@opencode-ai/util/error"
|
|
import { LSP } from "../lsp"
|
|
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/skill"
|
|
import { Auth } from "../auth"
|
|
import { Flag } from "../flag/flag"
|
|
import { Command } from "../command"
|
|
import { Global } from "../global"
|
|
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"
|
|
import { ExperimentalRoutes } from "./routes/experimental"
|
|
import { ProviderRoutes } from "./routes/provider"
|
|
import { lazy } from "../util/lazy"
|
|
import { InstanceBootstrap } from "../project/bootstrap"
|
|
import { NotFoundError } from "../storage/db"
|
|
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
|
import { createAdaptorServer } 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"
|
|
import { QuestionRoutes } from "./routes/question"
|
|
import { PermissionRoutes } from "./routes/permission"
|
|
import { GlobalRoutes } from "./routes/global"
|
|
import { MDNS } from "./mdns"
|
|
|
|
// @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" })
|
|
|
|
let _url: URL | undefined
|
|
let _corsWhitelist: string[] = []
|
|
|
|
export function url(): URL {
|
|
return _url ?? new URL("http://localhost:4096")
|
|
}
|
|
|
|
const app = new Hono()
|
|
const ws = createNodeWebSocket({ app })
|
|
export const upgradeWebSocket = ws.upgradeWebSocket
|
|
export const App: () => Hono = lazy(
|
|
() =>
|
|
// TODO: Break server.ts into smaller route files to fix type inference
|
|
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,
|
|
})
|
|
})
|
|
.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,
|
|
})
|
|
}
|
|
const timer = log.time("request", {
|
|
method: c.req.method,
|
|
path: c.req.path,
|
|
})
|
|
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
|
|
|
|
// *.opencode.ai (https only, adjust if needed)
|
|
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
|
|
return input
|
|
}
|
|
if (_corsWhitelist.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
|
|
}
|
|
|
|
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.heartbeat",
|
|
properties: {},
|
|
}),
|
|
})
|
|
}, 10_000)
|
|
|
|
await new Promise<void>((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
|
|
}) as unknown as Hono,
|
|
)
|
|
|
|
export async function openapi() {
|
|
// Cast to break excessive type recursion from long route chains
|
|
const result = await generateSpecs(App() as Hono, {
|
|
documentation: {
|
|
info: {
|
|
title: "opencode",
|
|
version: "1.0.0",
|
|
description: "opencode api",
|
|
},
|
|
openapi: "3.1.1",
|
|
},
|
|
})
|
|
return result
|
|
}
|
|
|
|
export async function listen(opts: {
|
|
port: number
|
|
hostname: string
|
|
mdns?: boolean
|
|
mdnsDomain?: string
|
|
cors?: string[]
|
|
}) {
|
|
_corsWhitelist = opts.cors ?? []
|
|
|
|
const start = async (port: number) => {
|
|
const server = createAdaptorServer(App())
|
|
ws.injectWebSocket(server)
|
|
await new Promise<void>((resolve, reject) => {
|
|
const fail = (err: Error) => {
|
|
cleanup()
|
|
reject(err)
|
|
}
|
|
const ready = () => {
|
|
cleanup()
|
|
resolve()
|
|
}
|
|
const cleanup = () => {
|
|
server.off("error", fail)
|
|
server.off("listening", ready)
|
|
}
|
|
server.once("error", fail)
|
|
server.once("listening", ready)
|
|
server.listen(port, opts.hostname)
|
|
})
|
|
return server
|
|
}
|
|
|
|
const server = await (async () => {
|
|
if (opts.port !== 0) return start(opts.port)
|
|
try {
|
|
return await start(4096)
|
|
} catch {
|
|
return start(0)
|
|
}
|
|
})()
|
|
|
|
const addr = server.address()
|
|
if (!addr || typeof addr === "string") {
|
|
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
|
}
|
|
|
|
const url = new URL("http://localhost")
|
|
url.hostname = opts.hostname
|
|
url.port = String(addr.port)
|
|
_url = url
|
|
|
|
const shouldPublishMDNS =
|
|
opts.mdns &&
|
|
addr.port &&
|
|
opts.hostname !== "127.0.0.1" &&
|
|
opts.hostname !== "localhost" &&
|
|
opts.hostname !== "::1"
|
|
if (shouldPublishMDNS) {
|
|
MDNS.publish(addr.port, opts.mdnsDomain)
|
|
} else if (opts.mdns) {
|
|
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
|
}
|
|
|
|
let closing: Promise<void> | undefined
|
|
return {
|
|
hostname: opts.hostname,
|
|
port: addr.port,
|
|
url,
|
|
stop(close?: boolean) {
|
|
closing ??= new Promise<void>((resolve, reject) => {
|
|
if (shouldPublishMDNS) MDNS.unpublish()
|
|
server.close((err?: Error) => {
|
|
if (err) {
|
|
reject(err)
|
|
return
|
|
}
|
|
resolve()
|
|
})
|
|
if (close) {
|
|
const node = server as { closeAllConnections?: () => void; closeIdleConnections?: () => void }
|
|
node.closeAllConnections?.()
|
|
node.closeIdleConnections?.()
|
|
}
|
|
})
|
|
return closing
|
|
},
|
|
}
|
|
}
|
|
}
|