Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- master
workflow_dispatch:

jobs:
publish:
Expand Down
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
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 +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