@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
JavaScript
;
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;
}