├── .idea ├── .gitignore ├── vcs.xml ├── jsLibraryMappings.xml ├── prettier.xml ├── jsLinters │ └── eslint.xml ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml └── cacheables.iml ├── tests ├── tsconfig.json ├── fetchPolicies.test.ts └── index.test.ts ├── .prettierrc.js ├── tsconfig.cjs.json ├── tsconfig.mjs.json ├── jest.config.js ├── .eslintrc.js ├── fixup-packages.ts ├── tsconfig.json ├── .github └── workflows │ └── npm-publish.yml ├── LICENSE.md ├── package.json ├── .gitignore ├── src └── index.ts └── README.md /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "include": ["."] 7 | } -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 80, 6 | tabWidth: 2, 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "dist/cjs", 6 | "target": "es2015" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.mjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "dist/mjs", 6 | "target": "esnext" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testPathIgnorePatterns: ['/node_modules/', './example/', './dist/'], 5 | watchPathIgnorePatterns: ['/node_modules/', './example/', './dist/'], 6 | } 7 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/jsLinters/eslint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | ecmaVersion: 2020, 5 | sourceType: 'module', 6 | }, 7 | extends: [ 8 | 'plugin:@typescript-eslint/recommended', 9 | 'prettier', 10 | 'plugin:prettier/recommended', 11 | ], 12 | rules: { 13 | '@typescript-eslint/no-explicit-any': 'off', 14 | }, 15 | ignorePatterns: ['dist'], 16 | } 17 | -------------------------------------------------------------------------------- /.idea/cacheables.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /fixup-packages.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'node:fs' 2 | import { join, dirname } from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)) 6 | 7 | const fixupPackages = () => { 8 | writeFileSync( 9 | join(__dirname, 'dist', 'mjs', 'package.json'), 10 | JSON.stringify({ type: 'module' }, null, 2), 11 | ) 12 | 13 | writeFileSync( 14 | join(__dirname, 'dist', 'cjs', 'package.json'), 15 | JSON.stringify({ type: 'commonjs' }, null, 2), 16 | ) 17 | } 18 | 19 | fixupPackages() 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "declaration": true, 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "removeComments": false, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictBindCallApply": true, 16 | "strictPropertyInitialization": true, 17 | "noUncheckedIndexedAccess": true, 18 | "noPropertyAccessFromIndexSignature": true, 19 | "noImplicitThis": true, 20 | "alwaysStrict": true, 21 | "esModuleInterop": true, 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true 24 | }, 25 | "include": ["src"], 26 | "exclude": ["node_modules", "dist"] 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: 20 18 | - run: npm ci 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-node@v2 27 | with: 28 | node-version: 20 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm ci 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Grischa Erbe 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. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cacheables", 3 | "version": "2.0.0", 4 | "description": "A simple in-memory cache written in Typescript with automatic cache invalidation and an elegant syntax.", 5 | "main": "dist/cjs/index.js", 6 | "module": "dist/mjs/index.js", 7 | "types": "dist/mjs/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "import": "./dist/mjs/index.js", 11 | "require": "./dist/cjs/index.js", 12 | "types": "./dist/mjs/index.d.ts" 13 | } 14 | }, 15 | "scripts": { 16 | "size": "size-limit", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "typecheck": "tsc --noEmit", 20 | "eslint": "eslint --fix src/**/*.ts", 21 | "prepublish": "npm run eslint && npm run typecheck && npm test && npm run build", 22 | "build": "rimraf dist && tsc -p tsconfig.mjs.json && tsc -p tsconfig.cjs.json && tsx ./fixup-packages.ts" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/grischaerbe/cacheables.git" 27 | }, 28 | "size-limit": [ 29 | { 30 | "path": "./dist/mjs/index.js" 31 | } 32 | ], 33 | "keywords": [ 34 | "node", 35 | "browser", 36 | "cache", 37 | "in-memory", 38 | "typescript", 39 | "cacheable", 40 | "cacheables" 41 | ], 42 | "author": "Grischa Erbe ", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/grischaerbe/cacheables/issues" 46 | }, 47 | "homepage": "https://github.com/grischaerbe/cacheables#readme", 48 | "devDependencies": { 49 | "@size-limit/preset-small-lib": "^11.1.4", 50 | "@types/jest": "^29.5.12", 51 | "@typescript-eslint/eslint-plugin": "^7.12.0", 52 | "@typescript-eslint/parser": "^7.12.0", 53 | "eslint": "^8.56.0", 54 | "eslint-config-prettier": "^9.1.0", 55 | "eslint-plugin-prettier": "^5.1.3", 56 | "jest": "^29.7.0", 57 | "prettier": "^3.3.1", 58 | "rimraf": "^5.0.7", 59 | "size-limit": "^11.1.4", 60 | "ts-jest": "^29.1.4", 61 | "tsx": "^4.11.2", 62 | "typescript": "^5.4.5" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/fetchPolicies.test.ts: -------------------------------------------------------------------------------- 1 | import { Cacheables } from '../src' 2 | 3 | const mockedApiRequest = (value: T, duration = 0): Promise => 4 | new Promise((resolve) => { 5 | if (duration > 0) { 6 | setTimeout(() => { 7 | resolve(value) 8 | }, duration) 9 | } else { 10 | resolve(value) 11 | } 12 | }) 13 | 14 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 15 | 16 | describe('Fetch Policies', () => { 17 | it('cache-only', async () => { 18 | const cache = new Cacheables() 19 | 20 | const COCacheable = (v: any) => 21 | cache.cacheable(() => mockedApiRequest(v), 'key', { 22 | cachePolicy: 'cache-only', 23 | }) 24 | 25 | const a = await COCacheable(0) 26 | const b = await COCacheable(1) 27 | const c = await COCacheable(2) 28 | 29 | expect(a).toEqual(0) 30 | expect(b).toEqual(0) 31 | expect(c).toEqual(0) 32 | }) 33 | 34 | it('network-only-non-concurrent', async () => { 35 | const cache = new Cacheables() 36 | 37 | const NONCCacheable = (v: any) => 38 | cache.cacheable(() => mockedApiRequest(v, 50), 'key', { 39 | cachePolicy: 'network-only-non-concurrent', 40 | }) 41 | 42 | // Preheat cache 43 | await NONCCacheable(-1) 44 | 45 | const a = NONCCacheable(0) 46 | const b = NONCCacheable(1) 47 | const c = NONCCacheable(2) 48 | 49 | await wait(100) 50 | 51 | const d = NONCCacheable(3) 52 | 53 | const values = await Promise.all([a, b, c, d]) 54 | 55 | expect(values).toEqual([0, 0, 0, 3]) 56 | }) 57 | it('network-only', async () => { 58 | const cache = new Cacheables() 59 | 60 | const NOCacheable = (v: any) => 61 | cache.cacheable(() => mockedApiRequest(v, 50), 'key', { 62 | cachePolicy: 'network-only', 63 | }) 64 | 65 | // Preheat cache 66 | await NOCacheable(-1) 67 | 68 | const a = NOCacheable(0) 69 | const b = NOCacheable(1) 70 | const c = NOCacheable(2) 71 | 72 | const values = await Promise.all([a, b, c]) 73 | 74 | expect(values).toEqual([0, 1, 2]) 75 | }) 76 | it('max-age', async () => { 77 | const cache = new Cacheables() 78 | 79 | const MACacheable = (v: any) => 80 | cache.cacheable(() => mockedApiRequest(v, 50), 'key', { 81 | cachePolicy: 'max-age', 82 | maxAge: 100, 83 | }) 84 | 85 | const a = await MACacheable(0) 86 | const b = await MACacheable(1) 87 | 88 | await wait(100) 89 | 90 | const c = await MACacheable(2) 91 | const d = await MACacheable(3) 92 | 93 | expect([a, b, c, d]).toEqual([0, 0, 2, 2]) 94 | }) 95 | it('stale-while-revalidate', async () => { 96 | const cache = new Cacheables() 97 | 98 | const SWRCacheable = (v: any) => 99 | cache.cacheable(() => mockedApiRequest(v, 50), 'key', { 100 | cachePolicy: 'stale-while-revalidate', 101 | }) 102 | 103 | // Preheat cache 104 | await SWRCacheable(-1) 105 | 106 | await wait(100) 107 | 108 | const a = await SWRCacheable(0) 109 | const b = await SWRCacheable(1) 110 | const c = await SWRCacheable(2) 111 | 112 | await wait(100) 113 | 114 | const d = await SWRCacheable(3) 115 | const e = await SWRCacheable(4) 116 | const f = await SWRCacheable(5) 117 | 118 | expect([a, b, c, d, e, f]).toEqual([-1, -1, -1, 0, 0, 0]) 119 | }) 120 | 121 | it('stale-while-revalidate with maxAge', async () => { 122 | const cache = new Cacheables() 123 | 124 | const SWRCacheable = (v: any) => 125 | cache.cacheable(() => mockedApiRequest(v, 50), 'key', { 126 | cachePolicy: 'stale-while-revalidate', 127 | maxAge: 200, 128 | }) 129 | 130 | // Preheat cache, takes ~50ms 131 | await SWRCacheable(0) 132 | 133 | await wait(100) 134 | 135 | // ~150ms on the clock, maxAge not reached 136 | const a = await SWRCacheable(1) 137 | 138 | await wait(100) 139 | 140 | // ~250ms on the clock, maxAge reached, cache updates silently 141 | const b = await SWRCacheable(2) 142 | 143 | await wait(100) 144 | 145 | // ~350ms on the clock, cache should be updated silently with value `2` 146 | const c = await SWRCacheable(3) 147 | 148 | expect([a, b, c]).toEqual([0, 0, 2]) 149 | }) 150 | }) 151 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/webstorm,node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=webstorm,node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variables file 77 | .env 78 | .env.test 79 | .env.production 80 | 81 | # parcel-bundler cache (https://parceljs.org/) 82 | .cache 83 | .parcel-cache 84 | 85 | # Next.js build output 86 | .next 87 | out 88 | 89 | # Nuxt.js build / generate output 90 | .nuxt 91 | dist 92 | 93 | # Gatsby files 94 | .cache/ 95 | # Comment in the public line in if your project uses Gatsby and not Next.js 96 | # https://nextjs.org/blog/next-9-1#public-directory-support 97 | # public 98 | 99 | # vuepress build output 100 | .vuepress/dist 101 | 102 | # Serverless directories 103 | .serverless/ 104 | 105 | # FuseBox cache 106 | .fusebox/ 107 | 108 | # DynamoDB Local files 109 | .dynamodb/ 110 | 111 | # TernJS port file 112 | .tern-port 113 | 114 | # Stores VSCode versions used for testing VSCode extensions 115 | .vscode-test 116 | 117 | # yarn v2 118 | .yarn/cache 119 | .yarn/unplugged 120 | .yarn/build-state.yml 121 | .yarn/install-state.gz 122 | .pnp.* 123 | 124 | ### WebStorm ### 125 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 126 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 127 | 128 | # User-specific stuff 129 | .idea/**/workspace.xml 130 | .idea/**/tasks.xml 131 | .idea/**/usage.statistics.xml 132 | .idea/**/dictionaries 133 | .idea/**/shelf 134 | 135 | # AWS User-specific 136 | .idea/**/aws.xml 137 | 138 | # Generated files 139 | .idea/**/contentModel.xml 140 | 141 | # Sensitive or high-churn files 142 | .idea/**/dataSources/ 143 | .idea/**/dataSources.ids 144 | .idea/**/dataSources.local.xml 145 | .idea/**/sqlDataSources.xml 146 | .idea/**/dynamic.xml 147 | .idea/**/uiDesigner.xml 148 | .idea/**/dbnavigator.xml 149 | 150 | # Gradle 151 | .idea/**/gradle.xml 152 | .idea/**/libraries 153 | 154 | # Gradle and Maven with auto-import 155 | # When using Gradle or Maven with auto-import, you should exclude module files, 156 | # since they will be recreated, and may cause churn. Uncomment if using 157 | # auto-import. 158 | # .idea/artifacts 159 | # .idea/compiler.xml 160 | # .idea/jarRepositories.xml 161 | # .idea/modules.xml 162 | # .idea/*.iml 163 | # .idea/modules 164 | # *.iml 165 | # *.ipr 166 | 167 | # CMake 168 | cmake-build-*/ 169 | 170 | # Mongo Explorer plugin 171 | .idea/**/mongoSettings.xml 172 | 173 | # File-based project format 174 | *.iws 175 | 176 | # IntelliJ 177 | out/ 178 | 179 | # mpeltonen/sbt-idea plugin 180 | .idea_modules/ 181 | 182 | # JIRA plugin 183 | atlassian-ide-plugin.xml 184 | 185 | # Cursive Clojure plugin 186 | .idea/replstate.xml 187 | 188 | # Crashlytics plugin (for Android Studio and IntelliJ) 189 | com_crashlytics_export_strings.xml 190 | crashlytics.properties 191 | crashlytics-build.properties 192 | fabric.properties 193 | 194 | # Editor-based Rest Client 195 | .idea/httpRequests 196 | 197 | # Android studio 3.1+ serialized cache file 198 | .idea/caches/build_file_checksums.ser 199 | 200 | ### WebStorm Patch ### 201 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 202 | 203 | # *.iml 204 | # modules.xml 205 | # .idea/misc.xml 206 | # *.ipr 207 | 208 | # Sonarlint plugin 209 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 210 | .idea/**/sonarlint/ 211 | 212 | # SonarQube Plugin 213 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 214 | .idea/**/sonarIssues.xml 215 | 216 | # Markdown Navigator plugin 217 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 218 | .idea/**/markdown-navigator.xml 219 | .idea/**/markdown-navigator-enh.xml 220 | .idea/**/markdown-navigator/ 221 | 222 | # Cache file creation bug 223 | # See https://youtrack.jetbrains.com/issue/JBR-2257 224 | .idea/$CACHE_FILE$ 225 | 226 | # CodeStream plugin 227 | # https://plugins.jetbrains.com/plugin/12206-codestream 228 | .idea/codestream.xml 229 | 230 | # End of https://www.toptal.com/developers/gitignore/api/webstorm,node -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { CacheableOptions, Cacheables } from '../src' 2 | 3 | const errorMessage = 'This is an error message.' 4 | 5 | const mockedApiRequest = ( 6 | value: T, 7 | duration = 0, 8 | reject = false, 9 | ): Promise => 10 | new Promise((resolve, r) => { 11 | if (reject) r(errorMessage) 12 | if (duration > 0) { 13 | setTimeout(() => { 14 | resolve(value) 15 | }, duration) 16 | } else { 17 | resolve(value) 18 | } 19 | }) 20 | 21 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 22 | 23 | describe('Cache operations', () => { 24 | it('Returns correct values', async () => { 25 | const cache = new Cacheables() 26 | const value = 10 27 | const cachedValue = await cache.cacheable( 28 | () => mockedApiRequest(value), 29 | 'a', 30 | ) 31 | expect(cache.isCached('a')).toEqual(true) 32 | expect(cache.keys()).toEqual(['a']) 33 | expect(cachedValue).toEqual(value) 34 | }) 35 | 36 | it('Stores multiple caches', async () => { 37 | const cache = new Cacheables() 38 | 39 | const valueA = 10 40 | const valueB = 20 41 | 42 | const cachedValueA = await cache.cacheable( 43 | () => mockedApiRequest(valueA), 44 | 'a', 45 | ) 46 | const cachedValueB = await cache.cacheable( 47 | () => mockedApiRequest(valueB), 48 | 'b', 49 | ) 50 | 51 | expect(cache.keys().sort()).toEqual(['a', 'b'].sort()) 52 | expect([cachedValueA, cachedValueB]).toEqual([valueA, valueB]) 53 | }) 54 | 55 | it('Deletes values', async () => { 56 | const cache = new Cacheables() 57 | 58 | const value = 10 59 | await cache.cacheable(() => mockedApiRequest(value), 'a') 60 | 61 | expect(cache.isCached('a')).toEqual(true) 62 | cache.delete('a') 63 | expect(cache.isCached('a')).toEqual(false) 64 | }) 65 | 66 | it('Clears the cache', async () => { 67 | const cache = new Cacheables() 68 | 69 | const value = 10 70 | await cache.cacheable(() => mockedApiRequest(value), 'a') 71 | 72 | expect(cache.isCached('a')).toEqual(true) 73 | cache.clear() 74 | expect(cache.isCached('a')).toEqual(false) 75 | }) 76 | 77 | it('Creates proper keys', () => { 78 | const key = Cacheables.key('aaa', 'bbb', 'ccc', 'ddd', 10, 20) 79 | expect(key).toEqual('aaa:bbb:ccc:ddd:10:20') 80 | }) 81 | 82 | it('Returns correctly if disabled', async () => { 83 | const cache = new Cacheables({ 84 | enabled: false, 85 | }) 86 | 87 | const value = 10 88 | const uncachedValue = await cache.cacheable( 89 | () => mockedApiRequest(value), 90 | 'a', 91 | ) 92 | 93 | expect(uncachedValue).toEqual(value) 94 | }) 95 | 96 | it('Logs correctly', async () => { 97 | console.log = jest.fn() 98 | 99 | const cache = new Cacheables({ 100 | log: true, 101 | enabled: false, 102 | }) 103 | 104 | const cachedRequest = () => cache.cacheable(() => mockedApiRequest(1), 'a') 105 | 106 | await cachedRequest() 107 | expect(console.log).lastCalledWith('CACHE: Caching disabled') 108 | cache.enabled = true 109 | 110 | await cachedRequest() 111 | expect(console.log).lastCalledWith('Cacheable "a": hits: 0') 112 | 113 | await cachedRequest() 114 | expect(console.log).lastCalledWith('Cacheable "a": hits: 1') 115 | }) 116 | 117 | /** 118 | * Prepare for some weird timings here. 119 | * IMPORTANT: Be aware that the maxAge for a cache 120 | * is set **before** resolving the resource. 121 | * 122 | * Assuming the time starts at 0 123 | */ 124 | it('Handles race conditions correctly', async () => { 125 | const cache = new Cacheables() 126 | 127 | const racingCache = (v: any) => 128 | cache.cacheable(() => mockedApiRequest(v, 50), 'a', { 129 | cachePolicy: 'max-age', 130 | maxAge: 100, 131 | }) 132 | 133 | // Create a cache that times out at 100 and resolves at 50 134 | const a = await racingCache('a') 135 | expect(a).toEqual('a') 136 | 137 | // The time is ~50, the cache should not be invalidated 138 | // yet, this should be a cache hit and should resolve value 'a' immediately. 139 | const b = await racingCache('b') 140 | expect(b).toEqual('a') 141 | 142 | // maxAge of previous requests expired 143 | await wait(200) 144 | 145 | // The time is ~(50 + 200 = 250) and the cache should be invalidated. 146 | const c = await racingCache('c') 147 | expect(c).toEqual('c') 148 | }) 149 | 150 | it('Handles multiple calls correctly', async () => { 151 | console.log = jest.fn() 152 | 153 | const cache = new Cacheables({ 154 | log: true, 155 | }) 156 | 157 | const hitCache = async () => { 158 | await cache.cacheable(() => mockedApiRequest(0, 10), 'a', { 159 | cachePolicy: 'max-age', 160 | maxAge: 100, 161 | }) 162 | } 163 | 164 | // This should be a miss and take ~10ms 165 | await hitCache() 166 | expect(console.log).lastCalledWith('Cacheable "a": hits: 0') 167 | 168 | // This should be a hit and take ~0ms 169 | await hitCache() 170 | expect(console.log).lastCalledWith('Cacheable "a": hits: 1') 171 | 172 | await wait(60) 173 | 174 | // This should be a hit and take ~0ms 175 | await hitCache() 176 | expect(console.log).lastCalledWith('Cacheable "a": hits: 2') 177 | 178 | await wait(60) 179 | 180 | // This should be a miss and take ~10ms 181 | await hitCache() 182 | expect(console.log).lastCalledWith('Cacheable "a": hits: 2') 183 | }) 184 | 185 | it("Doesn't interfere with error handling", async () => { 186 | const cache = new Cacheables() 187 | const rejecting = () => { 188 | return cache.cacheable(() => mockedApiRequest(0, 10, true), 'a') 189 | } 190 | await expect(rejecting).rejects.toEqual(errorMessage) 191 | }) 192 | 193 | it("Doesn't cache rejected value", async () => { 194 | const cache = new Cacheables() 195 | let errNo = 1 196 | const rejecting = () => { 197 | return cache.cacheable(() => Promise.reject(errNo++), 'a') 198 | } 199 | await expect(rejecting()).rejects.toEqual(1) 200 | await expect(rejecting()).rejects.toEqual(2) 201 | }) 202 | }) 203 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | //region Types 2 | export type CacheOptions = { 3 | /** 4 | * Enables caching 5 | */ 6 | enabled?: boolean 7 | /** 8 | * Enable/disable logging of cache hits 9 | */ 10 | log?: boolean 11 | /** 12 | * Enable/disable timings 13 | */ 14 | logTiming?: boolean 15 | } 16 | 17 | type CacheOnlyCachePolicy = { 18 | cachePolicy: 'cache-only' 19 | } 20 | 21 | type NetworkOnlyNonConcurrentCachePolicy = { 22 | cachePolicy: 'network-only-non-concurrent' 23 | } 24 | 25 | type NetworkOnlyCachePolicy = { 26 | cachePolicy: 'network-only' 27 | } 28 | 29 | type MaxAgeCachePolicy = { 30 | cachePolicy: 'max-age' 31 | maxAge: number 32 | } 33 | 34 | type SWRCachePolicy = { 35 | cachePolicy: 'stale-while-revalidate' 36 | maxAge?: number 37 | } 38 | 39 | /** 40 | * Cacheable options. 41 | */ 42 | export type CacheableOptions = 43 | | CacheOnlyCachePolicy 44 | | NetworkOnlyCachePolicy 45 | | NetworkOnlyNonConcurrentCachePolicy 46 | | MaxAgeCachePolicy 47 | | SWRCachePolicy 48 | 49 | //endregion 50 | 51 | //region Cacheables 52 | /** 53 | * Provides a simple in-memory cache with automatic or manual invalidation. 54 | */ 55 | export class Cacheables { 56 | enabled: boolean 57 | log: boolean 58 | logTiming: boolean 59 | 60 | constructor(options?: CacheOptions) { 61 | this.enabled = options?.enabled ?? true 62 | this.log = options?.log ?? false 63 | this.logTiming = options?.logTiming ?? false 64 | } 65 | 66 | #cacheables: Record> = {} 67 | 68 | /** 69 | * Builds a key with the provided strings or numbers. 70 | * @param args 71 | */ 72 | static key(...args: (string | number)[]): string { 73 | return args.join(':') 74 | } 75 | 76 | /** 77 | * Deletes a cacheable. 78 | * @param key 79 | */ 80 | delete(key: string): void { 81 | delete this.#cacheables[key] 82 | } 83 | 84 | /** 85 | * Clears the cache by deleting all cacheables. 86 | */ 87 | clear(): void { 88 | this.#cacheables = {} 89 | } 90 | 91 | /** 92 | * Returns whether a cacheable is present and valid (i.e., did not time out). 93 | */ 94 | isCached(key: string): boolean { 95 | return !!this.#cacheables[key] 96 | } 97 | 98 | /** 99 | * Returns all the cache keys 100 | */ 101 | keys(): string[] { 102 | return Object.keys(this.#cacheables) 103 | } 104 | 105 | /** 106 | * A "cacheable" represents a resource, commonly fetched from a remote source 107 | * that you want to cache for a certain period of time. 108 | * @param resource A function returning a Promise 109 | * @param key A key to identify the cache 110 | * @param options {CacheableOptions} options 111 | * @example 112 | * const apiResponse = await cache.cacheable( 113 | * () => api.query({ 114 | * query: someQuery, 115 | * variables: someVariables, 116 | * }), 117 | * Cache.key('type', someCacheKey, someOtherCacheKey), 118 | * 60000 119 | * ) 120 | * @returns promise Resolves to the value of the provided resource, either from 121 | * cache or from the remote resource itself. 122 | */ 123 | async cacheable( 124 | resource: () => Promise, 125 | key: string, 126 | options?: CacheableOptions, 127 | ): Promise { 128 | const shouldCache = this.enabled 129 | if (!shouldCache) { 130 | if (this.log) Logger.logDisabled() 131 | return resource() 132 | } 133 | 134 | // Persist log settings as this could be a race condition 135 | const { logTiming, log } = this 136 | const logId = Logger.getLogId(key) 137 | if (logTiming) Logger.logTime(logId) 138 | 139 | const result = await this.#cacheable(resource, key, options) 140 | 141 | if (logTiming) Logger.logTimeEnd(logId) 142 | if (log) Logger.logStats(key, this.#cacheables[key]) 143 | 144 | return result 145 | } 146 | 147 | #cacheable( 148 | resource: () => Promise, 149 | key: string, 150 | options?: CacheableOptions, 151 | ): Promise { 152 | let cacheable = this.#cacheables[key] as Cacheable | undefined 153 | 154 | if (!cacheable) { 155 | cacheable = new Cacheable() 156 | this.#cacheables[key] = cacheable 157 | } 158 | 159 | return cacheable.touch(resource, options) 160 | } 161 | } 162 | //endregion 163 | 164 | //region Cacheable 165 | /** 166 | * Helper class, can only be instantiated by calling its static 167 | * function `create`. 168 | */ 169 | class Cacheable { 170 | hits = 0 171 | #lastFetch = 0 172 | #initialized = false 173 | #promise: Promise | undefined 174 | 175 | get #isFetching() { 176 | return !!this.#promise 177 | } 178 | 179 | #value: T = undefined as unknown as T 180 | 181 | #logHit() { 182 | this.hits += 1 183 | } 184 | 185 | async #fetch(resource: () => Promise): Promise { 186 | this.#lastFetch = Date.now() 187 | this.#promise = resource() 188 | try { 189 | this.#value = await this.#promise 190 | if (!this.#initialized) this.#initialized = true 191 | } finally { 192 | this.#promise = undefined 193 | } 194 | return this.#value 195 | } 196 | 197 | async #fetchNonConcurrent(resource: () => Promise): Promise { 198 | if (this.#isFetching) { 199 | await this.#promise 200 | this.#logHit() 201 | return this.#value 202 | } 203 | return this.#fetch(resource) 204 | } 205 | 206 | #handlePreInit( 207 | resource: () => Promise, 208 | options?: CacheableOptions, 209 | ): Promise { 210 | if (!options) return this.#fetchNonConcurrent(resource) 211 | switch (options.cachePolicy) { 212 | case 'cache-only': 213 | return this.#fetchNonConcurrent(resource) 214 | case 'network-only': 215 | return this.#fetch(resource) 216 | case 'stale-while-revalidate': 217 | return this.#fetchNonConcurrent(resource) 218 | case 'max-age': 219 | return this.#fetchNonConcurrent(resource) 220 | case 'network-only-non-concurrent': 221 | return this.#fetchNonConcurrent(resource) 222 | } 223 | } 224 | 225 | #handleCacheOnly(): T { 226 | this.#logHit() 227 | return this.#value 228 | } 229 | 230 | #handleNetworkOnly(resource: () => Promise): Promise { 231 | return this.#fetch(resource) 232 | } 233 | 234 | #handleNetworkOnlyNonConcurrent(resource: () => Promise): Promise { 235 | return this.#fetchNonConcurrent(resource) 236 | } 237 | 238 | #handleMaxAge(resource: () => Promise, maxAge: number) { 239 | if (Date.now() > this.#lastFetch + maxAge) { 240 | return this.#fetchNonConcurrent(resource) 241 | } 242 | this.#logHit() 243 | return this.#value 244 | } 245 | 246 | #handleSwr(resource: () => Promise, maxAge?: number): T { 247 | if ( 248 | !this.#isFetching && 249 | ((maxAge && Date.now() > this.#lastFetch + maxAge) || !maxAge) 250 | ) { 251 | this.#fetchNonConcurrent(resource) 252 | } 253 | this.#logHit() 254 | return this.#value 255 | } 256 | 257 | /** 258 | * Get and set the value of the Cacheable. 259 | * Some tricky race are conditions going on here, 260 | * but this should behave as expected 261 | * @param resource 262 | * @param options {CacheableOptions} 263 | */ 264 | async touch( 265 | resource: () => Promise, 266 | options?: CacheableOptions, 267 | ): Promise { 268 | if (!this.#initialized) { 269 | return this.#handlePreInit(resource, options) 270 | } 271 | if (!options) { 272 | return this.#handleCacheOnly() 273 | } 274 | switch (options.cachePolicy) { 275 | case 'cache-only': 276 | return this.#handleCacheOnly() 277 | case 'network-only': 278 | return this.#handleNetworkOnly(resource) 279 | case 'stale-while-revalidate': 280 | return this.#handleSwr(resource, options.maxAge) 281 | case 'max-age': 282 | return this.#handleMaxAge(resource, options.maxAge) 283 | case 'network-only-non-concurrent': 284 | return this.#handleNetworkOnlyNonConcurrent(resource) 285 | } 286 | } 287 | } 288 | //endregion 289 | 290 | //region Logger 291 | /** 292 | * Logger class with static logging functions. 293 | */ 294 | class Logger { 295 | static getLogId(key: string) { 296 | return key + '---' + Math.random().toString(36).substr(2, 9) 297 | } 298 | 299 | static logTime(key: string): void { 300 | // eslint-disable-next-line no-console 301 | console.time(key) 302 | } 303 | 304 | static logTimeEnd(key: string): void { 305 | // eslint-disable-next-line no-console 306 | console.timeEnd(key) 307 | } 308 | 309 | static logDisabled(): void { 310 | // eslint-disable-next-line no-console 311 | console.log('CACHE: Caching disabled') 312 | } 313 | 314 | static logStats(key: string, cacheable: Cacheable | undefined): void { 315 | if (!cacheable) return 316 | const { hits } = cacheable 317 | console.log(`Cacheable "${key}": hits: ${hits}`) 318 | } 319 | } 320 | //endregion 321 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cacheables 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | ![Language](https://img.shields.io/github/languages/top/grischaerbe/cacheables) 5 | ![Build](https://img.shields.io/github/workflow/status/grischaerbe/cacheables/Node.js%20Package) 6 | 7 | A simple in-memory cache with support of different cache policies and elegant syntax written in Typescript. 8 | 9 | - Elegant syntax: **Wrap existing API calls** to save some of those precious API calls. 10 | - **Fully typed results**. No type casting required. 11 | - Supports different **cache policies**. 12 | - Written in **Typescript**. 13 | - **Integrated Logs**: Check on the timing of your API calls. 14 | - Helper function to build cache keys. 15 | - Works in the browser and Node.js. 16 | - **No dependencies**. 17 | - Extensively tested. 18 | - **Small**: 1.43 kB minified and gzipped. 19 | 20 | ```ts 21 | // without caching 22 | fetch('https://some-url.com/api') 23 | 24 | // with caching 25 | cache.cacheable(() => fetch('https://some-url.com/api'), 'key') 26 | ``` 27 | 28 | * [Installation](#installation) 29 | * [Quickstart](#quickstart) 30 | * [Usage](#usage) 31 | * [API](#api) 32 | * [new Cacheables(options?): Cacheables](#new-cacheablesoptions-cacheables) 33 | * [cache.cacheable(resource, key, options?): Promise<T>](#cachecacheableresource-key-options-promiset) 34 | * [cache.delete(key: string): void](#cachedeletekey-string-void) 35 | * [cache.clear(): void](#cacheclear-void) 36 | * [cache.keys(): string[]](#cachekeys-string) 37 | * [cache.isCached(key: string): boolean](#cacheiscachedkey-string-boolean) 38 | * [Cacheables.key(...args: (string | number)[]): string](#cacheableskeyargs-string--number-string) 39 | * [Cache Policies](#cache-policies) 40 | * [Cache Only](#cache-only) 41 | * [Network Only](#network-only) 42 | * [Network Only – Non Concurrent](#network-only--non-concurrent) 43 | * [Max Age](#max-age) 44 | * [Stale While Revalidate](#stale-while-revalidate) 45 | * [Cache Policy Composition](#cache-policy-composition) 46 | * [In Progress](#in-progress) 47 | * [License](#license) 48 | 49 | ## Installation 50 | 51 | ```bash 52 | npm install cacheables 53 | ``` 54 | 55 | ## Quickstart 56 | 57 | [https://codesandbox.io/s/quickstart-cacheables-5zh6h?file=/src/index.ts](https://codesandbox.io/s/quickstart-cacheables-5zh6h?file=/src/index.ts) 58 | 59 | ## Usage 60 | 61 | ```ts 62 | // Import Cacheables 63 | import { Cacheables } from "cacheables" 64 | 65 | const apiUrl = "https://goweather.herokuapp.com/weather/Karlsruhe" 66 | 67 | // Create a new cache instance 68 | const cache = new Cacheables({ 69 | logTiming: true, 70 | log: true 71 | }) 72 | 73 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 74 | 75 | // Wrap the existing API call `fetch(apiUrl)` and assign a cache 76 | // key `weather` to it. This example uses the cache policy 'max-age' 77 | // which invalidates the cache after a certain time. 78 | // The method returns a fully typed Promise just like `fetch(apiUrl)` 79 | // would but with the benefit of caching the result. 80 | const getWeatherData = () => 81 | cache.cacheable(() => fetch(apiUrl), 'weather', { 82 | cachePolicy: 'max-age', 83 | maxAge: 5000, 84 | }) 85 | 86 | const start = async () => { 87 | // Fetch some fresh weather data and store it in our cache. 88 | const weatherData = await getWeatherData() 89 | 90 | /** 3 seconds later **/ 91 | await wait(3000) 92 | 93 | // The cached weather data is returned as the 94 | // maxAge of 5 seconds did not yet expire. 95 | const cachedWeatherData = await getWeatherData() 96 | 97 | /** Another 3 seconds later **/ 98 | await wait(3000) 99 | 100 | // Now that the maxAge is expired, the resource 101 | // will be fetched and stored in our cache. 102 | const freshWeatherData = await getWeatherData() 103 | } 104 | 105 | start() 106 | ``` 107 | 108 | `cacheable` serves both as the getter and setter. This method will return a cached resource if available or use the provided argument `resource` to fill the cache and return a value. 109 | 110 | > Be aware that there is no exclusive cache getter (like `cache.get('key)`). This is by design as the Promise provided by the first argument to `cacheable` is used to infer the return type of the cached resource. 111 | 112 | ## API 113 | 114 | ### `new Cacheables(options?): Cacheables` 115 | 116 | - Creates a new `Cacheables` instance. 117 | 118 | #### Arguments 119 | 120 | ##### - `options?: CacheOptions` 121 | 122 | ```ts 123 | interface CacheOptions { 124 | enabled?: boolean // Enable/disable the cache, can be set anytime, default: true. 125 | log?: boolean // Log hits to the cache, default: false. 126 | logTiming?: boolean // Log the timing, default: false. 127 | } 128 | ``` 129 | 130 | #### Example: 131 | 132 | ```ts 133 | import { Cacheables } from 'cacheables' 134 | 135 | const cache = new Cacheables({ 136 | logTiming: true 137 | }) 138 | ``` 139 | 140 | ### `cache.cacheable(resource, key, options?): Promise` 141 | 142 | - If a resource exists in the cache (determined by the presence of a value with key `key`) `cacheable` decides on returning a cache based on the provided cache policy. 143 | - If there's no resource in the cache, the provided `resource` will be called and used to store a cache value with key `key` and the value is returned. 144 | 145 | #### Arguments 146 | 147 | ##### - `resource: () => Promise` 148 | 149 | A function that returns a `Promise`. 150 | 151 | ##### - `key: string` 152 | 153 | A key to store the cache at. 154 | See [Cacheables.key()](#cacheableskeyargs-string--number-string) for a safe and easy way to generate unique keys. 155 | 156 | ##### - `options?: CacheableOptions` (optional) 157 | 158 | An object defining the cache policy and possibly other options in the future. 159 | The default cache policy is `cache-only`. 160 | See [Cache Policies](#cache-policies). 161 | 162 | ```ts 163 | type CacheableOptions = { 164 | cachePolicy: 'cache-only' | 'network-only-non-concurrent' | 'network-only' | 'max-age' | 'stale-while-revalidate' // See cache policies for details 165 | maxAge?: number // Required if cache policy is `max-age` and optional if cache policy is `stale-while-revalidate` 166 | } 167 | ``` 168 | 169 | #### Example 170 | 171 | ```ts 172 | const cachedApiResponse = await cache.cacheable( 173 | () => fetch('https://github.com/'), 174 | 'key', 175 | { 176 | cachePolicy: 'max-age', 177 | maxAge: 10000 178 | } 179 | ) 180 | ``` 181 | 182 | ### `cache.delete(key: string): void` 183 | 184 | #### Arguments 185 | 186 | ##### - `key: string` 187 | 188 | Delete a cache for a certain key. 189 | 190 | #### Example 191 | 192 | ```ts 193 | cache.delete('key') 194 | ``` 195 | 196 | ### `cache.clear(): void` 197 | 198 | Delete all cached resources. 199 | 200 | ### `cache.keys(): string[]` 201 | 202 | Returns all the cache keys 203 | 204 | ### `cache.isCached(key: string): boolean` 205 | 206 | #### Arguments 207 | 208 | ##### - `key: string` 209 | 210 | Returns whether a cacheable is present for a certain key. 211 | 212 | #### Example 213 | 214 | ```ts 215 | const aIsCached = cache.isCached('a') 216 | ``` 217 | 218 | ### `Cacheables.key(...args: (string | number)[]): string` 219 | 220 | A static helper function to easily build safe and consistent cache keys. 221 | 222 | #### Example 223 | 224 | ```ts 225 | const id = '5d3c5be6-2da4-11ec-8d3d-0242ac130003' 226 | console.log(Cacheables.key('user', id)) 227 | // 'user:5d3c5be6-2da4-11ec-8d3d-0242ac130003' 228 | ``` 229 | 230 | ## Cache Policies 231 | 232 | *Cacheables* comes with multiple cache policies. 233 | Each policy has different behaviour when it comes to preheating the cache (i.e. the first time it is requested) and balancing network requests. 234 | 235 | | Cache Policy | Behaviour | 236 | |-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 237 | | `cache-only` (default) | All requests should return a value from the cache. | 238 | | `network-only` | All requests should be handled by the network.
Simultaneous requests trigger simultaneous network requests. | 239 | | `network-only-non-concurrent` | All requests should be handled by the network but no concurrent network requests are allowed.
All requests made in the timeframe of a network request are resolved once that is finished. | 240 | | `max-age` | All requests should be checked against max-age.
If max-age is expired, a network request is triggered.
All requests made in the timeframe of a network request are resolved once that is finished. | 241 | | `stale-while-revalidate` | All requests immediately return a cached value.
If no network request is running and maxAge is provided and reached or maxAge is not provided, a network request is triggered, 'silently' updating the cache in the background.
After the network request finished, subsequent requests will receive the updated cached value. | 242 | 243 | ### Cache Only (default) 244 | 245 | The default and simplest cache policy. If there is a cache, return it. 246 | If there is no cache yet, all calls will be resolved by the first network request (i.e. non-concurrent). 247 | 248 | ##### Example 249 | ```ts 250 | cache.cacheable(() => fetch(url), 'a', { cachePolicy: 'cache-only' }) 251 | ``` 252 | 253 | ### Network Only 254 | 255 | The opposite of `cache-only`. 256 | Simultaneous requests trigger simultaneous network requests. 257 | 258 | ##### Example 259 | ```ts 260 | cache.cacheable(() => fetch(url), 'a', { cachePolicy: 'network-only' }) 261 | ``` 262 | 263 | ### Network Only – Non Concurrent 264 | 265 | A version of `network-only` but only one network request is running at any point in time. 266 | All requests should be handled by the network but no concurrent network requests are allowed. All requests made in the timeframe of a network request are resolved once that is finished. 267 | 268 | ##### Example 269 | ```ts 270 | cache.cacheable(() => fetch(url), 'a', { cachePolicy: 'network-only-non-concurrent' }) 271 | ``` 272 | 273 | ### Max Age 274 | 275 | The cache policy `max-age` defines after what time a cached value is treated as invalid. 276 | All requests should be checked against max-age. If max-age is expired, a network request is triggered. All requests made in the timeframe of a network request are resolved once that is finished. 277 | 278 | ##### Example 279 | ```ts 280 | // Trigger a network request if the cached value is older than 1 second. 281 | cache.cacheable(() => fetch(url), 'a', { 282 | cachePolicy: 'max-age', 283 | maxAge: 1000 284 | }) 285 | ``` 286 | 287 | ### Stale While Revalidate 288 | 289 | The cache policy `stale-while-revalidate` will return a cached value immediately and – if there is no network request already running and `maxAge` is either provided and reached or not provided – trigger a network request to 'silently' update the cache in the background. 290 | 291 | ##### Example without `maxAge` 292 | ```ts 293 | // If there is a cache, return it but 'silently' update the cache. 294 | cache.cacheable(() => fetch(url), 'a', { cachePolicy: 'stale-while-revalidate'}) 295 | ``` 296 | 297 | ##### Example with `maxAge` 298 | ```ts 299 | // If there is a cache, return it and 'silently' update the cache if it's older than 1 second. 300 | cache.cacheable(() => fetch(url), 'a', { 301 | cachePolicy: 'stale-while-revalidate', 302 | maxAge: 1000 303 | }) 304 | ``` 305 | 306 | ### Cache Policy Composition 307 | A single cacheable can be requested with different cache policies at any time. 308 | 309 | #### Example 310 | ```ts 311 | // If there is a cache, return it. 312 | cache.cacheable(() => fetch(url), 'a', { cachePolicy: 'cache-only' }) 313 | 314 | // If there is a cache, return it but 'silently' update the cache. 315 | cache.cacheable(() => fetch(url), 'a', { cachePolicy: 'stale-while-revalidate' }) 316 | ``` 317 | 318 | ## In Progress 319 | 320 | PRs welcome 321 | 322 | - [ ] ~~Cache invalidation callback~~ 323 | - [ ] Adapters to store cache not only in memory 324 | - [X] Cache policies 325 | - [X] Tests 326 | 327 | ## License 328 | 329 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 330 | --------------------------------------------------------------------------------