@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.
- package/README.md +75 -10
- package/dist/actions/connection.d.ts +2 -0
- package/dist/actions/connection.d.ts.map +1 -1
- package/dist/actions/connection.js +20 -9
- package/dist/actions/connection.js.map +1 -1
- package/dist/actions/subject.d.ts +5 -1
- package/dist/actions/subject.d.ts.map +1 -1
- package/dist/actions/subject.js +60 -16
- package/dist/actions/subject.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/machines/credentials.d.ts +112 -0
- package/dist/machines/credentials.d.ts.map +1 -0
- package/dist/machines/credentials.js +252 -0
- package/dist/machines/credentials.js.map +1 -0
- package/dist/machines/root.d.ts +190 -3
- package/dist/machines/root.d.ts.map +1 -1
- package/dist/machines/root.js +107 -13
- package/dist/machines/root.js.map +1 -1
- package/dist/machines/subject.d.ts +7 -2
- package/dist/machines/subject.d.ts.map +1 -1
- package/dist/machines/subject.js +11 -1
- package/dist/machines/subject.js.map +1 -1
- package/dist/telemetry.d.ts +2 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +18 -1
- package/dist/telemetry.js.map +1 -1
- package/package.json +17 -19
- package/src/actions/connection.test.ts +62 -7
- package/src/actions/connection.ts +28 -8
- package/src/actions/subject.test.ts +120 -0
- package/src/actions/subject.ts +79 -19
- package/src/index.ts +9 -0
- package/src/machines/credentials.telemetry.test.ts +66 -0
- package/src/machines/credentials.test.ts +175 -0
- package/src/machines/credentials.ts +320 -0
- package/src/machines/root.test.ts +88 -1
- package/src/machines/root.ts +115 -13
- package/src/machines/subject.test.ts +60 -0
- package/src/machines/subject.ts +18 -1
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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(
|
|
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'))
|
package/src/actions/subject.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
})
|