├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ ├── codeql-analysis.yml │ └── main.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.mjs ├── src ├── constants.ts ├── event-emitter.ts ├── helpers.test.ts ├── helpers.ts ├── index.test.ts ├── index.ts └── test-helpers.ts ├── tsconfig.json └── types └── index.d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = false -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "prettier"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ], 11 | "rules": { 12 | "no-async-promise-executor": 0, 13 | "prettier/prettier": 2, 14 | "@typescript-eslint/no-empty-function": 0 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [jperasmus] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '26 10 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | build: 7 | name: Build, lint, and test on Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 18 14 | - 16 15 | - 14 16 | 17 | steps: 18 | - name: Checkout repo 19 | uses: actions/checkout@v3 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - run: npm install 27 | 28 | - name: Lint 29 | run: npm run lint 30 | 31 | - name: Test 32 | run: npm test --ci --coverage --maxWorkers=2 33 | 34 | - name: Build 35 | run: npm run build 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | coverage 6 | .vscode 7 | .idea -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "es5" 6 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ... 11 | 12 | ## [3.4.0] - 2024-03-21 13 | 14 | ### Added 15 | 16 | - Expose a `.retrieve()` convenience method to simply read from the cache without revalidation. 17 | 18 | ## [3.3.0] - 2024-02-15 19 | 20 | ### Added 21 | 22 | - Functionality to auto-retry failed tasks. Opt-in, by using `retry` and `retryDelay` config options. 23 | 24 | ## [3.2.1] - 2023-09-07 25 | 26 | ### Fixed 27 | 28 | - Incorrect emit of error event when cache miss returns `undefined` 29 | 30 | ## [3.2.0] - 2023-08-19 31 | 32 | ### Added 33 | 34 | - Functionality to deduplicate in-flight function invocations 35 | 36 | ### Change 37 | 38 | - This should be considered an implementation detail but as part of the deduplication functionality, there has been a subtle change to the library where it will wait for the cache persistence before returning the result. Theoretically, it means an uncached request will be slower for the time it takes to write to your cache. It's a trade-off. 39 | 40 | ## [3.1.3] - 2023-06-19 41 | 42 | ### Fixed 43 | 44 | - Exported missing `EmitterEvents` constant 45 | 46 | ## [3.1.2] - 2023-06-11 47 | 48 | ### Fixed 49 | 50 | - Transpiled away any uses of the nullish coalescing operator (`??`) since Webpack 4 doesn't support it 51 | 52 | ## [3.1.1] - 2023-05-10 53 | 54 | ### Fixed 55 | 56 | - Update main cache value type to reflect that the awaited value is stored 57 | 58 | ## [3.1.0] - 2023-02-14 59 | 60 | ### Added 61 | 62 | - Expose a `.delete()` convenience method to manually remove cache entries 63 | 64 | ## [3.0.0] - 2023-02-09 65 | 66 | ### Changed 67 | 68 | - Return type from `swr` cache function now returns a payload object containing the cache value and not just the cache value. 69 | - Event emitter events that used `cachedTime` changed to `cachedAt` 70 | - `swr.persist()` function now throws if an error occurs while writing to storage 71 | 72 | ## [2.2.0] - 2023-02-06 73 | 74 | ### Added 75 | 76 | - Expose a `.persist()` convenience method to manually write to the cache 77 | 78 | ## [2.1.0] - 2023-01-26 79 | 80 | ### Added 81 | 82 | - Partial overrides of any cache config values per function invocation. 83 | 84 | ## [2.0.0] - 2022-10-28 85 | 86 | ### Removed 87 | 88 | - Dropped support for Node.js v12 89 | 90 | ### Security 91 | 92 | - Updated dependencies with potential security vulnerabilities 93 | 94 | ### Changed 95 | 96 | - Internal build tools from the unmaintained TSDX to Rollup, Jest, ESLint & Prettier 97 | 98 | ## [1.2.0] - 2021-10-08 99 | 100 | ### Fixed 101 | 102 | - Fix incorrect emitting of `cacheExpired` event 103 | 104 | ### Added 105 | 106 | - Add `cacheStale` event 107 | - Allow falsy cache values excluding `null` and `undefined` 108 | 109 | ## [1.1.0] - 2021-10-01 110 | 111 | ### Added 112 | 113 | - Add emitter events for when storage get and set fails 114 | 115 | [unreleased]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.4.0...HEAD 116 | [3.4.0]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.3.0...v3.4.0 117 | [3.3.0]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.2.1...v3.3.0 118 | [3.2.1]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.2.0...v3.2.1 119 | [3.2.0]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.1.3...v3.2.0 120 | [3.1.3]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.1.2...v3.1.3 121 | [3.1.2]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.1.1...v3.1.2 122 | [3.1.1]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.1.0...v3.1.1 123 | [3.1.0]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.0.0...v3.1.0 124 | [3.0.0]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v2.2.0...v3.0.0 125 | [2.2.0]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v2.1.0...v2.2.0 126 | [2.1.0]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v2.0.0...v2.1.0 127 | [2.0.0]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v1.2.0...v2.0.0 128 | [1.2.0]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v1.1.0...v1.2.0 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 JP Erasmus 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stale While Revalidate Cache 2 | 3 | This small battle-tested TypeScript library is a storage-agnostic helper that implements a configurable stale-while-revalidate caching strategy for any functions, for any JavaScript environment. 4 | 5 | > The library will take care of deduplicating any function invocations (requests) for the same cache key so that making concurrent requests will not unnecessarily bypass your cache. 6 | 7 | ## Installation 8 | 9 | The library can be installed from [NPM](https://www.npmjs.com/package/stale-while-revalidate-cache) using your favorite package manager. 10 | 11 | To install via `npm`: 12 | 13 | ```sh 14 | npm install stale-while-revalidate-cache 15 | ``` 16 | 17 | ## Usage 18 | 19 | At the most basic level, you can import the exported `createStaleWhileRevalidateCache` function that takes some config and gives you back the cache helper. 20 | 21 | This cache helper (called `swr` in example below) is an asynchronous function that you can invoke whenever you want to run your cached function. This cache helper takes two arguments, a key to identify the resource in the cache, and the function that should be invoked to retrieve the data that you want to cache. (An optional third argument can be used to override the cache config for the specific invocation.) This function would typically fetch content from an external API, but it could be anything like some resource intensive computation that you don't want the user to wait for and a cache value would be acceptable. 22 | 23 | Invoking this `swr` function returns a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that resolves to an object of the following shape: 24 | 25 | ```typescript 26 | type ResponseObject = { 27 | /* The value is inferred from the async function passed to swr */ 28 | value: ReturnType 29 | /** 30 | * Indicates the cache status of the returned value: 31 | * 32 | * `fresh`: returned from cache without revalidating, ie. `cachedTime` < `minTimeToStale` 33 | * `stale`: returned from cache but revalidation running in background, ie. `minTimeToStale` < `cachedTime` < `maxTimeToLive` 34 | * `expired`: not returned from cache but fetched fresh from async function invocation, ie. `cachedTime` > `maxTimeToLive` 35 | * `miss`: no previous cache entry existed so waiting for response from async function before returning value 36 | */ 37 | status: 'fresh' | 'stale' | 'expired' | 'miss' 38 | /* `minTimeToStale` config value used (see configuration below) */ 39 | minTimeToStale: number 40 | /* `maxTimeToLive` config value used (see configuration below) */ 41 | maxTimeToLive: number 42 | /* Timestamp when function was invoked */ 43 | now: number 44 | /* Timestamp when value was cached */ 45 | cachedAt: number 46 | /* Timestamp when cache value will be stale */ 47 | staleAt: number 48 | /* Timestamp when cache value will expire */ 49 | expireAt: number 50 | } 51 | ``` 52 | 53 | The cache helper (`swr`) is also a fully functional event emitter, but more about that later. 54 | 55 | ```typescript 56 | import { createStaleWhileRevalidateCache } from 'stale-while-revalidate-cache' 57 | 58 | const swr = createStaleWhileRevalidateCache({ 59 | storage: window.localStorage, 60 | }) 61 | 62 | const cacheKey = 'a-cache-key' 63 | 64 | const result = await swr(cacheKey, async () => 'some-return-value') 65 | // result.value: 'some-return-value' 66 | 67 | const result2 = await swr(cacheKey, async () => 'some-other-return-value') 68 | // result2.value: 'some-return-value' <- returned from cache while revalidating to new value for next invocation 69 | 70 | const result3 = await swr(cacheKey, async () => 'yet-another-return-value') 71 | // result3.value: 'some-other-return-value' <- previous value (assuming it was already revalidated and cached by now) 72 | ``` 73 | 74 | ### Configuration 75 | 76 | The `createStaleWhileRevalidateCache` function takes a single config object, that you can use to configure how your stale-while-revalidate cache should behave. The only mandatory property is the `storage` property, which tells the library where the content should be persisted and retrieved from. 77 | 78 | You can also override any of the following configuration values when you call the actual `swr()` helper function by passing a partial config object as a third argument. For example: 79 | 80 | ```typescript 81 | const cacheKey = 'some-cache-key' 82 | const yourFunction = async () => ({ something: 'useful' }) 83 | const configOverrides = { 84 | maxTimeToLive: 30000, 85 | minTimeToStale: 3000, 86 | } 87 | 88 | const result = await swr(cacheKey, yourFunction, configOverrides) 89 | ``` 90 | 91 | #### storage 92 | 93 | The `storage` property can be any object that have `getItem(cacheKey: string)` and `setItem(cacheKey: string, value: any)` methods on it. If you want to use the `swr.delete(cacheKey)` method, the `storage` object needs to have a `removeItem(cacheKey: string)` method as well. Because of this, in the browser, you could simply use `window.localStorage` as your `storage` object, but there are many other storage options that satisfies this requirement. Or you can build your own. 94 | 95 | For instance, if you want to use Redis on the server: 96 | 97 | ```javascript 98 | import Redis from 'ioredis' 99 | import { createStaleWhileRevalidateCache } from 'stale-while-revalidate-cache' 100 | 101 | const redis = new Redis() 102 | 103 | const storage = { 104 | async getItem(cacheKey: string) { 105 | return redis.get(cacheKey) 106 | }, 107 | async setItem(cacheKey: string, cacheValue: any) { 108 | // Use px or ex depending on whether you use milliseconds or seconds for your ttl 109 | // It is recommended to set ttl to your maxTimeToLive (it has to be more than it) 110 | await redis.set(cacheKey, cacheValue, 'px', ttl) 111 | }, 112 | async removeItem(cacheKey: string) { 113 | await redis.del(cacheKey) 114 | }, 115 | } 116 | 117 | const swr = createStaleWhileRevalidateCache({ 118 | storage, 119 | }) 120 | ``` 121 | 122 | #### minTimeToStale 123 | 124 | Default: `0` 125 | 126 | Milliseconds until a cached value should be considered stale. If a cached value is fresher than the number of milliseconds, it is considered fresh and the task function is not invoked. 127 | 128 | #### maxTimeToLive 129 | 130 | Default: `Infinity` 131 | 132 | Milliseconds until a cached value should be considered expired. If a cached value is expired, it will be discarded and the task function will always be invoked and waited for before returning, ie. no background revalidation. 133 | 134 | #### retry 135 | 136 | Default: `false` (no retries) 137 | 138 | - `retry: true` will infinitely retry failing tasks. 139 | - `retry: false` will disable retries. 140 | - `retry: 5` will retry failing tasks 5 times before bubbling up the final error thrown by task function. 141 | - `retry: (failureCount: number, error: unknown) => ...` allows for custom logic based on why the task failed. 142 | 143 | #### retryDelay 144 | 145 | Default: `(invocationCount: number) => Math.min(1000 * 2 ** invocationCount, 30000)` 146 | 147 | The default configuration is set to double (starting at 1000ms) for each invocation, but not exceed 30 seconds. 148 | 149 | This setting has no effect if `retry` is `false`. 150 | 151 | - `retryDelay: 1000` will always wait 1000 milliseconds before retrying the task 152 | - `retryDelay: (invocationCount) => 1000 * 2 ** invocationCount` will infinitely double the retry delay time until the max number of retries is reached. 153 | 154 | #### serialize 155 | 156 | If your storage mechanism can't directly persist the value returned from your task function, supply a `serialize` method that will be invoked with the result from the task function and this will be persisted to your storage. 157 | 158 | A good example is if your task function returns an object, but you are using a storage mechanism like `window.localStorage` that is string-based. For that, you can set `serialize` to `JSON.stringify` and the object will be stringified before it is persisted. 159 | 160 | #### deserialize 161 | 162 | This property can optionally be provided if you want to deserialize a previously cached value before it is returned. 163 | 164 | To continue with the object value in `window.localStorage` example, you can set `deserialize` to `JSON.parse` and the serialized object will be parsed as a plain JavaScript object. 165 | 166 | ### Static Methods 167 | 168 | #### Manually persist to cache 169 | 170 | There is a convenience static method made available if you need to manually write to the underlying storage. This method is better than directly writing to the storage because it will ensure the necessary entries are made for timestamp invalidation. 171 | 172 | ```typescript 173 | const cacheKey = 'your-cache-key' 174 | const cacheValue = { something: 'useful' } 175 | 176 | const result = await swr.persist(cacheKey, cacheValue) 177 | ``` 178 | 179 | The value will be passed through the `serialize` method you optionally provided when you instantiated the `swr` helper. 180 | 181 | #### Manually read from cache 182 | 183 | There is a convenience static method made available if you need to simply read from the underlying storage without triggering revalidation. Sometimes you just want to know if there is a value in the cache for a given key. 184 | 185 | ```typescript 186 | const cacheKey = 'your-cache-key' 187 | 188 | const resultPayload = await swr.retrieve(cacheKey) 189 | ``` 190 | 191 | The cached value will be passed through the `deserialize` method you optionally provided when you instantiated the `swr` helper. 192 | 193 | #### Manually delete from cache 194 | 195 | There is a convenience static method made available if you need to manually delete a cache entry from the underlying storage. 196 | 197 | ```typescript 198 | const cacheKey = 'your-cache-key' 199 | 200 | await swr.delete(cacheKey) 201 | ``` 202 | 203 | The method returns a Promise that resolves or rejects depending on whether the delete was successful or not. 204 | 205 | ### Event Emitter 206 | 207 | The cache helper method returned from the `createStaleWhileRevalidateCache` function is a fully functional event emitter that is an instance of the excellent [Emittery](https://www.npmjs.com/package/emittery) package. Please look at the linked package's documentation to see all the available methods. 208 | 209 | The following events will be emitted when appropriate during the lifetime of the cache (all events will always include the `cacheKey` in its payload along with other event-specific properties): 210 | 211 | #### invoke 212 | 213 | Emitted when the cache helper is invoked with the cache key and function as payload. 214 | 215 | #### cacheHit 216 | 217 | Emitted when a fresh or stale value is found in the cache. It will not emit for expired cache values. When this event is emitted, this is the value that the helper will return, regardless of whether it will be revalidated or not. 218 | 219 | #### cacheExpired 220 | 221 | Emitted when a value was found in the cache, but it has expired. The payload will include the old `cachedValue` for your own reference. This cached value will not be used, but the task function will be invoked and waited for to provide the response. 222 | 223 | #### cacheStale 224 | 225 | Emitted when a value was found in the cache, but it is older than the allowed `minTimeToStale` and it has NOT expired. The payload will include the stale `cachedValue` and `cachedAge` for your own reference. 226 | 227 | #### cacheMiss 228 | 229 | Emitted when no value is found in the cache for the given key OR the cache has expired. This event can be used to capture the total number of cache misses. When this happens, the returned value is what is returned from your given task function. 230 | 231 | #### cacheGetFailed 232 | 233 | Emitted when an error occurs while trying to retrieve a value from the given `storage`, ie. if `storage.getItem()` throws. 234 | 235 | #### cacheSetFailed 236 | 237 | Emitted when an error occurs while trying to persist a value to the given `storage`, ie. if `storage.setItem()` throws. Cache persistence happens asynchronously, so you can't expect this error to bubble up to the main revalidate function. If you want to be aware of this error, you have to subscribe to this event. 238 | 239 | #### cacheInFlight 240 | 241 | Emitted when a duplicate function invocation occurs, ie. a new request is made while a previous one is not settled yet. 242 | 243 | #### cacheInFlightSettled 244 | 245 | Emitted when an in-flight request is settled (resolved or rejected). This event is emitted at the end of either a cache lookup or a revalidation request. 246 | 247 | #### revalidate 248 | 249 | Emitted whenever the task function is invoked. It will always be invoked except when the cache is considered fresh, NOT stale or expired. 250 | 251 | #### revalidateFailed 252 | 253 | Emitted whenever the revalidate function failed, whether that is synchronously when the cache is bypassed or asynchronously. 254 | 255 | ### Example 256 | 257 | A slightly more practical example. 258 | 259 | ```typescript 260 | import { 261 | createStaleWhileRevalidateCache, 262 | EmitterEvents, 263 | } from 'stale-while-revalidate-cache' 264 | import { metrics } from './utils/some-metrics-util.ts' 265 | 266 | const swr = createStaleWhileRevalidateCache({ 267 | storage: window.localStorage, // can be any object with getItem and setItem methods 268 | minTimeToStale: 5000, // 5 seconds 269 | maxTimeToLive: 600000, // 10 minutes 270 | serialize: JSON.stringify, // serialize product object to string 271 | deserialize: JSON.parse, // deserialize cached product string to object 272 | }) 273 | 274 | swr.onAny((event, payload) => { 275 | switch (event) { 276 | case EmitterEvents.invoke: 277 | metrics.countInvocations(payload.cacheKey) 278 | break 279 | 280 | case EmitterEvents.cacheHit: 281 | metrics.countCacheHit(payload.cacheKey, payload.cachedValue) 282 | break 283 | 284 | case EmitterEvents.cacheMiss: 285 | metrics.countCacheMisses(payload.cacheKey) 286 | break 287 | 288 | case EmitterEvents.cacheExpired: 289 | metrics.countCacheExpirations(payload) 290 | break 291 | 292 | case EmitterEvents.cacheGetFailed: 293 | case EmitterEvents.cacheSetFailed: 294 | metrics.countCacheErrors(payload) 295 | break 296 | 297 | case EmitterEvents.revalidateFailed: 298 | metrics.countRevalidationFailures(payload) 299 | break 300 | 301 | case EmitterEvents.revalidate: 302 | default: 303 | break 304 | } 305 | }) 306 | 307 | interface Product { 308 | id: string 309 | name: string 310 | description: string 311 | price: number 312 | } 313 | 314 | async function fetchProductDetails(productId: string): Promise { 315 | const response = await fetch(`/api/products/${productId}`) 316 | const product = (await response.json()) as Product 317 | return product 318 | } 319 | 320 | const productId = 'product-123456' 321 | 322 | const result = await swr(productId, async () => 323 | fetchProductDetails(productId) 324 | ) 325 | 326 | const product = result.value 327 | // The returned `product` will be typed as `Product` 328 | ``` 329 | 330 | ## Migrations 331 | 332 | ### Migrating from v2 to v3 333 | 334 | #### Return Type 335 | 336 | The main breaking change between v2 and v3 is that for v3, the `swr` function now returns a payload object with a `value` property whereas v2 returned this "value" property directly. 337 | 338 | **For v2** 339 | 340 | ```typescript 341 | const value = await swr('cacheKey', async () => 'cacheValue') 342 | ``` 343 | 344 | **For v3** 345 | 346 | > Notice the destructured object with the `value` property. The payload includes more properties you might be interested, like the cache `status`. 347 | 348 | ```typescript 349 | const { value, status } = await swr('cacheKey', async () => 'cacheValue') 350 | ``` 351 | 352 | #### Event Emitter property names 353 | 354 | For all events, like the `EmitterEvents.cacheExpired` event, the `cachedTime` property was renamed to `cachedAt`. 355 | 356 | #### Persist static method 357 | 358 | The `swr.persist()` method now throws an error if something goes wrong while writing to storage. Previously, this method only emitted the `EmitterEvents.cacheSetFailed` event and silently swallowed the error. 359 | 360 | ### Migrating from v1 to v2 361 | 362 | This was only a breaking change since support for Node.js v12 was dropped. If you are using a version newer than v12, this should be non-breaking for you. 363 | 364 | Otherwise, you will need to upgrade to a newer Node.js version to use v2. 365 | 366 | ## License 367 | 368 | MIT License 369 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3.4.0", 3 | "name": "stale-while-revalidate-cache", 4 | "license": "MIT", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "typings": "dist/index.d.ts", 8 | "files": [ 9 | "dist", 10 | "types" 11 | ], 12 | "engines": { 13 | "node": ">=14" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/jperasmus/stale-while-revalidate-cache.git" 18 | }, 19 | "author": { 20 | "name": "JP Erasmus", 21 | "email": "jperasmus11@gmail.com", 22 | "url": "https://github.com/jperasmus" 23 | }, 24 | "contributors": [ 25 | { 26 | "name": "Andreas Pålsson", 27 | "url": "https://github.com/andreaspalsson" 28 | }, 29 | { 30 | "name": "Andrew Tereshkin", 31 | "url": "https://github.com/upteran" 32 | }, 33 | { 34 | "name": "Paul Dixon", 35 | "url": "https://github.com/mintbridge" 36 | }, 37 | { 38 | "name": "Antonio Guerra", 39 | "url": "https://github.com/antonioguerra" 40 | } 41 | ], 42 | "scripts": { 43 | "clean": "rm -rf dist", 44 | "dev": "rollup -c -w", 45 | "prebuild": "npm run clean", 46 | "build": "rollup -c", 47 | "test": "jest", 48 | "test:watch": "jest --watch", 49 | "test:coverage": "jest --coverage", 50 | "prepare": "npm run build", 51 | "lint": "eslint . --ext .ts", 52 | "lint:fix": "npm run lint -- --fix" 53 | }, 54 | "husky": { 55 | "hooks": { 56 | "pre-commit": "npm run lint" 57 | } 58 | }, 59 | "devDependencies": { 60 | "@babel/core": "^7.22.5", 61 | "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.5", 62 | "@babel/preset-env": "^7.22.5", 63 | "@rollup/plugin-babel": "^6.0.3", 64 | "@rollup/plugin-commonjs": "^25.0.1", 65 | "@rollup/plugin-node-resolve": "^15.1.0", 66 | "@rollup/plugin-typescript": "^11.1.1", 67 | "@size-limit/preset-small-lib": "^8.1.0", 68 | "@types/jest": "^29.5.2", 69 | "@typescript-eslint/eslint-plugin": "^5.59.9", 70 | "@typescript-eslint/parser": "^5.59.9", 71 | "eslint": "^8.42.0", 72 | "eslint-config-prettier": "^8.8.0", 73 | "eslint-plugin-prettier": "^4.2.1", 74 | "husky": "^8.0.3", 75 | "jest": "^29.5.0", 76 | "np": "^8.0.3", 77 | "prettier": "^2.8.8", 78 | "rollup": "^3.25.0", 79 | "ts-jest": "^29.1.0", 80 | "tslib": "^2.5.3", 81 | "typescript": "^5.1.3" 82 | }, 83 | "dependencies": { 84 | "emittery": "^0.9.2" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import { nodeResolve } from '@rollup/plugin-node-resolve' 3 | import typescript from '@rollup/plugin-typescript' 4 | import babel from '@rollup/plugin-babel' 5 | import { createRequire } from 'node:module' 6 | 7 | const require = createRequire(import.meta.url) 8 | 9 | const pkg = require('./package.json') 10 | 11 | /** 12 | * @type {import('rollup').RollupOptions} 13 | */ 14 | const config = { 15 | input: 'src/index.ts', 16 | output: [ 17 | { 18 | file: pkg.main, 19 | format: 'cjs', 20 | sourcemap: true, 21 | }, 22 | { 23 | file: pkg.module, 24 | format: 'es', 25 | sourcemap: true, 26 | }, 27 | ], 28 | plugins: [ 29 | nodeResolve(), 30 | commonjs(), 31 | typescript(), 32 | babel({ 33 | babelHelpers: 'bundled', 34 | extensions: ['.ts'], 35 | presets: [ 36 | [ 37 | '@babel/preset-env', 38 | { 39 | targets: { 40 | node: '14', 41 | }, 42 | }, 43 | ], 44 | ], 45 | plugins: ['@babel/plugin-transform-nullish-coalescing-operator'], 46 | }), 47 | ], 48 | external: Object.keys(pkg.peerDependencies || {}), 49 | } 50 | 51 | export default config 52 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const EmitterEvents = { 2 | cacheHit: 'cacheHit', 3 | cacheMiss: 'cacheMiss', 4 | cacheStale: 'cacheStale', 5 | cacheExpired: 'cacheExpired', 6 | cacheGetFailed: 'cacheGetFailed', 7 | cacheRemoveFailed: 'cacheRemoveFailed', 8 | cacheSetFailed: 'cacheSetFailed', 9 | cacheInFlight: 'cacheInFlight', 10 | cacheInFlightSettled: 'cacheInFlightSettled', 11 | invoke: 'invoke', 12 | revalidate: 'revalidate', 13 | revalidateFailed: 'revalidateFailed', 14 | } as const 15 | 16 | export const CacheResponseStatus = { 17 | FRESH: 'fresh', 18 | STALE: 'stale', 19 | EXPIRED: 'expired', 20 | MISS: 'miss', 21 | } as const 22 | 23 | export const DefaultRetryDelay = { 24 | MIN_MS: 1000, 25 | MAX_MS: 30000, 26 | } 27 | -------------------------------------------------------------------------------- /src/event-emitter.ts: -------------------------------------------------------------------------------- 1 | import Emittery from 'emittery' 2 | 3 | export type EmitterMethods = typeof Emittery.prototype 4 | 5 | export const createEmitter = (): Emittery => { 6 | return new Emittery() 7 | } 8 | 9 | export const extendWithEmitterMethods = ( 10 | emitter: ReturnType, 11 | target: Target 12 | ): Target & EmitterMethods => { 13 | const extended = target as Target & EmitterMethods 14 | 15 | Object.getOwnPropertyNames(emitter.constructor.prototype).forEach((name) => { 16 | if (name !== 'constructor') { 17 | const methodName = name as keyof EmitterMethods 18 | extended[methodName] = (emitter[methodName] as any).bind(emitter) 19 | } 20 | }) 21 | 22 | return extended 23 | } 24 | -------------------------------------------------------------------------------- /src/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { isFunction, isPlainObject, parseConfig, passThrough } from './helpers' 2 | import { mockedLocalStorage } from './test-helpers' 3 | 4 | describe('isFunction', () => { 5 | it('should return true if the given value is a function', () => { 6 | expect(isFunction(() => {})).toBe(true) 7 | expect(isFunction(function () {})).toBe(true) 8 | expect(isFunction(async function () {})).toBe(true) 9 | // eslint-disable-next-line no-new-func 10 | expect(isFunction(new Function())).toBe(true) 11 | }) 12 | 13 | it('should return false if the given value is not a function', () => { 14 | expect(isFunction(null)).toBe(false) 15 | expect(isFunction(undefined)).toBe(false) 16 | expect(isFunction(0)).toBe(false) 17 | expect(isFunction('')).toBe(false) 18 | expect(isFunction({})).toBe(false) 19 | expect(isFunction([])).toBe(false) 20 | expect(isFunction(Symbol())).toBe(false) 21 | }) 22 | }) 23 | 24 | describe('isPlainObject', () => { 25 | it('should return true if the given value is a plain object', () => { 26 | expect(isPlainObject({})).toBe(true) 27 | // eslint-disable-next-line no-new-object 28 | expect(isPlainObject(new Object())).toBe(true) 29 | }) 30 | 31 | it('should return false if the given value is not a plain object', () => { 32 | expect(isPlainObject(null)).toBe(false) 33 | expect(isPlainObject(undefined)).toBe(false) 34 | expect(isPlainObject(0)).toBe(false) 35 | expect(isPlainObject('')).toBe(false) 36 | expect(isPlainObject(Symbol())).toBe(false) 37 | expect(isPlainObject([])).toBe(false) 38 | expect(isPlainObject(() => {})).toBe(false) 39 | expect(isPlainObject(async function () {})).toBe(false) 40 | // eslint-disable-next-line no-new-func 41 | expect(isPlainObject(new Function())).toBe(false) 42 | }) 43 | }) 44 | 45 | describe('passThrough', () => { 46 | it('should return the given value', () => { 47 | const noop = () => {} 48 | 49 | expect(passThrough(null)).toBe(null) 50 | expect(passThrough(undefined)).toBe(undefined) 51 | expect(passThrough(0)).toBe(0) 52 | expect(passThrough('')).toBe('') 53 | expect(passThrough({})).toEqual({}) 54 | expect(passThrough([])).toEqual([]) 55 | expect(passThrough(noop)).toEqual(noop) 56 | }) 57 | }) 58 | 59 | describe('parseConfig', () => { 60 | it('should throw an error if the given value is not a plain object', () => { 61 | const invalidConfigs = [ 62 | null, 63 | undefined, 64 | 0, 65 | '', 66 | Symbol(), 67 | [], 68 | async function () {}, 69 | // eslint-disable-next-line no-new-func 70 | new Function(), 71 | ] 72 | 73 | invalidConfigs.forEach((invalidConfig) => { 74 | // @ts-expect-error explicitly calling function with invalid config 75 | expect(() => parseConfig(invalidConfig)).toThrowError() 76 | }) 77 | }) 78 | 79 | it('should throw an error if a valid storage object is not provided', () => { 80 | const invalidConfigs = [ 81 | {}, 82 | { storage: null }, 83 | { storage: undefined }, 84 | { storage: 0 }, 85 | { storage: '' }, 86 | { storage: Symbol() }, 87 | { storage: [] }, 88 | { storage: async function () {} }, 89 | // eslint-disable-next-line no-new-func 90 | { storage: new Function() }, 91 | { storage: { getItem: null, setItem: null } }, 92 | ] 93 | 94 | invalidConfigs.forEach((invalidConfig) => { 95 | // @ts-expect-error explicitly calling function with invalid config 96 | expect(() => parseConfig(invalidConfig)).toThrowError() 97 | }) 98 | }) 99 | 100 | it('should throw an error if the minTimeToStale is greater or equal to maxTimeToLive', () => { 101 | const invalidConfigs = [ 102 | { storage: mockedLocalStorage, minTimeToStale: 10, maxTimeToLive: 5 }, 103 | { storage: mockedLocalStorage, minTimeToStale: 10, maxTimeToLive: 10 }, 104 | ] 105 | 106 | invalidConfigs.forEach((invalidConfig) => { 107 | expect(() => parseConfig(invalidConfig)).toThrowError() 108 | }) 109 | }) 110 | 111 | it('should set sensible defaults', () => { 112 | const config = parseConfig({ storage: mockedLocalStorage }) 113 | expect(config.storage).toBe(mockedLocalStorage) 114 | expect(config.minTimeToStale).toBe(0) 115 | expect(config.maxTimeToLive).toBe(Infinity) 116 | expect(config.serialize).toBe(passThrough) 117 | expect(config.deserialize).toBe(passThrough) 118 | }) 119 | 120 | it('should allow custom serialize and deserialize methods', () => { 121 | const customSerialize = jest.fn(() => 'serialized') 122 | const customDeserialize = jest.fn(() => 'deserialized') 123 | const config = parseConfig({ 124 | storage: mockedLocalStorage, 125 | serialize: customSerialize, 126 | deserialize: customDeserialize, 127 | }) 128 | expect(config.serialize).toBe(customSerialize) 129 | expect(config.deserialize).toBe(customDeserialize) 130 | }) 131 | }) 132 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { Config, IncomingCacheKey, RetryDelayFn, RetryFn } from '../types' 2 | import { DefaultRetryDelay } from './constants' 3 | 4 | type Fn = (...args: any[]) => any 5 | 6 | export const isFunction = (value: unknown): value is Fn => 7 | typeof value === 'function' 8 | 9 | type Nil = null | undefined 10 | 11 | export const isNil = (value: unknown): value is Nil => 12 | typeof value === 'undefined' || value === null 13 | 14 | export const isPlainObject = (value: unknown) => 15 | !!value && typeof value === 'object' && !Array.isArray(value) 16 | 17 | export const getCacheKey = (cacheKey: IncomingCacheKey) => 18 | isFunction(cacheKey) ? String(cacheKey()) : String(cacheKey) 19 | 20 | export const createTimeCacheKey = (cacheKey: string) => `${cacheKey}_time` 21 | 22 | export const passThrough = (value: unknown) => value 23 | 24 | export const waitFor = (ms: number) => 25 | new Promise((resolve) => setTimeout(resolve, ms)) 26 | 27 | const defaultRetryDelay: RetryDelayFn = (invocationCount) => 28 | Math.min( 29 | DefaultRetryDelay.MIN_MS * 2 ** invocationCount, 30 | DefaultRetryDelay.MAX_MS 31 | ) 32 | 33 | export function parseConfig(config: Config) { 34 | if (!isPlainObject(config)) { 35 | throw new Error('Config is required') 36 | } 37 | 38 | const storage = config.storage 39 | 40 | if ( 41 | !isPlainObject(storage) || 42 | !isFunction(storage.getItem) || 43 | !isFunction(storage.setItem) 44 | ) { 45 | throw new Error( 46 | 'Storage is required and should satisfy the Config["storage"] type' 47 | ) 48 | } 49 | 50 | const minTimeToStale = config.minTimeToStale || 0 51 | const maxTimeToLive = 52 | config.maxTimeToLive === Infinity 53 | ? Infinity 54 | : Math.min(config.maxTimeToLive ?? 0, Number.MAX_SAFE_INTEGER) || Infinity 55 | 56 | const retry: RetryFn = (failureCount, error) => { 57 | if (!config.retry) return false 58 | 59 | if (typeof config.retry === 'number') { 60 | return failureCount <= config.retry 61 | } 62 | 63 | if (isFunction(config.retry)) { 64 | return config.retry(failureCount, error) 65 | } 66 | 67 | return !!config.retry 68 | } 69 | const retryDelay: RetryDelayFn = (invocationCount) => { 70 | if (typeof config.retryDelay === 'number') { 71 | return config.retryDelay 72 | } 73 | 74 | if (isFunction(config.retryDelay)) { 75 | return config.retryDelay(invocationCount) 76 | } 77 | 78 | return defaultRetryDelay(invocationCount) 79 | } 80 | 81 | const serialize = isFunction(config.serialize) 82 | ? config.serialize 83 | : passThrough 84 | const deserialize = isFunction(config.deserialize) 85 | ? config.deserialize 86 | : passThrough 87 | 88 | if (minTimeToStale >= maxTimeToLive) { 89 | throw new Error('minTimeToStale must be less than maxTimeToLive') 90 | } 91 | 92 | return { 93 | storage, 94 | minTimeToStale, 95 | maxTimeToLive, 96 | retry, 97 | retryDelay, 98 | serialize, 99 | deserialize, 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { createStaleWhileRevalidateCache } from './index' 2 | import { createTimeCacheKey } from './helpers' 3 | import { mockedLocalStorage, valueFromEnvelope } from './test-helpers' 4 | import { EmitterEvents } from './constants' 5 | 6 | const validConfig = { 7 | storage: mockedLocalStorage, 8 | } 9 | 10 | describe('createStaleWhileRevalidateCache', () => { 11 | beforeEach(() => { 12 | mockedLocalStorage.clear() 13 | }) 14 | 15 | afterAll(() => { 16 | mockedLocalStorage.clear() 17 | }) 18 | 19 | describe('Config', () => { 20 | it(`should throw an error if the config is missing`, () => { 21 | // @ts-expect-error calling function without config 22 | expect(() => createStaleWhileRevalidateCache()).toThrow() 23 | }) 24 | 25 | it(`should create a stale while revalidate cache function`, () => { 26 | const swr = createStaleWhileRevalidateCache(validConfig) 27 | expect(swr).toEqual(expect.any(Function)) 28 | }) 29 | 30 | it('should allow overriding the cache config per invocation', async () => { 31 | const swr = createStaleWhileRevalidateCache(validConfig) 32 | const configOverrides = { 33 | minTimeToStale: 1000, 34 | maxTimeToLive: 2000, 35 | } 36 | const key = 'expired-config-override-example' 37 | const value1 = 'value 1' 38 | const value2 = 'value 2' 39 | const fn1 = jest.fn(() => value1) 40 | const fn2 = jest.fn(() => value2) 41 | const now = Date.now() 42 | const originalDateNow = Date.now 43 | 44 | Date.now = jest.fn(() => now - 3000) // 3 seconds back in time 45 | const envelope1 = await swr(key, fn1, configOverrides) 46 | 47 | Date.now = originalDateNow // Reset Date.now to original value so that cache for this key is expired 48 | const envelope2 = await swr(key, fn2, configOverrides) 49 | 50 | expect(valueFromEnvelope(envelope1)).toEqual(value1) 51 | expect(valueFromEnvelope(envelope2)).toEqual(value2) 52 | expect(fn1).toHaveBeenCalledTimes(1) 53 | expect(fn2).toHaveBeenCalledTimes(1) 54 | }) 55 | }) 56 | 57 | describe('Cache revalidation logic', () => { 58 | it('should invoke given function and persist to storage if not already freshly cached', async () => { 59 | const swr = createStaleWhileRevalidateCache(validConfig) 60 | const key = 'key' 61 | const value = 'value' 62 | const fn = jest.fn(async () => value) 63 | const result = await swr(key, async () => await fn()) 64 | 65 | expect(result).toMatchObject({ 66 | value, 67 | status: 'miss', 68 | minTimeToStale: 0, 69 | maxTimeToLive: Infinity, 70 | now: expect.any(Number), 71 | cachedAt: expect.any(Number), 72 | expireAt: Infinity, 73 | staleAt: expect.any(Number), 74 | }) 75 | expect(fn).toHaveBeenCalledTimes(1) 76 | expect(mockedLocalStorage.getItem(key)).toEqual(value) 77 | expect(mockedLocalStorage.getItem(createTimeCacheKey(key))).toEqual( 78 | expect.any(String) 79 | ) 80 | }) 81 | 82 | it('should invoke custom serializer and deserializer methods when reading from cache', async () => { 83 | const customSerialize = jest.fn(JSON.stringify) 84 | const customDeserialize = jest.fn(JSON.parse) 85 | const swr = createStaleWhileRevalidateCache({ 86 | ...validConfig, 87 | serialize: customSerialize, 88 | deserialize: customDeserialize, 89 | }) 90 | const key = 'key' 91 | const value = { value: 'value' } 92 | const fn = jest.fn(() => value) 93 | const result = await swr(key, fn) 94 | 95 | expect(result).toMatchObject({ 96 | value: JSON.parse(JSON.stringify(value)), 97 | status: 'miss', 98 | minTimeToStale: 0, 99 | maxTimeToLive: Infinity, 100 | now: expect.any(Number), 101 | cachedAt: expect.any(Number), 102 | expireAt: Infinity, 103 | staleAt: expect.any(Number), 104 | }) 105 | expect(fn).toHaveBeenCalledTimes(1) 106 | expect(customSerialize).toHaveBeenCalledTimes(1) 107 | expect(customDeserialize).toHaveBeenCalledTimes(0) 108 | expect(mockedLocalStorage.getItem(key)).toEqual(JSON.stringify(value)) 109 | 110 | const result2 = await swr(key, fn) 111 | 112 | expect(result2).toMatchObject({ 113 | value: JSON.parse(JSON.stringify(value)), 114 | status: 'stale', 115 | minTimeToStale: 0, 116 | maxTimeToLive: Infinity, 117 | now: expect.any(Number), 118 | cachedAt: expect.any(Number), 119 | expireAt: Infinity, 120 | staleAt: expect.any(Number), 121 | }) 122 | expect(fn).toHaveBeenCalledTimes(2) 123 | expect(customSerialize).toHaveBeenCalledTimes(2) 124 | expect(customDeserialize).toHaveBeenCalledTimes(1) 125 | expect(mockedLocalStorage.getItem(key)).toEqual(JSON.stringify(value)) 126 | }) 127 | 128 | it('should not invoke custom deserializer method when cache value of undefined returned', async () => { 129 | const customSerialize = jest.fn(JSON.stringify) 130 | const customDeserialize = jest.fn(JSON.parse) 131 | const swr = createStaleWhileRevalidateCache({ 132 | ...validConfig, 133 | storage: { 134 | ...validConfig.storage, 135 | getItem() { 136 | return undefined 137 | }, 138 | }, 139 | serialize: customSerialize, 140 | deserialize: customDeserialize, 141 | }) 142 | const key = 'key' 143 | const value = { value: 'value' } 144 | const fn = jest.fn(() => value) 145 | const result = await swr(key, fn) 146 | 147 | expect(result).toMatchObject({ 148 | value: JSON.parse(JSON.stringify(value)), 149 | status: 'miss', 150 | minTimeToStale: 0, 151 | maxTimeToLive: Infinity, 152 | now: expect.any(Number), 153 | cachedAt: expect.any(Number), 154 | expireAt: Infinity, 155 | staleAt: expect.any(Number), 156 | }) 157 | expect(fn).toHaveBeenCalledTimes(1) 158 | expect(customSerialize).toHaveBeenCalledTimes(1) 159 | expect(customDeserialize).toHaveBeenCalledTimes(0) 160 | expect(mockedLocalStorage.getItem(key)).toEqual(JSON.stringify(value)) 161 | }) 162 | 163 | it('should not revalidate if the value is cached and still fresh', async () => { 164 | // Set minTimeToStale to 1 second so that the cache is fresh for second invocation 165 | const swr = createStaleWhileRevalidateCache({ 166 | ...validConfig, 167 | minTimeToStale: 1000, 168 | }) 169 | const key = 'fresh-example' 170 | const value1 = 'value 1' 171 | const value2 = 'value 2' 172 | const fn1 = jest.fn(() => value1) 173 | const fn2 = jest.fn(() => value2) 174 | const result1 = await swr(key, fn1) 175 | const result2 = await swr(key, fn2) 176 | 177 | expect(result1).toMatchObject({ 178 | value: value1, 179 | status: 'miss', 180 | minTimeToStale: 1000, 181 | maxTimeToLive: Infinity, 182 | now: expect.any(Number), 183 | cachedAt: expect.any(Number), 184 | expireAt: Infinity, 185 | staleAt: expect.any(Number), 186 | }) 187 | expect(result2).toMatchObject({ 188 | value: value1, 189 | status: 'fresh', 190 | minTimeToStale: 1000, 191 | maxTimeToLive: Infinity, 192 | now: expect.any(Number), 193 | cachedAt: expect.any(Number), 194 | expireAt: Infinity, 195 | staleAt: expect.any(Number), 196 | }) 197 | expect(fn1).toHaveBeenCalledTimes(1) 198 | expect(fn2).not.toHaveBeenCalled() 199 | }) 200 | 201 | it('should return value from cache while revalidating the value in the background if cache is stale but not dead', async () => { 202 | // Explicitly set minTimeToStale to 0 and maxTimeToLive to Infinity so that the cache is always stale, but not dead for second invocation 203 | const swr = createStaleWhileRevalidateCache({ 204 | ...validConfig, 205 | minTimeToStale: 0, 206 | maxTimeToLive: Infinity, 207 | }) 208 | const key = 'stale-example' 209 | const value1 = 'value 1' 210 | const value2 = 'value 2' 211 | const fn1 = jest.fn(() => value1) 212 | const fn2 = jest.fn(() => value2) 213 | const result1 = await swr(key, fn1) 214 | const result2 = await swr(key, fn2) 215 | 216 | expect(result1).toMatchObject({ 217 | value: value1, 218 | status: 'miss', 219 | minTimeToStale: 0, 220 | maxTimeToLive: Infinity, 221 | now: expect.any(Number), 222 | cachedAt: expect.any(Number), 223 | expireAt: Infinity, 224 | staleAt: expect.any(Number), 225 | }) 226 | expect(result2).toMatchObject({ 227 | value: value1, // Still return value1 since it is from the cache 228 | status: 'stale', 229 | minTimeToStale: 0, 230 | maxTimeToLive: Infinity, 231 | now: expect.any(Number), 232 | cachedAt: expect.any(Number), 233 | expireAt: Infinity, 234 | staleAt: expect.any(Number), 235 | }) 236 | expect(fn1).toHaveBeenCalledTimes(1) 237 | expect(fn2).toHaveBeenCalledTimes(1) // But invoke the function to revalidate the value in the background 238 | }) 239 | 240 | it('should not return a value from cache if it has expired', async () => { 241 | const swr = createStaleWhileRevalidateCache({ 242 | ...validConfig, 243 | minTimeToStale: 1000, 244 | maxTimeToLive: 2000, 245 | }) 246 | const key = 'expired-example' 247 | const value1 = 'value 1' 248 | const value2 = 'value 2' 249 | const fn1 = jest.fn(() => value1) 250 | const fn2 = jest.fn(() => value2) 251 | const now = Date.now() 252 | const originalDateNow = Date.now 253 | 254 | Date.now = jest.fn(() => now - 3000) // 3 seconds back in time 255 | const result1 = await swr(key, fn1) 256 | 257 | Date.now = originalDateNow // Reset Date.now to original value so that cache for this key is expired 258 | const result2 = await swr(key, fn2) 259 | 260 | expect(result1).toMatchObject({ 261 | value: value1, 262 | status: 'miss', 263 | minTimeToStale: 1000, 264 | maxTimeToLive: 2000, 265 | now: expect.any(Number), 266 | cachedAt: expect.any(Number), 267 | expireAt: expect.any(Number), 268 | staleAt: expect.any(Number), 269 | }) 270 | expect(result2).toMatchObject({ 271 | value: value2, 272 | status: 'expired', 273 | minTimeToStale: 1000, 274 | maxTimeToLive: 2000, 275 | now: expect.any(Number), 276 | cachedAt: expect.any(Number), 277 | expireAt: expect.any(Number), 278 | staleAt: expect.any(Number), 279 | }) 280 | expect(fn1).toHaveBeenCalledTimes(1) 281 | expect(fn2).toHaveBeenCalledTimes(1) 282 | }) 283 | 284 | it('should deduplicate any concurrent requests with the same key', async () => { 285 | // Explicitly set minTimeToStale to 1_000 and maxTimeToLive to Infinity so that the cache is not stale, but also not dead for second invocation 286 | const swr = createStaleWhileRevalidateCache({ 287 | ...validConfig, 288 | minTimeToStale: 1_000, 289 | maxTimeToLive: Infinity, 290 | }) 291 | const key = 'duplicate-example' 292 | const value1 = 'value 1' 293 | const value2 = 'value 2' 294 | const fn1 = jest.fn(() => value1) 295 | const fn2 = jest.fn(() => value2) 296 | 297 | const promise1 = swr(key, fn1) 298 | const promise2 = swr(key, fn2) 299 | const [result1, result2] = await Promise.all([promise1, promise2]) 300 | 301 | expect(result1).toMatchObject({ 302 | value: value1, 303 | status: 'miss', 304 | minTimeToStale: 1_000, 305 | maxTimeToLive: Infinity, 306 | now: expect.any(Number), 307 | cachedAt: expect.any(Number), 308 | expireAt: Infinity, 309 | staleAt: expect.any(Number), 310 | }) 311 | expect(result2).toMatchObject({ 312 | value: value1, // Still return value1 since it is from the cache 313 | status: 'fresh', 314 | minTimeToStale: 1_000, 315 | maxTimeToLive: Infinity, 316 | now: expect.any(Number), 317 | cachedAt: expect.any(Number), 318 | expireAt: Infinity, 319 | staleAt: expect.any(Number), 320 | }) 321 | expect(fn1).toHaveBeenCalledTimes(1) 322 | expect(fn2).not.toHaveBeenCalled() 323 | }) 324 | 325 | describe('Retry', () => { 326 | it('should allow retrying the request using a number of retries', async () => { 327 | const swr = createStaleWhileRevalidateCache({ 328 | ...validConfig, 329 | minTimeToStale: 0, 330 | maxTimeToLive: Infinity, 331 | retry: 3, 332 | retryDelay: 0, 333 | }) 334 | const key = 'retry-example' 335 | const error = new Error('beep boop') 336 | const fn = jest.fn(() => { 337 | throw error 338 | }) 339 | 340 | expect.assertions(2) 341 | 342 | try { 343 | await swr(key, fn) 344 | 345 | fail('Expected swr to throw an error') 346 | } catch (err) { 347 | expect(err).toBe(error) 348 | } finally { 349 | expect(fn).toHaveBeenCalledTimes(4) // Initial invocation + 3 retries 350 | } 351 | }) 352 | }) 353 | 354 | it('should allow retrying the request using a custom retry function', async () => { 355 | const swr = createStaleWhileRevalidateCache({ 356 | ...validConfig, 357 | minTimeToStale: 0, 358 | maxTimeToLive: Infinity, 359 | retry: (failureCount, _error) => failureCount < 3, 360 | retryDelay: () => 10, 361 | }) 362 | const key = 'retry-example' 363 | const error = new Error('beep boop') 364 | const fn = jest.fn(() => { 365 | throw error 366 | }) 367 | 368 | expect.assertions(2) 369 | 370 | try { 371 | await swr(key, fn) 372 | 373 | fail('Expected swr to throw an error') 374 | } catch (err) { 375 | expect(err).toBe(error) 376 | } finally { 377 | expect(fn).toHaveBeenCalledTimes(3) // Initial invocation + 2 retries (testing failureCount < 3) 378 | } 379 | }) 380 | }) 381 | 382 | describe('EmitterEvents', () => { 383 | it(`should emit an '${EmitterEvents.revalidateFailed}' event if the cache is stale but not dead and the revalidation request fails`, () => { 384 | return new Promise(async (resolve) => { 385 | // Explicitly set minTimeToStale to 0 and maxTimeToLive to Infinity so that the cache is always stale, but not dead for second invocation 386 | const swr = createStaleWhileRevalidateCache({ 387 | ...validConfig, 388 | minTimeToStale: 0, 389 | maxTimeToLive: Infinity, 390 | }) 391 | const key = 'stale-example' 392 | const value1 = 'value 1' 393 | const error = new Error('beep boop') 394 | const fn1 = jest.fn(() => value1) 395 | const fn2 = jest.fn(() => { 396 | throw error 397 | }) 398 | 399 | const result1 = await swr(key, fn1) 400 | 401 | expect(result1).toMatchObject({ 402 | value: value1, 403 | status: 'miss', 404 | minTimeToStale: 0, 405 | maxTimeToLive: Infinity, 406 | now: expect.any(Number), 407 | cachedAt: expect.any(Number), 408 | expireAt: Infinity, 409 | staleAt: expect.any(Number), 410 | }) 411 | 412 | swr.once(EmitterEvents.revalidateFailed).then((payload) => { 413 | expect(payload).toEqual({ 414 | cacheKey: key, 415 | fn: fn2, 416 | error, 417 | }) 418 | resolve() 419 | }) 420 | 421 | const result2 = await swr(key, fn2) 422 | 423 | expect(result2).toMatchObject({ 424 | value: value1, // Still return value1 since it is from the cache 425 | status: 'stale', 426 | minTimeToStale: 0, 427 | maxTimeToLive: Infinity, 428 | now: expect.any(Number), 429 | cachedAt: expect.any(Number), 430 | expireAt: Infinity, 431 | staleAt: expect.any(Number), 432 | }) 433 | expect(fn1).toHaveBeenCalledTimes(1) 434 | expect(fn2).toHaveBeenCalledTimes(1) // But invoke the function to revalidate the value in the background 435 | }) 436 | }) 437 | 438 | it(`should emit an '${EmitterEvents.invoke}' event when called`, (done) => { 439 | const swr = createStaleWhileRevalidateCache(validConfig) 440 | const key = 'key' 441 | const value = 'value' 442 | const fn = jest.fn(() => value) 443 | 444 | swr.once(EmitterEvents.invoke).then((payload) => { 445 | expect(payload).toEqual({ 446 | cacheKey: key, 447 | fn, 448 | }) 449 | done() 450 | }) 451 | 452 | swr(key, fn) 453 | }) 454 | 455 | it(`should emit a '${EmitterEvents.cacheHit}' event when the value is found in the cache`, (done) => { 456 | const swr = createStaleWhileRevalidateCache({ 457 | ...validConfig, 458 | minTimeToStale: 10000, 459 | }) 460 | const key = () => 'key' 461 | const value = 'value' 462 | const fn = jest.fn(() => value) 463 | 464 | // Manually set the value in the cache 465 | swr.persist(key, value).then(() => { 466 | swr.once(EmitterEvents.cacheHit).then((payload) => { 467 | expect(payload).toEqual({ 468 | cacheKey: key, 469 | cachedValue: value, 470 | }) 471 | done() 472 | }) 473 | 474 | swr(key, fn) 475 | }) 476 | }) 477 | 478 | it(`should emit a '${EmitterEvents.cacheMiss}' event when the value is not found in the cache`, (done) => { 479 | const swr = createStaleWhileRevalidateCache(validConfig) 480 | const key = () => 'key' 481 | const value = 'value' 482 | const fn = jest.fn(() => value) 483 | 484 | swr.once(EmitterEvents.cacheMiss).then((payload) => { 485 | expect(payload).toEqual({ 486 | cacheKey: key, 487 | fn, 488 | }) 489 | done() 490 | }) 491 | 492 | swr(key, fn) 493 | }) 494 | 495 | it(`should emit '${EmitterEvents.cacheHit}', '${EmitterEvents.cacheStale}' and '${EmitterEvents.revalidate}' events when the cache is stale but not expired`, async () => { 496 | const swr = createStaleWhileRevalidateCache({ 497 | ...validConfig, 498 | minTimeToStale: 0, 499 | maxTimeToLive: Infinity, 500 | }) 501 | const key = 'key' 502 | const oldValue = 'old value' 503 | const value = 'value' 504 | const fn = jest.fn(() => value) 505 | 506 | const now = Date.now() 507 | const originalDateNow = Date.now 508 | Date.now = jest.fn(() => now) 509 | 510 | // Manually set the value in the cache 511 | await Promise.all([ 512 | validConfig.storage.setItem(key, oldValue), 513 | validConfig.storage.setItem( 514 | createTimeCacheKey(key), 515 | (now - 10000).toString() 516 | ), 517 | ]) 518 | 519 | const events: Record = {} 520 | 521 | swr.onAny((event: any, payload) => { 522 | events[event] = payload 523 | }) 524 | 525 | await swr(key, fn) 526 | 527 | Date.now = originalDateNow 528 | 529 | expect(events).toMatchInlineSnapshot(` 530 | { 531 | "cacheHit": { 532 | "cacheKey": "key", 533 | "cachedValue": "old value", 534 | }, 535 | "cacheStale": { 536 | "cacheKey": "key", 537 | "cachedAge": 10000, 538 | "cachedValue": "old value", 539 | }, 540 | "invoke": { 541 | "cacheKey": "key", 542 | "fn": [MockFunction] { 543 | "calls": [ 544 | [], 545 | ], 546 | "results": [ 547 | { 548 | "type": "return", 549 | "value": "value", 550 | }, 551 | ], 552 | }, 553 | }, 554 | "revalidate": { 555 | "cacheKey": "key", 556 | "fn": [MockFunction] { 557 | "calls": [ 558 | [], 559 | ], 560 | "results": [ 561 | { 562 | "type": "return", 563 | "value": "value", 564 | }, 565 | ], 566 | }, 567 | }, 568 | } 569 | `) 570 | }) 571 | 572 | it(`should emit '${EmitterEvents.cacheGetFailed}' event when an error is thrown when retrieving from the storage and continue as-if cache is expired`, (done) => { 573 | const error = new Error('storage read error') 574 | const swr = createStaleWhileRevalidateCache({ 575 | ...validConfig, 576 | storage: { 577 | ...validConfig.storage, 578 | getItem() { 579 | throw error 580 | }, 581 | }, 582 | minTimeToStale: 0, 583 | maxTimeToLive: Infinity, 584 | }) 585 | const key = () => 'storage-get-error' 586 | const value = 'value' 587 | const fn = jest.fn(() => value) 588 | 589 | expect.assertions(2) 590 | 591 | swr.once(EmitterEvents.cacheGetFailed).then((payload) => { 592 | expect(payload).toEqual({ 593 | cacheKey: key, 594 | error, 595 | }) 596 | done() 597 | }) 598 | 599 | swr(key, fn).then((result) => { 600 | expect(result).toMatchObject({ 601 | value, 602 | status: 'miss', 603 | }) 604 | }) 605 | }) 606 | 607 | it(`should emit '${EmitterEvents.cacheSetFailed}' event when an error is thrown when persisting to the storage`, (done) => { 608 | const error = new Error('storage persist error') 609 | const swr = createStaleWhileRevalidateCache({ 610 | ...validConfig, 611 | storage: { 612 | ...validConfig.storage, 613 | setItem() { 614 | throw error 615 | }, 616 | }, 617 | minTimeToStale: 0, 618 | maxTimeToLive: Infinity, 619 | }) 620 | const key = () => 'storage-set-error' 621 | const value = 'value' 622 | const fn = jest.fn(() => value) 623 | 624 | expect.assertions(2) 625 | 626 | swr.once(EmitterEvents.cacheSetFailed).then((payload) => { 627 | expect(payload).toEqual({ 628 | cacheKey: key, 629 | error, 630 | }) 631 | done() 632 | }) 633 | 634 | swr(key, fn).then((result) => { 635 | expect(result).toMatchObject({ 636 | value, 637 | status: 'miss', 638 | }) 639 | }) 640 | }) 641 | }) 642 | 643 | describe('swr.persist()', () => { 644 | it('should persist given cache value for given key including the time cache key', async () => { 645 | const swr = createStaleWhileRevalidateCache(validConfig) 646 | 647 | const key = 'persist key' 648 | const value = 'value' 649 | 650 | expect(mockedLocalStorage.getItem(key)).toEqual(null) 651 | expect(mockedLocalStorage.getItem(createTimeCacheKey(key))).toEqual(null) 652 | 653 | await swr.persist(key, value) 654 | 655 | expect(mockedLocalStorage.getItem(key)).toEqual(value) 656 | expect(mockedLocalStorage.getItem(createTimeCacheKey(key))).toEqual( 657 | expect.any(String) 658 | ) 659 | 660 | const fn = jest.fn(() => 'something else') 661 | const result = await swr(key, fn) 662 | 663 | expect(result).toMatchObject({ 664 | value, 665 | status: 'stale', 666 | minTimeToStale: 0, 667 | maxTimeToLive: Infinity, 668 | now: expect.any(Number), 669 | cachedAt: expect.any(Number), 670 | expireAt: Infinity, 671 | staleAt: expect.any(Number), 672 | }) 673 | expect(fn).toHaveBeenCalledTimes(1) 674 | }) 675 | }) 676 | 677 | describe('swr.delete()', () => { 678 | it('should remove the cache value for given key including the time cache key', async () => { 679 | const swr = createStaleWhileRevalidateCache(validConfig) 680 | 681 | const key = 'delete key' 682 | const value = 'value' 683 | 684 | expect(mockedLocalStorage.getItem(key)).toEqual(null) 685 | expect(mockedLocalStorage.getItem(createTimeCacheKey(key))).toEqual(null) 686 | 687 | await swr.persist(key, value) 688 | 689 | expect(mockedLocalStorage.getItem(key)).toEqual(value) 690 | expect(mockedLocalStorage.getItem(createTimeCacheKey(key))).toEqual( 691 | expect.any(String) 692 | ) 693 | 694 | await swr.delete(key) 695 | 696 | expect(mockedLocalStorage.getItem(key)).toEqual(null) 697 | expect(mockedLocalStorage.getItem(createTimeCacheKey(key))).toEqual(null) 698 | }) 699 | }) 700 | 701 | describe('swr.retrieve()', () => { 702 | it('should return the cache value and metadata for given key', async () => { 703 | const swr = createStaleWhileRevalidateCache(validConfig) 704 | 705 | const key = 'retrieve key' 706 | const value = 'value' 707 | 708 | const now = Date.now() 709 | 710 | // Manually set the value in the cache 711 | await Promise.all([ 712 | validConfig.storage.setItem(key, value), 713 | validConfig.storage.setItem( 714 | createTimeCacheKey(key), 715 | (now + 10000).toString() // 10 seconds in the future 716 | ), 717 | ]) 718 | 719 | const result = await swr.retrieve(key) 720 | 721 | expect(result).toMatchObject({ 722 | cachedValue: value, 723 | cachedAge: expect.any(Number), 724 | cachedAt: expect.any(Number), 725 | now: expect.any(Number), 726 | }) 727 | }) 728 | 729 | it('should return null for cachedValue if the cache value is not found', async () => { 730 | const swr = createStaleWhileRevalidateCache(validConfig) 731 | 732 | const key = 'retrieve missing key' 733 | 734 | const result = await swr.retrieve(key) 735 | 736 | expect(result).toMatchObject({ 737 | cachedValue: null, 738 | cachedAge: 0, 739 | now: expect.any(Number), 740 | }) 741 | }) 742 | }) 743 | }) 744 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CacheStatus, 3 | Config, 4 | IncomingCacheKey, 5 | ResponseEnvelope, 6 | RetrieveCachedValueResponse, 7 | StaleWhileRevalidateCache, 8 | StaticMethods, 9 | } from '../types' 10 | import { CacheResponseStatus, EmitterEvents } from './constants' 11 | import { 12 | EmitterMethods, 13 | extendWithEmitterMethods, 14 | createEmitter, 15 | } from './event-emitter' 16 | import { 17 | createTimeCacheKey, 18 | getCacheKey, 19 | isNil, 20 | parseConfig, 21 | waitFor, 22 | } from './helpers' 23 | 24 | export { EmitterEvents } 25 | 26 | export type StaleWhileRevalidate = StaleWhileRevalidateCache & 27 | EmitterMethods & 28 | StaticMethods 29 | 30 | export function createStaleWhileRevalidateCache( 31 | config: Config 32 | ): StaleWhileRevalidate { 33 | const cacheConfig = parseConfig(config) 34 | const emitter = createEmitter() 35 | const inFlightKeys = new Set() 36 | 37 | async function deleteValue({ 38 | cacheKey, 39 | storage, 40 | }: { 41 | cacheKey: IncomingCacheKey 42 | storage: Config['storage'] 43 | }): Promise { 44 | const key = getCacheKey(cacheKey) 45 | const timeKey = createTimeCacheKey(key) 46 | 47 | try { 48 | if (!storage.removeItem) { 49 | throw new Error('Storage does not support removeItem method') 50 | } 51 | 52 | await Promise.all([storage.removeItem(key), storage.removeItem(timeKey)]) 53 | } catch (error) { 54 | emitter.emit(EmitterEvents.cacheRemoveFailed, { cacheKey, error }) 55 | throw error 56 | } 57 | } 58 | 59 | async function persistValue({ 60 | cacheKey, 61 | cacheValue, 62 | cacheTime, 63 | serialize, 64 | storage, 65 | }: { 66 | cacheKey: IncomingCacheKey 67 | cacheValue: CacheValue 68 | cacheTime: number 69 | serialize: NonNullable 70 | storage: Config['storage'] 71 | }): Promise { 72 | const key = getCacheKey(cacheKey) 73 | const timeKey = createTimeCacheKey(key) 74 | 75 | try { 76 | await Promise.all([ 77 | storage.setItem(key, serialize(cacheValue)), 78 | storage.setItem(timeKey, cacheTime.toString()), 79 | ]) 80 | } catch (error) { 81 | emitter.emit(EmitterEvents.cacheSetFailed, { cacheKey, error }) 82 | throw error 83 | } 84 | } 85 | 86 | async function retrieveValue({ 87 | cacheKey, 88 | storage, 89 | deserialize, 90 | }: { 91 | cacheKey: IncomingCacheKey 92 | storage: Config['storage'] 93 | deserialize: NonNullable 94 | }): Promise> { 95 | const now = Date.now() 96 | const key = getCacheKey(cacheKey) 97 | const timeKey = createTimeCacheKey(key) 98 | 99 | try { 100 | const [cachedValue, cachedAt] = await Promise.all([ 101 | storage.getItem(key), 102 | storage.getItem(timeKey), 103 | ]) 104 | 105 | if ( 106 | isNil(cachedValue) || 107 | isNil(cachedAt) || 108 | Number.isNaN(Number(cachedAt)) 109 | ) { 110 | return { cachedValue: null, cachedAge: 0, now } 111 | } 112 | 113 | return { 114 | cachedValue: deserialize(cachedValue) as CacheValue | null, 115 | cachedAge: now - Number(cachedAt), 116 | cachedAt: Number(cachedAt), 117 | now, 118 | } 119 | } catch (error) { 120 | emitter.emit(EmitterEvents.cacheGetFailed, { cacheKey, error }) 121 | throw error 122 | } 123 | } 124 | 125 | async function staleWhileRevalidate( 126 | cacheKey: IncomingCacheKey, 127 | fn: () => CacheValue | Promise, 128 | configOverrides?: Partial 129 | ): Promise>> { 130 | const { 131 | storage, 132 | minTimeToStale, 133 | maxTimeToLive, 134 | serialize, 135 | deserialize, 136 | retry, 137 | retryDelay, 138 | } = configOverrides 139 | ? parseConfig({ ...cacheConfig, ...configOverrides }) 140 | : cacheConfig 141 | emitter.emit(EmitterEvents.invoke, { cacheKey, fn }) 142 | 143 | const key = getCacheKey(cacheKey) 144 | const timeKey = createTimeCacheKey(key) 145 | 146 | let invocationCount = 0 147 | let cacheStatus: CacheStatus = CacheResponseStatus.MISS 148 | 149 | if (inFlightKeys.has(key)) { 150 | emitter.emit(EmitterEvents.cacheInFlight, { key, cacheKey }) 151 | 152 | let inFlightListener: 153 | | ((eventData: Record<'key', string>) => void) 154 | | null = null 155 | 156 | await new Promise((resolve) => { 157 | inFlightListener = (eventData: Record<'key', string>) => { 158 | if (eventData.key === key) { 159 | resolve(eventData) 160 | } 161 | } 162 | 163 | emitter.on(EmitterEvents.cacheInFlightSettled, inFlightListener) 164 | }) 165 | 166 | if (inFlightListener) { 167 | emitter.off(EmitterEvents.cacheInFlightSettled, inFlightListener) 168 | } 169 | } 170 | 171 | inFlightKeys.add(key) 172 | 173 | async function retrieveCachedValue(): Promise< 174 | RetrieveCachedValueResponse 175 | > { 176 | const now = Date.now() 177 | 178 | try { 179 | // eslint-disable-next-line prefer-const 180 | let [cachedValue, cachedAt] = await Promise.all([ 181 | storage.getItem(key), 182 | storage.getItem(timeKey), 183 | ]) 184 | 185 | if (isNil(cachedValue) || isNil(cachedAt)) { 186 | return { cachedValue: null, cachedAge: 0, now } 187 | } 188 | 189 | cachedValue = deserialize(cachedValue) 190 | 191 | const cachedAge = now - Number(cachedAt) 192 | 193 | if (cachedAge > maxTimeToLive) { 194 | cacheStatus = CacheResponseStatus.EXPIRED 195 | emitter.emit(EmitterEvents.cacheExpired, { 196 | cacheKey, 197 | cachedAge, 198 | cachedAt, 199 | cachedValue, 200 | maxTimeToLive, 201 | }) 202 | cachedValue = null 203 | } 204 | 205 | return { cachedValue, cachedAge, cachedAt: Number(cachedAt), now } 206 | } catch (error) { 207 | emitter.emit(EmitterEvents.cacheGetFailed, { cacheKey, error }) 208 | return { cachedValue: null, cachedAge: 0, now } 209 | } 210 | } 211 | 212 | async function revalidate({ cacheTime }: { cacheTime: number }) { 213 | try { 214 | if (invocationCount === 0) { 215 | emitter.emit(EmitterEvents.revalidate, { cacheKey, fn }) 216 | inFlightKeys.add(key) 217 | } 218 | 219 | invocationCount++ 220 | 221 | let result: Awaited 222 | 223 | try { 224 | result = await fn() 225 | } catch (error) { 226 | if (!retry(invocationCount, error)) { 227 | throw error 228 | } 229 | 230 | const delay = retryDelay(invocationCount) 231 | 232 | await waitFor(delay) 233 | 234 | return revalidate({ cacheTime }) 235 | } 236 | 237 | // Error handled in `persistValue` by emitting an event, so only need a no-op here 238 | await persistValue({ 239 | cacheValue: result, 240 | cacheKey, 241 | cacheTime, 242 | serialize, 243 | storage, 244 | }).catch(() => {}) 245 | 246 | return result 247 | } catch (error) { 248 | emitter.emit(EmitterEvents.revalidateFailed, { cacheKey, fn, error }) 249 | throw error 250 | } finally { 251 | inFlightKeys.delete(key) 252 | emitter.emit(EmitterEvents.cacheInFlightSettled, { cacheKey, key }) 253 | } 254 | } 255 | 256 | const { cachedValue, cachedAge, cachedAt, now } = 257 | await retrieveCachedValue() 258 | 259 | if (!isNil(cachedValue) && !isNil(cachedAt)) { 260 | cacheStatus = CacheResponseStatus.FRESH 261 | emitter.emit(EmitterEvents.cacheHit, { cacheKey, cachedValue }) 262 | 263 | if (cachedAge >= minTimeToStale) { 264 | cacheStatus = CacheResponseStatus.STALE 265 | emitter.emit(EmitterEvents.cacheStale, { 266 | cacheKey, 267 | cachedValue, 268 | cachedAge, 269 | }) 270 | // Non-blocking so that revalidation runs while stale cache data is returned 271 | // Error handled in `revalidate` by emitting an event, so only need a no-op here 272 | revalidate({ cacheTime: Date.now() }).catch(() => {}) 273 | } else { 274 | // When it is a pure cache hit, we are not revalidating, so we can remove the key from the in-flight set 275 | inFlightKeys.delete(key) 276 | emitter.emit(EmitterEvents.cacheInFlightSettled, { cacheKey, key }) 277 | } 278 | 279 | return { 280 | cachedAt, 281 | expireAt: cachedAt + maxTimeToLive, 282 | maxTimeToLive, 283 | minTimeToStale, 284 | now, 285 | staleAt: cachedAt + minTimeToStale, 286 | status: cacheStatus, 287 | value: cachedValue as Awaited, 288 | } 289 | } 290 | 291 | emitter.emit(EmitterEvents.cacheMiss, { cacheKey, fn }) 292 | 293 | const revalidateCacheTime = Date.now() 294 | const result = await revalidate({ cacheTime: revalidateCacheTime }) 295 | 296 | return { 297 | cachedAt: revalidateCacheTime, 298 | expireAt: revalidateCacheTime + maxTimeToLive, 299 | maxTimeToLive, 300 | minTimeToStale, 301 | now: revalidateCacheTime, 302 | staleAt: revalidateCacheTime + minTimeToStale, 303 | status: cacheStatus, 304 | value: result, 305 | } 306 | } 307 | 308 | const del: StaticMethods['delete'] = (cacheKey: IncomingCacheKey) => { 309 | return deleteValue({ 310 | cacheKey, 311 | storage: cacheConfig.storage, 312 | }) 313 | } 314 | 315 | const persist: StaticMethods['persist'] = ( 316 | cacheKey: IncomingCacheKey, 317 | cacheValue: CacheValue 318 | ) => { 319 | return persistValue({ 320 | cacheKey, 321 | cacheValue, 322 | cacheTime: Date.now(), 323 | serialize: cacheConfig.serialize, 324 | storage: cacheConfig.storage, 325 | }) 326 | } 327 | 328 | const retrieve: StaticMethods['retrieve'] = async ( 329 | cacheKey: IncomingCacheKey 330 | ) => { 331 | return retrieveValue({ 332 | cacheKey, 333 | storage: cacheConfig.storage, 334 | deserialize: cacheConfig.deserialize, 335 | }) 336 | } 337 | 338 | staleWhileRevalidate.delete = del 339 | staleWhileRevalidate.persist = persist 340 | staleWhileRevalidate.retrieve = retrieve 341 | 342 | return extendWithEmitterMethods(emitter, staleWhileRevalidate) 343 | } 344 | -------------------------------------------------------------------------------- /src/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import type { ResponseEnvelope, Storage } from '../types' 2 | 3 | export const mockedLocalStorage: Storage = (function () { 4 | const store = new Map() 5 | 6 | return { 7 | clear() { 8 | store.clear() 9 | }, 10 | getItem(key: string) { 11 | return store.has(key) ? store.get(key) : null 12 | }, 13 | removeItem(key: string) { 14 | return store.delete(key) 15 | }, 16 | setItem(key: string, value: any) { 17 | store.set(key, value.toString()) 18 | }, 19 | } 20 | })() 21 | 22 | export const valueFromEnvelope = ( 23 | envelope: ResponseEnvelope 24 | ): Value => envelope.value 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "exclude": ["dist"], 5 | "compilerOptions": { 6 | "module": "esnext", 7 | "lib": ["dom", "esnext"], 8 | "importHelpers": true, 9 | // output .d.ts declaration files for consumers 10 | "declaration": true, 11 | "declarationDir": ".", 12 | // output .js.map sourcemap files for consumers 13 | "sourceMap": true, 14 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 15 | "rootDir": "./src", 16 | // stricter type-checking for stronger correctness. Recommended by TS 17 | "strict": true, 18 | // linter checks for common issues 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | // use Node's module resolution algorithm, instead of the legacy TS one 25 | "moduleResolution": "node", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | "target": "esnext" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface Storage { 2 | getItem(key: string): unknown | null | Promise 3 | setItem(key: string, value: unknown): void | Promise 4 | removeItem?: (key: string) => unknown | null | Promise 5 | [key: string]: any 6 | } 7 | 8 | export type RetryFn = (failureCount: number, error?: unknown) => boolean 9 | export type Retry = boolean | number | RetryFn 10 | export type RetryDelayFn = (invocationCount: number) => number 11 | export type RetryDelay = number | RetryDelayFn 12 | 13 | export interface Config { 14 | minTimeToStale?: number 15 | maxTimeToLive?: number 16 | storage: Storage 17 | retry?: Retry 18 | retryDelay?: RetryDelay 19 | serialize?: (value: any) => any 20 | deserialize?: (value: any) => any 21 | } 22 | 23 | export type IncomingCacheKey = string | (() => string) 24 | 25 | export type CacheStatus = 'fresh' | 'stale' | 'expired' | 'miss' 26 | 27 | export type RetrieveCachedValueResponse = { 28 | cachedValue: CacheValue | null 29 | cachedAge: number 30 | cachedAt?: number 31 | now: number 32 | } 33 | 34 | export type ResponseEnvelope = { 35 | value: CacheValue 36 | status: CacheStatus 37 | minTimeToStale: number 38 | maxTimeToLive: number 39 | now: number 40 | cachedAt: number 41 | expireAt: number 42 | staleAt: number 43 | } 44 | 45 | export type StaleWhileRevalidateCache = ( 46 | cacheKey: IncomingCacheKey, 47 | fn: () => CacheValue | Promise, 48 | configOverrides?: Partial 49 | ) => Promise>> 50 | 51 | export type StaticMethods = { 52 | delete: (cacheKey: IncomingCacheKey) => Promise 53 | retrieve: ( 54 | cacheKey: IncomingCacheKey 55 | ) => Promise> 56 | persist: ( 57 | cacheKey: IncomingCacheKey, 58 | cacheValue: CacheValue 59 | ) => Promise 60 | } 61 | --------------------------------------------------------------------------------