@flamingo-stack/openframe-frontend-core 0.0.210 → 0.0.212-snapshot.20260528112413

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 (128) hide show
  1. package/dist/{chunk-VBFOCTMD.cjs → chunk-35XIT2CF.cjs} +17 -17
  2. package/dist/{chunk-VBFOCTMD.cjs.map → chunk-35XIT2CF.cjs.map} +1 -1
  3. package/dist/{chunk-ATEUJQKU.js → chunk-3JWIJJ44.js} +2 -2
  4. package/dist/chunk-CZR7ARBA.js +698 -0
  5. package/dist/chunk-CZR7ARBA.js.map +1 -0
  6. package/dist/{chunk-UYQOPC57.js → chunk-HICZPTRR.js} +4 -351
  7. package/dist/chunk-HICZPTRR.js.map +1 -0
  8. package/dist/{chunk-WJBPLMBX.js → chunk-IK2X5YJU.js} +3 -3
  9. package/dist/{chunk-MDTIOPVS.cjs → chunk-OTKJASSX.cjs} +26 -26
  10. package/dist/{chunk-MDTIOPVS.cjs.map → chunk-OTKJASSX.cjs.map} +1 -1
  11. package/dist/chunk-OZ3GH6OQ.cjs +698 -0
  12. package/dist/chunk-OZ3GH6OQ.cjs.map +1 -0
  13. package/dist/{chunk-6RZYJICV.cjs → chunk-P5EE2VJX.cjs} +1 -1
  14. package/dist/chunk-P5EE2VJX.cjs.map +1 -0
  15. package/dist/{chunk-EH3RWVF3.cjs → chunk-WT5JV2GS.cjs} +8 -355
  16. package/dist/chunk-WT5JV2GS.cjs.map +1 -0
  17. package/dist/{chunk-TWKPYZNQ.cjs → chunk-ZDF6F7ED.cjs} +569 -694
  18. package/dist/chunk-ZDF6F7ED.cjs.map +1 -0
  19. package/dist/{chunk-7L4DWM7P.js → chunk-ZG2YY5E7.js} +1 -1
  20. package/dist/chunk-ZG2YY5E7.js.map +1 -0
  21. package/dist/{chunk-R5RNRH62.js → chunk-ZTJVRSN5.js} +422 -547
  22. package/dist/chunk-ZTJVRSN5.js.map +1 -0
  23. package/dist/components/chat/hooks/use-chat-identity.d.ts +3 -3
  24. package/dist/components/chat/hooks/use-chat-identity.d.ts.map +1 -1
  25. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
  26. package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts +0 -9
  27. package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts.map +1 -1
  28. package/dist/components/chat/index.cjs +6 -5
  29. package/dist/components/chat/index.cjs.map +1 -1
  30. package/dist/components/chat/index.js +5 -4
  31. package/dist/components/contact/index.cjs +7 -6
  32. package/dist/components/contact/index.cjs.map +1 -1
  33. package/dist/components/contact/index.js +6 -5
  34. package/dist/components/features/index.cjs +6 -5
  35. package/dist/components/features/index.cjs.map +1 -1
  36. package/dist/components/features/index.js +5 -4
  37. package/dist/components/features/notifications/index.d.ts +2 -2
  38. package/dist/components/features/notifications/index.d.ts.map +1 -1
  39. package/dist/components/features/notifications/notifications-context.d.ts +16 -1
  40. package/dist/components/features/notifications/notifications-context.d.ts.map +1 -1
  41. package/dist/components/features/notifications/types.d.ts +4 -0
  42. package/dist/components/features/notifications/types.d.ts.map +1 -1
  43. package/dist/components/footer-waitlist-button.d.ts +21 -2
  44. package/dist/components/footer-waitlist-button.d.ts.map +1 -1
  45. package/dist/components/index.cjs +93 -96
  46. package/dist/components/index.cjs.map +1 -1
  47. package/dist/components/index.js +17 -20
  48. package/dist/components/index.js.map +1 -1
  49. package/dist/components/navigation/app-header.d.ts.map +1 -1
  50. package/dist/components/navigation/index.cjs +6 -5
  51. package/dist/components/navigation/index.cjs.map +1 -1
  52. package/dist/components/navigation/index.js +5 -4
  53. package/dist/components/navigation/sticky-section-nav.d.ts.map +1 -1
  54. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  55. package/dist/components/tickets/index.cjs +144 -102
  56. package/dist/components/tickets/index.cjs.map +1 -1
  57. package/dist/components/tickets/index.js +98 -56
  58. package/dist/components/tickets/index.js.map +1 -1
  59. package/dist/components/tickets/ticket-row.d.ts.map +1 -1
  60. package/dist/components/ui/index.cjs +6 -5
  61. package/dist/components/ui/index.cjs.map +1 -1
  62. package/dist/components/ui/index.js +5 -4
  63. package/dist/contexts/chat-runtime-context.d.ts +6 -3
  64. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  65. package/dist/contexts/index.cjs +2 -2
  66. package/dist/contexts/index.js +1 -1
  67. package/dist/embed-shims/index.cjs +3 -3
  68. package/dist/embed-shims/index.cjs.map +1 -1
  69. package/dist/embed-shims/index.js +4 -4
  70. package/dist/hooks/index.cjs +3 -2
  71. package/dist/hooks/index.cjs.map +1 -1
  72. package/dist/hooks/index.js +2 -1
  73. package/dist/index.cjs +8 -5
  74. package/dist/index.cjs.map +1 -1
  75. package/dist/index.js +7 -4
  76. package/dist/nats/index.cjs +28 -346
  77. package/dist/nats/index.cjs.map +1 -1
  78. package/dist/nats/index.d.ts +3 -0
  79. package/dist/nats/index.d.ts.map +1 -1
  80. package/dist/nats/index.js +30 -346
  81. package/dist/nats/index.js.map +1 -1
  82. package/dist/nats/nats-provider.d.ts +28 -0
  83. package/dist/nats/nats-provider.d.ts.map +1 -0
  84. package/dist/nats/nats.d.ts +1 -0
  85. package/dist/nats/nats.d.ts.map +1 -1
  86. package/dist/nats/shared-connection.d.ts +73 -0
  87. package/dist/nats/shared-connection.d.ts.map +1 -0
  88. package/dist/nats/use-nats-subscription.d.ts +18 -0
  89. package/dist/nats/use-nats-subscription.d.ts.map +1 -0
  90. package/dist/utils/index.cjs +10 -0
  91. package/dist/utils/index.cjs.map +1 -1
  92. package/dist/utils/index.d.ts +1 -0
  93. package/dist/utils/index.d.ts.map +1 -1
  94. package/dist/utils/index.js +10 -1
  95. package/dist/utils/index.js.map +1 -1
  96. package/dist/utils/scroll-into-view.d.ts +63 -0
  97. package/dist/utils/scroll-into-view.d.ts.map +1 -0
  98. package/package.json +1 -1
  99. package/src/components/chat/hooks/use-chat-identity.ts +8 -7
  100. package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +60 -207
  101. package/src/components/chat/hooks/use-nats-dialog-subscription.ts +71 -214
  102. package/src/components/features/notifications/index.ts +2 -1
  103. package/src/components/features/notifications/notifications-context.tsx +104 -6
  104. package/src/components/features/notifications/types.ts +5 -0
  105. package/src/components/footer-waitlist-button.tsx +33 -16
  106. package/src/components/navigation/app-header.tsx +7 -9
  107. package/src/components/navigation/sticky-section-nav.tsx +6 -4
  108. package/src/components/tickets/help-center-card.tsx +55 -1
  109. package/src/components/tickets/help-center-list.tsx +9 -1
  110. package/src/components/tickets/ticket-detail-drawer.tsx +19 -4
  111. package/src/components/tickets/ticket-row.tsx +30 -19
  112. package/src/contexts/chat-runtime-context.tsx +6 -3
  113. package/src/nats/index.ts +3 -0
  114. package/src/nats/nats-provider.tsx +146 -0
  115. package/src/nats/nats.ts +2 -0
  116. package/src/nats/shared-connection.ts +285 -0
  117. package/src/nats/use-nats-subscription.ts +99 -0
  118. package/src/stories/EmbeddableChat.stories.tsx +1 -1
  119. package/src/utils/index.ts +12 -0
  120. package/src/utils/scroll-into-view.ts +74 -0
  121. package/dist/chunk-6RZYJICV.cjs.map +0 -1
  122. package/dist/chunk-7L4DWM7P.js.map +0 -1
  123. package/dist/chunk-EH3RWVF3.cjs.map +0 -1
  124. package/dist/chunk-R5RNRH62.js.map +0 -1
  125. package/dist/chunk-TWKPYZNQ.cjs.map +0 -1
  126. package/dist/chunk-UYQOPC57.js.map +0 -1
  127. /package/dist/{chunk-ATEUJQKU.js.map → chunk-3JWIJJ44.js.map} +0 -0
  128. /package/dist/{chunk-WJBPLMBX.js.map → chunk-IK2X5YJU.js.map} +0 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `scrollElementIntoView` — canonical "smooth scroll element to top
