odac 1.4.16 → 1.4.17
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/CHANGELOG.md +12 -0
- package/docs/backend/13-utilities/02-ipc.md +26 -1
- package/package.json +1 -1
- package/src/Ipc.js +87 -14
- package/src/Odac.js +20 -4
- package/src/WebSocket.js +20 -1
- package/test/Ipc/subscribe.test.js +154 -0
- package/test/Ipc/subscribeRedis.test.js +141 -0
- package/test/WebSocket/Client/fragmentation.test.js +24 -5
- package/test/WebSocket/Client/limits.test.js +21 -2
- package/test/WebSocket/Client/readyState.test.js +29 -10
- package/test/WebSocket/Client/send.test.js +148 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
### 🛠️ Fixes & Improvements
|
|
2
|
+
|
|
3
|
+
- repair broken redis pub/sub delivery and align unsubscribe() across drivers
|
|
4
|
+
- send() no longer JSON-serializes Buffer/TypedArray into TEXT frames
|
|
5
|
+
- **test:** close WebSocketClient instances left open after each test
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
Powered by [⚡ ODAC](https://odac.run)
|
|
12
|
+
|
|
1
13
|
### ✨ What's New
|
|
2
14
|
|
|
3
15
|
- add multipart file upload support with validation
|
|
@@ -72,6 +72,29 @@ await Odac.Ipc.publish('chat:global', { user: 'Emre', text: 'Hello World' });
|
|
|
72
72
|
> [!TIP]
|
|
73
73
|
> When using `memory` driver, the subscription listener is registered in the current worker. When a message is published, it goes to the Main process and is then broadcasted to all subscribed workers.
|
|
74
74
|
|
|
75
|
+
#### Unsubscribing
|
|
76
|
+
|
|
77
|
+
A channel can carry several independent subscribers, so a subscription is identified by its callback — not by the channel name alone. `subscribe()` returns a handle that removes exactly the subscription it created, which saves you from holding on to the callback yourself.
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
const stats = await Odac.Ipc.subscribe('server:1:stream', onStats); // long-lived
|
|
81
|
+
const ack = await Odac.Ipc.subscribe('server:1:stream', onAck); // short-lived
|
|
82
|
+
|
|
83
|
+
await ack.unsubscribe(); // only onAck stops; onStats keeps receiving
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`unsubscribe(channel, callback)` does the same thing when you already hold the callback. To drop every subscriber on a channel at once, ask for it explicitly:
|
|
87
|
+
|
|
88
|
+
```javascript
|
|
89
|
+
await Odac.Ipc.unsubscribeAll('server:1:stream');
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> [!WARNING]
|
|
93
|
+
> `unsubscribe(channel)` without a callback cannot tell which subscriber is leaving, so it does nothing and logs a warning. It will throw in the next major version — pass the callback or the handle, or use `unsubscribeAll()`.
|
|
94
|
+
|
|
95
|
+
> [!TIP]
|
|
96
|
+
> Subscriptions created through the request-scoped `Odac.Ipc` are released automatically when the request ends, so you only need to unsubscribe by hand for subscriptions that should end earlier than the request.
|
|
97
|
+
|
|
75
98
|
### Atomic Counters
|
|
76
99
|
|
|
77
100
|
Use `incrBy` / `decrBy` to atomically increment or decrement a numeric key. These are safe to call from multiple workers simultaneously — no read-then-write race conditions.
|
|
@@ -186,5 +209,7 @@ try {
|
|
|
186
209
|
| `srem(key, ...members)` | Remove members from a set |
|
|
187
210
|
| `lock(key, ttl)` | Acquire a mutex lock |
|
|
188
211
|
| `unlock(key)` | Release a mutex lock |
|
|
189
|
-
| `subscribe(channel, handler)` | Subscribe to a Pub/Sub channel |
|
|
212
|
+
| `subscribe(channel, handler)` | Subscribe to a Pub/Sub channel; returns a handle with `unsubscribe()` |
|
|
213
|
+
| `unsubscribe(channel, handler)` | Remove a single subscription, leaving other subscribers intact |
|
|
214
|
+
| `unsubscribeAll(channel)` | Remove every subscription on a channel |
|
|
190
215
|
| `publish(channel, message)` | Publish a message to a channel |
|
package/package.json
CHANGED
package/src/Ipc.js
CHANGED
|
@@ -8,6 +8,7 @@ class Ipc extends EventEmitter {
|
|
|
8
8
|
this.config = {}
|
|
9
9
|
this._requests = new Map() // For memory driver response tracking
|
|
10
10
|
this._subs = new Map() // For memory driver subscriptions
|
|
11
|
+
this._redisBridges = new Map() // channel -> the single node-redis listener bridging to our EventEmitter
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -263,17 +264,47 @@ class Ipc extends EventEmitter {
|
|
|
263
264
|
}
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
/**
|
|
268
|
+
* A single channel may carry several independent consumers (e.g. a long-lived stats
|
|
269
|
+
* listener and a short-lived ack listener on the same stream). The callback reference
|
|
270
|
+
* is what tells them apart, so both drivers key subscriptions on it and both must be
|
|
271
|
+
* able to remove one without disturbing the others.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} channel
|
|
274
|
+
* @param {function} callback
|
|
275
|
+
* @returns {Promise<{channel: string, callback: function, unsubscribe: function}>} Handle
|
|
276
|
+
* that removes only this subscription, so callers need not retain the callback themselves.
|
|
277
|
+
*/
|
|
266
278
|
async subscribe(channel, callback) {
|
|
279
|
+
if (typeof callback !== 'function') {
|
|
280
|
+
throw new TypeError(`Odac.Ipc.subscribe('${channel}') requires a callback function.`)
|
|
281
|
+
}
|
|
282
|
+
|
|
267
283
|
if (this.config.driver === 'redis') {
|
|
268
284
|
if (!this.subRedis) {
|
|
269
285
|
this.subRedis = this.redis.duplicate()
|
|
270
286
|
await this.subRedis.connect()
|
|
271
|
-
this.subRedis.on('message', (chan, msg) => {
|
|
272
|
-
this.emit(chan, JSON.parse(msg))
|
|
273
|
-
})
|
|
274
287
|
}
|
|
275
|
-
//
|
|
276
|
-
|
|
288
|
+
// node-redis v4+ delivers messages to a per-channel listener passed to subscribe();
|
|
289
|
+
// it does not emit a client-wide 'message' event. We register exactly one bridge
|
|
290
|
+
// listener per channel and fan out locally, so removing one consumer never tears
|
|
291
|
+
// down the Redis subscription the other consumers still depend on.
|
|
292
|
+
if (!this._redisBridges.has(channel)) {
|
|
293
|
+
const bridge = raw => {
|
|
294
|
+
try {
|
|
295
|
+
this.emit(channel, JSON.parse(raw))
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.error('[Odac Ipc] Failed to parse message on channel "' + channel + '":', err)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
this._redisBridges.set(channel, bridge)
|
|
301
|
+
try {
|
|
302
|
+
await this.subRedis.subscribe(channel, bridge)
|
|
303
|
+
} catch (err) {
|
|
304
|
+
this._redisBridges.delete(channel)
|
|
305
|
+
throw err
|
|
306
|
+
}
|
|
307
|
+
}
|
|
277
308
|
this.on(channel, callback)
|
|
278
309
|
} else {
|
|
279
310
|
// Memory driver subscription
|
|
@@ -284,27 +315,67 @@ class Ipc extends EventEmitter {
|
|
|
284
315
|
}
|
|
285
316
|
this._subs.get(channel).add(callback)
|
|
286
317
|
}
|
|
318
|
+
|
|
319
|
+
return {channel, callback, unsubscribe: () => this.unsubscribe(channel, callback)}
|
|
287
320
|
}
|
|
288
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Removes a single subscription. Other consumers of the same channel keep receiving
|
|
324
|
+
* messages; the underlying Redis/Primary subscription is torn down only once the last
|
|
325
|
+
* consumer is gone.
|
|
326
|
+
*/
|
|
289
327
|
async unsubscribe(channel, callback) {
|
|
328
|
+
if (typeof callback !== 'function') {
|
|
329
|
+
// Without a callback there is no way to know which consumer is leaving, so we
|
|
330
|
+
// refuse to guess: silently dropping the call leaks the listener, and dropping
|
|
331
|
+
// every listener would kill co-tenants on the channel.
|
|
332
|
+
console.warn(
|
|
333
|
+
`[Odac Ipc] unsubscribe('${channel}') was called without a callback and did nothing. ` +
|
|
334
|
+
`Pass the callback (or the handle returned by subscribe()) to remove one subscription, ` +
|
|
335
|
+
`or call unsubscribeAll('${channel}') to remove every subscription on the channel. ` +
|
|
336
|
+
`This call will throw in the next major version.`
|
|
337
|
+
)
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
|
|
290
341
|
if (this.config.driver === 'redis') {
|
|
291
342
|
this.removeListener(channel, callback)
|
|
292
343
|
// If no more listeners for this channel, unsubscribe from redis to save resources
|
|
293
|
-
if (this.listenerCount(channel) === 0
|
|
294
|
-
await this.
|
|
344
|
+
if (this.listenerCount(channel) === 0) {
|
|
345
|
+
await this._teardownRedisChannel(channel)
|
|
295
346
|
}
|
|
296
347
|
} else {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
this._sendMemory('unsubscribe', {channel})
|
|
303
|
-
}
|
|
348
|
+
const callbacks = this._subs.get(channel)
|
|
349
|
+
if (!callbacks || !callbacks.delete(callback)) return
|
|
350
|
+
if (callbacks.size === 0) {
|
|
351
|
+
this._subs.delete(channel)
|
|
352
|
+
this._sendMemory('unsubscribe', {channel})
|
|
304
353
|
}
|
|
305
354
|
}
|
|
306
355
|
}
|
|
307
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Removes every subscription on a channel. Kept separate from unsubscribe() so that
|
|
359
|
+
* tearing down a shared channel is always a deliberate act, never an accident.
|
|
360
|
+
*/
|
|
361
|
+
async unsubscribeAll(channel) {
|
|
362
|
+
if (this.config.driver === 'redis') {
|
|
363
|
+
this.removeAllListeners(channel)
|
|
364
|
+
await this._teardownRedisChannel(channel)
|
|
365
|
+
} else {
|
|
366
|
+
if (this._subs.delete(channel)) {
|
|
367
|
+
this._sendMemory('unsubscribe', {channel})
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async _teardownRedisChannel(channel) {
|
|
373
|
+
const bridge = this._redisBridges.get(channel)
|
|
374
|
+
if (!bridge) return
|
|
375
|
+
this._redisBridges.delete(channel)
|
|
376
|
+
if (this.subRedis) await this.subRedis.unsubscribe(channel, bridge)
|
|
377
|
+
}
|
|
378
|
+
|
|
308
379
|
// --- Drivers ---
|
|
309
380
|
|
|
310
381
|
async _initRedis() {
|
|
@@ -587,6 +658,8 @@ class Ipc extends EventEmitter {
|
|
|
587
658
|
*/
|
|
588
659
|
async close() {
|
|
589
660
|
if (this.config.driver === 'redis') {
|
|
661
|
+
for (const channel of this._redisBridges.keys()) this.removeAllListeners(channel)
|
|
662
|
+
this._redisBridges.clear()
|
|
590
663
|
if (this.subRedis) {
|
|
591
664
|
await this.subRedis.quit().catch(() => {})
|
|
592
665
|
this.subRedis = null
|
package/src/Odac.js
CHANGED
|
@@ -33,20 +33,36 @@ module.exports = {
|
|
|
33
33
|
_odac._ipcSubs = []
|
|
34
34
|
const ipcSingleton = require('./Ipc.js')
|
|
35
35
|
|
|
36
|
+
const forgetSub = (channel, callback) => {
|
|
37
|
+
const index = _odac._ipcSubs.findIndex(s => s.channel === channel && s.callback === callback)
|
|
38
|
+
if (index > -1) _odac._ipcSubs.splice(index, 1)
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
_odac.Ipc = new Proxy(ipcSingleton, {
|
|
37
42
|
get(target, prop) {
|
|
38
43
|
if (prop === 'subscribe') {
|
|
39
44
|
return async (channel, callback) => {
|
|
40
|
-
const
|
|
45
|
+
const handle = await target.subscribe(channel, callback)
|
|
41
46
|
_odac._ipcSubs.push({channel, callback})
|
|
42
|
-
|
|
47
|
+
// Route the handle's unsubscribe back through this instance so that releasing a
|
|
48
|
+
// subscription via the handle also drops it from the request-scoped cleanup list.
|
|
49
|
+
return {
|
|
50
|
+
...handle,
|
|
51
|
+
unsubscribe: () => _odac.Ipc.unsubscribe(channel, callback)
|
|
52
|
+
}
|
|
43
53
|
}
|
|
44
54
|
}
|
|
45
55
|
if (prop === 'unsubscribe') {
|
|
46
56
|
return async (channel, callback) => {
|
|
47
57
|
const res = await target.unsubscribe(channel, callback)
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
forgetSub(channel, callback)
|
|
59
|
+
return res
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (prop === 'unsubscribeAll') {
|
|
63
|
+
return async channel => {
|
|
64
|
+
const res = await target.unsubscribeAll(channel)
|
|
65
|
+
_odac._ipcSubs = _odac._ipcSubs.filter(s => s.channel !== channel)
|
|
50
66
|
return res
|
|
51
67
|
}
|
|
52
68
|
}
|
package/src/WebSocket.js
CHANGED
|
@@ -25,6 +25,21 @@ const READY_STATE = {
|
|
|
25
25
|
CLOSED: 3
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const isBinary = data => Buffer.isBuffer(data) || ArrayBuffer.isView(data) || data instanceof ArrayBuffer
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Views must be wrapped over their backing memory rather than passed to
|
|
32
|
+
* Buffer.from() directly: for anything wider than a byte (Uint16Array,
|
|
33
|
+
* Float32Array, DataView) Buffer.from() reads the view as a list of numbers
|
|
34
|
+
* and truncates each element to a single byte.
|
|
35
|
+
*/
|
|
36
|
+
const toPayload = data => {
|
|
37
|
+
if (Buffer.isBuffer(data)) return data
|
|
38
|
+
if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
|
|
39
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data)
|
|
40
|
+
return Buffer.from(data)
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
class WebSocketClient {
|
|
29
44
|
static CONNECTING = READY_STATE.CONNECTING
|
|
30
45
|
static OPEN = READY_STATE.OPEN
|
|
@@ -264,7 +279,7 @@ class WebSocketClient {
|
|
|
264
279
|
if (this.#readyState === READY_STATE.CLOSING && opcode !== OPCODE.CLOSE) return
|
|
265
280
|
if (!this.#socket.writable) return
|
|
266
281
|
|
|
267
|
-
const payload =
|
|
282
|
+
const payload = toPayload(data)
|
|
268
283
|
const length = payload.length
|
|
269
284
|
|
|
270
285
|
let header
|
|
@@ -313,6 +328,10 @@ class WebSocketClient {
|
|
|
313
328
|
|
|
314
329
|
send(data) {
|
|
315
330
|
if (this.#readyState !== READY_STATE.OPEN) return this
|
|
331
|
+
if (isBinary(data)) {
|
|
332
|
+
this.#sendFrame(OPCODE.BINARY, data)
|
|
333
|
+
return this
|
|
334
|
+
}
|
|
316
335
|
const payload = typeof data === 'object' ? JSON.stringify(data) : String(data)
|
|
317
336
|
this.#sendFrame(OPCODE.TEXT, payload)
|
|
318
337
|
return this
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const cluster = require('node:cluster')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests Ipc pub/sub subscription lifecycle on the memory driver.
|
|
7
|
+
* Why: A channel routinely carries several independent consumers (e.g. a long-lived stats
|
|
8
|
+
* listener and a short-lived ack listener on the same stream). Removing one must never
|
|
9
|
+
* silently leak it, nor take the co-tenants down with it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
let Ipc
|
|
13
|
+
let originalSend
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
jest.resetModules()
|
|
17
|
+
// Subscriptions are a worker-side concern: the Primary only tracks which workers listen.
|
|
18
|
+
Object.defineProperty(cluster, 'isPrimary', {value: false, configurable: true})
|
|
19
|
+
originalSend = process.send
|
|
20
|
+
process.send = jest.fn()
|
|
21
|
+
|
|
22
|
+
Ipc = require('../../src/Ipc')
|
|
23
|
+
global.Odac = {Config: {}}
|
|
24
|
+
await Ipc.init()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await Ipc.close()
|
|
29
|
+
process.removeAllListeners('message')
|
|
30
|
+
process.send = originalSend
|
|
31
|
+
delete global.Odac
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** Simulates the Primary forwarding a published message down to this worker. */
|
|
35
|
+
const deliver = (channel, message) => process.emit('message', {type: 'ipc:message', channel, message})
|
|
36
|
+
|
|
37
|
+
describe('Ipc - subscribe()', () => {
|
|
38
|
+
it('should deliver messages to the subscriber', async () => {
|
|
39
|
+
const cb = jest.fn()
|
|
40
|
+
await Ipc.subscribe('chan', cb)
|
|
41
|
+
|
|
42
|
+
deliver('chan', {hello: 'world'})
|
|
43
|
+
|
|
44
|
+
expect(cb).toHaveBeenCalledWith({hello: 'world'})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should fan out to every subscriber on the same channel', async () => {
|
|
48
|
+
const stats = jest.fn()
|
|
49
|
+
const ack = jest.fn()
|
|
50
|
+
await Ipc.subscribe('stream', stats)
|
|
51
|
+
await Ipc.subscribe('stream', ack)
|
|
52
|
+
|
|
53
|
+
deliver('stream', 'tick')
|
|
54
|
+
|
|
55
|
+
expect(stats).toHaveBeenCalledWith('tick')
|
|
56
|
+
expect(ack).toHaveBeenCalledWith('tick')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('should reject a missing callback instead of registering undefined', async () => {
|
|
60
|
+
await expect(Ipc.subscribe('chan')).rejects.toThrow(TypeError)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should return a handle that removes only its own subscription', async () => {
|
|
64
|
+
const stats = jest.fn()
|
|
65
|
+
const ack = jest.fn()
|
|
66
|
+
await Ipc.subscribe('stream', stats)
|
|
67
|
+
const ackSub = await Ipc.subscribe('stream', ack)
|
|
68
|
+
|
|
69
|
+
await ackSub.unsubscribe()
|
|
70
|
+
deliver('stream', 'tick')
|
|
71
|
+
|
|
72
|
+
expect(ack).not.toHaveBeenCalled()
|
|
73
|
+
expect(stats).toHaveBeenCalledWith('tick')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('Ipc - unsubscribe()', () => {
|
|
78
|
+
it('should stop delivering to the removed callback', async () => {
|
|
79
|
+
const cb = jest.fn()
|
|
80
|
+
await Ipc.subscribe('chan', cb)
|
|
81
|
+
await Ipc.unsubscribe('chan', cb)
|
|
82
|
+
|
|
83
|
+
deliver('chan', 'tick')
|
|
84
|
+
|
|
85
|
+
expect(cb).not.toHaveBeenCalled()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('should keep co-tenants on the channel alive', async () => {
|
|
89
|
+
const stats = jest.fn()
|
|
90
|
+
const ack = jest.fn()
|
|
91
|
+
await Ipc.subscribe('stream', stats)
|
|
92
|
+
await Ipc.subscribe('stream', ack)
|
|
93
|
+
|
|
94
|
+
await Ipc.unsubscribe('stream', ack)
|
|
95
|
+
deliver('stream', 'tick')
|
|
96
|
+
|
|
97
|
+
expect(ack).not.toHaveBeenCalled()
|
|
98
|
+
expect(stats).toHaveBeenCalledWith('tick')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should tell the Primary only once the last subscriber leaves', async () => {
|
|
102
|
+
const stats = jest.fn()
|
|
103
|
+
const ack = jest.fn()
|
|
104
|
+
await Ipc.subscribe('stream', stats)
|
|
105
|
+
await Ipc.subscribe('stream', ack)
|
|
106
|
+
|
|
107
|
+
const sentUnsub = () => process.send.mock.calls.filter(([m]) => m.type === 'ipc:unsubscribe').length
|
|
108
|
+
|
|
109
|
+
await Ipc.unsubscribe('stream', ack)
|
|
110
|
+
expect(sentUnsub()).toBe(0)
|
|
111
|
+
|
|
112
|
+
await Ipc.unsubscribe('stream', stats)
|
|
113
|
+
expect(sentUnsub()).toBe(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should warn and do nothing when the callback is omitted', async () => {
|
|
117
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
118
|
+
const stats = jest.fn()
|
|
119
|
+
await Ipc.subscribe('stream', stats)
|
|
120
|
+
|
|
121
|
+
await Ipc.unsubscribe('stream')
|
|
122
|
+
deliver('stream', 'tick')
|
|
123
|
+
|
|
124
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('without a callback'))
|
|
125
|
+
expect(stats).toHaveBeenCalledWith('tick')
|
|
126
|
+
warn.mockRestore()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should ignore an unknown channel', async () => {
|
|
130
|
+
await expect(Ipc.unsubscribe('never-subscribed', jest.fn())).resolves.toBeUndefined()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('Ipc - unsubscribeAll()', () => {
|
|
135
|
+
it('should remove every subscriber and notify the Primary', async () => {
|
|
136
|
+
const stats = jest.fn()
|
|
137
|
+
const ack = jest.fn()
|
|
138
|
+
await Ipc.subscribe('stream', stats)
|
|
139
|
+
await Ipc.subscribe('stream', ack)
|
|
140
|
+
|
|
141
|
+
await Ipc.unsubscribeAll('stream')
|
|
142
|
+
deliver('stream', 'tick')
|
|
143
|
+
|
|
144
|
+
expect(stats).not.toHaveBeenCalled()
|
|
145
|
+
expect(ack).not.toHaveBeenCalled()
|
|
146
|
+
expect(process.send).toHaveBeenCalledWith(expect.objectContaining({type: 'ipc:unsubscribe', channel: 'stream'}))
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should not notify the Primary for a channel it never joined', async () => {
|
|
150
|
+
await Ipc.unsubscribeAll('never-subscribed')
|
|
151
|
+
|
|
152
|
+
expect(process.send).not.toHaveBeenCalledWith(expect.objectContaining({type: 'ipc:unsubscribe'}))
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests Ipc pub/sub on the redis driver.
|
|
5
|
+
* Why: node-redis v4+ delivers messages to a listener passed to subscribe() and emits no
|
|
6
|
+
* client-wide 'message' event, so the bridge must be wired per channel. These tests pin
|
|
7
|
+
* that wiring and assert the driver removes subscriptions with the same per-callback
|
|
8
|
+
* semantics as the memory driver.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Channel -> the listener node-redis was handed. Lets a test push a message the way a real
|
|
12
|
+
// server would, without a live Redis.
|
|
13
|
+
const mockChannels = new Map()
|
|
14
|
+
|
|
15
|
+
const mockSubClient = {
|
|
16
|
+
connect: jest.fn().mockResolvedValue(undefined),
|
|
17
|
+
subscribe: jest.fn(async (channel, listener) => mockChannels.set(channel, listener)),
|
|
18
|
+
unsubscribe: jest.fn(async channel => mockChannels.delete(channel)),
|
|
19
|
+
quit: jest.fn().mockResolvedValue(undefined)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const mockClient = {
|
|
23
|
+
connect: jest.fn().mockResolvedValue(undefined),
|
|
24
|
+
duplicate: jest.fn(() => mockSubClient),
|
|
25
|
+
publish: jest.fn().mockResolvedValue(1),
|
|
26
|
+
quit: jest.fn().mockResolvedValue(undefined)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
jest.mock('redis', () => ({createClient: jest.fn(() => mockClient)}), {virtual: true})
|
|
30
|
+
|
|
31
|
+
let Ipc
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
jest.clearAllMocks()
|
|
35
|
+
jest.resetModules()
|
|
36
|
+
mockChannels.clear()
|
|
37
|
+
|
|
38
|
+
Ipc = require('../../src/Ipc')
|
|
39
|
+
global.Odac = {Config: {ipc: {driver: 'redis'}}}
|
|
40
|
+
await Ipc.init()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await Ipc.close()
|
|
45
|
+
delete global.Odac
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** Simulates Redis pushing a message on a channel, as node-redis would. */
|
|
49
|
+
const deliver = (channel, message) => mockChannels.get(channel)?.(JSON.stringify(message), channel)
|
|
50
|
+
|
|
51
|
+
describe('Ipc redis - subscribe()', () => {
|
|
52
|
+
it('should register a listener with redis and deliver parsed messages', async () => {
|
|
53
|
+
const cb = jest.fn()
|
|
54
|
+
await Ipc.subscribe('chan', cb)
|
|
55
|
+
|
|
56
|
+
expect(mockSubClient.subscribe).toHaveBeenCalledWith('chan', expect.any(Function))
|
|
57
|
+
|
|
58
|
+
deliver('chan', {hello: 'world'})
|
|
59
|
+
expect(cb).toHaveBeenCalledWith({hello: 'world'})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should subscribe to a channel only once, however many consumers join', async () => {
|
|
63
|
+
await Ipc.subscribe('stream', jest.fn())
|
|
64
|
+
await Ipc.subscribe('stream', jest.fn())
|
|
65
|
+
|
|
66
|
+
expect(mockSubClient.subscribe).toHaveBeenCalledTimes(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should fan out to every consumer on the same channel', async () => {
|
|
70
|
+
const stats = jest.fn()
|
|
71
|
+
const ack = jest.fn()
|
|
72
|
+
await Ipc.subscribe('stream', stats)
|
|
73
|
+
await Ipc.subscribe('stream', ack)
|
|
74
|
+
|
|
75
|
+
deliver('stream', 'tick')
|
|
76
|
+
|
|
77
|
+
expect(stats).toHaveBeenCalledWith('tick')
|
|
78
|
+
expect(ack).toHaveBeenCalledWith('tick')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should reject a missing callback instead of registering undefined', async () => {
|
|
82
|
+
await expect(Ipc.subscribe('chan')).rejects.toThrow(TypeError)
|
|
83
|
+
expect(mockSubClient.subscribe).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('Ipc redis - unsubscribe()', () => {
|
|
88
|
+
it('should keep co-tenants alive and hold the redis subscription open', async () => {
|
|
89
|
+
const stats = jest.fn()
|
|
90
|
+
const ack = jest.fn()
|
|
91
|
+
await Ipc.subscribe('stream', stats)
|
|
92
|
+
const ackSub = await Ipc.subscribe('stream', ack)
|
|
93
|
+
|
|
94
|
+
await ackSub.unsubscribe()
|
|
95
|
+
|
|
96
|
+
expect(mockSubClient.unsubscribe).not.toHaveBeenCalled()
|
|
97
|
+
|
|
98
|
+
deliver('stream', 'tick')
|
|
99
|
+
expect(ack).not.toHaveBeenCalled()
|
|
100
|
+
expect(stats).toHaveBeenCalledWith('tick')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should drop the redis subscription once the last consumer leaves', async () => {
|
|
104
|
+
const stats = jest.fn()
|
|
105
|
+
const ack = jest.fn()
|
|
106
|
+
await Ipc.subscribe('stream', stats)
|
|
107
|
+
await Ipc.subscribe('stream', ack)
|
|
108
|
+
|
|
109
|
+
await Ipc.unsubscribe('stream', ack)
|
|
110
|
+
await Ipc.unsubscribe('stream', stats)
|
|
111
|
+
|
|
112
|
+
expect(mockSubClient.unsubscribe).toHaveBeenCalledWith('stream', expect.any(Function))
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should warn instead of throwing when the callback is omitted', async () => {
|
|
116
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
117
|
+
const stats = jest.fn()
|
|
118
|
+
await Ipc.subscribe('stream', stats)
|
|
119
|
+
|
|
120
|
+
await expect(Ipc.unsubscribe('stream')).resolves.toBeUndefined()
|
|
121
|
+
|
|
122
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('without a callback'))
|
|
123
|
+
deliver('stream', 'tick')
|
|
124
|
+
expect(stats).toHaveBeenCalledWith('tick')
|
|
125
|
+
warn.mockRestore()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('Ipc redis - unsubscribeAll()', () => {
|
|
130
|
+
it('should remove every consumer and drop the redis subscription', async () => {
|
|
131
|
+
const stats = jest.fn()
|
|
132
|
+
const ack = jest.fn()
|
|
133
|
+
await Ipc.subscribe('stream', stats)
|
|
134
|
+
await Ipc.subscribe('stream', ack)
|
|
135
|
+
|
|
136
|
+
await Ipc.unsubscribeAll('stream')
|
|
137
|
+
|
|
138
|
+
expect(mockSubClient.unsubscribe).toHaveBeenCalledWith('stream', expect.any(Function))
|
|
139
|
+
expect(Ipc.listenerCount('stream')).toBe(0)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -31,6 +31,25 @@ function createMockSocket() {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const openClients = []
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a client and registers it for teardown.
|
|
38
|
+
*
|
|
39
|
+
* The constructor starts a rate-limit interval that is only cleared on close(),
|
|
40
|
+
* so a client left open keeps Jest's event loop alive after the run finishes.
|
|
41
|
+
*/
|
|
42
|
+
function createClient(...args) {
|
|
43
|
+
const client = new WebSocketClient(...args)
|
|
44
|
+
openClients.push(client)
|
|
45
|
+
return client
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
for (const client of openClients) client.close()
|
|
50
|
+
openClients.length = 0
|
|
51
|
+
})
|
|
52
|
+
|
|
34
53
|
describe('WebSocketClient Fragmentation', () => {
|
|
35
54
|
let server
|
|
36
55
|
|
|
@@ -40,7 +59,7 @@ describe('WebSocketClient Fragmentation', () => {
|
|
|
40
59
|
|
|
41
60
|
it('should reassemble fragmented text messages', () => {
|
|
42
61
|
const socket = createMockSocket()
|
|
43
|
-
const client =
|
|
62
|
+
const client = createClient(socket, server, 'frag-1')
|
|
44
63
|
client.resume()
|
|
45
64
|
|
|
46
65
|
const messages = []
|
|
@@ -60,7 +79,7 @@ describe('WebSocketClient Fragmentation', () => {
|
|
|
60
79
|
|
|
61
80
|
it('should reassemble fragmented binary messages', () => {
|
|
62
81
|
const socket = createMockSocket()
|
|
63
|
-
const client =
|
|
82
|
+
const client = createClient(socket, server, 'frag-2')
|
|
64
83
|
client.resume()
|
|
65
84
|
|
|
66
85
|
const messages = []
|
|
@@ -83,7 +102,7 @@ describe('WebSocketClient Fragmentation', () => {
|
|
|
83
102
|
|
|
84
103
|
it('should close with 1002 on unexpected continuation frame', () => {
|
|
85
104
|
const socket = createMockSocket()
|
|
86
|
-
const client =
|
|
105
|
+
const client = createClient(socket, server, 'frag-3')
|
|
87
106
|
client.resume()
|
|
88
107
|
|
|
89
108
|
const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
|
|
@@ -96,7 +115,7 @@ describe('WebSocketClient Fragmentation', () => {
|
|
|
96
115
|
|
|
97
116
|
it('should handle single unfragmented message normally', () => {
|
|
98
117
|
const socket = createMockSocket()
|
|
99
|
-
const client =
|
|
118
|
+
const client = createClient(socket, server, 'frag-4')
|
|
100
119
|
client.resume()
|
|
101
120
|
|
|
102
121
|
const messages = []
|
|
@@ -112,7 +131,7 @@ describe('WebSocketClient Fragmentation', () => {
|
|
|
112
131
|
|
|
113
132
|
it('should discard fragment buffer on close', () => {
|
|
114
133
|
const socket = createMockSocket()
|
|
115
|
-
const client =
|
|
134
|
+
const client = createClient(socket, server, 'frag-5')
|
|
116
135
|
client.resume()
|
|
117
136
|
|
|
118
137
|
const messages = []
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
const {WebSocketServer, WebSocketClient} = require('../../../src/WebSocket.js')
|
|
2
2
|
|
|
3
|
+
const openClients = []
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a client and registers it for teardown.
|
|
7
|
+
*
|
|
8
|
+
* The constructor starts a rate-limit interval that is only cleared on close(),
|
|
9
|
+
* so a client left open keeps Jest's event loop alive after the run finishes.
|
|
10
|
+
*/
|
|
11
|
+
function createClient(...args) {
|
|
12
|
+
const client = new WebSocketClient(...args)
|
|
13
|
+
openClients.push(client)
|
|
14
|
+
return client
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const client of openClients) client.close()
|
|
19
|
+
openClients.length = 0
|
|
20
|
+
})
|
|
21
|
+
|
|
3
22
|
describe('WebSocketClient Limits', () => {
|
|
4
23
|
let server
|
|
5
24
|
|
|
@@ -18,7 +37,7 @@ describe('WebSocketClient Limits', () => {
|
|
|
18
37
|
removeAllListeners: jest.fn(),
|
|
19
38
|
writable: true
|
|
20
39
|
}
|
|
21
|
-
const client =
|
|
40
|
+
const client = createClient(socket, server, 'test-id', {maxPayload: 10})
|
|
22
41
|
client.resume()
|
|
23
42
|
|
|
24
43
|
const buffer = Buffer.alloc(100)
|
|
@@ -42,7 +61,7 @@ describe('WebSocketClient Limits', () => {
|
|
|
42
61
|
removeAllListeners: jest.fn(),
|
|
43
62
|
writable: true
|
|
44
63
|
}
|
|
45
|
-
const client =
|
|
64
|
+
const client = createClient(socket, server, 'test-id', {rateLimit: {max: 2, window: 1000}})
|
|
46
65
|
client.resume()
|
|
47
66
|
|
|
48
67
|
const buffer = Buffer.alloc(7)
|
|
@@ -16,6 +16,25 @@ function createMockSocket() {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const openClients = []
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a client and registers it for teardown.
|
|
23
|
+
*
|
|
24
|
+
* The constructor starts a rate-limit interval that is only cleared on close(),
|
|
25
|
+
* so a client left open keeps Jest's event loop alive after the run finishes.
|
|
26
|
+
*/
|
|
27
|
+
function createClient(...args) {
|
|
28
|
+
const client = new WebSocketClient(...args)
|
|
29
|
+
openClients.push(client)
|
|
30
|
+
return client
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
for (const client of openClients) client.close()
|
|
35
|
+
openClients.length = 0
|
|
36
|
+
})
|
|
37
|
+
|
|
19
38
|
describe('WebSocketClient readyState', () => {
|
|
20
39
|
let server
|
|
21
40
|
|
|
@@ -41,13 +60,13 @@ describe('WebSocketClient readyState', () => {
|
|
|
41
60
|
|
|
42
61
|
it('should start in CONNECTING state', () => {
|
|
43
62
|
const socket = createMockSocket()
|
|
44
|
-
const client =
|
|
63
|
+
const client = createClient(socket, server, 'rs-1')
|
|
45
64
|
expect(client.readyState).toBe(READY_STATE.CONNECTING)
|
|
46
65
|
})
|
|
47
66
|
|
|
48
67
|
it('should transition to OPEN on resume()', () => {
|
|
49
68
|
const socket = createMockSocket()
|
|
50
|
-
const client =
|
|
69
|
+
const client = createClient(socket, server, 'rs-2')
|
|
51
70
|
|
|
52
71
|
client.resume()
|
|
53
72
|
|
|
@@ -57,7 +76,7 @@ describe('WebSocketClient readyState', () => {
|
|
|
57
76
|
|
|
58
77
|
it('should transition to CLOSED after close()', () => {
|
|
59
78
|
const socket = createMockSocket()
|
|
60
|
-
const client =
|
|
79
|
+
const client = createClient(socket, server, 'rs-3')
|
|
61
80
|
client.resume()
|
|
62
81
|
|
|
63
82
|
client.close()
|
|
@@ -68,7 +87,7 @@ describe('WebSocketClient readyState', () => {
|
|
|
68
87
|
|
|
69
88
|
it('should be idempotent — second close() is a no-op', () => {
|
|
70
89
|
const socket = createMockSocket()
|
|
71
|
-
const client =
|
|
90
|
+
const client = createClient(socket, server, 'rs-4')
|
|
72
91
|
client.resume()
|
|
73
92
|
|
|
74
93
|
client.close()
|
|
@@ -79,7 +98,7 @@ describe('WebSocketClient readyState', () => {
|
|
|
79
98
|
|
|
80
99
|
it('should not send data when in CONNECTING state', () => {
|
|
81
100
|
const socket = createMockSocket()
|
|
82
|
-
const client =
|
|
101
|
+
const client = createClient(socket, server, 'rs-5')
|
|
83
102
|
|
|
84
103
|
client.send('hello')
|
|
85
104
|
|
|
@@ -88,7 +107,7 @@ describe('WebSocketClient readyState', () => {
|
|
|
88
107
|
|
|
89
108
|
it('should not send data when in CLOSED state', () => {
|
|
90
109
|
const socket = createMockSocket()
|
|
91
|
-
const client =
|
|
110
|
+
const client = createClient(socket, server, 'rs-6')
|
|
92
111
|
client.resume()
|
|
93
112
|
client.close()
|
|
94
113
|
|
|
@@ -103,7 +122,7 @@ describe('WebSocketClient readyState', () => {
|
|
|
103
122
|
|
|
104
123
|
it('should not send ping when not OPEN', () => {
|
|
105
124
|
const socket = createMockSocket()
|
|
106
|
-
const client =
|
|
125
|
+
const client = createClient(socket, server, 'rs-7')
|
|
107
126
|
|
|
108
127
|
client.ping()
|
|
109
128
|
|
|
@@ -113,7 +132,7 @@ describe('WebSocketClient readyState', () => {
|
|
|
113
132
|
it('should not write when socket is not writable', () => {
|
|
114
133
|
const socket = createMockSocket()
|
|
115
134
|
socket.writable = false
|
|
116
|
-
const client =
|
|
135
|
+
const client = createClient(socket, server, 'rs-8')
|
|
117
136
|
client.resume()
|
|
118
137
|
|
|
119
138
|
client.send('hello')
|
|
@@ -123,7 +142,7 @@ describe('WebSocketClient readyState', () => {
|
|
|
123
142
|
|
|
124
143
|
it('should transition to CLOSED when socket fires close event', () => {
|
|
125
144
|
const socket = createMockSocket()
|
|
126
|
-
const client =
|
|
145
|
+
const client = createClient(socket, server, 'rs-9')
|
|
127
146
|
server.clients.set('rs-9', client)
|
|
128
147
|
client.resume()
|
|
129
148
|
|
|
@@ -136,7 +155,7 @@ describe('WebSocketClient readyState', () => {
|
|
|
136
155
|
|
|
137
156
|
it('should emit close event only once on double cleanup', () => {
|
|
138
157
|
const socket = createMockSocket()
|
|
139
|
-
const client =
|
|
158
|
+
const client = createClient(socket, server, 'rs-10')
|
|
140
159
|
server.clients.set('rs-10', client)
|
|
141
160
|
client.resume()
|
|
142
161
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const {WebSocketServer, WebSocketClient} = require('../../../src/WebSocket.js')
|
|
2
|
+
|
|
3
|
+
function createMockSocket() {
|
|
4
|
+
return {
|
|
5
|
+
pause: jest.fn(),
|
|
6
|
+
resume: jest.fn(),
|
|
7
|
+
on: jest.fn(),
|
|
8
|
+
write: jest.fn(),
|
|
9
|
+
end: jest.fn(),
|
|
10
|
+
removeAllListeners: jest.fn(),
|
|
11
|
+
writable: true
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Splits the frame handed to socket.write() back into opcode and payload.
|
|
17
|
+
* Server-sent frames are never masked, so the payload starts right after
|
|
18
|
+
* the (possibly extended) length field.
|
|
19
|
+
*/
|
|
20
|
+
function readFrame(socket) {
|
|
21
|
+
const frame = socket.write.mock.calls[0][0]
|
|
22
|
+
const opcode = frame[0] & 0x0f
|
|
23
|
+
const length = frame[1] & 0x7f
|
|
24
|
+
|
|
25
|
+
let offset = 2
|
|
26
|
+
if (length === 126) offset = 4
|
|
27
|
+
else if (length === 127) offset = 10
|
|
28
|
+
|
|
29
|
+
return {opcode, payload: frame.subarray(offset)}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const openClients = []
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a client and registers it for teardown.
|
|
36
|
+
*
|
|
37
|
+
* The constructor starts a rate-limit interval that is only cleared on close(),
|
|
38
|
+
* so a client left open keeps Jest's event loop alive after the run finishes.
|
|
39
|
+
*/
|
|
40
|
+
function createClient(...args) {
|
|
41
|
+
const client = new WebSocketClient(...args)
|
|
42
|
+
openClients.push(client)
|
|
43
|
+
return client
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
for (const client of openClients) client.close()
|
|
48
|
+
openClients.length = 0
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('WebSocketClient send', () => {
|
|
52
|
+
let server
|
|
53
|
+
let client
|
|
54
|
+
let socket
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
server = new WebSocketServer()
|
|
58
|
+
socket = createMockSocket()
|
|
59
|
+
client = createClient(socket, server, 'send-1')
|
|
60
|
+
client.resume()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should send strings as TEXT frames', () => {
|
|
64
|
+
client.send('hello')
|
|
65
|
+
|
|
66
|
+
const {opcode, payload} = readFrame(socket)
|
|
67
|
+
|
|
68
|
+
expect(opcode).toBe(0x1)
|
|
69
|
+
expect(payload.toString('utf8')).toBe('hello')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should send plain objects as JSON TEXT frames', () => {
|
|
73
|
+
client.send({type: 'ping', n: 1})
|
|
74
|
+
|
|
75
|
+
const {opcode, payload} = readFrame(socket)
|
|
76
|
+
|
|
77
|
+
expect(opcode).toBe(0x1)
|
|
78
|
+
expect(JSON.parse(payload.toString('utf8'))).toEqual({type: 'ping', n: 1})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should send a Buffer as a BINARY frame instead of JSON', () => {
|
|
82
|
+
client.send(Buffer.from([0xde, 0xad, 0xbe, 0xef]))
|
|
83
|
+
|
|
84
|
+
const {opcode, payload} = readFrame(socket)
|
|
85
|
+
|
|
86
|
+
expect(opcode).toBe(0x2)
|
|
87
|
+
expect([...payload]).toEqual([0xde, 0xad, 0xbe, 0xef])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should send a TypedArray as a BINARY frame', () => {
|
|
91
|
+
client.send(new Uint8Array([1, 2, 3]))
|
|
92
|
+
|
|
93
|
+
const {opcode, payload} = readFrame(socket)
|
|
94
|
+
|
|
95
|
+
expect(opcode).toBe(0x2)
|
|
96
|
+
expect([...payload]).toEqual([1, 2, 3])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should send an ArrayBuffer as a BINARY frame', () => {
|
|
100
|
+
const view = new Uint8Array([9, 8, 7])
|
|
101
|
+
client.send(view.buffer)
|
|
102
|
+
|
|
103
|
+
const {opcode, payload} = readFrame(socket)
|
|
104
|
+
|
|
105
|
+
expect(opcode).toBe(0x2)
|
|
106
|
+
expect([...payload]).toEqual([9, 8, 7])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should preserve the bytes of multi-byte TypedArrays', () => {
|
|
110
|
+
// Buffer.from(view) would read each element as a number and truncate it
|
|
111
|
+
// to one byte, silently corrupting anything wider than a Uint8Array.
|
|
112
|
+
client.send(new Uint16Array([0x0100, 0x0200]))
|
|
113
|
+
|
|
114
|
+
const {opcode, payload} = readFrame(socket)
|
|
115
|
+
|
|
116
|
+
expect(opcode).toBe(0x2)
|
|
117
|
+
expect(payload.length).toBe(4)
|
|
118
|
+
expect([...payload]).toEqual([0x00, 0x01, 0x00, 0x02])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should honour the byteOffset of a view over a larger buffer', () => {
|
|
122
|
+
const backing = new Uint8Array([1, 2, 3, 4, 5, 6])
|
|
123
|
+
const view = backing.subarray(2, 5)
|
|
124
|
+
|
|
125
|
+
client.send(view)
|
|
126
|
+
|
|
127
|
+
const {payload} = readFrame(socket)
|
|
128
|
+
|
|
129
|
+
expect([...payload]).toEqual([3, 4, 5])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should send binary through sendBinary without corrupting wide views', () => {
|
|
133
|
+
client.sendBinary(new Uint16Array([0x0100]))
|
|
134
|
+
|
|
135
|
+
const {opcode, payload} = readFrame(socket)
|
|
136
|
+
|
|
137
|
+
expect(opcode).toBe(0x2)
|
|
138
|
+
expect([...payload]).toEqual([0x00, 0x01])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('should not send when the connection is not open', () => {
|
|
142
|
+
const closed = createClient(createMockSocket(), server, 'send-2')
|
|
143
|
+
|
|
144
|
+
closed.send('hello')
|
|
145
|
+
|
|
146
|
+
expect(closed.readyState).toBe(WebSocketClient.CONNECTING)
|
|
147
|
+
})
|
|
148
|
+
})
|