Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,58 @@ stock.on('message', (message) => {
});
```

### Health Check

The WebSocket client can monitor connection liveness using an app-level
JSON ping/pong (`{ event: 'ping' }` / `{ event: 'pong' }`). It is disabled by
default. When enabled, on each interval tick the client checks whether **any**
inbound message has arrived since the last ping it sent (freshness check):

- If nothing arrived since the last ping, that counts as a *miss* and the
consecutive-miss counter is incremented.
- If any inbound message (a `pong`, market data, or anything else) arrived,
the counter is reset to `0`.
- Once the consecutive-miss counter reaches `maxMissedPongs`, the client
disconnects and stops the timer.

| Option | Type | Default | Description |
| ---------------- | --------- | ------- | ----------------------------------------------------------------- |
| `enabled` | `boolean` | `false` | Enables the health-check ping/pong. |
| `pingInterval` | `number` | `30000` | Interval in milliseconds between health-check pings. |
| `maxMissedPongs` | `number` | `2` | Consecutive misses (no inbound messages) before disconnecting. |

```js
const client = new WebSocketClient({
apiKey: 'YOUR_API_KEY',
healthCheck: {
enabled: true,
pingInterval: 30000,
maxMissedPongs: 2,
},
});
```

#### Disconnect reason

When the client disconnects because of a health-check timeout, the
`disconnect` event carries a second argument `{ reason: 'health-check-timeout' }`.
Normal or manual disconnects emit `disconnect` **without** a second argument
(it is `undefined`). Listeners that only read the first argument (the close
event) continue to work unchanged.

```js
const stock = client.stock;

stock.on('disconnect', (event, info) => {
if (info?.reason === 'health-check-timeout') {
console.log('Health check timed out, reconnecting...');
stock.connect().then(() => {
stock.subscribe({ channel: 'trades', symbol: '2330' });
});
}
});
```

## License

[MIT](LICENSE)
Expand Down
59 changes: 45 additions & 14 deletions src/websocket/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@ export interface WebSocketClientOptions {
healthCheck?: HealthCheckConfig;
}

export interface DisconnectReason {
reason: 'health-check-timeout';
}

export class WebSocketClient extends events.EventEmitter {
private socket!: WebSocket;
private missedPongs = 0;
private consecutiveMisses = 0;
private lastMessageAt = 0;
private lastPingAt = 0;
private pingTimerId: ReturnType<typeof setTimeout> | undefined;
private pendingDisconnectReason: DisconnectReason | undefined;

constructor(protected readonly options: WebSocketClientOptions) {
super();
Expand All @@ -28,9 +35,17 @@ export class WebSocketClient extends events.EventEmitter {
public connect() {
this.socket = new WebSocket(this.options.url);
this.socket.onopen = () => this.emit(CONNECT_EVENT);
this.socket.onmessage = event => this.emit(MESSAGE_EVENT, event.data);
this.socket.onmessage = event => {
// Any inbound message counts as freshness.
this.lastMessageAt = Date.now();
this.emit(MESSAGE_EVENT, event.data);
};
this.socket.onerror = event => this.emit(ERROR_EVENT, event.error);
this.socket.onclose = event => this.emit(DISCONNECT_EVENT, event);
this.socket.onclose = event => {
const reason = this.pendingDisconnectReason;
this.pendingDisconnectReason = undefined;
this.emit(DISCONNECT_EVENT, event, reason);
};
this.on(CONNECT_EVENT, () => this.authenticate());
this.on(MESSAGE_EVENT, message => this.handleMessage(message));

Expand All @@ -44,22 +59,36 @@ export class WebSocketClient extends events.EventEmitter {
this.socket.close();
if (this.pingTimerId) {
clearInterval(this.pingTimerId);
this.pingTimerId = undefined;
}
}

private detectConnectionStatus(state?: string) {
private detectConnectionStatus() {
if (!this.options.healthCheck?.enabled) return;

// Clamp to >= 1: maxMissedPongs of 0 would disconnect a healthy connection
// on the first tick (consecutiveMisses starts at 0, and 0 >= 0).
const maxMissed = Math.max(1, this.options.healthCheck.maxMissedPongs ?? 2);

// Freshness check: did anything arrive since our last ping?
if (this.lastMessageAt < this.lastPingAt) {
this.consecutiveMisses += 1;
} else {
this.consecutiveMisses = 0;
}

if (this.consecutiveMisses >= maxMissed) {
this.pendingDisconnectReason = { reason: 'health-check-timeout' };
this.disconnect();
return;
}

try {
this.ping({ state });
this.missedPongs += 1;
const maxMissed = this.options.healthCheck.maxMissedPongs ?? 2;
if (this.missedPongs > maxMissed) {
this.disconnect();
return;
}
this.ping({});
this.lastPingAt = Date.now();
} catch (error) {
console.error(`Failed to send ping: ${error}`);
this.pendingDisconnectReason = { reason: 'health-check-timeout' };
this.disconnect();
return;
}
Expand Down Expand Up @@ -107,6 +136,11 @@ export class WebSocketClient extends events.EventEmitter {
// Start health check if enabled
if (this.options.healthCheck?.enabled) {
const interval = this.options.healthCheck.pingInterval ?? 30000;
this.consecutiveMisses = 0;
// Seed timestamps so the first tick treats the connection as fresh.
const now = Date.now();
this.lastMessageAt = now;
this.lastPingAt = now;
this.pingTimerId = setInterval(() => {
this.detectConnectionStatus();
}, interval);
Expand All @@ -117,9 +151,6 @@ export class WebSocketClient extends events.EventEmitter {
this.emit(UNAUTHENTICATED_EVENT, data);
}
}
if (event === 'pong') {
this.missedPongs = 0;
}
} catch (err) {}
}
}
Loading
Loading