@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.
Files changed (46) hide show
  1. package/dist/{chunk-PJ5KFD2V.js → chunk-4ML3NA2L.js} +75 -1
  2. package/dist/{chunk-PJ5KFD2V.js.map → chunk-4ML3NA2L.js.map} +1 -1
  3. package/dist/{chunk-U6AJSRJP.js → chunk-BXPH5SOL.js} +707 -354
  4. package/dist/chunk-BXPH5SOL.js.map +1 -0
  5. package/dist/{chunk-IQM3G2I6.cjs → chunk-E6AWU7EI.cjs} +753 -400
  6. package/dist/chunk-E6AWU7EI.cjs.map +1 -0
  7. package/dist/{chunk-CVMSC7M4.cjs → chunk-OII2IERE.cjs} +77 -3
  8. package/dist/chunk-OII2IERE.cjs.map +1 -0
  9. package/dist/components/chat/hooks/index.d.ts +1 -0
  10. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  11. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts +15 -0
  12. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -0
  13. package/dist/components/chat/types/api.types.d.ts +43 -0
  14. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  15. package/dist/components/chat/types/network.types.d.ts +4 -0
  16. package/dist/components/chat/types/network.types.d.ts.map +1 -1
  17. package/dist/components/features/index.cjs +3 -3
  18. package/dist/components/features/index.js +2 -2
  19. package/dist/components/index.cjs +5 -3
  20. package/dist/components/index.cjs.map +1 -1
  21. package/dist/components/index.js +4 -2
  22. package/dist/components/navigation/index.cjs +3 -3
  23. package/dist/components/navigation/index.js +2 -2
  24. package/dist/components/ui/index.cjs +5 -3
  25. package/dist/components/ui/index.cjs.map +1 -1
  26. package/dist/components/ui/index.js +4 -2
  27. package/dist/hooks/index.cjs +2 -2
  28. package/dist/hooks/index.js +1 -1
  29. package/dist/index.cjs +5 -3
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.js +4 -2
  32. package/dist/nats/index.cjs +73 -0
  33. package/dist/nats/index.cjs.map +1 -1
  34. package/dist/nats/index.js +73 -0
  35. package/dist/nats/index.js.map +1 -1
  36. package/dist/nats/nats.d.ts +22 -1
  37. package/dist/nats/nats.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/components/chat/hooks/index.ts +1 -0
  40. package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +474 -0
  41. package/src/components/chat/types/api.types.ts +45 -0
  42. package/src/components/chat/types/network.types.ts +4 -0
  43. package/src/nats/nats.ts +117 -0
  44. package/dist/chunk-CVMSC7M4.cjs.map +0 -1
  45. package/dist/chunk-IQM3G2I6.cjs.map +0 -1
  46. 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
  }