@jr200-labs/xstate-nats 0.9.21 → 0.9.22

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 +1 -1
  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
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest'
2
- import { createActor } from 'xstate'
2
+ import { createActor, waitFor } from 'xstate'
3
3
  import { parseNatsResult, connectToNats, disconnectNats } from './connection'
4
+ import { credentialMachine } from '../machines/credentials'
4
5
 
5
6
  vi.mock('@nats-io/nats-core', async () => {
6
7
  const actual = await vi.importActual('@nats-io/nats-core')
@@ -8,6 +9,8 @@ vi.mock('@nats-io/nats-core', async () => {
8
9
  ...actual,
9
10
  wsconnect: vi.fn(),
10
11
  credsAuthenticator: vi.fn((_creds: Uint8Array) => 'mock-authenticator'),
12
+ usernamePasswordAuthenticator: vi.fn(() => 'mock-userpass-authenticator'),
13
+ tokenAuthenticator: vi.fn(() => 'mock-token-authenticator'),
11
14
  }
12
15
  })
13
16
 
@@ -44,6 +47,7 @@ describe('parseNatsResult', () => {
44
47
  describe('connectToNats', () => {
45
48
  beforeEach(() => {
46
49
  vi.restoreAllMocks()
50
+ vi.clearAllMocks()
47
51
  })
48
52
 
49
53
  it('should call wsconnect with opts and no auth', async () => {
@@ -103,8 +107,7 @@ describe('connectToNats', () => {
103
107
 
104
108
  expect(wsconnect).toHaveBeenCalledWith({
105
109
  servers: ['ws://localhost:4222'],
106
- user: 'admin',
107
- pass: 'secret',
110
+ authenticator: 'mock-userpass-authenticator',
108
111
  })
109
112
  })
110
113
 
@@ -137,10 +140,64 @@ describe('connectToNats', () => {
137
140
 
138
141
  expect(wsconnect).toHaveBeenCalledWith({
139
142
  servers: ['ws://localhost:4222'],
140
- token: 'my-token',
143
+ authenticator: 'mock-token-authenticator',
141
144
  })
142
145
  })
143
146
 
147
+ it('uses the latest actor credential for reconnect authentication', async () => {
148
+ const { wsconnect, usernamePasswordAuthenticator } = await import('@nats-io/nats-core')
149
+ const expiresAt = Date.now() + 3_600_000
150
+ const mockConnection = {
151
+ status: () => ({
152
+ [Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}) }),
153
+ }),
154
+ }
155
+ vi.mocked(wsconnect).mockResolvedValue(mockConnection as any)
156
+ const load = vi
157
+ .fn()
158
+ .mockResolvedValueOnce({
159
+ expiresAt,
160
+ auth: {
161
+ type: 'decentralised',
162
+ sentinelB64: btoa('sentinel'),
163
+ user: 'user',
164
+ pass: 'first',
165
+ },
166
+ })
167
+ .mockResolvedValueOnce({
168
+ expiresAt,
169
+ auth: {
170
+ type: 'decentralised',
171
+ sentinelB64: btoa('sentinel'),
172
+ user: 'user',
173
+ pass: 'second',
174
+ },
175
+ })
176
+ const credentialActor = createActor(credentialMachine, {
177
+ input: { adapter: { load } },
178
+ }).start()
179
+ const actor = createActor(connectToNats, {
180
+ input: {
181
+ opts: { servers: ['ws://localhost:4222'] },
182
+ credentialActor,
183
+ },
184
+ }).start()
185
+
186
+ await waitFor(actor, (snapshot) => snapshot.status === 'done')
187
+ const password = vi.mocked(usernamePasswordAuthenticator).mock.calls[0][1]
188
+ expect(typeof password).toBe('function')
189
+ expect((password as () => string)()).toBe('first')
190
+
191
+ credentialActor.send({ type: 'CREDENTIALS.RELOAD' })
192
+ await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(2))
193
+ await vi.waitFor(() => expect(credentialActor.getSnapshot().value).toBe('ready'))
194
+ expect((password as () => string)()).toBe('second')
195
+ const now = vi.spyOn(Date, 'now').mockReturnValue(expiresAt + 1)
196
+ expect(() => (password as () => string)()).toThrow('expired')
197
+ now.mockRestore()
198
+ credentialActor.stop()
199
+ })
200
+
144
201
  it('should merge decentralised auth config', async () => {
145
202
  const { wsconnect, credsAuthenticator } = await import('@nats-io/nats-core')
146
203
  const mockConnection = {
@@ -177,9 +234,7 @@ describe('connectToNats', () => {
177
234
  expect(wsconnect).toHaveBeenCalledWith(
178
235
  expect.objectContaining({
179
236
  servers: ['ws://localhost:4222'],
180
- authenticator: 'mock-authenticator',
181
- user: 'nkey-user',
182
- pass: 'nkey-pass',
237
+ authenticator: ['mock-authenticator', 'mock-userpass-authenticator'],
183
238
  }),
184
239
  )
185
240
  })
@@ -5,13 +5,16 @@ import {
5
5
  Msg,
6
6
  NatsConnection,
7
7
  Status,
8
+ tokenAuthenticator,
9
+ usernamePasswordAuthenticator,
8
10
  wsconnect,
9
11
  } from '@nats-io/nats-core'
10
12
  import { KvEntry } from '@nats-io/kv'
11
13
  import { type AuthConfig } from './types'
12
14
  import { withSpan } from '../telemetry'
15
+ import { CredentialActor, getCredentials } from '../machines/credentials'
13
16
 
14
- const makeAuthConfig = (auth?: AuthConfig) => {
17
+ const makeAuthConfig = (auth?: AuthConfig, getLatest?: () => AuthConfig | undefined) => {
15
18
  if (!auth) {
16
19
  return {}
17
20
  }
@@ -19,18 +22,24 @@ const makeAuthConfig = (auth?: AuthConfig) => {
19
22
  if (auth.type === 'decentralised') {
20
23
  const decodedSentinel = atob(auth!.sentinelB64!)
21
24
  return {
22
- authenticator: credsAuthenticator(new TextEncoder().encode(decodedSentinel)),
23
- user: auth.user,
24
- pass: auth.pass,
25
+ authenticator: [
26
+ credsAuthenticator(new TextEncoder().encode(decodedSentinel)),
27
+ usernamePasswordAuthenticator(
28
+ () => getLatest?.()?.user ?? auth.user!,
29
+ () => getLatest?.()?.pass ?? auth.pass!,
30
+ ),
31
+ ],
25
32
  }
26
33
  } else if (auth.type === 'userpass') {
27
34
  return {
28
- user: auth.user,
29
- pass: auth.pass,
35
+ authenticator: usernamePasswordAuthenticator(
36
+ () => getLatest?.()?.user ?? auth.user!,
37
+ () => getLatest?.()?.pass ?? auth.pass!,
38
+ ),
30
39
  }
31
40
  } else if (auth.type === 'token') {
32
41
  return {
33
- token: auth.token,
42
+ authenticator: tokenAuthenticator(() => getLatest?.()?.token ?? auth.token!),
34
43
  }
35
44
  }
36
45
 
@@ -51,12 +60,23 @@ export const connectToNats = fromPromise(
51
60
  input: {
52
61
  opts: ConnectionOptions
53
62
  auth?: AuthConfig
63
+ credentialActor?: CredentialActor
54
64
  onStatus?: (event: InternalStatusEvents) => void
55
65
  }
56
66
  }): Promise<NatsConnection> => {
67
+ const credentials = input.credentialActor
68
+ ? await getCredentials(input.credentialActor)
69
+ : undefined
70
+ const auth = credentials?.auth ?? input.auth
57
71
  const mergedOpts: ConnectionOptions = {
58
72
  ...input.opts,
59
- ...makeAuthConfig(input.auth),
73
+ ...makeAuthConfig(auth, () => {
74
+ const current = input.credentialActor?.getSnapshot().context.current
75
+ if (current?.expiresAt !== undefined && current.expiresAt <= Date.now()) {
76
+ throw new Error('NATS credentials expired')
77
+ }
78
+ return current?.auth
79
+ }),
60
80
  }
61
81
  const debug = Boolean(mergedOpts.debug)
62
82
  const nc = await wsconnect(mergedOpts)
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { headers, TimeoutError } from '@nats-io/nats-core'
2
3
  import { subjectConsolidateState, subjectRequest, subjectPublish } from './subject'
3
4
  import type { SubjectSubscriptionConfig } from './subject'
4
5
 
@@ -414,6 +415,125 @@ describe('subjectRequest', () => {
414
415
  expect(callback).not.toHaveBeenCalled()
415
416
  })
416
417
 
418
+ it('reports synchronous connection failures', async () => {
419
+ const connection = createMockConnection()
420
+ const requestError = new Error('connection closed')
421
+ connection.request.mockImplementation(() => {
422
+ throw requestError
423
+ })
424
+ const onRequestResult = vi.fn()
425
+
426
+ subjectRequest({
427
+ input: {
428
+ connection,
429
+ subject: 'test.closed',
430
+ payload: {},
431
+ callback: vi.fn(),
432
+ onRequestResult,
433
+ },
434
+ })
435
+
436
+ await vi.waitFor(() => {
437
+ expect(onRequestResult).toHaveBeenCalledWith({ ok: false, error: requestError })
438
+ })
439
+ })
440
+
441
+ it('settles once when the response callback fails', async () => {
442
+ const connection = createMockConnection()
443
+ connection.request.mockResolvedValue({ json: () => ({}), string: () => '{}' })
444
+ const callbackError = new Error('response callback failed')
445
+ const onRequestResult = vi.fn()
446
+
447
+ subjectRequest({
448
+ input: {
449
+ connection,
450
+ subject: 'test.callback-fail',
451
+ payload: {},
452
+ callback: () => {
453
+ throw callbackError
454
+ },
455
+ onRequestResult,
456
+ },
457
+ })
458
+
459
+ await vi.waitFor(() => {
460
+ expect(onRequestResult).toHaveBeenCalledWith({ ok: false, error: callbackError })
461
+ })
462
+ expect(onRequestResult).toHaveBeenCalledTimes(1)
463
+ })
464
+
465
+ it('reports request-header provider failures without sending', async () => {
466
+ const connection = createMockConnection()
467
+ const providerError = new Error('token refresh failed')
468
+ const onRequestResult = vi.fn()
469
+
470
+ subjectRequest({
471
+ input: {
472
+ connection,
473
+ subject: 'test.auth-fail',
474
+ payload: {},
475
+ callback: vi.fn(),
476
+ onRequestResult,
477
+ requestHeaders: () => {
478
+ throw providerError
479
+ },
480
+ },
481
+ })
482
+
483
+ await vi.waitFor(() => {
484
+ expect(onRequestResult).toHaveBeenCalledWith({ ok: false, error: providerError })
485
+ })
486
+ expect(connection.request).not.toHaveBeenCalled()
487
+ })
488
+
489
+ it('uses one timeout for request headers and transport', async () => {
490
+ const connection = createMockConnection()
491
+ const onRequestResult = vi.fn()
492
+
493
+ subjectRequest({
494
+ input: {
495
+ connection,
496
+ subject: 'test.auth-timeout',
497
+ payload: {},
498
+ opts: { timeout: 10 } as any,
499
+ callback: vi.fn(),
500
+ onRequestResult,
501
+ requestHeaders: () => new Promise(() => {}),
502
+ },
503
+ })
504
+
505
+ await vi.waitFor(() => {
506
+ expect(onRequestResult).toHaveBeenCalledWith({
507
+ ok: false,
508
+ error: expect.any(TimeoutError),
509
+ })
510
+ })
511
+ expect(connection.request).not.toHaveBeenCalled()
512
+ })
513
+
514
+ it('passes only the remaining timeout to transport', async () => {
515
+ const connection = createMockConnection()
516
+ connection.request.mockResolvedValue({ json: () => ({}), string: () => '{}' })
517
+ const requestHeaders = headers()
518
+ requestHeaders.set('authorization', 'Bearer token')
519
+ const now = vi.spyOn(performance, 'now').mockReturnValueOnce(100).mockReturnValueOnce(125)
520
+
521
+ subjectRequest({
522
+ input: {
523
+ connection,
524
+ subject: 'test.remaining-timeout',
525
+ payload: {},
526
+ opts: { timeout: 100 } as any,
527
+ callback: vi.fn(),
528
+ requestHeaders: async () => requestHeaders,
529
+ },
530
+ })
531
+
532
+ await vi.waitFor(() => expect(connection.request).toHaveBeenCalled())
533
+ expect(connection.request.mock.calls[0][2].timeout).toBe(75)
534
+ now.mockRestore()
535
+ })
536
+
417
537
  it('should log request errors for callers without onRequestResult', async () => {
418
538
  const connection = createMockConnection()
419
539
  connection.request.mockRejectedValue(new Error('request failed'))
@@ -1,10 +1,13 @@
1
1
  import {
2
+ deadline,
2
3
  Msg,
4
+ MsgHdrs,
3
5
  NatsConnection,
4
6
  PublishOptions,
5
7
  RequestOptions,
6
8
  Subscription,
7
9
  SubscriptionOptions,
10
+ TimeoutError,
8
11
  } from '@nats-io/nats-core'
9
12
  import { parseNatsResult } from './connection'
10
13
  import {
@@ -24,6 +27,25 @@ export type SubjectSubscriptionConfig = {
24
27
 
25
28
  export type RequestResult = { ok: true } | { ok: false; error: Error }
26
29
 
30
+ function notifyRequestResult(
31
+ callback: ((result: RequestResult) => void) | undefined,
32
+ result: RequestResult,
33
+ ) {
34
+ try {
35
+ callback?.(result)
36
+ } catch (error) {
37
+ console.error('NATS request result callback failed', error)
38
+ }
39
+ }
40
+
41
+ export function rejectUnavailableRequest(event: {
42
+ onRequestResult?: (result: RequestResult) => void
43
+ }) {
44
+ const error = new Error('NATS connection is not available')
45
+ notifyRequestResult(event.onRequestResult, { ok: false, error })
46
+ if (!event.onRequestResult) console.error(error.message)
47
+ }
48
+
27
49
  export const subjectConsolidateState = ({
28
50
  input,
29
51
  }: {
@@ -122,9 +144,19 @@ export const subjectRequest = ({
122
144
  callback: (data: any) => void
123
145
  onRequestResult?: (result: RequestResult) => void
124
146
  onDownloadBytes?: (bytes: number) => void
147
+ requestHeaders?: () => Promise<MsgHdrs | undefined>
125
148
  }
126
149
  }) => {
127
- const { connection, subject, payload, opts, callback, onRequestResult, onDownloadBytes } = input
150
+ const {
151
+ connection,
152
+ subject,
153
+ payload,
154
+ opts,
155
+ callback,
156
+ onRequestResult,
157
+ onDownloadBytes,
158
+ requestHeaders,
159
+ } = input
128
160
  if (!connection) {
129
161
  throw new Error('NATS connection is not available')
130
162
  }
@@ -145,27 +177,55 @@ export const subjectRequest = ({
145
177
  // declares `timeout` as required but nats-core only enforces it when
146
178
  // opts is provided; cast preserves the "no opts = use conn default"
147
179
  // contract.
148
- const headers = injectContextIntoHeaders(opts?.headers)
149
- const requestOpts = (opts ? { ...opts, headers } : { headers }) as RequestOptions
180
+ const request = (providedHeaders?: MsgHdrs, timeout = opts?.timeout) => {
181
+ const headers = injectContextIntoHeaders(opts?.headers)
182
+ providedHeaders?.keys().forEach((key) => {
183
+ const value = providedHeaders.get(key)
184
+ if (value) headers.set(key, value)
185
+ })
186
+ const requestOpts = (opts ? { ...opts, headers } : { headers }) as RequestOptions
187
+ if (timeout !== undefined) requestOpts.timeout = timeout
150
188
 
151
- return connection
152
- .request(subject, payload, requestOpts)
153
- .then((msg: Msg) => {
189
+ try {
190
+ return connection.request(subject, payload, requestOpts)
191
+ } catch (error) {
192
+ return Promise.reject(error)
193
+ }
194
+ }
195
+
196
+ const timeout = opts?.timeout
197
+ const startedAt = performance.now()
198
+ const preparedHeaders = requestHeaders ? Promise.resolve().then(requestHeaders) : undefined
199
+ const send = preparedHeaders
200
+ ? (timeout === undefined ? preparedHeaders : deadline(preparedHeaders, timeout)).then(
201
+ (headers) => {
202
+ if (timeout === undefined) return request(headers)
203
+ const remaining = Math.floor(timeout - (performance.now() - startedAt))
204
+ if (remaining <= 0) throw new TimeoutError()
205
+ return request(headers, remaining)
206
+ },
207
+ )
208
+ : request()
209
+
210
+ const fail = (err: unknown) => {
211
+ recordError(span, 'xstate.nats.error', err)
212
+ const error = err instanceof Error ? err : new Error(String(err))
213
+ notifyRequestResult(onRequestResult, { ok: false, error })
214
+ if (!onRequestResult) {
215
+ console.error(`RequestReply error for subject "${subject}"`, err)
216
+ }
217
+ }
218
+
219
+ return send.then((msg: Msg) => {
220
+ try {
154
221
  onDownloadBytes?.(msg.data?.length ?? 0)
155
222
  callback(parseNatsResult(msg))
156
- onRequestResult?.({ ok: true })
157
- })
158
- .catch((err) => {
159
- // Record on span manually so we can swallow the rejection here —
160
- // the original fire-and-forget API didn't propagate request errors
161
- // to callers and changing that now would be a breaking behaviour.
162
- recordError(span, 'xstate.nats.error', err)
163
- const error = err instanceof Error ? err : new Error(String(err))
164
- onRequestResult?.({ ok: false, error })
165
- if (!onRequestResult) {
166
- console.error(`RequestReply error for subject "${subject}"`, err)
167
- }
168
- })
223
+ } catch (error) {
224
+ fail(error)
225
+ return
226
+ }
227
+ notifyRequestResult(onRequestResult, { ok: true })
228
+ }, fail)
169
229
  },
170
230
  )
171
231
  }
package/src/index.ts CHANGED
@@ -12,6 +12,15 @@ export {
12
12
  export { KvSubscriptionKey, type KvSubscriptionConfig } from './actions/kv'
13
13
  export { parseNatsResult } from './actions/connection'
14
14
  export { type AuthConfig } from './actions/types'
15
+ export {
16
+ credentialMachine,
17
+ getCredentials,
18
+ type CredentialActor,
19
+ type CredentialContext,
20
+ type NatsCredentialAdapter,
21
+ type NatsCredentialConfig,
22
+ type NatsCredentials,
23
+ } from './machines/credentials'
15
24
  export {
16
25
  byteLength as natsTrafficByteLength,
17
26
  createEmptyTrafficMetrics,
@@ -0,0 +1,66 @@
1
+ import { SpanStatusCode } from '@opentelemetry/api'
2
+ import { createActor, waitFor } from 'xstate'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { setupInMemoryTracer, type OtelTestHarness } from '../../test/otel-setup'
5
+ import * as telemetry from '../telemetry'
6
+ import { credentialMachine, getCredentials } from './credentials'
7
+
8
+ vi.mock('../telemetry', async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import('../telemetry')>()
10
+ return {
11
+ ...actual,
12
+ recordCredentialOperation: vi.fn(actual.recordCredentialOperation),
13
+ recordCredentialState: vi.fn(actual.recordCredentialState),
14
+ }
15
+ })
16
+
17
+ describe('credential telemetry', () => {
18
+ let harness: OtelTestHarness
19
+
20
+ beforeEach(() => {
21
+ harness = setupInMemoryTracer()
22
+ vi.mocked(telemetry.recordCredentialOperation).mockClear()
23
+ vi.mocked(telemetry.recordCredentialState).mockClear()
24
+ })
25
+
26
+ afterEach(() => {
27
+ harness.teardown()
28
+ vi.restoreAllMocks()
29
+ })
30
+
31
+ it('records sanitized operation telemetry and diagnostics', async () => {
32
+ const debug = vi.spyOn(console, 'debug').mockImplementation(() => {})
33
+ const secret = 'secret-token-value'
34
+ const actor = createActor(credentialMachine, {
35
+ input: {
36
+ diagnostics: true,
37
+ adapter: {
38
+ load: async () => {
39
+ throw new Error(`provider rejected ${secret}`)
40
+ },
41
+ },
42
+ },
43
+ }).start()
44
+
45
+ await expect(getCredentials(actor)).rejects.toThrow(secret)
46
+ await waitFor(actor, (snapshot) => snapshot.matches('failed'))
47
+
48
+ expect(telemetry.recordCredentialOperation).toHaveBeenCalledWith(
49
+ 'load',
50
+ 'error',
51
+ expect.any(Number),
52
+ )
53
+ expect(telemetry.recordCredentialState).toHaveBeenCalledWith('loading')
54
+ expect(telemetry.recordCredentialState).toHaveBeenCalledWith('failed')
55
+ expect(JSON.stringify(debug.mock.calls)).not.toContain(secret)
56
+
57
+ const span = harness.exporter
58
+ .getFinishedSpans()
59
+ .find((candidate) => candidate.name === 'xstate.nats.credentials.load')
60
+ expect(span?.status.code).toBe(SpanStatusCode.ERROR)
61
+ expect(
62
+ JSON.stringify({ status: span?.status, attributes: span?.attributes, events: span?.events }),
63
+ ).not.toContain(secret)
64
+ actor.stop()
65
+ })
66
+ })