@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 +1 -0
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/__tests__/file-cache.test.ts +45 -0
- package/__tests__/pdfs/lorem.pdf +0 -0
- package/esbuild.mjs +19 -0
- package/lib/index.d.ts +67 -0
- package/lib/index.js +152 -0
- package/lib/index.js.map +7 -0
- package/package.json +38 -0
- package/src/index.ts +198 -0
- package/tsconfig.json +15 -0
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 };
|
package/lib/index.js.map
ADDED
|
@@ -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
|
+
}
|