@jr200-labs/xstate-nats 0.9.21 → 0.9.23

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 (43) hide show
  1. package/README.md +75 -10
  2. package/dist/actions/connection.d.ts +2 -0
  3. package/dist/actions/connection.d.ts.map +1 -1
  4. package/dist/actions/connection.js +20 -9
  5. package/dist/actions/connection.js.map +1 -1
  6. package/dist/actions/subject.d.ts +5 -1
  7. package/dist/actions/subject.d.ts.map +1 -1
  8. package/dist/actions/subject.js +60 -16
  9. package/dist/actions/subject.js.map +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/machines/credentials.d.ts +112 -0
  15. package/dist/machines/credentials.d.ts.map +1 -0
  16. package/dist/machines/credentials.js +252 -0
  17. package/dist/machines/credentials.js.map +1 -0
  18. package/dist/machines/root.d.ts +190 -3
  19. package/dist/machines/root.d.ts.map +1 -1
  20. package/dist/machines/root.js +107 -13
  21. package/dist/machines/root.js.map +1 -1
  22. package/dist/machines/subject.d.ts +7 -2
  23. package/dist/machines/subject.d.ts.map +1 -1
  24. package/dist/machines/subject.js +11 -1
  25. package/dist/machines/subject.js.map +1 -1
  26. package/dist/telemetry.d.ts +2 -0
  27. package/dist/telemetry.d.ts.map +1 -1
  28. package/dist/telemetry.js +18 -1
  29. package/dist/telemetry.js.map +1 -1
  30. package/package.json +17 -19
  31. package/src/actions/connection.test.ts +62 -7
  32. package/src/actions/connection.ts +28 -8
  33. package/src/actions/subject.test.ts +120 -0
  34. package/src/actions/subject.ts +79 -19
  35. package/src/index.ts +9 -0
  36. package/src/machines/credentials.telemetry.test.ts +66 -0
  37. package/src/machines/credentials.test.ts +175 -0
  38. package/src/machines/credentials.ts +320 -0
  39. package/src/machines/root.test.ts +88 -1
  40. package/src/machines/root.ts +115 -13
  41. package/src/machines/subject.test.ts +60 -0
  42. package/src/machines/subject.ts +18 -1
  43. package/src/telemetry.ts +35 -1
