@nxtedition/deepstream.io-client-js 32.0.26 → 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.
@@ -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,252 +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
- Object.assign(new Error(`invalid value: ${value}`), {
161
- cause: err,
162
- data: { name: provider.name, bigIntPaths },
163
- }),
164
- )
165
- return
166
- }
167
- }
168
-
169
- const body = value
170
- const hash = h64ToString(body)
171
- const version = `INF-${hash}`
172
-
173
- if (provider.version !== version) {
174
- provider.version = version
175
- this._connection.sendMsg(C.TOPIC.RECORD, C.ACTIONS.UPDATE, [
176
- provider.name,
177
- version,
178
- body,
179
- ])
180
- }
181
- }
182
- },
183
- error: provider.error,
184
- }
185
- provider.start = () => {
186
- try {
187
- const ret$ = this._callback(name)
188
- if (this._recursive && typeof ret$?.subscribe === 'function') {
189
- provider.patternSubscription = ret$.subscribe(provider)
190
- } else {
191
- provider.patternSubscription = rxjs.of(ret$).subscribe(provider)
192
- }
193
- } catch (err) {
194
- this._error(provider.name, err)
195
- }
196
- }
197
-
198
- provider.start()
199
-
200
- this._subscriptions.set(provider.name, provider)
201
- } else if (message.action === C.ACTIONS.LISTEN_ACCEPT) {
202
- const provider = this._subscriptions.get(name)
203
- if (!provider?.value$) {
204
- return
205
- }
206
-
207
- if (provider.valueSubscription) {
208
- this._error(
209
- name,
210
- 'invalid accept: listener started (pattern:' + this._pattern + ' name:' + name + ')',
211
- )
212
- } else {
213
- // TODO (fix): provider.version = message.data[2]
214
- provider.valueSubscription = provider.value$.subscribe(provider.observer)
215
- }
216
- } else if (message.action === C.ACTIONS.SUBSCRIPTION_FOR_PATTERN_REMOVED) {
217
- const provider = this._subscriptions.get(name)
218
-
219
- if (!provider) {
220
- this._error(
221
- name,
222
- 'invalid remove: listener missing (pattern:' + this._pattern + ' name:' + name + ')',
223
- )
224
- } else {
225
- provider.stop()
226
- this._subscriptions.delete(provider.name)
227
- }
228
- } else {
229
- return false
230
- }
231
- return true
232
- }
233
-
234
- _$onConnectionStateChange() {
235
- if (this.connected) {
236
- this._connection.sendMsg(this._topic, C.ACTIONS.LISTEN, [this._pattern])
237
- } else {
238
- this._reset()
239
- }
240
- }
241
-
242
- _error(name, err) {
243
- this._client._$onError(this._topic, C.EVENT.LISTENER_ERROR, err, [this._pattern, name])
244
- }
245
-
246
- _reset() {
247
- for (const provider of this._subscriptions.values()) {
248
- provider.stop()
249
- }
250
- this._subscriptions.clear()
251
- }
252
- }