|
@@ -1,9 +1,8 @@
|
|
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
|
|
|
|
+import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
|
|
import type { Event } from "@/types"
|
|
import type { Event } from "@/types"
|
|
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
|
|
-import { makeEventListener } from "@solid-primitives/event-listener"
|
|
|
|
|
-import { type Accessor, batch, onCleanup, onMount } from "solid-js"
|
|
|
|
|
-import { createStore } from "solid-js/store"
|
|
|
|
|
|
|
+import { type Accessor, onCleanup } from "solid-js"
|
|
|
import { createApiForServer, type ServerApi } from "@/utils/server"
|
|
import { createApiForServer, type ServerApi } from "@/utils/server"
|
|
|
import { usePlatform } from "./platform"
|
|
import { usePlatform } from "./platform"
|
|
|
import { ServerConnection } from "./servers"
|
|
import { ServerConnection } from "./servers"
|
|
@@ -12,78 +11,15 @@ import { ServerScope } from "@/utils/server-scope"
|
|
|
import { useServer } from "./server"
|
|
import { useServer } from "./server"
|
|
|
|
|
|
|
|
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
|
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
|
|
-type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
|
|
|
|
|
-type CurrentDelta = Extract<
|
|
|
|
|
- OpenCodeEvent,
|
|
|
|
|
- { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
|
|
|
|
->
|
|
|
|
|
|
|
|
|
|
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
|
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
|
|
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
|
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export function coalesceServerEvents(events: ServerEvent[]) {
|
|
|
|
|
- const output: ServerEvent[] = []
|
|
|
|
|
- events.forEach((event) => {
|
|
|
|
|
- const current = currentDelta(event.current)
|
|
|
|
|
- if (current) {
|
|
|
|
|
- const previous = output[output.length - 1]
|
|
|
|
|
- const prior = currentDelta(previous?.current)
|
|
|
|
|
- if (
|
|
|
|
|
- previous &&
|
|
|
|
|
- prior &&
|
|
|
|
|
- prior.location?.directory === current.location?.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] = {
|
|
|
|
|
- ...event,
|
|
|
|
|
- properties: data,
|
|
|
|
|
- current: { ...current, data } as CurrentDelta,
|
|
|
|
|
- } as ServerEvent
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- output.push(event)
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- output.push(event)
|
|
|
|
|
- })
|
|
|
|
|
- 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.id}`
|
|
|
|
|
- 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 ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
|
|
|
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
|
|
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
|
|
|
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
|
|
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
|
|
|
-export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
|
|
|
|
|
|
+export type ServerConnectionStatus = ClientConnectionStatus
|
|
|
type ServerSDKBase = {
|
|
type ServerSDKBase = {
|
|
|
server: ServerConnection.Any
|
|
server: ServerConnection.Any
|
|
|
scope: ServerScope
|
|
scope: ServerScope
|
|
@@ -105,227 +41,38 @@ type ServerSDKBase = {
|
|
|
|
|
|
|
|
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
|
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
|
|
const platform = usePlatform()
|
|
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 api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
|
|
const emitter = createGlobalEmitter<ServerEventMap>()
|
|
const emitter = createGlobalEmitter<ServerEventMap>()
|
|
|
const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>()
|
|
const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>()
|
|
|
|
|
|
|
|
- const FLUSH_FRAME_MS = 16
|
|
|
|
|
- const STREAM_YIELD_MS = 8
|
|
|
|
|
- const CONNECT_TIMEOUT_MS = 2_000
|
|
|
|
|
- const RECONNECT_DELAY_MS = 1_000
|
|
|
|
|
-
|
|
|
|
|
- let queue: ServerEvent[] = []
|
|
|
|
|
- let buffer: ServerEvent[] = []
|
|
|
|
|
- let timer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
|
- let last = 0
|
|
|
|
|
-
|
|
|
|
|
- function 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.type, event)
|
|
|
|
|
- const directory = event.current?.location?.directory
|
|
|
|
|
- if (directory) locations.emit(directory, event)
|
|
|
|
|
- })
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- buffer.length = 0
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function schedule() {
|
|
|
|
|
- if (timer) return
|
|
|
|
|
- const elapsed = Date.now() - last
|
|
|
|
|
- timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function publish(event: OpenCodeEvent) {
|
|
|
|
|
- queue.push(adaptServerEvent(event))
|
|
|
|
|
- schedule()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function wait(delay: number, signal: AbortSignal) {
|
|
|
|
|
- return new Promise<void>((resolve) => {
|
|
|
|
|
- const timer = setTimeout(done, delay)
|
|
|
|
|
- signal.addEventListener("abort", done, { once: true })
|
|
|
|
|
- function done() {
|
|
|
|
|
- clearTimeout(timer)
|
|
|
|
|
- signal.removeEventListener("abort", done)
|
|
|
|
|
- resolve()
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- }
|
|
|
|
|
- let attempt: AbortController | undefined
|
|
|
|
|
- let run: Promise<void> | undefined
|
|
|
|
|
- let started = false
|
|
|
|
|
- let generation = 0
|
|
|
|
|
- const [connection, setConnection] = createStore<{
|
|
|
|
|
- status: ServerConnectionStatus
|
|
|
|
|
- attempt: number
|
|
|
|
|
- error?: string
|
|
|
|
|
- }>({ status: "connecting", attempt: 0 })
|
|
|
|
|
-
|
|
|
|
|
- async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
|
|
|
|
|
- let connectedAt: number | undefined
|
|
|
|
|
-
|
|
|
|
|
- // Bound the initial handshake and tie this request to the stream lifetime.
|
|
|
|
|
- const request = new AbortController()
|
|
|
|
|
- const cancel = () => request.abort(signal.reason)
|
|
|
|
|
- const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
|
|
|
|
|
- signal.addEventListener("abort", cancel, { once: true })
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- // Open the event stream and validate its initial handshake.
|
|
|
|
|
- const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
|
|
|
|
- const first = await iterator.next()
|
|
|
|
|
-
|
|
|
|
|
- if (signal.aborted) return { error: undefined, connectedAt }
|
|
|
|
|
- if (first.done) {
|
|
|
|
|
- const error =
|
|
|
|
|
- request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
|
|
|
|
- return { error, connectedAt }
|
|
|
|
|
- }
|
|
|
|
|
- if (first.value.type !== "server.connected")
|
|
|
|
|
- return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
|
|
|
|
-
|
|
|
|
|
- // Publish the connected state before forwarding live events.
|
|
|
|
|
- clearTimeout(timeout)
|
|
|
|
|
- publish(first.value)
|
|
|
|
|
- connectedAt = Date.now()
|
|
|
|
|
- setConnection({ status: "connected", attempt: 0, error: undefined })
|
|
|
|
|
-
|
|
|
|
|
- // Forward events until the stream closes or this connection is cancelled.
|
|
|
|
|
- let yielded = Date.now()
|
|
|
|
|
- while (!signal.aborted) {
|
|
|
|
|
- const event = await iterator.next()
|
|
|
|
|
- if (signal.aborted) return { error: undefined, connectedAt }
|
|
|
|
|
- if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
|
|
|
|
- publish(event.value)
|
|
|
|
|
- if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
|
|
|
|
- yielded = Date.now()
|
|
|
|
|
- await wait(0, signal)
|
|
|
|
|
- }
|
|
|
|
|
- return { error: undefined, connectedAt }
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- return { error, connectedAt }
|
|
|
|
|
- } finally {
|
|
|
|
|
- request.abort()
|
|
|
|
|
- clearTimeout(timeout)
|
|
|
|
|
- signal.removeEventListener("abort", cancel)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- async function runStream(active: number) {
|
|
|
|
|
- let retries = 0
|
|
|
|
|
- // oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
|
|
|
|
|
- while (!abort.signal.aborted && started && generation === active) {
|
|
|
|
|
- setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
|
|
|
|
|
- const controller = new AbortController()
|
|
|
|
|
- attempt = controller
|
|
|
|
|
- const onAbort = () => controller.abort()
|
|
|
|
|
- abort.signal.addEventListener("abort", onAbort)
|
|
|
|
|
- const result = await connect(controller.signal)
|
|
|
|
|
- abort.signal.removeEventListener("abort", onAbort)
|
|
|
|
|
-
|
|
|
|
|
- if (abort.signal.aborted || !started || generation !== active) {
|
|
|
|
|
- if (attempt === controller) attempt = undefined
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
|
|
|
|
|
- retries += 1
|
|
|
|
|
- const message =
|
|
|
|
|
- result.error === undefined
|
|
|
|
|
- ? undefined
|
|
|
|
|
- : result.error instanceof Error
|
|
|
|
|
- ? result.error.message
|
|
|
|
|
- : String(result.error)
|
|
|
|
|
- console.info("[global-sdk] event stream disconnected", {
|
|
|
|
|
- url: server.http.url,
|
|
|
|
|
- fetch: eventFetch ? "platform" : "webview",
|
|
|
|
|
- attempt: retries,
|
|
|
|
|
- error: message,
|
|
|
|
|
- })
|
|
|
|
|
- setConnection({ status: "reconnecting", attempt: retries, error: message })
|
|
|
|
|
- await wait(RECONNECT_DELAY_MS, controller.signal)
|
|
|
|
|
- if (attempt === controller) attempt = undefined
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function start() {
|
|
|
|
|
- if (started) return run
|
|
|
|
|
- started = true
|
|
|
|
|
- const active = ++generation
|
|
|
|
|
- const previous = run
|
|
|
|
|
- const current = (async () => {
|
|
|
|
|
- if (previous) await previous
|
|
|
|
|
- await runStream(active)
|
|
|
|
|
- })().finally(() => {
|
|
|
|
|
- if (run !== current) return
|
|
|
|
|
- run = undefined
|
|
|
|
|
- flush()
|
|
|
|
|
- })
|
|
|
|
|
- run = current
|
|
|
|
|
- return run
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function stop() {
|
|
|
|
|
- started = false
|
|
|
|
|
- generation++
|
|
|
|
|
- attempt?.abort()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- onMount(() => {
|
|
|
|
|
- makeEventListener(window, "pagehide", stop)
|
|
|
|
|
- makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
|
|
|
|
- void start()
|
|
|
|
|
|
|
+ const connection = createClientConnection(api, {
|
|
|
|
|
+ flushInterval: 16,
|
|
|
|
|
+ pageLifecycle: true,
|
|
|
|
|
+ onEvent(event) {
|
|
|
|
|
+ const adapted = adaptServerEvent(event)
|
|
|
|
|
+ emitter.emit(adapted.type, adapted)
|
|
|
|
|
+ const directory = event.location?.directory
|
|
|
|
|
+ if (directory) locations.emit(directory, adapted)
|
|
|
|
|
+ },
|
|
|
|
|
+ log: {
|
|
|
|
|
+ info(message, data) {
|
|
|
|
|
+ if (message !== "event stream disconnected") return
|
|
|
|
|
+ console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
onCleanup(() => {
|
|
onCleanup(() => {
|
|
|
- stop()
|
|
|
|
|
- abort.abort()
|
|
|
|
|
- if (timer) clearTimeout(timer)
|
|
|
|
|
- timer = undefined
|
|
|
|
|
- queue = []
|
|
|
|
|
- buffer = []
|
|
|
|
|
emitter.clear()
|
|
emitter.clear()
|
|
|
locations.clear()
|
|
locations.clear()
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
|
|
|
|
-
|
|
|
|
|
return {
|
|
return {
|
|
|
server,
|
|
server,
|
|
|
scope,
|
|
scope,
|
|
|
url: server.http.url,
|
|
url: server.http.url,
|
|
|
api,
|
|
api,
|
|
|
- connection: {
|
|
|
|
|
- status: () => connection.status,
|
|
|
|
|
- attempt: () => connection.attempt,
|
|
|
|
|
- error: () => connection.error,
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ connection,
|
|
|
event: {
|
|
event: {
|
|
|
on: emitter.on.bind(emitter),
|
|
on: emitter.on.bind(emitter),
|
|
|
listen: emitter.listen.bind(emitter),
|
|
listen: emitter.listen.bind(emitter),
|