ioredis
Version:
A robust, performance-focused and full-featured Redis client for Node.js.
374 lines (373 loc) • 15.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const Command_1 = require("./Command");
const utils_1 = require("./utils");
const SubscriptionSet_1 = require("./SubscriptionSet");
const decoder_1 = require("./resp/decoder");
const debug = (0, utils_1.Debug)("dataHandler");
class DataHandler {
constructor(redis, parserOptions) {
this.redis = redis;
// Parser options can't change over the lifetime of a connection, so the
// mapping is resolved once instead of per reply.
const typeMapping = getParserTypeMapping(parserOptions);
const decoder = new decoder_1.Decoder({
getTypeMapping: () => typeMapping,
onReply: (reply) => {
this.dispatch(() => this.returnReply(reply));
},
onErrorReply: (err) => {
this.dispatch(() => this.returnError(err));
},
onPush: (reply) => {
this.dispatch(() => this.returnPush(reply));
},
});
// prependListener ensures the parser receives and processes data before socket timeout checks are performed
redis.stream.prependListener("data", (data) => {
try {
decoder.write(data);
}
catch (err) {
this.returnFatalError(err);
}
});
// prependListener() doesn't enable flowing mode automatically - we need to resume the stream manually
redis.stream.resume();
}
// Rethrows user listener/transformer exceptions asynchronously so they
// surface as uncaught exceptions (as with redis-parser in v5) instead of
// unwinding the decoder mid-parse and tearing down the connection as a
// fatal protocol error.
dispatch(fn) {
try {
fn();
}
catch (err) {
process.nextTick(() => {
throw err;
});
}
}
returnFatalError(err) {
err.message += ". Please report this.";
this.redis.recoverFromFatalError(err, err, { offlineQueue: false });
}
returnError(err) {
const item = this.shiftCommand(err);
if (!item) {
return;
}
err.command = {
name: item.command.name,
args: item.command.args,
};
// A MOVED reply to SSUBSCRIBE means the shard channel's slot migrated.
// Signal the subscriber layer to refresh its topology, but still settle
// the command through normal error handling so the caller's promise
// rejects instead of staying pending forever.
const isMovedSsubscribe = item.command.name === "ssubscribe" && err.message.startsWith("MOVED ");
if (isMovedSsubscribe) {
this.redis.emit("moved");
}
this.redis.handleReconnection(err, item);
}
returnReply(reply) {
if (this.handleMonitorReply(reply)) {
return;
}
// Under RESP3, pub/sub traffic arrives exclusively as push frames
// (returnPush), so a normal reply on a subscribed connection is always a
// command response. Routing it by content would misinterpret replies that
// merely look like pub/sub messages (e.g. an LRANGE result starting with
// "message") and desync the command queue.
if (this.redis.condition.protocol !== 3 && this.handleSubscriberReply(reply)) {
return;
}
const item = this.shiftCommand(reply);
if (!item) {
return;
}
if (Command_1.default.checkFlag("ENTER_SUBSCRIBER_MODE", item.command.name)) {
this.redis.condition.subscriber = new SubscriptionSet_1.default();
this.redis.condition.subscriber.add(item.command.name, reply[1].toString());
if (!fillSubCommand(item.command, reply[2])) {
this.redis.commandQueue.unshift(item);
}
}
else if (Command_1.default.checkFlag("EXIT_SUBSCRIBER_MODE", item.command.name)) {
if (!fillUnsubCommand(item.command, reply[2])) {
this.redis.commandQueue.unshift(item);
}
}
else {
item.command.resolve(reply);
}
}
returnPush(reply) {
if (!Array.isArray(reply) || reply.length === 0) {
return;
}
const replyType = reply[0].toString();
debug('receive push "%s"', replyType);
switch (replyType) {
case "message":
case "pmessage":
case "smessage":
this.handleSubscriberReply(reply);
break;
case "ssubscribe":
case "subscribe":
case "psubscribe": {
if (!this.redis.condition.subscriber) {
this.redis.condition.subscriber = new SubscriptionSet_1.default();
}
const channel = reply[1].toString();
this.redis.condition.subscriber.add(replyType, channel);
const item = this.shiftCommand(reply);
if (!item) {
return;
}
if (!fillSubCommand(item.command, reply[2])) {
this.redis.commandQueue.unshift(item);
}
break;
}
case "sunsubscribe":
case "unsubscribe":
case "punsubscribe": {
if (this.redis.condition.subscriber) {
const channel = reply[1] ? reply[1].toString() : null;
if (channel) {
this.redis.condition.subscriber.del(replyType, channel);
}
}
const count = reply[2];
if (Number(count) === 0) {
this.redis.condition.subscriber = false;
}
if (this.handleUnsolicitedUnsubscribe(replyType)) {
return;
}
const item = this.shiftCommand(reply);
if (!item) {
return;
}
if (!fillUnsubCommand(item.command, count)) {
this.redis.commandQueue.unshift(item);
}
break;
}
}
}
// Cluster sends unsolicited `sunsubscribe` pushes on slot migration; those
// answer no queued command, so shifting the queue head would resolve an
// unrelated in-flight command. Detected via a non-matching queue head, and
// `sunsubscribe` reuses the ssubscribe-MOVED recovery path.
handleUnsolicitedUnsubscribe(replyType) {
const head = this.redis.commandQueue.peekFront();
// Command names keep the user's casing (e.g. `call("UNSUBSCRIBE")`), while
// reply types from the server are lowercase — compare case-insensitively
// or an uppercase unsubscribe would be treated as unsolicited and its
// reply dropped, desyncing the queue.
if (head && head.command.name.toLowerCase() === replyType) {
return false;
}
if (replyType === "sunsubscribe") {
this.redis.emit("moved");
}
return true;
}
handleSubscriberReply(reply) {
if (!this.redis.condition.subscriber) {
return false;
}
const replyType = Array.isArray(reply) ? reply[0].toString() : null;
debug('receive reply "%s" in subscriber mode', replyType);
switch (replyType) {
case "message":
if (this.redis.listeners("message").length > 0) {
// Check if there're listeners to avoid unnecessary `toString()`.
this.redis.emit("message", reply[1].toString(), reply[2] ? reply[2].toString() : "");
}
this.redis.emit("messageBuffer", reply[1], reply[2]);
break;
case "pmessage": {
const pattern = reply[1].toString();
if (this.redis.listeners("pmessage").length > 0) {
this.redis.emit("pmessage", pattern, reply[2].toString(), reply[3].toString());
}
this.redis.emit("pmessageBuffer", pattern, reply[2], reply[3]);
break;
}
case "smessage": {
if (this.redis.listeners("smessage").length > 0) {
this.redis.emit("smessage", reply[1].toString(), reply[2] ? reply[2].toString() : "");
}
this.redis.emit("smessageBuffer", reply[1], reply[2]);
break;
}
case "ssubscribe":
case "subscribe":
case "psubscribe": {
const channel = reply[1].toString();
this.redis.condition.subscriber.add(replyType, channel);
const item = this.shiftCommand(reply);
if (!item) {
return;
}
if (!fillSubCommand(item.command, reply[2])) {
this.redis.commandQueue.unshift(item);
}
break;
}
case "sunsubscribe":
case "unsubscribe":
case "punsubscribe": {
const channel = reply[1] ? reply[1].toString() : null;
if (channel) {
this.redis.condition.subscriber.del(replyType, channel);
}
const count = reply[2];
if (Number(count) === 0) {
this.redis.condition.subscriber = false;
}
if (this.handleUnsolicitedUnsubscribe(replyType)) {
break;
}
const item = this.shiftCommand(reply);
if (!item) {
return;
}
if (!fillUnsubCommand(item.command, count)) {
this.redis.commandQueue.unshift(item);
}
break;
}
default: {
const item = this.shiftCommand(reply);
if (!item) {
return;
}
item.command.resolve(reply);
}
}
return true;
}
handleMonitorReply(reply) {
if (this.redis.status !== "monitoring") {
return false;
}
const replyStr = reply.toString();
if (replyStr === "OK") {
// Valid commands in the monitoring mode are AUTH and MONITOR,
// both of which always reply with 'OK'.
// So if we got an 'OK', we can make certain that
// the reply is made to AUTH & MONITOR.
return false;
}
// Since commands sent in the monitoring mode will trigger an exception,
// any replies we received in the monitoring mode should consider to be
// realtime monitor data instead of result of commands.
const len = replyStr.indexOf(" ");
const timestamp = replyStr.slice(0, len);
const argIndex = replyStr.indexOf('"');
const args = replyStr
.slice(argIndex + 1, -1)
.split('" "')
.map((elem) => elem.replace(/\\"/g, '"'));
const dbAndSource = replyStr.slice(len + 2, argIndex - 2).split(" ");
this.redis.emit("monitor", timestamp, args, dbAndSource[1], dbAndSource[0]);
return true;
}
shiftCommand(reply) {
const item = this.redis.commandQueue.shift();
if (!item) {
const message = "Command queue state error. If you can reproduce this, please report it.";
const error = new Error(message +
(reply instanceof Error
? ` Last error: ${reply.message}`
: ` Last reply: ${reply.toString()}`));
this.redis.emit("error", error);
return null;
}
return item;
}
}
exports.default = DataHandler;
// All string RESP types (simple, blob, verbatim) decode to Buffer rather than
// utf8 strings. This keeps the parser encoding-agnostic: ioredis decides
// utf8-vs-Buffer per command afterwards via `replyEncoding`. Decoding to utf8
// here would be irreversible and corrupt binary-safe values (e.g. DUMP output,
// serialized blobs), whereas bytes -> string stays lossless and deferred. This
// mirrors the old `redis-parser` running with `returnBuffers: true`.
// RESP2-compatible shapes: RESP3-only container and numeric types are
// flattened so replies are identical across both protocols. Strings stay
// buffers; utf8 conversion is decided per command by `replyEncoding`.
const legacyTypeMapping = {
[decoder_1.RESP_TYPES.SIMPLE_STRING]: Buffer,
[decoder_1.RESP_TYPES.BLOB_STRING]: Buffer,
[decoder_1.RESP_TYPES.VERBATIM_STRING]: Buffer,
[decoder_1.RESP_TYPES.BIG_NUMBER]: String,
// RESP2 represented doubles as bulk strings and booleans as `1`/`0`
// integers. Decode doubles to Buffer (like blob strings, so `replyEncoding`
// still chooses string-vs-buffer per command) and booleans to numbers, so
// RESP3 replies match the classic RESP2 shape.
[decoder_1.RESP_TYPES.DOUBLE]: Buffer,
[decoder_1.RESP_TYPES.BOOLEAN]: Number,
[decoder_1.RESP_TYPES.MAP]: Array,
[decoder_1.RESP_TYPES.SET]: Array,
};
// Leaving MAP and DOUBLE unmapped makes the decoder produce plain objects
// (with string keys) and numbers respectively.
const resp3TypeMapping = {
[decoder_1.RESP_TYPES.SIMPLE_STRING]: Buffer,
[decoder_1.RESP_TYPES.BLOB_STRING]: Buffer,
[decoder_1.RESP_TYPES.VERBATIM_STRING]: Buffer,
[decoder_1.RESP_TYPES.BIG_NUMBER]: String,
[decoder_1.RESP_TYPES.SET]: Array,
};
function getParserTypeMapping(parserOptions) {
const base = parserOptions.replyMapping === "resp3"
? resp3TypeMapping
: legacyTypeMapping;
// `stringNumbers` means "all numerics as strings", so it wins over the
// preset for DOUBLE as well.
return parserOptions.stringNumbers
? { ...base, [decoder_1.RESP_TYPES.NUMBER]: String, [decoder_1.RESP_TYPES.DOUBLE]: String }
: base;
}
const remainingRepliesMap = new WeakMap();
function fillSubCommand(command, count) {
let remainingReplies = remainingRepliesMap.has(command)
? remainingRepliesMap.get(command)
: command.args.length;
remainingReplies -= 1;
if (remainingReplies <= 0) {
command.resolve(count);
remainingRepliesMap.delete(command);
return true;
}
remainingRepliesMap.set(command, remainingReplies);
return false;
}
function fillUnsubCommand(command, count) {
let remainingReplies = remainingRepliesMap.has(command)
? remainingRepliesMap.get(command)
: command.args.length;
if (remainingReplies === 0) {
if (Number(count) === 0) {
remainingRepliesMap.delete(command);
command.resolve(count);
return true;
}
return false;
}
remainingReplies -= 1;
if (remainingReplies <= 0) {
command.resolve(count);
return true;
}
remainingRepliesMap.set(command, remainingReplies);
return false;
}