Skip to content
Open
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
191 changes: 114 additions & 77 deletions packages/javascript/src/modules/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -30,8 +38,8 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
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<T>[];

Expand All @@ -41,24 +49,28 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
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<typeof setTimeout> | 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<void>;

/**
* Creates new Socket instance. Setup initial socket params.
Expand All @@ -67,39 +79,32 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
*/
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();
};

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.
*/
}

/**
Expand All @@ -108,27 +113,18 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
* @param message - event data in Hawk Format
*/
public async send(message: CatcherMessage<T>): Promise<void> {
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();
}
}

Expand All @@ -147,10 +143,12 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
}

/**
* 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<void> {
private openConnection(): Promise<void> {
return new Promise((resolve, reject) => {
this.closeAndDetachSocket();
this.ws = new WebSocket(this.url);

/**
Expand All @@ -168,8 +166,27 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
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);
}
}
};

Expand All @@ -195,61 +212,81 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
}

/**
* 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<void>}
* 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<void> {
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();

if (!event) {
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);
}
}
}
}
20 changes: 20 additions & 0 deletions packages/javascript/src/utils/single-flight.ts
Original file line number Diff line number Diff line change
@@ -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<T>(fn: () => Promise<T>): () => Promise<T> {
let inFlight: Promise<T> | null = null;

return (): Promise<T> => {
if (inFlight === null) {
inFlight = fn().finally(() => {
inFlight = null;
});
}

return inFlight;
};
}
20 changes: 15 additions & 5 deletions packages/javascript/tests/socket.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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];
Expand All @@ -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'));

Expand Down
Loading