@owlmeans/client-socket 0.1.18-rc.3 → 0.1.18-rc.30

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,150 @@
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): 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
+ // Echoes everything back (heartbeat pings included) — enough for a test to prove a frame
25
+ // sent after a reconnect reaches the SAME pre-drop `observe` handler.
26
+ websocket: { open() { }, message(ws, data) { ws.send(data) }, close() { } },
27
+ })
28
+ return server
29
+ }
30
+
31
+ const stopServer = () => {
32
+ server?.stop(true)
33
+ server = null
34
+ }
35
+
36
+ afterEach(() => {
37
+ stopServer()
38
+ })
39
+
40
+ const waitFor = async (check: () => boolean, timeout: number, step = 20): Promise<void> => {
41
+ const startedAt = Date.now()
42
+ while (!check()) {
43
+ if (Date.now() - startedAt > timeout) {
44
+ throw new Error('waitFor: condition never became true')
45
+ }
46
+ await Bun.sleep(step)
47
+ }
48
+ }
49
+
50
+ const systemEvents = (connection: Awaited<ReturnType<typeof connect>>): string[] => {
51
+ const seen: string[] = []
52
+ connection.listen(async message => {
53
+ if (typeof message === 'object' && (message as EventMessage<unknown>).type === 'system') {
54
+ seen.push((message as EventMessage<unknown>).event)
55
+ }
56
+ })
57
+ return seen
58
+ }
59
+
60
+ describe('@owlmeans/client-socket — reconnect', () => {
61
+ test('reconnects when the server restarts on the same port, on the SAME connection', async () => {
62
+ const initial = startServer(0)
63
+ const port = initial.port
64
+
65
+ const ctx = makeTestContext({ reconnect: { minDelay: 50, maxDelay: 150, stableAfter: 200, budget: 30_000 } })
66
+ const connection = await connect(() => `ws://localhost:${port}`, ctx)
67
+ const events = systemEvents(connection)
68
+
69
+ // Register an observer BEFORE the drop — it must still be the one that fires after the
70
+ // reconnect, because the model is never recreated.
71
+ let observed = 0
72
+ connection.observe('probe', async () => { observed += 1 })
73
+
74
+ stopServer()
75
+ await waitFor(() => events.includes(SocketSystemEvent.Disconnected), 2_000)
76
+
77
+ startServer(port)
78
+ await waitFor(() => events.includes(SocketSystemEvent.Reconnected), 5_000)
79
+
80
+ expect(events).not.toContain(SocketSystemEvent.Close)
81
+
82
+ // The pre-drop observer is still live on the reconnected socket.
83
+ await connection.notify('probe', {})
84
+ await waitFor(() => observed === 1, 1_000)
85
+
86
+ await connection.close()
87
+ }, 10_000)
88
+
89
+ test('backoff grows geometrically and is capped at maxDelay', async () => {
90
+ // Drive `makeConnection` directly — `connect()`/`ws()` only hand back a `Connection` once
91
+ // `ready` settles, and here we need to observe frames WHILE every attempt is still failing.
92
+ const policy = {
93
+ minDelay: 200, maxDelay: 3_000, factor: 2, jitter: 0.1,
94
+ budget: 60_000, stableAfter: 999_999, heartbeat: 999_999, pongTimeout: 999_999,
95
+ }
96
+ const delays: number[] = []
97
+ const { connection, ready } = makeConnection({
98
+ policy, retry: true, open: () => Promise.reject(new Error('never up')),
99
+ })
100
+ ready.catch(() => void 0)
101
+ connection.listen(async message => {
102
+ const msg = message as EventMessage<{ attempt: number, delay: number }>
103
+ if (typeof message === 'object' && msg.type === 'system' && msg.event === SocketSystemEvent.Reconnecting) {
104
+ delays.push(msg.payload.delay)
105
+ }
106
+ })
107
+
108
+ await waitFor(() => delays.length >= 5, 10_000)
109
+ await connection.close()
110
+
111
+ expect(delays[0]).toBeGreaterThanOrEqual(180)
112
+ expect(delays[0]).toBeLessThanOrEqual(220)
113
+ expect(delays[1]).toBeGreaterThanOrEqual(360)
114
+ expect(delays[1]).toBeLessThanOrEqual(440)
115
+ expect(delays[2]).toBeGreaterThanOrEqual(720)
116
+ expect(delays[2]).toBeLessThanOrEqual(880)
117
+ expect(delays[3]).toBeGreaterThanOrEqual(1_440)
118
+ expect(delays[3]).toBeLessThanOrEqual(1_760)
119
+ // The 5th attempt's raw geometric value (3200) already exceeds maxDelay, so it is capped.
120
+ expect(delays[4]).toBeGreaterThanOrEqual(2_700)
121
+ expect(delays[4]).toBeLessThanOrEqual(3_000)
122
+ }, 15_000)
123
+
124
+ test('client-initiated close never retries and reports exactly one close frame', async () => {
125
+ const initial = startServer(0)
126
+ const port = initial.port
127
+
128
+ const ctx = makeTestContext({ reconnect: { minDelay: 50, maxDelay: 150 } })
129
+ const connection = await connect(() => `ws://localhost:${port}`, ctx)
130
+ const events = systemEvents(connection)
131
+
132
+ await connection.close()
133
+ await Bun.sleep(100)
134
+
135
+ expect(events.filter(e => e === SocketSystemEvent.Close).length).toBe(1)
136
+ expect(events).not.toContain(SocketSystemEvent.Disconnected)
137
+ expect(events).not.toContain(SocketSystemEvent.Reconnecting)
138
+ }, 5_000)
139
+
140
+ test('gives up after the retry budget elapses: lost, then close, status reads lost, connect() rejects', async () => {
141
+ const ctx = makeTestContext({ reconnect: { minDelay: 20, maxDelay: 40, budget: 200, stableAfter: 50 } })
142
+ appendSocketStatus(ctx)
143
+ ctx.configure()
144
+ await ctx.init()
145
+
146
+ await expect(connect(() => 'ws://127.0.0.1:1', ctx)).rejects.toBeTruthy()
147
+
148
+ expect(ctx.socketStatus().state()).toBe('lost')
149
+ }, 5_000)
150
+ })
@@ -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