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 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
@@ -7,7 +7,7 @@
7
7
  "email": "mail@emre.red",
8
8
  "url": "https://emre.red"
9
9
  },
10
- "version": "1.4.16",
10
+ "version": "1.4.17",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
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
- // Redis handles duplicate subscriptions gracefully (ignores them)
276
- await this.subRedis.subscribe(channel)
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 && this.subRedis) {
294
- await this.subRedis.unsubscribe(channel)
344
+ if (this.listenerCount(channel) === 0) {
345
+ await this._teardownRedisChannel(channel)
295
346
  }
296
347
  } else {
297
- if (this._subs.has(channel)) {
298
- const callbacks = this._subs.get(channel)
299
- callbacks.delete(callback)
300
- if (callbacks.size === 0) {
301
- this._subs.delete(channel)
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 res = await target.subscribe(channel, callback)
45
+ const handle = await target.subscribe(channel, callback)
41
46
  _odac._ipcSubs.push({channel, callback})
42
- return res
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
- const index = _odac._ipcSubs.findIndex(s => s.channel === channel && s.callback === callback)
49
- if (index > -1) _odac._ipcSubs.splice(index, 1)
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 = Buffer.isBuffer(data) ? data : Buffer.from(data)
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 = new WebSocketClient(socket, server, 'frag-1')
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 = new WebSocketClient(socket, server, 'frag-2')
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 = new WebSocketClient(socket, server, 'frag-3')
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 = new WebSocketClient(socket, server, 'frag-4')
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 = new WebSocketClient(socket, server, 'frag-5')
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 = new WebSocketClient(socket, server, 'test-id', {maxPayload: 10})
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 = new WebSocketClient(socket, server, 'test-id', {rateLimit: {max: 2, window: 1000}})
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 = new WebSocketClient(socket, server, 'rs-1')
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 = new WebSocketClient(socket, server, 'rs-2')
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 = new WebSocketClient(socket, server, 'rs-3')
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 = new WebSocketClient(socket, server, 'rs-4')
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 = new WebSocketClient(socket, server, 'rs-5')
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 = new WebSocketClient(socket, server, 'rs-6')
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 = new WebSocketClient(socket, server, 'rs-7')
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 = new WebSocketClient(socket, server, 'rs-8')
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 = new WebSocketClient(socket, server, 'rs-9')
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 = new WebSocketClient(socket, server, 'rs-10')
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
+ })