@mattduffy/koa-redis
Version:
My fork of koa-redis middleware/cache with Sentinel and Cluster support (now using official node-redis library instead of ioredis).
479 lines (448 loc) • 16.2 kB
JavaScript
/**
* @module @mattduffy/koa-redis
* @author Matthew Duffy <mattduffy@gmail.com>
* @summary A fork of the original koa/koa-redis package. This fork replaces ioredis with the
* official node-redis package, with support for sentinels and clusters.
* @file src/index.js
*/
/* Original Author's attribution. */
/**
* koa-redis - index.js
* Copyright(c) 2015
* MIT Licensed
*
* Authors:
* dead_horse <dead_horse@qq.com> (http://deadhorse.me)
*/
import Debug from 'debug'
import {
createClient,
createCluster,
createSentinel,
} from 'redis'
import { EventEmitter } from 'node:events'
const debug = Debug('@mattduffy/koa-redis')
/**
* Initialize redis session middleware with `opts` (see the README for more info):
*
* @param {Object} opts
* @param {String} opts.db Redis db.
* @param {Object} opts.client Redis client (overides all other options
* except db and duplicate).
* @param {String} opts.socket Redis socket (DEPRECATED: use 'path' instead).
* @param {Boolean} opts.duplicate If own client object, will use node redis's
* duplicate function and pass other options.
* @param {String} opts.password Redis user password.
* @param {String} [opts.keyPrefix] Like ioredis's tranaparent key prefix.
* @param {string} [opts.dataType = string] Redis data type to use, string or ReJSON-RL
* (or ReJSON is acceptable).
* @param {String} [opts.redisUrl]
* @param {Boolean} [opts.isRedisSingle = false]
* @param {Boolean} [opts.isRedisReplset = false]
* @param {Boolean} [opts.isRedisCluster = false]
* @param {String} opts.name
* @param {Object[]} opts.sentinelRootNodes
* @param {Object} opts.sentinelClientOptions
* @param {String} opts.sentinelClientOptions.username
* @param {String} opts.sentinelClientOptions.password
* @param {Object} opts.sentinelClientOptions.socket
* @param {Boolean} opts.sentinelClientOptions.socket.tls
* @param {Boolean} opts.sentinelClientOptions.socket.rejectUnauthorized
* @param {Blob} opts.sentinelClientOptions.socket.ca
* @param {Object} opts.nodeClientOptions
* @param {String} opts.nodeClientOptions.username
* @param {String} opts.nodeClientOptions.password
* @param {Object} opts.nodeClientOptions.socket
* @param {Boolean} opts.nodeClientOptions.socket.tls
* @param {Boolean} opts.nodeClientOptions.socket.rejectUnauthorized
* @param {Blob} opts.nodeClientOptions.socket.ca
* @param {String} opts.role
* @param {Object[]} opts.rootNodes
* @param {Object} opts.defaults
* @param {Object} opts.defaults.username
* @param {Object} opts.defaults.password
* @param {Object} [opts.defaults.socket]
* @param {Boolean} [opts.defaults.socket.tls]
* @param {Boolean} [opts.defaults.socket.rejectUnauthorized]
* @param {Blob} [opts.defaults.socket.ca]
* @param {Any} [any] All other options passed to redis.
* @returns {Object} Redis instance.
*/
class RedisStore extends EventEmitter {
/**
* The RedisStore constructor method.
* @summary Returns an instance, with an empty redis client placeholder.
* @author Matthew Duffy <mattduffy@gmail.com>
* @param {Object} [opts = null] - An optional options object.
* @returns {RedisStore}
*/
constructor(opts = null) {
super()
this.client = null
this.clientType = null
this.keyPrefix = ''
this.dataType = ''
this.options = opts || {}
}
/**
* Initialized an instance of the RedisStore class with redis client config object.
* @summary Initialized an instance of the RedisStore class with redis client config object.
* @author Matthew Duffy <mattduffy@gmail.com>
* @async
* @param {Object} opts - Redis client configuration options.
* @return {RedisStore} Returns the instance with a configured and connected redis client.
*/
async init(opts) {
this.options.isRedisSingle = true
this.options.isRedisReplset = false
this.options.isRedisCluster = false
this.options.redisUrl = false
this.options.lazyConnect = false
this.options = { ...opts }
// debug('redisStore init opts', opts)
if (this.options) {
this.keyPrefix = this.options?.keyPrefix || ''
}
this.dataType = this.options?.dataType || 'string'
// For backwards compatibility
this.options.password = this.options.password
|| this.options.auth_pass
|| this.options.pass
|| null
// For backwards compatibility
this.options.path = this.options.path
|| this.options.socket
|| null
if (!this.options.client) {
// const redisUrl = this.options.url && this.options.url.toString()
// delete this.options.url
if (this.options.isRedisCluster) {
debug('Initializing Redis Cluster')
delete this.options.isRedisCluster
delete this.options.isRedisSingle
delete this.options.isRedisReplset
this.client = await createCluster(this.options.clusterOptions)
this.clientType = 'cluster'
} else if (this.options.sentinelRootNodes
&& this.options.isRedisReplset
&& !this.options.isRedisCluster) {
delete this.options.isRedisSingle
delete this.options.isRedisReplset
delete this.options.isRedisCluster
debug('Initializing Redis Replica set with Sentinels')
this.client = await createSentinel(this.options)
this.clientType = 'sentinel'
} else {
debug('Initializing standalone Redis')
delete this.options.isRedisSingle
delete this.options.isRedisReplset
delete this.options.isRedisCluster
delete this.options.clusterOptions
delete this.options.nodes
if (this.options.redisUrl) {
this.client = await createClient(this.options.redisUrl, this.options)
} else {
if (this.options.url) {
// debug('standalone client, converting url to parts:', this.options.url)
const url = new URL(this.options.url)
if (!this.options.socket) {
this.options.socket = {}
}
this.options.socket.host = url.hostname
this.options.socket.port = url.port
this.options.username = url.username
this.options.password = url.password
delete this.options.url
}
// debug('standalone opts', this.options)
this.client = await createClient(this.options)
this.clientType = 'single'
debug('client created?', this.client)
}
}
} else if (this.options.duplicate) {
// Duplicate client and update with options provided
debug('Duplicating provided client with new options (if provided)')
const dupClient = this.options.client
delete this.options.client
delete this.options.duplicate
// Useful if you want to use the DB option without
// adjusting the client DB outside koa-redis
this.client = dupClient.duplicate(this.options)
} else {
debug('Using provided client')
this.client = this.options.client
}
if (!this.options.client) {
await this.client.connect()
}
if (this.options.db) {
debug('selecting db %s', this.options.db)
this.client.select(this.options.db)
this.client.on('connect', () => {
this.client.send_anyways = true
this.client.select(this.options.db)
this.client.send_anyways = false
})
}
['connect', 'ready', 'error', 'close', 'reconnecting', 'end'].forEach(
(name) => {
this.on(name, () => debug(`redis ${name}`))
this.client.on(name, this.emit.bind(this, name))
},
)
// For backwards compatibility
this.client.on('end', this.emit.bind(this, 'disconnect'))
// This is legacy ioredis stuff, not used in new fork.
// Object.defineProperty(this, 'status', {
// get() {
// return this.client.status
// },
// })
// Object.defineProperty(this, 'connected', {
// get() {
// return ['connect', 'ready'].includes(this.status)
// },
// })
// Support optional serialize and unserialize
this.serialize = (
typeof this.options.serialize === 'function' && this.options.serialize
) || JSON.stringify
this.unserialize = (
typeof this.options.unserialize === 'function' && this.options.unserialize
) || JSON.parse
// check the available server modules, is ReJSON included?
const mods = await this.mods()
if (/^rejson(?:-rl)?/i.test(this.dataType) && mods.includes('ReJSON')) {
this.useReJSON = true
} else {
this.useReJSON = false
}
// return the connected redis client instance
return this
}
// util.inherits(RedisStore, EventEmitter)
/**
* Returns PONG if no argument is provided.
* @summary Returns PONG if no argument is provided.
* @author Matthew Duffy <mattduffy@gmail.com>
* @async
* @return {string} The string 'PONG'
*/
async ping() {
return this.client.ping()
}
/**
* Returns an array of available Redis server modules.
* @summary Returns an array of available Redis server modules.
* @author Matthew Duffy <mattduffy@gmail.com>
* @async
* @return {string[]} Server module names
*/
async mods() {
if (this.modules?.length > 0) {
return this.modules
}
const mods = await this.client.info('modules')
// debug(mods)
const lines = mods.split('\r\n')
const availableModules = lines.filter((m) => /^module:name=.*/.test(m))
this.modules = availableModules.map((m) => m.slice(m.indexOf('=') + 1, m.indexOf(',')))
return this.modules
}
/**
* Returns the string value of the given key.
* @summary Returns the string value of the given key.
* @author Matthew Duffy <mattduffy@gmail.com>
* @async
* @param {string} _sid - The name of the key whose value is returned.
* @return {string|null} The value of 'key' or nil if key does not exist.
*/
async get(_sid) {
let result
let data
const sid = `${this.keyPrefix}${_sid}`
if (!this.useReJSON) {
debug(`koa-redis->get(${sid})`)
data = await this.client.get(sid)
} else {
debug(`koa-redis->json->get(${sid})`)
data = await this.client.json.get(sid)
return data
}
debug('get session: %s', data || 'none')
if (!data) {
return null
}
try {
result = this.unserialize(data.toString())
} catch (err) {
// ignore err
debug('parse session error: %s', err.message)
}
return result
}
/**
* Set the given key to the given value.
* @summary Set the given key to the given value, optionally with expiry time.
* @author Matthew Duffy <mattduffy@gmail.com>
* @async
* @param {string} _sid - The name of the key whose value is to be set.
* @param {string|Object} _sess - The value to be set on key <_sid>.
* @param {number} [_ttl] - An optional time in seconds before key expires.
* @return {undefined}
*/
async set(_sid, _sess, _ttl) {
let ttl = null
const sid = `${this.keyPrefix}${_sid}`
// debug('what is this.useReJSON?', this.useReJSON)
const sess = (this.useReJSON) ? _sess : this.serialize(_sess)
// debug('what is _sess now?', _sess)
if (typeof _ttl === 'number') {
ttl = Math.ceil(_ttl / 1000)
// debug('ttl', ttl)
}
// eslint-disable-next-line
// debug(`koa-redis->set(${sid}, ${sess}${ttl ? ', { EX: ' + ttl + ' }': ''})`)
if (ttl) {
if (!this.useReJSON) {
// debug('SET %s %s %o', sid, sess, { EX: ttl })
await this.client.set(sid, sess, { EX: ttl })
} else {
// debug('json.set(%s, %s, %o)', sid, sess)
await this.client.json.set(sid, '$', sess)
await this.client.expire(sid, ttl)
}
} else {
switch (this.useReJSON) {
case false:
// debug('SET %s %s', sid, sess)
await this.client.set(sid, sess)
break
case true:
// debug('json.set(%s, %s)', sid, sess)
await this.client.json.set(sid, '$', sess)
break
default:
debug('failed to set session (w/out TTL).')
debug('what was this.useReJSON?', this.useReJSON)
break
}
}
debug('%s %s complete', (this.useReJSON) ? 'json.set' : 'SET', sid)
}
/**
* Returns the remaining time to live of a key.
* @summary Returns the remaining time to live of a key.
* @author Matthew Duffy <mattduffy@gmail.com>
* @async
* @param {string} _key - The key whose expiry time is requested.
* @return {number} The number of seconds remaining before expiration (-2 if key does
* not exist, -1 if the key has no expiration set).
*/
async ttl(_key) {
debug(
`client.ttl(${this.keyPrefix}${_key}`,
await this.client.ttl(`${this.keyPrefix}${_key}`),
)
return this.client.ttl(`${this.keyPrefix}${_key}`)
}
/**
* Removes the specified key.
* @summary Removes the specified key.
* @author Matthew Duffy <mattduffy@gmail.com>
* @async
* @param {string} _sid - The key to be deleted.
* @return {number} The number of keys successfully deleted.
*/
async destroy(_sid) {
const sid = `${this.keyPrefix}${_sid}`
debug('DEL %s', sid)
return this.client.del(sid)
// debug('DEL %s complete', sid)
}
/**
* Ask the server to close the connection.
* @summary Ask the server to close the connection.
* @author Matthew Duffy <mattduffy@gmail.com>
* @async
* @return {undefined}
*/
async quit() {
// End connection SAFELY
debug('quitting redis client')
let _quit
if (this.clientType === 'cluster') {
_quit = await this.client.close()
} else if (this.clientType === 'sentinel') {
_quit = await this.client.close()
} else {
_quit = await this.client.quit()
}
return _quit
}
/**
* Ask the server to close the connection.
* @summary Ask the server to close the connection.
* @author Matthew Duffy <mattduffy@gmail.com>
* @async
* @borrows quit as end
*/
async end() {
// End connection SAFELY
debug('quitting redis client')
return this.client.quit()
}
/**
* Check if the the client is connected and ready to send commands.
* @summary Check if the the client is connected and ready to send commands.
* @author Matthew Duffy <mattduffy@gmail.com>
* @type {boolean}
*/
get isReady() {
return this.client.isReady
}
/**
* Check if the the client is connected and ready to send commands.
* @summary Check if the the client is connected and ready to send commands.
* @author Matthew Duffy <mattduffy@gmail.com>
* @type {boolean}
*/
get isOpen() {
return this.client.isOpen
}
/**
* Should return the current state of the redis client [ready, waiting, etc], but currently
* the client seems to only return 'undefined'.
* @summary Should return the current state of the redis client [ready, waiting, etc].
* @author Matthew Duffy <mattduffy@gmail.com>
* @type {string} open, ready, or waiting
*/
get status() {
if (this.client.isOpen) {
return 'open'
}
if (this.client.isReady) {
return 'ready'
}
return 'waiting'
}
/**
* Returns true if the client is connected and ready.
* @summary Returns true if the client is connected and ready.
* @author Matthew Duffy <mattduffy@gmail.com>
* @type {boolean}
*/
get connected() {
return this.client.isReady
}
}
// wrap(RedisStore.prototype)
// End connection SAFELY. The real end() command should
// never be used, as it cuts off to queue.
// RedisStore.prototype.end = RedisStore.prototype.quit
const _redis = new RedisStore()
export {
RedisStore,
_redis as redisStore,
}