server.ts 19 KB

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