@@ -0,0 +1,175 @@
1
+ import { TimeoutError } from '@nats-io/nats-core'
2
+ import { createActor, waitFor } from 'xstate'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { credentialMachine, getCredentials } from './credentials'
5
+
6
+ describe('credentialMachine', () => {
7
+ afterEach(() => vi.useRealTimers())
8
+
9
+ it('loads once and shares the ready credentials', async () => {
10
+ const credentials = { auth: { type: 'token' as const, token: 'one' } }
11
+ const load = vi.fn(async () => credentials)
12
+ const actor = createActor(credentialMachine, { input: { adapter: { load } } }).start()
13
+
14
+ await expect(Promise.all([getCredentials(actor), getCredentials(actor)])).resolves.toEqual([
15
+ credentials,
16
+ credentials,
17
+ ])
18
+ expect(load).toHaveBeenCalledOnce()
19
+ expect(actor.getSnapshot().value).toBe('ready')
20
+ actor.stop()
21
+ })
22
+
23
+ it('deduplicates requests while refreshing near expiry', async () => {
24
+ let finishRefresh!: (value: { auth: { type: 'token'; token: string } }) => void
25
+ const refreshed = { auth: { type: 'token' as const, token: 'two' } }
26
+ const refresh = vi.fn(
27
+ () =>
28
+ new Promise<{ auth: { type: 'token'; token: string } }>((resolve) => {
29
+ finishRefresh = resolve
30
+ }),
31
+ )
32
+ const actor = createActor(credentialMachine, {
33
+ input: {
34
+ adapter: {
35
+ load: async () => ({
36
+ auth: { type: 'token' as const, token: 'one' },
37
+ expiresAt: Date.now() + 30_000,
38
+ }),
39
+ refresh,
40
+ },
41
+ },
42
+ }).start()
43
+
44
+ await waitFor(actor, (snapshot) => snapshot.matches('refreshing'))
45
+ const first = getCredentials(actor)
46
+ const second = getCredentials(actor)
47
+ finishRefresh(refreshed)
48
+
49
+ await expect(Promise.all([first, second])).resolves.toEqual([refreshed, refreshed])
50
+ expect(refresh).toHaveBeenCalledOnce()
51
+ actor.stop()
52
+ })
53
+
54
+ it('proactively refreshes before credentials expire', async () => {
55
+ vi.useFakeTimers()
56
+ const now = new Date('2026-01-01T00:00:00Z')
57
+ vi.setSystemTime(now)
58
+ const refresh = vi.fn(async () => ({
59
+ auth: { type: 'token' as const, token: 'two' },
60
+ expiresAt: now.getTime() + 600_000,
61
+ }))
62
+ const actor = createActor(credentialMachine, {
63
+ input: {
64
+ refreshBeforeExpiryMs: 1_000,
65
+ adapter: {
66
+ load: async () => ({
67
+ auth: { type: 'token' as const, token: 'one' },
68
+ expiresAt: now.getTime() + 5_000,
69
+ }),
70
+ refresh,
71
+ },
72
+ },
73
+ }).start()
74
+
75
+ await vi.advanceTimersByTimeAsync(4_000)
76
+ await vi.waitFor(() => expect(refresh).toHaveBeenCalledOnce())
77
+ await expect(getCredentials(actor)).resolves.toMatchObject({
78
+ auth: { token: 'two' },
79
+ })
80
+ actor.stop()
81
+ })
82
+
83
+ it('rejects expired credentials when no refresh adapter exists', async () => {
84
+ vi.useFakeTimers()
85
+ const actor = createActor(credentialMachine, {
86
+ input: {
87
+ adapter: {
88
+ load: async () => ({
89
+ auth: { type: 'token' as const, token: 'one' },
90
+ expiresAt: Date.now() + 10,
91
+ }),
92
+ },
93
+ },
94
+ }).start()
95
+
96
+ await vi.advanceTimersByTimeAsync(10)
97
+ await expect(getCredentials(actor)).rejects.toThrow('expired')
98
+ expect(actor.getSnapshot().value).toBe('expired')
99
+ actor.stop()
100
+ })
101
+
102
+ it('does not release an expired value returned by the initial load', async () => {
103
+ const actor = createActor(credentialMachine, {
104
+ input: {
105
+ adapter: {
106
+ load: async () => ({
107
+ auth: { type: 'token' as const, token: 'stale' },
108
+ expiresAt: Date.now() - 1,
109
+ }),
110
+ },
111
+ },
112
+ }).start()
113
+
114
+ await expect(getCredentials(actor)).rejects.toThrow('expired')
115
+ expect(actor.getSnapshot().value).toBe('expired')
116
+ actor.stop()
117
+ })
118
+
119
+ it('fails closed when refresh returns another stale value', async () => {
120
+ const actor = createActor(credentialMachine, {
121
+ input: {
122
+ adapter: {
123
+ load: async () => ({ expiresAt: Date.now() + 1_000 }),
124
+ refresh: async () => ({ expiresAt: Date.now() + 1_000 }),
125
+ },
126
+ },
127
+ }).start()
128
+
129
+ await expect(getCredentials(actor)).rejects.toThrow('too close to expiry')
130
+ expect(actor.getSnapshot().value).toBe('failed')
131
+ actor.stop()
132
+ })
133
+
134
+ it('bounds a hung adapter operation and fails all waiters', async () => {
135
+ const actor = createActor(credentialMachine, {
136
+ input: {
137
+ operationTimeoutMs: 5,
138
+ adapter: { load: () => new Promise(() => {}) },
139
+ },
140
+ }).start()
141
+
142
+ const first = getCredentials(actor)
143
+ const second = getCredentials(actor)
144
+ await expect(first).rejects.toBeInstanceOf(TimeoutError)
145
+ await expect(second).rejects.toBeInstanceOf(TimeoutError)
146
+ await waitFor(actor, (snapshot) => snapshot.matches('failed'))
147
+ actor.stop()
148
+ })
149
+
150
+ it('can reload after a failure', async () => {
151
+ const load = vi
152
+ .fn<() => Promise<{ auth: { type: 'token'; token: string } }>>()
153
+ .mockRejectedValueOnce(new Error('session unavailable'))
154
+ .mockResolvedValueOnce({ auth: { type: 'token', token: 'recovered' } })
155
+ const actor = createActor(credentialMachine, { input: { adapter: { load } } }).start()
156
+ await waitFor(actor, (snapshot) => snapshot.matches('failed'))
157
+
158
+ actor.send({ type: 'CREDENTIALS.RELOAD' })
159
+
160
+ await expect(getCredentials(actor)).resolves.toMatchObject({
161
+ auth: { token: 'recovered' },
162
+ })
163
+ expect(load).toHaveBeenCalledTimes(2)
164
+ actor.stop()
165
+ })
166
+
167
+ it('rejects immediately after the actor is stopped', async () => {
168
+ const actor = createActor(credentialMachine, {
169
+ input: { adapter: { load: async () => ({}) } },
170
+ }).start()
171
+ actor.stop()
172
+
173
+ await expect(getCredentials(actor)).rejects.toThrow('actor is stopped')
174
+ })
175
+ })
@@ -0,0 +1,320 @@
1
+ import { deadline, MsgHdrs } from '@nats-io/nats-core'
2
+ import { ActorRefFrom, assign, fromPromise, setup } from 'xstate'
3
+ import { type AuthConfig } from '../actions/types'
4
+ import { recordCredentialOperation, recordCredentialState, withSpan } from '../telemetry'
5
+
6
+ export interface NatsCredentials {
7
+ auth?: AuthConfig
8
+ requestHeaders?: MsgHdrs
9
+ /** Epoch milliseconds after which these credentials must not be used. Use the earliest expiry. */
10
+ expiresAt?: number
11
+ }
12
+
13
+ export interface NatsCredentialAdapter {
14
+ load: () => Promise<NatsCredentials>
15
+ refresh?: (current: NatsCredentials) => Promise<NatsCredentials>
16
+ }
17
+
18
+ export interface NatsCredentialConfig {
19
+ adapter: NatsCredentialAdapter
20
+ /** Refresh this long before expiry. Defaults to 60 seconds. */
21
+ refreshBeforeExpiryMs?: number
22
+ /** Maximum duration of one adapter load or refresh. Defaults to 30 seconds. */
23
+ operationTimeoutMs?: number
24
+ /** Called when credentials expire or cannot be loaded/refreshed. */
25
+ onUnavailable?: (error: Error) => void
26
+ /** Emit sanitized credential lifecycle debug logs. */
27
+ diagnostics?: boolean
28
+ }
29
+
30
+ type CredentialResult = { ok: true; credentials: NatsCredentials } | { ok: false; error: Error }
31
+
32
+ type Waiter = (result: CredentialResult) => void
33
+
34
+ export interface CredentialContext {
35
+ config: NatsCredentialConfig
36
+ current?: NatsCredentials
37
+ waiters: Waiter[]
38
+ error?: Error
39
+ }
40
+
41
+ type Events = { type: 'CREDENTIALS.GET'; reply: Waiter } | { type: 'CREDENTIALS.RELOAD' }
42
+
43
+ function asError(error: unknown): Error {
44
+ return error instanceof Error ? error : new Error(String(error))
45
+ }
46
+
47
+ function operationTimeout(config: NatsCredentialConfig): number {
48
+ return config.operationTimeoutMs ?? 30_000
49
+ }
50
+
51
+ function refreshBeforeExpiry(config: NatsCredentialConfig): number {
52
+ return config.refreshBeforeExpiryMs ?? 60_000
53
+ }
54
+
55
+ function credentialDiagnostic(
56
+ config: NatsCredentialConfig,
57
+ event: string,
58
+ attributes: Record<string, string | number | boolean>,
59
+ ): void {
60
+ if (config.diagnostics) console.debug('xstate-nats credentials', { event, ...attributes })
61
+ }
62
+
63
+ async function runCredentialOperation<T>(
64
+ config: NatsCredentialConfig,
65
+ operation: 'load' | 'refresh',
66
+ fn: () => Promise<T>,
67
+ ): Promise<T> {
68
+ const timeout = operationTimeout(config)
69
+ const startedAt = performance.now()
70
+ credentialDiagnostic(config, 'operation.start', { operation, timeout_ms: timeout })
71
+ let originalError: unknown
72
+
73
+ try {
74
+ const result = await withSpan(
75
+ `xstate.nats.credentials.${operation}`,
76
+ 'xstate.nats.credentials.error',
77
+ { 'credentials.operation': operation, 'credentials.timeout.ms': timeout },
78
+ async (span) => {
79
+ try {
80
+ const value = await deadline(Promise.resolve().then(fn), timeout)
81
+ span.setAttribute('credentials.outcome', 'success')
82
+ return value
83
+ } catch (error) {
84
+ originalError = error
85
+ span.setAttribute('credentials.outcome', 'error')
86
+ // Keep provider error messages, stacks, tokens, and claims out of traces.
87
+ throw new Error(`NATS credential ${operation} failed`)
88
+ }
89
+ },
90
+ )
91
+ const durationMs = performance.now() - startedAt
92
+ recordCredentialOperation(operation, 'success', durationMs)
93
+ credentialDiagnostic(config, 'operation.finish', {
94
+ operation,
95
+ outcome: 'success',
96
+ duration_ms: durationMs,
97
+ })
98
+ return result
99
+ } catch {
100
+ const durationMs = performance.now() - startedAt
101
+ recordCredentialOperation(operation, 'error', durationMs)
102
+ credentialDiagnostic(config, 'operation.finish', {
103
+ operation,
104
+ outcome: 'error',
105
+ duration_ms: durationMs,
106
+ })
107
+ throw originalError ?? new Error(`NATS credential ${operation} failed`)
108
+ }
109
+ }
110
+
111
+ function credentialStateEntry(state: Parameters<typeof recordCredentialState>[0]) {
112
+ return ({ context }: { context: CredentialContext }) => {
113
+ recordCredentialState(state)
114
+ credentialDiagnostic(context.config, 'state.enter', {
115
+ state,
116
+ pending_waiters: context.waiters.length,
117
+ })
118
+ }
119
+ }
120
+
121
+ function notify(waiter: Waiter, result: CredentialResult): void {
122
+ try {
123
+ waiter(result)
124
+ } catch (error) {
125
+ console.error('NATS credential waiter failed', error)
126
+ }
127
+ }
128
+
129
+ export const credentialMachine = setup({
130
+ types: {
131
+ context: {} as CredentialContext,
132
+ events: {} as Events,
133
+ input: {} as NatsCredentialConfig,
134
+ },
135
+ actors: {
136
+ load: fromPromise(({ input }: { input: NatsCredentialConfig }) =>
137
+ runCredentialOperation(input, 'load', input.adapter.load),
138
+ ),
139
+ refresh: fromPromise(
140
+ ({ input }: { input: { config: NatsCredentialConfig; current: NatsCredentials } }) => {
141
+ const refresh = input.config.adapter.refresh
142
+ if (!refresh) throw new Error('NATS credentials cannot be refreshed')
143
+ return runCredentialOperation(input.config, 'refresh', async () => {
144
+ const credentials = await refresh(input.current)
145
+ const expiresAt = credentials.expiresAt
146
+ if (
147
+ expiresAt !== undefined &&
148
+ expiresAt - refreshBeforeExpiry(input.config) <= Date.now()
149
+ ) {
150
+ throw new Error('Refreshed NATS credentials are already expired or too close to expiry')
151
+ }
152
+ return credentials
153
+ })
154
+ },
155
+ ),
156
+ },
157
+ delays: {
158
+ credentialDeadline: ({ context }) => {
159
+ const expiresAt = context.current?.expiresAt
160
+ if (expiresAt === undefined) return 2_147_483_647
161
+ const advance = context.config.adapter.refresh ? refreshBeforeExpiry(context.config) : 0
162
+ return Math.max(0, expiresAt - advance - Date.now())
163
+ },
164
+ },
165
+ guards: {
166
+ canRefresh: ({ context }) => Boolean(context.current && context.config.adapter.refresh),
167
+ hasExpiry: ({ context }) => context.current?.expiresAt !== undefined,
168
+ shouldRefreshNow: ({ context }) => {
169
+ const expiresAt = context.current?.expiresAt
170
+ return Boolean(
171
+ context.config.adapter.refresh &&
172
+ expiresAt !== undefined &&
173
+ expiresAt - refreshBeforeExpiry(context.config) <= Date.now(),
174
+ )
175
+ },
176
+ isExpired: ({ context }) => {
177
+ const expiresAt = context.current?.expiresAt
178
+ return expiresAt !== undefined && expiresAt <= Date.now()
179
+ },
180
+ outputShouldRefresh: ({ context, event }) => {
181
+ if (!('output' in event) || !context.config.adapter.refresh) return false
182
+ const expiresAt = (event.output as NatsCredentials).expiresAt
183
+ return (
184
+ expiresAt !== undefined && expiresAt - refreshBeforeExpiry(context.config) <= Date.now()
185
+ )
186
+ },
187
+ outputExpired: ({ event }) => {
188
+ if (!('output' in event)) return false
189
+ const expiresAt = (event.output as NatsCredentials).expiresAt
190
+ return expiresAt !== undefined && expiresAt <= Date.now()
191
+ },
192
+ },
193
+ actions: {
194
+ queue: assign({
195
+ waiters: ({ context, event }) =>
196
+ event.type === 'CREDENTIALS.GET' ? [...context.waiters, event.reply] : context.waiters,
197
+ }),
198
+ replyReady: ({ context, event }) => {
199
+ if (event.type === 'CREDENTIALS.GET' && context.current) {
200
+ notify(event.reply, { ok: true, credentials: context.current })
201
+ }
202
+ },
203
+ acceptCredentials: assign(({ context, event }) => {
204
+ if (!('output' in event)) return {}
205
+ const credentials = event.output as NatsCredentials
206
+ context.waiters.forEach((waiter) => notify(waiter, { ok: true, credentials }))
207
+ return { current: credentials, waiters: [], error: undefined }
208
+ }),
209
+ storeCredentials: assign(({ event }) =>
210
+ 'output' in event ? { current: event.output as NatsCredentials, error: undefined } : {},
211
+ ),
212
+ rejectWaiters: assign(({ context, event }) => {
213
+ const error =
214
+ 'error' in event
215
+ ? asError(event.error)
216
+ : (context.error ?? new Error('NATS credentials are unavailable'))
217
+ context.waiters.forEach((waiter) => notify(waiter, { ok: false, error }))
218
+ return { waiters: [], error }
219
+ }),
220
+ markExpired: assign({
221
+ error: (_) => new Error('NATS credentials expired'),
222
+ }),
223
+ notifyUnavailable: ({ context }) => {
224
+ const error = context.error ?? new Error('NATS credentials are unavailable')
225
+ try {
226
+ context.config.onUnavailable?.(error)
227
+ } catch (callbackError) {
228
+ console.error('NATS credential unavailable callback failed', callbackError)
229
+ }
230
+ },
231
+ },
232
+ }).createMachine({
233
+ id: 'credentials',
234
+ initial: 'loading',
235
+ context: ({ input }) => ({ config: input, waiters: [] }),
236
+ states: {
237
+ loading: {
238
+ entry: credentialStateEntry('loading'),
239
+ on: {
240
+ 'CREDENTIALS.GET': { actions: 'queue' },
241
+ },
242
+ invoke: {
243
+ src: 'load',
244
+ input: ({ context }) => context.config,
245
+ onDone: [
246
+ { guard: 'outputShouldRefresh', target: 'refreshing', actions: 'storeCredentials' },
247
+ { guard: 'outputExpired', target: 'expired', actions: 'storeCredentials' },
248
+ { target: 'ready', actions: 'acceptCredentials' },
249
+ ],
250
+ onError: { target: 'failed', actions: 'rejectWaiters' },
251
+ },
252
+ },
253
+ ready: {
254
+ entry: credentialStateEntry('ready'),
255
+ after: {
256
+ credentialDeadline: [
257
+ { guard: 'canRefresh', target: 'refreshing' },
258
+ { guard: 'hasExpiry', target: 'expired' },
259
+ ],
260
+ },
261
+ on: {
262
+ 'CREDENTIALS.GET': [
263
+ { guard: 'shouldRefreshNow', target: 'refreshing', actions: 'queue' },
264
+ { guard: 'isExpired', target: 'expired', actions: 'queue' },
265
+ { actions: 'replyReady' },
266
+ ],
267
+ 'CREDENTIALS.RELOAD': { target: 'loading' },
268
+ },
269
+ },
270
+ refreshing: {
271
+ entry: credentialStateEntry('refreshing'),
272
+ on: {
273
+ 'CREDENTIALS.GET': { actions: 'queue' },
274
+ },
275
+ invoke: {
276
+ src: 'refresh',
277
+ input: ({ context }) => ({ config: context.config, current: context.current! }),
278
+ onDone: { target: 'ready', actions: 'acceptCredentials' },
279
+ onError: { target: 'failed', actions: 'rejectWaiters' },
280
+ },
281
+ },
282
+ expired: {
283
+ entry: [credentialStateEntry('expired'), 'markExpired', 'rejectWaiters', 'notifyUnavailable'],
284
+ on: {
285
+ 'CREDENTIALS.GET': {
286
+ actions: ({ event }) =>
287
+ notify(event.reply, { ok: false, error: new Error('NATS credentials expired') }),
288
+ },
289
+ 'CREDENTIALS.RELOAD': { target: 'loading' },
290
+ },
291
+ },
292
+ failed: {
293
+ entry: [credentialStateEntry('failed'), 'notifyUnavailable'],
294
+ on: {
295
+ 'CREDENTIALS.GET': {
296
+ actions: ({ context, event }) =>
297
+ notify(event.reply, {
298
+ ok: false,
299
+ error: context.error ?? new Error('NATS credentials are unavailable'),
300
+ }),
301
+ },
302
+ 'CREDENTIALS.RELOAD': { target: 'loading' },
303
+ },
304
+ },
305
+ },
306
+ })
307
+
308
+ export type CredentialActor = ActorRefFrom<typeof credentialMachine>
309
+
310
+ export function getCredentials(actor: CredentialActor): Promise<NatsCredentials> {
311
+ if (actor.getSnapshot().status === 'stopped') {
312
+ return Promise.reject(new Error('NATS credential actor is stopped'))
313
+ }
314
+ return new Promise((resolve, reject) => {
315
+ actor.send({
316
+ type: 'CREDENTIALS.GET',
317
+ reply: (result) => (result.ok ? resolve(result.credentials) : reject(result.error)),
318
+ })
319
+ })
320
+ }
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
2
  import { createActor, fromPromise, createMachine, sendParent } from 'xstate'
