├── .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 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/jsLinters/eslint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 | [](https://opensource.org/licenses/MIT)
4 | 
5 | 
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 |
--------------------------------------------------------------------------------