@chriscdn/file-cache 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.gitattributes ADDED
@@ -0,0 +1 @@
1
+ *.pdf filter=lfs diff=lfs merge=lfs -text
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Christopher Meyer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @chriscdn/file-cache
2
+
3
+ `FileCache` is a node utility for caching generated and remote files. Common use cases include:
4
+
5
+ - **Caching files stored in Amazon S3**: Keep a local cache of frequently accessed files to avoid repeatedly fetching them.
6
+ - **Thumbnail caching**: Store generated thumbnails locally for quick access instead of regenerating them every time.
7
+
8
+ `FileCache` does not store state in memory, meaning your cache remains unaffected by application restarts. Cached files are automatically expired using a time-to-live (TTL) policy and the file's modified date. The file's modified date is updated each time the file is accessed.
9
+
10
+ ## Installing
11
+
12
+ Using npm:
13
+
14
+ ```bash
15
+ npm install @chriscdn/file-cache
16
+ ```
17
+
18
+ Using yarn:
19
+
20
+ ```bash
21
+ yarn add @chriscdn/file-cache
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ The general approach is this:
27
+
28
+ - **Create a callback** — Write an async function that takes a file path and your custom input (like a URL), then generates or downloads the file and saves it to the path.
29
+ - **Set up the cache** — Initialize a `FileCache` with your callback and some options (like where to store files and how long to keep them).
30
+ - **Get a file** — Call the cache with your input. It will:
31
+ - Hash the input to create a unique file name.
32
+ - If the file is already cached and still valid, return its path.
33
+ - If not, run your callback to create it, then cache and return the path.
34
+
35
+ Here's an example of an image cache:
36
+
37
+ ```ts
38
+ import { FileCache, type FileCacheOptions } from "@chriscdn/file-cache";
39
+
40
+ // not required, but helps with duration calculations
41
+ import { Duration } from "@chriscdn/duration";
42
+
43
+ const downloadAndConvertToJPG = async (url: string, filePath: string) => {
44
+ // ...
45
+ };
46
+
47
+ type MyCallbackArgs = {
48
+ url: string;
49
+ };
50
+
51
+ const options: FileCacheOptions<MyCallbackArgs> = {
52
+ // The path to the cache directory. The directory must exist. Do not store
53
+ // anything else in this directory.
54
+ cachePath: "/some/path/on/your/filesystem/",
55
+
56
+ // An asynchronous callback function, which is executed if no matching cached
57
+ // file is found. It is the responsibility of this function to use `context`
58
+ // to write a file to `filePath`.
59
+ cb: async (filePath: string, context: MyCallbackArgs) => {
60
+ const { url } = context;
61
+ await downloadAndConvertToJPG(url, filePath);
62
+ },
63
+
64
+ // Specifies the file extension for each cached file.
65
+ // Currently, all files share the same extension (this may change in the future).
66
+ ext: ".jpg",
67
+
68
+ // Determines the time-to-live (TTL) of a cached file, in milliseconds,
69
+ // based on when it was last accessed.
70
+ ttl: Duration.toMilliseconds({ days: 7 }),
71
+
72
+ // How often the cleanup task should run to purge expired cached files, in milliseconds.
73
+ cleanupInterval: Duration.toMilliseconds({ hours: 4 }),
74
+ };
75
+
76
+ // create an instance
77
+ const imageCache = new FileCache(options);
78
+
79
+ const imageFilePath = await imageCache({
80
+ url: "https://example.com/some/file/path.jpg",
81
+ });
82
+ ```
83
+
84
+ ## Cleanup
85
+
86
+ `FileCache` instances are intended to be instantiated as singletons, persisting throughout the lifecycle of your app. If you need to deallocate an instance, be sure to call the `destroy()` method to prevent a memory leak.
87
+
88
+ ## Tests
89
+
90
+ Run the tests using:
91
+
92
+ ```bash
93
+ yarn test
94
+ ```
95
+
96
+ ## License
97
+
98
+ [MIT](LICENSE)
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FileCache } from "../src";
3
+ import { Duration } from "@chriscdn/duration";
4
+ import { promises as fs } from "fs";
5
+ import temp from "temp";
6
+ import { pathExists } from "path-exists";
7
+
8
+ const pause = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
+
10
+ const testPDFFile = "./__tests__/pdfs/lorem.pdf";
11
+
12
+ describe("Cache Expiration", async () => {
13
+ const cache = new FileCache({
14
+ cachePath: await temp.mkdir("file-cache-test"),
15
+ cb: async (filePath, { a }: { a: number }) => {
16
+ await fs.copyFile(
17
+ testPDFFile,
18
+ filePath,
19
+ );
20
+ },
21
+ ext: "pdf",
22
+ ttl: Duration.toMilliseconds({ seconds: 1 }),
23
+ cleanupInterval: Duration.toMilliseconds({ seconds: 2 }),
24
+ });
25
+
26
+ it("sync", async () => {
27
+ //
28
+ const filePath3 = await cache.getFile({ a: 3 });
29
+
30
+ // test if filePath exists
31
+ expect(await pathExists(filePath3)).toBe(true);
32
+
33
+ // wait a second
34
+ await pause(Duration.toMilliseconds({ seconds: 1 }));
35
+
36
+ // should still exist
37
+ expect(await pathExists(filePath3)).toBe(true);
38
+
39
+ // wait 2 seconds
40
+ await pause(Duration.toMilliseconds({ seconds: 2 }));
41
+
42
+ // should not longer exist due to cleanup
43
+ expect(await pathExists(filePath3)).toBe(false);
44
+ });
45
+ });
Binary file
package/esbuild.mjs ADDED
@@ -0,0 +1,19 @@
1
+ import { build } from "esbuild";
2
+
3
+ import pkg from "./package.json" with { type: "json" };
4
+
5
+ const external = [
6
+ ...Object.keys(pkg.dependencies || {}),
7
+ ...Object.keys(pkg.devDependencies || {}),
8
+ ];
9
+
10
+ build({
11
+ entryPoints: ["./src/index.ts"],
12
+ bundle: true,
13
+ platform: "node",
14
+ format: "esm", // or 'esm' if you prefer
15
+ outfile: "./lib/index.js",
16
+ external,
17
+ minify: false,
18
+ sourcemap: true,
19
+ }).catch(() => process.exit(1));
package/lib/index.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ type DirectoryPath = string;
2
+ type FilePath = string;
3
+ type Milliseconds = number;
4
+ export type FileCacheOptions<T extends Record<string, any>> = {
5
+ cachePath: DirectoryPath;
6
+ cb: (filePath: FilePath, context: T, cache: FileCache<T>) => Promise<void>;
7
+ ext: string;
8
+ cleanupInterval?: Milliseconds;
9
+ ttl: Milliseconds;
10
+ resolveFileName?: (args: T) => FilePath;
11
+ };
12
+ declare class FileCache<T extends Record<string, any>> {
13
+ private _cachePath;
14
+ private _resolveFileName;
15
+ private _cb;
16
+ private _ext;
17
+ private _ttl;
18
+ private _cleanupInterval;
19
+ private _intervalId;
20
+ private _semaphore;
21
+ private _cleanupSemaphore;
22
+ constructor({ cachePath, resolveFileName, cb, ext, ttl, cleanupInterval }: FileCacheOptions<T>);
23
+ /**
24
+ * Runs a cleanup pass to remove files older than 1 day from the cache.
25
+ * This is called periodically, but can be triggered manually.
26
+ */
27
+ cleanup(): Promise<void>;
28
+ /**
29
+ * Retrieves a cached file for the given arguments.
30
+ * If the file doesn't exist, it is generated by the callback and stored.
31
+ *
32
+ * Concurrency-safe, ensuring no conflicts when multiple requests are made
33
+ * for the same file.
34
+ *
35
+ * @param args The arguments used to determine the file's identity and generation
36
+ * @returns The path to the cached or newly created file
37
+ */
38
+ getFile(args: T): Promise<FilePath>;
39
+ has(args: T): Promise<boolean>;
40
+ /**
41
+ * Deletes a file from the cache if it exists.
42
+ *
43
+ * @param args The arguments used to determine the file's identity
44
+ * @returns True if the file was deleted, false if it didn't exist
45
+ */
46
+ expire(args: T): Promise<boolean>;
47
+ /**
48
+ * Stops the background cleanup process, but does not delete any cached files.
49
+ */
50
+ destroy(): void;
51
+ /**
52
+ * Resolves the filename for the cache based on the provided arguments.
53
+ *
54
+ * Uses the provided `resolveFileName` function if available, or falls back to
55
+ * generating a hash from the arguments to create a unique file name.
56
+ */
57
+ private resolveFileName;
58
+ /**
59
+ * Resolves the full file path for the cached file based on the provided arguments.
60
+ *
61
+ * @param args The arguments used to resolve the file path
62
+ * @returns The full path to the cached file
63
+ */
64
+ resolveFilePath(args: T): FilePath;
65
+ resolveCreateFilePath(args: T): Promise<FilePath>;
66
+ }
67
+ export { FileCache, type FilePath };
package/lib/index.js ADDED
@@ -0,0 +1,152 @@
1
+ import path from "path";
2
+ import { promises as fs } from "fs";
3
+ import { pathExists } from "path-exists";
4
+ import sha1 from "sha1";
5
+ import Semaphore from "@chriscdn/promise-semaphore";
6
+ import touch from "touch";
7
+ import { findNuke } from "@chriscdn/find-nuke";
8
+ import { Duration } from "@chriscdn/duration";
9
+ import { rimraf } from "rimraf";
10
+ class FileCache {
11
+ _cachePath;
12
+ _resolveFileName;
13
+ _cb;
14
+ _ext;
15
+ _ttl;
16
+ _cleanupInterval;
17
+ _intervalId = null;
18
+ _semaphore = new Semaphore();
19
+ _cleanupSemaphore = new Semaphore();
20
+ constructor({ cachePath, resolveFileName, cb, ext, ttl, cleanupInterval }) {
21
+ this._cachePath = cachePath;
22
+ this._resolveFileName = resolveFileName;
23
+ this._cb = cb;
24
+ this._ext = ext;
25
+ this._ttl = ttl;
26
+ this._cleanupInterval = cleanupInterval ??
27
+ Duration.toMilliseconds({ days: 1 });
28
+ (async () => {
29
+ if (!(await pathExists(cachePath))) {
30
+ throw new Error("💥 FileCache error: cachePath does not exist.");
31
+ // process.exit(1); // or throw new Error() if you want to let it bubble
32
+ }
33
+ })();
34
+ // fire and forget
35
+ // fire and forget
36
+ this.cleanup();
37
+ this._intervalId = setInterval(this.cleanup.bind(this), this._cleanupInterval);
38
+ }
39
+ /**
40
+ * Runs a cleanup pass to remove files older than 1 day from the cache.
41
+ * This is called periodically, but can be triggered manually.
42
+ */
43
+ async cleanup() {
44
+ try {
45
+ await this._cleanupSemaphore.acquire();
46
+ await findNuke(this._cachePath, {
47
+ olderThan: this._ttl,
48
+ verbose: true,
49
+ deleteEmptyDirectories: true,
50
+ });
51
+ }
52
+ finally {
53
+ this._cleanupSemaphore.release();
54
+ }
55
+ }
56
+ /**
57
+ * Retrieves a cached file for the given arguments.
58
+ * If the file doesn't exist, it is generated by the callback and stored.
59
+ *
60
+ * Concurrency-safe, ensuring no conflicts when multiple requests are made
61
+ * for the same file.
62
+ *
63
+ * @param args The arguments used to determine the file's identity and generation
64
+ * @returns The path to the cached or newly created file
65
+ */
66
+ async getFile(args) {
67
+ const filePath = this.resolveFilePath(args);
68
+ try {
69
+ await Promise.all([
70
+ this._semaphore.acquire(filePath),
71
+ this._cleanupSemaphore.wait(),
72
+ ]);
73
+ if (await pathExists(filePath)) {
74
+ // fire and forget
75
+ touch(filePath);
76
+ }
77
+ else {
78
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
79
+ await this._cb(filePath, args, this);
80
+ }
81
+ }
82
+ finally {
83
+ this._semaphore.release(filePath);
84
+ }
85
+ return filePath;
86
+ }
87
+ async has(args) {
88
+ const filePath = this.resolveFilePath(args);
89
+ return await pathExists(filePath);
90
+ }
91
+ /**
92
+ * Deletes a file from the cache if it exists.
93
+ *
94
+ * @param args The arguments used to determine the file's identity
95
+ * @returns True if the file was deleted, false if it didn't exist
96
+ */
97
+ async expire(args) {
98
+ const filePath = this.resolveFilePath(args);
99
+ if (await pathExists(filePath)) {
100
+ await rimraf(filePath);
101
+ return true;
102
+ }
103
+ else {
104
+ return false;
105
+ }
106
+ }
107
+ /**
108
+ * Stops the background cleanup process, but does not delete any cached files.
109
+ */
110
+ destroy() {
111
+ if (this._intervalId) {
112
+ clearInterval(this._intervalId);
113
+ this._intervalId = null;
114
+ }
115
+ }
116
+ /**
117
+ * Resolves the filename for the cache based on the provided arguments.
118
+ *
119
+ * Uses the provided `resolveFileName` function if available, or falls back to
120
+ * generating a hash from the arguments to create a unique file name.
121
+ */
122
+ resolveFileName(args) {
123
+ const baseName = sha1(JSON.stringify(args, Object.keys(args).sort()));
124
+ return path.format({
125
+ // dir: path.dirname(filePath),
126
+ name: baseName,
127
+ ext: this._ext,
128
+ });
129
+ // return this._resolveFileName
130
+ // ? this._resolveFileName(args)
131
+ // : `${sha1(JSON.stringify(args, Object.keys(args).sort()))}${this._ext}`;
132
+ }
133
+ /**
134
+ * Resolves the full file path for the cached file based on the provided arguments.
135
+ *
136
+ * @param args The arguments used to resolve the file path
137
+ * @returns The full path to the cached file
138
+ */
139
+ resolveFilePath(args) {
140
+ const fileName = this.resolveFileName(args);
141
+ const filePath = path.resolve(this._cachePath, fileName[0], fileName[1], fileName[2],
142
+ // fileName[3],
143
+ fileName);
144
+ return filePath;
145
+ }
146
+ async resolveCreateFilePath(args) {
147
+ const filePath = this.resolveFilePath(args);
148
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
149
+ return filePath;
150
+ }
151
+ }
152
+ export { FileCache };
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
4
+ "sourcesContent": ["import path from \"path\";\nimport { promises as fs } from \"fs\";\nimport { pathExists } from \"path-exists\";\nimport sha1 from \"sha1\";\nimport Semaphore from \"@chriscdn/promise-semaphore\";\nimport touch from \"touch\";\nimport { findNuke } from \"@chriscdn/find-nuke\";\nimport { Duration } from \"@chriscdn/duration\";\nimport { rimraf } from \"rimraf\";\n\ntype DirectoryPath = string;\ntype FilePath = string;\ntype Milliseconds = number;\n\nexport type FileCacheOptions<T extends Record<string, any>> = {\n cachePath: DirectoryPath;\n cb: (filePath: FilePath, context: T, cache: FileCache<T>) => Promise<void>;\n ext: string;\n cleanupInterval?: Milliseconds;\n ttl: Milliseconds;\n resolveFileName?: (args: T) => FilePath;\n};\n\nclass FileCache<T extends Record<string, any>> {\n private _cachePath: FileCacheOptions<T>[\"cachePath\"];\n private _resolveFileName: FileCacheOptions<T>[\"resolveFileName\"];\n private _cb: FileCacheOptions<T>[\"cb\"];\n private _ext: FileCacheOptions<T>[\"ext\"];\n private _ttl: FileCacheOptions<T>[\"ttl\"];\n private _cleanupInterval: FileCacheOptions<T>[\"cleanupInterval\"];\n\n private _intervalId: ReturnType<typeof setInterval> | null = null;\n\n private _semaphore: Semaphore = new Semaphore();\n private _cleanupSemaphore: Semaphore = new Semaphore();\n\n constructor(\n { cachePath, resolveFileName, cb, ext, ttl, cleanupInterval }:\n FileCacheOptions<T>,\n ) {\n this._cachePath = cachePath;\n this._resolveFileName = resolveFileName;\n this._cb = cb;\n this._ext = ext;\n this._ttl = ttl;\n this._cleanupInterval = cleanupInterval ??\n Duration.toMilliseconds({ days: 1 });\n\n (async () => {\n if (!(await pathExists(cachePath))) {\n throw new Error(\"\uD83D\uDCA5 FileCache error: cachePath does not exist.\");\n // process.exit(1); // or throw new Error() if you want to let it bubble\n }\n })();\n\n // fire and forget\n // fire and forget\n this.cleanup();\n\n this._intervalId = setInterval(\n this.cleanup.bind(this),\n this._cleanupInterval,\n );\n }\n\n /**\n * Runs a cleanup pass to remove files older than 1 day from the cache.\n * This is called periodically, but can be triggered manually.\n */\n async cleanup() {\n try {\n await this._cleanupSemaphore.acquire();\n await findNuke(this._cachePath, {\n olderThan: this._ttl,\n verbose: true,\n deleteEmptyDirectories: true,\n });\n } finally {\n this._cleanupSemaphore.release();\n }\n }\n\n /**\n * Retrieves a cached file for the given arguments.\n * If the file doesn't exist, it is generated by the callback and stored.\n *\n * Concurrency-safe, ensuring no conflicts when multiple requests are made\n * for the same file.\n *\n * @param args The arguments used to determine the file's identity and generation\n * @returns The path to the cached or newly created file\n */\n async getFile(args: T): Promise<FilePath> {\n const filePath = this.resolveFilePath(args);\n\n try {\n await Promise.all([\n this._semaphore.acquire(filePath),\n this._cleanupSemaphore.wait(),\n ]);\n\n if (await pathExists(filePath)) {\n // fire and forget\n touch(filePath);\n } else {\n await fs.mkdir(path.dirname(filePath), { recursive: true });\n await this._cb(filePath, args, this);\n }\n } finally {\n this._semaphore.release(filePath);\n }\n\n return filePath;\n }\n\n async has(args: T): Promise<boolean> {\n const filePath = this.resolveFilePath(args);\n return await pathExists(filePath);\n }\n\n /**\n * Deletes a file from the cache if it exists.\n *\n * @param args The arguments used to determine the file's identity\n * @returns True if the file was deleted, false if it didn't exist\n */\n async expire(args: T): Promise<boolean> {\n const filePath = this.resolveFilePath(args);\n\n if (await pathExists(filePath)) {\n await rimraf(filePath);\n return true;\n } else {\n return false;\n }\n }\n\n /**\n * Stops the background cleanup process, but does not delete any cached files.\n */\n destroy() {\n if (this._intervalId) {\n clearInterval(this._intervalId);\n this._intervalId = null;\n }\n }\n\n /**\n * Resolves the filename for the cache based on the provided arguments.\n *\n * Uses the provided `resolveFileName` function if available, or falls back to\n * generating a hash from the arguments to create a unique file name.\n */\n private resolveFileName(args: T): string {\n const baseName = sha1(JSON.stringify(args, Object.keys(args).sort()));\n\n return path.format({\n // dir: path.dirname(filePath),\n name: baseName,\n ext: this._ext,\n });\n\n // return this._resolveFileName\n // ? this._resolveFileName(args)\n // : `${sha1(JSON.stringify(args, Object.keys(args).sort()))}${this._ext}`;\n }\n\n /**\n * Resolves the full file path for the cached file based on the provided arguments.\n *\n * @param args The arguments used to resolve the file path\n * @returns The full path to the cached file\n */\n resolveFilePath(args: T): FilePath {\n const fileName = this.resolveFileName(args);\n\n const filePath = path.resolve(\n this._cachePath,\n fileName[0],\n fileName[1],\n fileName[2],\n // fileName[3],\n fileName,\n );\n\n return filePath;\n }\n\n async resolveCreateFilePath(args: T): Promise<FilePath> {\n const filePath = this.resolveFilePath(args);\n\n await fs.mkdir(path.dirname(filePath), { recursive: true });\n\n return filePath;\n }\n}\n\nexport { FileCache, type FilePath };\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@chriscdn/file-cache",
3
+ "version": "0.0.1",
4
+ "description": "File cache ",
5
+ "repository": "https://github.com/chriscdn/file-cache",
6
+ "author": "Christopher Meyer <chris@schwiiz.org>",
7
+ "license": "MIT",
8
+ "type": "module",
9
+ "source": "./src/index.ts",
10
+ "module": "./lib/index.js",
11
+ "types": "./lib/index.d.ts",
12
+ "exports": {
13
+ "types": "./lib/index.d.ts",
14
+ "default": "./lib/index.js"
15
+ },
16
+ "scripts": {
17
+ "build": "rimraf ./lib/ && node ./esbuild.mjs && tsc --declaration",
18
+ "dev": "microbundle watch",
19
+ "test": "vitest"
20
+ },
21
+ "dependencies": {
22
+ "@chriscdn/duration": "^1.0.3",
23
+ "@chriscdn/find-nuke": "^0.0.4",
24
+ "@chriscdn/promise-semaphore": "^2.0.11",
25
+ "path-exists": "^5.0.0",
26
+ "rimraf": "^6.0.1",
27
+ "sha1": "^1.1.1",
28
+ "temp": "^0.9.4",
29
+ "touch": "^3.1.1"
30
+ },
31
+ "devDependencies": {
32
+ "@types/sha1": "^1.1.5",
33
+ "@types/touch": "^3.1.5",
34
+ "esbuild": "^0.25.3",
35
+ "typescript": "^5.8.3",
36
+ "vitest": "^3.1.2"
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,198 @@
1
+ import path from "path";
2
+ import { promises as fs } from "fs";
3
+ import { pathExists } from "path-exists";
4
+ import sha1 from "sha1";
5
+ import Semaphore from "@chriscdn/promise-semaphore";
6
+ import touch from "touch";
7
+ import { findNuke } from "@chriscdn/find-nuke";
8
+ import { Duration } from "@chriscdn/duration";
9
+ import { rimraf } from "rimraf";
10
+
11
+ type DirectoryPath = string;
12
+ type FilePath = string;
13
+ type Milliseconds = number;
14
+
15
+ export type FileCacheOptions<T extends Record<string, any>> = {
16
+ cachePath: DirectoryPath;
17
+ cb: (filePath: FilePath, context: T, cache: FileCache<T>) => Promise<void>;
18
+ ext: string;
19
+ cleanupInterval?: Milliseconds;
20
+ ttl: Milliseconds;
21
+ resolveFileName?: (args: T) => FilePath;
22
+ };
23
+
24
+ class FileCache<T extends Record<string, any>> {
25
+ private _cachePath: FileCacheOptions<T>["cachePath"];
26
+ private _resolveFileName: FileCacheOptions<T>["resolveFileName"];
27
+ private _cb: FileCacheOptions<T>["cb"];
28
+ private _ext: FileCacheOptions<T>["ext"];
29
+ private _ttl: FileCacheOptions<T>["ttl"];
30
+ private _cleanupInterval: FileCacheOptions<T>["cleanupInterval"];
31
+
32
+ private _intervalId: ReturnType<typeof setInterval> | null = null;
33
+
34
+ private _semaphore: Semaphore = new Semaphore();
35
+ private _cleanupSemaphore: Semaphore = new Semaphore();
36
+
37
+ constructor(
38
+ { cachePath, resolveFileName, cb, ext, ttl, cleanupInterval }:
39
+ FileCacheOptions<T>,
40
+ ) {
41
+ this._cachePath = cachePath;
42
+ this._resolveFileName = resolveFileName;
43
+ this._cb = cb;
44
+ this._ext = ext;
45
+ this._ttl = ttl;
46
+ this._cleanupInterval = cleanupInterval ??
47
+ Duration.toMilliseconds({ days: 1 });
48
+
49
+ (async () => {
50
+ if (!(await pathExists(cachePath))) {
51
+ throw new Error("💥 FileCache error: cachePath does not exist.");
52
+ // process.exit(1); // or throw new Error() if you want to let it bubble
53
+ }
54
+ })();
55
+
56
+ // fire and forget
57
+ // fire and forget
58
+ this.cleanup();
59
+
60
+ this._intervalId = setInterval(
61
+ this.cleanup.bind(this),
62
+ this._cleanupInterval,
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Runs a cleanup pass to remove files older than 1 day from the cache.
68
+ * This is called periodically, but can be triggered manually.
69
+ */
70
+ async cleanup() {
71
+ try {
72
+ await this._cleanupSemaphore.acquire();
73
+ await findNuke(this._cachePath, {
74
+ olderThan: this._ttl,
75
+ verbose: true,
76
+ deleteEmptyDirectories: true,
77
+ });
78
+ } finally {
79
+ this._cleanupSemaphore.release();
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Retrieves a cached file for the given arguments.
85
+ * If the file doesn't exist, it is generated by the callback and stored.
86
+ *
87
+ * Concurrency-safe, ensuring no conflicts when multiple requests are made
88
+ * for the same file.
89
+ *
90
+ * @param args The arguments used to determine the file's identity and generation
91
+ * @returns The path to the cached or newly created file
92
+ */
93
+ async getFile(args: T): Promise<FilePath> {
94
+ const filePath = this.resolveFilePath(args);
95
+
96
+ try {
97
+ await Promise.all([
98
+ this._semaphore.acquire(filePath),
99
+ this._cleanupSemaphore.wait(),
100
+ ]);
101
+
102
+ if (await pathExists(filePath)) {
103
+ // fire and forget
104
+ touch(filePath);
105
+ } else {
106
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
107
+ await this._cb(filePath, args, this);
108
+ }
109
+ } finally {
110
+ this._semaphore.release(filePath);
111
+ }
112
+
113
+ return filePath;
114
+ }
115
+
116
+ async has(args: T): Promise<boolean> {
117
+ const filePath = this.resolveFilePath(args);
118
+ return await pathExists(filePath);
119
+ }
120
+
121
+ /**
122
+ * Deletes a file from the cache if it exists.
123
+ *
124
+ * @param args The arguments used to determine the file's identity
125
+ * @returns True if the file was deleted, false if it didn't exist
126
+ */
127
+ async expire(args: T): Promise<boolean> {
128
+ const filePath = this.resolveFilePath(args);
129
+
130
+ if (await pathExists(filePath)) {
131
+ await rimraf(filePath);
132
+ return true;
133
+ } else {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Stops the background cleanup process, but does not delete any cached files.
140
+ */
141
+ destroy() {
142
+ if (this._intervalId) {
143
+ clearInterval(this._intervalId);
144
+ this._intervalId = null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Resolves the filename for the cache based on the provided arguments.
150
+ *
151
+ * Uses the provided `resolveFileName` function if available, or falls back to
152
+ * generating a hash from the arguments to create a unique file name.
153
+ */
154
+ private resolveFileName(args: T): string {
155
+ const baseName = sha1(JSON.stringify(args, Object.keys(args).sort()));
156
+
157
+ return path.format({
158
+ // dir: path.dirname(filePath),
159
+ name: baseName,
160
+ ext: this._ext,
161
+ });
162
+
163
+ // return this._resolveFileName
164
+ // ? this._resolveFileName(args)
165
+ // : `${sha1(JSON.stringify(args, Object.keys(args).sort()))}${this._ext}`;
166
+ }
167
+
168
+ /**
169
+ * Resolves the full file path for the cached file based on the provided arguments.
170
+ *
171
+ * @param args The arguments used to resolve the file path
172
+ * @returns The full path to the cached file
173
+ */
174
+ resolveFilePath(args: T): FilePath {
175
+ const fileName = this.resolveFileName(args);
176
+
177
+ const filePath = path.resolve(
178
+ this._cachePath,
179
+ fileName[0],
180
+ fileName[1],
181
+ fileName[2],
182
+ // fileName[3],
183
+ fileName,
184
+ );
185
+
186
+ return filePath;
187
+ }
188
+
189
+ async resolveCreateFilePath(args: T): Promise<FilePath> {
190
+ const filePath = this.resolveFilePath(args);
191
+
192
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
193
+
194
+ return filePath;
195
+ }
196
+ }
197
+
198
+ export { FileCache, type FilePath };
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "lib": ["ESNext"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "node",
7
+ "strict": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "outDir": "./lib",
10
+ "declaration": true, // Generate declaration files
11
+ "declarationDir": "./lib",
12
+ "skipLibCheck": true
13
+ },
14
+ "include": ["./src/**/*"]
15
+ }