@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 +1 -1
- package/src/event/event-handler.js +4 -0
- package/src/record/record-handler.js +4 -4
- package/src/utils/legacy-listener.js +6 -4
- package/src/utils/unicast-listener.js +7 -3
- package/test/legacy-listener.test.js +450 -0
- package/test/unicast-listener.test.js +366 -0
- package/src/utils/multicast-listener.js +0 -253
package/package.json
CHANGED
|
@@ -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(
|
|
607
|
+
throw new Error(`invalid argument "state": ${state}`)
|
|
608
608
|
}
|
|
609
609
|
|
|
610
610
|
if (!Number.isInteger(timeout) || timeout < 0) {
|
|
611
|
-
throw new Error(
|
|
611
|
+
throw new Error(`invalid argument "timeout": ${timeout}`)
|
|
612
612
|
}
|
|
613
613
|
|
|
614
614
|
if (typeof dataOnly !== 'boolean') {
|
|
615
|
-
throw new Error(
|
|
615
|
+
throw new Error(`invalid argument "dataOnly": ${dataOnly}`)
|
|
616
616
|
}
|
|
617
617
|
|
|
618
618
|
if (typeof sync !== 'boolean') {
|
|
619
|
-
throw new Error(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|