memoize-fs
Version:
Node.js solution for memoizing/caching function results on the file system
303 lines (302 loc) • 9.37 kB
JavaScript
import * as path from "path";
import * as crypto from "crypto";
import * as fs from "fs/promises";
import { parse } from "meriyah";
const serializer = {
serialize,
deserialize
};
function serialize(val) {
function serializeRec(value, refs = /* @__PURE__ */ new WeakSet()) {
if (value && typeof value === "object" && refs.has(value)) {
return;
}
if (typeof value === "function") {
return;
}
if (typeof value === "object" && value !== null) {
if (refs.has(value))
return;
refs.add(value);
if (Array.isArray(value)) {
const arr = [];
for (let i = 0; i < value.length; i++) {
arr[i] = serializeRec(value[i], refs);
}
refs.delete(value);
return arr;
}
const obj = {};
for (const key in value) {
obj[key] = serializeRec(value[key], refs);
}
refs.delete(value);
return obj;
}
return value;
}
return JSON.stringify(val, function(_name, value) {
return serializeRec(value);
});
}
function deserialize(str) {
return JSON.parse(str).data;
}
function getCacheFilePath(fn, args, opt) {
const options = { ...serializer, ...opt };
const salt = options.salt || "";
let fnStr;
if (!options.noBody) {
fnStr = String(fn);
if (options.astBody) {
fnStr = parse(fnStr, { jsx: true, next: true });
}
fnStr = options.astBody ? JSON.stringify(fnStr) : fnStr;
}
const argsStr = options.serialize(args);
const hash = crypto.createHash("md5").update(fnStr + argsStr + salt).digest("hex");
return path.join(options.cachePath || "", options.cacheId || "", hash);
}
function checkOptions(allOptions) {
if (allOptions.salt && typeof allOptions.salt !== "string") {
throw new TypeError("salt option of type string expected, got: " + typeof allOptions.salt);
}
if (allOptions.cacheId && typeof allOptions.cacheId !== "string") {
throw new TypeError("cacheId option of type string expected, got: " + typeof allOptions.cacheId);
}
if (allOptions.maxAge && typeof allOptions.maxAge !== "number") {
throw new TypeError("maxAge option of type number bigger zero expected");
}
if (allOptions.serialize && typeof allOptions.serialize !== "function") {
throw new TypeError("serialize option of type function expected");
}
if (allOptions.deserialize && typeof allOptions.deserialize !== "function") {
throw new TypeError("deserialize option of type function expected");
}
if (allOptions.retryOnInvalidCache && typeof allOptions.retryOnInvalidCache !== "boolean") {
throw new TypeError("retryOnInvalidCache option of type boolean expected");
}
}
async function initCache(cachePath, cacheOptions) {
try {
await fs.mkdir(cachePath, { recursive: true });
} catch (err) {
if (err && err.code === "EEXIST" && (cacheOptions == null ? void 0 : cacheOptions.throwError) === false) {
return;
}
throw err;
}
}
async function writeResult(r, cb, optExt, filePath) {
let resultObj;
let resultString;
if (r && typeof r === "object" || typeof r === "string") {
resultObj = { data: r };
resultString = optExt.serialize(resultObj);
} else {
resultString = '{"data":' + r + "}";
}
try {
await fs.writeFile(filePath, resultString);
cb();
} catch (err) {
cb(err);
}
}
function parseResult(resultString, deserialize2) {
try {
return deserialize2(resultString);
} catch (e) {
return void 0;
}
}
function isPromise(something) {
return Boolean(something && typeof something.then === "function");
}
async function processFn(fn, args, allOptions, filePath, resolve, reject, cacheHitObj) {
let writtenResult;
let result;
try {
result = await fn.apply(null, args);
} catch (err) {
cacheHitObj.cacheHit = void 0;
reject(err);
return;
}
if (isPromise(result)) {
const resolved = await result;
await writeResult(resolved, function() {
writtenResult = resolved;
}, allOptions, filePath);
cacheHitObj.cacheHit = false;
resolve(writtenResult);
}
await writeResult(result, function(err) {
if (err) {
throw err;
}
writtenResult = result;
}, allOptions, filePath);
cacheHitObj.cacheHit = false;
resolve(writtenResult);
}
async function processFnAsync(fn, fnaCb, args, allOptions, filePath, resolve, reject, cacheHitObj) {
args.pop();
args.push(async function() {
const cbErr = arguments[0];
const cbArgs = Array.prototype.slice.call(arguments);
cbArgs.shift();
if (cbErr) {
cacheHitObj.cacheHit = void 0;
return resolve(cbErr);
}
cbArgs.unshift(null);
try {
await writeResult(cbArgs, function() {
cacheHitObj.cacheHit = false;
resolve(fnaCb.apply(null, cbArgs));
}, allOptions, filePath);
} catch (err) {
cacheHitObj.cacheHit = void 0;
reject(err);
}
});
fn.apply(null, args);
}
async function checkFileAgeAndRead(filePath, maxAge) {
let fileHandle;
try {
fileHandle = await fs.open(filePath, "r");
if (maxAge !== void 0) {
const stats = await fileHandle.stat();
const now = (/* @__PURE__ */ new Date()).getTime();
const fileAge = now - stats.mtimeMs;
if (fileAge > maxAge) {
return null;
}
}
const content = await fs.readFile(filePath, { encoding: "utf8" });
return content;
} catch (err) {
return null;
} finally {
if (fileHandle) {
await fileHandle.close();
}
}
}
function buildMemoizer(memoizerOptions) {
const cacheHitObj = {
cacheHit: void 0
};
const promiseCache = {};
if (!memoizerOptions || memoizerOptions && typeof memoizerOptions !== "object") {
throw new Error("options of type object expected");
}
if (typeof memoizerOptions.cachePath !== "string") {
throw new Error("option cachePath of type string expected");
}
memoizerOptions = { ...serializer, ...memoizerOptions };
checkOptions(memoizerOptions);
async function memoizeFn(fn, memoizeOptions) {
if (memoizeOptions && typeof memoizeOptions !== "object") {
throw new Error("opt of type object expected, got '" + typeof memoizeOptions + "'");
}
if (typeof fn !== "function") {
throw new Error("fn of type function expected");
}
const allOptions = {
cacheId: "./",
...memoizerOptions,
...memoizeOptions
};
checkOptions(allOptions);
await initCache(path.join(allOptions.cachePath, allOptions.cacheId), allOptions);
return function() {
const args = Array.prototype.slice.call(arguments);
const fnaCb = args.length ? args[args.length - 1] : void 0;
let isAsync = false;
if (typeof fnaCb === "function" && fnaCb.length > 0) {
isAsync = true;
}
const filePath = getCacheFilePathBound(fn, args, allOptions);
if (filePath in promiseCache) {
return promiseCache[filePath];
}
promiseCache[filePath] = new Promise(function(resolve, reject) {
async function cacheAndReturn() {
if (isAsync) {
await processFnAsync(fn, fnaCb, args, allOptions, filePath, resolve, reject, cacheHitObj);
return;
}
await processFn(fn, args, allOptions, filePath, resolve, reject, cacheHitObj);
}
checkFileAgeAndRead(filePath, allOptions.maxAge).then(function(data) {
if (data === null) {
return cacheAndReturn();
}
const parsedData = parseResult(data, allOptions.deserialize);
if (allOptions.retryOnInvalidCache && parsedData === void 0) {
return cacheAndReturn();
}
function retrieveAndReturn() {
cacheHitObj.cacheHit = true;
if (isAsync) {
resolve(fnaCb.apply(null, parsedData));
} else {
resolve(parsedData);
}
}
if (allOptions.force) {
allOptions.force = false;
cacheAndReturn();
} else {
retrieveAndReturn();
}
}).catch((err) => {
if (err.code === "ENOENT") {
return cacheAndReturn();
} else {
cacheHitObj.cacheHit = void 0;
reject(err);
}
});
});
promiseCache[filePath].finally(function() {
delete promiseCache[filePath];
}).catch(() => {
});
return promiseCache[filePath];
};
}
async function invalidateCache(cacheId) {
if (cacheId && typeof cacheId !== "string") {
throw new Error("cacheId option of type string expected, got '" + typeof cacheId + "'");
} else {
const cachPath = cacheId ? path.join(memoizerOptions.cachePath || "", cacheId) : memoizerOptions.cachePath || "";
await fs.rm(cachPath, { recursive: true });
}
}
function getCacheFilePathBound(fn, args, opt) {
return getCacheFilePath(fn, args, {
...memoizerOptions,
...opt,
cachePath: memoizerOptions.cachePath
});
}
return {
fn: async function(fn, opt) {
await initCache(memoizerOptions.cachePath || "");
return memoizeFn(fn, opt);
},
get cacheHit() {
return cacheHitObj.cacheHit;
},
getCacheFilePath: getCacheFilePathBound,
invalidate: invalidateCache
};
}
export {
buildMemoizer as default,
getCacheFilePath
};