server.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import { dirname, join } from "node:path"
  2. import { fileURLToPath } from "node:url"
  3. import { app, utilityProcess } from "electron"
  4. import type { Details } from "electron"
  5. import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
  6. import { getUserShell, loadShellEnv } from "./shell-env"
  7. import { getStore } from "./store"
  8. import type { SqliteMigrationProgress } from "../preload/types"
  9. export type WslConfig = { enabled: boolean }
  10. export type HealthCheck = { wait: Promise<void> }
  11. type SidecarMessage =
  12. | { type: "sqlite"; progress: SqliteMigrationProgress }
  13. | { type: "ready" }
  14. | { type: "stopped" }
  15. | { type: "error"; error: { message: string; stack?: string } }
  16. export type SidecarListener = { stop: () => Promise<void> }
  17. const SIDECAR_SERVICE_NAME = "opencode server"
  18. const SIDECAR_START_STALL_TIMEOUT = 60_000
  19. const SIDECAR_STOP_TIMEOUT = 6_000
  20. type SpawnLocalServerOptions = {
  21. needsMigration: boolean
  22. userDataPath: string
  23. onSqliteProgress?: (progress: SqliteMigrationProgress) => void
  24. onStdout?: (message: string) => void
  25. onStderr?: (message: string) => void
  26. onExit?: (code: number) => void
  27. }
  28. export function getDefaultServerUrl(): string | null {
  29. const value = getStore().get(DEFAULT_SERVER_URL_KEY)
  30. return typeof value === "string" ? value : null
  31. }
  32. export function setDefaultServerUrl(url: string | null) {
  33. if (url) {
  34. getStore().set(DEFAULT_SERVER_URL_KEY, url)
  35. return
  36. }
  37. getStore().delete(DEFAULT_SERVER_URL_KEY)
  38. }
  39. export function getWslConfig(): WslConfig {
  40. const value = getStore().get(WSL_ENABLED_KEY)
  41. return { enabled: typeof value === "boolean" ? value : false }
  42. }
  43. export function setWslConfig(config: WslConfig) {
  44. getStore().set(WSL_ENABLED_KEY, config.enabled)
  45. }
  46. export function preferAppEnv(userDataPath: string) {
  47. const shell = process.platform === "win32" ? null : getUserShell()
  48. Object.assign(process.env, {
  49. ...(shell ? loadShellEnv(shell) : null),
  50. OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
  51. OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
  52. OPENCODE_CLIENT: "desktop",
  53. XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
  54. })
  55. }
  56. export async function spawnLocalServer(
  57. hostname: string,
  58. port: number,
  59. password: string,
  60. options: SpawnLocalServerOptions,
  61. ) {
  62. const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
  63. const child = utilityProcess.fork(sidecar, [], {
  64. cwd: process.cwd(),
  65. env: createSidecarEnv(),
  66. serviceName: SIDECAR_SERVICE_NAME,
  67. stdio: "pipe",
  68. })
  69. let exited = false
  70. const exit = defer<number>()
  71. const onProcessGone = (_event: unknown, details: Details) => {
  72. if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return
  73. options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`)
  74. }
  75. app.on("child-process-gone", onProcessGone)
  76. child.once("exit", (code) => {
  77. exited = true
  78. app.off("child-process-gone", onProcessGone)
  79. options.onExit?.(code)
  80. exit.resolve(code)
  81. })
  82. child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`))
  83. child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd()))
  84. child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd()))
  85. await new Promise<void>((resolve, reject) => {
  86. let done = false
  87. let timeout: NodeJS.Timeout
  88. const fail = (error: Error) => {
  89. if (done) return
  90. done = true
  91. cleanup()
  92. reject(error)
  93. }
  94. const refreshTimeout = () => {
  95. clearTimeout(timeout)
  96. timeout = setTimeout(() => {
  97. fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`))
  98. }, SIDECAR_START_STALL_TIMEOUT)
  99. }
  100. const onMessage = (message: SidecarMessage) => {
  101. if (message.type === "sqlite") {
  102. refreshTimeout()
  103. options.onSqliteProgress?.(message.progress)
  104. return
  105. }
  106. if (message.type === "ready") {
  107. if (done) return
  108. done = true
  109. cleanup()
  110. resolve()
  111. return
  112. }
  113. if (message.type === "error") {
  114. fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
  115. }
  116. }
  117. const onExit = (code: number) => {
  118. fail(new Error(`Sidecar exited before ready with code ${code}`))
  119. }
  120. const cleanup = () => {
  121. clearTimeout(timeout)
  122. child.off("message", onMessage)
  123. child.off("exit", onExit)
  124. }
  125. child.on("message", onMessage)
  126. child.on("exit", onExit)
  127. refreshTimeout()
  128. child.postMessage({
  129. type: "start",
  130. hostname,
  131. port,
  132. password,
  133. userDataPath: options.userDataPath,
  134. needsMigration: options.needsMigration,
  135. })
  136. }).catch((error) => {
  137. if (!exited) child.kill()
  138. throw error
  139. })
  140. const wait = (async () => {
  141. const url = `http://${hostname}:${port}`
  142. let healthy = false
  143. const gone = exit.promise.then((code) => {
  144. if (healthy) return
  145. throw new Error(`Sidecar exited before health check passed with code ${code}`)
  146. })
  147. const ready = async () => {
  148. while (true) {
  149. await new Promise((resolve) => setTimeout(resolve, 100))
  150. if (await checkHealth(url, password)) {
  151. healthy = true
  152. return
  153. }
  154. }
  155. }
  156. await Promise.race([ready(), gone])
  157. })()
  158. let stopping: Promise<void> | undefined
  159. return {
  160. listener: {
  161. stop: () => {
  162. if (stopping) return stopping
  163. if (exited) return Promise.resolve()
  164. child.postMessage({ type: "stop" })
  165. stopping = Promise.race([
  166. exit.promise.then(() => undefined),
  167. delay(SIDECAR_STOP_TIMEOUT).then(() => {
  168. if (!exited) child.kill()
  169. }),
  170. ])
  171. return stopping
  172. },
  173. },
  174. health: { wait },
  175. }
  176. }
  177. export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
  178. let healthUrl: URL
  179. try {
  180. healthUrl = new URL("/global/health", url)
  181. } catch {
  182. return false
  183. }
  184. const headers = new Headers()
  185. if (password) {
  186. const auth = Buffer.from(`opencode:${password}`).toString("base64")
  187. headers.set("authorization", `Basic ${auth}`)
  188. }
  189. try {
  190. const res = await fetch(healthUrl, {
  191. method: "GET",
  192. headers,
  193. signal: AbortSignal.timeout(3000),
  194. })
  195. return res.ok
  196. } catch {
  197. return false
  198. }
  199. }
  200. function createSidecarEnv(): Record<string, string> {
  201. const env = Object.fromEntries(
  202. Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
  203. )
  204. delete env.DEBUG
  205. if (process.platform === "linux") delete env.LD_PRELOAD
  206. return env
  207. }
  208. function delay(ms: number) {
  209. return new Promise<void>((resolve) => setTimeout(resolve, ms))
  210. }
  211. function serializeError(error: unknown) {
  212. if (error instanceof Error) return { message: error.message, stack: error.stack }
  213. return { message: String(error) }
  214. }
  215. function defer<T>() {
  216. let resolve!: (value: T) => void
  217. let reject!: (error: Error) => void
  218. const promise = new Promise<T>((res, rej) => {
  219. resolve = res
  220. reject = rej
  221. })
  222. return { promise, resolve, reject }
  223. }