@owlmeans/client-socket 0.1.18-rc.4 → 0.1.18-rc.41

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,372 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import type { Server } from 'bun'
3
+ import { connect } from '../src/helper.js'
4
+ import { makeConnection } from '../src/utils/connection.js'
5
+ import { appendSocketStatus } from '../src/status.js'
6
+ import { SocketSystemEvent } from '@owlmeans/socket'
7
+ import type { EventMessage } from '@owlmeans/socket'
8
+ import { makeTestContext } from './context.js'
9
+
10
+ /**
11
+ * Real `Bun.serve` WebSocket servers and the real carrier — no mocks. Every server here answers
12
+ * every upgrade and otherwise does nothing; the point is the CLIENT's behaviour when the server
13
+ * disappears and comes back (or never comes back at all).
14
+ */
15
+ let server: Server | null = null
16
+
17
+ const startServer = (port: number, echo: boolean = true): Server => {
18
+ server = Bun.serve({
19
+ port,
20
+ fetch(req, srv) {
21
+ if (srv.upgrade(req)) return undefined
22
+ return new Response('upgrade failed', { status: 400 })
23
+ },
24
+ // Answers a heartbeat ping with a pong, exactly as `server-socket` does, and echoes every
25
+ // other frame — enough for a test to prove a frame sent after a reconnect reaches the SAME
26
+ // pre-drop `observe` handler. `echo: false` upgrades and then answers nothing at all: a
27
+ // half-open connection, from the client's side.
28
+ websocket: {
29
+ open() { },
30
+ message(ws, data) {
31
+ if (!echo) return
32
+ try {
33
+ if (typeof data === 'string' && JSON.parse(data)?.type === 'ping') {
34
+ ws.send(JSON.stringify({ type: 'pong' }))
35
+ return
36
+ }
37
+ } catch {
38
+ // Not JSON at all — echoed below like any other frame.
39
+ }
40
+ ws.send(data)
41
+ },
42
+ close() { },
43
+ },
44
+ })
45
+ return server
46
+ }
47
+
48
+ const stopServer = () => {
49
+ server?.stop(true)
50
+ server = null
51
+ }
52
+
53
+ afterEach(() => {
54
+ stopServer()
55
+ })
56
+
57
+ const waitFor = async (check: () => boolean, timeout: number, step = 20): Promise<void> => {
58
+ const startedAt = Date.now()
59
+ while (!check()) {
60
+ if (Date.now() - startedAt > timeout) {
61
+ throw new Error('waitFor: condition never became true')
62
+ }
63
+ await Bun.sleep(step)
64
+ }
65
+ }
66
+
67
+ const systemEvents = (connection: Awaited<ReturnType<typeof connect>>): string[] => {
68
+ const seen: string[] = []
69
+ connection.listen(async message => {
70
+ if (typeof message === 'object' && (message as EventMessage<unknown>).type === 'system') {
71
+ seen.push((message as EventMessage<unknown>).event)
72
+ }
73
+ })
74
+ return seen
75
+ }
76
+
77
+ describe('@owlmeans/client-socket — reconnect', () => {
78
+ test('reconnects when the server restarts on the same port, on the SAME connection', async () => {
79
+ const initial = startServer(0)
80
+ const port = initial.port
81
+
82
+ const ctx = makeTestContext({ reconnect: { minDelay: 50, maxDelay: 150, stableAfter: 200, budget: 30_000 } })
83
+ const connection = await connect(() => `ws://localhost:${port}`, ctx)
84
+ const events = systemEvents(connection)
85
+
86
+ // Register an observer BEFORE the drop — it must still be the one that fires after the
87
+ // reconnect, because the model is never recreated.
88
+ let observed = 0
89
+ connection.observe('probe', async () => { observed += 1 })
90
+
91
+ stopServer()
92
+ await waitFor(() => events.includes(SocketSystemEvent.Disconnected), 2_000)
93
+
94
+ startServer(port)
95
+ await waitFor(() => events.includes(SocketSystemEvent.Reconnected), 5_000)
96
+
97
+ expect(events).not.toContain(SocketSystemEvent.Close)
98
+
99
+ // The pre-drop observer is still live on the reconnected socket.
100
+ await connection.notify('probe', {})
101
+ await waitFor(() => observed === 1, 1_000)
102
+
103
+ await connection.close()
104
+ }, 10_000)
105
+
106
+ test('backoff grows geometrically and is capped at maxDelay', async () => {
107
+ // Drive `makeConnection` directly — `connect()`/`ws()` only hand back a `Connection` once
108
+ // `ready` settles, and here we need to observe frames WHILE every attempt is still failing.
109
+ const policy = {
110
+ minDelay: 200, maxDelay: 3_000, factor: 2, jitter: 0.1,
111
+ budget: 60_000, reviveBudget: 60_000, stableAfter: 999_999, heartbeat: 999_999, pongTimeout: 999_999,
112
+ }
113
+ const delays: number[] = []
114
+ const { connection, ready } = makeConnection({
115
+ policy, retry: true, open: () => Promise.reject(new Error('never up')),
116
+ })
117
+ ready.catch(() => void 0)
118
+ connection.listen(async message => {
119
+ const msg = message as EventMessage<{ attempt: number, delay: number }>
120
+ if (typeof message === 'object' && msg.type === 'system' && msg.event === SocketSystemEvent.Reconnecting) {
121
+ delays.push(msg.payload.delay)
122
+ }
123
+ })
124
+
125
+ await waitFor(() => delays.length >= 5, 10_000)
126
+ await connection.close()
127
+
128
+ expect(delays[0]).toBeGreaterThanOrEqual(180)
129
+ expect(delays[0]).toBeLessThanOrEqual(220)
130
+ expect(delays[1]).toBeGreaterThanOrEqual(360)
131
+ expect(delays[1]).toBeLessThanOrEqual(440)
132
+ expect(delays[2]).toBeGreaterThanOrEqual(720)
133
+ expect(delays[2]).toBeLessThanOrEqual(880)
134
+ expect(delays[3]).toBeGreaterThanOrEqual(1_440)
135
+ expect(delays[3]).toBeLessThanOrEqual(1_760)
136
+ // The 5th attempt's raw geometric value (3200) already exceeds maxDelay, so it is capped.
137
+ expect(delays[4]).toBeGreaterThanOrEqual(2_700)
138
+ expect(delays[4]).toBeLessThanOrEqual(3_000)
139
+ }, 15_000)
140
+
141
+ test('client-initiated close never retries and reports exactly one close frame', async () => {
142
+ const initial = startServer(0)
143
+ const port = initial.port
144
+
145
+ const ctx = makeTestContext({ reconnect: { minDelay: 50, maxDelay: 150 } })
146
+ const connection = await connect(() => `ws://localhost:${port}`, ctx)
147
+ const events = systemEvents(connection)
148
+
149
+ await connection.close()
150
+ await Bun.sleep(100)
151
+
152
+ expect(events.filter(e => e === SocketSystemEvent.Close).length).toBe(1)
153
+ expect(events).not.toContain(SocketSystemEvent.Disconnected)
154
+ expect(events).not.toContain(SocketSystemEvent.Reconnecting)
155
+ }, 5_000)
156
+
157
+ test('a socket that answers nothing at all is force-closed by the heartbeat', async () => {
158
+ const silent = startServer(0, false)
159
+
160
+ const ctx = makeTestContext({
161
+ reconnect: { heartbeat: 60, pongTimeout: 30, minDelay: 20, maxDelay: 40, budget: 10_000 }
162
+ })
163
+ const connection = await connect(() => `ws://localhost:${silent.port}`, ctx)
164
+ const events = systemEvents(connection)
165
+
166
+ await waitFor(() => events.includes(SocketSystemEvent.Disconnected), 3_000)
167
+
168
+ await connection.close()
169
+ }, 10_000)
170
+
171
+ test('a heartbeat wake-up long after the previous one keeps a socket the server answers', async () => {
172
+ const initial = startServer(0)
173
+ const port = initial.port
174
+ const realNow = Date.now.bind(Date)
175
+
176
+ const ctx = makeTestContext({
177
+ reconnect: { heartbeat: 100, pongTimeout: 50, minDelay: 20, maxDelay: 40, budget: 10_000 }
178
+ })
179
+ const connection = await connect(() => `ws://localhost:${port}`, ctx)
180
+ const events = systemEvents(connection)
181
+ let observed = 0
182
+ connection.observe('probe', async () => { observed += 1 })
183
+
184
+ // What a backgrounded tab looks like: real timers keep firing, but every wake-up lands a
185
+ // minute later than the last as far as the clock is concerned.
186
+ let skew = 0
187
+ Date.now = () => realNow() + skew
188
+ const throttle = setInterval(() => { skew += 60_000 }, 50)
189
+ const wait = async (ms: number) => {
190
+ const until = realNow() + ms
191
+ while (realNow() < until) await Bun.sleep(20)
192
+ }
193
+
194
+ try {
195
+ await wait(800)
196
+ // Several heartbeat intervals with an apparent minute between each: nothing dropped.
197
+ expect(events).toEqual([])
198
+
199
+ await connection.notify('probe', {})
200
+ const deadline = realNow() + 2_000
201
+ while (observed === 0 && realNow() < deadline) await Bun.sleep(20)
202
+ expect(observed).toBe(1)
203
+ } finally {
204
+ clearInterval(throttle)
205
+ Date.now = realNow
206
+ await connection.close()
207
+ }
208
+ }, 15_000)
209
+
210
+ test('gives up after the retry budget elapses: status reads lost, connect() rejects', async () => {
211
+ const ctx = makeTestContext({ reconnect: { minDelay: 20, maxDelay: 40, budget: 200, stableAfter: 50 } })
212
+ appendSocketStatus(ctx)
213
+ ctx.configure()
214
+ await ctx.init()
215
+
216
+ await expect(connect(() => 'ws://127.0.0.1:1', ctx)).rejects.toBeTruthy()
217
+
218
+ expect(ctx.socketStatus().state()).toBe('lost')
219
+ }, 5_000)
220
+
221
+ test('retry() revives a lost connection in place: no close frame, reconnected on the SAME connection', async () => {
222
+ const initial = startServer(0)
223
+ const port = initial.port
224
+
225
+ const ctx = makeTestContext({
226
+ reconnect: { minDelay: 20, maxDelay: 40, budget: 300, reviveBudget: 2_000, stableAfter: 50 }
227
+ })
228
+ appendSocketStatus(ctx)
229
+ ctx.configure()
230
+ await ctx.init()
231
+
232
+ const connection = await connect(() => `ws://localhost:${port}`, ctx)
233
+ const events = systemEvents(connection)
234
+ let observed = 0
235
+ connection.observe('probe', async () => { observed += 1 })
236
+
237
+ stopServer()
238
+ await waitFor(() => events.includes(SocketSystemEvent.Lost), 3_000)
239
+ expect(ctx.socketStatus().state()).toBe('lost')
240
+ // `close` means gone for good — a lost connection is still revivable.
241
+ expect(events).not.toContain(SocketSystemEvent.Close)
242
+
243
+ startServer(port)
244
+ let retried = 0
245
+ ctx.socketStatus().onRetry(() => { retried += 1 })
246
+ expect(ctx.socketStatus().retry()).toBe(true)
247
+ expect(retried).toBe(1)
248
+ expect(ctx.socketStatus().state()).toBe('reconnecting')
249
+
250
+ await waitFor(() => events.includes(SocketSystemEvent.Reconnected), 3_000)
251
+ expect(ctx.socketStatus().state()).toBe('online')
252
+
253
+ await connection.notify('probe', {})
254
+ await waitFor(() => observed === 1, 1_000)
255
+
256
+ // Nothing is lost any more — a second retry has nothing to do and tells nobody.
257
+ expect(ctx.socketStatus().retry()).toBe(false)
258
+ expect(retried).toBe(1)
259
+
260
+ await connection.close()
261
+ }, 10_000)
262
+
263
+ test('a revive that cannot succeed reports lost again once reviveBudget elapses', async () => {
264
+ const initial = startServer(0)
265
+ const port = initial.port
266
+
267
+ const ctx = makeTestContext({
268
+ reconnect: { minDelay: 20, maxDelay: 40, budget: 200, reviveBudget: 200, stableAfter: 50 }
269
+ })
270
+ appendSocketStatus(ctx)
271
+ ctx.configure()
272
+ await ctx.init()
273
+
274
+ const connection = await connect(() => `ws://localhost:${port}`, ctx)
275
+ const events = systemEvents(connection)
276
+
277
+ stopServer()
278
+ await waitFor(() => events.includes(SocketSystemEvent.Lost), 3_000)
279
+
280
+ ctx.socketStatus().retry()
281
+ expect(ctx.socketStatus().state()).toBe('reconnecting')
282
+ await waitFor(() => events.filter(e => e === SocketSystemEvent.Lost).length === 2, 3_000)
283
+ expect(ctx.socketStatus().state()).toBe('lost')
284
+ expect(events).not.toContain(SocketSystemEvent.Close)
285
+
286
+ await connection.close()
287
+ expect(ctx.socketStatus().state()).toBe('online')
288
+ }, 10_000)
289
+
290
+ test('revive() on a connection still retrying attempts at once and outlives its old budget', async () => {
291
+ // Its own budget alone would report lost ~400ms in; the revive at ~50ms must push that out.
292
+ const policy = {
293
+ minDelay: 200, maxDelay: 200, factor: 1, jitter: 0,
294
+ budget: 300, reviveBudget: 1_500, stableAfter: 999_999, heartbeat: 999_999, pongTimeout: 999_999,
295
+ }
296
+ let opens = 0
297
+ const events: string[] = []
298
+ const { connection, ready, revive } = makeConnection({
299
+ policy, retry: true, open: () => { opens += 1; return Promise.reject(new Error('down')) },
300
+ })
301
+ ready.catch(() => void 0)
302
+ connection.listen(async message => {
303
+ const msg = message as EventMessage<unknown>
304
+ if (typeof message === 'object' && msg.type === 'system') events.push(msg.event)
305
+ })
306
+
307
+ await waitFor(() => opens === 1, 1_000)
308
+ await Bun.sleep(50)
309
+ revive()
310
+ // At once — not after the 200ms backoff the failed first attempt scheduled.
311
+ expect(opens).toBe(2)
312
+
313
+ await Bun.sleep(600)
314
+ expect(events).not.toContain(SocketSystemEvent.Lost)
315
+ await waitFor(() => events.includes(SocketSystemEvent.Lost), 3_000)
316
+
317
+ await connection.close()
318
+ }, 10_000)
319
+
320
+ test('revive() during an in-flight attempt still extends the budget that attempt will face', async () => {
321
+ // The 2nd attempt hangs 400ms and fails at ~450ms, past the 300ms budget — lost, unless the
322
+ // revive at ~100ms moved the budget first.
323
+ const policy = {
324
+ minDelay: 50, maxDelay: 50, factor: 1, jitter: 0,
325
+ budget: 300, reviveBudget: 1_500, stableAfter: 999_999, heartbeat: 999_999, pongTimeout: 999_999,
326
+ }
327
+ let opens = 0
328
+ const events: string[] = []
329
+ const { connection, ready, revive } = makeConnection({
330
+ policy, retry: true, open: async () => {
331
+ opens += 1
332
+ if (opens === 2) await Bun.sleep(400)
333
+ throw new Error('down')
334
+ },
335
+ })
336
+ ready.catch(() => void 0)
337
+ connection.listen(async message => {
338
+ const msg = message as EventMessage<unknown>
339
+ if (typeof message === 'object' && msg.type === 'system') events.push(msg.event)
340
+ })
341
+
342
+ await waitFor(() => opens === 2, 1_000)
343
+ await Bun.sleep(50)
344
+ revive()
345
+ // No second attempt on top of the one in flight.
346
+ expect(opens).toBe(2)
347
+
348
+ await Bun.sleep(600)
349
+ expect(events).not.toContain(SocketSystemEvent.Lost)
350
+ await waitFor(() => events.includes(SocketSystemEvent.Lost), 3_000)
351
+
352
+ await connection.close()
353
+ }, 10_000)
354
+
355
+ test('retry() drops a connection that never opened and tells its owner to dial again', async () => {
356
+ const ctx = makeTestContext({ reconnect: { minDelay: 20, maxDelay: 40, budget: 200, stableAfter: 50 } })
357
+ appendSocketStatus(ctx)
358
+ ctx.configure()
359
+ await ctx.init()
360
+
361
+ await expect(connect(() => 'ws://127.0.0.1:1', ctx)).rejects.toBeTruthy()
362
+ expect(ctx.socketStatus().state()).toBe('lost')
363
+
364
+ let retried = 0
365
+ ctx.socketStatus().onRetry(() => { retried += 1 })
366
+ expect(ctx.socketStatus().retry()).toBe(true)
367
+
368
+ // There was no `Connection` to revive, so nothing is left reporting — the owner re-dials.
369
+ expect(ctx.socketStatus().state()).toBe('online')
370
+ expect(retried).toBe(1)
371
+ }, 5_000)
372
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.react.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": ["bun"],
8
+ "rootDir": "../",
9
+ "noEmit": true
10
+ },
11
+ "include": ["./**/*", "../src/**/*"]
12
+ }
package/tsconfig.json CHANGED
@@ -7,5 +7,5 @@
7
7
  "rootDir": "./src/",
8
8
  "outDir": "./build/"
9
9
  },
10
- "exclude": ["./dist/**/*", "./build/**/*", "./*.ts"]
10
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
11
11
  }
package/build/.gitkeep DELETED
File without changes