feat(streaming): add global activity watchers, client-side buffering, and improved real-time streaming UX
This commit is contained in:
@@ -60,8 +60,13 @@ export class ChangeStreamService {
|
||||
private typedSocket: plugins.typedsocket.TypedSocket | null = null;
|
||||
private isConnected = false;
|
||||
private isConnecting = false;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private subscriptions: Map<string, ISubscription> = new Map();
|
||||
|
||||
// Buffer activity events so they survive tab switches
|
||||
private activityBuffer: IActivityEvent[] = [];
|
||||
private static readonly ACTIVITY_BUFFER_SIZE = 500;
|
||||
|
||||
// RxJS Subjects for UI components
|
||||
public readonly mongoChanges$ = new plugins.smartrx.rxjs.Subject<IMongoChangeEvent>();
|
||||
public readonly s3Changes$ = new plugins.smartrx.rxjs.Subject<IS3ChangeEvent>();
|
||||
@@ -74,48 +79,75 @@ export class ChangeStreamService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the WebSocket server
|
||||
* Connect to the WebSocket server.
|
||||
* If a connection is already in progress, waits for it to complete.
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.isConnected || this.isConnecting) {
|
||||
if (this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If already connecting, wait for the existing attempt to finish
|
||||
if (this.isConnecting && this.connectPromise) {
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
this.connectionStatus$.next('connecting');
|
||||
|
||||
this.connectPromise = (async () => {
|
||||
try {
|
||||
// Create client router to handle server-initiated pushes
|
||||
const clientRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Register handlers for server push events
|
||||
this.registerPushHandlers(clientRouter);
|
||||
|
||||
// Connect to WebSocket server using current origin
|
||||
this.typedSocket = await plugins.typedsocket.TypedSocket.createClient(
|
||||
clientRouter,
|
||||
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl()
|
||||
);
|
||||
|
||||
this.isConnected = true;
|
||||
this.isConnecting = false;
|
||||
this.connectionStatus$.next('connected');
|
||||
|
||||
console.log('[ChangeStream] WebSocket connected');
|
||||
|
||||
// Handle reconnection events via statusSubject
|
||||
this.typedSocket.statusSubject.subscribe((status) => {
|
||||
if (status === 'disconnected') {
|
||||
this.handleDisconnect();
|
||||
} else if (status === 'connected') {
|
||||
this.handleReconnect();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.isConnecting = false;
|
||||
this.connectPromise = null;
|
||||
this.connectionStatus$.next('disconnected');
|
||||
console.error('[ChangeStream] Failed to connect:', error);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a WebSocket connection is established.
|
||||
* If not connected, triggers connect() and returns whether connection succeeded.
|
||||
*/
|
||||
private async ensureConnected(): Promise<boolean> {
|
||||
if (this.isConnected && this.typedSocket) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
// Create client router to handle server-initiated pushes
|
||||
const clientRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Register handlers for server push events
|
||||
this.registerPushHandlers(clientRouter);
|
||||
|
||||
// Connect to WebSocket server using current origin
|
||||
this.typedSocket = await plugins.typedsocket.TypedSocket.createClient(
|
||||
clientRouter,
|
||||
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl()
|
||||
);
|
||||
|
||||
this.isConnected = true;
|
||||
this.isConnecting = false;
|
||||
this.connectionStatus$.next('connected');
|
||||
|
||||
console.log('[ChangeStream] WebSocket connected');
|
||||
|
||||
// Handle reconnection events via statusSubject
|
||||
this.typedSocket.statusSubject.subscribe((status) => {
|
||||
if (status === 'disconnected') {
|
||||
this.handleDisconnect();
|
||||
} else if (status === 'connected') {
|
||||
this.handleReconnect();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.isConnecting = false;
|
||||
this.connectionStatus$.next('disconnected');
|
||||
console.error('[ChangeStream] Failed to connect:', error);
|
||||
throw error;
|
||||
await this.connect();
|
||||
return this.isConnected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +167,7 @@ export class ChangeStreamService {
|
||||
|
||||
this.typedSocket = null;
|
||||
this.isConnected = false;
|
||||
this.connectPromise = null;
|
||||
this.subscriptions.clear();
|
||||
this.connectionStatus$.next('disconnected');
|
||||
|
||||
@@ -172,6 +205,10 @@ export class ChangeStreamService {
|
||||
new plugins.typedrequest.TypedHandler<any>(
|
||||
'pushActivityEvent',
|
||||
async (data: { event: IActivityEvent }) => {
|
||||
this.activityBuffer.push(data.event);
|
||||
if (this.activityBuffer.length > ChangeStreamService.ACTIVITY_BUFFER_SIZE) {
|
||||
this.activityBuffer = this.activityBuffer.slice(-ChangeStreamService.ACTIVITY_BUFFER_SIZE);
|
||||
}
|
||||
this.activityEvents$.next(data.event);
|
||||
return { received: true };
|
||||
}
|
||||
@@ -228,8 +265,11 @@ export class ChangeStreamService {
|
||||
*/
|
||||
public async subscribeToCollection(database: string, collection: string): Promise<boolean> {
|
||||
if (!this.typedSocket || !this.isConnected) {
|
||||
console.warn('[ChangeStream] Not connected, cannot subscribe');
|
||||
return false;
|
||||
const connected = await this.ensureConnected();
|
||||
if (!connected) {
|
||||
console.warn('[ChangeStream] Not connected, cannot subscribe');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const key = `${database}/${collection}`;
|
||||
@@ -307,8 +347,11 @@ export class ChangeStreamService {
|
||||
*/
|
||||
public async subscribeToBucket(bucket: string, prefix?: string): Promise<boolean> {
|
||||
if (!this.typedSocket || !this.isConnected) {
|
||||
console.warn('[ChangeStream] Not connected, cannot subscribe');
|
||||
return false;
|
||||
const connected = await this.ensureConnected();
|
||||
if (!connected) {
|
||||
console.warn('[ChangeStream] Not connected, cannot subscribe');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const key = prefix ? `${bucket}/${prefix}` : bucket;
|
||||
@@ -387,8 +430,11 @@ export class ChangeStreamService {
|
||||
*/
|
||||
public async subscribeToActivity(): Promise<boolean> {
|
||||
if (!this.typedSocket || !this.isConnected) {
|
||||
console.warn('[ChangeStream] Not connected, cannot subscribe');
|
||||
return false;
|
||||
const connected = await this.ensureConnected();
|
||||
if (!connected) {
|
||||
console.warn('[ChangeStream] Not connected, cannot subscribe');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already subscribed
|
||||
@@ -450,7 +496,10 @@ export class ChangeStreamService {
|
||||
*/
|
||||
public async getRecentActivity(limit: number = 100): Promise<IActivityEvent[]> {
|
||||
if (!this.typedSocket || !this.isConnected) {
|
||||
return [];
|
||||
const connected = await this.ensureConnected();
|
||||
if (!connected) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -470,6 +519,13 @@ export class ChangeStreamService {
|
||||
return this.subscriptions.has('activity:activity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buffered activity events (captured regardless of UI subscriber)
|
||||
*/
|
||||
public getBufferedActivity(): IActivityEvent[] {
|
||||
return [...this.activityBuffer];
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Observables for UI Components
|
||||
// ===========================================
|
||||
|
||||
Reference in New Issue
Block a user