diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index 10f3fd6..692da78 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -2,6 +2,14 @@ import type { Transport } from '@hawk.so/core'; import { log } from '@hawk.so/core'; import type { CatcherMessage } from '@/types'; import type { CatcherMessageType } from '@hawk.so/types'; +import { singleFlight } from '../utils/single-flight'; + +/** + * WebSocket close codes that represent an intentional, expected closure. + * See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code + */ +const WS_CLOSE_NORMAL = 1000; +const WS_CLOSE_GOING_AWAY = 1001; /** * Custom WebSocket wrapper class @@ -30,8 +38,8 @@ export default class Socket private readonly onClose: (event: CloseEvent) => void; /** - * Queue of events collected while socket is not connected - * They will be sent when connection will be established + * Queue of events collected while socket is not connected. + * They will be sent once the connection is established. */ private eventsQueue: CatcherMessage[]; @@ -41,24 +49,28 @@ export default class Socket private ws: WebSocket | null; /** - * Reconnection tryings Timeout + * Page hide event handler reference (for removal) */ - private reconnectionTimer: unknown; + private pageHideHandler: () => void; /** - * Time between reconnection attempts + * Timer that closes an idle connection after no errors have been sent + * for connectionIdleMs milliseconds. */ - private readonly reconnectionTimeout: number; + private connectionIdleTimer: ReturnType | null = null; /** - * How many time we should attempt reconnection + * How long (ms) to keep the connection open after the last error was sent. + * Errors often come in bursts, so holding the socket briefly avoids + * the overhead of opening a new connection for each one. */ - private reconnectionAttempts: number; + private readonly connectionIdleMs: number; /** - * Page hide event handler reference (for removal) + * Deduplicates concurrent openConnection() calls — all callers share the + * same in-flight Promise so only one WebSocket is ever created at a time. */ - private pageHideHandler: () => void; + private readonly initOnce: () => Promise; /** * Creates new Socket instance. Setup initial socket params. @@ -67,21 +79,19 @@ export default class Socket */ constructor({ collectorEndpoint, - // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars - onMessage = (message: MessageEvent): void => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onMessage = (_message: MessageEvent): void => {}, // eslint-disable-next-line @typescript-eslint/no-empty-function onClose = (): void => {}, // eslint-disable-next-line @typescript-eslint/no-empty-function onOpen = (): void => {}, - reconnectionAttempts = 5, - reconnectionTimeout = 10000, // 10 * 1000 ms = 10 sec + connectionIdleMs = 10000, // 10 sec — close connection if no new errors arrive }) { this.url = collectorEndpoint; this.onMessage = onMessage; this.onClose = onClose; this.onOpen = onOpen; - this.reconnectionTimeout = reconnectionTimeout; - this.reconnectionAttempts = reconnectionAttempts; + this.connectionIdleMs = connectionIdleMs; this.pageHideHandler = () => { this.close(); @@ -89,17 +99,12 @@ export default class Socket this.eventsQueue = []; this.ws = null; + this.initOnce = singleFlight(() => this.openConnection()); - this.init() - .then(() => { - /** - * Send queued events if exists - */ - this.sendQueue(); - }) - .catch((error) => { - log('WebSocket error', 'error', error); - }); + /** + * Connection is not opened eagerly — it is created on the first send() + * and closed automatically after connectionIdleMs of inactivity. + */ } /** @@ -108,27 +113,18 @@ export default class Socket * @param message - event data in Hawk Format */ public async send(message: CatcherMessage): Promise { - if (this.ws === null) { - this.eventsQueue.push(message); - - await this.init(); - this.sendQueue(); + this.eventsQueue.push(message); - return; + if (this.ws !== null && this.ws.readyState === WebSocket.CLOSED) { + this.closeAndDetachSocket(); } - switch (this.ws.readyState) { - case WebSocket.OPEN: - return this.ws.send(JSON.stringify(message)); - - case WebSocket.CLOSED: - this.eventsQueue.push(message); - - return this.reconnect(); + if (this.ws === null) { + await this.initOnce(); + } - case WebSocket.CONNECTING: - case WebSocket.CLOSING: - this.eventsQueue.push(message); + if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) { + this.sendQueue(); } } @@ -147,10 +143,12 @@ export default class Socket } /** - * Create new WebSocket connection and setup socket event listeners + * Create new WebSocket connection and setup socket event listeners. + * Always call initOnce() instead — it deduplicates concurrent calls. */ - private init(): Promise { + private openConnection(): Promise { return new Promise((resolve, reject) => { + this.closeAndDetachSocket(); this.ws = new WebSocket(this.url); /** @@ -168,8 +166,27 @@ export default class Socket this.ws.onclose = (event: CloseEvent): void => { this.destroyListeners(); - if (typeof this.onClose === 'function') { - this.onClose(event); + /** + * Code 1000 = Normal Closure (intentional), 1001 = Going Away (page unload/navigation). + * These are expected and should not be reported as a lost connection. + * Any other code (e.g. 1006 = Abnormal Closure from idle timeout or infrastructure drop) + * means the connection was lost unexpectedly. + */ + const isExpectedClose = [WS_CLOSE_NORMAL, WS_CLOSE_GOING_AWAY].includes(event.code); + + if (!isExpectedClose) { + /** + * Cancel the idle timer — it belongs to the now-dead connection. + * A fresh timer will be set once the next send() opens a new connection. + */ + if (this.connectionIdleTimer !== null) { + clearTimeout(this.connectionIdleTimer); + this.connectionIdleTimer = null; + } + + if (typeof this.onClose === 'function') { + this.onClose(event); + } } }; @@ -195,50 +212,69 @@ export default class Socket } /** - * Closes socket connection + * Closes socket connection and cancels any pending idle timer */ private close(): void { - if (this.ws) { - this.ws.close(); - this.ws = null; + if (this.connectionIdleTimer !== null) { + clearTimeout(this.connectionIdleTimer); + this.connectionIdleTimer = null; } + + this.closeAndDetachSocket(); } /** - * Tries to reconnect to the server for specified number of times with the interval - * - * @param {boolean} [isForcedCall] - call function despite on timer - * @returns {Promise} + * Closes the WebSocket and nulls all event handlers before releasing the reference. + * Without this, the old connection stays open and its onclose/onerror + * handlers keep firing, causing duplicate reconnect attempts and log noise. */ - private async reconnect(isForcedCall = false): Promise { - if (this.reconnectionTimer && !isForcedCall) { + private closeAndDetachSocket(): void { + if (this.ws === null) { return; } - this.reconnectionTimer = null; - - try { - await this.init(); - - log('Successfully reconnected.', 'info'); - this.sendQueue(); - } catch (error) { - this.reconnectionAttempts--; + this.ws.onopen = null; + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.onmessage = null; + this.ws.close(); + this.ws = null; - if (this.reconnectionAttempts === 0) { - return; - } + /** + * onclose is nulled above so it won't fire — call destroyListeners() directly + * to ensure the pagehide listener is always removed on explicit close. + */ + this.destroyListeners(); + } - this.reconnectionTimer = setTimeout(() => { - void this.reconnect(true); - }, this.reconnectionTimeout); + /** + * Resets the idle close timer. + * Called after each successful send so the connection stays open + * for connectionIdleMs after the last error in a burst. + */ + private resetIdleTimer(): void { + if (this.connectionIdleTimer !== null) { + clearTimeout(this.connectionIdleTimer); } + + this.connectionIdleTimer = setTimeout(() => { + this.connectionIdleTimer = null; + this.close(); + }, this.connectionIdleMs); } /** - * Sends all queued events one-by-one + * Sends all queued events directly via the WebSocket. + * Bypasses send() intentionally — send() always enqueues first, + * so calling it here would cause infinite recursion. */ private sendQueue(): void { + if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) { + return; + } + + this.resetIdleTimer(); + while (this.eventsQueue.length) { const event = this.eventsQueue.shift(); @@ -246,10 +282,11 @@ export default class Socket continue; } - this.send(event) - .catch((sendingError) => { - log('WebSocket sending error', 'error', sendingError); - }); + try { + this.ws.send(JSON.stringify(event)); + } catch (sendingError) { + log('WebSocket sending error', 'error', sendingError); + } } } } diff --git a/packages/javascript/src/utils/single-flight.ts b/packages/javascript/src/utils/single-flight.ts new file mode 100644 index 0000000..2200a80 --- /dev/null +++ b/packages/javascript/src/utils/single-flight.ts @@ -0,0 +1,20 @@ +/** + * Wraps an async function so that concurrent calls share the same in-flight + * Promise. Once the Promise settles, the next call starts fresh. + * + * @param fn - The async function to guard against concurrent execution + * @returns {Function} A wrapped version of fn that never runs concurrently with itself + */ +export function singleFlight(fn: () => Promise): () => Promise { + let inFlight: Promise | null = null; + + return (): Promise => { + if (inFlight === null) { + inFlight = fn().finally(() => { + inFlight = null; + }); + } + + return inFlight; + }; +} diff --git a/packages/javascript/tests/socket.test.ts b/packages/javascript/tests/socket.test.ts index ef81a3e..9bc3304 100644 --- a/packages/javascript/tests/socket.test.ts +++ b/packages/javascript/tests/socket.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import Socket from '../src/modules/socket'; import type { CatcherMessage } from '@hawk.so/types'; @@ -52,15 +52,18 @@ describe('Socket', () => { this.onmessage = undefined; webSocket = this; }); + patchWebSocketMockConstructor(WebSocketConstructor); globalThis.WebSocket = WebSocketConstructor; const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); - // initialize socket and open fake websocket connection + // Connection is lazy — trigger it via send() const socket = new Socket({ collectorEndpoint: MOCK_WEBSOCKET_URL }); + const initSendPromise = socket.send({ foo: 'init' } as CatcherMessage); webSocket.readyState = WebSocket.OPEN; webSocket.onopen?.(new Event('open')); + await initSendPromise; // capture pagehide handler to verify it's properly removed const pagehideCall = addEventListenerSpy.mock.calls.find(([event]) => event === 'pagehide'); @@ -127,14 +130,19 @@ describe('Socket — events queue after connection loss', () => { reconnectionTimeout: 10, }); + // Connection is lazy — trigger it via send() so ws1 is created + const payload = { type: 'errors/javascript', title: 'queued-after-drop' } as unknown as CatcherMessage<'errors/javascript'>; + const firstSendPromise = socket.send(payload); + const ws1 = sockets[0]; + expect(ws1).toBeDefined(); ws1.readyState = WebSocket.OPEN; ws1.onopen?.(new Event('open')); - await Promise.resolve(); + await firstSendPromise; + // Simulate connection drop (readyState only, no onclose — tests the CLOSED branch in send()) ws1.readyState = WebSocket.CLOSED; - const payload = { type: 'errors/javascript', title: 'queued-after-drop' } as unknown as CatcherMessage<'errors/javascript'>; const sendPromise = socket.send(payload); const ws2 = sockets[1]; @@ -157,10 +165,12 @@ describe('Socket — events queue after connection loss', () => { const WebSocketConstructor = mockWebSocketFactory(sockets, closeSpy); globalThis.WebSocket = WebSocketConstructor as unknown as typeof WebSocket; + // Connection is lazy — trigger it via send() so sockets[0] is created const socket = new Socket({ collectorEndpoint: MOCK_WEBSOCKET_URL }); + const initSendPromise = socket.send({ foo: 'init' } as CatcherMessage); sockets[0].readyState = WebSocket.OPEN; sockets[0].onopen?.(new Event('open')); - await Promise.resolve(); + await initSendPromise; window.dispatchEvent(new Event('pagehide'));