import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js" import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true export type ServerEvent = Event & { current?: OpenCodeEvent } type QueuedServerEvent = { directory: string; payload: ServerEvent } type CurrentDelta = Extract< OpenCodeEvent, { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" } > export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { if (event.type === "permission.v2.asked") { return { id: event.id, type: "permission.asked", properties: { id: event.data.id, sessionID: event.data.sessionID, permission: event.data.action, patterns: event.data.resources, always: event.data.save ?? [], metadata: event.data.metadata ?? {}, tool: event.data.source?.type === "tool" ? { messageID: event.data.source.messageID, callID: event.data.source.callID } : undefined, }, current: event, } as ServerEvent } if (event.type === "permission.v2.replied") return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent if (event.type === "question.v2.asked") return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent if (event.type === "question.v2.replied") return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent if (event.type === "question.v2.rejected") return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent } const coalescedKey = (event: QueuedServerEvent) => { if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` if (event.payload.type === "message.part.updated") { const part = event.payload.properties.part return `message.part.updated:${event.directory}:${part.messageID}:${part.id}` } return undefined } export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) { const key = coalescedKey(event) const previous = queue[queue.length - 1] if (key && previous && coalescedKey(previous) === key) { queue[queue.length - 1] = event return false } queue.push(event) return true } export function coalesceServerEvents(events: QueuedServerEvent[]) { const output: QueuedServerEvent[] = [] events.forEach((event) => { const current = currentDelta(event.payload.current) if (current) { const previous = output[output.length - 1] const prior = currentDelta(previous?.payload.current) if ( previous && prior && previous.directory === event.directory && currentDeltaKey(prior) === currentDeltaKey(current) ) { const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current) const data = current.type === "session.compaction.delta" ? { ...current.data, text: fragment } : { ...current.data, delta: fragment } output[output.length - 1] = { directory: event.directory, payload: { ...event.payload, properties: data, current: { ...current, data } as CurrentDelta, } as ServerEvent, } return } output.push(event) return } if (event.payload.type !== "message.part.delta") { output.push(event) return } const props = event.payload.properties const previous = output[output.length - 1] if ( !previous || previous.payload.type !== "message.part.delta" || previous.directory !== event.directory || previous.payload.properties.messageID !== props.messageID || previous.payload.properties.partID !== props.partID || previous.payload.properties.field !== props.field ) { output.push({ directory: event.directory, payload: { ...event.payload, properties: { ...props } }, }) return } output[output.length - 1] = { directory: event.directory, payload: { ...event.payload, properties: { ...props, delta: previous.payload.properties.delta + props.delta }, }, } }) return output } function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { if ( event?.type === "session.text.delta" || event?.type === "session.reasoning.delta" || event?.type === "session.tool.input.delta" || event?.type === "session.compaction.delta" ) return event } function currentDeltaKey(event: CurrentDelta) { if (event.type === "session.tool.input.delta") return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}` if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}` return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}` } function currentDeltaFragment(event: CurrentDelta) { return event.type === "session.compaction.delta" ? event.data.text : event.data.delta } export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { if (!event.persisted) return start() } type ServerEventEmitter = ReturnType> type ServerSDKBase = { server: ServerConnection.Any scope: ServerScope protocol: Promise protocolKind: Accessor url: string client: ReturnType api: CompatibleApi currentApi: ServerApi event: { on: ServerEventEmitter["on"] listen: ServerEventEmitter["listen"] start: () => Promise | undefined } createClient: ( opts: Omit[0], "server" | "fetch">, ) => ReturnType } function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { const platform = usePlatform() const abort = new AbortController() const eventFetch = (() => { if (!platform.fetch || !server) return try { const url = new URL(server.http.url) const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" if (url.protocol === "http:" && !loopback) return platform.fetch } catch { return } })() const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) const eventSdk = createSdkForServer({ signal: abort.signal, fetch: eventFetch, server: server.http, }) const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch) const [protocolKind] = createResource( () => protocol, (value) => value, ) const emitter = createGlobalEmitter<{ [key: string]: ServerEvent }>() type Queued = QueuedServerEvent const FLUSH_FRAME_MS = 16 const STREAM_YIELD_MS = 8 const RECONNECT_DELAY_MS = 250 let queue: Queued[] = [] let buffer: Queued[] = [] let timer: ReturnType | undefined let last = 0 const flush = () => { if (timer) clearTimeout(timer) timer = undefined if (queue.length === 0) return const events = queue queue = buffer buffer = events queue.length = 0 last = Date.now() const output = coalesceServerEvents(events) batch(() => { output.forEach((event) => emitter.emit(event.directory, event.payload)) }) buffer.length = 0 } const schedule = () => { if (timer) return const elapsed = Date.now() - last timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed)) } let streamErrorLogged = false const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) let attempt: AbortController | undefined let run: Promise | undefined let started = false let generation = 0 const start = () => { if (started) return run started = true const active = ++generation const previous = run const current = (async () => { if (previous) await previous // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started && generation === active) { attempt = new AbortController() const onAbort = () => { attempt?.abort() } abort.signal.addEventListener("abort", onAbort) try { const kind = await protocol const events = kind === "v1" ? (await eventSdk.global.event({ signal: attempt.signal })).stream : eventApi.event.subscribe({ signal: attempt.signal }) let yielded = Date.now() for await (const event of events) { streamErrorLogged = false const legacy = "payload" in event if (legacy && event.payload.type === "sync") continue const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) if (enqueueServerEvent(queue, { directory, payload })) schedule() if (Date.now() - yielded < STREAM_YIELD_MS) continue yielded = Date.now() await wait(0) } } catch (error) { if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) { streamErrorLogged = true console.error("[global-sdk] event stream failed", { url: server.http.url, fetch: eventFetch ? "platform" : "webview", error, }) } } finally { abort.signal.removeEventListener("abort", onAbort) attempt = undefined } if (abort.signal.aborted || !started || generation !== active) return await wait(RECONNECT_DELAY_MS) } })().finally(() => { if (run !== current) return run = undefined flush() }) run = current return run } const stop = () => { started = false generation++ attempt?.abort() } onMount(() => { makeEventListener(window, "pagehide", stop) makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) }) onCleanup(() => { stop() abort.abort() flush() }) const sdk = createSdkForServer({ server: server.http, fetch: platform.fetch, throwOnError: true, }) const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch }) const legacy = (directory?: string) => createSdkForServer({ server: server.http, fetch: platform.fetch, throwOnError: true, directory, }) const api = createCompatibleApi({ protocol, current: currentApi, legacy }) return { server, scope, protocol, protocolKind, url: server.http.url, client: sdk, api, currentApi, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), start, }, createClient(opts: Omit[0], "server" | "fetch">) { return createSdkForServer({ server: server.http, fetch: platform.fetch, ...opts, }) }, } } export type ServerSDK = ServerSDKBase & { ensureDirSdkContext: (directory: string) => ReturnType } export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK { const sdk = createServerSdkContextBase(server, scope) return Object.assign(sdk, { ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)), }) } export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({ name: "ServerSDK", // Returns an accessor so the resolved server can change reactively (e.g. a // /new-session draft retargeting its server) without re-instantiating the subtree. init: (props: { server?: Accessor }) => { const global = useGlobal() const language = useLanguage() const server = useServer() return createMemo(() => { const conn = props.server?.() ?? server.current if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) return global.ensureServerCtx(conn).sdk }) }, }) export function useServerProtocol() { const serverSDK = useServerSDK() return createMemo(() => serverSDK().protocolKind()) } type SDKEventMap = { [key in Event["type"]]: Extract } function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { const client = serverSDK.createClient({ directory, throwOnError: true, }) const emitter = createGlobalEmitter() const unsub = serverSDK.event.on(directory, (event) => { emitter.emit(event.type, event) }) onCleanup(unsub) return { scope: serverSDK.scope, protocol: serverSDK.protocol, directory, client, api: createCompatibleApi({ protocol: serverSDK.protocol, current: serverSDK.currentApi, legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), directory, }), event: emitter, get url() { return serverSDK.url }, createClient(opts: Parameters[0]) { return serverSDK.createClient(opts) }, } }