@syncular/transport-ws 0.0.1-100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createWebSocketTransport } from './index';
3
+
4
+ class MockWebSocket {
5
+ static readonly CONNECTING = 0;
6
+ static readonly OPEN = 1;
7
+ static readonly CLOSING = 2;
8
+ static readonly CLOSED = 3;
9
+ static instances: MockWebSocket[] = [];
10
+
11
+ readonly url: string;
12
+ readonly sent: string[] = [];
13
+ readyState = MockWebSocket.CONNECTING;
14
+ onopen: ((ev: Event) => unknown) | null = null;
15
+ onmessage: ((ev: MessageEvent<string>) => unknown) | null = null;
16
+ onerror: ((ev: Event) => unknown) | null = null;
17
+ onclose: ((ev: Event) => unknown) | null = null;
18
+
19
+ constructor(url: string | URL, _protocols?: string | string[]) {
20
+ this.url = String(url);
21
+ MockWebSocket.instances.push(this);
22
+ }
23
+
24
+ send(data: string): void {
25
+ if (this.readyState !== MockWebSocket.OPEN) {
26
+ throw new Error('Socket is not open');
27
+ }
28
+ this.sent.push(data);
29
+ }
30
+
31
+ close(): void {
32
+ this.readyState = MockWebSocket.CLOSED;
33
+ }
34
+
35
+ async triggerOpen(): Promise<void> {
36
+ this.readyState = MockWebSocket.OPEN;
37
+ const handler = this.onopen;
38
+ if (!handler) return;
39
+ await handler(new Event('open'));
40
+ }
41
+
42
+ triggerClose(): void {
43
+ this.readyState = MockWebSocket.CLOSED;
44
+ const handler = this.onclose;
45
+ if (!handler) return;
46
+ handler(new Event('close'));
47
+ }
48
+ }
49
+
50
+ function clearMockSockets(): void {
51
+ MockWebSocket.instances.length = 0;
52
+ }
53
+
54
+ async function waitForSocket(): Promise<MockWebSocket> {
55
+ for (let i = 0; i < 10; i++) {
56
+ const socket = MockWebSocket.instances[0];
57
+ if (socket) return socket;
58
+ await new Promise((resolve) => setTimeout(resolve, 0));
59
+ }
60
+ throw new Error('Expected a websocket instance to be created');
61
+ }
62
+
63
+ function createDeferred<T>(): {
64
+ promise: Promise<T>;
65
+ resolve: (value: T) => void;
66
+ } {
67
+ let resolve!: (value: T) => void;
68
+ const promise = new Promise<T>((r) => {
69
+ resolve = r;
70
+ });
71
+ return { promise, resolve };
72
+ }
73
+
74
+ describe('createWebSocketTransport auth flow', () => {
75
+ test('derives default wsUrl from baseUrl as /sync/realtime', async () => {
76
+ clearMockSockets();
77
+ const transport = createWebSocketTransport({
78
+ baseUrl: 'http://localhost:3000/api',
79
+ WebSocketImpl: MockWebSocket as typeof WebSocket,
80
+ reconnectJitter: 0,
81
+ });
82
+
83
+ const disconnect = transport.connect(
84
+ { clientId: 'client-default-url' },
85
+ () => {}
86
+ );
87
+ const socket = await waitForSocket();
88
+
89
+ const url = new URL(socket.url);
90
+ expect(url.protocol).toBe('ws:');
91
+ expect(url.pathname).toBe('/api/sync/realtime');
92
+ expect(url.searchParams.get('clientId')).toBe('client-default-url');
93
+ expect(url.searchParams.get('transportPath')).toBe('relay');
94
+
95
+ disconnect();
96
+ });
97
+
98
+ test('sends first-message auth token after open', async () => {
99
+ clearMockSockets();
100
+ const transport = createWebSocketTransport({
101
+ baseUrl: 'http://localhost:3000/api',
102
+ WebSocketImpl: MockWebSocket as typeof WebSocket,
103
+ authToken: 'token-1',
104
+ reconnectJitter: 0,
105
+ });
106
+
107
+ const disconnect = transport.connect({ clientId: 'client-1' }, () => {});
108
+ const socket = await waitForSocket();
109
+
110
+ await socket.triggerOpen();
111
+
112
+ expect(transport.getConnectionState()).toBe('connected');
113
+ expect(socket.sent).toContain(
114
+ JSON.stringify({ type: 'auth', token: 'token-1' })
115
+ );
116
+
117
+ disconnect();
118
+ });
119
+
120
+ test('does not become connected when socket closes while waiting for auth token', async () => {
121
+ clearMockSockets();
122
+ const token = createDeferred<string>();
123
+ const transport = createWebSocketTransport({
124
+ baseUrl: 'http://localhost:3000/api',
125
+ WebSocketImpl: MockWebSocket as typeof WebSocket,
126
+ authToken: () => token.promise,
127
+ reconnectJitter: 0,
128
+ initialReconnectDelay: 1_000_000,
129
+ maxReconnectDelay: 1_000_000,
130
+ });
131
+
132
+ const disconnect = transport.connect({ clientId: 'client-2' }, () => {});
133
+ const socket = await waitForSocket();
134
+
135
+ const openPromise = socket.triggerOpen();
136
+ expect(transport.getConnectionState()).toBe('connecting');
137
+
138
+ socket.triggerClose();
139
+ expect(transport.getConnectionState()).toBe('disconnected');
140
+
141
+ token.resolve('token-2');
142
+ await openPromise;
143
+
144
+ expect(transport.getConnectionState()).toBe('disconnected');
145
+ expect(socket.sent).toEqual([]);
146
+
147
+ disconnect();
148
+ });
149
+
150
+ test('times out ws push quickly when configured', async () => {
151
+ clearMockSockets();
152
+ const transport = createWebSocketTransport({
153
+ baseUrl: 'http://localhost:3000/api',
154
+ WebSocketImpl: MockWebSocket as typeof WebSocket,
155
+ reconnectJitter: 0,
156
+ wsPushTimeoutMs: 20,
157
+ });
158
+
159
+ const disconnect = transport.connect({ clientId: 'client-3' }, () => {});
160
+ const socket = await waitForSocket();
161
+ await socket.triggerOpen();
162
+
163
+ const startedAt = Date.now();
164
+ const response = await transport.pushViaWs({
165
+ clientId: 'client-3',
166
+ clientCommitId: 'commit-1',
167
+ operations: [],
168
+ schemaVersion: 1,
169
+ });
170
+ const elapsedMs = Date.now() - startedAt;
171
+
172
+ expect(response).toBeNull();
173
+ expect(elapsedMs).toBeGreaterThanOrEqual(15);
174
+ expect(elapsedMs).toBeLessThan(500);
175
+
176
+ const pushMessage = socket.sent.find((msg) =>
177
+ msg.includes('"type":"push"')
178
+ );
179
+ expect(pushMessage).toBeDefined();
180
+
181
+ disconnect();
182
+ });
183
+ });