@flamingo-stack/openframe-frontend-core 0.0.195 → 0.0.196
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-PJ5KFD2V.js → chunk-4ML3NA2L.js} +75 -1
- package/dist/{chunk-PJ5KFD2V.js.map → chunk-4ML3NA2L.js.map} +1 -1
- package/dist/{chunk-U6AJSRJP.js → chunk-BXPH5SOL.js} +707 -354
- package/dist/chunk-BXPH5SOL.js.map +1 -0
- package/dist/{chunk-IQM3G2I6.cjs → chunk-E6AWU7EI.cjs} +753 -400
- package/dist/chunk-E6AWU7EI.cjs.map +1 -0
- package/dist/{chunk-CVMSC7M4.cjs → chunk-OII2IERE.cjs} +77 -3
- package/dist/chunk-OII2IERE.cjs.map +1 -0
- package/dist/components/chat/hooks/index.d.ts +1 -0
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts +15 -0
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -0
- package/dist/components/chat/types/api.types.d.ts +43 -0
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/network.types.d.ts +4 -0
- package/dist/components/chat/types/network.types.d.ts.map +1 -1
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/index.cjs +5 -3
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +4 -2
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/ui/index.cjs +5 -3
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +4 -2
- package/dist/hooks/index.cjs +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/index.cjs +5 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -2
- package/dist/nats/index.cjs +73 -0
- package/dist/nats/index.cjs.map +1 -1
- package/dist/nats/index.js +73 -0
- package/dist/nats/index.js.map +1 -1
- package/dist/nats/nats.d.ts +22 -1
- package/dist/nats/nats.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/hooks/index.ts +1 -0
- package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +474 -0
- package/src/components/chat/types/api.types.ts +45 -0
- package/src/components/chat/types/network.types.ts +4 -0
- package/src/nats/nats.ts +117 -0
- package/dist/chunk-CVMSC7M4.cjs.map +0 -1
- package/dist/chunk-IQM3G2I6.cjs.map +0 -1
- package/dist/chunk-U6AJSRJP.js.map +0 -1
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
createNatsClient,
|
|
6
|
+
type JetStreamSubscriptionHandle,
|
|
7
|
+
type NatsClient,
|
|
8
|
+
} from '../../../nats'
|
|
9
|
+
import {
|
|
10
|
+
type UseJetStreamDialogSubscriptionOptions,
|
|
11
|
+
type UseJetStreamDialogSubscriptionReturn,
|
|
12
|
+
NETWORK_CONFIG,
|
|
13
|
+
} from '../types'
|
|
14
|
+
|
|
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
|
+
const DEFAULT_INACTIVE_THRESHOLD_MS = 5 * 60_000
|
|
27
|
+
const DEFAULT_STREAM_NAME = 'CHAT_CHUNKS'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Subscribe to a chat dialog stream via a JetStream **ephemeral OrderedConsumer**.
|
|
31
|
+
*
|
|
32
|
+
* - Subject: `chat.{dialogId}.{topic}`
|
|
33
|
+
* - When `optStartSeq` is a number, the consumer resumes at `optStartSeq + 1`
|
|
34
|
+
* (`DeliverPolicy.ByStartSequence`). When null/undefined, it live-tails
|
|
35
|
+
* (`DeliverPolicy.New`).
|
|
36
|
+
* - On reconnect, the consumer is recreated starting from the highest stream
|
|
37
|
+
* sequence we've already observed + 1, so no chunk is replayed or skipped.
|
|
38
|
+
* - Consumer is ephemeral with `AckPolicy.None` and a 5-minute inactivity
|
|
39
|
+
* threshold (overridable via `inactiveThresholdMs`).
|
|
40
|
+
*/
|
|
41
|
+
export function useJetStreamDialogSubscription({
|
|
42
|
+
enabled,
|
|
43
|
+
dialogId,
|
|
44
|
+
streamName = DEFAULT_STREAM_NAME,
|
|
45
|
+
topic,
|
|
46
|
+
optStartSeq,
|
|
47
|
+
onEvent,
|
|
48
|
+
onConnect,
|
|
49
|
+
onDisconnect,
|
|
50
|
+
onSubscribed,
|
|
51
|
+
onBeforeReconnect,
|
|
52
|
+
getNatsWsUrl,
|
|
53
|
+
clientConfig = {},
|
|
54
|
+
reconnectionBackoff,
|
|
55
|
+
inactiveThresholdMs,
|
|
56
|
+
}: UseJetStreamDialogSubscriptionOptions): UseJetStreamDialogSubscriptionReturn {
|
|
57
|
+
const [isConnected, setIsConnected] = useState(false)
|
|
58
|
+
const [isSubscribed, setIsSubscribed] = useState(false)
|
|
59
|
+
const [reconnectionCount, setReconnectionCount] = useState(0)
|
|
60
|
+
const [currentStreamSeq, setCurrentStreamSeq] = useState<number | null>(null)
|
|
61
|
+
|
|
62
|
+
const clientRef = useRef<NatsClient | null>(null)
|
|
63
|
+
const subscriptionRef = useRef<JetStreamSubscriptionHandle | null>(null)
|
|
64
|
+
const highestStreamSeqRef = useRef<number | null>(null)
|
|
65
|
+
|
|
66
|
+
const onEventRef = useRef(onEvent)
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
onEventRef.current = onEvent
|
|
69
|
+
}, [onEvent])
|
|
70
|
+
|
|
71
|
+
const onConnectRef = useRef(onConnect)
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
onConnectRef.current = onConnect
|
|
74
|
+
}, [onConnect])
|
|
75
|
+
|
|
76
|
+
const onDisconnectRef = useRef(onDisconnect)
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
onDisconnectRef.current = onDisconnect
|
|
79
|
+
}, [onDisconnect])
|
|
80
|
+
|
|
81
|
+
const onSubscribedRef = useRef(onSubscribed)
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
onSubscribedRef.current = onSubscribed
|
|
84
|
+
}, [onSubscribed])
|
|
85
|
+
|
|
86
|
+
const onBeforeReconnectRef = useRef(onBeforeReconnect)
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
onBeforeReconnectRef.current = onBeforeReconnect
|
|
89
|
+
}, [onBeforeReconnect])
|
|
90
|
+
|
|
91
|
+
const getNatsWsUrlRef = useRef(getNatsWsUrl)
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
getNatsWsUrlRef.current = getNatsWsUrl
|
|
94
|
+
}, [getNatsWsUrl])
|
|
95
|
+
|
|
96
|
+
const reconnectionBackoffRef = useRef(reconnectionBackoff)
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
reconnectionBackoffRef.current = reconnectionBackoff
|
|
99
|
+
}, [reconnectionBackoff])
|
|
100
|
+
|
|
101
|
+
const optStartSeqRef = useRef<number | null | undefined>(optStartSeq)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
optStartSeqRef.current = optStartSeq
|
|
104
|
+
}, [optStartSeq])
|
|
105
|
+
|
|
106
|
+
const inactiveThresholdRef = useRef<number | undefined>(inactiveThresholdMs)
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
inactiveThresholdRef.current = inactiveThresholdMs
|
|
109
|
+
}, [inactiveThresholdMs])
|
|
110
|
+
|
|
111
|
+
const hadConnectionBeforeRef = useRef(false)
|
|
112
|
+
|
|
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
|
+
}, [])
|
|
173
|
+
|
|
174
|
+
const currentWsUrlRef = useRef<string>('')
|
|
175
|
+
|
|
176
|
+
// Connection lifecycle: acquire/release the shared client based on enabled + URL.
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const wsUrl = getNatsWsUrl()
|
|
179
|
+
if (!enabled || !wsUrl) {
|
|
180
|
+
if (currentWsUrlRef.current && clientRef.current) {
|
|
181
|
+
releaseClient(currentWsUrlRef.current)
|
|
182
|
+
clientRef.current = null
|
|
183
|
+
currentWsUrlRef.current = ''
|
|
184
|
+
setIsConnected(false)
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
wsUrl === currentWsUrlRef.current &&
|
|
191
|
+
clientRef.current &&
|
|
192
|
+
clientRef.current.isConnected()
|
|
193
|
+
) {
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
currentWsUrlRef.current &&
|
|
199
|
+
currentWsUrlRef.current !== wsUrl &&
|
|
200
|
+
clientRef.current
|
|
201
|
+
) {
|
|
202
|
+
releaseClient(currentWsUrlRef.current)
|
|
203
|
+
clientRef.current = null
|
|
204
|
+
setIsConnected(false)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
currentWsUrlRef.current = wsUrl
|
|
208
|
+
const sharedConn = acquireClient(wsUrl)
|
|
209
|
+
const client = sharedConn.client
|
|
210
|
+
|
|
211
|
+
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
|
|
256
|
+
|
|
257
|
+
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
|
+
}
|
|
265
|
+
} 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
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
onDisconnectRef.current?.()
|
|
313
|
+
scheduleRetry()
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
;(async () => {
|
|
318
|
+
try {
|
|
319
|
+
sharedConn.connectPromise ||= client.connect()
|
|
320
|
+
await sharedConn.connectPromise
|
|
321
|
+
if (!closed) {
|
|
322
|
+
setIsConnected(true)
|
|
323
|
+
hadConnectionBeforeRef.current = true
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
sharedConn.connectPromise = null
|
|
327
|
+
if (!closed) {
|
|
328
|
+
setIsConnected(false)
|
|
329
|
+
onDisconnectRef.current?.()
|
|
330
|
+
scheduleRetry()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
})()
|
|
334
|
+
|
|
335
|
+
return () => {
|
|
336
|
+
closed = true
|
|
337
|
+
setIsConnected(false)
|
|
338
|
+
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
|
+
}
|
|
354
|
+
|
|
355
|
+
if (clientRef.current && currentWsUrlRef.current) {
|
|
356
|
+
releaseClient(currentWsUrlRef.current)
|
|
357
|
+
clientRef.current = null
|
|
358
|
+
currentWsUrlRef.current = ''
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}, [enabled, getNatsWsUrl, acquireClient, releaseClient])
|
|
362
|
+
|
|
363
|
+
// Subscription lifecycle: (re)create the ephemeral JetStream consumer whenever
|
|
364
|
+
// we transition into a connected state for a dialog, and whenever the dialog
|
|
365
|
+
// changes. On reconnect we resume from highestStreamSeq + 1.
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
if (!enabled || !dialogId || !isConnected) {
|
|
368
|
+
if (subscriptionRef.current) {
|
|
369
|
+
try {
|
|
370
|
+
subscriptionRef.current.unsubscribe()
|
|
371
|
+
} catch {
|
|
372
|
+
// ignore
|
|
373
|
+
}
|
|
374
|
+
subscriptionRef.current = null
|
|
375
|
+
}
|
|
376
|
+
setIsSubscribed(false)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const client = clientRef.current
|
|
381
|
+
if (!client) return
|
|
382
|
+
|
|
383
|
+
const abortController = new AbortController()
|
|
384
|
+
const decoder = new TextDecoder()
|
|
385
|
+
const filterSubject = `chat.${dialogId}.${topic}`
|
|
386
|
+
|
|
387
|
+
const resumeSeq = highestStreamSeqRef.current
|
|
388
|
+
const initialOptStart = optStartSeqRef.current
|
|
389
|
+
const startSeq =
|
|
390
|
+
resumeSeq != null ? resumeSeq + 1 : initialOptStart != null ? initialOptStart + 1 : undefined
|
|
391
|
+
|
|
392
|
+
let cancelled = false
|
|
393
|
+
|
|
394
|
+
void (async () => {
|
|
395
|
+
try {
|
|
396
|
+
const handle = await client.subscribeJetStreamOrdered(
|
|
397
|
+
(msg) => {
|
|
398
|
+
if (cancelled) return
|
|
399
|
+
const streamSeq = msg.info.streamSequence
|
|
400
|
+
if (typeof streamSeq === 'number') {
|
|
401
|
+
if (
|
|
402
|
+
highestStreamSeqRef.current == null ||
|
|
403
|
+
streamSeq > highestStreamSeqRef.current
|
|
404
|
+
) {
|
|
405
|
+
highestStreamSeqRef.current = streamSeq
|
|
406
|
+
setCurrentStreamSeq(streamSeq)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const cb = onEventRef.current
|
|
410
|
+
if (!cb) return
|
|
411
|
+
try {
|
|
412
|
+
const parsed = JSON.parse(decoder.decode(msg.data)) as Record<string, unknown>
|
|
413
|
+
if (typeof streamSeq === 'number') {
|
|
414
|
+
;(parsed as { streamSeq?: number }).streamSeq = streamSeq
|
|
415
|
+
}
|
|
416
|
+
cb(parsed, topic)
|
|
417
|
+
} catch {
|
|
418
|
+
// Ignore malformed payloads.
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
streamName,
|
|
423
|
+
filterSubject,
|
|
424
|
+
deliverPolicy: startSeq != null ? 'byStartSequence' : 'new',
|
|
425
|
+
optStartSeq: startSeq,
|
|
426
|
+
inactiveThresholdMs: inactiveThresholdRef.current ?? DEFAULT_INACTIVE_THRESHOLD_MS,
|
|
427
|
+
signal: abortController.signal,
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
if (cancelled) {
|
|
432
|
+
try {
|
|
433
|
+
handle.unsubscribe()
|
|
434
|
+
} catch {
|
|
435
|
+
// ignore
|
|
436
|
+
}
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
subscriptionRef.current = handle
|
|
441
|
+
setIsSubscribed(true)
|
|
442
|
+
onSubscribedRef.current?.()
|
|
443
|
+
} catch {
|
|
444
|
+
if (!cancelled) {
|
|
445
|
+
setIsSubscribed(false)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
})()
|
|
449
|
+
|
|
450
|
+
return () => {
|
|
451
|
+
cancelled = true
|
|
452
|
+
abortController.abort()
|
|
453
|
+
if (subscriptionRef.current) {
|
|
454
|
+
try {
|
|
455
|
+
subscriptionRef.current.unsubscribe()
|
|
456
|
+
} catch {
|
|
457
|
+
// ignore
|
|
458
|
+
}
|
|
459
|
+
subscriptionRef.current = null
|
|
460
|
+
}
|
|
461
|
+
setIsSubscribed(false)
|
|
462
|
+
}
|
|
463
|
+
}, [enabled, dialogId, isConnected, streamName, topic, reconnectionCount])
|
|
464
|
+
|
|
465
|
+
// Reset the highest-seen sequence whenever the dialog changes so a new dialog
|
|
466
|
+
// starts from optStartSeq (or DeliverPolicy.New) rather than the previous
|
|
467
|
+
// dialog's offset.
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
highestStreamSeqRef.current = null
|
|
470
|
+
setCurrentStreamSeq(null)
|
|
471
|
+
}, [dialogId])
|
|
472
|
+
|
|
473
|
+
return { isConnected, isSubscribed, reconnectionCount, currentStreamSeq }
|
|
474
|
+
}
|
|
@@ -90,6 +90,51 @@ export interface UseNatsDialogSubscriptionReturn {
|
|
|
90
90
|
reconnectionCount: number
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
export interface UseJetStreamDialogSubscriptionOptions {
|
|
94
|
+
enabled: boolean
|
|
95
|
+
dialogId: string | null
|
|
96
|
+
/** JetStream stream name. Default: 'CHAT_CHUNKS'. */
|
|
97
|
+
streamName?: string
|
|
98
|
+
/** Single topic to subscribe to. */
|
|
99
|
+
topic: NatsMessageType
|
|
100
|
+
/**
|
|
101
|
+
* Resume from this JetStream sequence + 1. When null/undefined, the consumer starts with
|
|
102
|
+
* DeliverPolicy.New (live tail only).
|
|
103
|
+
*/
|
|
104
|
+
optStartSeq?: number | null
|
|
105
|
+
onEvent?: (payload: unknown, messageType: NatsMessageType) => void
|
|
106
|
+
onConnect?: () => void
|
|
107
|
+
onDisconnect?: () => void
|
|
108
|
+
onSubscribed?: () => void
|
|
109
|
+
/** Called on disconnect, before reconnect attempt. Use to refresh auth. */
|
|
110
|
+
onBeforeReconnect?: () => Promise<void> | void
|
|
111
|
+
/** Build the NATS WebSocket URL (or null when not yet available). */
|
|
112
|
+
getNatsWsUrl: () => string | null
|
|
113
|
+
clientConfig?: {
|
|
114
|
+
name?: string
|
|
115
|
+
user?: string
|
|
116
|
+
pass?: string
|
|
117
|
+
}
|
|
118
|
+
reconnectionBackoff?: {
|
|
119
|
+
fastRetries?: number
|
|
120
|
+
fastRetryDelayMs?: number
|
|
121
|
+
initialDelayMs?: number
|
|
122
|
+
maxDelayMs?: number
|
|
123
|
+
multiplier?: number
|
|
124
|
+
}
|
|
125
|
+
/** Consumer inactivity threshold in ms before NATS auto-cleans it. Default: 5 minutes. */
|
|
126
|
+
inactiveThresholdMs?: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface UseJetStreamDialogSubscriptionReturn {
|
|
130
|
+
isConnected: boolean
|
|
131
|
+
isSubscribed: boolean
|
|
132
|
+
/** Incremented each time the underlying NATS connection reconnects. Starts at 0. */
|
|
133
|
+
reconnectionCount: number
|
|
134
|
+
/** Highest JetStream stream sequence observed so far (null before first delivery). */
|
|
135
|
+
currentStreamSeq: number | null
|
|
136
|
+
}
|
|
137
|
+
|
|
93
138
|
export interface SegmentsUpdateMetadata {
|
|
94
139
|
/** Segments should be appended to the last assistant message */
|
|
95
140
|
append?: boolean
|
|
@@ -34,6 +34,10 @@ export type NetworkConfig = typeof NETWORK_CONFIG
|
|
|
34
34
|
|
|
35
35
|
export interface ChunkData {
|
|
36
36
|
sequenceId?: number
|
|
37
|
+
/** JetStream stream sequence, populated from JsMsg.info.streamSequence when delivered via JetStream. */
|
|
38
|
+
streamSeq?: number
|
|
39
|
+
/** Authoritative dialog streaming state at the time this chunk was emitted; consumers should mirror it into their dialog cache. */
|
|
40
|
+
streamState?: 'IDLE' | 'STREAMING'
|
|
37
41
|
type: string
|
|
38
42
|
text?: string
|
|
39
43
|
integratedToolType?: string
|
package/src/nats/nats.ts
CHANGED
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ConnectionOptions,
|
|
3
|
+
Consumer,
|
|
4
|
+
ConsumerMessages,
|
|
5
|
+
JsMsg,
|
|
3
6
|
MsgHdrs as NatsHeaders,
|
|
4
7
|
Msg,
|
|
5
8
|
NatsConnection,
|
|
6
9
|
Subscription,
|
|
7
10
|
} from 'nats.ws'
|
|
8
11
|
|
|
12
|
+
export type JetStreamDeliverPolicy = 'new' | 'byStartSequence'
|
|
13
|
+
|
|
14
|
+
export interface JetStreamOrderedSubscribeOptions {
|
|
15
|
+
streamName: string
|
|
16
|
+
filterSubject: string
|
|
17
|
+
deliverPolicy: JetStreamDeliverPolicy
|
|
18
|
+
/** Required when deliverPolicy === 'byStartSequence'. */
|
|
19
|
+
optStartSeq?: number
|
|
20
|
+
/** Auto-cleanup the ephemeral consumer after this idle time. Default: 5 minutes. */
|
|
21
|
+
inactiveThresholdMs?: number
|
|
22
|
+
/** AbortSignal to tear down the consumer. */
|
|
23
|
+
signal?: AbortSignal
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface JetStreamSubscriptionHandle {
|
|
27
|
+
unsubscribe(): void
|
|
28
|
+
}
|
|
29
|
+
|
|
9
30
|
export interface NatsClientOptions {
|
|
10
31
|
/**
|
|
11
32
|
* NATS server URL(s), for example:
|
|
@@ -147,6 +168,16 @@ export interface NatsClient {
|
|
|
147
168
|
options?: NatsSubscribeOptions,
|
|
148
169
|
): NatsSubscriptionHandle
|
|
149
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Subscribe to a JetStream subject via an ephemeral OrderedConsumer (no acks).
|
|
173
|
+
* Use `optStartSeq` with `deliverPolicy: 'byStartSequence'` to resume from a known offset,
|
|
174
|
+
* or `deliverPolicy: 'new'` to live-tail.
|
|
175
|
+
*/
|
|
176
|
+
subscribeJetStreamOrdered(
|
|
177
|
+
onMessage: (msg: JsMsg) => void | Promise<void>,
|
|
178
|
+
options: JetStreamOrderedSubscribeOptions,
|
|
179
|
+
): Promise<JetStreamSubscriptionHandle>
|
|
180
|
+
|
|
150
181
|
onStatus(listener: (event: NatsStatusEvent) => void): () => void
|
|
151
182
|
}
|
|
152
183
|
|
|
@@ -473,6 +504,91 @@ export function createNatsClient(options: NatsClientOptions): NatsClient {
|
|
|
473
504
|
)
|
|
474
505
|
}
|
|
475
506
|
|
|
507
|
+
async function subscribeJetStreamOrdered(
|
|
508
|
+
onMessage: (msg: JsMsg) => void | Promise<void>,
|
|
509
|
+
opts: JetStreamOrderedSubscribeOptions,
|
|
510
|
+
): Promise<JetStreamSubscriptionHandle> {
|
|
511
|
+
const conn = requireConnection()
|
|
512
|
+
if (opts.signal?.aborted) {
|
|
513
|
+
return { unsubscribe() {} }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const nats = await importNats()
|
|
517
|
+
if (opts.signal?.aborted) {
|
|
518
|
+
return { unsubscribe() {} }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const inactiveThresholdNs =
|
|
522
|
+
(opts.inactiveThresholdMs ?? 5 * 60_000) * 1_000_000
|
|
523
|
+
|
|
524
|
+
const js = conn.jetstream()
|
|
525
|
+
const consumer: Consumer = await js.consumers.get(opts.streamName, {
|
|
526
|
+
filterSubjects: opts.filterSubject,
|
|
527
|
+
deliver_policy: nats.DeliverPolicy.StartSequence,
|
|
528
|
+
opt_start_seq: opts.optStartSeq ?? 0,
|
|
529
|
+
inactive_threshold: inactiveThresholdNs,
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
const iterRef: { current: ConsumerMessages | null } = { current: null }
|
|
533
|
+
let closed = false
|
|
534
|
+
|
|
535
|
+
const onAbort = () => {
|
|
536
|
+
void teardown()
|
|
537
|
+
}
|
|
538
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
|
539
|
+
|
|
540
|
+
async function teardown(): Promise<void> {
|
|
541
|
+
if (closed) return
|
|
542
|
+
closed = true
|
|
543
|
+
opts.signal?.removeEventListener('abort', onAbort)
|
|
544
|
+
const iter = iterRef.current
|
|
545
|
+
iterRef.current = null
|
|
546
|
+
if (iter) {
|
|
547
|
+
try {
|
|
548
|
+
await iter.close()
|
|
549
|
+
} catch {
|
|
550
|
+
// ignore
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (opts.signal?.aborted) {
|
|
556
|
+
void teardown()
|
|
557
|
+
return { unsubscribe() {} }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
;(async () => {
|
|
561
|
+
try {
|
|
562
|
+
const iter = await consumer.consume()
|
|
563
|
+
if (closed) {
|
|
564
|
+
try {
|
|
565
|
+
await iter.close()
|
|
566
|
+
} catch {
|
|
567
|
+
// ignore
|
|
568
|
+
}
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
iterRef.current = iter
|
|
572
|
+
for await (const msg of iter) {
|
|
573
|
+
if (closed) break
|
|
574
|
+
try {
|
|
575
|
+
await onMessage(msg)
|
|
576
|
+
} catch (e) {
|
|
577
|
+
emitStatus({ status: 'error', data: e })
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} catch (e) {
|
|
581
|
+
if (!closed) emitStatus({ status: 'error', data: e })
|
|
582
|
+
}
|
|
583
|
+
})().catch((e) => emitStatus({ status: 'error', data: e }))
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
unsubscribe() {
|
|
587
|
+
void teardown()
|
|
588
|
+
},
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
476
592
|
function onStatus(listener: (event: NatsStatusEvent) => void): () => void {
|
|
477
593
|
statusListeners.add(listener)
|
|
478
594
|
return () => statusListeners.delete(listener)
|
|
@@ -491,6 +607,7 @@ export function createNatsClient(options: NatsClientOptions): NatsClient {
|
|
|
491
607
|
subscribeBytes,
|
|
492
608
|
subscribeString,
|
|
493
609
|
subscribeJson,
|
|
610
|
+
subscribeJetStreamOrdered,
|
|
494
611
|
onStatus,
|
|
495
612
|
}
|
|
496
613
|
}
|