@socket.io/redis-streams-adapter 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2023 The Socket.IO team
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # Socket.IO Redis Streams adapter
2
+
3
+ The `@socket.io/redis-streams-adapter` package allows broadcasting packets between multiple Socket.IO servers.
4
+
5
+ Supported features:
6
+
7
+ - [broadcasting](https://socket.io/docs/v4/broadcasting-events/)
8
+ - [utility methods](https://socket.io/docs/v4/server-instance/#Utility-methods)
9
+ - [`socketsJoin`](https://socket.io/docs/v4/server-instance/#socketsJoin)
10
+ - [`socketsLeave`](https://socket.io/docs/v4/server-instance/#socketsLeave)
11
+ - [`disconnectSockets`](https://socket.io/docs/v4/server-instance/#disconnectSockets)
12
+ - [`fetchSockets`](https://socket.io/docs/v4/server-instance/#fetchSockets)
13
+ - [`serverSideEmit`](https://socket.io/docs/v4/server-instance/#serverSideEmit)
14
+ - [connection state recovery](https://socket.io/docs/v4/connection-state-recovery)
15
+
16
+ Related packages:
17
+
18
+ - Redis adapter: https://github.com/socketio/socket.io-redis-adapter/
19
+ - Redis emitter: https://github.com/socketio/socket.io-redis-emitter/
20
+ - MongoDB adapter: https://github.com/socketio/socket.io-mongo-adapter/
21
+ - MongoDB emitter: https://github.com/socketio/socket.io-mongo-emitter/
22
+ - Postgres adapter: https://github.com/socketio/socket.io-postgres-adapter/
23
+ - Postgres emitter: https://github.com/socketio/socket.io-postgres-emitter/
24
+
25
+ **Table of contents**
26
+
27
+ - [Installation](#installation)
28
+ - [Usage](#usage)
29
+ - [Options](#options)
30
+ - [How it works](#how-it-works)
31
+ - [License](#license)
32
+
33
+ ## Installation
34
+
35
+ ```
36
+ npm install @socket.io/redis-streams-adapter redis
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```js
42
+ import { createClient } from "redis";
43
+ import { Server } from "socket.io";
44
+ import { createAdapter } from "@socket.io/redis-streams-adapter";
45
+
46
+ const redisClient = createClient({ host: "localhost", port: 6379 });
47
+
48
+ await redisClient.connect();
49
+
50
+ const io = new Server({
51
+ adapter: createAdapter(redisClient)
52
+ });
53
+
54
+ io.listen(3000);
55
+ ```
56
+
57
+ ## Options
58
+
59
+ | Name | Description | Default value |
60
+ |---------------------|--------------------------------------------------------------------|---------------|
61
+ | `streamName` | The name of the Redis stream. | `socket.io` |
62
+ | `maxLen` | The maximum size of the stream. Almost exact trimming (~) is used. | `10_000` |
63
+ | `readCount` | The number of elements to fetch per XREAD call. | `100` |
64
+ | `heartbeatInterval` | The number of ms between two heartbeats. | `5_000` |
65
+ | `heartbeatTimeout` | The number of ms without heartbeat before we consider a node down. | `10_000` |
66
+
67
+ ## How it works
68
+
69
+ The adapter will use a [Redis stream](https://redis.io/docs/data-types/streams/) to forward events between the Socket.IO servers.
70
+
71
+ Notes:
72
+
73
+ - a single stream is used for all namespaces
74
+ - the `maxLen` option allows to limit the size of the stream
75
+ - unlike the adapter based on Redis PUB/SUB mechanism, this adapter will properly handle any temporary disconnection to the Redis server and resume the stream
76
+ - if [connection state recovery](https://socket.io/docs/v4/connection-state-recovery) is enabled, the sessions will be stored in Redis as a classic key/value pair
77
+
78
+ ## License
79
+
80
+ [MIT](LICENSE)
@@ -0,0 +1,50 @@
1
+ import type { PrivateSessionId, Session } from "socket.io-adapter";
2
+ import { ClusterAdapter, ClusterAdapterOptions, ClusterMessage } from "./cluster-adapter";
3
+ export interface RedisStreamsAdapterOptions extends ClusterAdapterOptions {
4
+ /**
5
+ * The name of the Redis stream.
6
+ */
7
+ streamName?: string;
8
+ /**
9
+ * The maximum size of the stream. Almost exact trimming (~) is used.
10
+ * @default 10_000
11
+ */
12
+ maxLen?: number;
13
+ /**
14
+ * The number of elements to fetch per XREAD call.
15
+ * @default 100
16
+ */
17
+ readCount?: number;
18
+ }
19
+ interface RawClusterMessage {
20
+ uid: string;
21
+ nsp: string;
22
+ type: string;
23
+ data?: string;
24
+ }
25
+ /**
26
+ * Returns a function that will create a new adapter instance.
27
+ *
28
+ * @param redisClient - a Redis client that will be used to publish messages
29
+ * @param opts - additional options
30
+ */
31
+ export declare function createAdapter(redisClient: any, opts?: RedisStreamsAdapterOptions): (nsp: any) => RedisStreamsAdapter;
32
+ declare class RedisStreamsAdapter extends ClusterAdapter {
33
+ #private;
34
+ constructor(nsp: any, redisClient: any, opts: Required<RedisStreamsAdapterOptions>);
35
+ doPublish(message: ClusterMessage): any;
36
+ static encode(message: ClusterMessage): RawClusterMessage;
37
+ onRawMessage(rawMessage: RawClusterMessage, offset: string): any;
38
+ static decode(rawMessage: RawClusterMessage): ClusterMessage;
39
+ persistSession(session: any): void;
40
+ restoreSession(pid: PrivateSessionId, offset: string): Promise<Session>;
41
+ /**
42
+ * Exclusive ranges were added in Redis 6.2, so this is necessary for previous versions.
43
+ *
44
+ * @see https://redis.io/commands/xrange/
45
+ *
46
+ * @param offset
47
+ */
48
+ static nextOffset(offset: any): string;
49
+ }
50
+ export {};
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createAdapter = void 0;
4
+ const msgpack_1 = require("@msgpack/msgpack");
5
+ const redis_1 = require("redis");
6
+ const cluster_adapter_1 = require("./cluster-adapter");
7
+ const debug_1 = require("debug");
8
+ const util_1 = require("./util");
9
+ const debug = (0, debug_1.default)("socket.io-redis-streams-adapter");
10
+ const RESTORE_SESSION_MAX_XRANGE_CALLS = 100;
11
+ /**
12
+ * Returns a function that will create a new adapter instance.
13
+ *
14
+ * @param redisClient - a Redis client that will be used to publish messages
15
+ * @param opts - additional options
16
+ */
17
+ function createAdapter(redisClient, opts) {
18
+ const namespaceToAdapters = new Map();
19
+ const options = Object.assign({
20
+ streamName: "socket.io",
21
+ maxLen: 10000,
22
+ readCount: 100,
23
+ heartbeatInterval: 5000,
24
+ heartbeatTimeout: 10000,
25
+ }, opts);
26
+ let offset = "$";
27
+ let polling = false;
28
+ async function poll() {
29
+ try {
30
+ let response = await redisClient.xRead((0, redis_1.commandOptions)({
31
+ isolated: true,
32
+ }), [
33
+ {
34
+ key: options.streamName,
35
+ id: offset,
36
+ },
37
+ ], {
38
+ COUNT: options.readCount,
39
+ BLOCK: 5000,
40
+ });
41
+ if (response) {
42
+ for (const entry of response[0].messages) {
43
+ debug("reading entry %s", entry.id);
44
+ const message = entry.message;
45
+ if (message.nsp) {
46
+ namespaceToAdapters
47
+ .get(message.nsp)
48
+ ?.onRawMessage(message, entry.id);
49
+ }
50
+ offset = entry.id;
51
+ }
52
+ }
53
+ }
54
+ catch (e) {
55
+ debug("something went wrong while consuming the stream: %s", e.message);
56
+ }
57
+ if (namespaceToAdapters.size > 0 && redisClient.isOpen) {
58
+ poll();
59
+ }
60
+ else {
61
+ polling = false;
62
+ }
63
+ }
64
+ return function (nsp) {
65
+ const adapter = new RedisStreamsAdapter(nsp, redisClient, options);
66
+ namespaceToAdapters.set(nsp.name, adapter);
67
+ if (!polling) {
68
+ polling = true;
69
+ poll();
70
+ }
71
+ const defaultClose = adapter.close;
72
+ adapter.close = () => {
73
+ namespaceToAdapters.delete(nsp.name);
74
+ defaultClose.call(adapter);
75
+ };
76
+ return adapter;
77
+ };
78
+ }
79
+ exports.createAdapter = createAdapter;
80
+ class RedisStreamsAdapter extends cluster_adapter_1.ClusterAdapter {
81
+ #redisClient;
82
+ #opts;
83
+ constructor(nsp, redisClient, opts) {
84
+ super(nsp, opts);
85
+ this.#redisClient = redisClient;
86
+ this.#opts = opts;
87
+ this.initHeartbeat();
88
+ }
89
+ doPublish(message) {
90
+ debug("publishing %o", message);
91
+ return this.#redisClient.xAdd(this.#opts.streamName, "*", RedisStreamsAdapter.encode(message), {
92
+ TRIM: {
93
+ strategy: "MAXLEN",
94
+ strategyModifier: "~",
95
+ threshold: this.#opts.maxLen,
96
+ },
97
+ });
98
+ }
99
+ static encode(message) {
100
+ const rawMessage = {
101
+ uid: message.uid,
102
+ nsp: message.nsp,
103
+ type: message.type.toString(),
104
+ };
105
+ if (message.data) {
106
+ const mayContainBinary = [
107
+ cluster_adapter_1.MessageType.BROADCAST,
108
+ cluster_adapter_1.MessageType.BROADCAST_ACK,
109
+ cluster_adapter_1.MessageType.FETCH_SOCKETS_RESPONSE,
110
+ cluster_adapter_1.MessageType.SERVER_SIDE_EMIT,
111
+ cluster_adapter_1.MessageType.SERVER_SIDE_EMIT_RESPONSE,
112
+ ].includes(message.type);
113
+ if (mayContainBinary && (0, util_1.hasBinary)(message.data)) {
114
+ rawMessage.data = Buffer.from((0, msgpack_1.encode)(message.data)).toString("base64");
115
+ }
116
+ else {
117
+ rawMessage.data = JSON.stringify(message.data);
118
+ }
119
+ }
120
+ return rawMessage;
121
+ }
122
+ onRawMessage(rawMessage, offset) {
123
+ let message;
124
+ try {
125
+ message = RedisStreamsAdapter.decode(rawMessage);
126
+ }
127
+ catch (e) {
128
+ return debug("invalid format: %s", e.message);
129
+ }
130
+ this.onMessage(message, offset);
131
+ }
132
+ static decode(rawMessage) {
133
+ const message = {
134
+ uid: rawMessage.uid,
135
+ nsp: rawMessage.nsp,
136
+ type: parseInt(rawMessage.type, 10),
137
+ };
138
+ if (rawMessage.data) {
139
+ if (rawMessage.data.startsWith("{")) {
140
+ message.data = JSON.parse(rawMessage.data);
141
+ }
142
+ else {
143
+ message.data = (0, msgpack_1.decode)(Buffer.from(rawMessage.data, "base64"));
144
+ }
145
+ }
146
+ return message;
147
+ }
148
+ persistSession(session) {
149
+ debug("persisting session %o", session);
150
+ const encodedSession = Buffer.from((0, msgpack_1.encode)(session)).toString("base64");
151
+ this.#redisClient.set(`sio:session:${session.pid}`, encodedSession, {
152
+ PX: this.nsp.server.opts.connectionStateRecovery.maxDisconnectionDuration,
153
+ });
154
+ }
155
+ async restoreSession(pid, offset) {
156
+ debug("restoring session %s from offset %s", pid, offset);
157
+ if (!/^[0-9]+-[0-9]+$/.test(offset)) {
158
+ return Promise.reject("invalid offset");
159
+ }
160
+ const sessionKey = `sio:session:${pid}`;
161
+ const [rawSession, offsetExists] = await this.#redisClient
162
+ .multi()
163
+ .get(sessionKey)
164
+ .del(sessionKey) // GETDEL was added in Redis version 6.2
165
+ .xRange(this.#opts.streamName, offset, offset)
166
+ .exec();
167
+ if (!rawSession || !offsetExists) {
168
+ return Promise.reject("session or offset not found");
169
+ }
170
+ const session = (0, msgpack_1.decode)(Buffer.from(rawSession, "base64"));
171
+ debug("found session %o", session);
172
+ session.missedPackets = [];
173
+ // FIXME we need to add an arbitrary limit here, because if entries are added faster than what we can consume, then
174
+ // we will loop endlessly. But if we stop before reaching the end of the stream, we might lose messages.
175
+ for (let i = 0; i < RESTORE_SESSION_MAX_XRANGE_CALLS; i++) {
176
+ const entries = await this.#redisClient.xRange(this.#opts.streamName, RedisStreamsAdapter.nextOffset(offset), "+", {
177
+ COUNT: this.#opts.readCount,
178
+ });
179
+ if (entries.length === 0) {
180
+ break;
181
+ }
182
+ for (const entry of entries) {
183
+ if (entry.message.nsp === this.nsp.name && entry.message.type === "3") {
184
+ const message = RedisStreamsAdapter.decode(entry.message);
185
+ if (shouldIncludePacket(session.rooms, message.data.opts)) {
186
+ // @ts-ignore
187
+ session.missedPackets.push(message.data.packet.data);
188
+ }
189
+ }
190
+ offset = entry.id;
191
+ }
192
+ }
193
+ return session;
194
+ }
195
+ /**
196
+ * Exclusive ranges were added in Redis 6.2, so this is necessary for previous versions.
197
+ *
198
+ * @see https://redis.io/commands/xrange/
199
+ *
200
+ * @param offset
201
+ */
202
+ static nextOffset(offset) {
203
+ const [timestamp, sequence] = offset.split("-");
204
+ return timestamp + "-" + (parseInt(sequence) + 1);
205
+ }
206
+ }
207
+ function shouldIncludePacket(sessionRooms, opts) {
208
+ const included = opts.rooms.length === 0 ||
209
+ sessionRooms.some((room) => opts.rooms.indexOf(room) !== -1);
210
+ const notExcluded = sessionRooms.every((room) => opts.except.indexOf(room) === -1);
211
+ return included && notExcluded;
212
+ }
@@ -0,0 +1,54 @@
1
+ import { Adapter, BroadcastOptions, Room } from "socket.io-adapter";
2
+ export interface ClusterAdapterOptions {
3
+ /**
4
+ * The number of ms between two heartbeats.
5
+ * @default 5_000
6
+ */
7
+ heartbeatInterval?: number;
8
+ /**
9
+ * The number of ms without heartbeat before we consider a node down.
10
+ * @default 10_000
11
+ */
12
+ heartbeatTimeout?: number;
13
+ }
14
+ export declare enum MessageType {
15
+ INITIAL_HEARTBEAT = 1,
16
+ HEARTBEAT = 2,
17
+ BROADCAST = 3,
18
+ SOCKETS_JOIN = 4,
19
+ SOCKETS_LEAVE = 5,
20
+ DISCONNECT_SOCKETS = 6,
21
+ FETCH_SOCKETS = 7,
22
+ FETCH_SOCKETS_RESPONSE = 8,
23
+ SERVER_SIDE_EMIT = 9,
24
+ SERVER_SIDE_EMIT_RESPONSE = 10,
25
+ BROADCAST_CLIENT_COUNT = 11,
26
+ BROADCAST_ACK = 12
27
+ }
28
+ export interface ClusterMessage {
29
+ uid: string;
30
+ nsp: string;
31
+ type: MessageType;
32
+ data?: Record<string, unknown>;
33
+ }
34
+ export declare abstract class ClusterAdapter extends Adapter {
35
+ #private;
36
+ protected constructor(nsp: any, opts: Required<ClusterAdapterOptions>);
37
+ protected initHeartbeat(): void;
38
+ close(): Promise<void> | void;
39
+ onMessage(message: ClusterMessage, offset: string): Promise<any>;
40
+ broadcast(packet: any, opts: BroadcastOptions): Promise<any>;
41
+ broadcastWithAck(packet: any, opts: BroadcastOptions, clientCountCallback: (clientCount: number) => void, ack: (...args: any[]) => void): void;
42
+ serverCount(): Promise<number>;
43
+ /**
44
+ *
45
+ * @param opts
46
+ * @param rooms
47
+ */
48
+ addSockets(opts: BroadcastOptions, rooms: Room[]): void;
49
+ delSockets(opts: BroadcastOptions, rooms: Room[]): void;
50
+ disconnectSockets(opts: BroadcastOptions, close: boolean): void;
51
+ fetchSockets(opts: BroadcastOptions): Promise<any[]>;
52
+ serverSideEmit(packet: any[]): any;
53
+ abstract doPublish(message: ClusterMessage): Promise<string>;
54
+ }
@@ -0,0 +1,420 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClusterAdapter = exports.MessageType = void 0;
4
+ const socket_io_adapter_1 = require("socket.io-adapter");
5
+ const debug_1 = require("debug");
6
+ const util_1 = require("./util");
7
+ const debug = (0, debug_1.default)("socket.io-adapter");
8
+ const EMITTER_UID = "emitter";
9
+ const DEFAULT_TIMEOUT = 5000;
10
+ var MessageType;
11
+ (function (MessageType) {
12
+ MessageType[MessageType["INITIAL_HEARTBEAT"] = 1] = "INITIAL_HEARTBEAT";
13
+ MessageType[MessageType["HEARTBEAT"] = 2] = "HEARTBEAT";
14
+ MessageType[MessageType["BROADCAST"] = 3] = "BROADCAST";
15
+ MessageType[MessageType["SOCKETS_JOIN"] = 4] = "SOCKETS_JOIN";
16
+ MessageType[MessageType["SOCKETS_LEAVE"] = 5] = "SOCKETS_LEAVE";
17
+ MessageType[MessageType["DISCONNECT_SOCKETS"] = 6] = "DISCONNECT_SOCKETS";
18
+ MessageType[MessageType["FETCH_SOCKETS"] = 7] = "FETCH_SOCKETS";
19
+ MessageType[MessageType["FETCH_SOCKETS_RESPONSE"] = 8] = "FETCH_SOCKETS_RESPONSE";
20
+ MessageType[MessageType["SERVER_SIDE_EMIT"] = 9] = "SERVER_SIDE_EMIT";
21
+ MessageType[MessageType["SERVER_SIDE_EMIT_RESPONSE"] = 10] = "SERVER_SIDE_EMIT_RESPONSE";
22
+ MessageType[MessageType["BROADCAST_CLIENT_COUNT"] = 11] = "BROADCAST_CLIENT_COUNT";
23
+ MessageType[MessageType["BROADCAST_ACK"] = 12] = "BROADCAST_ACK";
24
+ })(MessageType = exports.MessageType || (exports.MessageType = {}));
25
+ function encodeOptions(opts) {
26
+ return {
27
+ rooms: [...opts.rooms],
28
+ except: [...opts.except],
29
+ flags: opts.flags,
30
+ };
31
+ }
32
+ function decodeOptions(opts) {
33
+ return {
34
+ rooms: new Set(opts.rooms),
35
+ except: new Set(opts.except),
36
+ flags: opts.flags,
37
+ };
38
+ }
39
+ class ClusterAdapter extends socket_io_adapter_1.Adapter {
40
+ #opts;
41
+ #uid;
42
+ #heartbeatTimer;
43
+ #nodesMap = new Map(); // uid => timestamp of last message
44
+ #requests = new Map();
45
+ #ackRequests = new Map();
46
+ constructor(nsp, opts) {
47
+ super(nsp);
48
+ this.#opts = opts;
49
+ this.#uid = (0, util_1.randomId)();
50
+ }
51
+ initHeartbeat() {
52
+ this.#publish({
53
+ type: MessageType.INITIAL_HEARTBEAT,
54
+ });
55
+ }
56
+ #scheduleHeartbeat() {
57
+ if (this.#heartbeatTimer) {
58
+ clearTimeout(this.#heartbeatTimer);
59
+ }
60
+ this.#heartbeatTimer = setTimeout(() => {
61
+ this.#publish({
62
+ type: MessageType.HEARTBEAT,
63
+ });
64
+ }, this.#opts.heartbeatInterval);
65
+ }
66
+ close() {
67
+ clearTimeout(this.#heartbeatTimer);
68
+ }
69
+ async onMessage(message, offset) {
70
+ if (message.uid === this.#uid) {
71
+ return debug("ignore message from self");
72
+ }
73
+ if (message.uid && message.uid !== EMITTER_UID) {
74
+ this.#nodesMap.set(message.uid, Date.now());
75
+ }
76
+ debug("new event of type %d from %s", message.type, message.uid);
77
+ switch (message.type) {
78
+ case MessageType.INITIAL_HEARTBEAT:
79
+ this.#publish({
80
+ type: MessageType.HEARTBEAT,
81
+ });
82
+ break;
83
+ case MessageType.BROADCAST: {
84
+ const withAck = message.data.requestId !== undefined;
85
+ if (withAck) {
86
+ super.broadcastWithAck(message.data.packet, decodeOptions(message.data.opts), (clientCount) => {
87
+ debug("waiting for %d client acknowledgements", clientCount);
88
+ this.#publish({
89
+ type: MessageType.BROADCAST_CLIENT_COUNT,
90
+ data: {
91
+ requestId: message.data.requestId,
92
+ clientCount,
93
+ },
94
+ });
95
+ }, (arg) => {
96
+ debug("received acknowledgement with value %j", arg);
97
+ this.#publish({
98
+ type: MessageType.BROADCAST_ACK,
99
+ data: {
100
+ requestId: message.data.requestId,
101
+ packet: arg,
102
+ },
103
+ });
104
+ });
105
+ }
106
+ else {
107
+ const packet = message.data.packet;
108
+ const opts = decodeOptions(message.data.opts);
109
+ this.#addOffsetIfNecessary(packet, opts, offset);
110
+ super.broadcast(packet, opts);
111
+ }
112
+ break;
113
+ }
114
+ case MessageType.BROADCAST_CLIENT_COUNT: {
115
+ const request = this.#ackRequests.get(message.data.requestId);
116
+ request?.clientCountCallback(message.data.clientCount);
117
+ break;
118
+ }
119
+ case MessageType.BROADCAST_ACK: {
120
+ const request = this.#ackRequests.get(message.data.requestId);
121
+ request?.ack(message.data.packet);
122
+ break;
123
+ }
124
+ case MessageType.SOCKETS_JOIN:
125
+ super.addSockets(decodeOptions(message.data.opts), message.data.rooms);
126
+ break;
127
+ case MessageType.SOCKETS_LEAVE:
128
+ super.delSockets(decodeOptions(message.data.opts), message.data.rooms);
129
+ break;
130
+ case MessageType.DISCONNECT_SOCKETS:
131
+ super.disconnectSockets(decodeOptions(message.data.opts), message.data.close);
132
+ break;
133
+ case MessageType.FETCH_SOCKETS: {
134
+ debug("calling fetchSockets with opts %j", message.data.opts);
135
+ const localSockets = await super.fetchSockets(decodeOptions(message.data.opts));
136
+ this.#publish({
137
+ type: MessageType.FETCH_SOCKETS_RESPONSE,
138
+ data: {
139
+ requestId: message.data.requestId,
140
+ sockets: localSockets.map((socket) => {
141
+ // remove sessionStore from handshake, as it may contain circular references
142
+ const { sessionStore, ...handshake } = socket.handshake;
143
+ return {
144
+ id: socket.id,
145
+ handshake,
146
+ rooms: [...socket.rooms],
147
+ data: socket.data,
148
+ };
149
+ }),
150
+ },
151
+ });
152
+ break;
153
+ }
154
+ case MessageType.FETCH_SOCKETS_RESPONSE: {
155
+ const requestId = message.data.requestId;
156
+ const request = this.#requests.get(requestId);
157
+ if (!request) {
158
+ return;
159
+ }
160
+ request.current++;
161
+ message.data.sockets.forEach((socket) => request.responses.push(socket));
162
+ if (request.current === request.expected) {
163
+ clearTimeout(request.timeout);
164
+ request.resolve(request.responses);
165
+ this.#requests.delete(requestId);
166
+ }
167
+ break;
168
+ }
169
+ case MessageType.SERVER_SIDE_EMIT: {
170
+ const packet = message.data.packet;
171
+ const withAck = message.data.requestId !== undefined;
172
+ if (!withAck) {
173
+ this.nsp._onServerSideEmit(packet);
174
+ return;
175
+ }
176
+ let called = false;
177
+ const callback = (arg) => {
178
+ // only one argument is expected
179
+ if (called) {
180
+ return;
181
+ }
182
+ called = true;
183
+ debug("calling acknowledgement with %j", arg);
184
+ this.#publish({
185
+ type: MessageType.SERVER_SIDE_EMIT_RESPONSE,
186
+ data: {
187
+ requestId: message.data.requestId,
188
+ packet: arg,
189
+ },
190
+ });
191
+ };
192
+ packet.push(callback);
193
+ this.nsp._onServerSideEmit(packet);
194
+ break;
195
+ }
196
+ case MessageType.SERVER_SIDE_EMIT_RESPONSE: {
197
+ const requestId = message.data.requestId;
198
+ const request = this.#requests.get(requestId);
199
+ if (!request) {
200
+ return;
201
+ }
202
+ request.current++;
203
+ request.responses.push(message.data.packet);
204
+ if (request.current === request.expected) {
205
+ clearTimeout(request.timeout);
206
+ request.resolve(null, request.responses);
207
+ this.#requests.delete(requestId);
208
+ }
209
+ }
210
+ }
211
+ }
212
+ async broadcast(packet, opts) {
213
+ const onlyLocal = opts.flags?.local;
214
+ if (!onlyLocal) {
215
+ try {
216
+ const offset = await this.#publish({
217
+ type: MessageType.BROADCAST,
218
+ data: {
219
+ packet,
220
+ opts: encodeOptions(opts),
221
+ },
222
+ });
223
+ this.#addOffsetIfNecessary(packet, opts, offset);
224
+ }
225
+ catch (e) {
226
+ return debug("error while broadcasting message: %s", e.message);
227
+ }
228
+ }
229
+ super.broadcast(packet, opts);
230
+ }
231
+ /**
232
+ * Adds an offset at the end of the data array in order to allow the client to receive any missed packets when it
233
+ * reconnects after a temporary disconnection.
234
+ *
235
+ * @param packet
236
+ * @param opts
237
+ * @param offset
238
+ * @private
239
+ */
240
+ #addOffsetIfNecessary(packet, opts, offset) {
241
+ if (!this.nsp.server.opts.connectionStateRecovery) {
242
+ return;
243
+ }
244
+ const isEventPacket = packet.type === 2;
245
+ // packets with acknowledgement are not stored because the acknowledgement function cannot be serialized and
246
+ // restored on another server upon reconnection
247
+ const withoutAcknowledgement = packet.id === undefined;
248
+ const notVolatile = opts.flags?.volatile === undefined;
249
+ if (isEventPacket && withoutAcknowledgement && notVolatile) {
250
+ packet.data.push(offset);
251
+ }
252
+ }
253
+ broadcastWithAck(packet, opts, clientCountCallback, ack) {
254
+ const onlyLocal = opts?.flags?.local;
255
+ if (!onlyLocal) {
256
+ const requestId = (0, util_1.randomId)();
257
+ this.#publish({
258
+ type: MessageType.BROADCAST,
259
+ data: {
260
+ packet,
261
+ requestId,
262
+ opts: encodeOptions(opts),
263
+ },
264
+ });
265
+ this.#ackRequests.set(requestId, {
266
+ clientCountCallback,
267
+ ack,
268
+ });
269
+ // we have no way to know at this level whether the server has received an acknowledgement from each client, so we
270
+ // will simply clean up the ackRequests map after the given delay
271
+ setTimeout(() => {
272
+ this.#ackRequests.delete(requestId);
273
+ }, opts.flags.timeout);
274
+ }
275
+ super.broadcastWithAck(packet, opts, clientCountCallback, ack);
276
+ }
277
+ serverCount() {
278
+ return Promise.resolve(1 + this.#nodesMap.size);
279
+ }
280
+ /**
281
+ *
282
+ * @param opts
283
+ * @param rooms
284
+ */
285
+ addSockets(opts, rooms) {
286
+ super.addSockets(opts, rooms);
287
+ const onlyLocal = opts.flags?.local;
288
+ if (onlyLocal) {
289
+ return;
290
+ }
291
+ this.#publish({
292
+ type: MessageType.SOCKETS_JOIN,
293
+ data: {
294
+ opts: encodeOptions(opts),
295
+ rooms,
296
+ },
297
+ });
298
+ }
299
+ delSockets(opts, rooms) {
300
+ super.delSockets(opts, rooms);
301
+ const onlyLocal = opts.flags?.local;
302
+ if (onlyLocal) {
303
+ return;
304
+ }
305
+ this.#publish({
306
+ type: MessageType.SOCKETS_LEAVE,
307
+ data: {
308
+ opts: encodeOptions(opts),
309
+ rooms,
310
+ },
311
+ });
312
+ }
313
+ disconnectSockets(opts, close) {
314
+ super.disconnectSockets(opts, close);
315
+ const onlyLocal = opts.flags?.local;
316
+ if (onlyLocal) {
317
+ return;
318
+ }
319
+ this.#publish({
320
+ type: MessageType.DISCONNECT_SOCKETS,
321
+ data: {
322
+ opts: encodeOptions(opts),
323
+ close,
324
+ },
325
+ });
326
+ }
327
+ #getExpectedResponseCount() {
328
+ this.#nodesMap.forEach((lastSeen, uid) => {
329
+ const nodeSeemsDown = Date.now() - lastSeen > this.#opts.heartbeatTimeout;
330
+ if (nodeSeemsDown) {
331
+ debug("node %s seems down", uid);
332
+ this.#nodesMap.delete(uid);
333
+ }
334
+ });
335
+ return this.#nodesMap.size;
336
+ }
337
+ async fetchSockets(opts) {
338
+ const localSockets = await super.fetchSockets(opts);
339
+ const expectedResponseCount = this.#getExpectedResponseCount();
340
+ if (opts.flags?.local || expectedResponseCount === 0) {
341
+ return localSockets;
342
+ }
343
+ const requestId = (0, util_1.randomId)();
344
+ return new Promise((resolve, reject) => {
345
+ const timeout = setTimeout(() => {
346
+ const storedRequest = this.#requests.get(requestId);
347
+ if (storedRequest) {
348
+ reject(new Error(`timeout reached: only ${storedRequest.current} responses received out of ${storedRequest.expected}`));
349
+ this.#requests.delete(requestId);
350
+ }
351
+ }, opts.flags.timeout || DEFAULT_TIMEOUT);
352
+ const storedRequest = {
353
+ type: MessageType.FETCH_SOCKETS,
354
+ resolve,
355
+ timeout,
356
+ current: 0,
357
+ expected: expectedResponseCount,
358
+ responses: localSockets,
359
+ };
360
+ this.#requests.set(requestId, storedRequest);
361
+ this.#publish({
362
+ type: MessageType.FETCH_SOCKETS,
363
+ data: {
364
+ opts: encodeOptions(opts),
365
+ requestId,
366
+ },
367
+ });
368
+ });
369
+ }
370
+ serverSideEmit(packet) {
371
+ const withAck = typeof packet[packet.length - 1] === "function";
372
+ if (!withAck) {
373
+ return this.#publish({
374
+ type: MessageType.SERVER_SIDE_EMIT,
375
+ data: {
376
+ packet,
377
+ },
378
+ });
379
+ }
380
+ const ack = packet.pop();
381
+ const expectedResponseCount = this.#getExpectedResponseCount();
382
+ debug('waiting for %d responses to "serverSideEmit" request', expectedResponseCount);
383
+ if (expectedResponseCount <= 0) {
384
+ return ack(null, []);
385
+ }
386
+ const requestId = (0, util_1.randomId)();
387
+ const timeout = setTimeout(() => {
388
+ const storedRequest = this.#requests.get(requestId);
389
+ if (storedRequest) {
390
+ ack(new Error(`timeout reached: only ${storedRequest.current} responses received out of ${storedRequest.expected}`), storedRequest.responses);
391
+ this.#requests.delete(requestId);
392
+ }
393
+ }, DEFAULT_TIMEOUT);
394
+ const storedRequest = {
395
+ type: MessageType.SERVER_SIDE_EMIT,
396
+ resolve: ack,
397
+ timeout,
398
+ current: 0,
399
+ expected: expectedResponseCount,
400
+ responses: [],
401
+ };
402
+ this.#requests.set(requestId, storedRequest);
403
+ this.#publish({
404
+ type: MessageType.SERVER_SIDE_EMIT,
405
+ data: {
406
+ requestId,
407
+ packet,
408
+ },
409
+ });
410
+ }
411
+ #publish(message) {
412
+ this.#scheduleHeartbeat();
413
+ return this.doPublish({
414
+ uid: this.#uid,
415
+ nsp: this.nsp.name,
416
+ ...message,
417
+ });
418
+ }
419
+ }
420
+ exports.ClusterAdapter = ClusterAdapter;
@@ -0,0 +1 @@
1
+ export { createAdapter, RedisStreamsAdapterOptions } from "./adapter";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createAdapter = void 0;
4
+ var adapter_1 = require("./adapter");
5
+ Object.defineProperty(exports, "createAdapter", { enumerable: true, get: function () { return adapter_1.createAdapter; } });
@@ -0,0 +1,47 @@
1
+ import type { PrivateSessionId, Session } from "socket.io-adapter";
2
+ import { ClusterAdapter, ClusterAdapterOptions, ClusterMessage } from "./cluster-adapter";
3
+ export interface RedisAdapterOptions extends ClusterAdapterOptions {
4
+ /**
5
+ * The name of the stream.
6
+ */
7
+ streamName?: string;
8
+ /**
9
+ * The maximum size of the stream. Almost exact trimming (~) is used.
10
+ * @default 10_000
11
+ */
12
+ maxLen?: number;
13
+ /**
14
+ * The number of elements to return per XREAD call
15
+ * @default 100
16
+ */
17
+ readCount?: number;
18
+ }
19
+ interface RawClusterMessage {
20
+ uid: string;
21
+ nsp: string;
22
+ type: string;
23
+ data?: string;
24
+ }
25
+ /**
26
+ * Returns a function that will create a RedisAdapter instance.
27
+ *
28
+ * @param redisClient - a Redis client that will be used to publish messages
29
+ * @param opts - additional options
30
+ */
31
+ export declare function createAdapter(redisClient: any, opts?: RedisAdapterOptions): (nsp: any) => RedisStreamsAdapter;
32
+ declare class RedisStreamsAdapter extends ClusterAdapter {
33
+ #private;
34
+ constructor(nsp: any, redisClient: any, opts: Required<RedisAdapterOptions>);
35
+ doPublish(message: ClusterMessage): any;
36
+ static encode(message: ClusterMessage): RawClusterMessage;
37
+ onRawMessage(rawMessage: RawClusterMessage, offset: string): any;
38
+ static decode(rawMessage: RawClusterMessage): ClusterMessage;
39
+ persistSession(session: any): void;
40
+ restoreSession(pid: PrivateSessionId, offset: string): Promise<Session>;
41
+ /**
42
+ * Exclusive ranges were added in Redis 6.2, so this is necessary for previous versions.
43
+ * @param offset
44
+ */
45
+ static nextOffset(offset: any): string;
46
+ }
47
+ export {};
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createAdapter = void 0;
4
+ const msgpack_1 = require("@msgpack/msgpack");
5
+ const redis_1 = require("redis");
6
+ const cluster_adapter_1 = require("./cluster-adapter");
7
+ const debug_1 = require("debug");
8
+ const util_1 = require("./util");
9
+ // TODO
10
+ // BROADCAST / other channels
11
+ // MAXLEN
12
+ const debug = (0, debug_1.default)("socket.io-redis-streams-adapter");
13
+ /**
14
+ * Returns a function that will create a RedisAdapter instance.
15
+ *
16
+ * @param redisClient - a Redis client that will be used to publish messages
17
+ * @param opts - additional options
18
+ */
19
+ function createAdapter(redisClient, opts) {
20
+ const namespaceToAdapters = new Map();
21
+ const options = Object.assign({
22
+ streamName: "socket.io",
23
+ maxLen: 1000,
24
+ readCount: 100,
25
+ heartbeatInterval: 5000,
26
+ heartbeatTimeout: 10000,
27
+ }, opts);
28
+ let offset = "$";
29
+ let polling = false;
30
+ async function poll() {
31
+ try {
32
+ let response = await redisClient.xRead((0, redis_1.commandOptions)({
33
+ isolated: true,
34
+ }), [
35
+ {
36
+ key: options.streamName,
37
+ id: offset,
38
+ },
39
+ ], {
40
+ COUNT: options.readCount,
41
+ BLOCK: 5000,
42
+ });
43
+ if (response) {
44
+ for (const entry of response[0].messages) {
45
+ debug("reading entry %s", entry.id);
46
+ const message = entry.message;
47
+ if (message.nsp) {
48
+ namespaceToAdapters
49
+ .get(message.nsp)
50
+ ?.onRawMessage(message, entry.id);
51
+ }
52
+ offset = entry.id;
53
+ }
54
+ }
55
+ }
56
+ catch (e) {
57
+ debug("something went wrong while consuming the stream: %s", e.message);
58
+ }
59
+ if (namespaceToAdapters.size > 0 && redisClient.isOpen) {
60
+ poll();
61
+ }
62
+ else {
63
+ polling = false;
64
+ }
65
+ }
66
+ return function (nsp) {
67
+ const adapter = new RedisStreamsAdapter(nsp, redisClient, options);
68
+ namespaceToAdapters.set(nsp.name, adapter);
69
+ if (!polling) {
70
+ polling = true;
71
+ poll();
72
+ }
73
+ const defaultClose = adapter.close;
74
+ adapter.close = () => {
75
+ namespaceToAdapters.delete(nsp.name);
76
+ defaultClose.call(adapter);
77
+ };
78
+ return adapter;
79
+ };
80
+ }
81
+ exports.createAdapter = createAdapter;
82
+ class RedisStreamsAdapter extends cluster_adapter_1.ClusterAdapter {
83
+ #redisClient;
84
+ #opts;
85
+ constructor(nsp, redisClient, opts) {
86
+ super(nsp, opts);
87
+ this.#redisClient = redisClient;
88
+ this.#opts = opts;
89
+ this.initHeartbeat();
90
+ }
91
+ doPublish(message) {
92
+ debug("publishing %o", message);
93
+ return this.#redisClient.xAdd(this.#opts.streamName, "*", RedisStreamsAdapter.encode(message), {
94
+ TRIM: {
95
+ strategy: "MAXLEN",
96
+ strategyModifier: "~",
97
+ threshold: this.#opts.maxLen,
98
+ },
99
+ });
100
+ }
101
+ static encode(message) {
102
+ const rawMessage = {
103
+ uid: message.uid,
104
+ nsp: message.nsp,
105
+ type: message.type.toString(),
106
+ };
107
+ if (message.data) {
108
+ const mayContainBinary = [
109
+ cluster_adapter_1.MessageType.BROADCAST,
110
+ cluster_adapter_1.MessageType.BROADCAST_ACK,
111
+ cluster_adapter_1.MessageType.FETCH_SOCKETS_RESPONSE,
112
+ cluster_adapter_1.MessageType.SERVER_SIDE_EMIT,
113
+ cluster_adapter_1.MessageType.SERVER_SIDE_EMIT_RESPONSE,
114
+ ].includes(message.type);
115
+ if (mayContainBinary && (0, util_1.hasBinary)(message.data)) {
116
+ rawMessage.data = Buffer.from((0, msgpack_1.encode)(message.data)).toString("base64");
117
+ }
118
+ else {
119
+ rawMessage.data = JSON.stringify(message.data);
120
+ }
121
+ }
122
+ return rawMessage;
123
+ }
124
+ onRawMessage(rawMessage, offset) {
125
+ let message;
126
+ try {
127
+ message = RedisStreamsAdapter.decode(rawMessage);
128
+ }
129
+ catch (e) {
130
+ return debug("invalid format: %s", e.message);
131
+ }
132
+ this.onMessage(message, offset);
133
+ }
134
+ static decode(rawMessage) {
135
+ const message = {
136
+ uid: rawMessage.uid,
137
+ nsp: rawMessage.nsp,
138
+ type: parseInt(rawMessage.type, 10),
139
+ };
140
+ if (rawMessage.data) {
141
+ if (rawMessage.data.startsWith("{")) {
142
+ message.data = JSON.parse(rawMessage.data);
143
+ }
144
+ else {
145
+ message.data = (0, msgpack_1.decode)(Buffer.from(rawMessage.data, "base64"));
146
+ }
147
+ }
148
+ return message;
149
+ }
150
+ persistSession(session) {
151
+ debug("persist session %o", session);
152
+ this.#redisClient.set(session.pid, JSON.stringify(session), {
153
+ PX: this.nsp.server.opts.connectionStateRecovery.maxDisconnectionDuration,
154
+ });
155
+ }
156
+ async restoreSession(pid, offset) {
157
+ console.log("restoreSession", pid);
158
+ console.log("offset", offset);
159
+ if (!/^[0-9]+-[0-9]+$/.test(offset)) {
160
+ console.log("invalid offset");
161
+ return Promise.reject("invalid offset");
162
+ }
163
+ const value = await this.#redisClient.get(pid);
164
+ if (!value) {
165
+ console.log("session or offset not found");
166
+ return Promise.reject("session or offset not found");
167
+ }
168
+ const session = JSON.parse(value);
169
+ console.log("session", session);
170
+ session.missedPackets = [];
171
+ // FIXME si les messages sont ajoutés plus vite que consommés, problème !
172
+ for (let i = 0; i < 10; i++) {
173
+ const entries = await this.#redisClient.xRange(this.#opts.streamName, RedisStreamsAdapter.nextOffset(offset), "+", {
174
+ COUNT: 100,
175
+ });
176
+ console.log("entries", entries.length);
177
+ console.log("offset", offset);
178
+ if (entries.length === 0) {
179
+ break;
180
+ }
181
+ for (const entry of entries) {
182
+ if (entry.message.nsp === this.nsp.name && entry.message.type === "3") {
183
+ const message = RedisStreamsAdapter.decode(entry.message);
184
+ console.log("message from the log", message);
185
+ if (message.type === cluster_adapter_1.MessageType.BROADCAST &&
186
+ shouldIncludePacket(session.rooms, message.data.opts)) {
187
+ // TODO match rooms
188
+ // @ts-ignore
189
+ session.missedPackets.push(message.data.packet.data);
190
+ }
191
+ }
192
+ offset = entry.id;
193
+ }
194
+ }
195
+ console.log("restored", session);
196
+ return session;
197
+ }
198
+ /**
199
+ * Exclusive ranges were added in Redis 6.2, so this is necessary for previous versions.
200
+ * @param offset
201
+ */
202
+ static nextOffset(offset) {
203
+ const [timestamp, sequence] = offset.split("-");
204
+ return timestamp + "-" + (parseInt(sequence) + 1);
205
+ }
206
+ }
207
+ function shouldIncludePacket(sessionRooms, opts) {
208
+ const included = opts.rooms.length === 0 ||
209
+ sessionRooms.some((room) => opts.rooms.indexOf(room) !== -1);
210
+ const notExcluded = sessionRooms.every((room) => opts.except.indexOf(room) === -1);
211
+ return included && notExcluded;
212
+ }
package/dist/test.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/test.js ADDED
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const socket_io_1 = require("socket.io");
4
+ const redis_1 = require("redis");
5
+ const index_1 = require("./index");
6
+ const io = new socket_io_1.Server();
7
+ const pubClient = (0, redis_1.createClient)({ host: 'localhost', port: 6379 });
8
+ const subClient = pubClient.duplicate();
9
+ Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
10
+ io.adapter((0, index_1.createAdapter)(pubClient, subClient));
11
+ io.listen(3000);
12
+ });
package/dist/util.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function hasBinary(obj: any, toJSON?: boolean): boolean;
2
+ export declare function randomId(): string;
package/dist/util.js ADDED
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.randomId = exports.hasBinary = void 0;
4
+ const crypto_1 = require("crypto");
5
+ function hasBinary(obj, toJSON) {
6
+ if (!obj || typeof obj !== "object") {
7
+ return false;
8
+ }
9
+ if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) {
10
+ return true;
11
+ }
12
+ if (Array.isArray(obj)) {
13
+ for (let i = 0, l = obj.length; i < l; i++) {
14
+ if (hasBinary(obj[i])) {
15
+ return true;
16
+ }
17
+ }
18
+ return false;
19
+ }
20
+ for (const key in obj) {
21
+ if (Object.prototype.hasOwnProperty.call(obj, key) && hasBinary(obj[key])) {
22
+ return true;
23
+ }
24
+ }
25
+ if (obj.toJSON && typeof obj.toJSON === "function" && !toJSON) {
26
+ return hasBinary(obj.toJSON(), true);
27
+ }
28
+ return false;
29
+ }
30
+ exports.hasBinary = hasBinary;
31
+ function randomId() {
32
+ return (0, crypto_1.randomBytes)(8).toString("hex");
33
+ }
34
+ exports.randomId = randomId;
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@socket.io/redis-streams-adapter",
3
+ "version": "0.1.0",
4
+ "description": "The Socket.IO adapter based on Redis Streams, allowing to broadcast events between several Socket.IO servers",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git@github.com:socketio/socket.io-redis-streams-adapter.git"
9
+ },
10
+ "files": [
11
+ "dist/"
12
+ ],
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "scripts": {
16
+ "compile": "tsc",
17
+ "format:check": "prettier --parser typescript --check lib/**/*.ts test/**/*.ts",
18
+ "format:fix": "prettier --parser typescript --write lib/**/*.ts test/**/*.ts",
19
+ "prepack": "npm run compile",
20
+ "test": "npm run format:check && npm run compile && nyc mocha --bail --require ts-node/register test/**/*.ts"
21
+ },
22
+ "dependencies": {
23
+ "@msgpack/msgpack": "~2.8.0",
24
+ "debug": "~4.3.1"
25
+ },
26
+ "peerDependencies": {
27
+ "socket.io-adapter": "^2.5.2"
28
+ },
29
+ "devDependencies": {
30
+ "@types/expect.js": "^0.3.29",
31
+ "@types/mocha": "^8.2.1",
32
+ "@types/node": "^18.15.11",
33
+ "expect.js": "0.3.1",
34
+ "mocha": "^10.1.0",
35
+ "nyc": "^15.1.0",
36
+ "prettier": "^2.8.7",
37
+ "redis": "^4.6.5",
38
+ "socket.io": "^4.6.1",
39
+ "socket.io-client": "^4.6.1",
40
+ "ts-node": "^10.9.1",
41
+ "typescript": "^4.9.5"
42
+ },
43
+ "engines": {
44
+ "node": ">=14.0.0"
45
+ },
46
+ "keywords": [
47
+ "socket.io",
48
+ "redis",
49
+ "adapter"
50
+ ]
51
+ }