@flamingo-stack/openframe-frontend-core 0.0.555 → 0.0.556

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.
Files changed (113) hide show
  1. package/dist/{chunk-6ER3AYJM.js → chunk-5756Q6UN.js} +3 -3
  2. package/dist/{chunk-E2IPBWGT.js → chunk-5EES5HD4.js} +6 -6
  3. package/dist/{chunk-ODRMPI3Y.js → chunk-5JWLZJK5.js} +2 -2
  4. package/dist/{chunk-CVCQW7WG.js → chunk-5M4PS3XX.js} +3 -3
  5. package/dist/{chunk-6QRKCVYR.cjs → chunk-7Q7GFIFB.cjs} +7 -7
  6. package/dist/{chunk-6QRKCVYR.cjs.map → chunk-7Q7GFIFB.cjs.map} +1 -1
  7. package/dist/{chunk-UXW63WCV.cjs → chunk-BNMKHG2L.cjs} +4 -4
  8. package/dist/{chunk-UXW63WCV.cjs.map → chunk-BNMKHG2L.cjs.map} +1 -1
  9. package/dist/{chunk-SST2YI7S.cjs → chunk-BZKUID4R.cjs} +14 -14
  10. package/dist/{chunk-SST2YI7S.cjs.map → chunk-BZKUID4R.cjs.map} +1 -1
  11. package/dist/{chunk-YAOBDOI5.cjs → chunk-C74PZU6M.cjs} +32 -32
  12. package/dist/{chunk-YAOBDOI5.cjs.map → chunk-C74PZU6M.cjs.map} +1 -1
  13. package/dist/{chunk-P7MSXPGL.js → chunk-CGUSNV6R.js} +8 -8
  14. package/dist/{chunk-N5A4AC2J.cjs → chunk-CRGIXNUE.cjs} +23 -23
  15. package/dist/{chunk-N5A4AC2J.cjs.map → chunk-CRGIXNUE.cjs.map} +1 -1
  16. package/dist/{chunk-Q6RC3YGW.cjs → chunk-DFJ7TWH6.cjs} +3 -3
  17. package/dist/{chunk-Q6RC3YGW.cjs.map → chunk-DFJ7TWH6.cjs.map} +1 -1
  18. package/dist/{chunk-HO3Q7NXJ.js → chunk-FLSEJSTN.js} +2 -2
  19. package/dist/{chunk-UL3WQWTF.js → chunk-FZT7BYNF.js} +2 -2
  20. package/dist/{chunk-XOKRFZBD.cjs → chunk-HOEOGFQM.cjs} +97 -97
  21. package/dist/{chunk-XOKRFZBD.cjs.map → chunk-HOEOGFQM.cjs.map} +1 -1
  22. package/dist/{chunk-MLVF3OXU.cjs → chunk-HYABNMBN.cjs} +76 -76
  23. package/dist/{chunk-MLVF3OXU.cjs.map → chunk-HYABNMBN.cjs.map} +1 -1
  24. package/dist/{chunk-J76LCSR4.cjs → chunk-ICG7JRYT.cjs} +40 -40
  25. package/dist/{chunk-J76LCSR4.cjs.map → chunk-ICG7JRYT.cjs.map} +1 -1
  26. package/dist/{chunk-P22HM2MO.cjs → chunk-JDUPAHJR.cjs} +18 -18
  27. package/dist/{chunk-P22HM2MO.cjs.map → chunk-JDUPAHJR.cjs.map} +1 -1
  28. package/dist/{chunk-B2GEKAAA.js → chunk-JRWOHH7K.js} +2 -2
  29. package/dist/{chunk-TXV444YV.js → chunk-JXJ4FTKN.js} +3 -3
  30. package/dist/{chunk-CU6Q2KQT.cjs → chunk-LZD3VSPS.cjs} +12 -12
  31. package/dist/{chunk-CU6Q2KQT.cjs.map → chunk-LZD3VSPS.cjs.map} +1 -1
  32. package/dist/{chunk-M5TWHFIN.cjs → chunk-Q5SZ2QTZ.cjs} +42 -42
  33. package/dist/{chunk-M5TWHFIN.cjs.map → chunk-Q5SZ2QTZ.cjs.map} +1 -1
  34. package/dist/{chunk-GSONSIWG.js → chunk-R7A7WHXP.js} +60 -35
  35. package/dist/chunk-R7A7WHXP.js.map +1 -0
  36. package/dist/{chunk-E56VNTBV.cjs → chunk-S4LL6QI6.cjs} +147 -147
  37. package/dist/{chunk-E56VNTBV.cjs.map → chunk-S4LL6QI6.cjs.map} +1 -1
  38. package/dist/{chunk-KQKU4VXB.cjs → chunk-STHVHAV4.cjs} +82 -57
  39. package/dist/chunk-STHVHAV4.cjs.map +1 -0
  40. package/dist/{chunk-A7ES3NZK.cjs → chunk-SXMJLNOF.cjs} +3 -3
  41. package/dist/{chunk-A7ES3NZK.cjs.map → chunk-SXMJLNOF.cjs.map} +1 -1
  42. package/dist/{chunk-CTTY5EKH.js → chunk-VF4DCOPD.js} +5 -5
  43. package/dist/{chunk-QTO3WYB2.cjs → chunk-VJTD2DGF.cjs} +3 -3
  44. package/dist/{chunk-QTO3WYB2.cjs.map → chunk-VJTD2DGF.cjs.map} +1 -1
  45. package/dist/{chunk-VGLDA77S.js → chunk-VRX6Y6A7.js} +2 -2
  46. package/dist/{chunk-L36R3G7Z.js → chunk-VXNMOGRP.js} +6 -6
  47. package/dist/{chunk-OR67WC32.js → chunk-WBQ3NOH7.js} +2 -2
  48. package/dist/{chunk-QMQQ37RW.js → chunk-WX5UCLL7.js} +2 -2
  49. package/dist/{chunk-QZ64JOE2.js → chunk-XGBVHQP7.js} +3 -3
  50. package/dist/{chunk-K432ES7N.js → chunk-YM2WHYYK.js} +4 -4
  51. package/dist/{chunk-KXLQITOX.cjs → chunk-Z3R3LOC5.cjs} +5 -5
  52. package/dist/{chunk-KXLQITOX.cjs.map → chunk-Z3R3LOC5.cjs.map} +1 -1
  53. package/dist/components/case-studies/index.cjs +10 -10
  54. package/dist/components/case-studies/index.js +4 -4
  55. package/dist/components/chat/index.cjs +4 -4
  56. package/dist/components/chat/index.js +3 -3
  57. package/dist/components/contact/index.cjs +5 -5
  58. package/dist/components/contact/index.js +4 -4
  59. package/dist/components/docs/index.cjs +7 -7
  60. package/dist/components/docs/index.js +6 -6
  61. package/dist/components/embeds/index.cjs +5 -5
  62. package/dist/components/embeds/index.js +4 -4
  63. package/dist/components/faq/index.cjs +6 -6
  64. package/dist/components/faq/index.js +5 -5
  65. package/dist/components/features/index.cjs +4 -4
  66. package/dist/components/features/index.js +3 -3
  67. package/dist/components/help-center-pages/index.cjs +25 -25
  68. package/dist/components/help-center-pages/index.js +16 -16
  69. package/dist/components/index.cjs +138 -138
  70. package/dist/components/index.js +14 -14
  71. package/dist/components/meeting-scheduler/index.cjs +35 -35
  72. package/dist/components/meeting-scheduler/index.js +5 -5
  73. package/dist/components/navigation/index.cjs +4 -4
  74. package/dist/components/navigation/index.js +3 -3
  75. package/dist/components/onboarding-guides/index.cjs +8 -8
  76. package/dist/components/onboarding-guides/index.js +7 -7
  77. package/dist/components/related-content/index.cjs +6 -6
  78. package/dist/components/related-content/index.js +5 -5
  79. package/dist/components/tickets/index.cjs +8 -8
  80. package/dist/components/tickets/index.js +7 -7
  81. package/dist/components/ui/index.cjs +4 -4
  82. package/dist/components/ui/index.js +3 -3
  83. package/dist/hooks/index.cjs +3 -3
  84. package/dist/hooks/index.js +2 -2
  85. package/dist/index.cjs +4 -4
  86. package/dist/index.js +3 -3
  87. package/dist/nats/index.cjs +2 -2
  88. package/dist/nats/index.js +1 -1
  89. package/dist/nats/nats.d.ts.map +1 -1
  90. package/dist/nats/shared-connection.d.ts +13 -6
  91. package/dist/nats/shared-connection.d.ts.map +1 -1
  92. package/package.json +1 -1
  93. package/src/nats/__tests__/reconnect-lifecycle.test.tsx +814 -0
  94. package/src/nats/nats.ts +56 -13
  95. package/src/nats/shared-connection.ts +132 -36
  96. package/dist/chunk-GSONSIWG.js.map +0 -1
  97. package/dist/chunk-KQKU4VXB.cjs.map +0 -1
  98. /package/dist/{chunk-6ER3AYJM.js.map → chunk-5756Q6UN.js.map} +0 -0
  99. /package/dist/{chunk-E2IPBWGT.js.map → chunk-5EES5HD4.js.map} +0 -0
  100. /package/dist/{chunk-ODRMPI3Y.js.map → chunk-5JWLZJK5.js.map} +0 -0
  101. /package/dist/{chunk-CVCQW7WG.js.map → chunk-5M4PS3XX.js.map} +0 -0
  102. /package/dist/{chunk-P7MSXPGL.js.map → chunk-CGUSNV6R.js.map} +0 -0
  103. /package/dist/{chunk-HO3Q7NXJ.js.map → chunk-FLSEJSTN.js.map} +0 -0
  104. /package/dist/{chunk-UL3WQWTF.js.map → chunk-FZT7BYNF.js.map} +0 -0
  105. /package/dist/{chunk-B2GEKAAA.js.map → chunk-JRWOHH7K.js.map} +0 -0
  106. /package/dist/{chunk-TXV444YV.js.map → chunk-JXJ4FTKN.js.map} +0 -0
  107. /package/dist/{chunk-CTTY5EKH.js.map → chunk-VF4DCOPD.js.map} +0 -0
  108. /package/dist/{chunk-VGLDA77S.js.map → chunk-VRX6Y6A7.js.map} +0 -0
  109. /package/dist/{chunk-L36R3G7Z.js.map → chunk-VXNMOGRP.js.map} +0 -0
  110. /package/dist/{chunk-OR67WC32.js.map → chunk-WBQ3NOH7.js.map} +0 -0
  111. /package/dist/{chunk-QMQQ37RW.js.map → chunk-WX5UCLL7.js.map} +0 -0
  112. /package/dist/{chunk-QZ64JOE2.js.map → chunk-XGBVHQP7.js.map} +0 -0
  113. /package/dist/{chunk-K432ES7N.js.map → chunk-YM2WHYYK.js.map} +0 -0
