@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.
- package/dist/{chunk-6ER3AYJM.js → chunk-5756Q6UN.js} +3 -3
- package/dist/{chunk-E2IPBWGT.js → chunk-5EES5HD4.js} +6 -6
- package/dist/{chunk-ODRMPI3Y.js → chunk-5JWLZJK5.js} +2 -2
- package/dist/{chunk-CVCQW7WG.js → chunk-5M4PS3XX.js} +3 -3
- package/dist/{chunk-6QRKCVYR.cjs → chunk-7Q7GFIFB.cjs} +7 -7
- package/dist/{chunk-6QRKCVYR.cjs.map → chunk-7Q7GFIFB.cjs.map} +1 -1
- package/dist/{chunk-UXW63WCV.cjs → chunk-BNMKHG2L.cjs} +4 -4
- package/dist/{chunk-UXW63WCV.cjs.map → chunk-BNMKHG2L.cjs.map} +1 -1
- package/dist/{chunk-SST2YI7S.cjs → chunk-BZKUID4R.cjs} +14 -14
- package/dist/{chunk-SST2YI7S.cjs.map → chunk-BZKUID4R.cjs.map} +1 -1
- package/dist/{chunk-YAOBDOI5.cjs → chunk-C74PZU6M.cjs} +32 -32
- package/dist/{chunk-YAOBDOI5.cjs.map → chunk-C74PZU6M.cjs.map} +1 -1
- package/dist/{chunk-P7MSXPGL.js → chunk-CGUSNV6R.js} +8 -8
- package/dist/{chunk-N5A4AC2J.cjs → chunk-CRGIXNUE.cjs} +23 -23
- package/dist/{chunk-N5A4AC2J.cjs.map → chunk-CRGIXNUE.cjs.map} +1 -1
- package/dist/{chunk-Q6RC3YGW.cjs → chunk-DFJ7TWH6.cjs} +3 -3
- package/dist/{chunk-Q6RC3YGW.cjs.map → chunk-DFJ7TWH6.cjs.map} +1 -1
- package/dist/{chunk-HO3Q7NXJ.js → chunk-FLSEJSTN.js} +2 -2
- package/dist/{chunk-UL3WQWTF.js → chunk-FZT7BYNF.js} +2 -2
- package/dist/{chunk-XOKRFZBD.cjs → chunk-HOEOGFQM.cjs} +97 -97
- package/dist/{chunk-XOKRFZBD.cjs.map → chunk-HOEOGFQM.cjs.map} +1 -1
- package/dist/{chunk-MLVF3OXU.cjs → chunk-HYABNMBN.cjs} +76 -76
- package/dist/{chunk-MLVF3OXU.cjs.map → chunk-HYABNMBN.cjs.map} +1 -1
- package/dist/{chunk-J76LCSR4.cjs → chunk-ICG7JRYT.cjs} +40 -40
- package/dist/{chunk-J76LCSR4.cjs.map → chunk-ICG7JRYT.cjs.map} +1 -1
- package/dist/{chunk-P22HM2MO.cjs → chunk-JDUPAHJR.cjs} +18 -18
- package/dist/{chunk-P22HM2MO.cjs.map → chunk-JDUPAHJR.cjs.map} +1 -1
- package/dist/{chunk-B2GEKAAA.js → chunk-JRWOHH7K.js} +2 -2
- package/dist/{chunk-TXV444YV.js → chunk-JXJ4FTKN.js} +3 -3
- package/dist/{chunk-CU6Q2KQT.cjs → chunk-LZD3VSPS.cjs} +12 -12
- package/dist/{chunk-CU6Q2KQT.cjs.map → chunk-LZD3VSPS.cjs.map} +1 -1
- package/dist/{chunk-M5TWHFIN.cjs → chunk-Q5SZ2QTZ.cjs} +42 -42
- package/dist/{chunk-M5TWHFIN.cjs.map → chunk-Q5SZ2QTZ.cjs.map} +1 -1
- package/dist/{chunk-GSONSIWG.js → chunk-R7A7WHXP.js} +60 -35
- package/dist/chunk-R7A7WHXP.js.map +1 -0
- package/dist/{chunk-E56VNTBV.cjs → chunk-S4LL6QI6.cjs} +147 -147
- package/dist/{chunk-E56VNTBV.cjs.map → chunk-S4LL6QI6.cjs.map} +1 -1
- package/dist/{chunk-KQKU4VXB.cjs → chunk-STHVHAV4.cjs} +82 -57
- package/dist/chunk-STHVHAV4.cjs.map +1 -0
- package/dist/{chunk-A7ES3NZK.cjs → chunk-SXMJLNOF.cjs} +3 -3
- package/dist/{chunk-A7ES3NZK.cjs.map → chunk-SXMJLNOF.cjs.map} +1 -1
- package/dist/{chunk-CTTY5EKH.js → chunk-VF4DCOPD.js} +5 -5
- package/dist/{chunk-QTO3WYB2.cjs → chunk-VJTD2DGF.cjs} +3 -3
- package/dist/{chunk-QTO3WYB2.cjs.map → chunk-VJTD2DGF.cjs.map} +1 -1
- package/dist/{chunk-VGLDA77S.js → chunk-VRX6Y6A7.js} +2 -2
- package/dist/{chunk-L36R3G7Z.js → chunk-VXNMOGRP.js} +6 -6
- package/dist/{chunk-OR67WC32.js → chunk-WBQ3NOH7.js} +2 -2
- package/dist/{chunk-QMQQ37RW.js → chunk-WX5UCLL7.js} +2 -2
- package/dist/{chunk-QZ64JOE2.js → chunk-XGBVHQP7.js} +3 -3
- package/dist/{chunk-K432ES7N.js → chunk-YM2WHYYK.js} +4 -4
- package/dist/{chunk-KXLQITOX.cjs → chunk-Z3R3LOC5.cjs} +5 -5
- package/dist/{chunk-KXLQITOX.cjs.map → chunk-Z3R3LOC5.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +10 -10
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/index.cjs +4 -4
- package/dist/components/chat/index.js +3 -3
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +6 -6
- package/dist/components/faq/index.js +5 -5
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/features/index.js +3 -3
- package/dist/components/help-center-pages/index.cjs +25 -25
- package/dist/components/help-center-pages/index.js +16 -16
- package/dist/components/index.cjs +138 -138
- package/dist/components/index.js +14 -14
- package/dist/components/meeting-scheduler/index.cjs +35 -35
- package/dist/components/meeting-scheduler/index.js +5 -5
- package/dist/components/navigation/index.cjs +4 -4
- package/dist/components/navigation/index.js +3 -3
- package/dist/components/onboarding-guides/index.cjs +8 -8
- package/dist/components/onboarding-guides/index.js +7 -7
- package/dist/components/related-content/index.cjs +6 -6
- package/dist/components/related-content/index.js +5 -5
- package/dist/components/tickets/index.cjs +8 -8
- package/dist/components/tickets/index.js +7 -7
- package/dist/components/ui/index.cjs +4 -4
- package/dist/components/ui/index.js +3 -3
- package/dist/hooks/index.cjs +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/index.cjs +4 -4
- package/dist/index.js +3 -3
- package/dist/nats/index.cjs +2 -2
- package/dist/nats/index.js +1 -1
- package/dist/nats/nats.d.ts.map +1 -1
- package/dist/nats/shared-connection.d.ts +13 -6
- package/dist/nats/shared-connection.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/nats/__tests__/reconnect-lifecycle.test.tsx +814 -0
- package/src/nats/nats.ts +56 -13
- package/src/nats/shared-connection.ts +132 -36
- package/dist/chunk-GSONSIWG.js.map +0 -1
- package/dist/chunk-KQKU4VXB.cjs.map +0 -1
- /package/dist/{chunk-6ER3AYJM.js.map → chunk-5756Q6UN.js.map} +0 -0
- /package/dist/{chunk-E2IPBWGT.js.map → chunk-5EES5HD4.js.map} +0 -0
- /package/dist/{chunk-ODRMPI3Y.js.map → chunk-5JWLZJK5.js.map} +0 -0
- /package/dist/{chunk-CVCQW7WG.js.map → chunk-5M4PS3XX.js.map} +0 -0
- /package/dist/{chunk-P7MSXPGL.js.map → chunk-CGUSNV6R.js.map} +0 -0
- /package/dist/{chunk-HO3Q7NXJ.js.map → chunk-FLSEJSTN.js.map} +0 -0
- /package/dist/{chunk-UL3WQWTF.js.map → chunk-FZT7BYNF.js.map} +0 -0
- /package/dist/{chunk-B2GEKAAA.js.map → chunk-JRWOHH7K.js.map} +0 -0
- /package/dist/{chunk-TXV444YV.js.map → chunk-JXJ4FTKN.js.map} +0 -0
- /package/dist/{chunk-CTTY5EKH.js.map → chunk-VF4DCOPD.js.map} +0 -0
- /package/dist/{chunk-VGLDA77S.js.map → chunk-VRX6Y6A7.js.map} +0 -0
- /package/dist/{chunk-L36R3G7Z.js.map → chunk-VXNMOGRP.js.map} +0 -0
- /package/dist/{chunk-OR67WC32.js.map → chunk-WBQ3NOH7.js.map} +0 -0
- /package/dist/{chunk-QMQQ37RW.js.map → chunk-WX5UCLL7.js.map} +0 -0
- /package/dist/{chunk-QZ64JOE2.js.map → chunk-XGBVHQP7.js.map} +0 -0
- /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
|
+
})
|