| 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<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>> |
| type ServerSDKBase = { |
| server: ServerConnection.Any |
| scope: ServerScope |
| protocol: Promise<ServerProtocol> |
| protocolKind: Accessor<ServerProtocol | undefined> |
| url: string |
| client: ReturnType<typeof createSdkForServer> |
| api: CompatibleApi |
| currentApi: ServerApi |
| event: { |
| on: ServerEventEmitter["on"] |
| listen: ServerEventEmitter["listen"] |
| start: () => Promise<void> | undefined |
| } |
| createClient: ( |
| opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">, |
| ) => ReturnType<typeof createSdkForServer> |
| } |
|
|
| 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<typeof setTimeout> | 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<void>((resolve) => setTimeout(resolve, ms)) |
| let attempt: AbortController | undefined |
| let run: Promise<void> | 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 |
| |
| 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<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) { |
| return createSdkForServer({ |
| server: server.http, |
| fetch: platform.fetch, |
| ...opts, |
| }) |
| }, |
| } |
| } |
|
|
| export type ServerSDK = ServerSDKBase & { |
| ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext> |
| } |
|
|
| 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", |
| |
| |
| init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => { |
| const global = useGlobal() |
| const language = useLanguage() |
| const server = useServer() |
|
|
| return createMemo<ServerSDK>(() => { |
| 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<ServerEvent, { type: key }> |
| } |
|
|
| function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { |
| const client = serverSDK.createClient({ |
| directory, |
| throwOnError: true, |
| }) |
|
|
| const emitter = createGlobalEmitter<SDKEventMap>() |
|
|
| 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<typeof serverSDK.createClient>[0]) { |
| return serverSDK.createClient(opts) |
| }, |
| } |
| } |
|
|