Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
130 changes: 109 additions & 21 deletions packages/javascript/src/modules/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { log } from '@hawk.so/core';
import type { CatcherMessage } from '@/types';
import type { CatcherMessageType } from '@hawk.so/types';

/**
* WebSocket close codes that represent an intentional, expected closure.
* See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
Comment thread
khaydarov marked this conversation as resolved.
Outdated
*/
const WS_CLOSE_NORMAL = 1000;
const WS_CLOSE_GOING_AWAY = 1001;

/**
* Custom WebSocket wrapper class
*
Expand Down Expand Up @@ -30,8 +37,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 @@ -51,7 +58,7 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
private readonly reconnectionTimeout: number;

/**
* How many time we should attempt reconnection
* How many times we should attempt reconnection
*/
private reconnectionAttempts: number;

Expand All @@ -60,6 +67,19 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
*/
private pageHideHandler: () => void;

/**
* Timer that closes an idle connection after no errors have been sent
* for connectionIdleMs milliseconds.
*/
private connectionIdleTimer: ReturnType<typeof setTimeout> | null = null;

/**
* 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 readonly connectionIdleMs: number;

/**
* Creates new Socket instance. Setup initial socket params.
*
Expand All @@ -75,13 +95,15 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
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();
Expand All @@ -90,16 +112,10 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
this.eventsQueue = [];
this.ws = null;

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 @@ -119,6 +135,8 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>

switch (this.ws.readyState) {
case WebSocket.OPEN:
this.resetIdleTimer();

return this.ws.send(JSON.stringify(message));

case WebSocket.CLOSED:
Expand Down Expand Up @@ -151,6 +169,20 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
*/
private init(): Promise<void> {
return new Promise((resolve, reject) => {
/**
* Detach handlers and close the previous socket before opening a new one.
* Without this, the old connection stays open and its onclose/onerror
* handlers keep firing, causing duplicate reconnect attempts and log noise.
*/
if (this.ws !== null) {
this.ws.onopen = null;
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.onmessage = null;
this.ws.close();
this.ws = null;
Comment thread
khaydarov marked this conversation as resolved.
Outdated
}

this.ws = new WebSocket(this.url);

/**
Expand All @@ -168,8 +200,32 @@ 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 — notify and reconnect if there are
* queued events waiting to be sent.
*/
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 reconnect will set a fresh timer once the new connection is sending.
*/
if (this.connectionIdleTimer !== null) {
clearTimeout(this.connectionIdleTimer);
this.connectionIdleTimer = null;
}

if (typeof this.onClose === 'function') {
this.onClose(event);
}

if (this.eventsQueue.length > 0) {
void this.reconnect();
}
}
};

Expand All @@ -195,20 +251,52 @@ 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;
}

if (this.ws === null) {
return;
}

this.ws.onopen = null;
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.onmessage = null;
this.ws.close();
this.ws = null;

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

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

/**
* 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>}
* @param isForcedCall - call function despite on timer
*/
private async reconnect(isForcedCall = false): Promise<void> {
if (this.reconnectionTimer && !isForcedCall) {
Expand Down
18 changes: 14 additions & 4 deletions packages/javascript/tests/socket.test.ts
Original file line number Diff line number Diff line change
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