@jr200-labs/xstate-nats 0.6.0

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/dist/actions/connection.d.ts +28 -0
  4. package/dist/actions/connection.d.ts.map +1 -0
  5. package/dist/actions/connection.js +102 -0
  6. package/dist/actions/connection.js.map +1 -0
  7. package/dist/actions/kv.d.ts +21 -0
  8. package/dist/actions/kv.d.ts.map +1 -0
  9. package/dist/actions/kv.js +66 -0
  10. package/dist/actions/kv.js.map +1 -0
  11. package/dist/actions/subject.d.ts +39 -0
  12. package/dist/actions/subject.d.ts.map +1 -0
  13. package/dist/actions/subject.js +79 -0
  14. package/dist/actions/subject.js.map +1 -0
  15. package/dist/actions/types.d.ts +8 -0
  16. package/dist/actions/types.d.ts.map +1 -0
  17. package/dist/actions/types.js +2 -0
  18. package/dist/actions/types.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +6 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/machines/kv.d.ts +190 -0
  24. package/dist/machines/kv.d.ts.map +1 -0
  25. package/dist/machines/kv.js +273 -0
  26. package/dist/machines/kv.js.map +1 -0
  27. package/dist/machines/root.d.ts +510 -0
  28. package/dist/machines/root.d.ts.map +1 -0
  29. package/dist/machines/root.js +245 -0
  30. package/dist/machines/root.js.map +1 -0
  31. package/dist/machines/subject.d.ts +95 -0
  32. package/dist/machines/subject.d.ts.map +1 -0
  33. package/dist/machines/subject.js +162 -0
  34. package/dist/machines/subject.js.map +1 -0
  35. package/dist/utils.d.ts +10 -0
  36. package/dist/utils.d.ts.map +1 -0
  37. package/dist/utils.js +27 -0
  38. package/dist/utils.js.map +1 -0
  39. package/package.json +55 -0
  40. package/src/actions/connection.test.ts +324 -0
  41. package/src/actions/connection.ts +135 -0
  42. package/src/actions/kv.test.ts +439 -0
  43. package/src/actions/kv.ts +92 -0
  44. package/src/actions/subject.test.ts +460 -0
  45. package/src/actions/subject.ts +127 -0
  46. package/src/actions/types.ts +7 -0
  47. package/src/index.ts +20 -0
  48. package/src/machines/kv.test.ts +720 -0
  49. package/src/machines/kv.ts +327 -0
  50. package/src/machines/root.test.ts +329 -0
  51. package/src/machines/root.ts +286 -0
  52. package/src/machines/subject.test.ts +272 -0
  53. package/src/machines/subject.ts +205 -0
  54. package/src/utils.test.ts +35 -0
  55. package/src/utils.ts +30 -0