3
+ * of viewport, account for sticky chrome, optionally adjust for
4
+ * known layout shifts" helper.
5
+ *
6
+ * Before this util existed, ~3 different call sites in the lib + hub
7
+ * had the same 5-line snippet copy-pasted with subtle differences:
8
+ *
9
+ * - `useUnifiedNav` same-URL re-scroll branch (hub)
10
+ * - `<HelpCenterCard>` click-to-expand (lib, with cross-row
11
+ * layout-shift adjustment)
12
+ * - Future ticket-row / docs anchor scrolls
13
+ *
14
+ * The canonical pattern is `window.scrollTo({top, behavior:'smooth'})`
15
+ * with a pre-computed pixel target — NOT `element.scrollIntoView()`.
16
+ * Pre-computing the target lets the browser run a clean uninterrupted
17
+ * smooth animation to a fixed pixel value. `scrollIntoView` re-targets
18
+ * continuously as the page layout shifts during the animation, which
19
+ * causes visible jitter when content above the target is also moving
20
+ * (a sibling collapsing, an async image loading, …).
21
+ *
22
+ * The `adjustTargetY` callback is the escape hatch for cases where
23
+ * the consumer KNOWS about an upcoming layout shift and can compute
24
+ * the correct FINAL target before the animation starts. Example: in
25
+ * `HelpCenterCard`, clicking row B while row A above is currently
26
+ * expanded — A's drawer collapses simultaneously with B's expansion,
27
+ * shifting B's tile up by A's drawer height. The consumer passes
28
+ * `adjustTargetY: raw => raw - getAboveDrawerHeight()` and the
29
+ * browser smooth-scrolls to the post-collapse position directly.
30
+ */
31
+ export interface ScrollElementIntoViewOptions {
32
+ /** Pixels to subtract from the target element's `top` so it lands
33
+ * BELOW sticky chrome. Defaults to 0. Pass `96` (matches
34
+ * `scroll-mt-24`) for the standard hub header offset. */
35
+ headerOffset?: number;
36
+ /** Scroll animation style. Defaults to `'smooth'`. Use `'instant'`
37
+ * for imperative jumps where animation would feel laggy (deep
38
+ * link land, programmatic focus moves). */
39
+ behavior?: ScrollBehavior;
40
+ /** Optional adjustment applied to the computed pixel target. The
41
+ * callback receives the "raw" Y (`element.top + scrollY -
42
+ * headerOffset`) and returns the FINAL pixel target. Use this
43
+ * when the caller knows about a layout shift that will happen
44
+ * between the call and the animation completing — the browser's
45
+ * smooth-scroll commits to a single pixel value, so providing the
46
+ * post-shift target up front lands the element correctly even as
47
+ * content above it moves. */
48
+ adjustTargetY?: (rawTargetY: number) => number;
49
+ }
50
+ /**
51
+ * Scroll the page so `target` lands at the top of the viewport,
52
+ * accounting for sticky chrome via `headerOffset`. Returns void; the
53
+ * scroll runs async via the browser's smooth-scroll engine.
54
+ *
55
+ * Accepts:
56
+ * - `HTMLElement` — direct reference (most common from `useRef`).
57
+ * - `null` / `undefined` — no-op so callers can pass refs without
58
+ * defensive branching.
59
+ *
60
+ * SSR-safe: short-circuits when `window` is undefined.
61
+ */
62
+ export declare function scrollElementIntoView(target: HTMLElement | null | undefined, options?: ScrollElementIntoViewOptions): void;
63
+ //# sourceMappingURL=scroll-into-view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scroll-into-view.d.ts","sourceRoot":"","sources":["../../src/utils/scroll-into-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,WAAW,4BAA4B;IAC3C;;8DAE0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;gDAE4C;IAC5C,QAAQ,CAAC,EAAE,cAAc,CAAA;IACzB;;;;;;;kCAO8B;IAC9B,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAA;CAC/C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,GAAE,4BAAiC,GACzC,IAAI,CAON"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.210",
3
+ "version": "0.0.212-snapshot.20260528112413",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -8,8 +8,9 @@
8
8
  * (attachment button, drawer composer, /tickets gate) on chat-side
