server.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. import { BusEvent } from "@/bus/bus-event"
  2. import { Bus } from "@/bus"
  3. import { Log } from "../util/log"
  4. import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
  5. import { Hono } from "hono"
  6. import { cors } from "hono/cors"
  7. import { streamSSE } from "hono/streaming"
  8. import { proxy } from "hono/proxy"
  9. import { basicAuth } from "hono/basic-auth"
  10. import z from "zod"
  11. import { Provider } from "../provider/provider"
  12. import { NamedError } from "@opencode-ai/util/error"
  13. import { LSP } from "../lsp"
  14. import { Format } from "../format"
  15. import { TuiRoutes } from "./routes/tui"
  16. import { Instance } from "../project/instance"
  17. import { Vcs } from "../project/vcs"
  18. import { Agent } from "../agent/agent"
  19. import { Skill } from "../skill/skill"
  20. import { Auth } from "../auth"
  21. import { Flag } from "../flag/flag"
  22. import { Command } from "../command"
  23. import { Global } from "../global"
  24. import { WorkspaceContext } from "../control-plane/workspace-context"
  25. import { ProjectRoutes } from "./routes/project"
  26. import { SessionRoutes } from "./routes/session"
  27. import { PtyRoutes } from "./routes/pty"
  28. import { McpRoutes } from "./routes/mcp"
  29. import { FileRoutes } from "./routes/file"
  30. import { ConfigRoutes } from "./routes/config"
  31. import { ExperimentalRoutes } from "./routes/experimental"
  32. import { ProviderRoutes } from "./routes/provider"
  33. import { lazy } from "../util/lazy"
  34. import { InstanceBootstrap } from "../project/bootstrap"
  35. import { NotFoundError } from "../storage/db"
  36. import type { ContentfulStatusCode } from "hono/utils/http-status"
  37. import { websocket } from "hono/bun"
  38. import { HTTPException } from "hono/http-exception"
  39. import { errors } from "./error"
  40. import { QuestionRoutes } from "./routes/question"
  41. import { PermissionRoutes } from "./routes/permission"
  42. import { GlobalRoutes } from "./routes/global"
  43. import { MDNS } from "./mdns"
  44. // @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
  45. globalThis.AI_SDK_LOG_WARNINGS = false
  46. export namespace Server {
  47. const log = Log.create({ service: "server" })
  48. let _url: URL | undefined
  49. let _corsWhitelist: string[] = []
  50. export function url(): URL {
  51. return _url ?? new URL("http://localhost:4096")
  52. }
  53. const app = new Hono()
  54. export const App: () => Hono = lazy(
  55. () =>
  56. // TODO: Break server.ts into smaller route files to fix type inference
  57. app
  58. .onError((err, c) => {
  59. log.error("failed", {
  60. error: err,
  61. })
  62. if (err instanceof NamedError) {
  63. let status: ContentfulStatusCode
  64. if (err instanceof NotFoundError) status = 404
  65. else if (err instanceof Provider.ModelNotFoundError) status = 400
  66. else if (err.name.startsWith("Worktree")) status = 400
  67. else status = 500
  68. return c.json(err.toObject(), { status })
  69. }
  70. if (err instanceof HTTPException) return err.getResponse()
  71. const message = err instanceof Error && err.stack ? err.stack : err.toString()
  72. return c.json(new NamedError.Unknown({ message }).toObject(), {
  73. status: 500,
  74. })
  75. })
  76. .use((c, next) => {
  77. // Allow CORS preflight requests to succeed without auth.
  78. // Browser clients sending Authorization headers will preflight with OPTIONS.
  79. if (c.req.method === "OPTIONS") return next()
  80. const password = Flag.OPENCODE_SERVER_PASSWORD
  81. if (!password) return next()
  82. const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
  83. return basicAuth({ username, password })(c, next)
  84. })
  85. .use(async (c, next) => {
  86. const skipLogging = c.req.path === "/log"
  87. if (!skipLogging) {
  88. log.info("request", {
  89. method: c.req.method,
  90. path: c.req.path,
  91. })
  92. }
  93. const timer = log.time("request", {
  94. method: c.req.method,
  95. path: c.req.path,
  96. })
  97. await next()
  98. if (!skipLogging) {
  99. timer.stop()
  100. }
  101. })
  102. .use(
  103. cors({
  104. origin(input) {
  105. if (!input) return
  106. if (input.startsWith("http://localhost:")) return input
  107. if (input.startsWith("http://127.0.0.1:")) return input
  108. if (
  109. input === "tauri://localhost" ||
  110. input === "http://tauri.localhost" ||
  111. input === "https://tauri.localhost"
  112. )
  113. return input
  114. // *.opencode.ai (https only, adjust if needed)
  115. if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
  116. return input
  117. }
  118. if (_corsWhitelist.includes(input)) {
  119. return input
  120. }
  121. return
  122. },
  123. }),
  124. )
  125. .route("/global", GlobalRoutes())
  126. .put(
  127. "/auth/:providerID",
  128. describeRoute({
  129. summary: "Set auth credentials",
  130. description: "Set authentication credentials",
  131. operationId: "auth.set",
  132. responses: {
  133. 200: {
  134. description: "Successfully set authentication credentials",
  135. content: {
  136. "application/json": {
  137. schema: resolver(z.boolean()),
  138. },
  139. },
  140. },
  141. ...errors(400),
  142. },
  143. }),
  144. validator(
  145. "param",
  146. z.object({
  147. providerID: z.string(),
  148. }),
  149. ),
  150. validator("json", Auth.Info),
  151. async (c) => {
  152. const providerID = c.req.valid("param").providerID
  153. const info = c.req.valid("json")
  154. await Auth.set(providerID, info)
  155. return c.json(true)
  156. },
  157. )
  158. .delete(
  159. "/auth/:providerID",
  160. describeRoute({
  161. summary: "Remove auth credentials",
  162. description: "Remove authentication credentials",
  163. operationId: "auth.remove",
  164. responses: {
  165. 200: {
  166. description: "Successfully removed authentication credentials",
  167. content: {
  168. "application/json": {
  169. schema: resolver(z.boolean()),
  170. },
  171. },
  172. },
  173. ...errors(400),
  174. },
  175. }),
  176. validator(
  177. "param",
  178. z.object({
  179. providerID: z.string(),
  180. }),
  181. ),
  182. async (c) => {
  183. const providerID = c.req.valid("param").providerID
  184. await Auth.remove(providerID)
  185. return c.json(true)
  186. },
  187. )
  188. .use(async (c, next) => {
  189. if (c.req.path === "/log") return next()
  190. const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
  191. const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
  192. const directory = (() => {
  193. try {
  194. return decodeURIComponent(raw)
  195. } catch {
  196. return raw
  197. }
  198. })()
  199. return WorkspaceContext.provide({
  200. workspaceID,
  201. async fn() {
  202. return Instance.provide({
  203. directory,
  204. init: InstanceBootstrap,
  205. async fn() {
  206. return next()
  207. },
  208. })
  209. },
  210. })
  211. })
  212. .get(
  213. "/doc",
  214. openAPIRouteHandler(app, {
  215. documentation: {
  216. info: {
  217. title: "opencode",
  218. version: "0.0.3",
  219. description: "opencode api",
  220. },
  221. openapi: "3.1.1",
  222. },
  223. }),
  224. )
  225. .use(
  226. validator(
  227. "query",
  228. z.object({
  229. directory: z.string().optional(),
  230. workspace: z.string().optional(),
  231. }),
  232. ),
  233. )
  234. .route("/project", ProjectRoutes())
  235. .route("/pty", PtyRoutes())
  236. .route("/config", ConfigRoutes())
  237. .route("/experimental", ExperimentalRoutes())
  238. .route("/session", SessionRoutes())
  239. .route("/permission", PermissionRoutes())
  240. .route("/question", QuestionRoutes())
  241. .route("/provider", ProviderRoutes())
  242. .route("/", FileRoutes())
  243. .route("/mcp", McpRoutes())
  244. .route("/tui", TuiRoutes())
  245. .post(
  246. "/instance/dispose",
  247. describeRoute({
  248. summary: "Dispose instance",
  249. description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
  250. operationId: "instance.dispose",
  251. responses: {
  252. 200: {
  253. description: "Instance disposed",
  254. content: {
  255. "application/json": {
  256. schema: resolver(z.boolean()),
  257. },
  258. },
  259. },
  260. },
  261. }),
  262. async (c) => {
  263. await Instance.dispose()
  264. return c.json(true)
  265. },
  266. )
  267. .get(
  268. "/path",
  269. describeRoute({
  270. summary: "Get paths",
  271. description:
  272. "Retrieve the current working directory and related path information for the OpenCode instance.",
  273. operationId: "path.get",
  274. responses: {
  275. 200: {
  276. description: "Path",
  277. content: {
  278. "application/json": {
  279. schema: resolver(
  280. z
  281. .object({
  282. home: z.string(),
  283. state: z.string(),
  284. config: z.string(),
  285. worktree: z.string(),
  286. directory: z.string(),
  287. })
  288. .meta({
  289. ref: "Path",
  290. }),
  291. ),
  292. },
  293. },
  294. },
  295. },
  296. }),
  297. async (c) => {
  298. return c.json({
  299. home: Global.Path.home,
  300. state: Global.Path.state,
  301. config: Global.Path.config,
  302. worktree: Instance.worktree,
  303. directory: Instance.directory,
  304. })
  305. },
  306. )
  307. .get(
  308. "/vcs",
  309. describeRoute({
  310. summary: "Get VCS info",
  311. description:
  312. "Retrieve version control system (VCS) information for the current project, such as git branch.",
  313. operationId: "vcs.get",
  314. responses: {
  315. 200: {
  316. description: "VCS info",
  317. content: {
  318. "application/json": {
  319. schema: resolver(Vcs.Info),
  320. },
  321. },
  322. },
  323. },
  324. }),
  325. async (c) => {
  326. const branch = await Vcs.branch()
  327. return c.json({
  328. branch,
  329. })
  330. },
  331. )
  332. .get(
  333. "/command",
  334. describeRoute({
  335. summary: "List commands",
  336. description: "Get a list of all available commands in the OpenCode system.",
  337. operationId: "command.list",
  338. responses: {
  339. 200: {
  340. description: "List of commands",
  341. content: {
  342. "application/json": {
  343. schema: resolver(Command.Info.array()),
  344. },
  345. },
  346. },
  347. },
  348. }),
  349. async (c) => {
  350. const commands = await Command.list()
  351. return c.json(commands)
  352. },
  353. )
  354. .post(
  355. "/log",
  356. describeRoute({
  357. summary: "Write log",
  358. description: "Write a log entry to the server logs with specified level and metadata.",
  359. operationId: "app.log",
  360. responses: {
  361. 200: {
  362. description: "Log entry written successfully",
  363. content: {
  364. "application/json": {
  365. schema: resolver(z.boolean()),
  366. },
  367. },
  368. },
  369. ...errors(400),
  370. },
  371. }),
  372. validator(
  373. "json",
  374. z.object({
  375. service: z.string().meta({ description: "Service name for the log entry" }),
  376. level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
  377. message: z.string().meta({ description: "Log message" }),
  378. extra: z
  379. .record(z.string(), z.any())
  380. .optional()
  381. .meta({ description: "Additional metadata for the log entry" }),
  382. }),
  383. ),
  384. async (c) => {
  385. const { service, level, message, extra } = c.req.valid("json")
  386. const logger = Log.create({ service })
  387. switch (level) {
  388. case "debug":
  389. logger.debug(message, extra)
  390. break
  391. case "info":
  392. logger.info(message, extra)
  393. break
  394. case "error":
  395. logger.error(message, extra)
  396. break
  397. case "warn":
  398. logger.warn(message, extra)
  399. break
  400. }
  401. return c.json(true)
  402. },
  403. )
  404. .get(
  405. "/agent",
  406. describeRoute({
  407. summary: "List agents",
  408. description: "Get a list of all available AI agents in the OpenCode system.",
  409. operationId: "app.agents",
  410. responses: {
  411. 200: {
  412. description: "List of agents",
  413. content: {
  414. "application/json": {
  415. schema: resolver(Agent.Info.array()),
  416. },
  417. },
  418. },
  419. },
  420. }),
  421. async (c) => {
  422. const modes = await Agent.list()
  423. return c.json(modes)
  424. },
  425. )
  426. .get(
  427. "/skill",
  428. describeRoute({
  429. summary: "List skills",
  430. description: "Get a list of all available skills in the OpenCode system.",
  431. operationId: "app.skills",
  432. responses: {
  433. 200: {
  434. description: "List of skills",
  435. content: {
  436. "application/json": {
  437. schema: resolver(Skill.Info.array()),
  438. },
  439. },
  440. },
  441. },
  442. }),
  443. async (c) => {
  444. const skills = await Skill.all()
  445. return c.json(skills)
  446. },
  447. )
  448. .get(
  449. "/lsp",
  450. describeRoute({
  451. summary: "Get LSP status",
  452. description: "Get LSP server status",
  453. operationId: "lsp.status",
  454. responses: {
  455. 200: {
  456. description: "LSP server status",
  457. content: {
  458. "application/json": {
  459. schema: resolver(LSP.Status.array()),
  460. },
  461. },
  462. },
  463. },
  464. }),
  465. async (c) => {
  466. return c.json(await LSP.status())
  467. },
  468. )
  469. .get(
  470. "/formatter",
  471. describeRoute({
  472. summary: "Get formatter status",
  473. description: "Get formatter status",
  474. operationId: "formatter.status",
  475. responses: {
  476. 200: {
  477. description: "Formatter status",
  478. content: {
  479. "application/json": {
  480. schema: resolver(Format.Status.array()),
  481. },
  482. },
  483. },
  484. },
  485. }),
  486. async (c) => {
  487. return c.json(await Format.status())
  488. },
  489. )
  490. .get(
  491. "/event",
  492. describeRoute({
  493. summary: "Subscribe to events",
  494. description: "Get events",
  495. operationId: "event.subscribe",
  496. responses: {
  497. 200: {
  498. description: "Event stream",
  499. content: {
  500. "text/event-stream": {
  501. schema: resolver(BusEvent.payloads()),
  502. },
  503. },
  504. },
  505. },
  506. }),
  507. async (c) => {
  508. log.info("event connected")
  509. c.header("X-Accel-Buffering", "no")
  510. c.header("X-Content-Type-Options", "nosniff")
  511. return streamSSE(c, async (stream) => {
  512. stream.writeSSE({
  513. data: JSON.stringify({
  514. type: "server.connected",
  515. properties: {},
  516. }),
  517. })
  518. const unsub = Bus.subscribeAll(async (event) => {
  519. await stream.writeSSE({
  520. data: JSON.stringify(event),
  521. })
  522. if (event.type === Bus.InstanceDisposed.type) {
  523. stream.close()
  524. }
  525. })
  526. // Send heartbeat every 10s to prevent stalled proxy streams.
  527. const heartbeat = setInterval(() => {
  528. stream.writeSSE({
  529. data: JSON.stringify({
  530. type: "server.heartbeat",
  531. properties: {},
  532. }),
  533. })
  534. }, 10_000)
  535. await new Promise<void>((resolve) => {
  536. stream.onAbort(() => {
  537. clearInterval(heartbeat)
  538. unsub()
  539. resolve()
  540. log.info("event disconnected")
  541. })
  542. })
  543. })
  544. },
  545. )
  546. .all("/*", async (c) => {
  547. const path = c.req.path
  548. const response = await proxy(`https://app.opencode.ai${path}`, {
  549. ...c.req,
  550. headers: {
  551. ...c.req.raw.headers,
  552. host: "app.opencode.ai",
  553. },
  554. })
  555. response.headers.set(
  556. "Content-Security-Policy",
  557. "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:",
  558. )
  559. return response
  560. }) as unknown as Hono,
  561. )
  562. export async function openapi() {
  563. // Cast to break excessive type recursion from long route chains
  564. const result = await generateSpecs(App() as Hono, {
  565. documentation: {
  566. info: {
  567. title: "opencode",
  568. version: "1.0.0",
  569. description: "opencode api",
  570. },
  571. openapi: "3.1.1",
  572. },
  573. })
  574. return result
  575. }
  576. export function listen(opts: {
  577. port: number
  578. hostname: string
  579. mdns?: boolean
  580. mdnsDomain?: string
  581. cors?: string[]
  582. }) {
  583. _corsWhitelist = opts.cors ?? []
  584. const args = {
  585. hostname: opts.hostname,
  586. idleTimeout: 0,
  587. fetch: App().fetch,
  588. websocket: websocket,
  589. } as const
  590. const tryServe = (port: number) => {
  591. try {
  592. return Bun.serve({ ...args, port })
  593. } catch {
  594. return undefined
  595. }
  596. }
  597. const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
  598. if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
  599. _url = server.url
  600. const shouldPublishMDNS =
  601. opts.mdns &&
  602. server.port &&
  603. opts.hostname !== "127.0.0.1" &&
  604. opts.hostname !== "localhost" &&
  605. opts.hostname !== "::1"
  606. if (shouldPublishMDNS) {
  607. MDNS.publish(server.port!, opts.mdnsDomain)
  608. } else if (opts.mdns) {
  609. log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
  610. }
  611. const originalStop = server.stop.bind(server)
  612. server.stop = async (closeActiveConnections?: boolean) => {
  613. if (shouldPublishMDNS) MDNS.unpublish()
  614. return originalStop(closeActiveConnections)
  615. }
  616. return server
  617. }
  618. }