mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
194 lines
6.4 KiB
TypeScript
194 lines
6.4 KiB
TypeScript
import { isHigherSseEventId, RecentSseEventIds } from './sseEventIds'
|
|
import { canonicalTeamEventKey } from './chat/teamEventOwnership'
|
|
|
|
/** One parsed SSE frame before JSON decoding. */
|
|
export interface TeamSseFrame {
|
|
id?: string
|
|
event: string
|
|
data: string
|
|
}
|
|
|
|
export interface TeamSseParseResult {
|
|
frames: TeamSseFrame[]
|
|
remainder: string
|
|
}
|
|
|
|
/** Parse all complete SSE frames and retain the incomplete trailing bytes. */
|
|
export function parseTeamSseFrames(input: string): TeamSseParseResult {
|
|
const frames: TeamSseFrame[] = []
|
|
let remainder = input
|
|
|
|
for (;;) {
|
|
const separator = /\r\n\r\n|\n\n|\r\r/.exec(remainder)
|
|
if (!separator || separator.index == null) break
|
|
const rawFrame = remainder.slice(0, separator.index)
|
|
remainder = remainder.slice(separator.index + separator[0].length)
|
|
|
|
let event = 'message'
|
|
let id: string | undefined
|
|
const data: string[] = []
|
|
for (const line of rawFrame.split(/\r\n|\r|\n/)) {
|
|
if (!line || line.startsWith(':')) continue
|
|
const colon = line.indexOf(':')
|
|
const field = colon < 0 ? line : line.slice(0, colon)
|
|
let value = colon < 0 ? '' : line.slice(colon + 1)
|
|
if (value.startsWith(' ')) value = value.slice(1)
|
|
if (field === 'event') event = value || 'message'
|
|
else if (field === 'data') data.push(value)
|
|
else if (field === 'id' && !value.includes('\0')) id = value
|
|
}
|
|
frames.push({ ...(id === undefined ? {} : { id }), event, data: data.join('\n') })
|
|
}
|
|
|
|
return { frames, remainder }
|
|
}
|
|
|
|
export interface TeamBoardEvent {
|
|
id?: string
|
|
event: string
|
|
data: Record<string, unknown>
|
|
}
|
|
|
|
export interface TeamEventSubscriptionOptions {
|
|
fetchImpl?: typeof fetch
|
|
storage?: Pick<Storage, 'getItem'>
|
|
retryBaseMs?: number
|
|
retryMaxMs?: number
|
|
seenEventLimit?: number
|
|
maxBufferBytes?: number
|
|
setTimeoutImpl?: (callback: () => void, delay: number) => unknown
|
|
clearTimeoutImpl?: (handle: unknown) => void
|
|
}
|
|
|
|
/** Subscribe to team events with resumable, de-duplicated SSE reconnection. */
|
|
export function subscribeTeamEvents(
|
|
teamId: string,
|
|
onEvent: (event: TeamBoardEvent) => void,
|
|
options: TeamEventSubscriptionOptions = {},
|
|
): () => void {
|
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis)
|
|
const storage = options.storage ?? localStorage
|
|
const retryBaseMs = options.retryBaseMs ?? 1_000
|
|
const retryMaxMs = options.retryMaxMs ?? 30_000
|
|
const maxBufferBytes = Math.max(1_024, options.maxBufferBytes ?? 1_048_576)
|
|
const setTimeoutImpl = options.setTimeoutImpl
|
|
?? ((callback, delay) => globalThis.setTimeout(callback, delay))
|
|
const clearTimeoutImpl = options.clearTimeoutImpl
|
|
?? (handle => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>))
|
|
|
|
let stopped = false
|
|
let controller: AbortController | null = null
|
|
let retryTimer: unknown
|
|
let retryAttempt = 0
|
|
let lastEventId: string | undefined
|
|
const seenEventIds = new RecentSseEventIds(options.seenEventLimit)
|
|
|
|
const scheduleReconnect = () => {
|
|
if (stopped) return
|
|
const delay = Math.min(retryBaseMs * (2 ** retryAttempt), retryMaxMs)
|
|
retryAttempt += 1
|
|
retryTimer = setTimeoutImpl(() => {
|
|
retryTimer = undefined
|
|
void connect()
|
|
}, delay)
|
|
}
|
|
|
|
const dispatchFrames = (frames: TeamSseFrame[]) => {
|
|
for (const frame of frames) {
|
|
if (stopped) return
|
|
if (frame.id !== undefined) {
|
|
if (isHigherSseEventId(frame.id, lastEventId)) lastEventId = frame.id
|
|
}
|
|
if (frame.event === 'heartbeat' || !frame.data) continue
|
|
try {
|
|
const event: TeamBoardEvent = {
|
|
...(frame.id === undefined ? {} : { id: frame.id }),
|
|
event: frame.event,
|
|
data: JSON.parse(frame.data) as Record<string, unknown>,
|
|
}
|
|
const canonicalKey = canonicalTeamEventKey(event)
|
|
if (canonicalKey && seenEventIds.has(canonicalKey)) continue
|
|
if (canonicalKey) seenEventIds.add(canonicalKey)
|
|
onEvent(event)
|
|
retryAttempt = 0
|
|
} catch {
|
|
// Ignore malformed or non-JSON board events.
|
|
}
|
|
}
|
|
}
|
|
|
|
const connect = async () => {
|
|
if (stopped) return
|
|
const activeController = new AbortController()
|
|
controller = activeController
|
|
try {
|
|
const headers: Record<string, string> = { Accept: 'text/event-stream' }
|
|
const token = storage.getItem('token')
|
|
const workspaceId = storage.getItem('mc-workspace-id')
|
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
if (workspaceId) headers['X-Workspace-Id'] = workspaceId
|
|
if (lastEventId !== undefined) headers['Last-Event-ID'] = lastEventId
|
|
|
|
const response = await fetchImpl(`/api/v1/teams/${teamId}/events`, {
|
|
headers,
|
|
signal: activeController.signal,
|
|
})
|
|
if (!response.ok || !response.body) throw new Error('Team event stream unavailable')
|
|
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
let discardingOversizedFrame = false
|
|
let discardBoundaryTail = ''
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (stopped) break
|
|
if (done) {
|
|
buffer += decoder.decode()
|
|
const parsed = parseTeamSseFrames(buffer)
|
|
dispatchFrames(parsed.frames)
|
|
break
|
|
}
|
|
let chunk = decoder.decode(value, { stream: true })
|
|
if (discardingOversizedFrame) {
|
|
const discardInput = discardBoundaryTail + chunk
|
|
const separator = /\r\n\r\n|\n\n|\r\r/.exec(discardInput)
|
|
if (!separator || separator.index == null) {
|
|
discardBoundaryTail = discardInput.slice(-3)
|
|
continue
|
|
}
|
|
chunk = discardInput.slice(separator.index + separator[0].length)
|
|
discardingOversizedFrame = false
|
|
discardBoundaryTail = ''
|
|
}
|
|
buffer += chunk
|
|
const parsed = parseTeamSseFrames(buffer)
|
|
buffer = parsed.remainder
|
|
dispatchFrames(parsed.frames)
|
|
if (buffer.length > maxBufferBytes) {
|
|
discardBoundaryTail = buffer.slice(-3)
|
|
buffer = ''
|
|
discardingOversizedFrame = true
|
|
}
|
|
}
|
|
} catch {
|
|
// A dropped stream follows the same reconnect path as a clean EOF.
|
|
} finally {
|
|
if (controller === activeController) controller = null
|
|
}
|
|
scheduleReconnect()
|
|
}
|
|
|
|
void connect()
|
|
|
|
return () => {
|
|
stopped = true
|
|
controller?.abort()
|
|
controller = null
|
|
if (retryTimer !== undefined) {
|
|
clearTimeoutImpl(retryTimer)
|
|
retryTimer = undefined
|
|
}
|
|
}
|
|
}
|