9
9
  * identity tiers WITHOUT sending a chat message first.
10
10
  *
11
- * Server-side parity: the route at `/api/chat/identity` runs the same
12
- * 3-tier `requireChatAuth` chain the chat itself uses.
11
+ * Server-side parity: the host's identity endpoint (hub default
12
+ * `/api/auth/identity`, override via `runtime.endpoints.identityUrl`)
13
+ * runs the same 3-tier `requireChatAuth` chain the chat itself uses.
13
14
  * `attachmentsEnabled` is computed server-side as
14
15
  * `authTier !== 'anon' AND isSelfScopedSource(source)` — single
15
16
  * source of truth, consumers don't combine the fields themselves.
@@ -27,7 +28,7 @@
27
28
  * 3. The fetch is cheap and short — no perf justification for a
28
29
  * cache layer
29
30
  *
30
- * Endpoint URL: read from `useRequiredChatRuntime().endpoints.chatIdentityUrl`
31
+ * Endpoint URL: read from `useRequiredChatRuntime().endpoints.identityUrl`
31
32
  * so embedded apps with their own reverse-proxy topology can override.
32
33
  */
33
34
 
@@ -37,9 +38,9 @@ import { chatAuthedFetch } from '../utils/chat-authed-fetch'
37
38
  import { getChatProxyAuth } from '../utils/chat-proxy-auth-storage'
