@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
|
@@ -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()
|