Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/javascript/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hawk.so/javascript",
"version": "3.2.18",
"version": "3.2.19",
"description": "JavaScript errors tracking for Hawk.so",
"files": [
"dist"
Expand Down
6 changes: 5 additions & 1 deletion packages/javascript/src/modules/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
if (this.ws === null) {
this.eventsQueue.push(message);

return this.init();
await this.init();
this.sendQueue();

return;
}

switch (this.ws.readyState) {
Expand Down Expand Up @@ -218,6 +221,7 @@ export default class Socket<T extends CatcherMessageType = 'errors/javascript'>
await this.init();

log('Successfully reconnected.', 'info');
this.sendQueue();
} catch (error) {
this.reconnectionAttempts--;

Expand Down
102 changes: 102 additions & 0 deletions packages/javascript/tests/socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ import type { CatcherMessage } from '@hawk.so/types';

const MOCK_WEBSOCKET_URL = 'ws://localhost:1234';

/**
* vi.fn() replacement has no WebSocket.OPEN/CLOSED; Socket uses them in switch — without this,
* `undefined === undefined` always hits the first `case WebSocket.OPEN` and reconnect never runs.
*/
function patchWebSocketMockConstructor(ctor: { CONNECTING?: number; OPEN?: number; CLOSING?: number; CLOSED?: number }): void {
ctor.CONNECTING = 0;
ctor.OPEN = 1;
ctor.CLOSING = 2;
ctor.CLOSED = 3;
}

type MockWebSocket = {
url: string;
readyState: number;
Expand Down Expand Up @@ -72,3 +83,94 @@ describe('Socket', () => {
expect(WebSocketConstructor).toHaveBeenCalledTimes(2);
});
});

/**
* Regression: queued events must be flushed after reconnect / init, not only on first constructor connect.
*/
describe('Socket — events queue after connection loss', () => {
afterEach(() => {
vi.restoreAllMocks();
});

function mockWebSocketFactory(sockets: MockWebSocket[], closeSpy: ReturnType<typeof vi.fn>) {
const ctor = vi.fn<(url: string) => void>().mockImplementation(function (
this: MockWebSocket,
url: string
) {
this.url = url;
this.readyState = WebSocket.CONNECTING;
this.send = vi.fn();
this.close = closeSpy;
this.onopen = undefined;
this.onclose = undefined;
this.onerror = undefined;
this.onmessage = undefined;
sockets.push(this);
});
patchWebSocketMockConstructor(ctor);

return ctor;
}

it('should flush queued event after reconnect when socket is CLOSED', async () => {
const sockets: MockWebSocket[] = [];
const closeSpy = vi.fn(function (this: MockWebSocket) {
this.readyState = WebSocket.CLOSED;
this.onclose?.({ code: 1001 } as CloseEvent);
});

const WebSocketConstructor = mockWebSocketFactory(sockets, closeSpy);
globalThis.WebSocket = WebSocketConstructor as unknown as typeof WebSocket;

const socket = new Socket({
collectorEndpoint: MOCK_WEBSOCKET_URL,
reconnectionTimeout: 10,
});

const ws1 = sockets[0];
ws1.readyState = WebSocket.OPEN;
ws1.onopen?.(new Event('open'));
await Promise.resolve();

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];
expect(ws2).toBeDefined();
ws2.readyState = WebSocket.OPEN;
ws2.onopen?.(new Event('open'));
await sendPromise;

expect(ws2.send).toHaveBeenCalledTimes(1);
expect(ws2.send).toHaveBeenCalledWith(JSON.stringify(payload));
});

it('should flush queued event when ws is null after pagehide and send()', async () => {
const closeSpy = vi.fn(function (this: MockWebSocket) {
this.readyState = WebSocket.CLOSED;
this.onclose?.({ code: 1000 } as CloseEvent);
});

const sockets: MockWebSocket[] = [];
const WebSocketConstructor = mockWebSocketFactory(sockets, closeSpy);
globalThis.WebSocket = WebSocketConstructor as unknown as typeof WebSocket;

const socket = new Socket({ collectorEndpoint: MOCK_WEBSOCKET_URL });
sockets[0].readyState = WebSocket.OPEN;
sockets[0].onopen?.(new Event('open'));
await Promise.resolve();

window.dispatchEvent(new Event('pagehide'));

const queued = { foo: 'bar' } as unknown as CatcherMessage<'errors/javascript'>;
const sendPromise = socket.send(queued);
sockets[1].readyState = WebSocket.OPEN;
sockets[1].onopen?.(new Event('open'));
await sendPromise;

expect(sockets[1].send).toHaveBeenCalledTimes(1);
expect(sockets[1].send).toHaveBeenCalledWith(JSON.stringify(queued));
});
});
Loading