mcode-cache
Version:
Our Data Caching functions. These support files, JSON, JS Objects, any primitive, etc. And provide common keying support.
1,197 lines (1,043 loc) • 38.7 kB
JavaScript
// #region F I L E
// <copyright file="mcode-cache/index.js" company="MicroCODE Incorporated">Copyright © 2022-2024 MicroCODE Incorporated Troy, MI</copyright><author>Timothy J. McGuire</author>
// #region M O D U L E
// #region D O C U M E N T A T I O N
/**
* Project: MicroCODE MERN Applications
* Customer: Internal + MIT xPRO Course
* @module 'mcode-cache.js'
* @memberof mcode
* @created January 2022-2024
* @author Timothy McGuire, MicroCODE, Inc.
* @description >
* MicroCODE File and Data Caching Library
*
* LICENSE:
* --------
* MIT License: MicroCODE.mcode-cache
*
* Copyright (c) 2022-2024 Timothy McGuire, MicroCODE, Inc.
*
* 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:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* 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.
*
*
* DESCRIPTION:
* ------------
* This module implements the MicroCODE's Common JavaScript functions for data caching.
*
* NOTE:
*
* o 'key' in this code refers to the Application Key or File Path.
*
* o 'cacheKey' in this code refers to a fully qualified cache Key.
*
* o 'cacheKeys' are made from 'key' values by the 'cacheMakeKey()' method,
* which formats the 'key' into a cache Key with a prefix
* based on the current Namespace.
*
* o '_cache*()' are private methods that are not exposed to the caller,
* these expect 'cacheKey' values, not 'key' values.
*
* <key> - application 'key' format
* <namespace>:<key> - 'cacheKey' format
*
* Examples:
*
* key: '/backend/components/app/tool/tool.template.htmx'
* cacheKey: 'GM-GPS-eMITS-UI:backend:components:app:tool:tool.template.htmx'
*
* key: 'myKey'
* cacheKey: 'MicroCODE:myKey'
*
*
* REFERENCES:
* -----------
* 1. MIT xPRO Course: Professional Certificate in Coding: Full Stack Development with MERN
*
* 2. MicroCODE JavaScript Style Guide
* Local File: MCX-S02 (Internal JS Style Guide).docx
* https://github.com/MicroCODEIncorporated/JavaScriptSG
*
*
*
*
* MODIFICATIONS:
* --------------
* Date: By-Group: Rev: Description:
*
* 30-Jan-2024 TJM-MCODE {0001} New module for common reusable JavaScript data caching functions.
* 01-Feb-2024 TJM-MCODE {0002} Changed to the Universal Module Definition (UMD) pattern to support AMD,
* CommonJS/Node.js, and browser global in our exported module.
* 15-Sep-2024 TJM-MCODE {0003} Extended to support *node-cache* package for caching local information
* to avoid network latency, this is now the default cache provider.
*
*
*
*
* NOTE: This module follow's MicroCODE's JavaScript Style Guide and Template JS file, see:
*
* o https://github.com/MicroCODEIncorporated/JavaScriptSG
* o https://github.com/MicroCODEIncorporated/TemplatesJS
*
* ...be sure to check out the CTRL-SHIFT+K, +L, +J keybaord shortcuts in Visual Studio Code
* for taking advance of the #regions in this file and our templates.
*
*/
// #endregion
// #endregion
// #endregion
// #region I N C L U D E S
const _log = require('mcode-log');
const _data = require('mcode-data');
const path = require('path');
const fs = require('fs').promises;
const Redis = require('redis');
const NodeCache = require('node-cache');
// #endregion
// #region C O N S T A N T S
// MicroCODE: define this module's name for our 'mcode-log' package
const MODULE_NAME = 'mcode-cache.js';
// #endregion
// #region C L A S S
/**
* @class cache Class to provide transparent data caching for MicroCODE applications.
*
*/
class cache
{
// #region C O N S T A N T S
static CACHE_TTL = 60 * 60 * 24; // 24 hours in seconds
static CLASS_TYPE = 'cache';
static REDIS_URL = 'redis://127.0.0.1:6379';
static REDIS_PORT = 6379;
static REDIS_USER = 'user';
static REDIS_PASSWORD = 'password';
// #endregion
// #region P R I V A T E F I E L D S
// node-cache instance
#cache = null;
#cacheTTL = cache.CACHE_TTL;
#cacheNamespace = '';
#cacheNamespaces = {};
#cacheEnabled = true;
// Redis instance
#redis = null;
#redisURL = cache.REDIS_URL;
#redisPort = cache.REDIS_PORT;
#redisUser = cache.REDIS_USER;
#redisPassword = cache.REDIS_PASSWORD;
#redisConnected = false;
#redisEnabled = true;
#privateExample = 'PRIVATE PROPERTY';
// #endregion
// #region C O N S T R U C T O R
/**
* @constructor cache class constructor.
*/
constructor ()
{
// Create a Singleton instance
if (!cache.instance)
{
this.#cacheTTL = cache.CACHE_TTL;
this.#redisURL = `${cache.REDIS_URL}`;
this.#redisPort = `${cache.REDIS_PORT}`;
this.#redisUser = `${cache.REDIS_USER}`;
this.#redisPassword = `${cache.REDIS_PASSWORD}`;
this._cacheInit();
// add the default namespace as a node-cache namespace
this.addNamespace({name: 'MicroCODE', type: 'node'});
// make it current
this.#cacheNamespace = 'MicroCODE';
// generate a default cache key for the current namespace
this.cacheSet('Default', 'node-cache');
cache.instance = this;
}
_log.done(`mcode-cache initialized with namespace: ${this.#cacheNamespace}`, MODULE_NAME);
return cache.instance;
}
// #endregion
// #region E N U M E R A T I O N S
/**
* @enum namedEnum1 - a description of this enum, its use, and meaning. TEMPLATE.
*/
static namedEnum1 = Object.freeze
({
name1: 0,
name2: 1,
name3: 2,
name4: 3,
name5: 4,
name6: 5,
name7: 6
});
// #endregion
// #region P R O P E R T I E S
/**
* @property {number} cacheReady the cache instance and default namespace has=ve been established successfully.
*/
get cacheReady()
{
if (this.#redis)
{
return (this.#cache != null && this.#redisConnected);
}
else
{
return this.#cache != null;
}
}
/**
* @property {number} cacheTTL the cache Time-To-Live property, in seconds.
*/
get cacheTTL()
{
return this.#cacheTTL;
}
set cacheTTL(value)
{
this.#cacheTTL = value;
}
/**
* @property {string} cacheNamespaces the 'prefix' used to group our keys in the cache Server.
* This property switches to a new namespace, to be used for all subsequent cache operations as the default.
* The namespace must already exist in the cache servers 'namespace' list, see addNamespace().
*/
get cacheNamespaces()
{
return this.#cacheNamespaces;
}
/**
* @property {string} cacheNamespace the 'prefix' used to group our keys in the cache Server.
* This property switches to a new namespace, to be used for all subsequent cache operations as the default.
* The namespace must already exist in the cache servers 'namespace' list, see addNamespace().
*/
get cacheNamespace()
{
return this.#cacheNamespace;
}
set cacheNamespace(value)
{
this.#cacheNamespace = value;
_log.success(`Switched to namespace: '${this.#cacheNamespace}`, MODULE_NAME);
}
/**
* @property {number} cacheEnabled returns a value indicating whether or not the Node Caches are caching the namespaces.
*/
get cacheEnabled()
{
return this.#cacheEnabled;
}
/**
* @property {number} redisEnabled returns a value indicating whether or not Redis Caches is caching the namespaces.
*/
get redisEnabled()
{
return this.#redisEnabled;
}
/**
* @property {string} redisURL the URL to the Redis Server.
*/
get redisURL()
{
return this.#redisURL;
}
set redisURL(value)
{
this.#redisURL = value;
}
/**
* @property {string} redisPort the PORT to the Redis Server.
*/
get redisPort()
{
return this.#redisPort;
}
set redisPort(value)
{
this.#redisPort = value;
}
/**
* @property {string} redisUser the User to the Redis Server.
*/
get redisUser()
{
return this.#redisUser;
}
set redisUser(value)
{
this.#redisUser = value;
}
/**
* @property {string} redisPassword the Password to the Redis Server.
*/
get redisPassword()
{
return this.#redisPassword;
}
set redisPassword(value)
{
this.#redisPassword = value;
}
/**
* @property {string} _privateExample an example of a private property.
*/
get _privateExample()
{
return this.#privateExample;
}
set _privateExample(value)
{
this.#privateExample = value;
}
// #endregion
// #region S Y M B O L S
/**
* iterator1 - a description of this iterator, its use, and meaning.
*/
[Symbol('iterator1')]()
{
// method with computed name (symbol here) TEMPLATE
}
// #endregion
// #region M E T H O D S – S T A T I C
/**
* static1() – description of public static method, called by prototype not object.
* This does not operate on a specific copy of a Class object.
* @api public
*
* @param {type} param1 description of param1.
* @returns {type} description of return value.
*
* @example
*
* cache.static1('param1');
*/
static static1(param1)
{
// ... TEMPLATE
return value;
}
// #endregion
// #region M E T H O D S – P U B L I C
/**
* @func addNamespace
* @memberof mcode.cache
* @desc Adds a new namespace to the cache server.
* @param {object} namespace the namespace and configuration to be added to the cache server.
* @api public
* @example
* const namespace = {name: 'MicroCODE', type: 'node', user: 'username', password: '...'};
*/
addNamespace(namespace)
{
// get the namespace name and type
if (!namespace || !namespace.name || !namespace.type)
{
_log.warn(`Invalid namespace: it must have a 'name' and 'type' defined.`, MODULE_NAME);
return;
}
// only allow 'node' or 'redis' cache types
if (namespace.type !== 'node' && namespace.type !== 'redis')
{
_log.warn(`Invalid cache type: ${namespace.type}, selected for namespace: ${namespace.name}, must be 'node' or 'redis'.`, MODULE_NAME);
return;
}
// When we add the 1st Redis namespace, initialize the Redis client
if (namespace.type === 'redis' && !this.#redis)
{
// if no Redis url is provided, use the default
if (!namespace.url)
{
namespace.url = this.#redisURL;
}
// if no port is provided, use the default
if (!namespace.port)
{
namespace.port = 6379;
}
// if no user is provided, use the default
if (!namespace.user)
{
namespace.user = null;
}
// if no password is provided, use the default
if (!namespace.password)
{
namespace.password = null;
}
this.#redisURL = `${namespace.url}`;
this.#redisPort = `${namespace.port}`;
this.#redisUser = `${namespace.user}`;
this.#redisPassword = `${namespace.password}`;
this._redisInit();
}
// add the namespace to the cache server
this.#cacheNamespaces[namespace.name] = namespace.type;
_log.success(`Added namespace: '${namespace.name}`, MODULE_NAME);
}
/**
* @func cacheMakeKey
* @memberof mcode.cache
* @desc Converts a 'key source' into a cache Key by replacing slashes with colons and removing spaces.
* @param {string} keySource the path to the key to be converted.
* @returns {string} the cache Key.
* @api public
* @example
* const keyPath = 'components/app/tool/tool.template.htmx';
* returns 'GM-GPS-eMITS-UI:components:app:tool:tool.template.htmx';
*/
cacheMakeKey(keySource)
{
// convert the file path into a cache Key
let key = keySource.replace(/[\\/]/g, ':'); // Handle both forward and backward slashes
// remove spaces ' '
key = key.replace(/\s/g, ' ');
// remove double-dots '..'
key = key.replace(/\.\./g, '.');
// remove leading '.' and trailing '.'
key = key.replace(/^\.+|\.+$/g, '');
// remove leading and trailing colons
key = key.replace(/^:+|:+$/g, '');
// now, make it specific to the caller's namespace..
return `${this.cacheNamespace}:${key}`;
}
/**
* @function cacheGet
* @memberof mcode.cache
* @desc Caches the results of a callback function in cache under the current namespace and returns the key's value.
* @param {string} key the app key to get from the current namespace.
* @param {function} cb the callback function to get fresh value.
* @returns {Promise} the cached value.
*/
async cacheGet(key, cb = () => {return undefined;})
{
// make the auto-generated cache key for the 'key' - get from current namespace, add if not cached
const cacheKey = this.fileMakeKey(key);
// get the value from the cache associated with the current namespace
if (this.#cacheNamespaces[this.#cacheNamespace] === 'redis')
{
// use the Redis client
return await this.#redis.get(cacheKey);
}
return await this._cacheGet(cacheKey, cb);
}
/**
* @function cacheSet
* @memberof mcode.cache
* @desc Sets a key value in the cache.
* @param {string} key the app key to be set into current namespace.
* @param {string} value the value to be set in the cache.
* @returns {string} the value set in the cache.
*/
async cacheSet(key, value)
{
// make the auto-generated cache key for the 'key' - set into current namespace
const cacheKey = this.fileMakeKey(key);
// set the value in the cache associated with the current namespace
if (this.#cacheNamespaces[this.#cacheNamespace] === 'redis')
{
// use the Redis client
return await this.#redis.set(cacheKey, value);
}
return await this._cacheSet(cacheKey, value);
}
/**
* @func cacheDrop
* @memberof mcode.cache
* @desc Drops a key value from the cache based on the 'key' name.
* @param {string} key the app key to be droppped.
* @returns {number} the number of keys deleted from the cache.
* @api public
* @example
* const count = await mcode.cacheDrop(keyName);
*/
async cacheDrop(key)
{
// make the auto-generated cache key for the 'key' - drop from current namespace
const cacheKey = this.fileMakeKey(key);
// delete the value from the cache associated with the current namespace
if (this.#cacheNamespaces[this.#cacheNamespace] === 'redis')
{
// use the Redis client
return await this._redisDrop(cacheKey);
}
return await this._cacheDrop(cacheKey);
}
/**
* @func cacheDropAll
* @memberof mcode.cache
* @desc Drops all keys from the cache based on the App's namespace.
* @param {string} cache the cache to drop all keys from.
* @param {string} namespace the namespace to drop all keys from.
* @param {string} pattern the key pattern to drop all keys from.
* @returns {number} the number of keys deleted from the cache.
* @api public
* @example
* const result = await mcode.cacheDropAll();
* const result = await mcode.cacheDropAll({cache: 'redis', namespace: 'GM-GPS-eMITS-DB', pattern: '*'});
*/
async cacheDropAll({cache = '*', namespace = '*', pattern = '*'})
{
let result = 0;
for (const thisNamespace in this.#cacheNamespaces)
{
if (thisNamespace === namespace || namespace === '*')
{
const cacheType = this.#cacheNamespaces[thisNamespace];
if (cacheType === 'node' && (cache === 'node' || cache === '*'))
{
// Get keys from the Node cache
const nodeKeys = await this._cacheKeys(`${thisNamespace}:${pattern}`);
result += nodeKeys.length;
// Delete all keys from the Node cache
await Promise.all(nodeKeys.map(key => this.#cache.del(key)));
}
if (cacheType === 'redis' && (cache === 'redis' || cache === '*'))
{
// Get keys from the Redis client
const redisKeys = await this.#redis.keys(`${thisNamespace}:${pattern}`);
result += redisKeys.length;
// Delete all keys from the Redis cache
await Promise.all(redisKeys.map(key => this.#redis.del(key)));
}
}
}
// Return the total number of keys deleted
return result;
}
/**
* @func cacheListAll
* @memberof mcode.cache
* @desc Lists all keys from the cache based on the App's namespace.
* @param {string} cache the cache to list all keys from.
* @param {string} namespace the namespace to list all keys from.
* @param {string} pattern the key pattern to list all keys from.
* @returns {Array} an array of namespace keys in the cache.
* @api public
* @example
* const result = await mcode.cacheListAll();
* const result = await mcode.cacheListAll({cache: 'node', namespace: '*', keyStar: '*'});
*/
async cacheListAll({cache = '*', namespace = '*', pattern = '*'})
{
let keys = [];
for (const thisNamespace in this.#cacheNamespaces)
{
if (thisNamespace === namespace || namespace === '*')
{
const cacheType = this.#cacheNamespaces[thisNamespace];
if (cacheType === cache || cache === '*')
{
if (cacheType === 'node')
{
// use the Node cache - NOTE: node-cache.keys() does not support wildcards
keys = keys.concat(await this._cacheKeys(`${thisNamespace}:${pattern}`));
}
if (cacheType === 'redis')
{
// use the Redis client
keys = keys.concat(await this.#redis.keys(`${thisNamespace}:${pattern}`));
}
}
}
}
return keys;
}
/**
* @func cacheOn
* @memberof mcode.cache
* @desc Turns ON caching in the Node caches.
* @api public
*/
async cacheOn()
{
this.#cacheEnabled = true;
}
/**
* @func cacheOff
* @memberof mcode.cache
* @desc Turns OFF caching in the Node caches.
* @api public
*/
async cacheOff()
{
this.#cacheEnabled = false;
this.cacheDropAll({cache: 'node', namespace: '*', pattern: '*'});
}
/**
* @func redisOn
* @memberof mcode.redis
* @desc Turns ON caching in the Redis caches.
* @api public
*/
async redisOn()
{
this.#redisEnabled = true;
}
/**
* @func redisOff
* @memberof mcode.redis
* @desc Turns OFF caching in the Redis caches.
* @api public
*/
async redisOff()
{
this.#redisEnabled = false;
this.cacheDropAll({cache: 'redis', namespace: '*', pattern: '*'});
}
/**
* @func cacheClose
* @memberof mcode.cache
* @desc Closes the cache connection.
* @returns {status} the cache client connection.
* @api public
* @example
* const result = await mcode.cacheClose();
*/
async cacheClose()
{
if (this.#cache)
{
this.#cache = null;
}
if (this.#redis)
{
this.#redis.quit();
this.#redis = null;
}
}
/**
* @func fileRead
* @memberof mcode
* @desc Reads a file from 'path' and caches its for future reference. The cache 'key' generated
* is based on the 'path' and the server's base URL (which is removed from the 'key' before caching).
* @api public
* @param {string} filePath a standard file system reference to the file to be read,
* @param {string} fileEncoding the encoding of the file to be read (default is 'utf8').
*
* NOTE: 'filePath' is reduced to the unique sub-folder path to the file being read on the server.
* Explicit paths to files outside the server's root directory are supported with
* the 'complete path' parameter becoming the unique key for the file.
*
* @returns {string} the contents of the file read from 'path'.
*
* @example
* const filePath = './data.json';
* const fileData = mcode.fileRead(path.join(__dirname, filePath);
*
* filePath: "D:\MicroCODE\GM-GPS-eMITS-UI\Source\backend\components\app\tool\tool.template.htmx",
* rootDir: "D:\MicroCODE\GM-GPS-eMITS-UI\Source\backend",
* keyPath: "\components\app\tool\tool.template.htmx",
* key: "GM-GPS-eMITS-UI:components:app:tool:tool.template.htmx"
*
* The 1st time 'mcode.fileRead()' is called, the file is read from disk and cached.
* The 2nd time 'mcode.fileRead()' is called, the file is read from the cache.
* The 'key' used to cache the file is based on the 'path' and the server's base URL
* and does not need to be provided by the caller, nor stored by the caller, it is transparent.
*
*/
async fileRead(filePath, fileEncoding = 'utf8')
{
try
{
// make the auto-generated cache key for the file
const cacheKey = this.fileMakeKey(filePath);
return this._cacheGet(cacheKey, async () =>
{
try
{
// Check if the file exists and is accessible
await fs.access(filePath, fs.constants.R_OK);
}
catch (exp)
{
_log.exp(`File is NOT READ accessible: ${filePath}`, MODULE_NAME, exp);
throw new Error(`File READ access error: ${filePath}`);
}
return await fs.readFile(filePath, fileEncoding);
});
}
catch (exp)
{
_log.exp(`Exception reading from disk for cache, file: ${filePath}`, MODULE_NAME, exp);
return null;
}
}
/**
* @func fileWrite
* @memberof mcode.cache
* @desc Writes 'fileData' to 'filePath' and caches it in the cache.
* @param {string} filePath a standard file system reference to the file to be read.
* @param {string} fileData the data to be written to the file.
* @param {string} fileEncoding the encoding of the file to be written (default is 'utf8').
* @returns {Promise} the file data written to disk.
*/
async fileWrite(filePath, fileData, fileEncoding = 'utf8')
{
try
{
// make the auto-generated cache key for the file
const cacheKey = this.fileMakeKey(filePath);
// the cached value is no longer valid, so drop it
this.cacheDrop(cacheKey);
// cache the new value
this._cacheSet(cacheKey, fileData);
try
{
// Check if the file exists and is accessible
await fs.access(filePath, fs.constants.W_OK);
}
catch (exp)
{
_log.exp(`File is NOT WRITE accessible: ${filePath}`, MODULE_NAME, exp);
throw new Error(`File WRITE access error: ${filePath}`);
}
// write the file to disk
return await fs.writeFile(filePath, fileData, {encoding: fileEncoding});
}
catch (exp)
{
_log.exp(`Exception writing to disk and cache, file: ${filePath}`, MODULE_NAME, exp);
return null;
}
}
/**
* @func fileDrop
* @memberof mcode.cache
* @desc Drops a file from the cache based on the 'filePath'.
* @param {string} filePath a standard file system reference to the file to be read.
* @returns {number} the number of keys deleted from the cache.
* @api public
* @example
* const filePath = './data.json';
* const result = await mcode.fileDrop(path.join(__dirname, filePath));
*/
async fileDrop(filePath)
{
const cacheKey = this.fileMakeKey(filePath);
return await this._cacheDrop(cacheKey);
}
/**
* @func fileMakeKey
* @memberof mcode.cache
* @desc Generates a unique key for the file in the cache based on the 'filePath'.
* @param {string} filePath a standard file system reference to the file to be read.
* @returns {string} the key for the file in the cache.
*/
fileMakeKey(filePath)
{
// remove 'rootKey' from the 'filePath'
const keyPath = filePath.replace(this.fileGetRoot(), '');
const key = this.cacheMakeKey(keyPath);
return key;
}
/**
* @func fileGetRoot
* @memberof mcode.cache
* @desc Gets the root directory for cache Keys based on the server's execution path.
* @returns {string} the root directory for the server.
* @api public
* @example
* const rootDir = mcode.fileGetRoot();
*/
fileGetRoot()
{
// get the execution root directory
const mainDir = path.dirname(require.main.filename);
// Determine the common base path
const rootDir = path.resolve(path.join(mainDir, '..'));
return rootDir;
}
// #endregion
// #region M E T H O D S - G E N E R A T O R S
/**
* getValue() - returns all values in 'enums'. TEMPLATE marked private '_' for now.
*
*/
*_getValue()
{
for (const enumValue of this.enums)
{
yield value;
}
}
// #endregion
// #region M E T H O D S – P R I V A T E
/**
* @function _cacheInit
* @api private
* @memberof mcode.cache
* @desc Initializes the internals of mcode-cache, including the instantiation of the cache client.
* @returns {status} the cache client connection.
*/
_cacheInit()
{
// if a client already exists, close it and start a new one
if (this.#cache)
{
this.#cache = null;
}
if (!this.#cache)
{
this.#cache = new NodeCache({stdTTL: cache.CACHE_TTL});
_log.done(`mcode-cache initialized with TTL: ${cache.CACHE_TTL} 📣`, MODULE_NAME);
}
}
/**
* @function _redisInit
* @api private
* @memberof mcode.cache
* @desc Initializes the internals of mcode-cache, including the instantiation of the Redis client.
* @returns {status} the Redis client connection.
*/
_redisInit()
{
if (this.#redisConnected)
{
_log.warn(`Redis client is already connected to: ${this.#redisURL}`, MODULE_NAME);
return;
}
if (this.#redis)
{
_log.info('Closing existing Redis client before reinitializing.', MODULE_NAME);
this.#redis.quit();
this.#redis = null;
}
if (!this.#redis)
{
// Set-up Redis client configuration based on security
const clientOptions = this.#redisUser && this.#redisPassword
? {url: this.#redisURL, username: this.#redisUser, password: this.#redisPassword}
: {url: this.#redisURL};
// Create Redis Client
this.#redis = Redis.createClient(clientOptions);
this.#redis.on('connect', () =>
{
_log.done(`REDIS client connected on: ${this.#redisURL} 📣`, MODULE_NAME);
this.#redisConnected = true;
});
this.#redis.on('error', (err) =>
{
_log.error(`REDIS client error on: ${this.#redisURL}`, MODULE_NAME,
_data.default(err, 'REDIS is unreachable, check network connection, VPNs, firewalls, etc.'));
});
this.#redis.connect();
}
}
/**
* @function _cacheGet
* @memberof mcode.cache
* @desc Caches the results of a callback function in cache under the current namespace and returns the key's value.
* @param {string} cacheKey the key to the cache.
* @param {function} cb the callback function to get fresh value.
* @returns {Promise} the cached value.
*/
async _cacheGet(cacheKey, cb)
{
try
{
// if the cache is not enabled, just get the data from the callback
if (!this.#cacheEnabled)
{
return cb();
}
let value = this.#cache.get(cacheKey);
if (!value)
{
// if the key does not exist in cache, use the callback to get the actual data...
value = await cb();
// ...and then Set the key:value in the cache
await this.#cache.set(cacheKey, value);
}
return value;
}
catch (exp)
{
_log.exp(`Exception getting cached '${cacheKey}' key value in NODE cache.`, MODULE_NAME, exp);
return cb(); // get the actual data from the data-specific callback function
}
}
/**
* @function _redisGet
* @memberof mcode.cache
* @desc Caches the results of a callback function in cache under the current namespace and returns the key's value.
* @param {string} cacheKey the key to the cache.
* @param {function} cb the callback function to get fresh value.
* @returns {Promise} the cached value.
*/
async _redisGet(cacheKey, cb)
{
try
{
// if the cache is not enabled, just get the data from the callback
if (!this.#redisEnabled)
{
return cb();
}
let value = this.#redis.get(cacheKey);
if (!value)
{
// if the key does not exist in cache, use the callback to get the actual data...
value = await cb();
// ...and then Set the key:value in the cache
await this.#redis.set(cacheKey, value);
}
return value;
}
catch (exp)
{
_log.exp(`Exception getting cached '${cacheKey}' key value in REDIS cache.`, MODULE_NAME, exp);
return cb(); // get the actual data from the data-specific callback function
}
}
/**
* @function _cacheSet
* @memberof mcode.cache
* @desc Sets a key value in the cache.
* @param {string} cacheKey the cache key to be set.
* @param {string} value the value to be set in the cache.
*/
async _cacheSet(cacheKey, value)
{
try
{
// if the cache is not enabled, just return
if (!this.#cacheEnabled)
{
return;
}
await this.#cache.set(cacheKey, value);
}
catch (exp)
{
_log.exp(`Exception setting ${cacheKey} value in NODE cache.`, MODULE_NAME, exp);
}
}
/**
* @function _redisSet
* @memberof mcode.cache
* @desc Sets a key value in the cache.
* @param {string} cacheKey the cache key to be set.
* @param {string} value the value to be set in the cache.
*/
async _redisSet(cacheKey, value)
{
try
{
// if the cache is not enabled, just return
if (!this.#redisEnabled)
{
return;
}
await this.#redis.set(cacheKey, value);
}
catch (exp)
{
_log.exp(`Exception setting ${cacheKey} value in REDIS cache.`, MODULE_NAME, exp);
}
}
/**
* @func _cacheDrop
* @memberof mcode.cache
* @desc Drops a key value from the cache based on the 'key' name.
* @param {string} cacheKey the cache key to be droppped.
* @returns {number} the number of keys deleted from the cache.
* @api public
* @example
* const count = await mcode.cacheDrop(keyName);
*/
async _cacheDrop(cacheKey)
{
return await this.#cache.del(cacheKey);
}
/**
* @func _redisDrop
* @memberof mcode.cache
* @desc Drops a key value from the cache based on the 'key' name.
* @param {string} cacheKey the cache key to be droppped.
* @returns {number} the number of keys deleted from the cache.
* @api public
* @example
* const count = await mcode.cacheDrop(keyName);
*/
async _redisDrop(cacheKey)
{
return await this.#redis.del(cacheKey);
}
/**
* @func _cacheKeys
* @memberof mcode.cache
* @desc returns a filtered list of keys in the cache,
* NOTE: keys() in node-cache does not support wildcards.
* @param {string} pattern the key pattern to list all keys from.
* @returns {number} the number of keys deleted from the cache.
* @api public
* @example
* const count = await mcode.cacheDrop(keyName);
*/
async _cacheKeys(pattern)
{
const allKeys = this.#cache.keys(); // Get all keys from node-cache
const regexPattern = this._convertGlobToRegExp(pattern); // Convert glob pattern to RegExp
return allKeys.filter(key => regexPattern.test(key)); // Filter keys based on the Regex
}
// Convert Redis glob pattern to RegExp
_convertGlobToRegExp(globPattern)
{
const escapedPattern = globPattern
.replace(/\*/g, '.*') // Replace * with .* (matches any characters)
.replace(/\?/g, '.') // Replace ? with . (matches any single character)
.replace(/\[/g, '\\[') // Escape [
.replace(/\]/g, '\\]'); // Escape ]
return new RegExp(`^${escapedPattern}$`); // Create a RegExp from the glob pattern
}
// #endregion
}
// #endregion
// #region E X P O R T S
// Export the Singleton instance
const instance = new cache();
Object.freeze(instance);
// Automatically export all public methods and properties...
// Export all the Public METHODs (excluding the constructor)
Object.getOwnPropertyNames(Object.getPrototypeOf(instance)).forEach((method) =>
{
if (method !== 'constructor' && typeof instance[method] === 'function' && !method.startsWith('_'))
{
// Bind the method to the instance if it is a function and does not start with '_' (Private)
module.exports[method] = instance[method].bind(instance);
}
});
// Export all the Public PROPERTYs (get/set)
const descriptors = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(instance));
for (const [key, descriptor] of Object.entries(descriptors))
{
if (!key.startsWith('_') && (descriptor.get || descriptor.set))
{
Object.defineProperty(module.exports, key, {
get: descriptor.get ? descriptor.get.bind(instance) : undefined,
set: descriptor.set ? descriptor.set.bind(instance) : undefined,
enumerable: true, // Ensure the property is enumerable
configurable: true,
});
}
}
// #endregion
// #endregion