server.ts 19 KB

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