server-process.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. export * as ServerProcess from "./server-process"
  2. import { NodeServices } from "@effect/platform-node"
  3. import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
  4. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  5. import { Global } from "@opencode-ai/util/global"
  6. import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
  7. import { AppProcess } from "@opencode-ai/util/process"
  8. import { randomBytes, randomUUID } from "node:crypto"
  9. import path from "node:path"
  10. import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
  11. import { HttpServer } from "effect/unstable/http"
  12. import { Env } from "./env"
  13. import { ServiceConfig } from "./services/service-config"
  14. import { Updater } from "./services/updater"
  15. export type Mode = "default" | "service" | "stdio"
  16. export type Options = {
  17. readonly mode: Mode
  18. readonly hostname?: string
  19. readonly port?: number
  20. }
  21. // The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
  22. export const run = Effect.fnUntraced(function* (options: Options) {
  23. return yield* processEffect(options).pipe(
  24. Effect.provide(Updater.layer),
  25. Effect.provide(
  26. LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [
  27. [
  28. Global.node,
  29. Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
  30. ],
  31. ]),
  32. ),
  33. Effect.provide(NodeServices.layer),
  34. )
  35. })
  36. const processEffect = Effect.fnUntraced(function* (options: Options) {
  37. if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
  38. return yield* Effect.scoped(
  39. Effect.gen(function* () {
  40. const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
  41. const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
  42. const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
  43. const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
  44. if (
  45. serviceOptions !== undefined &&
  46. port !== undefined &&
  47. (yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
  48. )
  49. return
  50. const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
  51. const environmentPassword = yield* Env.password
  52. // Keep the lease credential out of the environment inherited by tools.
  53. if (options.mode === "stdio") {
  54. delete process.env.OPENCODE_PASSWORD
  55. delete process.env.OPENCODE_SERVER_PASSWORD
  56. }
  57. const password =
  58. options.mode === "service"
  59. ? config.password || randomBytes(32).toString("base64url")
  60. : environmentPassword
  61. ? Redacted.value(environmentPassword)
  62. : randomBytes(32).toString("base64url")
  63. if (!password) return yield* Effect.fail(new Error("Missing server password"))
  64. const instanceID = randomUUID()
  65. const server = yield* start(
  66. {
  67. app: {
  68. name: process.env.OPENCODE_CLIENT ?? "cli",
  69. version: OPENCODE_VERSION,
  70. channel: OPENCODE_CHANNEL,
  71. },
  72. hostname,
  73. port,
  74. password,
  75. simulation: truthy(process.env.OPENCODE_SIMULATE),
  76. database: {
  77. path:
  78. process.env.OPENCODE_DB ??
  79. (["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) ||
  80. process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
  81. process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
  82. ? "opencode.db"
  83. : `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
  84. },
  85. models: {
  86. url: process.env.OPENCODE_MODELS_URL,
  87. file: process.env.OPENCODE_MODELS_PATH,
  88. fetch: !truthy(process.env.OPENCODE_DISABLE_MODELS_FETCH),
  89. },
  90. observability: {
  91. endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
  92. headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
  93. },
  94. config: {
  95. directory: process.env.OPENCODE_CONFIG_DIR,
  96. project: !truthy(
  97. process.env.OPENCODE_CONFIG_PROJECT_DISABLE ?? process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
  98. ),
  99. file: process.env.OPENCODE_CONFIG,
  100. content: process.env.OPENCODE_CONFIG_CONTENT,
  101. },
  102. windows: {
  103. gitbash: process.env.OPENCODE_GIT_BASH_PATH,
  104. },
  105. fs: {
  106. filewatcher: !truthy(
  107. process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER,
  108. ),
  109. fff:
  110. process.env.OPENCODE_DISABLE_FFF === undefined
  111. ? process.platform !== "win32"
  112. : !truthy(process.env.OPENCODE_DISABLE_FFF),
  113. },
  114. },
  115. serviceOptions === undefined
  116. ? undefined
  117. : {
  118. instanceID,
  119. onListen: (address, shutdown) =>
  120. Effect.gen(function* () {
  121. if (!config.password) yield* ServiceConfig.password(password)
  122. return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
  123. }),
  124. },
  125. ).pipe(
  126. Effect.provide(Logger.layer([], { mergeWithExisting: false })),
  127. Effect.catch((error) => {
  128. if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
  129. return recognizeIncumbent(serviceOptions, hostname, port).pipe(
  130. Effect.flatMap((found) =>
  131. found
  132. ? Effect.void
  133. : Effect.fail(
  134. new Error(
  135. `Managed service port ${port} on ${hostname} is already in use by another process. ` +
  136. "Configure another port with `opencode service set port <port>` and start the service again.",
  137. { cause: error },
  138. ),
  139. ),
  140. ),
  141. )
  142. }),
  143. )
  144. if (server === undefined) return
  145. const url = HttpServer.formatAddress(server.address)
  146. console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
  147. if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
  148. const updater = yield* Updater.Service
  149. yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
  150. return yield* options.mode === "service"
  151. ? server.shutdown
  152. : options.mode === "stdio"
  153. ? waitForStdinClose()
  154. : Effect.never
  155. }).pipe(Effect.annotateLogs({ role: "server" })),
  156. )
  157. })
  158. const infoJson = Schema.fromJsonString(Service.Info)
  159. const encodeInfo = Schema.encodeEffect(infoJson)
  160. const decodeInfo = Schema.decodeUnknownEffect(infoJson)
  161. const register = Effect.fnUntraced(function* (
  162. address: HttpServer.Address,
  163. password: string,
  164. id: string,
  165. file: string,
  166. shutdown: Effect.Effect<void>,
  167. ) {
  168. const fs = yield* FileSystem.FileSystem
  169. const temp = file + "." + id + ".tmp"
  170. yield* fs.makeDirectory(path.dirname(file), { recursive: true })
  171. const info = {
  172. id,
  173. version: OPENCODE_VERSION,
  174. url: HttpServer.formatAddress(address),
  175. pid: process.pid,
  176. password,
  177. }
  178. const encoded = yield* encodeInfo(info)
  179. const current = fs.readFileString(file).pipe(
  180. Effect.flatMap(decodeInfo),
  181. Effect.orElseSucceed(() => undefined),
  182. )
  183. const owns = (found: Info | undefined) =>
  184. found?.id === info.id &&
  185. found.version === info.version &&
  186. found.url === info.url &&
  187. found.pid === info.pid &&
  188. found.password === info.password
  189. yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
  190. yield* current.pipe(
  191. Effect.filterOrFail(owns),
  192. Effect.repeat(Schedule.spaced("5 seconds")),
  193. Effect.ignore,
  194. Effect.andThen(shutdown),
  195. Effect.forkScoped,
  196. )
  197. return current.pipe(
  198. Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
  199. Effect.ignore,
  200. )
  201. })
  202. const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
  203. const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
  204. Effect.filterOrFail((value) => value !== undefined),
  205. Effect.retry(Schedule.spaced("100 millis")),
  206. Effect.timeoutOption("15 seconds"),
  207. )
  208. return Option.isSome(found)
  209. })
  210. function serviceURL(hostname: string, port: number) {
  211. return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
  212. }
  213. function truthy(value?: string) {
  214. return value === "1" || value?.toLowerCase() === "true"
  215. }
  216. function addressInUse(error: unknown): boolean {
  217. if (typeof error !== "object" || error === null) return false
  218. if ("code" in error && error.code === "EADDRINUSE") return true
  219. return "cause" in error && addressInUse(error.cause)
  220. }
  221. function waitForStdinClose() {
  222. return Effect.callback<void>((resume) => {
  223. const close = () => resume(Effect.void)
  224. process.stdin.once("end", close)
  225. process.stdin.once("close", close)
  226. process.stdin.resume()
  227. if (process.stdin.readableEnded || process.stdin.destroyed) close()
  228. return Effect.sync(() => {
  229. process.stdin.off("end", close)
  230. process.stdin.off("close", close)
  231. process.stdin.pause()
  232. })
  233. })
  234. }