@flamingo-stack/openframe-frontend-core 0.0.212 → 0.0.213
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-ZFBLC5GV.cjs → chunk-35XIT2CF.cjs} +17 -17
- package/dist/{chunk-ZFBLC5GV.cjs.map → chunk-35XIT2CF.cjs.map} +1 -1
- package/dist/{chunk-QKFBZLIR.js → chunk-3JWIJJ44.js} +2 -2
- package/dist/chunk-CZR7ARBA.js +698 -0
- package/dist/chunk-CZR7ARBA.js.map +1 -0
- package/dist/{chunk-UYQOPC57.js → chunk-HICZPTRR.js} +4 -351
- package/dist/chunk-HICZPTRR.js.map +1 -0
- package/dist/{chunk-5BNWGK6D.js → chunk-IK2X5YJU.js} +3 -3
- package/dist/{chunk-VTUIMMHO.cjs → chunk-OTKJASSX.cjs} +26 -26
- package/dist/{chunk-VTUIMMHO.cjs.map → chunk-OTKJASSX.cjs.map} +1 -1
- package/dist/chunk-OZ3GH6OQ.cjs +698 -0
- package/dist/chunk-OZ3GH6OQ.cjs.map +1 -0
- package/dist/{chunk-EH3RWVF3.cjs → chunk-WT5JV2GS.cjs} +8 -355
- package/dist/chunk-WT5JV2GS.cjs.map +1 -0
- package/dist/{chunk-WI76ZUBE.cjs → chunk-ZDF6F7ED.cjs} +544 -678
- package/dist/chunk-ZDF6F7ED.cjs.map +1 -0
- package/dist/{chunk-3E5ANY55.js → chunk-ZTJVRSN5.js} +409 -543
- package/dist/{chunk-3E5ANY55.js.map → chunk-ZTJVRSN5.js.map} +1 -1
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts +0 -9
- package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +5 -4
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +4 -3
- package/dist/components/contact/index.cjs +6 -5
- package/dist/components/contact/index.cjs.map +1 -1
- package/dist/components/contact/index.js +5 -4
- package/dist/components/features/index.cjs +5 -4
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +4 -3
- package/dist/components/features/notifications/index.d.ts +2 -2
- package/dist/components/features/notifications/index.d.ts.map +1 -1
- package/dist/components/features/notifications/notifications-context.d.ts +16 -1
- package/dist/components/features/notifications/notifications-context.d.ts.map +1 -1
- package/dist/components/features/notifications/types.d.ts +4 -0
- package/dist/components/features/notifications/types.d.ts.map +1 -1
- package/dist/components/index.cjs +55 -54
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +7 -6
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/app-header.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +5 -4
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.js +4 -3
- package/dist/components/tickets/index.cjs +66 -65
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.js +6 -5
- package/dist/components/tickets/index.js.map +1 -1
- package/dist/components/ui/index.cjs +5 -4
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +4 -3
- package/dist/embed-shims/index.cjs +3 -3
- package/dist/embed-shims/index.cjs.map +1 -1
- package/dist/embed-shims/index.js +4 -4
- package/dist/hooks/index.cjs +3 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +2 -1
- package/dist/index.cjs +5 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -3
- package/dist/nats/index.cjs +28 -346
- package/dist/nats/index.cjs.map +1 -1
- package/dist/nats/index.d.ts +3 -0
- package/dist/nats/index.d.ts.map +1 -1
- package/dist/nats/index.js +30 -346
- package/dist/nats/index.js.map +1 -1
- package/dist/nats/nats-provider.d.ts +28 -0
- package/dist/nats/nats-provider.d.ts.map +1 -0
- package/dist/nats/nats.d.ts +1 -0
- package/dist/nats/nats.d.ts.map +1 -1
- package/dist/nats/shared-connection.d.ts +73 -0
- package/dist/nats/shared-connection.d.ts.map +1 -0
- package/dist/nats/use-nats-subscription.d.ts +18 -0
- package/dist/nats/use-nats-subscription.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +60 -207
- package/src/components/chat/hooks/use-nats-dialog-subscription.ts +71 -214
- package/src/components/features/notifications/index.ts +2 -1
- package/src/components/features/notifications/notifications-context.tsx +104 -6
- package/src/components/features/notifications/types.ts +5 -0
- package/src/components/navigation/app-header.tsx +7 -9
- package/src/nats/index.ts +3 -0
- package/src/nats/nats-provider.tsx +146 -0
- package/src/nats/nats.ts +2 -0
- package/src/nats/shared-connection.ts +285 -0
- package/src/nats/use-nats-subscription.ts +99 -0
- package/dist/chunk-EH3RWVF3.cjs.map +0 -1
- package/dist/chunk-UYQOPC57.js.map +0 -1
- package/dist/chunk-WI76ZUBE.cjs.map +0 -1
- /package/dist/{chunk-QKFBZLIR.js.map → chunk-3JWIJJ44.js.map} +0 -0
- /package/dist/{chunk-5BNWGK6D.js.map → chunk-IK2X5YJU.js.map} +0 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { NatsClient, NatsStatus } from './nats'
|
|
5
|
+
import {
|
|
6
|
+
acquireClient,
|
|
7
|
+
releaseClient,
|
|
8
|
+
startConnectionLifecycle,
|
|
9
|
+
type AcquireClientOptions,
|
|
10
|
+
type NatsReconnectionBackoff,
|
|
11
|
+
} from './shared-connection'
|
|
12
|
+
|
|
13
|
+
export type { NatsReconnectionBackoff } from './shared-connection'
|
|
14
|
+
|
|
15
|
+
export interface NatsProviderProps {
|
|
16
|
+
children: React.ReactNode
|
|
17
|
+
/** Return the current NATS WebSocket URL (or null when not yet available, e.g. unauthenticated). */
|
|
18
|
+
getWsUrl: () => string | null
|
|
19
|
+
/** Called before each reconnect attempt. */
|
|
20
|
+
onBeforeReconnect?: () => Promise<void> | void
|
|
21
|
+
clientConfig?: AcquireClientOptions
|
|
22
|
+
reconnectionBackoff?: NatsReconnectionBackoff
|
|
23
|
+
/**
|
|
24
|
+
* Bump this to force re-evaluating `getWsUrl()` (e.g. when auth state flips).
|
|
25
|
+
* Provider does not subscribe to external auth state by itself.
|
|
26
|
+
*/
|
|
27
|
+
urlRevision?: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface NatsContextValue {
|
|
31
|
+
client: NatsClient | null
|
|
32
|
+
status: NatsStatus
|
|
33
|
+
isReady: boolean
|
|
34
|
+
reconnectionCount: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const NatsContext = React.createContext<NatsContextValue | null>(null)
|
|
38
|
+
|
|
39
|
+
export function NatsProvider({
|
|
40
|
+
children,
|
|
41
|
+
getWsUrl,
|
|
42
|
+
onBeforeReconnect,
|
|
43
|
+
clientConfig,
|
|
44
|
+
reconnectionBackoff,
|
|
45
|
+
urlRevision,
|
|
46
|
+
}: NatsProviderProps) {
|
|
47
|
+
const [client, setClient] = React.useState<NatsClient | null>(null)
|
|
48
|
+
const [status, setStatus] = React.useState<NatsStatus>('closed')
|
|
49
|
+
const [reconnectionCount, setReconnectionCount] = React.useState(0)
|
|
50
|
+
|
|
51
|
+
const getWsUrlRef = React.useRef(getWsUrl)
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
getWsUrlRef.current = getWsUrl
|
|
54
|
+
}, [getWsUrl])
|
|
55
|
+
|
|
56
|
+
const onBeforeReconnectRef = React.useRef(onBeforeReconnect)
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
onBeforeReconnectRef.current = onBeforeReconnect
|
|
59
|
+
}, [onBeforeReconnect])
|
|
60
|
+
|
|
61
|
+
const reconnectionBackoffRef = React.useRef(reconnectionBackoff)
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
reconnectionBackoffRef.current = reconnectionBackoff
|
|
64
|
+
}, [reconnectionBackoff])
|
|
65
|
+
|
|
66
|
+
const clientConfigRef = React.useRef(clientConfig)
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
clientConfigRef.current = clientConfig
|
|
69
|
+
}, [clientConfig])
|
|
70
|
+
|
|
71
|
+
const heldUrlRef = React.useRef<string | null>(null)
|
|
72
|
+
const hadConnectionBeforeRef = React.useRef(false)
|
|
73
|
+
|
|
74
|
+
React.useEffect(() => {
|
|
75
|
+
const wsUrl = getWsUrlRef.current()
|
|
76
|
+
|
|
77
|
+
if (!wsUrl) {
|
|
78
|
+
if (heldUrlRef.current) {
|
|
79
|
+
releaseClient(heldUrlRef.current)
|
|
80
|
+
heldUrlRef.current = null
|
|
81
|
+
setClient(null)
|
|
82
|
+
setStatus('closed')
|
|
83
|
+
}
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (heldUrlRef.current && heldUrlRef.current !== wsUrl) {
|
|
88
|
+
releaseClient(heldUrlRef.current)
|
|
89
|
+
heldUrlRef.current = null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const conn = acquireClient(wsUrl, clientConfigRef.current)
|
|
93
|
+
heldUrlRef.current = wsUrl
|
|
94
|
+
setClient(conn.client)
|
|
95
|
+
setStatus(conn.client.isConnected() ? 'connected' : 'connecting')
|
|
96
|
+
|
|
97
|
+
const lifecycle = startConnectionLifecycle({
|
|
98
|
+
conn,
|
|
99
|
+
wsUrl,
|
|
100
|
+
onBeforeReconnect: () => onBeforeReconnectRef.current?.(),
|
|
101
|
+
backoff: reconnectionBackoffRef.current,
|
|
102
|
+
getFreshUrl: () => getWsUrlRef.current(),
|
|
103
|
+
onStatusChange: (newStatus) => {
|
|
104
|
+
setStatus(newStatus)
|
|
105
|
+
if (newStatus === 'connected') {
|
|
106
|
+
if (hadConnectionBeforeRef.current) {
|
|
107
|
+
setReconnectionCount((c) => c + 1)
|
|
108
|
+
}
|
|
109
|
+
hadConnectionBeforeRef.current = true
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
lifecycle.stop()
|
|
116
|
+
if (heldUrlRef.current) {
|
|
117
|
+
releaseClient(heldUrlRef.current)
|
|
118
|
+
heldUrlRef.current = null
|
|
119
|
+
}
|
|
120
|
+
setClient(null)
|
|
121
|
+
setStatus('closed')
|
|
122
|
+
}
|
|
123
|
+
}, [urlRevision])
|
|
124
|
+
|
|
125
|
+
const value = React.useMemo<NatsContextValue>(
|
|
126
|
+
() => ({
|
|
127
|
+
client,
|
|
128
|
+
status,
|
|
129
|
+
isReady: status === 'connected' && client !== null,
|
|
130
|
+
reconnectionCount,
|
|
131
|
+
}),
|
|
132
|
+
[client, status, reconnectionCount],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return <NatsContext.Provider value={value}>{children}</NatsContext.Provider>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function useNats(): NatsContextValue {
|
|
139
|
+
const ctx = React.useContext(NatsContext)
|
|
140
|
+
if (!ctx) throw new Error('useNats must be used inside <NatsProvider>')
|
|
141
|
+
return ctx
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function useOptionalNats(): NatsContextValue | null {
|
|
145
|
+
return React.useContext(NatsContext)
|
|
146
|
+
}
|
package/src/nats/nats.ts
CHANGED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { createNatsClient, type NatsClient, type NatsStatus, type NatsStatusEvent } from './nats'
|
|
2
|
+
|
|
3
|
+
export const NATS_DEFAULTS = {
|
|
4
|
+
SHARED_CLOSE_DELAY_MS: 3000,
|
|
5
|
+
CONNECT_TIMEOUT_MS: 10_000,
|
|
6
|
+
PING_INTERVAL_MS: 30_000,
|
|
7
|
+
MAX_PING_OUT: 3,
|
|
8
|
+
RETRY_INITIAL_DELAY_MS: 1000,
|
|
9
|
+
RETRY_MAX_DELAY_MS: 30_000,
|
|
10
|
+
RETRY_MULTIPLIER: 2,
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
export interface NatsReconnectionBackoff {
|
|
14
|
+
/** Number of fast retries before exponential phase kicks in. Default: 0. */
|
|
15
|
+
fastRetries?: number
|
|
16
|
+
/** Delay used during the fast-retry phase. Default: RETRY_INITIAL_DELAY_MS. */
|
|
17
|
+
fastRetryDelayMs?: number
|
|
18
|
+
/** Base delay for the exponential phase. Default: RETRY_INITIAL_DELAY_MS. */
|
|
19
|
+
initialDelayMs?: number
|
|
20
|
+
/** Upper cap on any single retry delay. Default: RETRY_MAX_DELAY_MS. */
|
|
21
|
+
maxDelayMs?: number
|
|
22
|
+
/** Per-attempt multiplier during exponential phase. Default: RETRY_MULTIPLIER. */
|
|
23
|
+
multiplier?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SharedConnection {
|
|
27
|
+
wsUrl: string
|
|
28
|
+
client: NatsClient
|
|
29
|
+
connectPromise: Promise<void> | null
|
|
30
|
+
refCount: number
|
|
31
|
+
closeTimer: ReturnType<typeof setTimeout> | null
|
|
32
|
+
retryTimer: ReturnType<typeof setTimeout> | null
|
|
33
|
+
/**
|
|
34
|
+
* Identity of the consumer driving reconnect. When set, other consumers
|
|
35
|
+
* should observe status only and skip their own scheduleRetry — otherwise
|
|
36
|
+
* each disconnect triggers multiple concurrent connect() calls racing over
|
|
37
|
+
* connectPromise.
|
|
38
|
+
*/
|
|
39
|
+
retryOwner: object | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AcquireClientOptions {
|
|
43
|
+
name?: string
|
|
44
|
+
user?: string
|
|
45
|
+
pass?: string
|
|
46
|
+
connectTimeoutMs?: number
|
|
47
|
+
pingIntervalMs?: number
|
|
48
|
+
maxPingOut?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ReleaseClientOptions {
|
|
52
|
+
delayMs?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let shared: SharedConnection | null = null
|
|
56
|
+
|
|
57
|
+
export function getSharedConnection(): SharedConnection | null {
|
|
58
|
+
return shared
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function acquireClient(url: string, opts?: AcquireClientOptions): SharedConnection {
|
|
62
|
+
if (shared?.wsUrl !== url) {
|
|
63
|
+
if (shared) {
|
|
64
|
+
if (shared.closeTimer) clearTimeout(shared.closeTimer)
|
|
65
|
+
const old = shared
|
|
66
|
+
shared = null
|
|
67
|
+
void old.client.close().catch(() => {})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const {
|
|
71
|
+
name = 'openframe-frontend',
|
|
72
|
+
user = 'machine',
|
|
73
|
+
pass = '',
|
|
74
|
+
connectTimeoutMs = NATS_DEFAULTS.CONNECT_TIMEOUT_MS,
|
|
75
|
+
pingIntervalMs = NATS_DEFAULTS.PING_INTERVAL_MS,
|
|
76
|
+
maxPingOut = NATS_DEFAULTS.MAX_PING_OUT,
|
|
77
|
+
} = opts ?? {}
|
|
78
|
+
|
|
79
|
+
const client = createNatsClient({
|
|
80
|
+
servers: url,
|
|
81
|
+
name,
|
|
82
|
+
user,
|
|
83
|
+
pass,
|
|
84
|
+
connectTimeoutMs,
|
|
85
|
+
reconnect: false,
|
|
86
|
+
pingIntervalMs,
|
|
87
|
+
maxPingOut,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
shared = {
|
|
91
|
+
wsUrl: url,
|
|
92
|
+
client,
|
|
93
|
+
connectPromise: null,
|
|
94
|
+
refCount: 0,
|
|
95
|
+
closeTimer: null,
|
|
96
|
+
retryTimer: null,
|
|
97
|
+
retryOwner: null,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
shared.refCount += 1
|
|
102
|
+
if (shared.closeTimer) {
|
|
103
|
+
clearTimeout(shared.closeTimer)
|
|
104
|
+
shared.closeTimer = null
|
|
105
|
+
}
|
|
106
|
+
return shared
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function releaseClient(url: string, opts?: ReleaseClientOptions): void {
|
|
110
|
+
if (!shared || shared.wsUrl !== url) return
|
|
111
|
+
|
|
112
|
+
shared.refCount = Math.max(0, shared.refCount - 1)
|
|
113
|
+
if (shared.refCount > 0) return
|
|
114
|
+
|
|
115
|
+
const delay = opts?.delayMs ?? NATS_DEFAULTS.SHARED_CLOSE_DELAY_MS
|
|
116
|
+
shared.closeTimer = setTimeout(() => {
|
|
117
|
+
const s = shared
|
|
118
|
+
shared = null
|
|
119
|
+
if (s) {
|
|
120
|
+
if (s.retryTimer) {
|
|
121
|
+
clearTimeout(s.retryTimer)
|
|
122
|
+
s.retryTimer = null
|
|
123
|
+
}
|
|
124
|
+
void s.client.close().catch(() => {})
|
|
125
|
+
}
|
|
126
|
+
}, delay)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getSharedConnectionFor(url: string | null | undefined): SharedConnection | null {
|
|
130
|
+
if (!url) return null
|
|
131
|
+
return shared && shared.wsUrl === url ? shared : null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Connection lifecycle: retry + status loop, shared by NatsProvider and the
|
|
136
|
+
// chat hooks. Each consumer that wants to drive reconnect creates an
|
|
137
|
+
// ownerToken and calls startConnectionLifecycle(). The first caller to claim
|
|
138
|
+
// retryOwner runs the actual retry loop; subsequent claimants observe status
|
|
139
|
+
// only. When the owner unmounts and releases retryOwner, the next status
|
|
140
|
+
// event lets a surviving consumer pick up ownership opportunistically.
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export interface ConnectionLifecycleOptions {
|
|
144
|
+
conn: SharedConnection
|
|
145
|
+
wsUrl: string
|
|
146
|
+
onBeforeReconnect?: () => Promise<void> | void
|
|
147
|
+
backoff?: NatsReconnectionBackoff
|
|
148
|
+
getFreshUrl: () => string | null
|
|
149
|
+
/** Called on every status change (after closed-guard). */
|
|
150
|
+
onStatusChange?: (status: NatsStatus, evt: NatsStatusEvent) => void
|
|
151
|
+
/**
|
|
152
|
+
* Decide which statuses should trigger a retry attempt. Defaults to closed +
|
|
153
|
+
* disconnected. Override to skip 'error' (JetStream protocol errors that
|
|
154
|
+
* don't close the WS) or include it.
|
|
155
|
+
*/
|
|
156
|
+
shouldRetryOn?: (status: NatsStatus) => boolean
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ConnectionLifecycleHandle {
|
|
160
|
+
/** Stop observing status, clear any pending retry, release ownership if held. */
|
|
161
|
+
stop(): void
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const defaultShouldRetryOn = (status: NatsStatus) => status === 'closed' || status === 'disconnected'
|
|
165
|
+
|
|
166
|
+
export function startConnectionLifecycle(options: ConnectionLifecycleOptions): ConnectionLifecycleHandle {
|
|
167
|
+
const { conn, wsUrl } = options
|
|
168
|
+
// Each lifecycle gets its own identity for retry ownership. The first lifecycle to claim
|
|
169
|
+
// an unowned connection drives reconnect; later attachers observe status only until the
|
|
170
|
+
// owner releases (see opportunistic claim in scheduleRetry).
|
|
171
|
+
const ownerToken = {}
|
|
172
|
+
if (!conn.retryOwner) conn.retryOwner = ownerToken
|
|
173
|
+
|
|
174
|
+
let closed = false
|
|
175
|
+
let retryAttempt = 0
|
|
176
|
+
|
|
177
|
+
function emitSynthetic(status: NatsStatus) {
|
|
178
|
+
if (closed) return
|
|
179
|
+
options.onStatusChange?.(status, { status })
|
|
180
|
+
if (status === 'connected') {
|
|
181
|
+
retryAttempt = 0
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function scheduleRetry() {
|
|
186
|
+
if (closed) return
|
|
187
|
+
if (getSharedConnectionFor(wsUrl) !== conn) return
|
|
188
|
+
if (!conn.retryOwner) conn.retryOwner = ownerToken
|
|
189
|
+
if (conn.retryOwner !== ownerToken) return
|
|
190
|
+
|
|
191
|
+
if (conn.retryTimer) {
|
|
192
|
+
clearTimeout(conn.retryTimer)
|
|
193
|
+
conn.retryTimer = null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const cfg = options.backoff ?? {}
|
|
197
|
+
const fastRetries = cfg.fastRetries ?? 0
|
|
198
|
+
const fastDelay = cfg.fastRetryDelayMs ?? NATS_DEFAULTS.RETRY_INITIAL_DELAY_MS
|
|
199
|
+
const baseDelay = cfg.initialDelayMs ?? NATS_DEFAULTS.RETRY_INITIAL_DELAY_MS
|
|
200
|
+
const maxDelay = cfg.maxDelayMs ?? NATS_DEFAULTS.RETRY_MAX_DELAY_MS
|
|
201
|
+
const multiplier = cfg.multiplier ?? NATS_DEFAULTS.RETRY_MULTIPLIER
|
|
202
|
+
|
|
203
|
+
const delay =
|
|
204
|
+
retryAttempt < fastRetries
|
|
205
|
+
? fastDelay
|
|
206
|
+
: Math.min(baseDelay * multiplier ** (retryAttempt - fastRetries), maxDelay)
|
|
207
|
+
const jitteredDelay = delay * (0.5 + Math.random() * 0.5)
|
|
208
|
+
retryAttempt++
|
|
209
|
+
|
|
210
|
+
conn.retryTimer = setTimeout(async () => {
|
|
211
|
+
conn.retryTimer = null
|
|
212
|
+
if (closed) return
|
|
213
|
+
if (getSharedConnectionFor(wsUrl) !== conn) return
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await options.onBeforeReconnect?.()
|
|
217
|
+
} catch {
|
|
218
|
+
// continue regardless of token-refresh outcome
|
|
219
|
+
}
|
|
220
|
+
if (closed) return
|
|
221
|
+
if (getSharedConnectionFor(wsUrl) !== conn) return
|
|
222
|
+
|
|
223
|
+
const freshUrl = options.getFreshUrl()
|
|
224
|
+
if (freshUrl !== wsUrl) return
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
conn.connectPromise = null
|
|
228
|
+
conn.connectPromise = conn.client.connect()
|
|
229
|
+
await conn.connectPromise
|
|
230
|
+
if (!closed && getSharedConnectionFor(wsUrl) === conn) {
|
|
231
|
+
retryAttempt = 0
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
conn.connectPromise = null
|
|
235
|
+
if (!closed && getSharedConnectionFor(wsUrl) === conn) {
|
|
236
|
+
scheduleRetry()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}, jitteredDelay)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const shouldRetryOn = options.shouldRetryOn ?? defaultShouldRetryOn
|
|
243
|
+
|
|
244
|
+
const unsubStatus = conn.client.onStatus((evt) => {
|
|
245
|
+
if (closed) return
|
|
246
|
+
options.onStatusChange?.(evt.status, evt)
|
|
247
|
+
if (evt.status === 'connected') {
|
|
248
|
+
retryAttempt = 0
|
|
249
|
+
}
|
|
250
|
+
if (shouldRetryOn(evt.status)) {
|
|
251
|
+
scheduleRetry()
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
if (conn.client.isConnected()) {
|
|
256
|
+
emitSynthetic('connected')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
void (async () => {
|
|
260
|
+
try {
|
|
261
|
+
conn.connectPromise ||= conn.client.connect()
|
|
262
|
+
await conn.connectPromise
|
|
263
|
+
} catch {
|
|
264
|
+
conn.connectPromise = null
|
|
265
|
+
if (closed) return
|
|
266
|
+
|
|
267
|
+
emitSynthetic('disconnected')
|
|
268
|
+
scheduleRetry()
|
|
269
|
+
}
|
|
270
|
+
})()
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
stop() {
|
|
274
|
+
closed = true
|
|
275
|
+
unsubStatus()
|
|
276
|
+
if (conn.retryTimer) {
|
|
277
|
+
clearTimeout(conn.retryTimer)
|
|
278
|
+
conn.retryTimer = null
|
|
279
|
+
}
|
|
280
|
+
if (conn.retryOwner === ownerToken) {
|
|
281
|
+
conn.retryOwner = null
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { JsMsg, Msg, NatsSubscribeOptions } from './nats'
|
|
5
|
+
import { useOptionalNats } from './nats-provider'
|
|
6
|
+
|
|
7
|
+
export interface UseNatsSubscriptionOptions extends NatsSubscribeOptions {
|
|
8
|
+
enabled?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UseNatsSubscriptionReturn {
|
|
12
|
+
isSubscribed: boolean
|
|
13
|
+
isReady: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Subscribe to a NATS subject using the shared connection from <NatsProvider>.
|
|
18
|
+
* Automatically (re)subscribes when the connection becomes ready, when the
|
|
19
|
+
* subject changes, and after reconnections.
|
|
20
|
+
*/
|
|
21
|
+
export function useNatsSubscription(
|
|
22
|
+
subject: string | null,
|
|
23
|
+
onMessage: (msg: Msg) => void | Promise<void>,
|
|
24
|
+
options?: UseNatsSubscriptionOptions,
|
|
25
|
+
): UseNatsSubscriptionReturn {
|
|
26
|
+
const nats = useOptionalNats()
|
|
27
|
+
const handlerRef = React.useRef(onMessage)
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
handlerRef.current = onMessage
|
|
30
|
+
}, [onMessage])
|
|
31
|
+
|
|
32
|
+
const [isSubscribed, setIsSubscribed] = React.useState(false)
|
|
33
|
+
|
|
34
|
+
const enabled = options?.enabled !== false
|
|
35
|
+
const queue = options?.queue
|
|
36
|
+
const max = options?.max
|
|
37
|
+
const reconnectionCount = nats?.reconnectionCount ?? 0
|
|
38
|
+
const isReady = !!nats?.isReady
|
|
39
|
+
const client = nats?.client ?? null
|
|
40
|
+
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
if (!client || !isReady || !subject || !enabled) {
|
|
43
|
+
setIsSubscribed(false)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sub = client.subscribeBytes(
|
|
48
|
+
subject,
|
|
49
|
+
(msg) => handlerRef.current(msg),
|
|
50
|
+
{ queue, max },
|
|
51
|
+
)
|
|
52
|
+
setIsSubscribed(true)
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
setIsSubscribed(false)
|
|
56
|
+
try {
|
|
57
|
+
sub.unsubscribe()
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// reconnectionCount intentionally in deps: re-subscribe after reconnect
|
|
63
|
+
}, [client, isReady, subject, enabled, queue, max, reconnectionCount])
|
|
64
|
+
|
|
65
|
+
return { isSubscribed, isReady }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type UseNatsJsonSubscriptionOptions = UseNatsSubscriptionOptions
|
|
69
|
+
|
|
70
|
+
export function useNatsJsonSubscription<T = unknown>(
|
|
71
|
+
subject: string | null,
|
|
72
|
+
onPayload: (payload: T, msg: Msg) => void | Promise<void>,
|
|
73
|
+
options?: UseNatsJsonSubscriptionOptions,
|
|
74
|
+
): UseNatsSubscriptionReturn {
|
|
75
|
+
const handlerRef = React.useRef(onPayload)
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
handlerRef.current = onPayload
|
|
78
|
+
}, [onPayload])
|
|
79
|
+
|
|
80
|
+
const decoderRef = React.useRef<TextDecoder | null>(null)
|
|
81
|
+
if (!decoderRef.current && typeof TextDecoder !== 'undefined') {
|
|
82
|
+
decoderRef.current = new TextDecoder()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const wrapped = React.useCallback(async (msg: Msg) => {
|
|
86
|
+
const decoder = decoderRef.current
|
|
87
|
+
if (!decoder) return
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(decoder.decode(msg.data)) as T
|
|
90
|
+
await handlerRef.current(parsed, msg)
|
|
91
|
+
} catch {
|
|
92
|
+
// ignore malformed payloads
|
|
93
|
+
}
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
96
|
+
return useNatsSubscription(subject, wrapped, options)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type NatsJsMsg = JsMsg
|