@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,460 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { subjectConsolidateState, subjectRequest, subjectPublish } from './subject'
3
+ import type { SubjectSubscriptionConfig } from './subject'
4
+
5
+ // Mock the connection module
6
+ vi.mock('./connection', () => ({
7
+ parseNatsResult: vi.fn((msg: any) => {
8
+ if (!msg) return null
9
+ if (msg instanceof Error) return msg
10
+ try {
11
+ return msg.json()
12
+ } catch {
13
+ return msg.string()
14
+ }
15
+ }),
16
+ }))
17
+
18
+ function createMockConnection() {
19
+ return {
20
+ subscribe: vi.fn(),
21
+ request: vi.fn(),
22
+ publish: vi.fn(),
23
+ } as any
24
+ }
25
+
26
+ function createMockSubscription(subject: string) {
27
+ return {
28
+ unsubscribe: vi.fn(),
29
+ [Symbol.asyncIterator]: () => ({
30
+ next: () => new Promise(() => {}), // never resolves
31
+ }),
32
+ } as any
33
+ }
34
+
35
+ describe('subjectConsolidateState', () => {
36
+ it('should throw when connection is null', () => {
37
+ expect(() =>
38
+ subjectConsolidateState({
39
+ input: {
40
+ connection: null,
41
+ currentSubscriptions: new Map(),
42
+ targetSubscriptions: new Map(),
43
+ },
44
+ }),
45
+ ).toThrow('NATS connection is not available')
46
+ })
47
+
48
+ it('should subscribe to new subjects in target', () => {
49
+ const connection = createMockConnection()
50
+ const mockSub = createMockSubscription('test.subject')
51
+ connection.subscribe.mockReturnValue(mockSub)
52
+
53
+ const callback = vi.fn()
54
+ const targetSubscriptions = new Map<string, SubjectSubscriptionConfig>([
55
+ ['test.subject', { subject: 'test.subject', callback }],
56
+ ])
57
+
58
+ const result = subjectConsolidateState({
59
+ input: {
60
+ connection,
61
+ currentSubscriptions: new Map(),
62
+ targetSubscriptions,
63
+ },
64
+ })
65
+
66
+ expect(connection.subscribe).toHaveBeenCalledWith('test.subject', undefined)
67
+ expect(result.subscriptions.has('test.subject')).toBe(true)
68
+ })
69
+
70
+ it('should unsubscribe from subjects not in target', () => {
71
+ const connection = createMockConnection()
72
+ const mockSub = createMockSubscription('old.subject')
73
+ const currentSubscriptions = new Map([['old.subject', mockSub]])
74
+
75
+ const result = subjectConsolidateState({
76
+ input: {
77
+ connection,
78
+ currentSubscriptions,
79
+ targetSubscriptions: new Map(),
80
+ },
81
+ })
82
+
83
+ expect(mockSub.unsubscribe).toHaveBeenCalled()
84
+ expect(result.subscriptions.has('old.subject')).toBe(false)
85
+ })
86
+
87
+ it('should keep existing subscriptions that are still in target', () => {
88
+ const connection = createMockConnection()
89
+ const existingSub = createMockSubscription('keep.subject')
90
+ const callback = vi.fn()
91
+
92
+ const currentSubscriptions = new Map([['keep.subject', existingSub]])
93
+ const targetSubscriptions = new Map<string, SubjectSubscriptionConfig>([
94
+ ['keep.subject', { subject: 'keep.subject', callback }],
95
+ ])
96
+
97
+ const result = subjectConsolidateState({
98
+ input: {
99
+ connection,
100
+ currentSubscriptions,
101
+ targetSubscriptions,
102
+ },
103
+ })
104
+
105
+ expect(connection.subscribe).not.toHaveBeenCalled()
106
+ expect(existingSub.unsubscribe).not.toHaveBeenCalled()
107
+ expect(result.subscriptions.get('keep.subject')).toBe(existingSub)
108
+ })
109
+
110
+ it('should handle subscribe errors gracefully', () => {
111
+ const connection = createMockConnection()
112
+ connection.subscribe.mockImplementation(() => {
113
+ throw new Error('subscribe failed')
114
+ })
115
+
116
+ const callback = vi.fn()
117
+ const targetSubscriptions = new Map<string, SubjectSubscriptionConfig>([
118
+ ['fail.subject', { subject: 'fail.subject', callback }],
119
+ ])
120
+
121
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
122
+ const result = subjectConsolidateState({
123
+ input: {
124
+ connection,
125
+ currentSubscriptions: new Map(),
126
+ targetSubscriptions,
127
+ },
128
+ })
129
+ consoleSpy.mockRestore()
130
+
131
+ expect(result.subscriptions.has('fail.subject')).toBe(false)
132
+ })
133
+
134
+ it('should handle unsubscribe errors gracefully', () => {
135
+ const connection = createMockConnection()
136
+ const mockSub = {
137
+ unsubscribe: vi.fn(() => {
138
+ throw new Error('unsub failed')
139
+ }),
140
+ } as any
141
+
142
+ const currentSubscriptions = new Map([['bad.subject', mockSub]])
143
+
144
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
145
+ const result = subjectConsolidateState({
146
+ input: {
147
+ connection,
148
+ currentSubscriptions,
149
+ targetSubscriptions: new Map(),
150
+ },
151
+ })
152
+ consoleSpy.mockRestore()
153
+
154
+ expect(mockSub.unsubscribe).toHaveBeenCalled()
155
+ })
156
+
157
+ it('should invoke callback for each message in the async loop', async () => {
158
+ const connection = createMockConnection()
159
+ const messages = [
160
+ { json: () => ({ id: 1 }), string: () => '{"id":1}' },
161
+ { json: () => ({ id: 2 }), string: () => '{"id":2}' },
162
+ ]
163
+ let resolveIterator: () => void
164
+ const iteratorDone = new Promise<void>((r) => (resolveIterator = r))
165
+ let msgIndex = 0
166
+
167
+ const mockSub = {
168
+ unsubscribe: vi.fn(),
169
+ [Symbol.asyncIterator]: () => ({
170
+ next: () => {
171
+ if (msgIndex < messages.length) {
172
+ return Promise.resolve({ value: messages[msgIndex++], done: false })
173
+ }
174
+ resolveIterator!()
175
+ return new Promise(() => {}) // hang after messages delivered
176
+ },
177
+ }),
178
+ } as any
179
+ connection.subscribe.mockReturnValue(mockSub)
180
+
181
+ const callback = vi.fn()
182
+ const targetSubscriptions = new Map<string, SubjectSubscriptionConfig>([
183
+ ['test.subject', { subject: 'test.subject', callback }],
184
+ ])
185
+
186
+ subjectConsolidateState({
187
+ input: {
188
+ connection,
189
+ currentSubscriptions: new Map(),
190
+ targetSubscriptions,
191
+ },
192
+ })
193
+
194
+ await iteratorDone
195
+ // Allow microtasks to flush
196
+ await new Promise((r) => setTimeout(r, 10))
197
+ expect(callback).toHaveBeenCalledTimes(2)
198
+ expect(callback).toHaveBeenCalledWith({ id: 1 })
199
+ expect(callback).toHaveBeenCalledWith({ id: 2 })
200
+ })
201
+
202
+ it('should handle callback errors in the message loop', async () => {
203
+ const connection = createMockConnection()
204
+ let resolveIterator: () => void
205
+ const iteratorDone = new Promise<void>((r) => (resolveIterator = r))
206
+ let delivered = false
207
+
208
+ const mockSub = {
209
+ unsubscribe: vi.fn(),
210
+ [Symbol.asyncIterator]: () => ({
211
+ next: () => {
212
+ if (!delivered) {
213
+ delivered = true
214
+ return Promise.resolve({
215
+ value: { json: () => ({ id: 1 }), string: () => '{"id":1}' },
216
+ done: false,
217
+ })
218
+ }
219
+ resolveIterator!()
220
+ return new Promise(() => {})
221
+ },
222
+ }),
223
+ } as any
224
+ connection.subscribe.mockReturnValue(mockSub)
225
+
226
+ const callback = vi.fn(() => {
227
+ throw new Error('callback error')
228
+ })
229
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
230
+
231
+ subjectConsolidateState({
232
+ input: {
233
+ connection,
234
+ currentSubscriptions: new Map(),
235
+ targetSubscriptions: new Map([['test.subject', { subject: 'test.subject', callback }]]),
236
+ },
237
+ })
238
+
239
+ await iteratorDone
240
+ await new Promise((r) => setTimeout(r, 10))
241
+ consoleSpy.mockRestore()
242
+
243
+ expect(callback).toHaveBeenCalledTimes(1)
244
+ })
245
+
246
+ it('should handle iterator errors in the message loop', async () => {
247
+ const connection = createMockConnection()
248
+
249
+ const mockSub = {
250
+ unsubscribe: vi.fn(),
251
+ [Symbol.asyncIterator]: () => ({
252
+ next: () => Promise.reject(new Error('iterator broke')),
253
+ }),
254
+ } as any
255
+ connection.subscribe.mockReturnValue(mockSub)
256
+
257
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
258
+
259
+ subjectConsolidateState({
260
+ input: {
261
+ connection,
262
+ currentSubscriptions: new Map(),
263
+ targetSubscriptions: new Map([
264
+ ['test.subject', { subject: 'test.subject', callback: vi.fn() }],
265
+ ]),
266
+ },
267
+ })
268
+
269
+ await vi.waitFor(() => {
270
+ expect(consoleSpy).toHaveBeenCalledWith(
271
+ expect.stringContaining('Iterator error'),
272
+ expect.any(Error),
273
+ )
274
+ })
275
+ consoleSpy.mockRestore()
276
+ })
277
+
278
+ it('should pass subscription opts to connection.subscribe', () => {
279
+ const connection = createMockConnection()
280
+ const mockSub = createMockSubscription('test.subject')
281
+ connection.subscribe.mockReturnValue(mockSub)
282
+
283
+ const opts = { max: 10 }
284
+ const targetSubscriptions = new Map<string, SubjectSubscriptionConfig>([
285
+ ['test.subject', { subject: 'test.subject', callback: vi.fn(), opts }],
286
+ ])
287
+
288
+ subjectConsolidateState({
289
+ input: {
290
+ connection,
291
+ currentSubscriptions: new Map(),
292
+ targetSubscriptions,
293
+ },
294
+ })
295
+
296
+ expect(connection.subscribe).toHaveBeenCalledWith('test.subject', opts)
297
+ })
298
+ })
299
+
300
+ describe('subjectRequest', () => {
301
+ it('should throw when connection is null', () => {
302
+ expect(() =>
303
+ subjectRequest({
304
+ input: {
305
+ connection: null,
306
+ subject: 'test',
307
+ payload: {},
308
+ callback: vi.fn(),
309
+ },
310
+ }),
311
+ ).toThrow('NATS connection is not available')
312
+ })
313
+
314
+ it('should call connection.request and invoke callback on success', async () => {
315
+ const connection = createMockConnection()
316
+ const mockMsg = { json: () => ({ result: 'ok' }), string: () => '{"result":"ok"}' }
317
+ connection.request.mockResolvedValue(mockMsg)
318
+
319
+ const callback = vi.fn()
320
+ subjectRequest({
321
+ input: {
322
+ connection,
323
+ subject: 'test.request',
324
+ payload: { data: 1 },
325
+ opts: { timeout: 5000 } as any,
326
+ callback,
327
+ },
328
+ })
329
+
330
+ // Wait for promise to resolve
331
+ await vi.waitFor(() => {
332
+ expect(callback).toHaveBeenCalled()
333
+ })
334
+
335
+ expect(connection.request).toHaveBeenCalledWith('test.request', { data: 1 }, { timeout: 5000 })
336
+ })
337
+
338
+ it('should handle request errors', async () => {
339
+ const connection = createMockConnection()
340
+ connection.request.mockRejectedValue(new Error('request failed'))
341
+
342
+ const callback = vi.fn()
343
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
344
+
345
+ subjectRequest({
346
+ input: {
347
+ connection,
348
+ subject: 'test.fail',
349
+ payload: {},
350
+ callback,
351
+ },
352
+ })
353
+
354
+ await vi.waitFor(() => {
355
+ expect(consoleSpy).toHaveBeenCalledWith(
356
+ expect.stringContaining('RequestReply error'),
357
+ expect.any(Error),
358
+ )
359
+ })
360
+
361
+ consoleSpy.mockRestore()
362
+ expect(callback).not.toHaveBeenCalled()
363
+ })
364
+ })
365
+
366
+ describe('subjectPublish', () => {
367
+ it('should throw when connection is null', () => {
368
+ expect(() =>
369
+ subjectPublish({
370
+ input: {
371
+ connection: null,
372
+ subject: 'test',
373
+ payload: {},
374
+ },
375
+ }),
376
+ ).toThrow('NATS connection is not available')
377
+ })
378
+
379
+ it('should publish to connection', () => {
380
+ const connection = createMockConnection()
381
+
382
+ subjectPublish({
383
+ input: {
384
+ connection,
385
+ subject: 'test.publish',
386
+ payload: { msg: 'hello' },
387
+ },
388
+ })
389
+
390
+ expect(connection.publish).toHaveBeenCalledWith('test.publish', { msg: 'hello' }, undefined)
391
+ })
392
+
393
+ it('should call onPublishResult with ok on success', () => {
394
+ const connection = createMockConnection()
395
+ const onPublishResult = vi.fn()
396
+
397
+ subjectPublish({
398
+ input: {
399
+ connection,
400
+ subject: 'test.publish',
401
+ payload: 'data',
402
+ onPublishResult,
403
+ },
404
+ })
405
+
406
+ expect(onPublishResult).toHaveBeenCalledWith({ ok: true })
407
+ })
408
+
409
+ it('should pass publish options', () => {
410
+ const connection = createMockConnection()
411
+ const options = { headers: {} } as any
412
+
413
+ subjectPublish({
414
+ input: {
415
+ connection,
416
+ subject: 'test.publish',
417
+ payload: 'data',
418
+ options,
419
+ },
420
+ })
421
+
422
+ expect(connection.publish).toHaveBeenCalledWith('test.publish', 'data', options)
423
+ })
424
+
425
+ it('should call onPublishResult with error on failure', () => {
426
+ const connection = createMockConnection()
427
+ const publishError = new Error('publish failed')
428
+ connection.publish.mockImplementation(() => {
429
+ throw publishError
430
+ })
431
+ const onPublishResult = vi.fn()
432
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
433
+
434
+ subjectPublish({
435
+ input: {
436
+ connection,
437
+ subject: 'test.fail',
438
+ payload: 'data',
439
+ onPublishResult,
440
+ },
441
+ })
442
+
443
+ consoleSpy.mockRestore()
444
+ expect(onPublishResult).toHaveBeenCalledWith({ ok: false, error: publishError })
445
+ })
446
+
447
+ it('should not throw when onPublishResult is not provided and publish succeeds', () => {
448
+ const connection = createMockConnection()
449
+
450
+ expect(() =>
451
+ subjectPublish({
452
+ input: {
453
+ connection,
454
+ subject: 'test.publish',
455
+ payload: 'data',
456
+ },
457
+ }),
458
+ ).not.toThrow()
459
+ })
460
+ })
@@ -0,0 +1,127 @@
1
+ import {
2
+ Msg,
3
+ NatsConnection,
4
+ PublishOptions,
5
+ RequestOptions,
6
+ Subscription,
7
+ SubscriptionOptions,
8
+ } from '@nats-io/nats-core'
9
+ import { parseNatsResult } from './connection'
10
+
11
+ export type SubjectSubscriptionConfig = {
12
+ subject: string
13
+ callback: (data: any) => void
14
+ opts?: SubscriptionOptions
15
+ }
16
+
17
+ export const subjectConsolidateState = ({
18
+ input,
19
+ }: {
20
+ input: {
21
+ connection: NatsConnection | null
22
+ currentSubscriptions: Map<string, Subscription>
23
+ targetSubscriptions: Map<string, SubjectSubscriptionConfig>
24
+ }
25
+ }) => {
26
+ const { connection, currentSubscriptions, targetSubscriptions } = input
27
+ if (!connection) {
28
+ throw new Error('NATS connection is not available')
29
+ }
30
+
31
+ const syncedSubscriptions = new Map(currentSubscriptions)
32
+
33
+ // Unsubscribe from subjects that are in currentSubscriptions but not in targetSubscriptions
34
+ for (const [subject, subscription] of currentSubscriptions) {
35
+ if (!targetSubscriptions.has(subject)) {
36
+ try {
37
+ syncedSubscriptions.delete(subject)
38
+ subscription.unsubscribe()
39
+ } catch (error) {
40
+ console.error(`Error unsubscribing from subject "${subject}"`, error)
41
+ }
42
+ }
43
+ }
44
+
45
+ // Subscribe to new subjects that are in targetSubscriptions but not in currentSubscriptions
46
+ for (const [subject, subscriptionConfig] of targetSubscriptions) {
47
+ if (!currentSubscriptions.has(subject)) {
48
+ try {
49
+ const sub = connection.subscribe(subject, subscriptionConfig.opts)
50
+
51
+ // Set up the message handler
52
+ ;(async () => {
53
+ try {
54
+ for await (const msg of sub) {
55
+ try {
56
+ subscriptionConfig?.callback(parseNatsResult(msg))
57
+ } catch (callbackError) {
58
+ console.error(`Callback error for subject "${subject}"`, callbackError)
59
+ }
60
+ }
61
+ } catch (iteratorError) {
62
+ console.error(`Iterator error for subject "${subject}"`, iteratorError)
63
+ }
64
+ })()
65
+
66
+ syncedSubscriptions.set(subject, sub)
67
+ } catch (error) {
68
+ console.error(`Error subscribing to subject "${subject}"`, error)
69
+ }
70
+ }
71
+ }
72
+
73
+ return {
74
+ subscriptions: syncedSubscriptions,
75
+ }
76
+ }
77
+
78
+ export const subjectRequest = ({
79
+ input,
80
+ }: {
81
+ input: {
82
+ connection: NatsConnection | null
83
+ subject: string
84
+ payload: any
85
+ opts?: RequestOptions
86
+ callback: (data: any) => void
87
+ }
88
+ }) => {
89
+ const { connection, subject, payload, opts, callback } = input
90
+ if (!connection) {
91
+ throw new Error('NATS connection is not available')
92
+ }
93
+
94
+ connection
95
+ .request(subject, payload, opts)
96
+ .then((msg: Msg) => {
97
+ callback(parseNatsResult(msg))
98
+ })
99
+ .catch((err) => {
100
+ console.error(`RequestReply error for subject "${subject}"`, err)
101
+ })
102
+ }
103
+
104
+ export const subjectPublish = ({
105
+ input,
106
+ }: {
107
+ input: {
108
+ connection: NatsConnection | null
109
+ subject: string
110
+ payload: any
111
+ options?: PublishOptions
112
+ onPublishResult?: (result: { ok: true } | { ok: false; error: Error }) => void
113
+ }
114
+ }) => {
115
+ const { connection, subject, payload, options, onPublishResult } = input
116
+ if (!connection) {
117
+ throw new Error('NATS connection is not available')
118
+ }
119
+
120
+ try {
121
+ connection.publish(subject, payload, options)
122
+ onPublishResult?.({ ok: true })
123
+ } catch (callbackError) {
124
+ console.error(`Publish callback error for subject "${subject}"`, callbackError)
125
+ onPublishResult?.({ ok: false, error: callbackError as Error })
126
+ }
127
+ }
@@ -0,0 +1,7 @@
1
+ export interface AuthConfig {
2
+ type: 'decentralised' | 'userpass' | 'token'
3
+ sentinelB64?: string
4
+ user?: string
5
+ pass?: string
6
+ token?: string
7
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { natsMachine } from './machines/root'
2
+ export {
3
+ subjectManagerLogic,
4
+ type Context as SubjectContext,
5
+ type ExternalEvents as SubjectEvent,
6
+ } from './machines/subject'
7
+ export {
8
+ kvManagerLogic,
9
+ type Context as KvContext,
10
+ type ExternalEvents as KvEvent,
11
+ } from './machines/kv'
12
+ export { KvSubscriptionKey, type KvSubscriptionConfig } from './actions/kv'
13
+ export { parseNatsResult } from './actions/connection'
14
+ export { type AuthConfig } from './actions/types'
15
+
16
+ export {
17
+ type NatsConnectionConfig,
18
+ type Context as NatsContext,
19
+ type ExternalEvents as NatsEvent,
20
+ } from './machines/root'