UNPKG

@socket.io/redis-streams-adapter

Version:

The Socket.IO adapter based on Redis Streams, allowing to broadcast events between several Socket.IO servers

210 lines (209 loc) 8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createAdapter = void 0; const socket_io_adapter_1 = require("socket.io-adapter"); const msgpack_1 = require("@msgpack/msgpack"); const debug_1 = require("debug"); const util_1 = require("./util"); const debug = (0, debug_1.default)("socket.io-redis-streams-adapter"); const RESTORE_SESSION_MAX_XRANGE_CALLS = 100; /** * Returns a function that will create a new adapter instance. * * @param redisClient - a Redis client that will be used to publish messages * @param opts - additional options */ function createAdapter(redisClient, opts) { const namespaceToAdapters = new Map(); const options = Object.assign({ streamName: "socket.io", maxLen: 10000, readCount: 100, sessionKeyPrefix: "sio:session:", heartbeatInterval: 5000, heartbeatTimeout: 10000, }, opts); let offset = "$"; let polling = false; let shouldClose = false; async function poll() { try { let response = await (0, util_1.XREAD)(redisClient, options.streamName, offset, options.readCount); if (response) { for (const entry of response[0].messages) { debug("reading entry %s", entry.id); const message = entry.message; if (message.nsp) { namespaceToAdapters .get(message.nsp) ?.onRawMessage(message, entry.id); } offset = entry.id; } } } catch (e) { debug("something went wrong while consuming the stream: %s", e.message); } if (namespaceToAdapters.size > 0 && !shouldClose) { poll(); } else { polling = false; } } return function (nsp) { const adapter = new RedisStreamsAdapter(nsp, redisClient, options); namespaceToAdapters.set(nsp.name, adapter); if (!polling) { polling = true; shouldClose = false; poll(); } const defaultClose = adapter.close; adapter.close = () => { namespaceToAdapters.delete(nsp.name); if (namespaceToAdapters.size === 0) { shouldClose = true; } defaultClose.call(adapter); }; return adapter; }; } exports.createAdapter = createAdapter; class RedisStreamsAdapter extends socket_io_adapter_1.ClusterAdapterWithHeartbeat { #redisClient; #opts; constructor(nsp, redisClient, opts) { super(nsp, opts); this.#redisClient = redisClient; this.#opts = opts; this.init(); } doPublish(message) { debug("publishing %o", message); return (0, util_1.XADD)(this.#redisClient, this.#opts.streamName, RedisStreamsAdapter.encode(message), this.#opts.maxLen); } doPublishResponse(requesterUid, response) { // @ts-ignore return this.doPublish(response); } static encode(message) { const rawMessage = { uid: message.uid, nsp: message.nsp, type: message.type.toString(), }; // @ts-ignore if (message.data) { const mayContainBinary = [ socket_io_adapter_1.MessageType.BROADCAST, socket_io_adapter_1.MessageType.FETCH_SOCKETS_RESPONSE, socket_io_adapter_1.MessageType.SERVER_SIDE_EMIT, socket_io_adapter_1.MessageType.SERVER_SIDE_EMIT_RESPONSE, socket_io_adapter_1.MessageType.BROADCAST_ACK, ].includes(message.type); // @ts-ignore if (mayContainBinary && (0, util_1.hasBinary)(message.data)) { // @ts-ignore rawMessage.data = Buffer.from((0, msgpack_1.encode)(message.data)).toString("base64"); } else { // @ts-ignore rawMessage.data = JSON.stringify(message.data); } } return rawMessage; } onRawMessage(rawMessage, offset) { let message; try { message = RedisStreamsAdapter.decode(rawMessage); } catch (e) { return debug("invalid format: %s", e.message); } this.onMessage(message, offset); } static decode(rawMessage) { const message = { uid: rawMessage.uid, nsp: rawMessage.nsp, type: parseInt(rawMessage.type, 10), }; if (rawMessage.data) { if (rawMessage.data.startsWith("{")) { // @ts-ignore message.data = JSON.parse(rawMessage.data); } else { // @ts-ignore message.data = (0, msgpack_1.decode)(Buffer.from(rawMessage.data, "base64")); } } return message; } persistSession(session) { debug("persisting session %o", session); const sessionKey = this.#opts.sessionKeyPrefix + session.pid; const encodedSession = Buffer.from((0, msgpack_1.encode)(session)).toString("base64"); (0, util_1.SET)(this.#redisClient, sessionKey, encodedSession, this.nsp.server.opts.connectionStateRecovery.maxDisconnectionDuration); } async restoreSession(pid, offset) { debug("restoring session %s from offset %s", pid, offset); if (!/^[0-9]+-[0-9]+$/.test(offset)) { return Promise.reject("invalid offset"); } const sessionKey = this.#opts.sessionKeyPrefix + pid; const results = await Promise.all([ (0, util_1.GETDEL)(this.#redisClient, sessionKey), (0, util_1.XRANGE)(this.#redisClient, this.#opts.streamName, offset, offset), ]); const rawSession = results[0][0]; const offsetExists = results[1][0]; if (!rawSession || !offsetExists) { return Promise.reject("session or offset not found"); } const session = (0, msgpack_1.decode)(Buffer.from(rawSession, "base64")); debug("found session %o", session); session.missedPackets = []; // FIXME we need to add an arbitrary limit here, because if entries are added faster than what we can consume, then // we will loop endlessly. But if we stop before reaching the end of the stream, we might lose messages. for (let i = 0; i < RESTORE_SESSION_MAX_XRANGE_CALLS; i++) { const entries = await (0, util_1.XRANGE)(this.#redisClient, this.#opts.streamName, RedisStreamsAdapter.nextOffset(offset), "+"); if (entries.length === 0) { break; } for (const entry of entries) { if (entry.message.nsp === this.nsp.name && entry.message.type === "3") { const message = RedisStreamsAdapter.decode(entry.message); // @ts-ignore if (shouldIncludePacket(session.rooms, message.data.opts)) { // @ts-ignore session.missedPackets.push(message.data.packet.data); } } offset = entry.id; } } return session; } /** * Exclusive ranges were added in Redis 6.2, so this is necessary for previous versions. * * @see https://redis.io/commands/xrange/ * * @param offset */ static nextOffset(offset) { const [timestamp, sequence] = offset.split("-"); return timestamp + "-" + (parseInt(sequence) + 1); } } function shouldIncludePacket(sessionRooms, opts) { const included = opts.rooms.length === 0 || sessionRooms.some((room) => opts.rooms.indexOf(room) !== -1); const notExcluded = sessionRooms.every((room) => opts.except.indexOf(room) === -1); return included && notExcluded; }