LCOV - code coverage report
Current view: top level - dashql-app/src/demos/stock_analysis - websocket_feed.ts Coverage Total Hit
Test: DashQL 915828b Lines: 96.2 % 80 77
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import type { Tick, TickFeed, TickFeedStatus } from './tick_feed.js';
       2              : 
       3              : /// The longest wait before a reconnect
       4            1 : const MAX_RECONNECT_DELAY_MS = 30 * 1000;
       5              : 
       6              : /// What a message of a market data stream holds for the feed
       7              : export type FeedMessage =
       8              :     /// A trade
       9              :     | { tick: Tick }
      10              :     /// An error that the stream reports
      11              :     | { error: string }
      12              :     /// Anything else, e.g. the confirmation of a subscription or a quote that the next trade carries
      13              :     | null;
      14              : 
      15              : /// The protocol of a market data stream
      16              : export interface FeedProtocol {
      17              :     /// The url of the stream
      18              :     url: string;
      19              :     /// The message to send once the socket is open, e.g. a subscription, null if the url subscribes
      20              :     subscription(): string | null;
      21              :     /// Read a text message of the stream
      22              :     read(data: string): FeedMessage;
      23              : }
      24              : 
      25              : /// The options of a WebSocket feed
      26              : export interface WebSocketFeedOptions {
      27              :     /// The time between two deliveries
      28              :     emitIntervalMs?: number;
      29              :     /// Create the socket, for tests
      30              :     createSocket?: (url: string) => WebSocket;
      31              : }
      32              : 
      33              : /// The trades of a market data stream over a WebSocket, delivered in batches.
      34              : /// The feed reconnects with an increasing delay when the socket closes.
      35            1 : export class WebSocketTickFeed implements TickFeed {
      36              :     /// The socket
      37            1 :     private socket: WebSocket | null = null;
      38              :     /// The ticks since the last delivery
      39            1 :     private pending: Tick[] = [];
      40              :     /// The timer of the deliveries
      41            1 :     private emitTimer: ReturnType<typeof setInterval> | null = null;
      42              :     /// The timer of the next reconnect
      43            1 :     private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
      44              :     /// The delay of the next reconnect
      45            1 :     private reconnectDelayMs = 1000;
      46              :     /// Is the feed stopped?
      47            1 :     private stopped = true;
      48              : 
      49            1 :     private readonly emitIntervalMs: number;
      50            1 :     private readonly createSocket: (url: string) => WebSocket;
      51              : 
      52            1 :     constructor(private readonly protocol: FeedProtocol, options: WebSocketFeedOptions = {}) {
      53            1 :         this.emitIntervalMs = options.emitIntervalMs ?? 100;
      54            1 :         this.createSocket = options.createSocket ?? (url => new WebSocket(url));
      55            1 :     }
      56              : 
      57            1 :     start(emit: (ticks: Tick[]) => void, onStatus: (status: TickFeedStatus) => void): void {
      58            1 :         this.stop();
      59            1 :         this.stopped = false;
      60            1 :         this.emitTimer = setInterval(() => {
      61            1 :             if (this.pending.length === 0) return;
      62            1 :             const ticks = this.pending;
      63            1 :             this.pending = [];
      64            1 :             emit(ticks);
      65            1 :         }, this.emitIntervalMs);
      66              :         // Every trade would report the feed as live again, only changes are reported
      67            1 :         let reported: string | null = null;
      68            1 :         this.connect(status => {
      69            1 :             const key = `${status.state}:${status.detail ?? ''}`;
      70            1 :             if (key === reported) return;
      71            1 :             reported = key;
      72            1 :             onStatus(status);
      73            1 :         });
      74            1 :     }
      75              : 
      76            1 :     private connect(onStatus: (status: TickFeedStatus) => void): void {
      77            1 :         onStatus({ state: 'connecting' });
      78            1 :         const socket = this.createSocket(this.protocol.url);
      79            1 :         this.socket = socket;
      80            1 :         socket.onopen = () => {
      81            1 :             const subscription = this.protocol.subscription();
      82            1 :             if (subscription != null) socket.send(subscription);
      83            1 :         };
      84            1 :         socket.onmessage = (event: MessageEvent) => {
      85            1 :             if (typeof event.data !== 'string') return;
      86            1 :             const message = this.protocol.read(event.data);
      87            1 :             if (message == null) return;
      88            1 :             if ('error' in message) {
      89            0 :                 onStatus({ state: 'failed', detail: message.error });
      90            0 :                 return;
      91            0 :             }
      92            1 :             if (this.pending.length === 0) this.reconnectDelayMs = 1000;
      93            1 :             this.pending.push(message.tick);
      94            1 :             onStatus({ state: 'live' });
      95            1 :         };
      96            1 :         socket.onclose = () => {
      97            1 :             if (this.stopped || this.socket !== socket) return;
      98            1 :             this.socket = null;
      99            1 :             const delay = this.reconnectDelayMs;
     100            1 :             this.reconnectDelayMs = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS);
     101            1 :             onStatus({ state: 'connecting', detail: `Reconnecting in ${Math.round(delay / 1000)}s` });
     102            1 :             this.reconnectTimer = setTimeout(() => {
     103            1 :                 this.reconnectTimer = null;
     104            1 :                 if (!this.stopped) this.connect(onStatus);
     105            1 :             }, delay);
     106            1 :         };
     107            1 :     }
     108              : 
     109            1 :     stop(): void {
     110            1 :         this.stopped = true;
     111            1 :         if (this.emitTimer != null) clearInterval(this.emitTimer);
     112            1 :         if (this.reconnectTimer != null) clearTimeout(this.reconnectTimer);
     113            1 :         this.emitTimer = null;
     114            1 :         this.reconnectTimer = null;
     115            1 :         const socket = this.socket;
     116            1 :         this.socket = null;
     117            1 :         socket?.close();
     118            1 :         this.pending = [];
     119            1 :     }
     120            1 : }
     121              : 
     122              : /// A number of a stream, the streams send numbers as strings
     123            1 : export function parseFeedNumber(value: unknown): number | null {
     124            1 :     if (typeof value !== 'string' && typeof value !== 'number') return null;
     125            1 :     const number = Number(value);
     126            1 :     return Number.isFinite(number) ? number : null;
     127            1 : }
        

Generated by: LCOV version 2.0-1