rxprotoplex-pingpong
Version:
A ping-pong mechanism using rxprotoplex and RxJS for managing reliable connections.
262 lines (236 loc) • 11.7 kB
JavaScript
import { Observable, Subject, ReplaySubject, fromEvent, EMPTY, interval, take } from 'rxjs';
import { takeUntil, filter, tap, finalize, switchMap, catchError, timeout, retry } from 'rxjs/operators';
import { CHANNEL } from "./CHANNEL.js";
import {connect$, destroy, listenAndConnection$, withEncoding} from "rxprotoplex";
/**
* Manages a ping-pong mechanism over a Plex connection to maintain connectivity.
* It supports error handling, reconnection logic, and customizable behavior.
*
* @param {Object} plex - The Plex connection object that facilitates communication.
* @param {boolean} isInitiator - Specifies whether this instance initiates the connection.
* @param {Object} [config] - Configuration options for the ping-pong mechanism.
* @param {string | Uint8Array | Buffer} [config.channel=CHANNEL] - The communication channel used for messages.
* @param {number} [config.interval=6000] - Interval (in milliseconds) between ping-pong messages.
* @param {number} [config.connectionTimeout=1000] - Timeout (in milliseconds) for initial connection setup.
* @param {number} [config.retryDelay=1000] - Delay (in milliseconds) between reconnection attempts.
* @param {number} [config.reconnectAttemptCount=3] - Maximum number of reconnection attempts before giving up.
* @param {boolean} [config.log=false] - Enables logging of connection and ping-pong events.
* @param {Function} [config.onPingPongFailure] - Optional custom handler for ping-pong failures.
* If provided, errors will not propagate to the observable.
* The handler receives an `Error` object as its argument.
* @returns {Observable<{ type: string, plex: Object }>} - An observable that emits events:
* - `type: 'ping'` when a ping message is sent or received.
* - `type: 'pong'` when a pong message is received.
* - Includes the `plex` object for context.
*
* @example
* const subscription = plexPingPong(plex, true, {
* channel: '$PINGPONG$',
* interval: 5000,
* log: true,
* onPingPongFailure: (err) => console.error('Ping-Pong failed:', err.message),
* }).subscribe({
* next: (event) => console.log('Ping-Pong event:', event),
* error: (err) => console.error('Connection error:', err),
* complete: () => console.log('Connection closed'),
* });
*
* // To clean up:
* subscription.unsubscribe();
*/
const plexPingPong = (plex, isInitiator, config = {}) => {
const {
channel = CHANNEL,
interval: _interval = 6000,
connectionTimeout = 1000, // Default timeout for connection setup
retryDelay = 1000,
reconnectAttemptCount = 3,
log = false,
onPingPongFailure // Custom handler for failures
} = config;
// Helper function for conditional logging
const logMessage = (level, message) => {
if (log) {
switch (level) {
case 'info':
console.info(message);
break;
case 'warn':
console.warn(message);
break;
case 'error':
console.error(message);
break;
default:
console.log(message);
}
}
};
const obs = new Observable((subscriber) => {
const disconnect$ = new Subject();
let isDisconnected = false;
const heartbeatSubject = new Subject();
let pingSubscription;
const performDisconnect = (error) => {
if (!isDisconnected) {
isDisconnected = true;
disconnect$.next();
disconnect$.complete();
heartbeatSubject.complete();
if (pingSubscription) {
pingSubscription.unsubscribe();
}
logMessage('info', `performDisconnect called for channel '${channel}'.`);
// Call destroy on the Plex instance
if (!plex.destroyed) {
destroy(plex/*, error || new Error('Ping-Pong failure detected')*/);
}
if (onPingPongFailure && typeof onPingPongFailure === 'function') {
try {
onPingPongFailure(error || new Error('Ping-Pong failure detected'));
} catch (handlerError) {
logMessage('error', `Error in onPingPongFailure handler: ${handlerError.message}`);
}
}
if (!onPingPongFailure) {
logMessage('info', `Emitting error for channel '${channel}': ${error.message}`);
subscriber.error(error || new Error('Ping-Pong failure detected'));
}
} else {
logMessage('warn', `performDisconnect called again for channel '${channel}' but is already disconnected.`);
}
};
const handleStream = (stream) => {
const data$ = fromEvent(stream, 'data').pipe(
takeUntil(disconnect$),
filter(data => data === "ping" || data === "pong"),
tap((msg) => {
if (msg === 'ping') {
stream.write("pong");
logMessage('info', `Received 'ping' on channel '${channel}'. Responded with 'pong'.`);
subscriber.next({ type: 'ping', plex });
} else if (msg === 'pong') {
heartbeatSubject.next();
logMessage('info', `Received 'pong' on channel '${channel}'. Connection is active.`);
subscriber.next({ type: 'pong', plex });
}
}),
finalize(() => logMessage('info', `data$ finalized for channel '${channel}'`))
);
return data$.subscribe({
error: (err) => {
logMessage('error', `Stream error on channel '${channel}': ${err.message}`);
performDisconnect(err);
},
complete: () => {
logMessage('warn', `Stream on channel '${channel}' has been completed unexpectedly or closed.`);
performDisconnect(new Error('Stream completed unexpectedly'));
}
});
};
const heartbeat$ = heartbeatSubject.pipe(
takeUntil(disconnect$),
switchMap(() => EMPTY.pipe(
timeout(_interval),
catchError((err) => {
logMessage('error', `Connection lost due to missed 'pong' on channel '${channel}'.`);
performDisconnect(err);
return EMPTY;
})
)),
finalize(() => logMessage('info', `heartbeat$ finalized for channel '${channel}'`))
);
const heartbeatSubscription = heartbeat$.subscribe();
const initiateConnection = () => {
return connect$(plex, channel, withEncoding('json')).pipe(
takeUntil(disconnect$),
timeout(connectionTimeout), // Timeout for initial connection setup
switchMap((stream) => {
const streamSubscription = handleStream(stream);
if (isInitiator) {
pingSubscription = interval(_interval / 2).pipe(
takeUntil(disconnect$)
).subscribe(() => {
if (!stream.destroyed) {
stream.write("ping");
logMessage('info', `Sent 'ping' on channel '${channel}' to maintain connection.`);
subscriber.next({ type: 'ping', plex });
} else {
performDisconnect(new Error('Stream destroyed'));
logMessage('info', `Stream destroyed for channel '${channel}'. Triggering disconnection.`);
}
});
}
return disconnect$.pipe(
tap(() => {
if (!stream.destroyed) {
stream.destroy();
logMessage('info', `Stream destroyed for channel '${channel}'.`);
}
streamSubscription.unsubscribe();
if (isInitiator && pingSubscription) {
pingSubscription.unsubscribe();
}
}),
take(1)
);
}),
retry({ delay: retryDelay, count: reconnectAttemptCount, resetOnSuccess: true }),
finalize(() => logMessage('info', `Connection on channel '${channel}' finalized`))
).subscribe({
error: (err) => {
logMessage('error', `Connection error on channel '${channel}': ${err.message}`);
performDisconnect(err); // Ensure cleanup on timeout or failure
},
complete: () => logMessage('warn', `Connection on channel '${channel}' has been completed.`)
});
};
const listenConnection = () => {
return listenAndConnection$(plex, channel, withEncoding('json')).pipe(
takeUntil(disconnect$),
timeout(connectionTimeout), // Timeout for initial connection setup
switchMap((stream) => {
const streamSubscription = handleStream(stream);
return disconnect$.pipe(
tap(() => {
if (!stream.destroyed) {
stream.destroy();
logMessage('info', `Stream destroyed for channel '${channel}'.`);
}
streamSubscription.unsubscribe();
}),
take(1)
);
}),
retry({ delay: retryDelay, count: reconnectAttemptCount, resetOnSuccess: true }),
finalize(() => logMessage('info', `Listener on channel '${channel}' finalized`))
).subscribe({
error: (err) => {
logMessage('error', `Listener error on channel '${channel}': ${err.message}`);
performDisconnect(err); // Ensure cleanup on timeout or failure
},
complete: () => logMessage('warn', `Listener on channel '${channel}' has been completed.`)
});
};
const plexCloseSubscription = plex.close$.subscribe(() => {
logMessage('warn', `>>>>>>>> Plex closes on channel '${channel}'.`);
performDisconnect(new Error('Plex connection closed'));
});
const connectionSubscription = isInitiator ? initiateConnection() : listenConnection();
heartbeatSubject.next();
return () => {
performDisconnect(new Error('Unsubscribed'));
plexCloseSubscription.unsubscribe();
connectionSubscription.unsubscribe();
heartbeatSubscription.unsubscribe();
if (pingSubscription) {
pingSubscription.unsubscribe();
}
logMessage('info', `Teardown complete for channel '${channel}'.`);
};
}).pipe(
finalize(() => logMessage('info', `Observable completed and disconnected for channel '${channel}'`))
);
return obs;
};
export { plexPingPong };