@nxtedition/deepstream.io-client-js 32.0.27 → 32.0.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/deepstream.io-client-js",
3
- "version": "32.0.27",
3
+ "version": "32.0.28",
4
4
  "description": "the javascript client for deepstream.io",
5
5
  "homepage": "http://deepstream.io",
6
6
  "type": "module",
@@ -157,6 +157,10 @@ EventHandler.prototype._$handle = function (message) {
157
157
  }
158
158
 
159
159
  EventHandler.prototype._onConnectionStateChange = function (connected) {
160
+ for (const listener of this._listeners.values()) {
161
+ listener._$onConnectionStateChange(connected)
162
+ }
163
+
160
164
  if (connected) {
161
165
  for (const eventName of this._emitter.eventNames()) {
162
166
  this._connection.sendMsg(C.TOPIC.EVENT, C.ACTIONS.SUBSCRIBE, [eventName])
@@ -604,19 +604,19 @@ class RecordHandler {
604
604
  }
605
605
 
606
606
  if (!Number.isInteger(state) || state < 0) {
607
- throw new Error('invalid argument: state')
607
+ throw new Error(`invalid argument "state": ${state}`)
608
608
  }
609
609
 
610
610
  if (!Number.isInteger(timeout) || timeout < 0) {
611
- throw new Error('invalid argument: timeout')
611
+ throw new Error(`invalid argument "timeout": ${timeout}`)
612
612
  }
613
613
 
614
614
  if (typeof dataOnly !== 'boolean') {
615
- throw new Error('invalid argument: dataOnly')
615
+ throw new Error(`invalid argument "dataOnly": ${dataOnly}`)
616
616
  }
617
617
 
618
618
  if (typeof sync !== 'boolean') {
619
- throw new Error('invalid argument: sync')
619
+ throw new Error(`invalid argument "sync": ${sync}`)
620
620
  }
621
621
 
622
622
  return new rxjs.Observable((subscriber) => {
@@ -38,12 +38,12 @@ export default class Listener {
38
38
  _$onMessage(message) {
39
39
  if (!this.connected) {
40
40
  this._client._$onError(
41
- C.TOPIC.RECORD,
41
+ this._topic,
42
42
  C.EVENT.NOT_CONNECTED,
43
43
  new Error('received message while not connected'),
44
44
  message,
45
45
  )
46
- return
46
+ return true
47
47
  }
48
48
 
49
49
  const name = message.data[1]
@@ -192,13 +192,15 @@ export default class Listener {
192
192
  provider.patternSubscription = rxjs.of(ret$).subscribe(provider)
193
193
  }
194
194
  } catch (err) {
195
+ provider.stop()
196
+ this._subscriptions.delete(provider.name)
195
197
  this._error(provider.name, err)
196
198
  }
197
199
  }
198
200
 
199
- provider.start()
200
-
201
201
  this._subscriptions.set(provider.name, provider)
202
+
203
+ provider.start()
202
204
  } else if (message.action === C.ACTIONS.LISTEN_ACCEPT) {
203
205
  const provider = this._subscriptions.get(name)
204
206
  if (!provider?.value$) {
@@ -55,6 +55,8 @@ export default class Listener {
55
55
 
56
56
  _$destroy() {
57
57
  this._reset()
58
+
59
+ this._connection.sendMsg(this._topic, C.ACTIONS.UNLISTEN, [this._pattern])
58
60
  }
59
61
 
60
62
  _$onMessage(message) {
@@ -77,12 +79,14 @@ export default class Listener {
77
79
  }
78
80
 
79
81
  if (value$) {
82
+ let errored = false
80
83
  const subscription = value$.pipe(PIPE).subscribe({
81
84
  next: (data) => {
82
85
  const version = `INF-${h64ToString(data)}`
83
86
  this._connection.sendMsg(this._topic, C.ACTIONS.UPDATE, [name, version, data])
84
87
  },
85
88
  error: (err) => {
89
+ errored = true
86
90
  this._error(name, err)
87
91
 
88
92
  this._subscriptions.delete(name)
@@ -90,7 +94,9 @@ export default class Listener {
90
94
  },
91
95
  })
92
96
 
93
- this._subscriptions.set(name, subscription)
97
+ if (!errored) {
98
+ this._subscriptions.set(name, subscription)
99
+ }
94
100
  } else {
95
101
  this._connection.sendMsg(this._topic, C.ACTIONS.LISTEN_REJECT, [this._pattern, name])
96
102
  }
@@ -129,7 +135,5 @@ export default class Listener {
129
135
  subscription.unsubscribe()
130
136
  }
131
137
  this._subscriptions.clear()
132
-
133
- this._connection.sendMsg(this._topic, C.ACTIONS.UNLISTEN, [this._pattern])
134
138
  }
135
139
  }
@@ -0,0 +1,450 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import * as C from '../src/constants/constants.js'
4
+
5
+ // Minimal mock helpers
6
+ function createMockConnection(connected = true) {
7
+ return {
8
+ connected,
9
+ messages: [],
10
+ sendMsg(topic, action, data) {
11
+ this.messages.push({ topic, action, data })
12
+ },
13
+ }
14
+ }
15
+
16
+ function createMockClient() {
17
+ return {
18
+ errors: [],
19
+ _$onError(topic, event, err, data) {
20
+ this.errors.push({ topic, event, err, data })
21
+ },
22
+ }
23
+ }
24
+
25
+ function createMockHandler(connection, client) {
26
+ return {
27
+ _client: client,
28
+ _connection: connection,
29
+ }
30
+ }
31
+
32
+ function msg(action, data) {
33
+ return { action, data }
34
+ }
35
+
36
+ // We can't import the Listener directly because it depends on xxhash-wasm (top-level await).
37
+ // Instead, we dynamically import after setting up mocks.
38
+ let Listener
39
+
40
+ describe('LegacyListener', async () => {
41
+ Listener = (await import('../src/utils/legacy-listener.js')).default
42
+
43
+ describe('constructor', () => {
44
+ it('sends LISTEN on construction when connected', () => {
45
+ const connection = createMockConnection(true)
46
+ const client = createMockClient()
47
+ const handler = createMockHandler(connection, client)
48
+
49
+ new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler)
50
+
51
+ assert.equal(connection.messages.length, 1)
52
+ assert.deepEqual(connection.messages[0], {
53
+ topic: C.TOPIC.RECORD,
54
+ action: C.ACTIONS.LISTEN,
55
+ data: ['test/.*'],
56
+ })
57
+ })
58
+
59
+ it('does not send LISTEN when not connected', () => {
60
+ const connection = createMockConnection(false)
61
+ const client = createMockClient()
62
+ const handler = createMockHandler(connection, client)
63
+
64
+ new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler)
65
+
66
+ assert.equal(connection.messages.length, 0)
67
+ })
68
+ })
69
+
70
+ describe('_$onMessage - not connected', () => {
71
+ it('returns true and reports error with correct topic', () => {
72
+ const connection = createMockConnection(true)
73
+ const client = createMockClient()
74
+ const handler = createMockHandler(connection, client)
75
+
76
+ const listener = new Listener(C.TOPIC.EVENT, 'test/.*', () => null, handler)
77
+
78
+ // Simulate disconnect without calling _$onConnectionStateChange
79
+ connection.connected = false
80
+
81
+ const result = listener._$onMessage(
82
+ msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']),
83
+ )
84
+
85
+ assert.equal(result, true)
86
+ assert.equal(client.errors.length, 1)
87
+ assert.equal(client.errors[0].topic, C.TOPIC.EVENT) // Bug 1: was hardcoded to RECORD
88
+ assert.equal(client.errors[0].event, C.EVENT.NOT_CONNECTED)
89
+ })
90
+ })
91
+
92
+ describe('_$onMessage - SUBSCRIPTION_FOR_PATTERN_FOUND', () => {
93
+ it('creates a provider and sends LISTEN_ACCEPT via microtask', async () => {
94
+ const connection = createMockConnection(true)
95
+ const client = createMockClient()
96
+ const handler = createMockHandler(connection, client)
97
+ const { of } = await import('rxjs')
98
+
99
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => of({ key: 'value' }), handler)
100
+ connection.messages.length = 0
101
+
102
+ const result = listener._$onMessage(
103
+ msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']),
104
+ )
105
+
106
+ assert.equal(result, true)
107
+ assert.equal(listener.stats.subscriptions, 1)
108
+
109
+ // Wait for microtask to fire provider.send()
110
+ await new Promise((r) => queueMicrotask(r))
111
+
112
+ const acceptMsg = connection.messages.find((m) => m.action === C.ACTIONS.LISTEN_ACCEPT)
113
+ assert.ok(acceptMsg, 'should send LISTEN_ACCEPT')
114
+ assert.deepEqual(acceptMsg.data, ['test/.*', 'test/1'])
115
+ })
116
+
117
+ it('rejects duplicate subscription names', async () => {
118
+ const connection = createMockConnection(true)
119
+ const client = createMockClient()
120
+ const handler = createMockHandler(connection, client)
121
+ const rxjs = await import('rxjs')
122
+
123
+ const listener = new Listener(
124
+ C.TOPIC.RECORD,
125
+ 'test/.*',
126
+ () => rxjs.of({ key: 'val' }),
127
+ handler,
128
+ )
129
+ connection.messages.length = 0
130
+
131
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
132
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
133
+
134
+ assert.equal(client.errors.length, 1)
135
+ assert.ok(String(client.errors[0].err).includes('invalid add'))
136
+ })
137
+
138
+ it('cleans up provider when callback throws (Bug 3)', async () => {
139
+ const connection = createMockConnection(true)
140
+ const client = createMockClient()
141
+ const handler = createMockHandler(connection, client)
142
+
143
+ const listener = new Listener(
144
+ C.TOPIC.RECORD,
145
+ 'test/.*',
146
+ () => {
147
+ throw new Error('callback failed')
148
+ },
149
+ handler,
150
+ )
151
+ connection.messages.length = 0
152
+
153
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
154
+
155
+ // Provider should be removed from map after callback throws
156
+ assert.equal(listener.stats.subscriptions, 0)
157
+ assert.equal(client.errors.length, 1)
158
+ })
159
+
160
+ it('sends LISTEN_REJECT when callback returns null', async () => {
161
+ const connection = createMockConnection(true)
162
+ const client = createMockClient()
163
+ const handler = createMockHandler(connection, client)
164
+
165
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler)
166
+ connection.messages.length = 0
167
+
168
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
169
+
170
+ // rxjs.of(null) emits null synchronously, provider.next(null) sets value$ to null
171
+ // provider.send is queued via microtask — wait for it, then another for delivery
172
+ await new Promise((r) => queueMicrotask(r))
173
+ await new Promise((r) => queueMicrotask(r))
174
+
175
+ // With null callback, value$ stays null, so accepted=false matches initial accepted=false
176
+ // The provider never sends ACCEPT in the first place, so no REJECT is sent either.
177
+ // This is correct behavior: null means "don't provide", so we don't accept or reject.
178
+ assert.equal(listener.stats.subscriptions, 1)
179
+ })
180
+ })
181
+
182
+ describe('_$onMessage - SUBSCRIPTION_FOR_PATTERN_REMOVED', () => {
183
+ it('stops and removes the provider', async () => {
184
+ const connection = createMockConnection(true)
185
+ const client = createMockClient()
186
+ const handler = createMockHandler(connection, client)
187
+
188
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler)
189
+ connection.messages.length = 0
190
+
191
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
192
+ assert.equal(listener.stats.subscriptions, 1)
193
+
194
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_REMOVED, ['test/.*', 'test/1']))
195
+ assert.equal(listener.stats.subscriptions, 0)
196
+ })
197
+
198
+ it('errors on removal of unknown subscription', () => {
199
+ const connection = createMockConnection(true)
200
+ const client = createMockClient()
201
+ const handler = createMockHandler(connection, client)
202
+
203
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler)
204
+
205
+ listener._$onMessage(
206
+ msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_REMOVED, ['test/.*', 'test/unknown']),
207
+ )
208
+
209
+ assert.equal(client.errors.length, 1)
210
+ assert.ok(String(client.errors[0].err).includes('invalid remove'))
211
+ })
212
+ })
213
+
214
+ describe('_$onMessage - LISTEN_ACCEPT', () => {
215
+ it('starts value subscription when provider has value$', async () => {
216
+ const connection = createMockConnection(true)
217
+ const client = createMockClient()
218
+ const handler = createMockHandler(connection, client)
219
+ const rxjs = await import('rxjs')
220
+
221
+ const value$ = new rxjs.BehaviorSubject({ data: 'hello' })
222
+
223
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => value$, handler)
224
+ connection.messages.length = 0
225
+
226
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
227
+
228
+ // Wait for microtask to send LISTEN_ACCEPT
229
+ await new Promise((r) => queueMicrotask(r))
230
+
231
+ // Server sends back LISTEN_ACCEPT
232
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
233
+
234
+ // Should have sent an UPDATE with the value
235
+ const updateMsg = connection.messages.find((m) => m.action === C.ACTIONS.UPDATE)
236
+ assert.ok(updateMsg, 'should send UPDATE after LISTEN_ACCEPT')
237
+ assert.equal(updateMsg.data[0], 'test/1')
238
+ })
239
+
240
+ it('ignores LISTEN_ACCEPT when provider has no value$', async () => {
241
+ const connection = createMockConnection(true)
242
+ const client = createMockClient()
243
+ const handler = createMockHandler(connection, client)
244
+
245
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler)
246
+ connection.messages.length = 0
247
+
248
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
249
+
250
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
251
+
252
+ // No error, no crash
253
+ assert.equal(client.errors.length, 0)
254
+ })
255
+ })
256
+
257
+ describe('_$onMessage - unknown action', () => {
258
+ it('returns false for unrecognized actions', () => {
259
+ const connection = createMockConnection(true)
260
+ const client = createMockClient()
261
+ const handler = createMockHandler(connection, client)
262
+
263
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler)
264
+
265
+ const result = listener._$onMessage(msg(C.ACTIONS.UPDATE, ['test/.*', 'test/1']))
266
+ assert.equal(result, false)
267
+ })
268
+ })
269
+
270
+ describe('_$onConnectionStateChange', () => {
271
+ it('re-sends LISTEN on reconnect', () => {
272
+ const connection = createMockConnection(true)
273
+ const client = createMockClient()
274
+ const handler = createMockHandler(connection, client)
275
+
276
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler)
277
+ connection.messages.length = 0
278
+
279
+ // Simulate disconnect
280
+ connection.connected = false
281
+ listener._$onConnectionStateChange()
282
+
283
+ // Simulate reconnect
284
+ connection.connected = true
285
+ listener._$onConnectionStateChange()
286
+
287
+ assert.equal(connection.messages.length, 1)
288
+ assert.deepEqual(connection.messages[0], {
289
+ topic: C.TOPIC.RECORD,
290
+ action: C.ACTIONS.LISTEN,
291
+ data: ['test/.*'],
292
+ })
293
+ })
294
+
295
+ it('clears all subscriptions on disconnect', () => {
296
+ const connection = createMockConnection(true)
297
+ const client = createMockClient()
298
+ const handler = createMockHandler(connection, client)
299
+
300
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler)
301
+
302
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
303
+ assert.equal(listener.stats.subscriptions, 1)
304
+
305
+ connection.connected = false
306
+ listener._$onConnectionStateChange()
307
+
308
+ assert.equal(listener.stats.subscriptions, 0)
309
+ })
310
+ })
311
+
312
+ describe('_$destroy', () => {
313
+ it('sends UNLISTEN and cleans up when connected', () => {
314
+ const connection = createMockConnection(true)
315
+ const client = createMockClient()
316
+ const handler = createMockHandler(connection, client)
317
+
318
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler)
319
+
320
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
321
+ connection.messages.length = 0
322
+
323
+ listener._$destroy()
324
+
325
+ assert.equal(listener.stats.subscriptions, 0)
326
+ const unlistenMsg = connection.messages.find((m) => m.action === C.ACTIONS.UNLISTEN)
327
+ assert.ok(unlistenMsg, 'should send UNLISTEN')
328
+ })
329
+
330
+ it('does not send UNLISTEN when not connected', () => {
331
+ const connection = createMockConnection(false)
332
+ const client = createMockClient()
333
+ const handler = createMockHandler(connection, client)
334
+
335
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler)
336
+
337
+ listener._$destroy()
338
+
339
+ const unlistenMsg = connection.messages.find((m) => m.action === C.ACTIONS.UNLISTEN)
340
+ assert.equal(unlistenMsg, undefined)
341
+ })
342
+ })
343
+
344
+ describe('provider.observer - record values', () => {
345
+ it('deduplicates identical values by hash', async () => {
346
+ const connection = createMockConnection(true)
347
+ const client = createMockClient()
348
+ const handler = createMockHandler(connection, client)
349
+ const rxjs = await import('rxjs')
350
+
351
+ const subject = new rxjs.BehaviorSubject({ key: 'value' })
352
+
353
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => subject, handler)
354
+ connection.messages.length = 0
355
+
356
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
357
+ await new Promise((r) => queueMicrotask(r))
358
+
359
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
360
+
361
+ const updateCount = connection.messages.filter((m) => m.action === C.ACTIONS.UPDATE).length
362
+
363
+ // Emit same value again
364
+ subject.next({ key: 'value' })
365
+
366
+ const updateCount2 = connection.messages.filter((m) => m.action === C.ACTIONS.UPDATE).length
367
+ assert.equal(updateCount2, updateCount, 'should not send duplicate UPDATE for same hash')
368
+ })
369
+
370
+ it('handles null values from observer by calling provider.next(null)', async () => {
371
+ const connection = createMockConnection(true)
372
+ const client = createMockClient()
373
+ const handler = createMockHandler(connection, client)
374
+ const rxjs = await import('rxjs')
375
+
376
+ const subject = new rxjs.Subject()
377
+
378
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => subject, handler)
379
+ connection.messages.length = 0
380
+
381
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
382
+ await new Promise((r) => queueMicrotask(r))
383
+
384
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
385
+
386
+ // Emit null - should trigger provider.next(null) which rejects
387
+ subject.next(null)
388
+ await new Promise((r) => queueMicrotask(r))
389
+
390
+ const rejectMsg = connection.messages.find((m) => m.action === C.ACTIONS.LISTEN_REJECT)
391
+ assert.ok(rejectMsg, 'should send LISTEN_REJECT when value becomes null')
392
+ })
393
+
394
+ it('errors on invalid non-object non-string values', async () => {
395
+ const connection = createMockConnection(true)
396
+ const client = createMockClient()
397
+ const handler = createMockHandler(connection, client)
398
+ const rxjs = await import('rxjs')
399
+
400
+ const subject = new rxjs.Subject()
401
+
402
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => subject, handler)
403
+ connection.messages.length = 0
404
+
405
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
406
+ await new Promise((r) => queueMicrotask(r))
407
+
408
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
409
+
410
+ subject.next(42)
411
+
412
+ assert.equal(client.errors.length, 1)
413
+ assert.ok(String(client.errors[0].err).includes('invalid value'))
414
+ })
415
+ })
416
+
417
+ describe('provider.error - retry behavior', () => {
418
+ it('retries after error with timeout', async () => {
419
+ const connection = createMockConnection(true)
420
+ const client = createMockClient()
421
+ const handler = createMockHandler(connection, client)
422
+ const rxjs = await import('rxjs')
423
+
424
+ let callCount = 0
425
+ const listener = new Listener(
426
+ C.TOPIC.RECORD,
427
+ 'test/.*',
428
+ () => {
429
+ callCount++
430
+ if (callCount === 1) {
431
+ return rxjs.throwError(() => new Error('temporary'))
432
+ }
433
+ return rxjs.of({ ok: true })
434
+ },
435
+ handler,
436
+ { recursive: true },
437
+ )
438
+ connection.messages.length = 0
439
+
440
+ listener._$onMessage(msg(C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND, ['test/.*', 'test/1']))
441
+
442
+ assert.equal(callCount, 1)
443
+ assert.equal(client.errors.length, 1)
444
+
445
+ // The retry timeout is 10 seconds, we don't want to actually wait
446
+ // Just verify the provider is still in the map (will be retried)
447
+ assert.equal(listener.stats.subscriptions, 1)
448
+ })
449
+ })
450
+ })
@@ -0,0 +1,366 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import * as C from '../src/constants/constants.js'
4
+
5
+ function createMockConnection(connected = true) {
6
+ return {
7
+ connected,
8
+ messages: [],
9
+ sendMsg(topic, action, data) {
10
+ this.messages.push({ topic, action, data })
11
+ },
12
+ }
13
+ }
14
+
15
+ function createMockClient() {
16
+ return {
17
+ errors: [],
18
+ _$onError(topic, event, err, data) {
19
+ this.errors.push({ topic, event, err, data })
20
+ },
21
+ }
22
+ }
23
+
24
+ function createMockHandler(connection, client) {
25
+ return {
26
+ _client: client,
27
+ _connection: connection,
28
+ }
29
+ }
30
+
31
+ function msg(action, data) {
32
+ return { action, data }
33
+ }
34
+
35
+ let Listener
36
+
37
+ describe('UnicastListener', async () => {
38
+ Listener = (await import('../src/utils/unicast-listener.js')).default
39
+
40
+ describe('constructor', () => {
41
+ it('sends LISTEN with U flag on construction', () => {
42
+ const connection = createMockConnection(true)
43
+ const client = createMockClient()
44
+ const handler = createMockHandler(connection, client)
45
+
46
+ new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler, {})
47
+
48
+ assert.equal(connection.messages.length, 1)
49
+ assert.deepEqual(connection.messages[0], {
50
+ topic: C.TOPIC.RECORD,
51
+ action: C.ACTIONS.LISTEN,
52
+ data: ['test/.*', 'U'],
53
+ })
54
+ })
55
+
56
+ it('throws on recursive option', () => {
57
+ const connection = createMockConnection(true)
58
+ const client = createMockClient()
59
+ const handler = createMockHandler(connection, client)
60
+
61
+ assert.throws(
62
+ () => new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler, { recursive: true }),
63
+ /invalid argument: recursive/,
64
+ )
65
+ })
66
+
67
+ it('throws on stringify option', () => {
68
+ const connection = createMockConnection(true)
69
+ const client = createMockClient()
70
+ const handler = createMockHandler(connection, client)
71
+
72
+ assert.throws(
73
+ () =>
74
+ new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler, {
75
+ stringify: JSON.stringify,
76
+ }),
77
+ /invalid argument: stringify/,
78
+ )
79
+ })
80
+ })
81
+
82
+ describe('_$onMessage - LISTEN_ACCEPT', () => {
83
+ it('calls callback and subscribes when value$ returned', async () => {
84
+ const connection = createMockConnection(true)
85
+ const client = createMockClient()
86
+ const handler = createMockHandler(connection, client)
87
+ const rxjs = await import('rxjs')
88
+
89
+ const value$ = new rxjs.BehaviorSubject('{"key":"value"}')
90
+
91
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => value$, handler, {})
92
+ connection.messages.length = 0
93
+
94
+ const result = listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
95
+
96
+ assert.equal(result, true)
97
+ assert.equal(listener.stats.subscriptions, 1)
98
+
99
+ const updateMsg = connection.messages.find((m) => m.action === C.ACTIONS.UPDATE)
100
+ assert.ok(updateMsg, 'should send UPDATE')
101
+ assert.equal(updateMsg.data[0], 'test/1')
102
+ })
103
+
104
+ it('sends LISTEN_REJECT when callback returns falsy', () => {
105
+ const connection = createMockConnection(true)
106
+ const client = createMockClient()
107
+ const handler = createMockHandler(connection, client)
108
+
109
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler, {})
110
+ connection.messages.length = 0
111
+
112
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
113
+
114
+ assert.equal(listener.stats.subscriptions, 0)
115
+ const rejectMsg = connection.messages.find((m) => m.action === C.ACTIONS.LISTEN_REJECT)
116
+ assert.ok(rejectMsg, 'should send LISTEN_REJECT')
117
+ })
118
+
119
+ it('wraps callback exceptions in throwError and rejects', async () => {
120
+ const connection = createMockConnection(true)
121
+ const client = createMockClient()
122
+ const handler = createMockHandler(connection, client)
123
+
124
+ const listener = new Listener(
125
+ C.TOPIC.RECORD,
126
+ 'test/.*',
127
+ () => {
128
+ throw new Error('callback failed')
129
+ },
130
+ handler,
131
+ {},
132
+ )
133
+ connection.messages.length = 0
134
+
135
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
136
+
137
+ // The error is caught and wrapped in rxjs.throwError, which triggers the PIPE error,
138
+ // which triggers the subscription error handler that sends LISTEN_REJECT
139
+ assert.equal(client.errors.length, 1)
140
+ const rejectMsg = connection.messages.find((m) => m.action === C.ACTIONS.LISTEN_REJECT)
141
+ assert.ok(rejectMsg, 'should send LISTEN_REJECT on error')
142
+ // subscription is removed by the error handler
143
+ assert.equal(listener.stats.subscriptions, 0)
144
+ })
145
+
146
+ it('rejects duplicate subscription names', async () => {
147
+ const connection = createMockConnection(true)
148
+ const client = createMockClient()
149
+ const handler = createMockHandler(connection, client)
150
+ const rxjs = await import('rxjs')
151
+
152
+ const listener = new Listener(
153
+ C.TOPIC.RECORD,
154
+ 'test/.*',
155
+ () => new rxjs.BehaviorSubject('{"x":1}'),
156
+ handler,
157
+ {},
158
+ )
159
+ connection.messages.length = 0
160
+
161
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
162
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
163
+
164
+ assert.equal(client.errors.length, 1)
165
+ assert.ok(String(client.errors[0].err).includes('invalid accept'))
166
+ })
167
+ })
168
+
169
+ describe('_$onMessage - LISTEN_REJECT', () => {
170
+ it('unsubscribes and removes provider', async () => {
171
+ const connection = createMockConnection(true)
172
+ const client = createMockClient()
173
+ const handler = createMockHandler(connection, client)
174
+ const rxjs = await import('rxjs')
175
+
176
+ const value$ = new rxjs.BehaviorSubject('{"key":"value"}')
177
+
178
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => value$, handler, {})
179
+ connection.messages.length = 0
180
+
181
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
182
+ assert.equal(listener.stats.subscriptions, 1)
183
+
184
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_REJECT, ['test/.*', 'test/1']))
185
+ assert.equal(listener.stats.subscriptions, 0)
186
+ })
187
+
188
+ it('errors on removal of unknown subscription', () => {
189
+ const connection = createMockConnection(true)
190
+ const client = createMockClient()
191
+ const handler = createMockHandler(connection, client)
192
+
193
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler, {})
194
+
195
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_REJECT, ['test/.*', 'test/unknown']))
196
+
197
+ assert.equal(client.errors.length, 1)
198
+ assert.ok(String(client.errors[0].err).includes('invalid remove'))
199
+ })
200
+ })
201
+
202
+ describe('_$onMessage - unknown action', () => {
203
+ it('returns false for unrecognized actions', () => {
204
+ const connection = createMockConnection(true)
205
+ const client = createMockClient()
206
+ const handler = createMockHandler(connection, client)
207
+
208
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler, {})
209
+
210
+ const result = listener._$onMessage(msg(C.ACTIONS.UPDATE, ['test/.*', 'test/1']))
211
+ assert.equal(result, false)
212
+ })
213
+ })
214
+
215
+ describe('_$onConnectionStateChange', () => {
216
+ it('re-sends LISTEN on reconnect', () => {
217
+ const connection = createMockConnection(true)
218
+ const client = createMockClient()
219
+ const handler = createMockHandler(connection, client)
220
+
221
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => null, handler, {})
222
+ connection.messages.length = 0
223
+
224
+ listener._$onConnectionStateChange(false)
225
+ listener._$onConnectionStateChange(true)
226
+
227
+ assert.equal(connection.messages.length, 1)
228
+ assert.deepEqual(connection.messages[0], {
229
+ topic: C.TOPIC.RECORD,
230
+ action: C.ACTIONS.LISTEN,
231
+ data: ['test/.*', 'U'],
232
+ })
233
+ })
234
+
235
+ it('clears subscriptions on disconnect without sending UNLISTEN (Bug 5 fix)', async () => {
236
+ const connection = createMockConnection(true)
237
+ const client = createMockClient()
238
+ const handler = createMockHandler(connection, client)
239
+ const rxjs = await import('rxjs')
240
+
241
+ const listener = new Listener(
242
+ C.TOPIC.RECORD,
243
+ 'test/.*',
244
+ () => new rxjs.BehaviorSubject('{"x":1}'),
245
+ handler,
246
+ {},
247
+ )
248
+ connection.messages.length = 0
249
+
250
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
251
+ assert.equal(listener.stats.subscriptions, 1)
252
+
253
+ connection.messages.length = 0
254
+ listener._$onConnectionStateChange(false)
255
+
256
+ assert.equal(listener.stats.subscriptions, 0)
257
+ // Should NOT send UNLISTEN on disconnected connection
258
+ const unlistenMsg = connection.messages.find((m) => m.action === C.ACTIONS.UNLISTEN)
259
+ assert.equal(unlistenMsg, undefined, 'should not send UNLISTEN when disconnected')
260
+ })
261
+ })
262
+
263
+ describe('_$destroy', () => {
264
+ it('sends UNLISTEN and cleans up', async () => {
265
+ const connection = createMockConnection(true)
266
+ const client = createMockClient()
267
+ const handler = createMockHandler(connection, client)
268
+ const rxjs = await import('rxjs')
269
+
270
+ const listener = new Listener(
271
+ C.TOPIC.RECORD,
272
+ 'test/.*',
273
+ () => new rxjs.BehaviorSubject('{"x":1}'),
274
+ handler,
275
+ {},
276
+ )
277
+ connection.messages.length = 0
278
+
279
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
280
+ connection.messages.length = 0
281
+
282
+ listener._$destroy()
283
+
284
+ assert.equal(listener.stats.subscriptions, 0)
285
+ const unlistenMsg = connection.messages.find((m) => m.action === C.ACTIONS.UNLISTEN)
286
+ assert.ok(unlistenMsg, 'should send UNLISTEN on destroy')
287
+ })
288
+ })
289
+
290
+ describe('PIPE - value validation', () => {
291
+ it('rejects non-JSON strings', async () => {
292
+ const connection = createMockConnection(true)
293
+ const client = createMockClient()
294
+ const handler = createMockHandler(connection, client)
295
+ const rxjs = await import('rxjs')
296
+
297
+ const subject = new rxjs.BehaviorSubject('not-json')
298
+
299
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => subject, handler, {})
300
+ connection.messages.length = 0
301
+
302
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
303
+
304
+ // Error should have been reported
305
+ assert.equal(client.errors.length, 1)
306
+ })
307
+
308
+ it('accepts valid JSON strings', async () => {
309
+ const connection = createMockConnection(true)
310
+ const client = createMockClient()
311
+ const handler = createMockHandler(connection, client)
312
+ const rxjs = await import('rxjs')
313
+
314
+ const subject = new rxjs.BehaviorSubject('{"valid": true}')
315
+
316
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => subject, handler, {})
317
+ connection.messages.length = 0
318
+
319
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
320
+
321
+ assert.equal(client.errors.length, 0)
322
+ const updateMsg = connection.messages.find((m) => m.action === C.ACTIONS.UPDATE)
323
+ assert.ok(updateMsg)
324
+ })
325
+
326
+ it('serializes objects to JSON', async () => {
327
+ const connection = createMockConnection(true)
328
+ const client = createMockClient()
329
+ const handler = createMockHandler(connection, client)
330
+ const rxjs = await import('rxjs')
331
+
332
+ const subject = new rxjs.BehaviorSubject({ key: 'value' })
333
+
334
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => subject, handler, {})
335
+ connection.messages.length = 0
336
+
337
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
338
+
339
+ const updateMsg = connection.messages.find((m) => m.action === C.ACTIONS.UPDATE)
340
+ assert.ok(updateMsg)
341
+ assert.equal(updateMsg.data[2], '{"key":"value"}')
342
+ })
343
+
344
+ it('deduplicates identical serialized values', async () => {
345
+ const connection = createMockConnection(true)
346
+ const client = createMockClient()
347
+ const handler = createMockHandler(connection, client)
348
+ const rxjs = await import('rxjs')
349
+
350
+ const subject = new rxjs.BehaviorSubject({ key: 'value' })
351
+
352
+ const listener = new Listener(C.TOPIC.RECORD, 'test/.*', () => subject, handler, {})
353
+ connection.messages.length = 0
354
+
355
+ listener._$onMessage(msg(C.ACTIONS.LISTEN_ACCEPT, ['test/.*', 'test/1']))
356
+
357
+ const count1 = connection.messages.filter((m) => m.action === C.ACTIONS.UPDATE).length
358
+
359
+ // Same value, different object reference
360
+ subject.next({ key: 'value' })
361
+
362
+ const count2 = connection.messages.filter((m) => m.action === C.ACTIONS.UPDATE).length
363
+ assert.equal(count2, count1, 'should deduplicate identical serialized values')
364
+ })
365
+ })
366
+ })
@@ -1,253 +0,0 @@
1
- import * as rxjs from 'rxjs'
2
- import * as C from '../constants/constants.js'
3
- import { h64ToString, findBigIntPaths } from '../utils/utils.js'
4
-
5
- export default class Listener {
6
- constructor(topic, pattern, callback, handler, { recursive = false, stringify = null } = {}) {
7
- this._topic = topic
8
- this._pattern = pattern
9
- this._callback = callback
10
- this._handler = handler
11
- this._client = this._handler._client
12
- this._connection = this._handler._connection
13
- this._subscriptions = new Map()
14
- this._recursive = recursive
15
- this._stringify = stringify || JSON.stringify
16
-
17
- this._$onConnectionStateChange()
18
- }
19
-
20
- get connected() {
21
- return this._connection.connected
22
- }
23
-
24
- get stats() {
25
- return {
26
- subscriptions: this._subscriptions.size,
27
- }
28
- }
29
-
30
- _$destroy() {
31
- this._reset()
32
-
33
- if (this.connected) {
34
- this._connection.sendMsg(this._topic, C.ACTIONS.UNLISTEN, [this._pattern])
35
- }
36
- }
37
-
38
- _$onMessage(message) {
39
- if (!this.connected) {
40
- this._client._$onError(
41
- C.TOPIC.RECORD,
42
- C.EVENT.NOT_CONNECTED,
43
- new Error('received message while not connected'),
44
- message,
45
- )
46
- return
47
- }
48
-
49
- const name = message.data[1]
50
-
51
- if (message.action === C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_FOUND) {
52
- if (this._subscriptions.has(name)) {
53
- this._error(name, 'invalid add: listener exists')
54
- return
55
- }
56
-
57
- // TODO (refactor): Move to class
58
- const provider = {
59
- name,
60
- value$: null,
61
- sending: false,
62
- accepted: false,
63
- version: null,
64
- timeout: null,
65
- patternSubscription: null,
66
- valueSubscription: null,
67
- }
68
- provider.stop = () => {
69
- if (this.connected && provider.accepted) {
70
- this._connection.sendMsg(this._topic, C.ACTIONS.LISTEN_REJECT, [
71
- this._pattern,
72
- provider.name,
73
- ])
74
- }
75
-
76
- provider.value$ = null
77
- provider.version = null
78
- provider.accepted = false
79
- provider.sending = false
80
-
81
- clearTimeout(provider.timeout)
82
- provider.timeout = null
83
-
84
- provider.patternSubscription?.unsubscribe()
85
- provider.patternSubscription = null
86
-
87
- provider.valueSubscription?.unsubscribe()
88
- provider.valueSubscription = null
89
- }
90
- provider.send = () => {
91
- provider.sending = false
92
-
93
- if (!provider.patternSubscription) {
94
- return
95
- }
96
-
97
- const accepted = Boolean(provider.value$)
98
- if (provider.accepted === accepted) {
99
- return
100
- }
101
-
102
- this._connection.sendMsg(
103
- this._topic,
104
- accepted ? C.ACTIONS.LISTEN_ACCEPT : C.ACTIONS.LISTEN_REJECT,
105
- [this._pattern, provider.name],
106
- )
107
-
108
- provider.version = null
109
- provider.accepted = accepted
110
- }
111
- provider.next = (value$) => {
112
- if (!value$) {
113
- value$ = null
114
- } else if (typeof value$.subscribe !== 'function') {
115
- value$ = rxjs.of(value$) // Compat for recursive with value
116
- }
117
-
118
- if (Boolean(provider.value$) !== Boolean(value$) && !provider.sending) {
119
- provider.sending = true
120
- queueMicrotask(provider.send)
121
- }
122
-
123
- provider.value$ = value$
124
-
125
- if (provider.valueSubscription) {
126
- provider.valueSubscription.unsubscribe()
127
- provider.valueSubscription = provider.value$?.subscribe(provider.observer)
128
- }
129
- }
130
- provider.error = (err) => {
131
- provider.stop()
132
- // TODO (feat): backoff retryCount * delay?
133
- // TODO (feat): backoff option?
134
- provider.timeout = setTimeout(() => {
135
- provider.start()
136
- }, 10e3)
137
- this._error(provider.name, err)
138
- }
139
- provider.observer = {
140
- next: (value) => {
141
- if (value == null) {
142
- provider.next(null) // TODO (fix): This is weird...
143
- return
144
- }
145
-
146
- if (this._topic === C.TOPIC.EVENT) {
147
- this._handler.emit(provider.name, value)
148
- } else if (this._topic === C.TOPIC.RECORD) {
149
- if (typeof value !== 'object' && typeof value !== 'string') {
150
- this._error(provider.name, 'invalid value')
151
- return
152
- }
153
-
154
- if (typeof value !== 'string') {
155
- try {
156
- value = this._stringify(value)
157
- } catch (err) {
158
- const bigIntPaths = /BigInt/.test(err.message) ? findBigIntPaths(value) : undefined
159
- this._error(
160
- provider.name,
161
- Object.assign(new Error(`invalid value: ${value}`), {
162
- cause: err,
163
- data: { name: provider.name, bigIntPaths },
164
- }),
165
- )
166
- return
167
- }
168
- }
169
-
170
- const body = value
171
- const hash = h64ToString(body)
172
- const version = `INF-${hash}`
173
-
174
- if (provider.version !== version) {
175
- provider.version = version
176
- this._connection.sendMsg(C.TOPIC.RECORD, C.ACTIONS.UPDATE, [
177
- provider.name,
178
- version,
179
- body,
180
- ])
181
- }
182
- }
183
- },
184
- error: provider.error,
185
- }
186
- provider.start = () => {
187
- try {
188
- const ret$ = this._callback(name)
189
- if (this._recursive && typeof ret$?.subscribe === 'function') {
190
- provider.patternSubscription = ret$.subscribe(provider)
191
- } else {
192
- provider.patternSubscription = rxjs.of(ret$).subscribe(provider)
193
- }
194
- } catch (err) {
195
- this._error(provider.name, err)
196
- }
197
- }
198
-
199
- provider.start()
200
-
201
- this._subscriptions.set(provider.name, provider)
202
- } else if (message.action === C.ACTIONS.LISTEN_ACCEPT) {
203
- const provider = this._subscriptions.get(name)
204
- if (!provider?.value$) {
205
- return
206
- }
207
-
208
- if (provider.valueSubscription) {
209
- this._error(
210
- name,
211
- 'invalid accept: listener started (pattern:' + this._pattern + ' name:' + name + ')',
212
- )
213
- } else {
214
- // TODO (fix): provider.version = message.data[2]
215
- provider.valueSubscription = provider.value$.subscribe(provider.observer)
216
- }
217
- } else if (message.action === C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_REMOVED) {
218
- const provider = this._subscriptions.get(name)
219
-
220
- if (!provider) {
221
- this._error(
222
- name,
223
- 'invalid remove: listener missing (pattern:' + this._pattern + ' name:' + name + ')',
224
- )
225
- } else {
226
- provider.stop()
227
- this._subscriptions.delete(provider.name)
228
- }
229
- } else {
230
- return false
231
- }
232
- return true
233
- }
234
-
235
- _$onConnectionStateChange() {
236
- if (this.connected) {
237
- this._connection.sendMsg(this._topic, C.ACTIONS.LISTEN, [this._pattern])
238
- } else {
239
- this._reset()
240
- }
241
- }
242
-
243
- _error(name, err) {
244
- this._client._$onError(this._topic, C.EVENT.LISTENER_ERROR, err, [this._pattern, name])
245
- }
246
-
247
- _reset() {
248
- for (const provider of this._subscriptions.values()) {
249
- provider.stop()
250
- }
251
- this._subscriptions.clear()
252
- }
253
- }