@trpc/client
Version:
256 lines (221 loc) • 6.81 kB
text/typescript
import { behaviorSubject } from '@trpc/server/observable';
import type { UrlOptionsWithConnectionParams } from '../../internals/urlWithConnectionParams';
import type { Encoder } from './encoder';
import { buildConnectionMessage, prepareUrl, withResolvers } from './utils';
/**
* Opens a WebSocket connection asynchronously and returns a promise
* that resolves when the connection is successfully established.
* The promise rejects if an error occurs during the connection attempt.
*/
function asyncWsOpen(ws: WebSocket) {
const { promise, resolve, reject } = withResolvers<void>();
ws.addEventListener('open', () => {
ws.removeEventListener('error', reject);
resolve();
});
ws.addEventListener('error', reject);
return promise;
}
interface PingPongOptions {
/**
* The interval (in milliseconds) between "PING" messages.
*/
intervalMs: number;
/**
* The timeout (in milliseconds) to wait for a "PONG" response before closing the connection.
*/
pongTimeoutMs: number;
}
/**
* Sets up a periodic ping-pong mechanism to keep the WebSocket connection alive.
*
* - Sends "PING" messages at regular intervals defined by `intervalMs`.
* - If a "PONG" response is not received within the `pongTimeoutMs`, the WebSocket is closed.
* - The ping timer resets upon receiving any message to maintain activity.
* - Automatically starts the ping process when the WebSocket connection is opened.
* - Cleans up timers when the WebSocket is closed.
*
* @param ws - The WebSocket instance to manage.
* @param options - Configuration options for ping-pong intervals and timeouts.
*/
function setupPingInterval(
ws: WebSocket,
{ intervalMs, pongTimeoutMs }: PingPongOptions,
) {
let pingTimeout: ReturnType<typeof setTimeout> | undefined;
let pongTimeout: ReturnType<typeof setTimeout> | undefined;
function start() {
pingTimeout = setTimeout(() => {
ws.send('PING');
pongTimeout = setTimeout(() => {
ws.close();
}, pongTimeoutMs);
}, intervalMs);
}
function reset() {
clearTimeout(pingTimeout);
start();
}
function pong() {
clearTimeout(pongTimeout);
reset();
}
ws.addEventListener('open', start);
ws.addEventListener('message', ({ data }) => {
clearTimeout(pingTimeout);
start();
if (data === 'PONG') {
pong();
}
});
ws.addEventListener('close', () => {
clearTimeout(pingTimeout);
clearTimeout(pongTimeout);
});
}
export interface WebSocketConnectionOptions {
WebSocketPonyfill?: typeof WebSocket;
urlOptions: UrlOptionsWithConnectionParams;
keepAlive: PingPongOptions & {
enabled: boolean;
};
encoder: Encoder;
}
/**
* Manages a WebSocket connection with support for reconnection, keep-alive mechanisms,
* and observable state tracking.
*/
export class WsConnection {
static connectCount = 0;
public id = ++WsConnection.connectCount;
private readonly WebSocketPonyfill: typeof WebSocket;
private readonly urlOptions: UrlOptionsWithConnectionParams;
private readonly keepAliveOpts: WebSocketConnectionOptions['keepAlive'];
private readonly encoder: Encoder;
public readonly wsObservable = behaviorSubject<WebSocket | null>(null);
constructor(opts: WebSocketConnectionOptions) {
this.WebSocketPonyfill = opts.WebSocketPonyfill ?? WebSocket;
if (!this.WebSocketPonyfill) {
throw new Error(
"No WebSocket implementation found - you probably don't want to use this on the server, but if you do you need to pass a `WebSocket`-ponyfill",
);
}
this.urlOptions = opts.urlOptions;
this.keepAliveOpts = opts.keepAlive;
this.encoder = opts.encoder;
}
public get ws() {
return this.wsObservable.get();
}
private set ws(ws) {
this.wsObservable.next(ws);
}
/**
* Checks if the WebSocket connection is open and ready to communicate.
*/
public isOpen(): this is { ws: WebSocket } {
return (
!!this.ws &&
this.ws.readyState === this.WebSocketPonyfill.OPEN &&
!this.openPromise
);
}
/**
* Checks if the WebSocket connection is closed or in the process of closing.
*/
public isClosed(): this is { ws: WebSocket } {
return (
!!this.ws &&
(this.ws.readyState === this.WebSocketPonyfill.CLOSING ||
this.ws.readyState === this.WebSocketPonyfill.CLOSED)
);
}
/**
* Manages the WebSocket opening process, ensuring that only one open operation
* occurs at a time. Tracks the ongoing operation with `openPromise` to avoid
* redundant calls and ensure proper synchronization.
*
* Sets up the keep-alive mechanism and necessary event listeners for the connection.
*
* @returns A promise that resolves once the WebSocket connection is successfully opened.
*/
private openPromise: Promise<void> | null = null;
public async open() {
if (this.openPromise) return this.openPromise;
this.id = ++WsConnection.connectCount;
const wsPromise = prepareUrl(this.urlOptions).then(
(url) => new this.WebSocketPonyfill(url),
);
this.openPromise = wsPromise.then(async (ws) => {
this.ws = ws;
// Set binaryType to handle both text and binary messages consistently
ws.binaryType = 'arraybuffer';
// Setup ping listener
ws.addEventListener('message', function ({ data }) {
if (data === 'PING') {
this.send('PONG');
}
});
if (this.keepAliveOpts.enabled) {
setupPingInterval(ws, this.keepAliveOpts);
}
ws.addEventListener('close', () => {
if (this.ws === ws) {
this.ws = null;
}
});
await asyncWsOpen(ws);
if (this.urlOptions.connectionParams) {
ws.send(
await buildConnectionMessage(
this.urlOptions.connectionParams,
this.encoder,
),
);
}
});
try {
await this.openPromise;
} finally {
this.openPromise = null;
}
}
/**
* Closes the WebSocket connection gracefully.
* Waits for any ongoing open operation to complete before closing.
*/
public async close() {
try {
await this.openPromise;
} finally {
this.ws?.close();
}
}
}
/**
* Provides a backward-compatible representation of the connection state.
*/
export function backwardCompatibility(connection: WsConnection) {
if (connection.isOpen()) {
return {
id: connection.id,
state: 'open',
ws: connection.ws,
} as const;
}
if (connection.isClosed()) {
return {
id: connection.id,
state: 'closed',
ws: connection.ws,
} as const;
}
if (!connection.ws) {
return null;
}
return {
id: connection.id,
state: 'connecting',
ws: connection.ws,
} as const;
}