@ghini/kit
Version:
js practical tools to assist efficient development
1,248 lines (1,134 loc) • 35.3 kB
JavaScript
export {
// path路径相关
exefile,
exedir,
exeroot,
metaroot,
xpath,
fileurl2path,
// 时间相关
stamps,
date,
now,
sleep,
interval,
timelog,
ttl,
TTLMap,
// fs path相关 同步
rf,
wf,
mkdir,
isdir,
isfile,
dir,
exist,
rm,
cp,
env,
// 异步版本
exe,
arf,
awf,
amkdir,
aisdir,
aisfile,
adir,
aexist,
arm,
aonedir,
astat,
aloadyml,
aloadjson,
//
cookie_obj,
cookie_str,
cookie_merge,
cookies_obj,
cookies_str,
cookies_merge,
mreplace,
mreplace_calc,
xreq,
ast_jsbuild,
gcatch,
};
import { createRequire } from "module";
import { parse } from "acorn";
import fs from "fs";
import { dirname, resolve, join, normalize, isAbsolute, sep } from "path";
import yaml from "yaml";
import { exec } from "child_process";
const platform = process.platform; //win32|linux|darwin
const slice_len_file = platform == "win32" ? 8 : 7;
const exefile =
process.env.KIT_EXEPATH || process.env.KIT_EXEFILE || process.argv[1]; //执行文件的路径,如果使用如pm2等工具需要设置,补偿process.argv[1]的修改
const exedir = dirname(exefile);
const exeroot = findPackageJsonDir(exefile);
/**
* 当前库的rootpath
* @returns {string} 返回当前文件所处最近nodejs项目的绝对路径
*/
const metaroot = findPackageJsonDir(import.meta.dirname);
let globalCatchError = false;
/*XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX*/
/** 根据日期获取秒时间戳,不传参数则获取当前秒时间戳 */
function stamps(date) {
return Math.floor((Date.parse(date) || Date.now()) / 1000);
}
/** 获取秒时间戳 */
function now() {
return Math.floor(Date.now() / 1000);
}
/** 执行命令行指令 默认打印日志,返回输出 */
function exe(command, log = true) {
return new Promise((resolve) => {
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(error);
return resolve(0);
}
if (stderr) {
console.warn("Warning:", stderr);
}
if (log) console.log(stdout);
resolve(stdout);
});
});
}
/**
* gcatch 捕获全局异常
* @param {boolean} open 是否开启
*/
function gcatch(open = true) {
if (open) {
// 避免重复监听
if (!globalCatchError) {
console.dev("use gcatch");
globalCatchError = true;
// 捕获异步的未处理错误
process.on("unhandledRejection", fn0);
// 捕获同步的未处理错误
process.on("uncaughtException", fn1);
}
} else {
globalCatchError = false;
process.off("unhandledRejection", fn0);
process.off("uncaughtException", fn1);
}
function fn0(reason, promise) {
// console.error("Unhandled Rejection at:");
console.error("gcatch异步中未捕获错误:", promise, "reason:", reason);
}
function fn1(err) {
// console.error("Uncaught Exception:");
console.error("gcatch主线程未捕获错误:", err);
}
}
/*
Date.now() msstamp
*/
// 生成各时区时间,默认北京时间
function date(timestamp, offset = 8) {
if (timestamp) {
timestamp = timestamp.toString();
if (timestamp.length < 12) timestamp = timestamp * 1000;
else timestamp = timestamp * 1;
} else timestamp = Date.now();
return new Date(timestamp + offset * 3600000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
}
/**
* Load and parse YAML file
* @param {string} filePath - Absolute or relative path to YAML file
* @returns {Promise<any>} Parsed YAML content
*/
async function aloadyml(filePath) {
try {
// Convert to absolute path if relative
const absolutePath = isAbsolute(filePath)
? filePath
: resolve(process.cwd(), filePath);
// Read and parse YAML file
const content = await fs.promises.readFile(absolutePath, "utf8");
return yaml.parse(content);
} catch (error) {
console.error(error.message);
}
}
/**
* 解析 ENV 内容
* @param {string} content - ENV 文件内容
* @returns {object} 解析后的对象
*/
function parseENV(content) {
const result = {};
const lines = content?.split("\n") || [];
for (let line of lines) {
// 跳过空行、注释和导出语句
if (
!line.trim() ||
line.trim().startsWith("#") ||
line.trim().startsWith("export")
) {
continue;
}
// 查找第一个等号的位置
const separatorIndex = line.indexOf("=");
if (separatorIndex !== -1) {
const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim();
// 移除值两端的引号
value = value.replace(/^["'](.*)["']$/, "$1");
result[key] = value;
}
}
return result;
}
/**
* 加载ENV 默认读取根目录下的.env文件,系统环境变量优先,可直接从返回取用
* 写相对路径则从当前运行文件
* @param {string} filePath - ENV 文件的绝对或相对路径
* @returns {Promise<object>} 解析后的对象
*/
function env(filePath, cover = false) {
try {
if (filePath) filePath = xpath(filePath);
else {
filePath = join(exeroot, ".env");
if (!isfile(filePath)) {
filePath = join(exefile, ".env");
if (!isfile(filePath)) return null;
}
}
const content = parseENV(rf(filePath));
if (cover) process.env = { ...process.env, ...content };
else process.env = { ...content, ...process.env };
return content||{};
} catch (error) {
console.error(error);
}
}
function findPackageJsonDir(currentPath) {
if (isdir(currentPath)) {
if (isfile(join(currentPath, "package.json"))) return currentPath;
} else {
currentPath = dirname(currentPath);
if (isfile(join(currentPath, "package.json"))) return currentPath;
}
while (currentPath !== dirname(currentPath)) {
currentPath = dirname(currentPath);
if (isfile(join(currentPath, "package.json"))) return currentPath;
}
return null;
}
async function aloadjson(filePath) {
try {
// 获取绝对路径
const absolutePath = xpath(filePath);
const content = await arf(absolutePath);
// 预处理内容以移除注释
const processedContent = content
// 移除多行注释 /* ... */
.replace(/\/\*[\s\S]*?\*\//g, "")
// 移除单行注释 // ...
.replace(/\/\/.*/g, "")
// 移除行尾的逗号
.replace(/,(\s*[}\]])/g, "$1")
// 处理多余的换行和空格
.replace(/^\s+|\s+$/gm, "");
// 尝试解析JSON
try {
return JSON.parse(processedContent);
} catch (parseError) {
// 如果解析失败,尝试进行更严格的处理
const strictContent = processedContent
// 确保属性名使用双引号
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":')
// 处理未转义的换行符
.replace(/\n/g, "\\n")
// 处理未转义的制表符
.replace(/\t/g, "\\t");
return JSON.parse(strictContent);
}
} catch (error) {
console.error(error.message);
}
}
async function astat(path) {
return await fs.promises.stat(path);
}
async function aonedir(dir) {
// 检查文件夹是否含有内容,返回:第一个|null|路径不存在undefined
try {
const dirHandle = await fs.promises.opendir(dir);
const firstEntry = await dirHandle.read();
dirHandle.close();
return firstEntry ? firstEntry.name : null;
} catch {
return undefined;
}
}
async function arf(filename, option = "utf8") {
try {
const data = await fs.promises.readFile(xpath(filename), option);
return data;
} catch (error) {
if (error.code === "ENOENT") {
// console.error(filename + "文件不存在");
return;
}
}
}
async function awf(filename, data, append = false, option = "utf8") {
try {
await amkdir(dirname(filename));
const writeOption = append ? { encoding: option, flag: "a" } : option;
await fs.promises.writeFile(filename, data, writeOption);
return true;
} catch (error) {
console.error("写入" + filename + "文件失败:", error);
}
}
async function amkdir(dir) {
try {
return await fs.promises.mkdir(dir, { recursive: true });
} catch (err) {
console.error(err.message);
}
}
async function aisdir(path) {
try {
const stats = await fs.promises.lstat(path);
return stats.isDirectory();
} catch (err) {
console.error.bind({ info: -1 })(err.message);
return;
}
}
async function aisfile(path) {
try {
const stats = await fs.promises.lstat(path);
return stats.isFile();
} catch (err) {
// console.error(err.message);
return;
}
}
async function adir(path) {
try {
return await fs.promises.readdir(path);
} catch (err) {
console.error(err.message);
return;
}
}
async function aexist(path) {
try {
await fs.promises.access(path);
return true;
} catch {
return false;
}
}
async function arm(targetPath, confirm = false) {
try {
if (confirm) await prompt(`确认删除? ${targetPath} `);
await fs.promises.stat(targetPath);
// await fs.promises.rm(targetPath);
await fs.promises.rm(targetPath, { recursive: true });
return true;
} catch (err) {
// console.error(`删除失败: ${err.message.replace(/[\r\n]+/, "")}`);
return;
}
}
/**
* 纳秒计时,由于封装函数调用计时,有10微秒|10000n往上的波动开销,跟使用console.time相当
* 需要精确还是直接用process.hrtime.bigint() (误差1微妙|1000n)
* @param {*} fn
*/
async function timelog(fn) {
const start = performance.now();
await fn();
console.log(performance.now() - start + "ms");
// console.log(`${dur / 1000000n}ms`);
}
/**
* 使当前执行暂停指定的毫秒数。
*
* @param {number} ms - 暂停的时间,以毫秒为单位。
* @returns {Promise<void>} 一个在指定时间后解决的 Promise 对象,使用await暂停,线程阻塞。
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 按照指定的时间间隔重复执行函数,并在达到指定的总持续时间后停止。
*
* @param {Function} fn - 要执行的回调函数。
* @param {number} ms - 两次函数执行之间的时间间隔,以毫秒为单位。
* @param {number} [PX] - 可选参数,总持续时间,以毫秒为单位。超过此时间后将停止执行。因函数有运行时间,通常运行次数是PX/ms-1向上取整
*/
async function interval(fn, ms, PX) {
const start = Date.now();
let id = setInterval(() => {
if (PX && Date.now() - start > PX) {
clearInterval(id);
} else fn();
}, ms);
}
/**
* 将file:///形式的url转换为绝对路径,初始开发场景为解决stack中的file:///格式
* @param {string} url
*/
function fileurl2path(url) {
// 从file://开始,去掉末尾行号,最后根据系统去掉开头长度转化为path
return (url = url
.slice(url.indexOf("file:///"))
.replace(/\:\d.*$/, "")
.slice(slice_len_file));
}
/**
* 强大可靠的路径处理
* 使用此函数的最大好处是安全省心!符合逻辑,不用处理尾巴带不带/,../裁切,不能灵活拼接等;用了就不怕格式错误,要错都路径问题,且最后都输出绝对路径方便检验
* @param {string} inputPath - 目标路径(最终指向,可以是相对路径或绝对路径)
* @param {string} [basePath=exedir] - 辅助路径,默认为exedir(可以是相对路径或绝对路径)
* @returns {string} 绝对路径在前,相对路径在后,最终都转换为绝对路径统一sep,方便比较路径
*/
function xpath(targetPath, basePath, separator = "/") {
// 判断basePath是否存在,是否文件?处理为目录:继续
try {
if (basePath) {
if (basePath.startsWith("file:///"))
basePath = basePath.slice(slice_len_file);
else if (!isAbsolute(basePath)) {
// 如果存在且是个文件,dirname处理
if (fs.existsSync(basePath) && fs.statSync(basePath).isFile()) {
basePath = dirname(basePath);
}
basePath = join(exedir, basePath);
}
} else {
basePath = exedir;
}
let resPath;
// 判断targetPath是否为绝对路径,是就直接使用
if (targetPath.startsWith("file:///"))
resPath = normalize(targetPath.slice(slice_len_file));
else if (isAbsolute(targetPath)) {
resPath = normalize(targetPath);
} else {
resPath = join(basePath, targetPath);
}
if (separator === "/") {
if (slice_len_file === 7) return resPath;
else return resPath.split(sep).join("/");
}
if (separator === "\\") {
if (slice_len_file === 8) return resPath;
else return resPath.split(sep).join("\\");
}
return resPath.split(sep).join(separator);
} catch (error) {
console.error(error);
}
}
/**
* 递归复制文件或目录
* @param {string} oldPath - 源路径
* @param {string} newPath - 目标路径
* @throws {Error} 当路径不存在或复制过程出错时抛出异常
*/
function cp(oldPath, newPath) {
try {
// 获取源文件/目录的状态
const stats = fs.statSync(oldPath);
if (stats.isDirectory()) {
// 处理目录复制
fs.mkdirSync(newPath, { recursive: true });
// 读取并遍历目录内容
const entries = fs.readdirSync(oldPath);
for (const entry of entries) {
// 构建源和目标的完整路径
const srcPath = join(oldPath, entry);
const destPath = join(newPath, entry);
// 递归复制子项
cp(srcPath, destPath);
}
} else if (stats.isFile()) {
// 处理文件复制
// 确保目标文件的父目录存在
const targetDir = dirname(newPath);
fs.mkdirSync(targetDir, { recursive: true });
// 执行文件复制
fs.copyFileSync(oldPath, newPath);
} else {
throw new Error(`不支持的文件类型: ${oldPath}`);
}
} catch (error) {
throw new Error(`复制失败 "${oldPath}" -> "${newPath}": ${error.message}`);
}
}
/**
* 删除指定路径的文件或文件夹(同步方法)。
* @param {string} targetPath - 要删除的文件或文件夹路径,支持相对路径或绝对路径。
* @returns {undefined} - 无返回值。如果删除失败,会打印错误信息。
*/
function rm(targetPath) {
try {
const stats = fs.statSync(targetPath);
fs.rmSync(targetPath, { recursive: true });
return true;
// if (stats.isFile()) {
// // 如果是文件,删除文件
// fs.unlinkSync(targetPath);
// } else if (stats.isDirectory()) {
// // 如果是文件夹,递归删除文件夹内容
// fs.rmSync(targetPath, { recursive: true });
// }
} catch (err) {
// console.error(`删除失败: ${err.message.replace(/[\r\n]+/, "")}`);
return;
}
}
/**
* 检查指定路径是否存在(同步方法)。
* @param {string} path - 要检查的路径,支持文件或目录路径。
* @returns {boolean} - 如果路径存在返回 `true`,否则返回 `false`。发生错误时打印错误信息。
*/
function exist(path) {
try {
return fs.existsSync(path);
} catch (err) {
console.error(err.message);
}
}
/**
* 读取目录内容(同步方法)。
* @param {string} path - 要读取的目录路径。
* @returns {string[]|undefined} - 返回目录中的文件和子目录名称数组。如果路径不是目录或发生错误,打印错误信息并返回 `undefined`。
*/
function dir(path) {
try {
return fs.readdirSync(path);
} catch (err) {
// console.error(err.message);
return;
}
}
/**
* 判断路径是否为文件(同步方法)。
* @param {string} path - 要判断的路径。
* @returns {boolean|undefined} - 如果路径是文件返回 `true`,否则返回 `false`。发生错误时打印错误信息并返回 `undefined`。
*/
function isfile(path) {
try {
return fs.lstatSync(path).isFile();
} catch (err) {
// console.error(err.message);
return;
}
}
/**
* 判断路径是否为目录(同步方法)。
* @param {string} path - 要判断的路径。
* @returns {boolean|undefined} - 如果路径是目录返回 `true`,否则返回 `false`。发生错误时打印错误信息并返回 `undefined`。
*/
function isdir(path) {
try {
return fs.lstatSync(path).isDirectory();
} catch (err) {
// console.error(err.message);
return;
}
}
/**
* 递归创建目录,如果路径中有不存在自动创建
* @param {string} path
* @returns {undefined}
*/
function mkdir(dir) {
try {
return fs.mkdirSync(dir, { recursive: true });
// return fs.mkdirSync(dirname(dir), { recursive: true });
} catch (err) {
console.error(err.message);
}
}
/**
* 使用ast,删除非jsdoc注释,将代码变得紧凑
* @param {string} code - The JavaScript code to process.
* @returns {string} New code.
*/
function ast_jsbuild(code) {
let comments = [];
const ast = parse(code, {
ecmaVersion: "latest",
sourceType: "module",
onComment: comments,
});
let cursor = 0;
let newContent = "";
comments.forEach((item) => {
if (item.type == "Block" && item.value.match(/^\*\s/)) return; //放过jsdoc
newContent += code.slice(cursor, item.start);
cursor = item.end;
});
return (newContent + code.slice(cursor)).replace(/^\s*[\r\n]/gm, "");
}
/**
* @param {string} path
* @returns {object}
*/
function xreq(path) {
const require = createRequire(exefile);
return require(path);
}
/**
* 同步读取文件
* @param {string} filename - 文件路径
* @param {string} [option="utf8"] - 文件编码,默认为 "utf8"
* @returns {string|null} 文件内容或undefined(文件不存在)
*/
function rf(filename, option = "utf8") {
try {
const data = fs.readFileSync(xpath(filename), option);
return data;
} catch (error) {
if (error.code === "ENOENT") {
// console.error(filename + "文件不存在");
return;
}
}
}
/**
* 同步写入文件,默认覆写,append=true时为追加
* @param {string} filename - 文件路径
* @param {string|Buffer} data - 要写入的内容
* @param {boolean} [append=false] - 是否追加写入,默认为false
* @param {string} [option="utf8"] - 文件编码,默认为 "utf8"
* @returns {boolean} 是否写入成功
*/
function wf(filename, data, append = false, option = "utf8") {
try {
// fs.writeFileSync()
mkdir(dirname(filename));
append ? (option = { encoding: option, flag: "a" }) : 0;
fs.writeFileSync(filename, data, option);
// console.log(filename + "文件写入成功");
return true;
} catch (error) {
console.error("写入" + filename + "文件失败:", error);
}
}
/**
* 批量替换字符串中的内容。
* @param {string} str - 待替换的原字符串。
* @param {Array<[string|RegExp, string]>} replacements - 替换规则数组,每项包含两个元素:
* - `search`:要匹配的字符串或正则表达式。
* - `replacement`:替换的目标字符串,支持 `$1` 等引用捕获组。
* @returns {string} - 替换后的字符串。
*/
function mreplace(str, replacements) {
for (const [search, replacement] of replacements) {
str = str.replace(new RegExp(search), (...args) => {
// 第一个是完整匹配内容,中间捕获组,最后两个参数是 offset 和原字符串,为节省运算不做切割,原版有如下下切割
// const captures = args.slice(0, -2);
return replacement.replace(/(\$)?\$(\d+)/g, (...args_$) => {
// 保留$0可引用(原版无), $1 对应 captures[1], $2 对应 captures[2], 以此类推
// 另考虑$1可能有用,$$1基本无用,可用作转义保护真正要用的$1
if (args_$[1]) {
return args_$[1] + args_$[2];
} else {
return args[args_$[2]] || args_$[0];
}
});
});
}
return str;
}
/**
* 批量替换字符串,并统计替换次数和详细信息。
* @param {string} str - 待替换的原字符串。
* @param {Array<[string|RegExp, string]>} replacements - 替换规则数组,每项包含两个元素:
* - `search`:要匹配的字符串或正则表达式。
* - `replacement`:替换的目标字符串,支持 `$1` 等引用捕获组。
* @returns {[string, Array<[number, string|RegExp]>, Array<[number, string]>]} - 返回包含以下三部分:
* - 替换后的字符串。
* - 替换统计数组:每项为 `[匹配次数, 对应的 search]`。
* - 替换详情数组:每项为 `[替换位置, 替换前的内容]`。
*/
function mreplace_calc(str, replacements) {
const counts = [];
const detail = [];
counts.sum = 0;
let result = str;
for (const [search, replacement] of replacements) {
let count = 0;
result = result.replace(new RegExp(search), (...args) => {
count++;
detail.push([args.at(-2), args[0]]);
// 第一个是完整匹配内容,中间捕获组,最后两个参数是 offset 和原字符串,为节省运算不做切割,原版有如下下切割
// const captures = args.slice(0, -2);
return replacement.replace(/(\$)?\$(\d+)/g, (...args_$) => {
// 保留$0可引用(原版无), $1 对应 captures[1], $2 对应 captures[2], 以此类推
// 另考虑$1可能有用,$$1基本无用,可用作转义保护真正要用的$1
if (args_$[1]) {
//(\$)不为undefined,即有转义不做变量,返回拼接
return args_$[1] + args_$[2];
} else {
//正常作为变量,在范围内输出变量,否则输出原匹配即不变。
return args[args_$[2]] || args_$[0];
}
// mreplace_calc('这苹果的价格为¥7',[[/¥(\d+)/,'$$1/¥$1']])
// '这苹果的价格为¥7'.replace(/¥(\d+)/,'$$1/¥$1') 输出结果一致
});
});
counts.push([count, search]);
counts.sum += count;
}
// if (
// res[1][3][0] > 0 ||
// res[1][4][0] > 0 ||
// res[1][5][0] > 0 ||
// res[1][6][0] > 0
// ) {
// console.log(content.length, res[1], res[2], req.url);
// }
return [result, counts, detail];
}
function cookies_obj(str) {
// 如果输入为空字符串,返回空对象
if (!str) return {};
// 将 cookies 字符串转换为对象
return str.split("; ").reduce((obj, pair) => {
// 处理每一个键值对
const [key, value] = pair.split("=");
if (key && value) {
// 确保键和值都存在
obj[key] = value;
}
return obj;
}, {});
}
function cookies_str(obj) {
// 如果输入为空对象,返回空字符串
if (!obj || Object.keys(obj).length === 0) return "";
// 将对象转换回 cookies 字符串
return Object.entries(obj)
.filter(([key, value]) => key && value) // 确保键和值都存在
.map(([key, value]) => `${key}=${value}`)
.join("; ");
}
function cookies_merge(str1, str2) {
// 将两个字符串都转换为对象
const obj1 = cookies_obj(str1);
const obj2 = cookies_obj(str2);
// 合并对象,str2 的值会覆盖 str1 中的重复键
const merged = { ...obj1, ...obj2 };
// 转换回字符串
return cookies_str(merged);
}
/**
* 解析一个 cookie 字符串并返回键值对对象。
* @param str 要解析的 cookie 字符串。
* @returns 表示 cookies 的对象, 如果没有 cookie 字符串,则返回一个空对象。
*/
function cookie_obj(str) {
// 定义所有可能的属性标志
const cookieFlags = [
"Max-Age",
"Path",
"Domain",
"SameSite",
"Secure",
"HttpOnly",
];
// 结果对象会包含两个部分
const result = {
value: {}, // 存储实际的数据
flags: {}, // 存储所有的属性标志
};
// 解析 cookie 字符串
str
.split(";")
.map((part) => part.trim())
.forEach((part) => {
// 处理不带等号的标志位
if (!part.includes("=")) {
result.flags[part] = true;
return;
}
// 处理带等号的部分
const [key, value] = part.split("=", 2).map((s) => s.trim());
// 判断是属性标志还是实际数据
if (cookieFlags.includes(key)) {
result.flags[key] = value;
} else {
result.value[key] = value;
}
});
return result;
}
function cookie_str(obj) {
const parts = [];
// 首先添加实际的数据值
for (const [key, value] of Object.entries(obj.value)) {
parts.push(`${key}=${value}`);
}
// 然后添加属性标志
for (const [key, value] of Object.entries(obj.flags)) {
if (value === true) {
parts.push(key);
} else {
parts.push(`${key}=${value}`);
}
}
return parts.join("; ");
}
function cookie_merge(str1, str2) {
const obj1 = cookie_obj(str1);
const obj2 = cookie_obj(str2);
// 合并结果,注意保持结构
const merged = {
value: { ...obj1.value, ...obj2.value }, // 合并数据值
flags: { ...obj1.flags, ...obj2.flags }, // 合并属性标志
};
return cookie_str(merged);
}
// Class ======================================================================
/**
* An optimized time-to-live (TTL) map implementation with efficient memory management.
* Provides automatic expiration of entries based on specified TTL values.
*/
class TTLMap {
/**
* Creates a new TTLMap instance
* @param {Object} options - Configuration options
* @param {number} [options.defaultTTL=Infinity] - Default TTL in milliseconds
* @param {number} [options.cleanupInterval=1000] - Minimum time between cleanup operations in milliseconds
*/
constructor(options = {}) {
// Main storage for values
this.storage = new Map();
// Map that stores expiry timestamps
this.expiryMap = new Map();
// Min-heap for tracking expirations (stores indices for fast lookup)
this.expiryHeap = [];
// Index map to quickly find items in the heap
this.heapIndices = new Map();
// Cleanup tracking
this.lastCleanup = Date.now();
// Configuration
this.cleanupInterval = options.cleanupInterval || 1000;
this.defaultTTL = options.defaultTTL || Infinity;
}
/**
* Sets a key-value pair with an optional TTL
* @param {any} key - The key
* @param {any} value - The value to store
* @param {number} [ttl] - The TTL in milliseconds (uses defaultTTL if not provided)
* @returns {TTLMap} - Returns this instance for chaining
*/
set(key, value, ttl) {
// Validate TTL
if (ttl !== undefined && (!Number.isFinite(ttl) || ttl < 0)) {
throw new Error('TTL must be a non-negative finite number');
}
const finalTTL = ttl === undefined ? this.defaultTTL : ttl;
const expiryTime = finalTTL === Infinity ? Infinity : Date.now() + finalTTL;
// Store the value and expiry time
this.storage.set(key, value);
this.expiryMap.set(key, expiryTime);
// Remove existing entry from heap if present
if (this.heapIndices.has(key)) {
this._removeFromHeap(key);
}
// Add to heap if it has a finite expiry time
if (expiryTime !== Infinity) {
const heapItem = { key, expiryTime };
this.expiryHeap.push(heapItem);
this.heapIndices.set(key, this.expiryHeap.length - 1);
this._siftUp(this.expiryHeap.length - 1);
}
// Clean up expired items if needed
this._lazyCleanup();
return this;
}
/**
* Updates the TTL for an existing key without changing its value
* @param {any} key - The key to update
* @param {number} ttl - The new TTL in milliseconds
* @returns {boolean} - True if the key was found and updated, false otherwise
*/
updateTTL(key, ttl) {
if (!this.storage.has(key)) {
return false;
}
// Validate TTL
if (!Number.isFinite(ttl) || ttl < 0) {
throw new Error('TTL must be a non-negative finite number');
}
const value = this.storage.get(key);
this.set(key, value, ttl);
return true;
}
/**
* Gets the remaining TTL for a key in milliseconds
* @param {any} key - The key to check
* @returns {number|undefined} - The remaining TTL or undefined if the key doesn't exist
*/
getTTL(key) {
const expiryTime = this.expiryMap.get(key);
if (expiryTime === undefined) {
return undefined;
}
if (expiryTime === Infinity) {
return Infinity;
}
const remaining = expiryTime - Date.now();
if (remaining <= 0) {
this.delete(key);
return undefined;
}
return remaining;
}
/**
* Gets a value by key, returning undefined if expired or not found
* @param {any} key - The key to retrieve
* @returns {any} - The stored value or undefined
*/
get(key) {
// Check if key exists
if (!this.storage.has(key)) {
return undefined;
}
// Check if expired
const expiryTime = this.expiryMap.get(key);
if (expiryTime !== Infinity && expiryTime <= Date.now()) {
this.delete(key);
return undefined;
}
return this.storage.get(key);
}
/**
* Checks if a key exists and is not expired
* @param {any} key - The key to check
* @returns {boolean} - True if the key exists and is not expired
*/
has(key) {
if (!this.storage.has(key)) {
return false;
}
const expiryTime = this.expiryMap.get(key);
if (expiryTime !== Infinity && expiryTime <= Date.now()) {
this.delete(key);
return false;
}
return true;
}
/**
* Gets all unexpired keys
* @returns {Array} - Array of keys
*/
keys() {
this._lazyCleanup();
return [...this.storage.keys()];
}
/**
* Gets all unexpired values
* @returns {Array} - Array of values
*/
values() {
this._lazyCleanup();
return [...this.storage.values()];
}
/**
* Gets all unexpired entries as [key, value] pairs
* @returns {Array} - Array of [key, value] pairs
*/
entries() {
this._lazyCleanup();
return [...this.storage.entries()];
}
/**
* Deletes a key and all its associated data
* @param {any} key - The key to delete
* @returns {boolean} - True if the key was found and deleted
*/
delete(key) {
const existed = this.storage.delete(key);
if (!existed) {
return false;
}
this.expiryMap.delete(key);
// Remove from heap if present
if (this.heapIndices.has(key)) {
this._removeFromHeap(key);
}
return true;
}
/**
* Clears all data from the TTLMap
*/
clear() {
this.storage.clear();
this.expiryMap.clear();
this.expiryHeap = [];
this.heapIndices.clear();
}
/**
* Gets the number of unexpired items
* @returns {number} - The count of unexpired items
*/
get size() {
this._lazyCleanup();
return this.storage.size;
}
/**
* Sift up operation for min-heap maintenance
* @private
* @param {number} index - The index to sift up from
*/
_siftUp(index) {
if (index === 0) return;
const element = this.expiryHeap[index];
while (index > 0) {
const parentIndex = Math.floor((index - 1) / 2);
const parent = this.expiryHeap[parentIndex];
if (element.expiryTime >= parent.expiryTime) {
break;
}
// Swap with parent
this.expiryHeap[index] = parent;
this.expiryHeap[parentIndex] = element;
// Update indices map
this.heapIndices.set(parent.key, index);
this.heapIndices.set(element.key, parentIndex);
// Move up
index = parentIndex;
}
}
/**
* Sift down operation for min-heap maintenance
* @private
* @param {number} index - The index to sift down from
*/
_siftDown(index) {
const heapLength = this.expiryHeap.length;
const element = this.expiryHeap[index];
const halfLength = Math.floor(heapLength / 2);
while (index < halfLength) {
let childIndex = 2 * index + 1;
let child = this.expiryHeap[childIndex];
// Find the smaller child
const rightChildIndex = childIndex + 1;
if (rightChildIndex < heapLength) {
const rightChild = this.expiryHeap[rightChildIndex];
if (rightChild.expiryTime < child.expiryTime) {
childIndex = rightChildIndex;
child = rightChild;
}
}
if (element.expiryTime <= child.expiryTime) {
break;
}
// Swap with child
this.expiryHeap[index] = child;
this.expiryHeap[childIndex] = element;
// Update indices map
this.heapIndices.set(child.key, index);
this.heapIndices.set(element.key, childIndex);
// Move down
index = childIndex;
}
}
/**
* Removes an item from the heap by key
* @private
* @param {any} key - The key to remove
*/
_removeFromHeap(key) {
const index = this.heapIndices.get(key);
if (index === undefined) return;
const lastIndex = this.expiryHeap.length - 1;
// If it's the last element, simple removal
if (index === lastIndex) {
this.expiryHeap.pop();
this.heapIndices.delete(key);
return;
}
// Replace with the last element and reheapify
const lastElement = this.expiryHeap.pop();
this.expiryHeap[index] = lastElement;
this.heapIndices.set(lastElement.key, index);
this.heapIndices.delete(key);
// Fix heap property
const parentIndex = index > 0 ? Math.floor((index - 1) / 2) : 0;
if (index > 0 && this.expiryHeap[index].expiryTime < this.expiryHeap[parentIndex].expiryTime) {
this._siftUp(index);
} else {
this._siftDown(index);
}
}
/**
* Performs lazy cleanup of expired items
* @private
*/
_lazyCleanup() {
const now = Date.now();
// Control cleanup frequency
if (now - this.lastCleanup < this.cleanupInterval) {
return;
}
// Clean up expired items from the top of the heap
while (this.expiryHeap.length > 0) {
const top = this.expiryHeap[0];
if (top.expiryTime > now) {
break;
}
// Remove expired item from all data structures
this.storage.delete(top.key);
this.expiryMap.delete(top.key);
// Remove from heap and update indices
const lastElement = this.expiryHeap.pop();
this.heapIndices.delete(top.key);
if (this.expiryHeap.length > 0 && top !== lastElement) {
this.expiryHeap[0] = lastElement;
this.heapIndices.set(lastElement.key, 0);
this._siftDown(0);
}
}
this.lastCleanup = now;
}
/**
* Manually triggers a cleanup of all expired items
* @returns {number} - Number of items removed
*/
cleanup() {
const sizeBefore = this.storage.size;
const now = Date.now();
// Clean up expired items from the top of the heap
while (this.expiryHeap.length > 0) {
const top = this.expiryHeap[0];
if (top.expiryTime > now) {
break;
}
// Remove expired item
this.delete(top.key);
}
this.lastCleanup = now;
return sizeBefore - this.storage.size;
}
/**
* Iterator implementation to allow for...of loops
*/
[Symbol.iterator]() {
this._lazyCleanup();
return this.storage.entries();
}
}
const ttl = new TTLMap();