mastercache
Version:
Multi-tier cache module for Node.js. Redis, Upstash, CloudfareKV, File, in-memory and others drivers
238 lines (199 loc) • 5.93 kB
text/typescript
import { dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { access, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
// import { resolveTtl } from '../../helpers';
import { BaseDriver } from '../base-driver';
import type { CacheDriver, CreateDriverResult, FileConfig } from '../../types/main';
/**
* Caching driver for the filesystem
*
* - Each key is stored as a file in the filesystem.
* - Each namespace is a folder created in the parent namespace
* - Files are stored in the following format: [stringifiedValue, expireTimestamp]
* - If the expireTimestamp is -1, the value should never expire
*/
export class FileDriver extends BaseDriver implements CacheDriver {
type = 'l2' as const;
/**
* Root directory for storing the cache files
*/
#directory: string;
/**
* Worker thread that will clean up the expired files
*/
#cleanerWorker?: Worker;
declare config: FileConfig;
constructor(config: FileConfig, isNamespace: boolean = false) {
super(config);
this.#directory = this.#sanitizePath(join(config.directory, config.prefix || ''));
/**
* If this is a namespaced class, then we should not start the cleaner
* worker multiple times. Only the parent class will take care of it.
*/
if (isNamespace) return;
if (config.pruneInterval === false) return;
// this.#cleanerWorker = new Worker(new URL('./cleaner-worker'), {
// workerData: {
// directory: this.#directory,
// pruneInterval: resolveTtl(config.pruneInterval)
// },
// });
}
/**
* Since keys and namespace uses `:` as a separator, we need to
* purge them from the given path. We replace them with `/` to
* create a nested directory structure.
*/
#sanitizePath(path?: string) {
if (!path) return '';
return path.replaceAll(':', '/');
}
/**
* Converts the given key to a file path
*/
#keyToPath(key: string) {
const keyWithoutPrefix = key.replace(this.prefix, '');
/**
* Check if the key contains a relative path
*/
const re = /(\.\/|\.\.\/)/g;
if (re.test(key)) {
throw new Error(`Invalid key: ${keyWithoutPrefix}. Should not contain relative paths.`);
}
return join(this.#directory, this.#sanitizePath(keyWithoutPrefix));
}
/**
* Check if a file exists at a given path or not
*/
async pathExists(path: string) {
try {
await access(path);
return true;
} catch {
return false;
}
}
/**
* Output a file to the disk and create the directory recursively if
* it's missing
*/
async #outputFile(filename: string, content: string) {
const directory = dirname(filename);
const pathExists = await this.pathExists(directory);
if (!pathExists) {
await mkdir(directory, { recursive: true });
}
await writeFile(filename, content);
}
/**
* Returns a new instance of the driver namespaced
*/
namespace(namespace: string) {
return new FileDriver({ ...this.config, prefix: this.createNamespacePrefix(namespace) }, true);
}
/**
* Get a value from the cache
*/
async get(key: string) {
key = this.getItemKey(key);
const path = this.#keyToPath(key);
const pathExists = await this.pathExists(path);
if (!pathExists) {
return undefined;
}
const content = await readFile(path, { encoding: 'utf-8' });
const [value, expire] = JSON.parse(content);
if (expire !== -1 && expire < Date.now()) {
await this.delete(key);
return undefined;
}
return value as string;
}
/**
* Get the value of a key and delete it
*
* Returns the value if the key exists, undefined otherwise
*/
async pull(key: string) {
const value = await this.get(key);
if (!value) return undefined;
await this.delete(key);
return value;
}
/**
* Put a value in the cache
* Returns true if the value was set, false otherwise
*/
async set(key: string, value: string, ttl?: number) {
key = this.getItemKey(key);
await this.#outputFile(
this.#keyToPath(key),
JSON.stringify([value, ttl ? Date.now() + ttl : -1]),
);
return true;
}
/**
* Check if a key exists in the cache
*/
async has(key: string) {
key = this.getItemKey(key);
/**
* Check if the file exists
*/
const path = this.#keyToPath(key);
const pathExists = await this.pathExists(path);
if (!pathExists) return false;
/**
* Check if the file is expired
*/
const content = await readFile(path, { encoding: 'utf-8' });
const [, expire] = JSON.parse(content);
if (expire !== -1 && expire < Date.now()) {
await this.delete(key);
return false;
}
return true;
}
/**
* Remove all items from the cache
*/
async clear() {
const cacheExists = await this.pathExists(this.#directory);
if (!cacheExists) return;
/**
* By removing the directory and sub-directories, we are also
* removing the namespaces inside it
*/
await rm(this.#directory, { recursive: true });
}
/**
* Delete a key from the cache
* Returns true if the key was deleted, false otherwise
*/
async delete(key: string) {
key = this.getItemKey(key);
const path = this.#keyToPath(key);
const pathExists = await this.pathExists(path);
if (!pathExists) {
return false;
}
await rm(path);
return true;
}
/**
* Delete multiple keys from the cache
*/
async deleteMany(keys: string[]) {
await Promise.all(keys.map((key) => this.delete(key)));
return true;
}
async disconnect() {
await this.#cleanerWorker?.terminate();
}
}
/**
* Create a new file driver
*/
export function fileDriver(options: FileConfig): CreateDriverResult<FileDriver> {
return { options, factory: (config: FileConfig) => new FileDriver(config) };
}