@@ -0,0 +1,814 @@
1
+ /**
2
+ * Reconnect behaviour of the shared NATS connection, exercised end to end: the
3
+ * real `NatsProvider`, the real `shared-connection` bookkeeping and the real
4
+ * `nats.ws` client, against a fake gateway on a stubbed `WebSocket`.
5
+ *
6
+ * The scenarios are drawn from how the connection is actually driven in a
7
+ * NATIVE SHELL, which is the harshest caller. There the bearer token is a
8
+ * query param on the socket URL (`buildNatsWsUrl` with `includeAuthParam`), so
9
+ * every token rotation changes the URL and forces a full teardown and rebuild
10
+ * — on the web the URL is constant and a rotation is invisible here. The
11
+ * gateway also closes every `/ws/nats*` session at JWT `exp + 60s`
12
+ * (`WebSocketServiceSecurityDecorator`), so those rebuilds are guaranteed to
13
+ * happen on the token clock rather than only when the network misbehaves.
14
+ *
15
+ * Several of these pin failures that were PERMANENT: no retry, no error, no
16
+ * status event, the tail simply never came back and nothing said so.
17
+ */
18
+ import { act, cleanup, render, waitFor } from '@testing-library/react'
19
+ import * as React from 'react'
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
21
+ import {
22
+ acquireClient,
23
+ getSharedConnectionFor,
24
+ NATS_DEFAULTS,
25
+ NatsProvider,
26
+ type NatsStatus,
27
+ releaseClient,
28
+ startConnectionLifecycle,
29
+ useNats,
30
+ } from '@/nats'
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Fake gateway
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const encoder = new TextEncoder()
37
+ const decoder = new TextDecoder()
38
+
39
+ const INFO =
40
+ `INFO {"server_id":"FAKE","server_name":"FAKE","version":"2.10.0","proto":1,"go":"go1.21",` +
41
+ `"host":"127.0.0.1","port":4222,"headers":true,"max_payload":1048576,"client_id":1,` +
42
+ `"client_ip":"127.0.0.1"}\r\n`
43
+
44
+ /** Tokens the fake gateway currently accepts on the handshake. */
45
+ let validTokens = new Set<string>()
46
+ let sockets: FakeWebSocket[] = []
47
+ /** Unique per test: connections are cached by URL in module state. */
48
+ let host = 'h0'
49
+ /** Stop answering PING, which is what makes nats.ws raise `staleConnection`. */
50
+ let suppressPong = false
51
+
52
+ const WS_CONNECTING = 0
53
+ const WS_OPEN = 1
54
+ const WS_CLOSED = 3
55
+
56
+ function tokenOf(url: string): string {
57
+ return new URL(url.replace(/^ws/, 'http')).searchParams.get('authorization') ?? ''
58
+ }
59
+
60
+ class FakeWebSocket
61
+ implements Pick<WebSocket, 'url' | 'readyState' | 'binaryType' | 'bufferedAmount' | 'close' | 'send'>
62
+ {
63
+ url: string
64
+ readyState: number = WS_CONNECTING
65
+ binaryType: BinaryType = 'blob'
66
+ bufferedAmount = 0
67
+ onopen: ((e?: unknown) => void) | null = null
68
+ onmessage: ((e: { data: ArrayBuffer }) => void) | null = null
69
+ onclose: ((e: { wasClean: boolean; code: number; reason: string }) => void) | null = null
70
+ onerror: ((e: { message: string; error: Error }) => void) | null = null
71
+ readonly accepted: boolean
72
+
73
+ constructor(url: string) {
74
+ this.url = url
75
+ this.accepted = validTokens.has(tokenOf(url))
76
+ sockets.push(this)
77
+ setTimeout(() => {
78
+ if (!this.accepted) {
79
+ // A gateway that refuses the upgrade (expired/unknown bearer) looks
80
+ // like this from the browser: error, then a non-clean close.
81
+ this.readyState = WS_CLOSED
82
+ this.onerror?.({ message: 'handshake refused', error: new Error('401') })
83
+ this.onclose?.({ wasClean: false, code: 1006, reason: '' })
84
+ return
85
+ }
86
+ this.readyState = WS_OPEN
87
+ this.onopen?.()
88
+ this.deliver(INFO)
89
+ }, 0)
90
+ }
91
+
92
+ private deliver(frame: string) {
93
+ const bytes = encoder.encode(frame)
94
+ this.onmessage?.({
95
+ data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
96
+ })
97
+ }
98
+
99
+ send(buf: string | ArrayBufferLike | Blob | ArrayBufferView) {
100
+ const view = ArrayBuffer.isView(buf)
101
+ ? new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
102
+ : new Uint8Array(buf as ArrayBufferLike)
103
+ if (decoder.decode(view).includes('PING') && !suppressPong) {
104
+ setTimeout(() => this.deliver('PONG\r\n'), 0)
105
+ }
106
+ }
107
+
108
+ close(code?: number) {
109
+ if (this.readyState === WS_CLOSED) return
110
+ this.readyState = WS_CLOSED
111
+ this.onclose?.({ wasClean: true, code: code ?? 1000, reason: '' })
112
+ }
113
+
114
+ /** The gateway retiring the session at JWT expiry. */
115
+ serverClose() {
116
+ this.close(1000)
117
+ }
118
+
119
+ get isOpen() {
120
+ return this.readyState === WS_OPEN
121
+ }
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Harness
126
+ // ---------------------------------------------------------------------------
127
+
128
+ async function tick(ms = 0) {
129
+ await act(async () => {
130
+ await new Promise((r) => setTimeout(r, ms))
131
+ })
132
+ }
133
+
134
+ function currentHost() {
135
+ return `${host}.test`
136
+ }
137
+
138
+ /**
139
+ * The one place the socket URL is spelled. It is what keys the module-level
140
+ * connection registry, so a second copy drifting from this one would leave
141
+ * `getSharedConnectionFor` matching nothing while the assertions still passed.
142
+ */
143
+ function currentUrl(token: string) {
144
+ return `ws://${currentHost()}/ws/nats-api?authorization=${token}`
145
+ }
146
+
147
+ /**
148
+ * Scoped by host as well as by token: `beforeEach` bumps the host precisely so
149
+ * that a connection cached from an earlier test cannot satisfy a later test's
150
+ * assertion, and matching on the token alone would throw that isolation away.
151
+ * Substring rather than equality against `currentUrl` because nats.ws hands the
152
+ * WebSocket a normalised URL — the default port spelled out.
153
+ */
154
+ function openSocketsFor(token: string) {
155
+ return sockets.filter((s) => s.url.includes(currentHost()) && tokenOf(s.url) === token && s.isOpen)
156
+ }
157
+
158
+ const BACKOFF = { fastRetries: 3, fastRetryDelayMs: 200 } as const
159
+ const CLIENT = { name: 'openframe-frontend-app', user: 'machine', pass: '' } as const
160
+
161
+ /** How long a first connect may take, and how long a recovery may take. */
162
+ const READY_TIMEOUT = 6_000
163
+ const RECOVERY_TIMEOUT = 20_000
164
+ /**
165
+ * Per-test budget. Has to dominate the waits inside the test, or vitest kills it
166
+ * before the assertion it exists to bound gets its own timeout — the busiest
167
+ * test here spends up to `READY_TIMEOUT * 2 + RECOVERY_TIMEOUT * 3`.
168
+ */
169
+ const TEST_TIMEOUT = READY_TIMEOUT * 2 + RECOVERY_TIMEOUT * 3 + 10_000
170
+
171
+ interface ProviderState {
172
+ status: NatsStatus
173
+ ready: boolean
174
+ }
175
+
176
+ function Probe({ onState }: { onState: (s: ProviderState) => void }) {
177
+ const { status, isReady } = useNats()
178
+ React.useEffect(() => {
179
+ onState({ status, ready: isReady })
180
+ })
181
+ return null
182
+ }
183
+
184
+ interface Controls {
185
+ setToken: (t: string) => void
186
+ setChatOpen: (v: boolean) => void
187
+ }
188
+
189
+ /**
190
+ * Mirrors `NatsAppConfigProvider` + `NatsAppProvider`: the bearer lives in
191
+ * React state, the URL is derived from it and `urlRevision` carries it, so a
192
+ * rotation re-runs the provider's only effect.
193
+ */
194
+ function App({
195
+ controls,
196
+ onBeforeReconnect,
197
+ onProviderState,
198
+ onChatState,
199
+ chatOpenInitially = false,
200
+ withChat = false,
201
+ }: {
202
+ controls: Controls
203
+ onBeforeReconnect: (setToken: (t: string) => void) => Promise<void>
204
+ onProviderState: (s: ProviderState) => void
205
+ onChatState?: (s: { connected: boolean }) => void
206
+ chatOpenInitially?: boolean
207
+ withChat?: boolean
208
+ }) {
209
+ const [token, setToken] = React.useState('T1')
210
+ const [chatOpen, setChatOpen] = React.useState(chatOpenInitially)
211
+ React.useEffect(() => {
212
+ controls.setToken = setToken
213
+ controls.setChatOpen = setChatOpen
214
+ }, [controls])
215
+
216
+ const getWsUrl = React.useCallback(() => (token ? currentUrl(token) : null), [token])
217
+ const before = React.useCallback(async () => {
218
+ await onBeforeReconnect(setToken)
219
+ }, [onBeforeReconnect])
220
+
221
+ return (
222
+ <NatsProvider
223
+ getWsUrl={getWsUrl}
224
+ onBeforeReconnect={before}
225
+ clientConfig={CLIENT}
226
+ reconnectionBackoff={BACKOFF}
227
+ urlRevision={token}
228
+ >
229
+ <Probe onState={onProviderState} />
230
+ {withChat && chatOpen ? (
231
+ <ChatSurface getWsUrl={getWsUrl} onBeforeReconnect={before} onState={onChatState!} />
232
+ ) : null}
233
+ </NatsProvider>
234
+ )
235
+ }
236
+
237
+ /**
238
+ * Stands in for `useJetStreamDialogSubscription` / `useNatsDialogSubscription`:
239
+ * resolves the URL during render, acquires the SHARED connection, runs its own
240
+ * lifecycle and releases on cleanup, keyed on `[enabled, wsUrl]` like the real
241
+ * hooks. It matters that this shares one connection with the provider — every
242
+ * NATS surface resolves to the same `/ws/nats-api` URL.
243
+ */
244
+ function ChatSurface({
245
+ getWsUrl,
246
+ onBeforeReconnect,
247
+ onState,
248
+ }: {
249
+ getWsUrl: () => string | null
250
+ onBeforeReconnect: () => Promise<void>
251
+ onState: (s: { connected: boolean }) => void
252
+ }) {
253
+ const wsUrl = getWsUrl()
254
+ const [connected, setConnected] = React.useState(false)
255
+ const heldRef = React.useRef<string>('')
256
+ const cbRef = React.useRef({ getWsUrl, onBeforeReconnect })
257
+ React.useEffect(() => {
258
+ cbRef.current = { getWsUrl, onBeforeReconnect }
259
+ })
260
+
261
+ React.useEffect(() => {
262
+ if (!wsUrl) {
263
+ if (heldRef.current) {
264
+ releaseClient(heldRef.current)
265
+ heldRef.current = ''
266
+ setConnected(false)
267
+ }
268
+ return
269
+ }
270
+ heldRef.current = wsUrl
271
+ const conn = acquireClient(wsUrl, CLIENT)
272
+ setConnected(conn.client.isConnected())
273
+
274
+ const lifecycle = startConnectionLifecycle({
275
+ conn,
276
+ wsUrl,
277
+ onBeforeReconnect: () => cbRef.current.onBeforeReconnect(),
278
+ backoff: BACKOFF,
279
+ getFreshUrl: () => cbRef.current.getWsUrl(),
280
+ onStatusChange: (status) => {
281
+ if (status === 'connected') setConnected(true)
282
+ if (status === 'closed' || status === 'disconnected') setConnected(false)
283
+ },
284
+ })
285
+
286
+ return () => {
287
+ lifecycle.stop()
288
+ setConnected(false)
289
+ if (heldRef.current) {
290
+ releaseClient(heldRef.current)
291
+ heldRef.current = ''
292
+ }
293
+ }
294
+ }, [wsUrl])
295
+
296
+ React.useEffect(() => {
297
+ onState({ connected })
298
+ })
299
+ return null
300
+ }
301
+
302
+ let testIndex = 0
303
+
304
+ describe('shared NATS connection reconnect', () => {
305
+ beforeEach(() => {
306
+ vi.stubGlobal('WebSocket', FakeWebSocket)
307
+ host = `h${++testIndex}`
308
+ validTokens = new Set(['T1'])
309
+ sockets = []
310
+ suppressPong = false
311
+ })
312
+
313
+ afterEach(() => {
314
+ // Unmount BEFORE restoring the real WebSocket: a lifecycle left mounted
315
+ // keeps its provider tree, its shared connection and any armed retry timer
316
+ // alive into the next test, and a retry firing after the stub is gone would
317
+ // dial jsdom's real WebSocket.
318
+ cleanup()
319
+ vi.unstubAllGlobals()
320
+ })
321
+
322
+ function boot(
323
+ onBeforeReconnect: (setToken: (t: string) => void) => Promise<void>,
324
+ opts: { withChat?: boolean; chatOpenInitially?: boolean } = {},
325
+ ) {
326
+ let provider: ProviderState = { status: 'closed', ready: false }
327
+ let chat = { connected: false }
328
+ const controls: Controls = {
329
+ setToken: () => {},
330
+ setChatOpen: () => {},
331
+ }
332
+ render(
333
+ <App
334
+ controls={controls}
335
+ onBeforeReconnect={onBeforeReconnect}
336
+ onProviderState={(s) => {
337
+ provider = s
338
+ }}
339
+ onChatState={(s) => {
340
+ chat = s
341
+ }}
342
+ withChat={opts.withChat}
343
+ chatOpenInitially={opts.chatOpenInitially}
344
+ />,
345
+ )
346
+ return {
347
+ controls,
348
+ get provider() {
349
+ return provider
350
+ },
351
+ get chat() {
352
+ return chat
353
+ },
354
+ }
355
+ }
356
+
357
+ const noRefresh = async () => {}
358
+
359
+ /** What a 401 inside `onBeforeReconnect` does: rotate, and let the gateway follow. */
360
+ const refreshToT2 = async (setToken: (t: string) => void) => {
361
+ await Promise.resolve()
362
+ validTokens = new Set(['T2'])
363
+ act(() => setToken('T2'))
364
+ }
365
+
366
+ // -------------------------------------------------------------------------
367
+ describe('token rotation', () => {
368
+ it('reconnects with the rotated token after the gateway closes at JWT expiry', async () => {
369
+ const app = boot(refreshToT2)
370
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
371
+
372
+ validTokens = new Set()
373
+ await act(async () => {
374
+ openSocketsFor('T1')[0].serverClose()
375
+ await tick()
376
+ })
377
+
378
+ await waitFor(() => expect(openSocketsFor('T2').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
379
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
380
+ }, TEST_TIMEOUT)
381
+
382
+ it('swaps cleanly when a REST 401 rotates the token while the socket is healthy', async () => {
383
+ const app = boot(noRefresh)
384
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
385
+
386
+ await act(async () => {
387
+ validTokens = new Set(['T1', 'T2'])
388
+ app.controls.setToken('T2')
389
+ await tick()
390
+ })
391
+
392
+ await waitFor(() => expect(openSocketsFor('T2').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
393
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
394
+ }, TEST_TIMEOUT)
395
+
396
+ it('recovers when the rotation lands on an already dead socket', async () => {
397
+ const app = boot(noRefresh)
398
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
399
+
400
+ validTokens = new Set()
401
+ await act(async () => {
402
+ openSocketsFor('T1')[0].serverClose()
403
+ await tick(50)
404
+ })
405
+ await act(async () => {
406
+ validTokens = new Set(['T2'])
407
+ app.controls.setToken('T2')
408
+ await tick()
409
+ })
410
+
411
+ await waitFor(() => expect(openSocketsFor('T2').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
412
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
413
+ }, TEST_TIMEOUT)
414
+
415
+ it('recovers when the rotation lands while a retry connect is in flight', async () => {
416
+ const app = boot(async () => {
417
+ await new Promise((r) => setTimeout(r, 400))
418
+ })
419
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
420
+
421
+ validTokens = new Set()
422
+ await act(async () => {
423
+ openSocketsFor('T1')[0].serverClose()
424
+ await tick(250)
425
+ })
426
+ await act(async () => {
427
+ validTokens = new Set(['T2'])
428
+ app.controls.setToken('T2')
429
+ await tick()
430
+ })
431
+
432
+ await waitFor(() => expect(openSocketsFor('T2').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
433
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
434
+ }, TEST_TIMEOUT)
435
+
436
+ it('settles on the newest token when two rotations land back to back', async () => {
437
+ const app = boot(noRefresh)
438
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
439
+
440
+ validTokens = new Set()
441
+ await act(async () => {
442
+ openSocketsFor('T1')[0].serverClose()
443
+ await tick()
444
+ })
445
+ await act(async () => {
446
+ app.controls.setToken('T2')
447
+ await tick()
448
+ })
449
+ await act(async () => {
450
+ validTokens = new Set(['T3'])
451
+ app.controls.setToken('T3')
452
+ await tick()
453
+ })
454
+
455
+ await waitFor(() => expect(openSocketsFor('T3').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
456
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
457
+ }, TEST_TIMEOUT)
458
+
459
+ it('brings a chat surface back with the provider after a JWT-expiry close', async () => {
460
+ const app = boot(refreshToT2, { withChat: true, chatOpenInitially: true })
461
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
462
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: READY_TIMEOUT })
463
+
464
+ validTokens = new Set()
465
+ await act(async () => {
466
+ openSocketsFor('T1')[0].serverClose()
467
+ await tick()
468
+ })
469
+
470
+ await waitFor(() => expect(openSocketsFor('T2').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
471
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
472
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: RECOVERY_TIMEOUT })
473
+ }, TEST_TIMEOUT)
474
+
475
+ it('brings a chat surface across a rotation on a healthy socket', async () => {
476
+ const app = boot(noRefresh, { withChat: true, chatOpenInitially: true })
477
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
478
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: READY_TIMEOUT })
479
+
480
+ await act(async () => {
481
+ validTokens = new Set(['T1', 'T2'])
482
+ app.controls.setToken('T2')
483
+ await tick()
484
+ })
485
+
486
+ await waitFor(() => expect(openSocketsFor('T2').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
487
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
488
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: RECOVERY_TIMEOUT })
489
+ }, TEST_TIMEOUT)
490
+ })
491
+
492
+ // -------------------------------------------------------------------------
493
+ describe('reuse of a cached connection', () => {
494
+ // `connectPromise` used to survive a SUCCESSFUL connect, so re-acquiring a
495
+ // still-cached connection whose socket had since died joined that settled
496
+ // promise instead of dialling: no connect, no failure, nothing to schedule
497
+ // a retry, and no status event coming either. Permanently dead.
498
+ it('dials again when the URL returns to a cached value inside the release grace', async () => {
499
+ const app = boot(noRefresh)
500
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
501
+
502
+ await act(async () => {
503
+ openSocketsFor('T1')[0].serverClose()
504
+ app.controls.setToken('') // getWsUrl() -> null, connection released
505
+ await tick()
506
+ })
507
+ await act(async () => {
508
+ app.controls.setToken('T1') // same URL, still inside SHARED_CLOSE_DELAY_MS
509
+ await tick()
510
+ })
511
+
512
+ await waitFor(() => expect(openSocketsFor('T1').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
513
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
514
+ }, TEST_TIMEOUT)
515
+
516
+ it('dials again when the URL returns after the release grace has expired', async () => {
517
+ const app = boot(noRefresh)
518
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
519
+
520
+ await act(async () => {
521
+ openSocketsFor('T1')[0].serverClose()
522
+ app.controls.setToken('')
523
+ await tick()
524
+ })
525
+ await tick(NATS_DEFAULTS.SHARED_CLOSE_DELAY_MS + 500) // cache entry is gone
526
+ await act(async () => {
527
+ app.controls.setToken('T1')
528
+ await tick()
529
+ })
530
+
531
+ await waitFor(() => expect(openSocketsFor('T1').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
532
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
533
+ }, TEST_TIMEOUT)
534
+
535
+ it('reconnects when a chat surface re-opens on the same URL with the socket dead', async () => {
536
+ const app = boot(noRefresh, { withChat: true, chatOpenInitially: true })
537
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
538
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: READY_TIMEOUT })
539
+
540
+ await act(async () => {
541
+ app.controls.setChatOpen(false)
542
+ await tick()
543
+ })
544
+ await act(async () => {
545
+ openSocketsFor('T1')[0].serverClose()
546
+ await tick()
547
+ })
548
+ await act(async () => {
549
+ app.controls.setChatOpen(true)
550
+ await tick()
551
+ })
552
+
553
+ await waitFor(() => expect(openSocketsFor('T1').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
554
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
555
+ }, TEST_TIMEOUT)
556
+ })
557
+
558
+ // -------------------------------------------------------------------------
559
+ describe('retry ownership across consumers', () => {
560
+ // `retryTimer` lives on the CONNECTION, and every lifecycle used to clear
561
+ // it on stop() whether or not it owned the loop. So any consumer merely
562
+ // re-running its effect during a backoff — a dialog closing, an `enabled`
563
+ // toggle, a route change — cancelled reconnect for everyone still attached,
564
+ // and nothing rearmed it: retry is only ever scheduled from a status event,
565
+ // and a socket that is already down produces none.
566
+ it('keeps retrying when a chat surface unmounts mid-backoff (chat owned the loop)', async () => {
567
+ const app = boot(noRefresh, { withChat: true, chatOpenInitially: true })
568
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
569
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: READY_TIMEOUT })
570
+
571
+ validTokens = new Set()
572
+ await act(async () => {
573
+ openSocketsFor('T1')[0].serverClose()
574
+ await tick(1200)
575
+ })
576
+ const attempts = sockets.length
577
+ expect(attempts).toBeGreaterThan(1)
578
+
579
+ await act(async () => {
580
+ app.controls.setChatOpen(false)
581
+ await tick(50)
582
+ })
583
+
584
+ validTokens = new Set(['T1'])
585
+ await waitFor(() => expect(openSocketsFor('T1').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
586
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
587
+ }, TEST_TIMEOUT)
588
+
589
+ it('keeps retrying when a NON-owner chat surface unmounts mid-backoff', async () => {
590
+ // Provider connects alone first, so it owns the loop; the chat surface
591
+ // attaching later is a pure observer whose stop() must not touch it.
592
+ const app = boot(noRefresh, { withChat: true })
593
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
594
+ validTokens = new Set(['T1'])
595
+ await act(async () => {
596
+ app.controls.setChatOpen(true)
597
+ await tick(50)
598
+ })
599
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: READY_TIMEOUT })
600
+
601
+ validTokens = new Set()
602
+ await act(async () => {
603
+ openSocketsFor('T1')[0].serverClose()
604
+ await tick(1500)
605
+ })
606
+ const attempts = sockets.length
607
+ expect(attempts).toBeGreaterThan(1)
608
+ // Without this the unmount can land while a dial is in flight instead of
609
+ // while the timer is armed, and the dial's own catch reschedules — so the
610
+ // test would pass against the unfixed code. Polled rather than sampled:
611
+ // the attempt this lands on is jittered, so a single read can catch the
612
+ // dial rather than the backoff and fail the code for the harness's sake.
613
+ await waitFor(
614
+ () => expect(getSharedConnectionFor(currentUrl('T1'))?.retryTimer ?? null).not.toBeNull(),
615
+ { timeout: READY_TIMEOUT },
616
+ )
617
+
618
+ await act(async () => {
619
+ app.controls.setChatOpen(false)
620
+ await tick(50)
621
+ })
622
+
623
+ validTokens = new Set(['T1'])
624
+ await waitFor(() => expect(openSocketsFor('T1').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
625
+ expect(sockets.length).toBeGreaterThan(attempts)
626
+ }, TEST_TIMEOUT)
627
+
628
+ it('lets a re-opened chat surface join the connection the survivor restored', async () => {
629
+ const app = boot(noRefresh, { withChat: true })
630
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
631
+ validTokens = new Set(['T1'])
632
+ await act(async () => {
633
+ app.controls.setChatOpen(true)
634
+ await tick(50)
635
+ })
636
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: READY_TIMEOUT })
637
+
638
+ validTokens = new Set()
639
+ await act(async () => {
640
+ openSocketsFor('T1')[0].serverClose()
641
+ await tick(1500)
642
+ })
643
+ await act(async () => {
644
+ app.controls.setChatOpen(false)
645
+ await tick(50)
646
+ })
647
+ validTokens = new Set(['T1'])
648
+
649
+ await waitFor(() => expect(openSocketsFor('T1').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
650
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
651
+
652
+ await act(async () => {
653
+ app.controls.setChatOpen(true)
654
+ await tick(100)
655
+ })
656
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: RECOVERY_TIMEOUT })
657
+ }, TEST_TIMEOUT)
658
+
659
+ it('recovers when a chat surface unmounts and the token then rotates', async () => {
660
+ const app = boot(async () => {
661
+ await new Promise((r) => setTimeout(r, 3000))
662
+ }, { withChat: true, chatOpenInitially: true })
663
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
664
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: READY_TIMEOUT })
665
+
666
+ validTokens = new Set()
667
+ await act(async () => {
668
+ openSocketsFor('T1')[0].serverClose()
669
+ await tick(20)
670
+ })
671
+ await act(async () => {
672
+ app.controls.setChatOpen(false)
673
+ await tick(20)
674
+ })
675
+ await act(async () => {
676
+ validTokens = new Set(['T2'])
677
+ app.controls.setToken('T2')
678
+ await tick()
679
+ })
680
+
681
+ await waitFor(() => expect(openSocketsFor('T2').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
682
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
683
+ }, TEST_TIMEOUT)
684
+
685
+ // Two consumers that have both re-pointed at a new URL but have not torn
686
+ // down yet used to alternate ownership forever — each pass released the
687
+ // loop, handed it to the other, and ran onBeforeReconnect (a token refresh
688
+ // in the real callers) without dialling anything. Measured at 39 refreshes
689
+ // in 3s before the fix.
690
+ it('does not pass the loop between lifecycles that have all moved to another URL', async () => {
691
+ const url = currentUrl('T1')
692
+ const movedOn = currentUrl('T2')
693
+ let refreshes = 0
694
+ const onBeforeReconnect = async () => {
695
+ refreshes += 1
696
+ }
697
+
698
+ const conn = acquireClient(url, CLIENT)
699
+ acquireClient(url, CLIENT)
700
+ // Dial directly: nothing connects on acquire, and attaching a lifecycle
701
+ // that still wants this URL would make it a legitimate successor.
702
+ await act(async () => {
703
+ await conn.client.connect()
704
+ })
705
+ expect(conn.client.isConnected()).toBe(true)
706
+
707
+ const a = startConnectionLifecycle({
708
+ conn,
709
+ wsUrl: url,
710
+ onBeforeReconnect,
711
+ backoff: BACKOFF,
712
+ getFreshUrl: () => movedOn,
713
+ })
714
+ const b = startConnectionLifecycle({
715
+ conn,
716
+ wsUrl: url,
717
+ onBeforeReconnect,
718
+ backoff: BACKOFF,
719
+ getFreshUrl: () => movedOn,
720
+ })
721
+
722
+ validTokens = new Set()
723
+ openSocketsFor('T1')[0].serverClose()
724
+ await tick(1_500)
725
+
726
+ expect(refreshes).toBe(0)
727
+ expect(conn.retryTimer).toBeNull()
728
+
729
+ a.stop()
730
+ b.stop()
731
+ releaseClient(url)
732
+ releaseClient(url)
733
+ }, TEST_TIMEOUT)
734
+
735
+ it('recovers from a drop after a rotation moved every lifecycle', async () => {
736
+ const app = boot(noRefresh, { withChat: true })
737
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: READY_TIMEOUT })
738
+ validTokens = new Set(['T1', 'T2'])
739
+ await act(async () => {
740
+ app.controls.setChatOpen(true)
741
+ await tick(50)
742
+ })
743
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: READY_TIMEOUT })
744
+
745
+ await act(async () => {
746
+ app.controls.setToken('T2')
747
+ await tick(100)
748
+ })
749
+ await waitFor(() => expect(openSocketsFor('T2').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
750
+ await waitFor(() => expect(app.chat.connected).toBe(true), { timeout: RECOVERY_TIMEOUT })
751
+
752
+ await act(async () => {
753
+ app.controls.setChatOpen(false)
754
+ await tick(50)
755
+ })
756
+ await act(async () => {
757
+ openSocketsFor('T2')[0].serverClose()
758
+ await tick(100)
759
+ })
760
+
761
+ await waitFor(() => expect(openSocketsFor('T2').length).toBeGreaterThan(0), { timeout: RECOVERY_TIMEOUT })
762
+ await waitFor(() => expect(app.provider.ready).toBe(true), { timeout: RECOVERY_TIMEOUT })
763
+ }, TEST_TIMEOUT)
764
+ })
765
+
766
+ // -------------------------------------------------------------------------
767
+ describe('status mapping', () => {
768
+ // `staleConnection` and `client initiated reconnect` both contain "connect"
769
+ // and neither contains "disconnect", so substring matching reported them as
770
+ // CONNECTED — a live connection announced at the moment the client gave up
771
+ // on one, plus a phantom `reconnectionCount` bump that every subscriber
772
+ // reads as "the tail came back".
773
+ it('reports staleConnection as disconnected, with no phantom reconnect', async () => {
774
+ const statuses: NatsStatus[] = []
775
+ let reconnectionCount = 0
776
+
777
+ function Spy() {
778
+ const nats = useNats()
779
+ React.useEffect(() => {
780
+ statuses.push(nats.status)
781
+ reconnectionCount = nats.reconnectionCount
782
+ })
783
+ return null
784
+ }
785
+
786
+ function StaleApp() {
787
+ const getWsUrl = React.useCallback(() => currentUrl('T1'), [])
788
+ return (
789
+ <NatsProvider
790
+ getWsUrl={getWsUrl}
791
+ onBeforeReconnect={noRefresh}
792
+ clientConfig={{ ...CLIENT, pingIntervalMs: 80, maxPingOut: 1 }}
793
+ reconnectionBackoff={{ fastRetries: 0, initialDelayMs: 30000 }}
794
+ urlRevision="T1"
795
+ >
796
+ <Spy />
797
+ </NatsProvider>
798
+ )
799
+ }
800
+
801
+ render(<StaleApp />)
802
+ await waitFor(() => expect(statuses).toContain('connected'), { timeout: READY_TIMEOUT })
803
+
804
+ const mark = statuses.length
805
+ suppressPong = true
806
+ await tick(600)
807
+
808
+ const afterPongLoss = statuses.slice(mark)
809
+ expect(afterPongLoss).toContain('disconnected')
810
+ expect(afterPongLoss).not.toContain('connected')
811
+ expect(reconnectionCount).toBe(0)
812
+ }, TEST_TIMEOUT)
813
+ })
814
+ })