@@ -0,0 +1,324 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { createActor } from 'xstate'
3
+ import { parseNatsResult, connectToNats, disconnectNats } from './connection'
4
+
5
+ vi.mock('@nats-io/nats-core', async () => {
6
+ const actual = await vi.importActual('@nats-io/nats-core')
7
+ return {
8
+ ...actual,
9
+ wsconnect: vi.fn(),
10
+ credsAuthenticator: vi.fn((_creds: Uint8Array) => 'mock-authenticator'),
11
+ }
12
+ })
13
+
14
+ describe('parseNatsResult', () => {
15
+ it('should return null for null input', () => {
16
+ expect(parseNatsResult(null)).toBeNull()
17
+ })
18
+
19
+ it('should return the Error for Error input', () => {
20
+ const error = new Error('test error')
21
+ expect(parseNatsResult(error)).toBe(error)
22
+ })
23
+
24
+ it('should parse JSON message', () => {
25
+ const data = { foo: 'bar', num: 42 }
26
+ const msg = {
27
+ json: () => data,
28
+ string: () => JSON.stringify(data),
29
+ } as any
30
+ expect(parseNatsResult(msg)).toEqual(data)
31
+ })
32
+
33
+ it('should fall back to string when JSON parsing fails', () => {
34
+ const msg = {
35
+ json: () => {
36
+ throw new Error('not json')
37
+ },
38
+ string: () => 'plain text',
39
+ } as any
40
+ expect(parseNatsResult(msg)).toBe('plain text')
41
+ })
42
+ })
43
+
44
+ describe('connectToNats', () => {
45
+ beforeEach(() => {
46
+ vi.restoreAllMocks()
47
+ })
48
+
49
+ it('should call wsconnect with opts and no auth', async () => {
50
+ const { wsconnect } = await import('@nats-io/nats-core')
51
+ const mockConnection = {
52
+ status: () => ({
53
+ [Symbol.asyncIterator]: () => ({
54
+ next: () => new Promise(() => {}),
55
+ }),
56
+ }),
57
+ }
58
+ vi.mocked(wsconnect).mockResolvedValue(mockConnection as any)
59
+ vi.spyOn(console, 'log').mockImplementation(() => {})
60
+
61
+ const actor = createActor(connectToNats, {
62
+ input: { opts: { servers: ['ws://localhost:4222'] } },
63
+ })
64
+
65
+ const outputPromise = new Promise<any>((resolve) => {
66
+ actor.subscribe((snap) => {
67
+ if (snap.output !== undefined) resolve(snap.output)
68
+ })
69
+ })
70
+ actor.start()
71
+
72
+ const result = await outputPromise
73
+ expect(wsconnect).toHaveBeenCalledWith({ servers: ['ws://localhost:4222'] })
74
+ expect(result).toBe(mockConnection)
75
+ })
76
+
77
+ it('should merge userpass auth config', async () => {
78
+ const { wsconnect } = await import('@nats-io/nats-core')
79
+ const mockConnection = {
80
+ status: () => ({
81
+ [Symbol.asyncIterator]: () => ({
82
+ next: () => new Promise(() => {}),
83
+ }),
84
+ }),
85
+ }
86
+ vi.mocked(wsconnect).mockResolvedValue(mockConnection as any)
87
+ vi.spyOn(console, 'log').mockImplementation(() => {})
88
+
89
+ const actor = createActor(connectToNats, {
90
+ input: {
91
+ opts: { servers: ['ws://localhost:4222'] },
92
+ auth: { type: 'userpass' as const, user: 'admin', pass: 'secret' },
93
+ },
94
+ })
95
+
96
+ const outputPromise = new Promise<any>((resolve) => {
97
+ actor.subscribe((snap) => {
98
+ if (snap.output !== undefined) resolve(snap.output)
99
+ })
100
+ })
101
+ actor.start()
102
+ await outputPromise
103
+
104
+ expect(wsconnect).toHaveBeenCalledWith({
105
+ servers: ['ws://localhost:4222'],
106
+ user: 'admin',
107
+ pass: 'secret',
108
+ })
109
+ })
110
+
111
+ it('should merge token auth config', async () => {
112
+ const { wsconnect } = await import('@nats-io/nats-core')
113
+ const mockConnection = {
114
+ status: () => ({
115
+ [Symbol.asyncIterator]: () => ({
116
+ next: () => new Promise(() => {}),
117
+ }),
118
+ }),
119
+ }
120
+ vi.mocked(wsconnect).mockResolvedValue(mockConnection as any)
121
+ vi.spyOn(console, 'log').mockImplementation(() => {})
122
+
123
+ const actor = createActor(connectToNats, {
124
+ input: {
125
+ opts: { servers: ['ws://localhost:4222'] },
126
+ auth: { type: 'token' as const, token: 'my-token' },
127
+ },
128
+ })
129
+
130
+ const outputPromise = new Promise<any>((resolve) => {
131
+ actor.subscribe((snap) => {
132
+ if (snap.output !== undefined) resolve(snap.output)
133
+ })
134
+ })
135
+ actor.start()
136
+ await outputPromise
137
+
138
+ expect(wsconnect).toHaveBeenCalledWith({
139
+ servers: ['ws://localhost:4222'],
140
+ token: 'my-token',
141
+ })
142
+ })
143
+
144
+ it('should merge decentralised auth config', async () => {
145
+ const { wsconnect, credsAuthenticator } = await import('@nats-io/nats-core')
146
+ const mockConnection = {
147
+ status: () => ({
148
+ [Symbol.asyncIterator]: () => ({
149
+ next: () => new Promise(() => {}),
150
+ }),
151
+ }),
152
+ }
153
+ vi.mocked(wsconnect).mockResolvedValue(mockConnection as any)
154
+ vi.spyOn(console, 'log').mockImplementation(() => {})
155
+
156
+ const actor = createActor(connectToNats, {
157
+ input: {
158
+ opts: { servers: ['ws://localhost:4222'] },
159
+ auth: {
160
+ type: 'decentralised' as const,
161
+ sentinelB64: btoa('test-creds'),
162
+ user: 'nkey-user',
163
+ pass: 'nkey-pass',
164
+ },
165
+ },
166
+ })
167
+
168
+ const outputPromise = new Promise<any>((resolve) => {
169
+ actor.subscribe((snap) => {
170
+ if (snap.output !== undefined) resolve(snap.output)
171
+ })
172
+ })
173
+ actor.start()
174
+ await outputPromise
175
+
176
+ expect(credsAuthenticator).toHaveBeenCalled()
177
+ expect(wsconnect).toHaveBeenCalledWith(
178
+ expect.objectContaining({
179
+ servers: ['ws://localhost:4222'],
180
+ authenticator: 'mock-authenticator',
181
+ user: 'nkey-user',
182
+ pass: 'nkey-pass',
183
+ }),
184
+ )
185
+ })
186
+
187
+ it('should process status events from the connection', async () => {
188
+ const { wsconnect } = await import('@nats-io/nats-core')
189
+ let statusIndex = 0
190
+ const statuses = [
191
+ { type: 'disconnect', data: {} },
192
+ { type: 'reconnect', data: {} },
193
+ { type: 'error', data: {} },
194
+ { type: 'close', data: {} },
195
+ { type: 'ldm', data: {} },
196
+ { type: 'ping', data: {} },
197
+ { type: 'forceReconnect', data: {} },
198
+ { type: 'reconnecting', data: {} },
199
+ { type: 'slowConsumer', data: {} },
200
+ { type: 'staleConnection', data: {} },
201
+ { type: 'update', data: {} },
202
+ ]
203
+ let resolveStatusDone: () => void
204
+ const statusDone = new Promise<void>((r) => (resolveStatusDone = r))
205
+
206
+ const mockConnection = {
207
+ status: () => ({
208
+ [Symbol.asyncIterator]: () => ({
209
+ next: () => {
210
+ if (statusIndex < statuses.length) {
211
+ return Promise.resolve({ value: statuses[statusIndex++], done: false })
212
+ }
213
+ // Signal done, then return done: true to exit the loop
214
+ resolveStatusDone!()
215
+ return Promise.resolve({ value: undefined, done: true })
216
+ },
217
+ }),
218
+ }),
219
+ }
220
+ vi.mocked(wsconnect).mockResolvedValue(mockConnection as any)
221
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
222
+ const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
223
+
224
+ const actor = createActor(connectToNats, {
225
+ input: { opts: { servers: ['ws://localhost:4222'] } },
226
+ })
227
+
228
+ const outputPromise = new Promise<any>((resolve) => {
229
+ actor.subscribe((snap) => {
230
+ if (snap.output !== undefined) resolve(snap.output)
231
+ })
232
+ })
233
+ actor.start()
234
+ await outputPromise
235
+ await statusDone
236
+ // Wait for the status loop to finish processing
237
+ await new Promise((r) => setTimeout(r, 50))
238
+
239
+ // Verify the status loop processed all events
240
+ expect(consoleSpy).toHaveBeenCalledWith(
241
+ 'Status loop received status',
242
+ expect.objectContaining({ type: 'disconnect' }),
243
+ )
244
+ expect(consoleSpy).toHaveBeenCalledWith(
245
+ 'Status loop received status',
246
+ expect.objectContaining({ type: 'reconnect' }),
247
+ )
248
+ expect(consoleSpy).toHaveBeenCalledWith('Exiting nats status loop')
249
+ consoleSpy.mockRestore()
250
+ debugSpy.mockRestore()
251
+ })
252
+
253
+ it('should throw for unsupported auth type', async () => {
254
+ const { wsconnect } = await import('@nats-io/nats-core')
255
+ vi.mocked(wsconnect).mockResolvedValue({} as any)
256
+ vi.spyOn(console, 'log').mockImplementation(() => {})
257
+
258
+ // The error is thrown synchronously inside the fromPromise creator,
259
+ // so it becomes an unhandled exception. We test via the actor error status.
260
+ const actor = createActor(connectToNats, {
261
+ input: {
262
+ opts: { servers: ['ws://localhost:4222'] },
263
+ auth: { type: 'unknown' as any },
264
+ },
265
+ })
266
+
267
+ // Catch the unhandled error from the actor
268
+ let caughtError: Error | undefined
269
+ const origListeners = process.listeners('uncaughtException')
270
+ process.removeAllListeners('uncaughtException')
271
+ process.once('uncaughtException', (err: Error) => {
272
+ caughtError = err
273
+ })
274
+
275
+ actor.start()
276
+
277
+ await vi.waitFor(() => {
278
+ expect(caughtError).toBeDefined()
279
+ })
280
+ expect(caughtError!.message).toContain('Unsupported auth config type')
281
+
282
+ // Restore listeners
283
+ origListeners.forEach((l) => process.on('uncaughtException', l))
284
+ })
285
+ })
286
+
287
+ describe('disconnectNats', () => {
288
+ it('should drain and close connection', async () => {
289
+ const mockConnection = {
290
+ drain: vi.fn().mockResolvedValue(undefined),
291
+ close: vi.fn().mockResolvedValue(undefined),
292
+ }
293
+
294
+ const actor = createActor(disconnectNats, {
295
+ input: { connection: mockConnection as any },
296
+ })
297
+
298
+ const donePromise = new Promise<void>((resolve) => {
299
+ actor.subscribe((snap) => {
300
+ if (snap.status === 'done') resolve()
301
+ })
302
+ })
303
+ actor.start()
304
+ await donePromise
305
+
306
+ expect(mockConnection.drain).toHaveBeenCalled()
307
+ expect(mockConnection.close).toHaveBeenCalled()
308
+ })
309
+
310
+ it('should handle null connection gracefully', async () => {
311
+ const actor = createActor(disconnectNats, {
312
+ input: { connection: null as any },
313
+ })
314
+
315
+ const donePromise = new Promise<void>((resolve) => {
316
+ actor.subscribe((snap) => {
317
+ if (snap.status === 'done') resolve()
318
+ })
319
+ })
320
+ actor.start()
321
+
322
+ await expect(donePromise).resolves.not.toThrow()
323
+ })
324
+ })
@@ -0,0 +1,135 @@
1
+ import { fromPromise } from 'xstate'
2
+ import {
3
+ ConnectionOptions,
4
+ credsAuthenticator,
5
+ Msg,
6
+ NatsConnection,
7
+ Status,
8
+ wsconnect,
9
+ } from '@nats-io/nats-core'
10
+ import { KvEntry } from '@nats-io/kv'
11
+ import { type AuthConfig } from './types'
12
+ import { sendParent } from 'xstate'
13
+
14
+ const makeAuthConfig = (auth?: AuthConfig) => {
15
+ if (!auth) {
16
+ return {}
17
+ }
18
+
19
+ if (auth.type === 'decentralised') {
20
+ const decodedSentinel = atob(auth!.sentinelB64!)
21
+ return {
22
+ authenticator: credsAuthenticator(new TextEncoder().encode(decodedSentinel)),
23
+ user: auth.user,
24
+ pass: auth.pass,
25
+ }
26
+ } else if (auth.type === 'userpass') {
27
+ return {
28
+ user: auth.user,
29
+ pass: auth.pass,
30
+ }
31
+ } else if (auth.type === 'token') {
32
+ return {
33
+ token: auth.token,
34
+ }
35
+ }
36
+
37
+ throw new Error(`Unsupported auth config type ${auth.type}`)
38
+ }
39
+
40
+ export type InternalStatusEvents =
41
+ | { type: 'NATS_CONNECTION.DISCONNECTED'; status: Status }
42
+ | { type: 'NATS_CONNECTION.RECONNECT'; status: Status }
43
+ | { type: 'NATS_CONNECTION.ERROR'; status: Status }
44
+ | { type: 'NATS_CONNECTION.CLOSE'; status: Status }
45
+ | { type: 'NATS_CONNECTION.RECONNECTING'; status: Status }
46
+
47
+ export const connectToNats = fromPromise(
48
+ async ({
49
+ input,
50
+ }: {
51
+ input: { opts: ConnectionOptions; auth?: AuthConfig }
52
+ }): Promise<NatsConnection> => {
53
+ const mergedOpts: ConnectionOptions = {
54
+ ...input.opts,
55
+ ...makeAuthConfig(input.auth),
56
+ }
57
+ const nc = await wsconnect(mergedOpts)
58
+
59
+ // bug: self refers to 'this' promise, which is short-lived....
60
+ // TODO: Emit status events into the machine instead
61
+ ;(async () => {
62
+ for await (const status of nc.status()) {
63
+ console.log('Status loop received status', status)
64
+ const { type } = status
65
+
66
+ switch (type) {
67
+ case 'disconnect':
68
+ sendParent({ type: 'NATS_CONNECTION.DISCONNECTED', status })
69
+ break
70
+ case 'reconnect':
71
+ sendParent({ type: 'NATS_CONNECTION.RECONNECT', status })
72
+ break
73
+ case 'error':
74
+ sendParent({ type: 'NATS_CONNECTION.ERROR', status })
75
+ break
76
+ case 'close':
77
+ sendParent({ type: 'NATS_CONNECTION.CLOSE', status })
78
+ break
79
+ case 'ldm':
80
+ console.debug('LDM', status)
81
+ break
82
+ case 'ping':
83
+ // console.debug('Received ping, pong sent automatically')
84
+ break
85
+ case 'forceReconnect':
86
+ sendParent({ type: 'NATS_CONNECTION.RECONNECT', status })
87
+ break
88
+ case 'reconnecting':
89
+ sendParent({ type: 'NATS_CONNECTION.RECONNECTING', status })
90
+ break
91
+ case 'slowConsumer':
92
+ console.debug('SLOW_CONSUMER', status)
93
+ break
94
+ case 'staleConnection':
95
+ console.debug('STALE_CONNECTION', status)
96
+ break
97
+ case 'update':
98
+ console.debug('NATS_CONNECTION.UPDATE', status)
99
+ break
100
+ }
101
+ }
102
+ console.log('Exiting nats status loop')
103
+ })()
104
+
105
+ return nc
106
+ },
107
+ )
108
+
109
+ export const disconnectNats = fromPromise(
110
+ async ({ input }: { input: { connection: NatsConnection | null } }) => {
111
+ if (input.connection) {
112
+ await input.connection.drain()
113
+ await input.connection.close()
114
+ }
115
+ },
116
+ )
117
+
118
+ export const parseNatsResult = (msg: Msg | KvEntry | null | Error) => {
119
+ if (!msg) {
120
+ return null
121
+ }
122
+
123
+ if (msg instanceof Error) {
124
+ return msg
125
+ }
126
+
127
+ let data
128
+ try {
129
+ data = msg.json()
130
+ } catch (jsonError) {
131
+ // If JSON parsing fails, use the raw string
132
+ data = msg.string()
133
+ }
134
+ return data
135
+ }