├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── eslint.config.js ├── index.js ├── package.json ├── test ├── cache.test.js └── headers.test.js └── types ├── index.d.ts └── index.test-d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | trim_trailing_whitespace = true 11 | 12 | # [*.md] 13 | # trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 15 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - "discussion" 8 | - "feature request" 9 | - "bug" 10 | - "help wanted" 11 | - "plugin suggestion" 12 | - "good first issue" 13 | # Label to use when marking an issue as stale 14 | staleLabel: stale 15 | # Comment to post when marking an issue as stale. Set to `false` to disable 16 | markComment: > 17 | This issue has been automatically marked as stale because it has not had 18 | recent activity. It will be closed if no further activity occurs. Thank you 19 | for your contributions. 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 26 | with: 27 | license-check: true 28 | lint: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ 153 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 James Sumners 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fastify/caching 2 | 3 | [![CI](https://github.com/fastify/fastify-caching/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-caching/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/caching.svg?style=flat)](https://www.npmjs.com/package/@fastify/caching) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | *@fastify/caching* is a plugin for the [Fastify](http://fastify.dev/) framework 8 | that provides server-side caching and mechanisms for manipulating HTTP cache headers according to 9 | [RFC 2616 §14.9](https://tools.ietf.org/html/rfc2616#section-14.9). 10 | 11 | This plugin fully supports Fastify's encapsulation. Therefore, routes that 12 | should have differing cache settings should be registered within different 13 | contexts. 14 | 15 | In addition to providing header manipulation, the plugin also decorates the 16 | server instance with an object that can be used for caching items. **Note:** 17 | the default cache should not be used in a "production" environment. It is 18 | an LRU, in-memory cache that is capped at 100,000 items. It is *highly* 19 | recommended that a full-featured cache object be supplied, e.g. 20 | [abstract-cache-redis][acache-redis]. 21 | 22 | [acache-redis]: https://www.npmjs.com/package/abstract-cache-redis 23 | 24 | ## Install 25 | ``` 26 | npm i @fastify/caching 27 | ``` 28 | 29 | ### Compatibility 30 | | Plugin version | Fastify version | 31 | | ---------------|-----------------| 32 | | `>=9.x` | `^5.x` | 33 | | `^8.x` | `^4.x` | 34 | | `>=6.x <8.x` | `^3.x` | 35 | | `>=4.x <6.x` | `^2.x` | 36 | | `>=1.x <4.x` | `^1.x` | 37 | 38 | 39 | Please note that if a Fastify version is out of support, then so are the corresponding versions of this plugin 40 | in the table above. 41 | See [Fastify's LTS policy](https://github.com/fastify/fastify/blob/main/docs/Reference/LTS.md) for more details. 42 | 43 | ## Example 44 | 45 | This example shows using the plugin to disable client-side caching of all 46 | routes. 47 | 48 | ```js 49 | const http = require('node:http') 50 | const fastify = require('fastify')() 51 | const fastifyCaching = require('@fastify/caching') 52 | 53 | fastify.register( 54 | fastifyCaching, 55 | {privacy: fastifyCaching.privacy.NOCACHE}, 56 | (err) => { if (err) throw err } 57 | ) 58 | 59 | fastify.get('/', (req, reply) => { 60 | reply.send({hello: 'world'}) 61 | }) 62 | 63 | fastify.listen({ port: 3000 }, (err) => { 64 | if (err) throw err 65 | 66 | http.get('http://127.0.0.1:3000/', (res) => { 67 | console.log(res.headers['cache-control']) 68 | }) 69 | }) 70 | ``` 71 | 72 | This example shows how to register the plugin such that it only provides 73 | a server-local cache. It will not set any cache control headers on responses. 74 | It also shows how to retain a reference to the cache object so that it can 75 | be re-used. 76 | 77 | ```js 78 | const IORedis = require('ioredis') 79 | const redis = new IORedis({host: '127.0.0.1'}) 80 | const abcache = require('abstract-cache')({ 81 | useAwait: false, 82 | driver: { 83 | name: 'abstract-cache-redis', // must be installed via `npm i` 84 | options: {client: redis} 85 | } 86 | }) 87 | 88 | const fastify = require('fastify')() 89 | fastify 90 | .register(require('@fastify/redis'), {client: redis}) 91 | .register(require('@fastify/caching'), {cache: abcache}) 92 | 93 | fastify.get('/', (req, reply) => { 94 | fastify.cache.set('hello', {hello: 'world'}, 10000, (err) => { 95 | if (err) return reply.send(err) 96 | reply.send({hello: 'world'}) 97 | }) 98 | }) 99 | 100 | fastify.listen({ port: 3000 }, (err) => { 101 | if (err) throw err 102 | }) 103 | ``` 104 | 105 | ## API 106 | 107 | ### Options 108 | 109 | *@fastify/caching* accepts the options object: 110 | 111 | ```js 112 | { 113 | privacy: 'value', 114 | expiresIn: 300, 115 | cache: {get, set}, 116 | cacheSegment: 'segment-name' 117 | } 118 | ``` 119 | 120 | + `privacy` (Default: `undefined`): can be set to any string that is valid 121 | for a *cache-response-directive* as defined by RFC 2616. 122 | + `expiresIn` (Default: `undefined`): a value, in seconds, for the *max-age* the 123 | resource may be cached. When this is set, and `privacy` is not set to `no-cache`, 124 | then `', max-age='` will be appended to the `cache-control` header. 125 | + `cache` (Default: `abstract-cache.memclient`): an [abstract-cache][acache] 126 | protocol-compliant cache object. Note: the plugin requires a cache instance to 127 | properly support the ETag mechanism. Therefore, if a falsy value is supplied 128 | the default will be used. 129 | + `cacheSegment` (Default: `'@fastify/caching'`): segment identifier to use when 130 | communicating with the cache. 131 | + `serverExpiresIn` (Default: `undefined`): a value, in seconds, for the length of time the resource is fresh and may be held in a shared cache (e.g. a CDN). Shared caches will ignore max-age when this is specified, though browsers will continue to use max-age. Should be used with expiresIn, not in place of it. When this is set, and `privacy` is set to `public`, then `', s-maxage='` will be appended to the `cache-control` header. 132 | 133 | [acache]: https://www.npmjs.com/package/abstract-cache 134 | 135 | ### `reply.etag(string, number)` 136 | 137 | This method allows setting of the `etag` header. It accepts any arbitrary 138 | string. Be sure to supply a string that is valid for HTTP headers. 139 | 140 | If a tag string is not supplied then [uid-safe][uid-safe] will be used to 141 | generate a tag. This operation will be performed ***synchronously***. It is 142 | recommended to always supply a value to this method to avoid this operation. 143 | 144 | All incoming requests to paths affected by this plugin will be inspected for 145 | the `if-none-match` header. If the header is present, the value will be used 146 | to lookup the tag within the cache associated with this plugin. If the tag is 147 | found, then the response will end with a 304 status code sent to 148 | the client. 149 | 150 | Tags will be cached according to the second parameter. If the second parameter 151 | is supplied, and it is an integer, then it will be used as the time to cache 152 | the etag for generating 304 responses. The time must be specified in 153 | milliseconds. The default lifetime, when the parameter is not specified, is 154 | `3600000`. 155 | 156 | [uid-safe]: https://www.npmjs.com/package/uid-safe 157 | 158 | ### `reply.expires(date)` 159 | 160 | This method allows setting of the `expires` header. It accepts a regular `Date` 161 | object or a string that is a valid date string according to 162 | [RFC 2616 §14.21][sec14.21]. 163 | 164 | [sec14.21]: https://tools.ietf.org/html/rfc2616#section-14.21 165 | 166 | ## License 167 | 168 | Licensed under [MIT](./LICENSE). 169 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* eslint no-prototype-builtins: 0 */ 4 | 5 | const fp = require('fastify-plugin') 6 | const { sync } = require('uid-safe') 7 | const abstractCache = require('abstract-cache') 8 | 9 | const defaultOptions = { 10 | expiresIn: undefined, 11 | serverExpiresIn: undefined, 12 | privacy: undefined, 13 | cache: undefined, 14 | cacheSegment: 'fastify-caching' 15 | } 16 | 17 | function cachingExpires (date) { 18 | if (!date) return this 19 | this.header('Expires', (Date.prototype.isPrototypeOf(date)) ? date.toUTCString() : date) 20 | return this 21 | } 22 | 23 | function etag (value, lifetime) { 24 | this.header('ETag', value || sync(18)) 25 | this._etagLife = Number.isInteger(lifetime) ? lifetime : 3600000 26 | return this 27 | } 28 | 29 | function cachingLoadByEtag (req, res, next) { 30 | if (!req.headers['if-none-match']) return next() 31 | const etag = req.headers['if-none-match'] 32 | this.cache.get({ id: etag, segment: this.cacheSegment }, (err, cached) => { 33 | if (err) return next(err) 34 | if (cached?.item) { 35 | return res.status(304).send() 36 | } 37 | next() 38 | }) 39 | } 40 | 41 | function cachingStoreByEtag (_req, res, payload, next) { 42 | const etag = res.getHeader('etag') 43 | if (!etag || !res._etagLife) return next() 44 | this.cache.set( 45 | { id: etag, segment: this.cacheSegment }, 46 | true, 47 | res._etagLife, 48 | (err) => next(err, payload) 49 | ) 50 | } 51 | 52 | function fastifyCaching (instance, options, next) { 53 | let _options 54 | if (Function.prototype.isPrototypeOf(options)) { 55 | _options = Object.assign({}, defaultOptions) 56 | } else { 57 | _options = Object.assign({}, defaultOptions, options) 58 | } 59 | 60 | if (!_options.cache) _options.cache = abstractCache() 61 | 62 | if (_options.privacy) { 63 | // https://tools.ietf.org/html/rfc2616#section-14.9.4 64 | let value = _options.privacy 65 | if (_options.privacy.toLowerCase() !== 'no-cache' && _options.expiresIn) { 66 | value = `${_options.privacy}, max-age=${_options.expiresIn}` 67 | } 68 | 69 | if (_options.privacy !== undefined && _options.privacy.toLowerCase() === 'public' && _options.serverExpiresIn) { 70 | value += `, s-maxage=${_options.serverExpiresIn}` 71 | } 72 | 73 | instance.addHook('onRequest', function cachingSetCacheControlHeader (_req, res, next) { 74 | if (!res.hasHeader('Cache-control')) { 75 | res.header('Cache-control', value) 76 | } 77 | next() 78 | }) 79 | } 80 | 81 | instance.decorate('cache', _options.cache) 82 | instance.decorate('cacheSegment', _options.cacheSegment) 83 | instance.decorate('etagMaxLife', _options.etagMaxLife) 84 | instance.decorateReply('etag', etag) 85 | instance.decorateReply('expires', cachingExpires) 86 | instance.addHook('onRequest', cachingLoadByEtag) 87 | instance.addHook('onSend', cachingStoreByEtag) 88 | 89 | instance[Symbol.for('fastify-caching.registered')] = true 90 | next() 91 | } 92 | 93 | module.exports = fp(fastifyCaching, { 94 | fastify: '5.x', 95 | name: '@fastify/caching' 96 | }) 97 | module.exports.default = fastifyCaching 98 | module.exports.fastifyCaching = fastifyCaching 99 | 100 | module.exports.privacy = { 101 | NOCACHE: 'no-cache', 102 | PUBLIC: 'public', 103 | PRIVATE: 'private' 104 | } 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/caching", 3 | "version": "9.0.3", 4 | "description": "A plugin for Fastify to enable management of cache control headers", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "types": "types/index.d.ts", 8 | "scripts": { 9 | "lint": "eslint", 10 | "lint:fix": "eslint --fix", 11 | "test": "npm run test:unit && npm run test:typescript", 12 | "test:typescript": "tsd", 13 | "test:unit": "c8 --100 node --test", 14 | "test:unit:report": "npm run test:unit -- --coverage-report=html", 15 | "test:unit:verbose": "npm run test:unit -- -Rspec" 16 | }, 17 | "precommit": [ 18 | "lint", 19 | "test" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "git+ssh://git@github.com/fastify/fastify-caching.git" 24 | }, 25 | "keywords": [ 26 | "fastify", 27 | "cache", 28 | "caching" 29 | ], 30 | "author": "James Sumners ", 31 | "contributors": [ 32 | { 33 | "name": "Matteo Collina", 34 | "email": "hello@matteocollina.com" 35 | }, 36 | { 37 | "name": "Manuel Spigolon", 38 | "email": "behemoth89@gmail.com" 39 | }, 40 | { 41 | "name": "Aras Abbasi", 42 | "email": "aras.abbasi@gmail.com" 43 | }, 44 | { 45 | "name": "Frazer Smith", 46 | "email": "frazer.dev@icloud.com", 47 | "url": "https://github.com/fdawgs" 48 | } 49 | ], 50 | "license": "MIT", 51 | "bugs": { 52 | "url": "https://github.com/fastify/fastify-caching/issues" 53 | }, 54 | "homepage": "https://github.com/fastify/fastify-caching#readme", 55 | "funding": [ 56 | { 57 | "type": "github", 58 | "url": "https://github.com/sponsors/fastify" 59 | }, 60 | { 61 | "type": "opencollective", 62 | "url": "https://opencollective.com/fastify" 63 | } 64 | ], 65 | "devDependencies": { 66 | "@fastify/pre-commit": "^2.1.0", 67 | "@types/node": "^22.0.0", 68 | "c8": "^10.1.2", 69 | "eslint": "^9.17.0", 70 | "fastify": "^5.0.0", 71 | "neostandard": "^0.12.0", 72 | "tsd": "^0.32.0" 73 | }, 74 | "dependencies": { 75 | "abstract-cache": "^1.0.1", 76 | "fastify-plugin": "^5.0.0", 77 | "uid-safe": "^2.1.5" 78 | }, 79 | "publishConfig": { 80 | "access": "public" 81 | }, 82 | "pre-commit": [ 83 | "lint", 84 | "test" 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /test/cache.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const plugin = require('..') 5 | 6 | const Fastify = require('fastify') 7 | const { setTimeout: sleep } = require('node:timers/promises') 8 | 9 | test('cache property gets added to instance', async (t) => { 10 | t.plan(2) 11 | 12 | const fastify = Fastify() 13 | await fastify.register(plugin) 14 | await fastify.ready() 15 | 16 | t.assert.ok(fastify.cache) 17 | t.assert.ok(fastify.cache.set) 18 | }) 19 | 20 | test('cache is usable', async (t) => { 21 | t.plan(4) 22 | 23 | const fastify = Fastify() 24 | await fastify.register(async (instance) => { 25 | instance.addHook('onRequest', async function checkCachingRegistered () { 26 | t.assert.ifError(instance[Symbol.for('fastify-caching.registered')]) 27 | }) 28 | }) 29 | await fastify.register(plugin) 30 | 31 | fastify.addHook('onRequest', async function checkCachingRegistered () { 32 | t.assert.strictEqual(this[Symbol.for('fastify-caching.registered')], true) 33 | }) 34 | 35 | fastify.get('/one', (_req, reply) => { 36 | fastify.cache.set('one', { one: true }, 1000, (err) => { 37 | if (err) return reply.send(err) 38 | reply.redirect('/two') 39 | }) 40 | }) 41 | 42 | fastify.get('/two', (_req, reply) => { 43 | fastify.cache.get('one', (err, obj) => { 44 | t.assert.ifError(err) 45 | t.assert.strictEqual(obj.item, { one: true }) 46 | reply.send() 47 | }) 48 | }) 49 | 50 | await fastify.ready() 51 | 52 | const response = await fastify.inject({ 53 | method: 'GET', 54 | path: '/one' 55 | }) 56 | 57 | if ( 58 | response.statusCode > 300 && 59 | response.statusCode < 400 && 60 | response.headers.location 61 | ) { 62 | await fastify.inject({ 63 | method: 'GET', 64 | path: response.headers.location 65 | }) 66 | } 67 | }) 68 | 69 | test('cache is usable with function as plugin default options input', async (t) => { 70 | t.plan(4) 71 | 72 | const fastify = Fastify() 73 | await fastify.register(async (instance) => { 74 | instance.addHook('onRequest', async function checkCachingNotRegistered () { 75 | t.assert.failure(instance[Symbol.for('fastify-caching.registered')]) 76 | }) 77 | }) 78 | await fastify.register(plugin, () => () => {}) 79 | 80 | fastify.addHook('onRequest', async function checkCachingRegistered () { 81 | t.assert.strictEqual(this[Symbol.for('fastify-caching.registered')], true) 82 | }) 83 | 84 | fastify.get('/one', (_req, reply) => { 85 | fastify.cache.set('one', { one: true }, 1000, (err) => { 86 | if (err) return reply.send(err) 87 | reply.redirect('/two') 88 | }) 89 | }) 90 | 91 | fastify.get('/two', (_req, reply) => { 92 | fastify.cache.get('one', (err, obj) => { 93 | t.assert.ifError(err) 94 | t.assert.strictEqual(obj.item, { one: true }) 95 | 96 | reply.send() 97 | }) 98 | }) 99 | 100 | await fastify.ready() 101 | 102 | const response = await fastify.inject({ 103 | method: 'GET', 104 | path: '/one' 105 | }) 106 | 107 | if ( 108 | response.statusCode > 300 && 109 | response.statusCode < 400 && 110 | response.headers.location 111 | ) { 112 | await fastify.inject({ 113 | method: 'GET', 114 | path: response.headers.location 115 | }) 116 | } 117 | }) 118 | 119 | test('getting cache item with error returns error', async (t) => { 120 | t.plan(1) 121 | 122 | const mockCache = { 123 | get: (_info, callback) => callback(new Error('cache.get always errors')), 124 | set: (_key, _value, _ttl, callback) => callback() 125 | } 126 | 127 | const fastify = Fastify() 128 | await fastify.register(plugin, { cache: mockCache }) 129 | 130 | fastify.get('/one', (_req, reply) => { 131 | fastify.cache.set('one', { one: true }, 1000, (err) => { 132 | if (err) return reply.send(err) 133 | return reply.etag('123456').send({ hello: 'world' }) 134 | }) 135 | }) 136 | 137 | fastify.get('/two', () => { 138 | fastify.cache.get('one', (err, obj) => { 139 | t.assert.failure(err) 140 | t.assert.failure(obj) 141 | }) 142 | }) 143 | 144 | await fastify.ready() 145 | 146 | await fastify.inject({ 147 | method: 'GET', 148 | path: '/one' 149 | }) 150 | 151 | const response = await fastify.inject({ 152 | method: 'GET', 153 | path: '/two', 154 | headers: { 155 | 'if-none-match': '123456' 156 | } 157 | }) 158 | t.assert.strictEqual(response.statusCode, 500) 159 | }) 160 | 161 | test('etags get stored in cache', async (t) => { 162 | t.plan(1) 163 | 164 | const fastify = Fastify() 165 | await fastify.register(plugin) 166 | 167 | fastify.get('/one', (_req, reply) => { 168 | reply.etag('123456').send({ hello: 'world' }) 169 | }) 170 | 171 | await fastify.ready() 172 | 173 | await fastify.inject({ 174 | method: 'GET', 175 | path: '/one' 176 | }) 177 | 178 | const response = await fastify.inject({ 179 | method: 'GET', 180 | path: '/one', 181 | headers: { 182 | 'if-none-match': '123456' 183 | } 184 | }) 185 | t.assert.strictEqual(response.statusCode, 304) 186 | }) 187 | 188 | test('etag cache life is customizable', async (t) => { 189 | t.plan(4) 190 | 191 | const fastify = Fastify() 192 | await fastify.register(plugin) 193 | 194 | fastify.get('/one', function (_req, reply) { 195 | reply 196 | // We set a cache lifetime of 50 milliseconds 197 | .etag('123456', 50) 198 | .send({ hello: 'world' }) 199 | }) 200 | 201 | fastify.ready((err) => { 202 | t.assert.ifError(err) 203 | 204 | fastify.inject( 205 | { 206 | method: 'GET', 207 | path: '/one' 208 | }, 209 | (err, _response) => { 210 | t.assert.ifError(err) 211 | 212 | // We wait 70 milliseconds that the cache expires 213 | setTimeout(() => { 214 | fastify.inject( 215 | { 216 | method: 'GET', 217 | path: '/one', 218 | headers: { 219 | 'if-none-match': '123456' 220 | } 221 | }, 222 | (err, response) => { 223 | t.assert.ifError(err) 224 | t.assert.strictEqual(response.statusCode, 200) 225 | } 226 | ) 227 | }, 70) 228 | } 229 | ) 230 | }) 231 | await sleep(100) 232 | }) 233 | 234 | test('returns response payload', async (t) => { 235 | t.plan(1) 236 | 237 | const fastify = Fastify() 238 | await fastify.register(plugin) 239 | 240 | fastify.get('/one', (_req, reply) => { 241 | reply.etag('123456', 300).send({ hello: 'world' }) 242 | }) 243 | 244 | await fastify.ready() 245 | 246 | await fastify.inject({ 247 | method: 'GET', 248 | path: '/one' 249 | }) 250 | 251 | const response = await fastify.inject({ 252 | method: 'GET', 253 | path: '/one' 254 | }) 255 | 256 | t.assert.deepStrictEqual(JSON.parse(response.payload), { hello: 'world' }) 257 | }) 258 | -------------------------------------------------------------------------------- /test/headers.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const plugin = require('..') 6 | 7 | test('decorators get added', async (t) => { 8 | t.plan(1) 9 | 10 | const fastify = Fastify() 11 | await fastify.register(plugin) 12 | 13 | fastify.get('/', (_req, reply) => { 14 | t.assert.ok(reply.etag) 15 | reply.send() 16 | }) 17 | 18 | await fastify.ready() 19 | 20 | await fastify.inject({ 21 | method: 'GET', 22 | path: '/' 23 | }) 24 | }) 25 | 26 | test('decorators add headers', async (t) => { 27 | t.plan(2) 28 | 29 | const tag = '123456' 30 | 31 | const fastify = Fastify() 32 | await fastify.register(plugin) 33 | 34 | fastify.get('/', (_req, reply) => { 35 | reply.etag(tag).send() 36 | }) 37 | 38 | await fastify.ready() 39 | 40 | const response = await fastify.inject({ 41 | method: 'GET', 42 | path: '/' 43 | }) 44 | t.assert.ok(response.headers.etag) 45 | t.assert.strictEqual(response.headers.etag, tag) 46 | }) 47 | 48 | test('sets etag header for falsy argument', async (t) => { 49 | t.plan(1) 50 | 51 | const fastify = Fastify() 52 | await fastify.register(plugin) 53 | 54 | fastify.get('/', (_req, reply) => { 55 | reply.etag().send() 56 | }) 57 | 58 | await fastify.ready() 59 | 60 | const response = await fastify.inject({ 61 | method: 'GET', 62 | path: '/' 63 | }) 64 | t.assert.ok(response.headers.etag) 65 | }) 66 | 67 | test('sets no-cache header', async (t) => { 68 | t.plan(2) 69 | 70 | const fastify = Fastify() 71 | await fastify.register(plugin, { privacy: plugin.privacy.NOCACHE }) 72 | 73 | fastify.get('/', (_req, reply) => { 74 | reply.send({ hello: 'world' }) 75 | }) 76 | 77 | await fastify.ready() 78 | 79 | const response = await fastify.inject({ 80 | method: 'GET', 81 | path: '/' 82 | }) 83 | t.assert.ok(response.headers['cache-control']) 84 | t.assert.strictEqual(response.headers['cache-control'], 'no-cache') 85 | }) 86 | 87 | test('sets private with max-age header', async (t) => { 88 | t.plan(2) 89 | 90 | const opts = { 91 | privacy: plugin.privacy.PRIVATE, 92 | expiresIn: 300 93 | } 94 | 95 | const fastify = Fastify() 96 | await fastify.register(plugin, opts) 97 | 98 | fastify.get('/', (_req, reply) => { 99 | reply.send({ hello: 'world' }) 100 | }) 101 | 102 | await fastify.ready() 103 | 104 | const response = await fastify.inject({ 105 | method: 'GET', 106 | path: '/' 107 | }) 108 | t.assert.ok(response.headers['cache-control']) 109 | t.assert.strictEqual( 110 | response.headers['cache-control'], 111 | 'private, max-age=300' 112 | ) 113 | }) 114 | 115 | test('sets public with max-age and s-maxage header', async (t) => { 116 | t.plan(2) 117 | 118 | const opts = { 119 | privacy: plugin.privacy.PUBLIC, 120 | expiresIn: 300, 121 | serverExpiresIn: 12345 122 | } 123 | 124 | const fastify = Fastify() 125 | await fastify.register(plugin, opts) 126 | 127 | fastify.get('/', (_req, reply) => { 128 | reply.send({ hello: 'world' }) 129 | }) 130 | 131 | await fastify.ready() 132 | 133 | const response = await fastify.inject({ 134 | method: 'GET', 135 | path: '/' 136 | }) 137 | t.assert.ok(response.headers['cache-control']) 138 | t.assert.strictEqual( 139 | response.headers['cache-control'], 140 | 'public, max-age=300, s-maxage=12345' 141 | ) 142 | }) 143 | 144 | test('do not set headers if another upstream plugin already sets it', async (t) => { 145 | t.plan(2) 146 | 147 | const opts = { 148 | privacy: plugin.privacy.PUBLIC, 149 | expiresIn: 300, 150 | serverExpiresIn: 12345 151 | } 152 | 153 | const fastify = Fastify() 154 | fastify.addHook('onRequest', async function checkCachingDoesNotOverrideCacheControlHeader (_req, reply) { 155 | reply.header('cache-control', 'do not override') 156 | }) 157 | await fastify.register(plugin, opts) 158 | 159 | fastify.get('/', (_req, reply) => { 160 | reply.send({ hello: 'world' }) 161 | }) 162 | 163 | await fastify.ready() 164 | 165 | const response = await fastify.inject({ 166 | method: 'GET', 167 | path: '/' 168 | }) 169 | t.assert.ok(response.headers['cache-control']) 170 | t.assert.strictEqual(response.headers['cache-control'], 'do not override') 171 | }) 172 | 173 | test('only sets max-age and ignores s-maxage with private header', async (t) => { 174 | t.plan(2) 175 | 176 | const opts = { 177 | privacy: plugin.privacy.PRIVATE, 178 | expiresIn: 300, 179 | serverExpiresIn: 12345 180 | } 181 | 182 | const fastify = Fastify() 183 | await fastify.register(plugin, opts) 184 | 185 | fastify.get('/', (_req, reply) => { 186 | reply.send({ hello: 'world' }) 187 | }) 188 | 189 | await fastify.ready() 190 | 191 | const response = await fastify.inject({ 192 | method: 'GET', 193 | path: '/' 194 | }) 195 | t.assert.ok(response.headers['cache-control']) 196 | t.assert.strictEqual( 197 | response.headers['cache-control'], 198 | 'private, max-age=300' 199 | ) 200 | }) 201 | 202 | test('s-maxage is optional with public header', async (t) => { 203 | t.plan(2) 204 | 205 | const opts = { 206 | privacy: plugin.privacy.PUBLIC, 207 | expiresIn: 300 208 | } 209 | 210 | const fastify = Fastify() 211 | await fastify.register(plugin, opts) 212 | 213 | fastify.get('/', (_req, reply) => { 214 | reply.send({ hello: 'world' }) 215 | }) 216 | 217 | await fastify.ready() 218 | 219 | const response = await fastify.inject({ 220 | method: 'GET', 221 | path: '/' 222 | }) 223 | t.assert.ok(response.headers['cache-control']) 224 | t.assert.strictEqual(response.headers['cache-control'], 'public, max-age=300') 225 | }) 226 | 227 | test('sets no-store with max-age header', async (t) => { 228 | t.plan(2) 229 | 230 | const fastify = Fastify() 231 | await fastify.register(plugin, { privacy: 'no-store', expiresIn: 300 }) 232 | 233 | fastify.get('/', (_req, reply) => { 234 | reply.send({ hello: 'world' }) 235 | }) 236 | 237 | await fastify.ready() 238 | 239 | const response = await fastify.inject({ 240 | method: 'GET', 241 | path: '/' 242 | }) 243 | t.assert.ok(response.headers['cache-control']) 244 | t.assert.strictEqual( 245 | response.headers['cache-control'], 246 | 'no-store, max-age=300' 247 | ) 248 | }) 249 | 250 | test('sets the expires header', async (t) => { 251 | t.plan(2) 252 | 253 | const now = new Date() 254 | 255 | const fastify = Fastify() 256 | await fastify.register(plugin, { privacy: plugin.privacy.NOCACHE }) 257 | 258 | fastify.get('/', (_req, reply) => { 259 | reply.expires(now).send({ hello: 'world' }) 260 | }) 261 | 262 | await fastify.ready() 263 | 264 | const response = await fastify.inject({ 265 | method: 'GET', 266 | path: '/' 267 | }) 268 | t.assert.ok(response.headers.expires) 269 | t.assert.strictEqual(response.headers.expires, now.toUTCString()) 270 | }) 271 | 272 | test('sets the expires header to a falsy value', async (t) => { 273 | t.plan(1) 274 | 275 | const fastify = Fastify() 276 | await fastify.register(plugin, { privacy: plugin.privacy.NOCACHE }) 277 | 278 | fastify.get('/', (_req, reply) => { 279 | reply.expires().send({ hello: 'world' }) 280 | }) 281 | 282 | await fastify.ready() 283 | 284 | const response = await fastify.inject({ 285 | method: 'GET', 286 | path: '/' 287 | }) 288 | t.assert.ifError(response.headers.expires) 289 | }) 290 | 291 | test('sets the expires header to a custom value', async (t) => { 292 | t.plan(2) 293 | 294 | const fastify = Fastify() 295 | await fastify.register(plugin, { privacy: plugin.privacy.NOCACHE }) 296 | 297 | fastify.get('/', (_req, reply) => { 298 | reply.expires('foobar').send({ hello: 'world' }) 299 | }) 300 | 301 | await fastify.ready() 302 | 303 | const response = await fastify.inject({ 304 | method: 'GET', 305 | path: '/' 306 | }) 307 | t.assert.ok(response.headers.expires) 308 | t.assert.strictEqual(response.headers.expires, 'foobar') 309 | }) 310 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { FastifyPluginCallback } from 'fastify' 4 | 5 | declare module 'fastify' { 6 | interface FastifyInstance { 7 | cache: fastifyCaching.AbstractCacheCompliantObject; 8 | cacheSegment: string; 9 | etagMaxLife: number | undefined; 10 | } 11 | 12 | interface FastifyReply { 13 | /** 14 | * This method allows setting of the `expires` header. 15 | * 16 | * @link [reply.expires() documentation](https://github.com/fastify/fastify-caching#replyexpiresdate) 17 | * 18 | * @param date A regular `Date` object, or a valid date string according to [RFC 2616 section 14.21](https://datatracker.ietf.org/doc/html/rfc2616#section-14.21). 19 | */ 20 | expires(date?: Date): this; 21 | 22 | /** 23 | * This method allows setting of the `etag` header. 24 | * 25 | * @link [reply.etag() documentation](https://github.com/fastify/fastify-caching#replyetagstring-number) 26 | * 27 | * @param tag Any arbitrary string that is valid for HTTP headers. 28 | * @param timeToLive The time must be specified in milliseconds. The default lifetime, when the parameter is not specified, is `3600000`. 29 | */ 30 | etag(tag?: string, timeToLive?: number): this; 31 | } 32 | } 33 | 34 | type FastifyCaching = FastifyPluginCallback & { 35 | privacy: fastifyCaching.Privacy; 36 | } 37 | 38 | type CacheResult = { 39 | item: T, 40 | stored: number, 41 | ttl: number, 42 | } | null 43 | 44 | declare namespace fastifyCaching { 45 | /** 46 | * @link [`abstract-cache` protocol documentation](https://github.com/jsumners/abstract-cache#protocol) 47 | */ 48 | export interface AbstractCacheCompliantObject { 49 | get( 50 | key: string | { id: string; segment: string }, 51 | callback: (error: unknown, result: CacheResult) => void 52 | ): void; 53 | /** 54 | * If AbstractCache is using useAwait = true, then this method-header must be used. 55 | * @param key 56 | */ 57 | get( 58 | key: string | { id: string; segment: string }, 59 | ): Promise>; 60 | set( 61 | key: string | { id: string; segment: string }, 62 | value: unknown, 63 | timeToLive: number, 64 | callback?: (error: unknown, result: unknown) => void 65 | ): void; 66 | } 67 | 68 | export interface Privacy { 69 | NOCACHE: 'no-cache'; 70 | PRIVATE: 'private'; 71 | PUBLIC: 'public'; 72 | } 73 | 74 | /** 75 | * @link [`fastify-caching` options documentation](https://github.com/fastify/fastify-caching#options) 76 | */ 77 | export interface FastifyCachingPluginOptions { 78 | /** 79 | * An [abstract-cache](https://www.npmjs.com/package/abstract-cache) protocol compliant cache object. 80 | * Note: the plugin requires a cache instance to properly support the ETag mechanism. 81 | * Therefore, if a falsy value is supplied the default will be used. 82 | * 83 | * - Default value: `abstract-cache.memclient` 84 | */ 85 | cache?: AbstractCacheCompliantObject; 86 | 87 | /** 88 | * The segment identifier to use when communicating with the cache. 89 | * 90 | * - Default value: `fastify-caching` 91 | */ 92 | cacheSegment?: string; 93 | 94 | etagMaxLife?: number; 95 | 96 | /** 97 | * A value, in seconds, for the max-age the resource may be cached. 98 | * When this is set, and privacy is not set to no-cache, then ', max-age=' 99 | * will be appended to the cache-control header. 100 | * 101 | * - Default value: `undefined` 102 | */ 103 | expiresIn?: number; 104 | 105 | /** 106 | * It can be set to any string that is valid for a cache-response-directive as 107 | * defined by [RFC 2616](https://datatracker.ietf.org/doc/html/rfc2616#section-14.9). 108 | * 109 | * - Default value: `undefined` 110 | * 111 | * @link [MDN Cache-Control - Response Directives](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#response_directives) 112 | * 113 | * @example 114 | * const fastifyCaching = require('fastify-caching'); 115 | * 116 | * // Disabling client side caching of all routes. 117 | * fastify.register(fastifyCaching, { privacy: fastifyCaching.privacy.NOCACHE }); 118 | */ 119 | privacy?: string; 120 | 121 | /** 122 | * A value, in seconds, for the length of time the resource is fresh and may be 123 | * held in a shared cache (e.g. a CDN). Shared caches will ignore max-age when 124 | * this is specified, though browsers will continue to use max-age. Should be 125 | * used with expiresIn, not in place of it. When this is set, and privacy is set 126 | * to public, then ', s-maxage=' will be appended to the cache-control header. 127 | * 128 | * - Default value: `undefined` 129 | */ 130 | serverExpiresIn?: number; 131 | } 132 | 133 | export const privacy: Privacy 134 | 135 | export const fastifyCaching: FastifyCaching 136 | export { fastifyCaching as default } 137 | } 138 | 139 | declare function fastifyCaching (...params: Parameters): ReturnType 140 | export = fastifyCaching 141 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import Fastify, { FastifyReply } from 'fastify' 2 | import { expectAssignable, expectError, expectType } from 'tsd' 3 | import fastifyCaching, { 4 | AbstractCacheCompliantObject, 5 | FastifyCachingPluginOptions, 6 | } from '..' 7 | 8 | const fastify = Fastify({ logger: true }) 9 | 10 | const cachingOptions: FastifyCachingPluginOptions = { 11 | privacy: fastifyCaching.privacy.PUBLIC, 12 | expiresIn: 300, 13 | cacheSegment: 'fastify-caching', 14 | } 15 | expectAssignable(cachingOptions) 16 | 17 | fastify.register(fastifyCaching, cachingOptions) 18 | 19 | expectType(fastify.cache) 20 | expectType(fastify.cache.get) 21 | expectType(fastify.cache.set) 22 | expectType(fastify.cacheSegment) 23 | // expectType(fastify.etagMaxLife); 24 | 25 | fastify.get('/one', async (_request, reply) => { 26 | expectType<(tag?: string, timeToLive?: number) => FastifyReply>(reply.etag) 27 | expectType<(date?: Date) => FastifyReply>(reply.expires) 28 | 29 | expectType(reply.etag('hello', 6000)) 30 | expectType(reply.expires(new Date(Date.now() + 6000))) 31 | 32 | return { message: 'one' } 33 | }) 34 | 35 | fastify.get('/two', async (_request, reply) => { 36 | expectType( 37 | reply.etag('hello', 6000).expires(new Date(Date.now() + 6000)) 38 | ) 39 | 40 | return { message: 'two' } 41 | }) 42 | 43 | // We register a new instance that should trigger a typescript error. 44 | const shouldErrorApp = Fastify({ logger: true }) 45 | 46 | const badCachingOptions = { 47 | privacy: fastifyCaching.privacy.PRIVATE, 48 | expiresIn: 'a string instead of a number of second', 49 | cacheSegment: 'fastify-caching', 50 | } 51 | 52 | expectError(shouldErrorApp.register(fastifyCaching, badCachingOptions)) 53 | 54 | fastify.get('/three', async () => { 55 | expectAssignable>( 56 | fastify.cache.get('well-known') 57 | ) 58 | expectAssignable>( 59 | fastify.cache.get('well-known') 60 | ) 61 | expectType( 62 | fastify.cache.get('well-known', (err, value) => { 63 | expectType(err) 64 | expectAssignable<{ item: string; stored: number; ttl: number; } | null>(value) 65 | }) 66 | ) 67 | 68 | return { message: 'two' } 69 | }) 70 | --------------------------------------------------------------------------------