ideBridge.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. type Message = {
  2. id?: string
  3. replyTo?: string
  4. type: string
  5. payload?: any
  6. timestamp?: number
  7. ok?: boolean
  8. error?: string
  9. }
  10. type Handler = (message: Message) => void
  11. // Parse URL params once at module load
  12. const params = new URLSearchParams(window.location.search)
  13. const bridgeBase = params.get("ideBridge")
  14. const token = params.get("ideBridgeToken")
  15. class IdeBridge {
  16. ready = false
  17. private queue: Message[] = []
  18. private handlers: Set<Handler> = new Set()
  19. private pending = new Map<string, { resolve: (m: Message) => void; reject: (e: any) => void }>()
  20. private eventSource: EventSource | null = null
  21. private reconnectDelay = 1000
  22. private readonly maxReconnectDelay = 30000
  23. private reconnectScheduled = false
  24. private connectErrorLogged = false
  25. isInstalled(): boolean {
  26. return !!(bridgeBase && token)
  27. }
  28. init() {
  29. this.connect()
  30. }
  31. private connect() {
  32. if (!bridgeBase || !token) return
  33. const url = `${bridgeBase}/events?token=${encodeURIComponent(token)}`
  34. try {
  35. this.eventSource = new EventSource(url)
  36. } catch (e) {
  37. console.warn("[ideBridge] Failed to create EventSource", { bridgeBase }, e)
  38. this.ready = false
  39. this.scheduleReconnect()
  40. return
  41. }
  42. this.eventSource.addEventListener("connected", (ev: MessageEvent) => {
  43. try {
  44. // ignore payload (kept for compatibility)
  45. JSON.parse(String(ev.data))
  46. } catch {
  47. }
  48. })
  49. this.eventSource.onopen = () => {
  50. this.ready = true
  51. this.reconnectDelay = 1000
  52. this.connectErrorLogged = false
  53. console.log("[ideBridge] Connected", { bridgeBase })
  54. this.flushQueue()
  55. void this.getState().then((state) => {
  56. try {
  57. window.dispatchEvent(new CustomEvent("opencode:ui-bridge-state", { detail: { state } }))
  58. } catch {}
  59. })
  60. }
  61. this.eventSource.onmessage = (ev) => {
  62. try {
  63. const msg = JSON.parse(ev.data) as Message
  64. this.dispatch(msg)
  65. } catch (e) {
  66. console.warn("[ideBridge] Failed to parse SSE message:", e)
  67. }
  68. }
  69. this.eventSource.onerror = () => {
  70. if (!this.connectErrorLogged) {
  71. this.connectErrorLogged = true
  72. console.warn("[ideBridge] Connection error", {
  73. bridgeBase,
  74. readyState: this.eventSource?.readyState,
  75. })
  76. }
  77. this.ready = false
  78. this.scheduleReconnect()
  79. }
  80. }
  81. onReady(handler: () => void) {
  82. const run = () => {
  83. try {
  84. handler()
  85. } catch {}
  86. }
  87. if (this.ready) {
  88. run()
  89. return () => {}
  90. }
  91. const listener = () => run()
  92. window.addEventListener("opencode:idebridge-ready", listener)
  93. return () => window.removeEventListener("opencode:idebridge-ready", listener)
  94. }
  95. private scheduleReconnect() {
  96. if (this.reconnectScheduled) return
  97. this.reconnectScheduled = true
  98. this.eventSource?.close()
  99. this.eventSource = null
  100. setTimeout(() => {
  101. this.reconnectScheduled = false
  102. this.connect()
  103. }, this.reconnectDelay)
  104. this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay)
  105. }
  106. private dispatch(msg: Message) {
  107. if (msg && msg.replyTo) {
  108. const p = this.pending.get(msg.replyTo)
  109. if (p) {
  110. this.pending.delete(msg.replyTo)
  111. p.resolve(msg)
  112. return
  113. }
  114. }
  115. this.handlers.forEach((h) => {
  116. try {
  117. h(msg)
  118. } catch {}
  119. })
  120. }
  121. on(handler: Handler) {
  122. this.handlers.add(handler)
  123. }
  124. off(handler: Handler) {
  125. this.handlers.delete(handler)
  126. }
  127. send(msg: Message) {
  128. if (!bridgeBase || !token) {
  129. console.warn("[ideBridge] Bridge not configured, ignoring send:", msg.type)
  130. return
  131. }
  132. if (!this.ready) {
  133. this.queue.push(msg)
  134. return
  135. }
  136. this.doSend(msg)
  137. }
  138. private async doSend(msg: Message, retryCount = 0) {
  139. if (!bridgeBase || !token) return
  140. const quiet = msg.type === "uiGetState" || msg.type === "uiSetState"
  141. try {
  142. const response = await fetch(`${bridgeBase}/send?token=${encodeURIComponent(token)}`, {
  143. method: "POST",
  144. headers: { "Content-Type": "application/json" },
  145. body: JSON.stringify(msg),
  146. })
  147. if (!response.ok) {
  148. if (!quiet) console.warn("[ideBridge] Send failed with status:", response.status)
  149. // Requeue on server errors (5xx) with limited retries
  150. if (response.status >= 500 && retryCount < 3) {
  151. this.requeueWithBackoff(msg, retryCount)
  152. }
  153. }
  154. } catch (e) {
  155. if (!quiet) console.warn("[ideBridge] Send failed:", e)
  156. // Network error - requeue with backoff
  157. if (retryCount < 3) {
  158. this.requeueWithBackoff(msg, retryCount)
  159. }
  160. }
  161. }
  162. private requeueWithBackoff(msg: Message, retryCount: number) {
  163. const delay = Math.min(1000 * Math.pow(2, retryCount), 10000)
  164. setTimeout(() => {
  165. if (this.ready) {
  166. this.doSend(msg, retryCount + 1)
  167. } else {
  168. this.queue.push(msg)
  169. }
  170. }, delay)
  171. }
  172. request<T = any>(type: string, payload?: any): Promise<Message & { result?: T }> {
  173. return new Promise((resolve, reject) => {
  174. if (!this.isInstalled()) {
  175. reject(new Error("[ideBridge] Bridge not installed"))
  176. return
  177. }
  178. try {
  179. const id = String(Date.now()) + Math.random().toString(36).slice(2)
  180. this.pending.set(id, { resolve, reject })
  181. this.send({ id, type, payload, timestamp: Date.now() })
  182. } catch (e) {
  183. reject(e)
  184. }
  185. })
  186. }
  187. private flushQueue() {
  188. const q = this.queue.splice(0, this.queue.length)
  189. for (const msg of q) {
  190. this.doSend(msg)
  191. }
  192. try {
  193. window.dispatchEvent(new Event("opencode:idebridge-ready"))
  194. } catch {}
  195. }
  196. async getState<T = any>(): Promise<T | null> {
  197. try {
  198. const res = await this.request<{ state: T }>("uiGetState")
  199. const state = (res as any)?.payload?.state
  200. return (state ?? null) as T | null
  201. } catch {
  202. return null
  203. }
  204. }
  205. async setState(state: any): Promise<boolean> {
  206. try {
  207. const res = await this.request("uiSetState", { state })
  208. return !!(res as any)?.ok
  209. } catch {
  210. return false
  211. }
  212. }
  213. }
  214. export const ideBridge = new IdeBridge()
  215. export function reloadPath(path: string, operation: "write" | "edit" | "apply_patch") {
  216. if (!ideBridge.isInstalled()) return
  217. ideBridge.send({ type: "reloadPath", payload: { path, operation } })
  218. }