444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
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
|
|
// 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<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",
|
|
// 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<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)
|
|
},
|
|
}
|
|
}
|