38
39
 
39
40
  /**
40
- * Wire-shape for the `/api/chat/identity` route response. Mirrors
41
- * the hub's `ChatIdentityResponse` (in `app/api/chat/identity/route.ts`)
42
- * kept in sync there. Lib-side declaration so the chat panel can
41
+ * Wire-shape for the identity route response. Mirrors the hub's
42
+ * `ChatIdentityResponse` (in `app/api/auth/identity/route.ts`)
43
+ * kept in sync there. Lib-side declaration so the chat panel can
43
44
  * compile without depending on hub-internal types.
44
45
  */
45
46
  export interface ChatIdentityResponse {
@@ -87,7 +88,7 @@ const ANON_DEFAULTS: ChatIdentityResponse = {
87
88
 
88
89
  export function useChatIdentity(): ChatIdentitySurface {
89
90
  const runtime = useRequiredChatRuntime()
90
- const url = runtime.endpoints.chatIdentityUrl
91
+ const url = runtime.endpoints.identityUrl
91
92
  // `getChatProxyAuth()` reads localStorage every render. If the user
92
93
  // pastes bearer creds mid-session (via the `/debug` creds bar),
93
94
  // their email arrives here and the effect's dep changes → refetch.
@@ -1,28 +1,19 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from 'react'
3
+ import { useEffect, useRef, useState } from 'react'
4
4
  import {
5
- createNatsClient,
5
+ acquireClient as acquireSharedClient,
6
+ releaseClient as releaseSharedClient,
7
+ startConnectionLifecycle,
6
8
  type JetStreamSubscriptionHandle,
7
9
  type NatsClient,
10
+ type SharedConnection,
8
11
  } from '../../../nats'
9
12
  import {
10
13
  type UseJetStreamDialogSubscriptionOptions,
11
14
  type UseJetStreamDialogSubscriptionReturn,
12
- NETWORK_CONFIG,
13
15
  } from '../types'
14
16
 
15
- type SharedConnection = {
16
- wsUrl: string
17
- client: NatsClient
18
- connectPromise: Promise<void> | null
19
- refCount: number
20
- closeTimer: ReturnType<typeof setTimeout> | null
21
- retryTimer: ReturnType<typeof setTimeout> | null
22
- }
23
-
24
- let shared: SharedConnection | null = null
25
-
26
17
  const DEFAULT_INACTIVE_THRESHOLD_MS = 5 * 60_000
27
18
  const DEFAULT_STREAM_NAME = 'CHAT_CHUNKS'
28
19
 
@@ -110,75 +101,23 @@ export function useJetStreamDialogSubscription({
110
101
 
111
102
  const hadConnectionBeforeRef = useRef(false)
112
103
 
113
- const acquireClient = useCallback(
114
- (url: string): SharedConnection => {
115
- if (shared?.wsUrl !== url) {
116
- if (shared) {
117
- if (shared.closeTimer) clearTimeout(shared.closeTimer)
118
- const old = shared
119
- shared = null
120
- void old.client.close().catch(() => {})
121
- }
122
-
123
- const { name = 'openframe-frontend-jetstream', user = 'machine', pass = '' } = clientConfig
124
-
125
- const client = createNatsClient({
126
- servers: url,
127
- name,
128
- user,
129
- pass,
130
- connectTimeoutMs: NETWORK_CONFIG.CONNECT_TIMEOUT_MS,
131
- reconnect: false,
132
- pingIntervalMs: NETWORK_CONFIG.PING_INTERVAL_MS,
133
- maxPingOut: NETWORK_CONFIG.MAX_PING_OUT,
134
- })
135
- shared = {
136
- wsUrl: url,
137
- client,
138
- connectPromise: null,
139
- refCount: 0,
140
- closeTimer: null,
141
- retryTimer: null,
142
- }
143
- }
144
-
145
- shared.refCount += 1
146
- if (shared.closeTimer) {
147
- clearTimeout(shared.closeTimer)
148
- shared.closeTimer = null
149
- }
150
- return shared
151
- },
152
- [clientConfig],
153
- )
154
-
155
- const releaseClient = useCallback((url: string) => {
156
- if (!shared || shared.wsUrl !== url) return
157
-
158
- shared.refCount = Math.max(0, shared.refCount - 1)
159
- if (shared.refCount > 0) return
160
-
161
- shared.closeTimer = setTimeout(() => {
162
- const s = shared
163
- shared = null
164
- if (s) {
165
- if (s.retryTimer) {
166
- clearTimeout(s.retryTimer)
167
- s.retryTimer = null
168
- }
169
- void s.client.close().catch(() => {})
170
- }
171
- }, NETWORK_CONFIG.SHARED_CLOSE_DELAY_MS)
172
- }, [])
104
+ const clientConfigRef = useRef(clientConfig)
105
+ useEffect(() => {
106
+ clientConfigRef.current = clientConfig
107
+ }, [clientConfig])
173
108
 
174
109
  const currentWsUrlRef = useRef<string>('')
175
110
 
176
- // Connection lifecycle: acquire/release the shared client based on enabled + URL.
111
+ // Resolve the URL synchronously each render so the effect depends on the URL string
112
+ // itself, not the (often inline-allocated) getNatsWsUrl callback identity. Otherwise
113
+ // every silent token rotation that rebuilds getNatsWsUrl in the caller would tear
114
+ // the WS down and reacquire even though the resolved URL hasn't changed.
115
+ const wsUrl = getNatsWsUrl()
116
+
177
117
  useEffect(() => {
178
- const wsUrl = getNatsWsUrl()
179
118
  if (!enabled || !wsUrl) {
180
119
  if (currentWsUrlRef.current && clientRef.current) {
181
- releaseClient(currentWsUrlRef.current)
120
+ releaseSharedClient(currentWsUrlRef.current)
182
121
  clientRef.current = null
183
122
  currentWsUrlRef.current = ''
184
123
  setIsConnected(false)
@@ -199,166 +138,80 @@ export function useJetStreamDialogSubscription({
199
138
  currentWsUrlRef.current !== wsUrl &&
200
139
  clientRef.current
201
140
  ) {
202
- releaseClient(currentWsUrlRef.current)
141
+ releaseSharedClient(currentWsUrlRef.current)
203
142
  clientRef.current = null
204
143
  setIsConnected(false)
205
144
  }
206
145
 
207
146
  currentWsUrlRef.current = wsUrl
208
- const sharedConn = acquireClient(wsUrl)
147
+ const cfg = clientConfigRef.current
148
+ const sharedConn = acquireSharedClient(wsUrl, {
149
+ name: cfg.name ?? 'openframe-frontend-jetstream',
150
+ user: cfg.user ?? 'machine',
151
+ pass: cfg.pass ?? '',
152
+ })
209
153
  const client = sharedConn.client
210
154
 
211
155
  clientRef.current = client
212
- setIsConnected(false)
213
-
214
- let closed = false
215
- let retryAttempt = 0
216
-
217
- function scheduleRetry() {
218
- if (closed) return
219
- if (shared !== sharedConn) return
220
-
221
- if (sharedConn.retryTimer) {
222
- clearTimeout(sharedConn.retryTimer)
223
- sharedConn.retryTimer = null
224
- }
225
-
226
- const cfg = reconnectionBackoffRef.current ?? {}
227
- const fastRetries = cfg.fastRetries ?? 0
228
- const fastDelay = cfg.fastRetryDelayMs ?? NETWORK_CONFIG.RETRY_INITIAL_DELAY_MS
229
- const baseDelay = cfg.initialDelayMs ?? NETWORK_CONFIG.RETRY_INITIAL_DELAY_MS
230
- const maxDelay = cfg.maxDelayMs ?? NETWORK_CONFIG.RETRY_MAX_DELAY_MS
231
- const multiplier = cfg.multiplier ?? NETWORK_CONFIG.RETRY_BACKOFF_MULTIPLIER
232
-
233
- const delay =
234
- retryAttempt < fastRetries
235
- ? fastDelay
236
- : Math.min(baseDelay * multiplier ** (retryAttempt - fastRetries), maxDelay)
237
- const jitteredDelay = delay * (0.5 + Math.random() * 0.5)
238
- retryAttempt++
239
-
240
- sharedConn.retryTimer = setTimeout(async () => {
241
- sharedConn.retryTimer = null
242
- if (closed) return
243
- if (shared !== sharedConn) return
244
-
245
- try {
246
- await onBeforeReconnectRef.current?.()
247
- } catch {
248
- // Token refresh failed; still try to reconnect.
249
- }
250
-
251
- if (closed) return
252
- if (shared !== sharedConn) return
253
-
254
- const freshUrl = getNatsWsUrlRef.current()
255
- if (freshUrl !== wsUrl) return
156
+ setIsConnected(client.isConnected())
256
157
 
158
+ const tearDownSubscription = () => {
159
+ if (subscriptionRef.current) {
257
160
  try {
258
- sharedConn.connectPromise = null
259
- sharedConn.connectPromise = client.connect()
260
- await sharedConn.connectPromise
261
- if (!closed && shared === sharedConn) {
262
- retryAttempt = 0
263
- setIsConnected(true)
264
- }
161
+ subscriptionRef.current.unsubscribe()
265
162
  } catch {
266
- sharedConn.connectPromise = null
267
- if (!closed && shared === sharedConn) {
268
- scheduleRetry()
269
- }
270
- }
271
- }, jitteredDelay)
272
- }
273
-
274
- const unsubscribeStatus = client.onStatus((event) => {
275
- const connected = event.status === 'connected'
276
- // `error` is a protocol-level signal (e.g. -ERR Permissions Violation when
277
- // CONSUMER.CREATE is denied) that does NOT close the WebSocket. Treating
278
- // it as a disconnect causes scheduleRetry() to fire on every -ERR, which
279
- // re-runs onBeforeReconnect (auth refresh / `/api/me`) on a loop. Real
280
- // transport loss arrives separately as `disconnected` or `closed`.
281
- const disconnected = event.status === 'closed' || event.status === 'disconnected'
282
- if (connected) {
283
- setIsConnected(true)
284
- if (hadConnectionBeforeRef.current) {
285
- setReconnectionCount((c) => c + 1)
286
- }
287
- hadConnectionBeforeRef.current = true
288
- retryAttempt = 0
289
- onConnectRef.current?.()
290
- }
291
- if (event.status === 'error') {
292
- // Subscription-level failures (e.g. consumer.get rejected by JetStream
293
- // ACLs) already surface to the subscribe effect via the rejected
294
- // promise; log here for diagnostics and let the existing WS connection
295
- // keep running.
296
- console.warn('[JetStream] NATS protocol error:', event.data)
297
- return
298
- }
299
- if (disconnected) {
300
- setIsConnected(false)
301
- setIsSubscribed(false)
302
-
303
- if (subscriptionRef.current) {
304
- try {
305
- subscriptionRef.current.unsubscribe()
306
- } catch {
307
- // ignore
308
- }
309
- subscriptionRef.current = null
163
+ // ignore
310
164
  }
311
-
312
- onDisconnectRef.current?.()
313
- scheduleRetry()
165
+ subscriptionRef.current = null
314
166
  }
315
- })
167
+ }
316
168
 
317
- ;(async () => {
318
- try {
319
- sharedConn.connectPromise ||= client.connect()
320
- await sharedConn.connectPromise
321
- if (!closed) {
169
+ const lifecycle = startConnectionLifecycle({
170
+ conn: sharedConn,
171
+ wsUrl,
172
+ onBeforeReconnect: () => onBeforeReconnectRef.current?.(),
173
+ backoff: reconnectionBackoffRef.current,
174
+ getFreshUrl: () => getNatsWsUrlRef.current(),
175
+ // JetStream emits 'error' for protocol-level failures (e.g. -ERR Permissions
176
+ // Violation when CONSUMER.CREATE is denied) without closing the WebSocket.
177
+ // Retrying on 'error' would loop onBeforeReconnect on every -ERR; let the
178
+ // subscribe effect surface those via its own rejected promise instead.
179
+ shouldRetryOn: (status) => status === 'closed' || status === 'disconnected',
180
+ onStatusChange: (status, evt) => {
181
+ if (status === 'connected') {
322
182
  setIsConnected(true)
183
+ if (hadConnectionBeforeRef.current) {
184
+ setReconnectionCount((c) => c + 1)
185
+ }
323
186
  hadConnectionBeforeRef.current = true
187
+ onConnectRef.current?.()
324
188
  }
325
- } catch {
326
- sharedConn.connectPromise = null
327
- if (!closed) {
189
+ if (status === 'error') {
190
+ console.warn('[JetStream] NATS protocol error:', evt.data)
191
+ return
192
+ }
193
+ if (status === 'closed' || status === 'disconnected') {
328
194
  setIsConnected(false)
195
+ setIsSubscribed(false)
196
+ tearDownSubscription()
329
197
  onDisconnectRef.current?.()
330
- scheduleRetry()
331
198
  }
332
- }
333
- })()
199
+ },
200
+ })
334
201
 
335
202
  return () => {
336
- closed = true
203
+ lifecycle.stop()
337
204
  setIsConnected(false)
338
205
  setIsSubscribed(false)
339
- unsubscribeStatus()
340
-
341
- if (sharedConn.retryTimer) {
342
- clearTimeout(sharedConn.retryTimer)
343
- sharedConn.retryTimer = null
344
- }
345
-
346
- if (subscriptionRef.current) {
347
- try {
348
- subscriptionRef.current.unsubscribe()
349
- } catch {
350
- // ignore
351
- }
352
- subscriptionRef.current = null
353
- }
206
+ tearDownSubscription()
354
207
 
355
208
  if (clientRef.current && currentWsUrlRef.current) {
356
- releaseClient(currentWsUrlRef.current)
209
+ releaseSharedClient(currentWsUrlRef.current)
357
210
  clientRef.current = null
358
211
  currentWsUrlRef.current = ''
359
212
  }
360
213
  }
361
- }, [enabled, getNatsWsUrl, acquireClient, releaseClient])
214
+ }, [enabled, wsUrl])
362
215
 
363
216
  // Subscription lifecycle: (re)create the ephemeral JetStream consumer whenever
364
217
  // we transition into a connected state for a dialog, and whenever the dialog