| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- type Message = {
- id?: string
- replyTo?: string
- type: string
- payload?: any
- timestamp?: number
- ok?: boolean
- error?: string
- }
- type Handler = (message: Message) => void
- // Parse URL params once at module load
- const params = new URLSearchParams(window.location.search)
- const bridgeBase = params.get("ideBridge")
- const token = params.get("ideBridgeToken")
- class IdeBridge {
- ready = false
- private queue: Message[] = []
- private handlers: Set<Handler> = new Set()
- private pending = new Map<string, { resolve: (m: Message) => void; reject: (e: any) => void }>()
- private eventSource: EventSource | null = null
- private reconnectDelay = 1000
- private readonly maxReconnectDelay = 30000
- private reconnectScheduled = false
- private connectErrorLogged = false
- isInstalled(): boolean {
- return !!(bridgeBase && token)
- }
- init() {
- this.connect()
- }
- private connect() {
- if (!bridgeBase || !token) return
- const url = `${bridgeBase}/events?token=${encodeURIComponent(token)}`
- try {
- this.eventSource = new EventSource(url)
- } catch (e) {
- console.warn("[ideBridge] Failed to create EventSource", { bridgeBase }, e)
- this.ready = false
- this.scheduleReconnect()
- return
- }
- this.eventSource.addEventListener("connected", (ev: MessageEvent) => {
- try {
- // ignore payload (kept for compatibility)
- JSON.parse(String(ev.data))
- } catch {
- }
- })
- this.eventSource.onopen = () => {
- this.ready = true
- this.reconnectDelay = 1000
- this.connectErrorLogged = false
- console.log("[ideBridge] Connected", { bridgeBase })
- this.flushQueue()
- void this.getState().then((state) => {
- try {
- window.dispatchEvent(new CustomEvent("opencode:ui-bridge-state", { detail: { state } }))
- } catch {}
- })
- }
- this.eventSource.onmessage = (ev) => {
- try {
- const msg = JSON.parse(ev.data) as Message
- this.dispatch(msg)
- } catch (e) {
- console.warn("[ideBridge] Failed to parse SSE message:", e)
- }
- }
- this.eventSource.onerror = () => {
- if (!this.connectErrorLogged) {
- this.connectErrorLogged = true
- console.warn("[ideBridge] Connection error", {
- bridgeBase,
- readyState: this.eventSource?.readyState,
- })
- }
- this.ready = false
- this.scheduleReconnect()
- }
- }
- onReady(handler: () => void) {
- const run = () => {
- try {
- handler()
- } catch {}
- }
- if (this.ready) {
- run()
- return () => {}
- }
- const listener = () => run()
- window.addEventListener("opencode:idebridge-ready", listener)
- return () => window.removeEventListener("opencode:idebridge-ready", listener)
- }
- private scheduleReconnect() {
- if (this.reconnectScheduled) return
- this.reconnectScheduled = true
- this.eventSource?.close()
- this.eventSource = null
- setTimeout(() => {
- this.reconnectScheduled = false
- this.connect()
- }, this.reconnectDelay)
- this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay)
- }
- private dispatch(msg: Message) {
- if (msg && msg.replyTo) {
- const p = this.pending.get(msg.replyTo)
- if (p) {
- this.pending.delete(msg.replyTo)
- p.resolve(msg)
- return
- }
- }
- this.handlers.forEach((h) => {
- try {
- h(msg)
- } catch {}
- })
- }
- on(handler: Handler) {
- this.handlers.add(handler)
- }
- off(handler: Handler) {
- this.handlers.delete(handler)
- }
- send(msg: Message) {
- if (!bridgeBase || !token) {
- console.warn("[ideBridge] Bridge not configured, ignoring send:", msg.type)
- return
- }
- if (!this.ready) {
- this.queue.push(msg)
- return
- }
- this.doSend(msg)
- }
- private async doSend(msg: Message, retryCount = 0) {
- if (!bridgeBase || !token) return
- const quiet = msg.type === "uiGetState" || msg.type === "uiSetState"
- try {
- const response = await fetch(`${bridgeBase}/send?token=${encodeURIComponent(token)}`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(msg),
- })
- if (!response.ok) {
- if (!quiet) console.warn("[ideBridge] Send failed with status:", response.status)
- // Requeue on server errors (5xx) with limited retries
- if (response.status >= 500 && retryCount < 3) {
- this.requeueWithBackoff(msg, retryCount)
- }
- }
- } catch (e) {
- if (!quiet) console.warn("[ideBridge] Send failed:", e)
- // Network error - requeue with backoff
- if (retryCount < 3) {
- this.requeueWithBackoff(msg, retryCount)
- }
- }
- }
- private requeueWithBackoff(msg: Message, retryCount: number) {
- const delay = Math.min(1000 * Math.pow(2, retryCount), 10000)
- setTimeout(() => {
- if (this.ready) {
- this.doSend(msg, retryCount + 1)
- } else {
- this.queue.push(msg)
- }
- }, delay)
- }
- request<T = any>(type: string, payload?: any): Promise<Message & { result?: T }> {
- return new Promise((resolve, reject) => {
- if (!this.isInstalled()) {
- reject(new Error("[ideBridge] Bridge not installed"))
- return
- }
- try {
- const id = String(Date.now()) + Math.random().toString(36).slice(2)
- this.pending.set(id, { resolve, reject })
- this.send({ id, type, payload, timestamp: Date.now() })
- } catch (e) {
- reject(e)
- }
- })
- }
- private flushQueue() {
- const q = this.queue.splice(0, this.queue.length)
- for (const msg of q) {
- this.doSend(msg)
- }
- try {
- window.dispatchEvent(new Event("opencode:idebridge-ready"))
- } catch {}
- }
- async getState<T = any>(): Promise<T | null> {
- try {
- const res = await this.request<{ state: T }>("uiGetState")
- const state = (res as any)?.payload?.state
- return (state ?? null) as T | null
- } catch {
- return null
- }
- }
- async setState(state: any): Promise<boolean> {
- try {
- const res = await this.request("uiSetState", { state })
- return !!(res as any)?.ok
- } catch {
- return false
- }
- }
- }
- export const ideBridge = new IdeBridge()
- export function reloadPath(path: string, operation: "write" | "edit" | "apply_patch") {
- if (!ideBridge.isInstalled()) return
- ideBridge.send({ type: "reloadPath", payload: { path, operation } })
- }
|