feat: add shared SSE event transport for real-time sync

This commit is contained in:
Vaibhaav 2026-06-26 04:01:41 +05:30
parent 06a21ecbe1
commit 57694a48a6
4 changed files with 144 additions and 0 deletions

View File

@ -3,5 +3,7 @@ export * from './api/client';
export * from './api/paths';
export * from './api/types';
export * from './lib/bridge';
export * from './lib/event-transport';
export * from './lib/events-connection';
export * from './hooks/useWorkspaceQuery';
export * from './hooks/useDaemonStatus';

View File

@ -0,0 +1,31 @@
// packages/shared/src/lib/event-transport.test.ts
import { describe, it, expect, beforeEach } from '@jest/globals';
import { createEventTransport } from './event-transport';
import { QueryClient } from '@tanstack/react-query';
import { setPlatformBridge } from './bridge';
describe('EventTransport', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient();
setPlatformBridge({
getDaemonStatus: async () => ({ running: true, port: 3001 }),
startDaemon: async () => {},
stopDaemon: async () => {},
getApiBaseUrl: () => 'http://test:3001',
subscribeApiBaseUrl: () => () => {},
});
});
it('should create transport with connect method', () => {
const transport = createEventTransport(queryClient);
expect(typeof transport.connect).toBe('function');
});
it('should return cleanup function from connect', () => {
const transport = createEventTransport(queryClient);
const cleanup = transport.connect();
expect(typeof cleanup).toBe('function');
});
});

View File

@ -0,0 +1,89 @@
// packages/shared/src/lib/event-transport.ts
import type { QueryClient } from '@tanstack/react-query';
import { getPlatformBridge } from './bridge';
import { setEventsConnectionState } from './events-connection';
import { WORKSPACE_QUERY_KEY } from '../hooks/useWorkspaceQuery';
export type EventTransport = {
connect: () => () => void;
};
const INVALIDATE_DEBOUNCE_MS = 150;
const SSE_RETRY_MS = 5_000;
const EVENTSOURCE_CLOSED = 2;
const CDC_EVENT_TYPES = [
'session_created',
'session_updated',
'pr_created',
'pr_updated',
'pr_check_recorded',
'pr_session_changed',
'pr_review_thread_added',
'pr_review_thread_resolved',
] as const;
export function createEventTransport(queryClient: QueryClient): EventTransport {
return {
connect() {
let debounce: ReturnType<typeof setTimeout> | undefined;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let source: EventSource | undefined;
let sourceBaseUrl: string | undefined;
const refreshWorkspaces = () => {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => {
void queryClient.invalidateQueries({ queryKey: WORKSPACE_QUERY_KEY });
}, INVALIDATE_DEBOUNCE_MS);
};
const scheduleRetry = () => {
if (retryTimer) return;
retryTimer = setTimeout(() => {
retryTimer = undefined;
connectSource();
}, SSE_RETRY_MS);
};
const bridge = getPlatformBridge();
const connectSource = () => {
if (typeof EventSource === 'undefined') return;
const baseUrl = bridge.getApiBaseUrl();
if (source && sourceBaseUrl === baseUrl && source.readyState !== EVENTSOURCE_CLOSED) return;
source?.close();
source = undefined;
sourceBaseUrl = baseUrl;
try {
source = new EventSource(`${baseUrl.replace(/\/+$/, '')}/api/v1/events`);
source.onopen = () => {
setEventsConnectionState('connected');
refreshWorkspaces();
};
source.onerror = () => {
setEventsConnectionState('disconnected');
if (source?.readyState === EVENTSOURCE_CLOSED) scheduleRetry();
};
source.onmessage = refreshWorkspaces;
for (const type of CDC_EVENT_TYPES) {
source.addEventListener(type, refreshWorkspaces);
}
} catch {
source = undefined;
}
};
const removeBaseUrlListener = bridge.subscribeApiBaseUrl(connectSource);
connectSource();
return () => {
if (debounce) clearTimeout(debounce);
if (retryTimer) clearTimeout(retryTimer);
removeBaseUrlListener();
source?.close();
setEventsConnectionState('idle');
};
},
};
}

View File

@ -0,0 +1,22 @@
// packages/shared/src/lib/events-connection.ts
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected';
let connectionState: ConnectionState = 'idle';
const listeners: Set<(state: ConnectionState) => void> = new Set();
export function setEventsConnectionState(state: ConnectionState): void {
connectionState = state;
listeners.forEach((listener) => listener(state));
}
export function getEventsConnectionState(): ConnectionState {
return connectionState;
}
export function subscribeEventsConnectionState(
callback: (state: ConnectionState) => void
): () => void {
listeners.add(callback);
callback(connectionState);
return () => listeners.delete(callback);
}