3
3
  import { natsMachine } from './root'
4
4
 
5
+ const subjectEventSpy = vi.fn()
6
+
5
7
  vi.mock('@nats-io/nats-core', async () => {
6
8
  const actual = await vi.importActual<typeof import('@nats-io/nats-core')>('@nats-io/nats-core')
7
9
  return {
@@ -29,7 +31,7 @@ const mockSubjectMachine = createMachine({
29
31
  connected: {
30
32
  entry: sendParent({ type: 'SUBJECT.CONNECTED' }),
31
33
  on: {
32
- 'SUBJECT.*': {},
34
+ 'SUBJECT.*': { actions: ({ event }) => subjectEventSpy(event) },
33
35
  'SUBJECT.DISCONNECTED': { target: 'idle' },
34
36
  },
35
37
  },
@@ -137,6 +139,7 @@ function configureAndConnect(actor: any) {
137
139
 
138
140
  describe('natsMachine', () => {
139
141
  beforeEach(() => {
142
+ subjectEventSpy.mockClear()
140
143
  vi.spyOn(console, 'log').mockImplementation(() => {})
141
144
  vi.spyOn(console, 'error').mockImplementation(() => {})
142
145
  })
@@ -179,6 +182,90 @@ describe('natsMachine', () => {
179
182
  actor.stop()
180
183
  })
181
184
 
185
+ it('forwards current credential headers to the subject manager', async () => {
186
+ const { headers } = await import('@nats-io/nats-core')
187
+ const requestHeaders = headers()
188
+ requestHeaders.set('authorization', 'Bearer current')
189
+ const actor = createActor(createTestMachine())
190
+ actor.start()
191
+ actor.send({
192
+ type: 'CONFIGURE',
193
+ config: {
194
+ opts: { servers: ['ws://localhost:4222'] },
195
+ maxRetries: 3,
196
+ credentials: {
197
+ adapter: { load: vi.fn(async () => ({ requestHeaders })) },
198
+ },
199
+ },
200
+ })
201
+ actor.send({ type: 'CONNECT' })
202
+ await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('connected'))
203
+
204
+ actor.send({
205
+ type: 'SUBJECT.REQUEST',
206
+ subject: 'test.request',
207
+ payload: {},
208
+ callback: vi.fn(),
209
+ })
210
+
211
+ await vi.waitFor(() => {
212
+ expect(subjectEventSpy).toHaveBeenCalledWith(
213
+ expect.objectContaining({
214
+ requestHeaders: expect.any(Function),
215
+ type: 'SUBJECT.REQUEST',
216
+ }),
217
+ )
218
+ })
219
+ const forwarded = subjectEventSpy.mock.calls.at(-1)?.[0]
220
+ await expect(forwarded.requestHeaders()).resolves.toBe(requestHeaders)
221
+ actor.stop()
222
+ })
223
+
224
+ it('closes an active connection when credential refresh fails', async () => {
225
+ const actor = createActor(createTestMachine()).start()
226
+ actor.send({
227
+ type: 'CONFIGURE',
228
+ config: {
229
+ opts: { servers: ['ws://localhost:4222'] },
230
+ maxRetries: 3,
231
+ credentials: {
232
+ refreshBeforeExpiryMs: 5,
233
+ adapter: {
234
+ load: async () => ({ expiresAt: Date.now() + 100 }),
235
+ refresh: async () => {
236
+ throw new Error('refresh failed')
237
+ },
238
+ },
239
+ },
240
+ },
241
+ })
242
+ actor.send({ type: 'CONNECT' })
243
+ await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('connected'))
244
+
245
+ await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('closed'))
246
+ actor.stop()
247
+ })
248
+
249
+ it('fails requests immediately while disconnected', () => {
250
+ const actor = createActor(createTestMachine())
251
+ const onRequestResult = vi.fn()
252
+ actor.start()
253
+
254
+ actor.send({
255
+ type: 'SUBJECT.REQUEST',
256
+ subject: 'test.request',
257
+ payload: {},
258
+ callback: vi.fn(),
259
+ onRequestResult,
260
+ })
261
+
262
+ expect(onRequestResult).toHaveBeenCalledWith({
263
+ ok: false,
264
+ error: expect.objectContaining({ message: 'NATS connection is not available' }),
265
+ })
266
+ actor.stop()
267
+ })
268
+
182
269
  it('should transition to closed on DISCONNECT from connected', async () => {
183
270
  const actor = createActor(createTestMachine())
184
271
  actor.start()