├── .gitignore ├── .npmignore ├── .eslintrc.json ├── package.json ├── LICENSE ├── src └── file-cache.js ├── README.md └── test └── file-cache-test.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /dist 3 | /node_modules 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.sublime-* 2 | .github 3 | .DS_Store 4 | docs/ 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended" 4 | ], 5 | "env": { 6 | "es6": true, 7 | "node": true, 8 | "mocha": true 9 | }, 10 | "parserOptions": { 11 | "ecmaVersion": 2022, 12 | "sourceType": "module" 13 | } 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@uwdata/file-cache", 3 | "version": "0.0.1", 4 | "description": "File-based cache for JSON-serializable data.", 5 | "license": "BSD-3-Clause", 6 | "author": "Jeffrey Heer", 7 | "main": "src/file-cache.js", 8 | "module": "src/file-cache.js", 9 | "type": "module", 10 | "scripts": { 11 | "test": "mocha 'test/**/*-test.js'", 12 | "lint": "eslint '**/*.js'", 13 | "prepublishOnly": "npm run test & npm run lint" 14 | }, 15 | "devDependencies": { 16 | "eslint": "^8.23.1", 17 | "mocha": "^10.0.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, UW Interactive Data Lab 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /src/file-cache.js: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | import { createHash } from 'node:crypto'; 3 | import { mkdir, rm, readFile, writeFile } from 'node:fs/promises'; 4 | 5 | const DEFAULT_CACHE_DIR = './.cache'; 6 | const DEFAULT_TTL = 1000 * 60 * 60 * 24 * 15; // 15 days 7 | 8 | export function deleteCache(cacheDir = DEFAULT_CACHE_DIR) { 9 | return rm(cacheDir, { recursive: true, force: true }); 10 | } 11 | 12 | export async function fileCache({ 13 | cacheDir = DEFAULT_CACHE_DIR, 14 | defaultTTL = DEFAULT_TTL 15 | } = {}) { 16 | const md5 = key => createHash('md5').update(key).digest('hex'); 17 | const local = new Map; 18 | 19 | await mkdir(cacheDir, { recursive: true }); 20 | 21 | function file(key) { 22 | return join(cacheDir, md5(key)); 23 | } 24 | 25 | function _delete(key) { 26 | local.delete(key); 27 | return rm(file(key), { recursive: true, force: true }); 28 | } 29 | 30 | return { 31 | async get(key) { 32 | try { 33 | let entry; 34 | if (local.has(key)) { 35 | entry = local.get(key); 36 | } else { 37 | entry = JSON.parse(await readFile(file(key), 'utf8')); 38 | } 39 | 40 | if (entry?.expires < Date.now()) { 41 | _delete(key); 42 | entry = null; 43 | } 44 | return entry?.data; 45 | } catch (err) { 46 | return; 47 | } 48 | }, 49 | set(key, data, ttl = defaultTTL) { 50 | const entry = { data, expires: Date.now() + ttl }; 51 | local.set(key, entry); 52 | return writeFile(file(key), JSON.stringify(entry), 'utf8'); 53 | }, 54 | delete: _delete 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # file-cache 2 | 3 | File-based cache for JSON-serializable data. 4 | 5 | ## Install 6 | 7 | Requires at least Node.js v14.14.0. 8 | 9 | ``` 10 | npm install @uwdata/file-cache 11 | ``` 12 | 13 | ## Usage 14 | 15 | `@uwdata/file-cache` is an ESM-only module - you are not able to import it with `require()`. 16 | 17 | ### Standard Usage 18 | 19 | ```js 20 | import { fileCache, deleteCache } from '@uwdata/file-cache'; 21 | 22 | // create a new cache, writes to '.cache' in current working dir 23 | // the cache directory will be created if it does not exist 24 | const cache = await fileCache(); 25 | 26 | // set cache value writes both to in-memory map and to disk 27 | await cache.set('key', { value: true }); 28 | 29 | // get cache value reads from disk if not found in-memory 30 | const value = await cache.get('key'); 31 | // value = { value: true} 32 | 33 | // delete cache value from both in-memory map and disk 34 | await cache.delete('key'); 35 | 36 | // delete cache values and folder from disk 37 | // subsequent use of existing cache instance is ill-advised 38 | await deleteCache(); 39 | ``` 40 | 41 | ### Custom Usage 42 | 43 | ```js 44 | import { fileCache, deleteCache } from '@uwdata/file-cache'; 45 | 46 | // create a new cache with custom directory and time-to-live 47 | const cache = await fileCache({ 48 | cacheDir: '.my-cache-dir', // custom cache directory 49 | defaultTTL: 60 * 1000, // default time-to-live before expiration (ms) 50 | }); 51 | 52 | // set cache value along with a custom TTL value for the entry 53 | await cache.set('key', { value: true }, 120 * 1000); 54 | 55 | // delete cache values and folder from disk 56 | // subsequent use of existing cache instance is ill-advised 57 | await deleteCache('.my-cache-dir'); 58 | ``` 59 | -------------------------------------------------------------------------------- /test/file-cache-test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { fileCache, deleteCache } from '../src/file-cache.js'; 3 | 4 | function wait(ms) { 5 | return new Promise(resolve => setTimeout(resolve, ms)); 6 | } 7 | 8 | describe('file-cache', () => { 9 | describe('default cache', function () { 10 | const key = 'my_key'; 11 | const value1 = { foo: 1, bar: false, baz: ['foo'] }; 12 | const value2 = { foo: 2, bar: true, baz: ['bop'] }; 13 | 14 | let cacheA; 15 | let cacheB; 16 | 17 | before(async () => { 18 | cacheA = await fileCache(); 19 | cacheB = await fileCache(); 20 | }); 21 | 22 | after(async () => { 23 | await deleteCache(); 24 | }) 25 | 26 | it('can read and write cache values', async () => { 27 | // cache can read non-existent values 28 | assert.deepStrictEqual(await cacheA.get(key), undefined); 29 | 30 | // cache can write and read values 31 | await cacheA.set(key, value1); 32 | assert.deepStrictEqual(await cacheA.get(key), value1); 33 | 34 | // cache can overwrite values 35 | await cacheA.set(key, value2); 36 | assert.deepStrictEqual(await cacheA.get(key), value2); 37 | 38 | // a different cache over same files gets the same value 39 | assert.deepStrictEqual(await cacheB.get(key), value2); 40 | }); 41 | }); 42 | 43 | describe('custom cache', function () { 44 | const key = 'custom_key'; 45 | const value = { foo: false, bar: 'who', baz: [3.14] }; 46 | 47 | let cacheA; 48 | let cacheB; 49 | 50 | before(async () => { 51 | cacheA = await fileCache({ cacheDir: './.my_cache' }); 52 | cacheB = await fileCache(); 53 | }); 54 | 55 | after(async () => { 56 | await deleteCache('./.my_cache'); 57 | await deleteCache(); 58 | }) 59 | 60 | it('can read and write cache values', async () => { 61 | // cache can write and read values 62 | await cacheA.set(key, value); 63 | assert.deepStrictEqual(await cacheA.get(key), value); 64 | 65 | // a different cache over different files does not get the same value 66 | assert.deepStrictEqual(await cacheB.get(key), undefined); 67 | }); 68 | }); 69 | 70 | describe('time-to-live', function () { 71 | const key = 'ttl_key'; 72 | const value = { foo: false, bar: 'who', baz: [3.141] }; 73 | 74 | let cacheA; 75 | 76 | before(async () => { 77 | cacheA = await fileCache({ defaultTTL: 30 }); 78 | }); 79 | 80 | after(async () => { 81 | await deleteCache(); 82 | }) 83 | 84 | it('supports default time-to-live values', async () => { 85 | // set and read cache value 86 | await cacheA.set(key, value); 87 | assert.deepStrictEqual(await cacheA.get(key), value); 88 | 89 | // cache value should expire 90 | await wait(31); 91 | assert.deepStrictEqual(await cacheA.get(key), undefined); 92 | }); 93 | 94 | it('supports custom time-to-live values', async () => { 95 | // set and read cache value 96 | await cacheA.set(key, value, 40); 97 | await wait(31); 98 | assert.deepStrictEqual(await cacheA.get(key), value); 99 | 100 | // cache value should expire 101 | await wait(10); 102 | assert.deepStrictEqual(await cacheA.get(key), undefined); 103 | }); 104 | }); 105 | }); 106 | --------------------------------------------------------------------------------