import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { type KenEventEnvelope, KenEventSocket, prop } from './kenEvent' export type RealtimeStatus = 'disabled' | 'connecting' | 'online' | 'offline' | 'auth_required' | 'failed' export interface TopicSubscription { topic: string tag: string filter?: Record key?: string } export interface RealtimeApi { readonly enabled: boolean readonly status: RealtimeStatus readonly selfId?: number subscribe(spec: TopicSubscription): () => void onEnvelope(handler: (envelope: KenEventEnvelope) => void): () => void retry(): void lastError?: string } const DISABLED: RealtimeApi = { enabled: false, status: 'disabled', subscribe: () => () => {}, onEnvelope: () => () => {}, retry: () => {}, } const RealtimeContext = createContext(null) function endpoint(): string | null { const configured = import.meta.env.VITE_EVENT_URL as string | undefined if (configured?.trim()) return configured.trim() return null } export function RealtimeProvider({ children }: { children: ReactNode }) { const url = endpoint() const socketRef = useRef(null) const listeners = useRef(new Set<(envelope: KenEventEnvelope) => void>()) const pending = useRef(new Map void>()) const [status, setStatus] = useState(url ? 'connecting' : 'disabled') const [selfId, setSelfId] = useState(undefined) const [lastError, setLastError] = useState(undefined) useEffect(() => { if (!url) return const socket = new KenEventSocket({ url, onOpen: () => { setStatus('online') setLastError(undefined) }, onClose: (_reason, wasOpen) => { setStatus(wasOpen ? 'offline' : 'failed') }, onAuthRequired: (message) => { setStatus('auth_required') setLastError(message) }, onConnectError: (message) => { setStatus('failed') setLastError(message) }, onEnvelope: (envelope) => { const recipient = envelope.recipient_user_id if (typeof recipient === 'number') setSelfId((current) => current ?? recipient) for (const listener of [...listeners.current]) listener(envelope) }, onResync: (envelope) => { for (const listener of [...listeners.current]) listener({ ...envelope, resync_required: true }) }, onServerError: (message) => setLastError(message), onSubscribeError: (subscription, message) => setLastError(`${subscription}: ${message}`), }) socketRef.current = socket socket.subscribePersonal() return () => { socket.dispose() socketRef.current = null pending.current.clear() } }, [url]) const subscribe = useCallback((spec: TopicSubscription) => { const socket = socketRef.current if (!socket) return () => {} const key = spec.key ?? `${spec.topic}|${spec.tag}|${JSON.stringify(spec.filter ?? {})}` const existing = pending.current.get(key) if (existing) return existing const off = socket.subscribeTopic({ key, topic: spec.topic, tag: spec.tag, filter: spec.filter }) const release = () => { pending.current.delete(key) off() } pending.current.set(key, release) return release }, []) const onEnvelope = useCallback((handler: (envelope: KenEventEnvelope) => void) => { listeners.current.add(handler) return () => { listeners.current.delete(handler) } }, []) const retry = useCallback(() => { socketRef.current?.stop() socketRef.current?.start() }, []) const value = useMemo( () => ({ enabled: Boolean(url), status, selfId, subscribe, onEnvelope, retry, lastError }), [url, status, selfId, subscribe, onEnvelope, retry, lastError], ) return {children} } export function useRealtime(): RealtimeApi { return useContext(RealtimeContext) ?? DISABLED } export function useRealtimeEnvelope(handler: (envelope: KenEventEnvelope) => void): void { const realtime = useRealtime() const ref = useRef(handler) useEffect(() => { ref.current = handler }) useEffect(() => realtime.onEnvelope((envelope) => ref.current(envelope)), [realtime]) } /** 读取信封里的资源属性(RocketMQ 用户属性都是字符串)。 */ export const envelopeProperty = prop