├── examples └── redis │ ├── README.md │ ├── .prettierrc │ ├── src │ ├── public │ │ └── favicon.ico │ ├── pages │ │ ├── api │ │ │ ├── invalidate.ts │ │ │ └── cache-info.ts │ │ ├── _app.tsx │ │ └── alphabet │ │ │ └── [[...letter]].tsx │ ├── hooks │ │ ├── usePrevious.ts │ │ ├── useAgeColor.ts │ │ ├── useCmdPressed.ts │ │ ├── useCacheAge.ts │ │ └── useCacheInfo.ts │ ├── lib │ │ ├── alphabet.ts │ │ └── cache-tags.ts │ ├── components │ │ ├── CacheStatus │ │ │ └── index.tsx │ │ ├── Letter │ │ │ └── index.tsx │ │ └── CacheUpdater │ │ │ └── index.tsx │ └── styles │ │ └── globals.css │ ├── next-env.d.ts │ ├── scripts │ └── clear-cache-tags.js │ ├── .gitignore │ ├── tsconfig.json │ ├── package.json │ └── yarn.lock ├── .gitignore ├── .prettierrc ├── jest.config.js ├── src ├── index.ts └── lib │ ├── registry │ ├── type.ts │ ├── memory.ts │ ├── memory.test.ts │ └── redis.ts │ ├── hash.test.ts │ ├── hash.ts │ ├── stack.ts │ ├── cache-tags.ts │ └── cache-tags.test.ts ├── .github └── workflows │ ├── ci.yml │ ├── code-coverage.yml │ ├── release-publish.yml │ └── release-create.yml ├── CHANGELOG.md ├── LICENSE ├── package.json ├── tsconfig.json └── README.md /examples/redis/README.md: -------------------------------------------------------------------------------- 1 | > TODO -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | .vscode 4 | node_modules 5 | .cache 6 | dist 7 | coverage 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /examples/redis/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /examples/redis/src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasconstantino/next-cache-tags/HEAD/examples/redis/src/public/favicon.ico -------------------------------------------------------------------------------- /examples/redis/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | transform: { 4 | '^.+\\.ts$': ['ts-jest', { isolatedModules: true }], 5 | }, 6 | testEnvironment: 'node', 7 | collectCoverage: true, 8 | } 9 | -------------------------------------------------------------------------------- /examples/redis/src/pages/api/invalidate.ts: -------------------------------------------------------------------------------- 1 | import { cacheTags } from '~/lib/cache-tags' 2 | 3 | /** 4 | * Resolves tags to invalidate from "tag" query param. 5 | */ 6 | export default cacheTags.invalidator({ 7 | wait: true, 8 | resolver: (req) => [req.query.tag as string], 9 | }) 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { defaultGenerateHash } from './lib/hash' 2 | export { CacheTagsRegistry } from './lib/registry/type' 3 | export { MemoryCacheTagsRegistry } from './lib/registry/memory' 4 | export { RedisCacheTagsRegistry } from './lib/registry/redis' 5 | export { CacheTags } from './lib/cache-tags' 6 | export type { TagsResolver } from './lib/cache-tags' 7 | -------------------------------------------------------------------------------- /examples/redis/src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react' 2 | 3 | /** 4 | * Maintain the previous value in a reference. 5 | */ 6 | const usePrevious = (value: T) => { 7 | const ref = useRef(value) 8 | 9 | // @ts-ignore 10 | useEffect(() => void (ref.current = value)) 11 | 12 | return ref.current 13 | } 14 | 15 | export { usePrevious } 16 | -------------------------------------------------------------------------------- /src/lib/registry/type.ts: -------------------------------------------------------------------------------- 1 | interface CacheTagsRegistry { 2 | /** 3 | * Registers a set of cache-tags for a given path. 4 | */ 5 | register: (path: string, tags: string[]) => Promise | void 6 | 7 | /** 8 | * Removes and returns a set of paths related to a cache-tag. 9 | */ 10 | extract: (tag: string) => Promise | string[] 11 | } 12 | 13 | export { CacheTagsRegistry } 14 | -------------------------------------------------------------------------------- /examples/redis/src/lib/alphabet.ts: -------------------------------------------------------------------------------- 1 | export const alphabet = [ 2 | 'A', 3 | 'B', 4 | 'C', 5 | 'D', 6 | 'E', 7 | 'F', 8 | 'G', 9 | 'H', 10 | 'I', 11 | 'J', 12 | 'K', 13 | 'L', 14 | 'M', 15 | 'N', 16 | 'O', 17 | 'P', 18 | 'Q', 19 | 'R', 20 | 'S', 21 | 'T', 22 | 'U', 23 | 'V', 24 | 'W', 25 | 'X', 26 | 'Y', 27 | 'Z', 28 | ] as const 29 | 30 | export type TLetter = typeof alphabet[number] 31 | -------------------------------------------------------------------------------- /examples/redis/src/hooks/useAgeColor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Resolve a color based on cache age, from green (recent) to red (old). 3 | */ 4 | const useAgeColor = (age: number | null, maxAge = 20) => { 5 | if (age === null) { 6 | return 'black' 7 | } 8 | 9 | const perc = Math.max(0, Math.min(1, age / maxAge)) 10 | 11 | // RGB construction based on age percentage. 12 | return `rgb(${perc * 255}, ${255 - perc * 255}, 0)` 13 | } 14 | 15 | export { useAgeColor } 16 | -------------------------------------------------------------------------------- /examples/redis/scripts/clear-cache-tags.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const dotenv = require('dotenv') 3 | const { createClient } = require('redis') 4 | 5 | dotenv.config({ path: path.resolve(__dirname, '../.env') }) 6 | dotenv.config({ path: path.resolve(__dirname, '../.env.local') }) 7 | 8 | const redis = createClient({ url: process.env.CACHE_TAGS_REDIS_URL }) 9 | 10 | const run = async () => { 11 | await redis.connect() 12 | await redis.FLUSHDB() 13 | await redis.disconnect() 14 | } 15 | 16 | run().catch(console.error) 17 | -------------------------------------------------------------------------------- /src/lib/hash.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultGenerateHash } from './hash' 2 | 3 | describe('defaultGenerateHash', () => { 4 | it('should create a hash string from a tag', () => { 5 | expect(typeof defaultGenerateHash('some-tag')).toBe('string') 6 | }) 7 | 8 | it('should create indepotent hashes', () => { 9 | expect(defaultGenerateHash('some-tag')).toBe( 10 | defaultGenerateHash('some-tag') 11 | ) 12 | expect(defaultGenerateHash('some-tag')).not.toBe( 13 | defaultGenerateHash('other-tag') 14 | ) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /examples/redis/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /src/lib/hash.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a 7 chars long hash of a given tag. 3 | */ 4 | const defaultGenerateHash = (tag: string) => { 5 | let hash = 0 6 | 7 | for (let i = 0; i < tag.length; i++) { 8 | const char = tag.charCodeAt(i) 9 | hash = (hash << 5) - hash + char 10 | hash &= hash // Convert to 32bit integer 11 | } 12 | 13 | return new Uint32Array([hash])[0].toString(36) 14 | } 15 | 16 | /** 17 | * Empty implementation of hash generator, for disabling it. 18 | */ 19 | const noHash = (tag: string) => tag 20 | 21 | export { defaultGenerateHash, noHash } 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | ci: 7 | name: Code Quality 8 | runs-on: ubuntu-20.04 9 | timeout-minutes: 5 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Install Node 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: latest 18 | cache: yarn 19 | 20 | - name: Install Dependencies 21 | run: yarn install --ignore-scripts 22 | 23 | - name: Run Code Quality 24 | run: yarn code-quality 25 | -------------------------------------------------------------------------------- /examples/redis/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from 'next/app' 2 | import { useRouter } from 'next/router' 3 | import { Analytics } from '@vercel/analytics/react' 4 | 5 | import '../styles/globals.css' 6 | 7 | function MyApp({ Component, pageProps }: AppProps) { 8 | const router = useRouter() 9 | 10 | // Disable any sorts of prefetching to simplify comprehension 11 | // of the cache-tags cleaning events. 12 | router.prefetch = async () => void 0 13 | 14 | return ( 15 | <> 16 | 17 | 18 | 19 | ) 20 | } 21 | export default MyApp 22 | -------------------------------------------------------------------------------- /examples/redis/src/lib/cache-tags.ts: -------------------------------------------------------------------------------- 1 | import { RedisCacheTagsRegistry, CacheTags, defaultGenerateHash } from 'next-cache-tags' 2 | 3 | if (!process.env.CACHE_TAGS_REDIS_URL) { 4 | throw new Error('Missing CACHE_TAGS_REDIS_URL environment variable') 5 | } 6 | 7 | const debugging = Boolean(process.env.CACHE_TAGS_DEBUG) 8 | 9 | const cacheTags = new CacheTags({ 10 | registry: new RedisCacheTagsRegistry({ 11 | url: process.env.CACHE_TAGS_REDIS_URL, 12 | socket: { connectTimeout: 50000 }, 13 | }), 14 | generateHash: debugging ? false : defaultGenerateHash, 15 | log: debugging, 16 | }) 17 | 18 | export { cacheTags } 19 | -------------------------------------------------------------------------------- /examples/redis/src/hooks/useCmdPressed.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | 3 | const useCmdPressed = () => { 4 | const [isPressed, setIsPressed] = useState(false) 5 | 6 | useEffect(() => { 7 | const handleKeyDown = () => setIsPressed(true) 8 | const handleKeyUp = () => setIsPressed(false) 9 | 10 | window.addEventListener('keydown', handleKeyDown) 11 | window.addEventListener('keyup', handleKeyUp) 12 | 13 | return () => { 14 | window.removeEventListener('keydown', handleKeyDown) 15 | window.removeEventListener('keyup', handleKeyUp) 16 | } 17 | }, []) 18 | 19 | return isPressed 20 | } 21 | 22 | export { useCmdPressed } 23 | -------------------------------------------------------------------------------- /.github/workflows/code-coverage.yml: -------------------------------------------------------------------------------- 1 | name: 'Code Coverage' 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | code-coverage: 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Install Node 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: latest 18 | cache: yarn 19 | 20 | - name: Install Dependencies & Build 21 | run: yarn install 22 | 23 | - name: Run Tests 24 | run: yarn test 25 | 26 | - name: Upload Code Coverage 27 | uses: paambaati/codeclimate-action@v3.2.0 28 | env: 29 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 30 | -------------------------------------------------------------------------------- /examples/redis/src/hooks/useCacheAge.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | /** 4 | * Calculates and updates the age of a given cache in seconds. 5 | */ 6 | const useCacheAge = (cache: Date | undefined) => { 7 | const [seconds, setSeconds] = useState(null) 8 | 9 | useEffect(() => { 10 | if (cache) { 11 | const calculate = () => 12 | setSeconds(Math.floor((new Date().getTime() - cache.getTime()) / 1000)) 13 | 14 | // Initial calculation, 15 | calculate() 16 | 17 | // Refresh every second to come. 18 | const interval = setInterval(calculate, 1000) 19 | 20 | return () => clearInterval(interval) 21 | } 22 | }, [cache]) 23 | 24 | return seconds 25 | } 26 | 27 | export { useCacheAge } 28 | -------------------------------------------------------------------------------- /examples/redis/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "baseUrl": ".", 22 | "paths": { 23 | "~/*": ["src/*"] 24 | }, 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "**/*.ts", 29 | "**/*.tsx" 30 | ], 31 | "exclude": [ 32 | "node_modules" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /examples/redis/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redis", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "yarn clear-cache-tags && next build", 8 | "start": "next start", 9 | "clear-cache-tags": "node scripts/clear-cache-tags.js" 10 | }, 11 | "dependencies": { 12 | "@vercel/analytics": "^0.1.6", 13 | "classnames": "^2.3.2", 14 | "next": "12.x.x", 15 | "next-cache-tags": "x.x.x", 16 | "react": "18.2.0", 17 | "react-circular-progressbar": "^2.1.0", 18 | "react-dom": "18.2.0", 19 | "redis": "^4.5.0" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^18.11.9", 23 | "@types/react": "18.0.25", 24 | "dotenv": "^16.0.3", 25 | "prettier": "^2.7.1", 26 | "typescript": "4.9.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/registry/memory.ts: -------------------------------------------------------------------------------- 1 | import { CacheTagsRegistry } from './type' 2 | 3 | /** 4 | * A Cache-Tags registry implemented using memory, for testing purposes. 5 | */ 6 | class MemoryCacheTagsRegistry implements CacheTagsRegistry { 7 | private store: Map> 8 | 9 | constructor() { 10 | this.store = new Map() 11 | } 12 | 13 | register = (path: string, tags: string[]) => { 14 | for (const tag of tags) { 15 | const map = this.store.get(tag) ?? new Map() 16 | map.set(path, true) 17 | this.store.set(tag, map) 18 | } 19 | } 20 | 21 | extract = (tag: string) => { 22 | const map = this.store.get(tag) ?? new Map() 23 | const paths = [...map.keys()] 24 | this.store.delete(tag) 25 | return paths 26 | } 27 | } 28 | 29 | export { MemoryCacheTagsRegistry } 30 | -------------------------------------------------------------------------------- /.github/workflows/release-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will be triggered after a GitHub release is marked as published. 2 | # This will ultimately trigger NPM package publishing. 3 | name: 'Release: Publish' 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | publish-release: 11 | runs-on: ubuntu-20.04 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | 16 | - name: Install Node 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: latest 20 | cache: yarn 21 | 22 | - name: Install Dependencies & Build 23 | run: yarn install 24 | 25 | - name: Run Tests 26 | run: yarn test 27 | 28 | - name: Publish Package 29 | uses: JS-DevTools/npm-publish@v1 30 | with: 31 | token: ${{ secrets.NPM_ACCESS_TOKEN }} 32 | -------------------------------------------------------------------------------- /examples/redis/src/pages/api/cache-info.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiHandler } from 'next' 2 | import { cacheTags } from '~/lib/cache-tags' 3 | 4 | /** 5 | * Load current cache info from Redis. 6 | */ 7 | const handler: NextApiHandler = async (_req, res) => 8 | await cacheTags.registry.act(async (client) => { 9 | const tags = await client.KEYS('*') 10 | const paths: Record = {} 11 | const transaction = client.multi() 12 | 13 | for (const tag of tags) { 14 | transaction.HGETALL(tag) 15 | } 16 | 17 | const result = (await transaction.exec()) as unknown[] as Record[] 18 | 19 | for (const hashes of result) { 20 | for (const path in hashes) { 21 | paths[path] = paths[path] > hashes[path] ? paths[path] : hashes[path] 22 | } 23 | } 24 | 25 | res.json(paths) 26 | }) 27 | 28 | export default handler 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.2.2](https://github.com/lucasconstantino/next-cache-tags/compare/v0.2.1...v0.2.2) (2023-01-02) 6 | 7 | 8 | ### Features 9 | 10 | * upgraded next-cache-tags on example ([96e82ad](https://github.com/lucasconstantino/next-cache-tags/commit/96e82ad73af388d13871ab3d3c88a45aff264737)) 11 | 12 | ### [0.1.4](https://github.com/lucasconstantino/next-cache-tags/compare/v0.1.3...v0.1.4) (2022-11-27) 13 | 14 | ### [0.1.3](https://github.com/lucasconstantino/next-cache-tags/compare/v0.1.2...v0.1.3) (2022-11-26) 15 | 16 | ### [0.1.2](https://github.com/lucasconstantino/next-cache-tags/compare/v0.1.1...v0.1.2) (2022-11-26) 17 | 18 | 19 | ### Features 20 | 21 | * avoid redis socket timeouts ([42fe2fe](https://github.com/lucasconstantino/next-cache-tags/commit/42fe2fea759f6678cc16550381b5c50a21d8a31d)) 22 | * downgrade to next.13 ([195f022](https://github.com/lucasconstantino/next-cache-tags/commit/195f022d04de3b2c64c485d87cef6bff378da2fb)) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Lucas Constantino Silva 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. -------------------------------------------------------------------------------- /src/lib/stack.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ensure the environment supports stack capturing. 3 | */ 4 | const isSupported = () => 5 | ['stackTraceLimit', 'prepareStackTrace', 'captureStackTrace'].every( 6 | (method) => method in Error 7 | ) 8 | 9 | /** 10 | * Get the current stack. 11 | */ 12 | const get = (limit = Infinity) => { 13 | if (!isSupported()) { 14 | throw new Error( 15 | 'Cannot use next-cache-tags automatic path resolving outside V8 runtimes.' 16 | ) 17 | } 18 | 19 | const target = {} as { stack: NodeJS.CallSite[] } 20 | 21 | const initials = { 22 | stackTraceLimit: Error.stackTraceLimit, 23 | prepareStackTrace: Error.prepareStackTrace, 24 | } 25 | 26 | Error.stackTraceLimit = limit 27 | 28 | // Override 29 | Error.prepareStackTrace = function (_obj, stack) { 30 | return stack 31 | } 32 | 33 | // Execute stack capturing. 34 | Error.captureStackTrace(target, exports.get) 35 | 36 | const stack = target.stack 37 | 38 | // Recover initials. 39 | Error.prepareStackTrace = initials.prepareStackTrace 40 | Error.stackTraceLimit = initials.stackTraceLimit 41 | 42 | return stack 43 | } 44 | 45 | export { get } 46 | -------------------------------------------------------------------------------- /examples/redis/src/components/CacheStatus/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAgeColor } from '~/hooks/useAgeColor' 2 | import { useCacheAge } from '~/hooks/useCacheAge' 3 | import type { TCacheInfo } from '~/hooks/useCacheInfo' 4 | import { alphabet } from '~/lib/alphabet' 5 | import type { TLetter } from '~/lib/alphabet' 6 | 7 | const CacheItem: React.FC<{ letter: TLetter; date?: Date }> = ({ letter, date }) => { 8 | const age = useCacheAge(date) 9 | const color = useAgeColor(age) 10 | const style = { '--time-color': color, opacity: date ? 1 : 0.25 } as React.CSSProperties 11 | 12 | return ( 13 |
14 | {letter}: {date?.toISOString() ?? 'empty'} 15 |
16 | ) 17 | } 18 | 19 | const CacheStatus: React.FC<{ cacheInfo: TCacheInfo }> = ({ cacheInfo }) => ( 20 |
21 |

Cache status

22 |
    23 | {alphabet.map((letter) => ( 24 |
  • 25 | 26 |
  • 27 | ))} 28 |
29 |
30 | ) 31 | 32 | export { CacheStatus } 33 | -------------------------------------------------------------------------------- /src/lib/registry/memory.test.ts: -------------------------------------------------------------------------------- 1 | import { MemoryCacheTagsRegistry } from './memory' 2 | 3 | describe('registry/memory', () => { 4 | let registry: MemoryCacheTagsRegistry 5 | 6 | beforeEach(() => (registry = new MemoryCacheTagsRegistry())) 7 | 8 | it('should be possible to register path/tags relationship', () => { 9 | expect(() => registry.register('some-path', ['tag1', 'tag2'])).not.toThrow() 10 | }) 11 | 12 | it('should be possible to retrieve a path related to a tag', () => { 13 | registry.register('some-path', ['tag1']) 14 | expect(registry.extract('tag1')).toEqual(['some-path']) 15 | }) 16 | 17 | it('should not register a path more than once', () => { 18 | registry.register('some-path', ['tag1']) 19 | registry.register('some-path', ['tag1']) 20 | expect(registry.extract('tag1')).toEqual(['some-path']) 21 | }) 22 | 23 | it('should clear the registry after extraction', () => { 24 | registry.register('some-path', ['tag1']) 25 | expect(registry.extract('tag1')).toEqual(['some-path']) 26 | expect(registry.extract('tag1')).toEqual([]) 27 | }) 28 | 29 | it('should be possible to retrieve multiple paths', () => { 30 | registry.register('some-path', ['tag1']) 31 | registry.register('other-path', ['tag1', 'tag2']) 32 | expect(registry.extract('tag1')).toEqual(['some-path', 'other-path']) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-cache-tags", 3 | "description": "Active ISR revalidation based on surrogate keys for Next.js", 4 | "version": "0.2.2", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/lucasconstantino/next-cache-tags.git" 8 | }, 9 | "license": "MIT", 10 | "author": "Lucas Constantino Silva", 11 | "main": "dist/commonjs/index.js", 12 | "module": "dist/esm/index.js", 13 | "typings": "dist/esm/index.d.ts", 14 | "files": [ 15 | "dist", 16 | "src" 17 | ], 18 | "scripts": { 19 | "clean": "rimraf dist", 20 | "prebuild": "yarn clean", 21 | "build": "yarn build:commonjs && yarn build:esm", 22 | "build:esm": "tsc --module esnext --outDir dist/esm", 23 | "build:commonjs": "tsc --module commonjs --outDir dist/commonjs", 24 | "test": "jest src --silent", 25 | "type-check": "tsc --noEmit", 26 | "code-quality": "yarn test && yarn type-check", 27 | "prepare": "yarn build" 28 | }, 29 | "devDependencies": { 30 | "@types/jest": "^29.2.3", 31 | "@types/react": "^18.0.20", 32 | "@types/react-dom": "^18.0.6", 33 | "extract-changelog-release": "^1.0.2", 34 | "jest": "^29.3.1", 35 | "next": "^12.3.0", 36 | "node-mocks-http": "^1.11.0", 37 | "react": "^18.2.0", 38 | "react-dom": "^18.2.0", 39 | "redis": "^4.5.0", 40 | "rimraf": "^3.0.2", 41 | "standard-version": "^9.5.0", 42 | "ts-jest": "^29.0.3", 43 | "tslib": "^2.4.0", 44 | "typescript": "^4.9.3" 45 | }, 46 | "peerDependencies": { 47 | "react": ">=16" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "target": "es2015", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 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 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | // "noEmit": true, 34 | }, 35 | "exclude": ["src/**/*.test.ts"] 36 | } 37 | -------------------------------------------------------------------------------- /examples/redis/src/components/Letter/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | import Link from 'next/link' 3 | import classnames from 'classnames' 4 | 5 | import type { TLetter } from '~/lib/alphabet' 6 | import { useCacheAge } from '~/hooks/useCacheAge' 7 | import { useAgeColor } from '~/hooks/useAgeColor' 8 | import type { TCacheInfo, TCacheKey } from '~/hooks/useCacheInfo' 9 | 10 | type TProps = { 11 | letter: TLetter 12 | isCurrent: boolean 13 | isPrevious: boolean 14 | isNext: boolean 15 | cacheInfo: TCacheInfo 16 | } 17 | 18 | const Letter: React.FC = ({ letter, isCurrent, isPrevious, isNext, cacheInfo }) => { 19 | const url: TCacheKey = `/alphabet/${letter}` 20 | 21 | const age = useCacheAge(cacheInfo.cache[url]) 22 | const color = useAgeColor(age) 23 | 24 | // Navigate to home if current letter is clicked. 25 | const href = `/alphabet/${isCurrent ? '' : letter}` 26 | const style = { '--time-color': color } as React.CSSProperties 27 | 28 | /** 29 | * Invalidate (instead of navigate) if cmd/ctrl is pressed upon clicking. 30 | */ 31 | const handleOnClick = async (e: React.MouseEvent) => { 32 | if (e.metaKey) { 33 | e.preventDefault() 34 | cacheInfo.invalidate(letter) 35 | } 36 | } 37 | 38 | return ( 39 | 40 | 48 | {letter} 49 | {age ? `${age}s` : 'empty'} 50 | invalidate 51 | 52 | 53 | ) 54 | } 55 | 56 | const MemoizedLetter = memo(Letter) 57 | 58 | export { MemoizedLetter as Letter } 59 | -------------------------------------------------------------------------------- /examples/redis/src/hooks/useCacheInfo.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from 'react' 2 | import type { TLetter } from '../lib/alphabet' 3 | 4 | type TCacheKey = `/alphabet/${TLetter}` 5 | type TCacheTag = `letter:${string}` 6 | type TCacheMap = { [key in TCacheKey]?: Date } 7 | 8 | const parse = { 9 | /** 10 | * Parse a cache entry (key => time string) 11 | */ 12 | entry: ([path, time]: [string, string]) => [path, new Date(time)], 13 | 14 | /** 15 | * Parse a map object ({ [key]: time string }) 16 | */ 17 | map: (object: { [key in TCacheKey]: string }): TCacheMap => 18 | Object.fromEntries(Object.entries(object).map(parse.entry)), 19 | } 20 | 21 | /** 22 | * Resolves a cache-tag given a letter. 23 | */ 24 | const createCacheTag = (letter: TLetter | null): TCacheTag => `letter:${letter}` 25 | 26 | /** 27 | * Cache status and invalidation connector. 28 | */ 29 | const useCacheInfo = () => { 30 | const [cache, setCache] = useState({}) 31 | const [updating, setUpdating] = useState(false) 32 | 33 | /** 34 | * Performs a cache info update. 35 | */ 36 | const update = useCallback(async () => { 37 | setUpdating(true) 38 | 39 | await fetch('/api/cache-info') 40 | .then((res) => res.json()) 41 | .then(parse.map) 42 | .then(setCache) 43 | 44 | setUpdating(false) 45 | }, [setUpdating, setCache]) 46 | 47 | /** 48 | * Performs an invalidation of a letter cache-tag. 49 | */ 50 | const invalidate = useCallback( 51 | async (letter: TLetter) => { 52 | setUpdating(true) 53 | await fetch(`/api/invalidate?tag=${createCacheTag(letter)}`) 54 | await update() 55 | }, 56 | [setUpdating, update] 57 | ) 58 | 59 | return useMemo( 60 | () => ({ cache, update, updating, invalidate }), 61 | [cache, update, updating, invalidate] 62 | ) 63 | } 64 | 65 | type TCacheInfo = ReturnType 66 | 67 | export type { TCacheKey, TCacheMap, TCacheInfo } 68 | 69 | export { useCacheInfo, createCacheTag } 70 | -------------------------------------------------------------------------------- /.github/workflows/release-create.yml: -------------------------------------------------------------------------------- 1 | # This workflow should be triggered manually, 2 | # through the GitHub UI at https://github.com/lucasconstantino/next-cache-tags/actions/workflows/release-create.yml 3 | name: 'Release: Create' 4 | 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | publish: 9 | description: 'Publish release? [yes/no]' 10 | required: true 11 | default: 'no' 12 | 13 | jobs: 14 | release-create: 15 | runs-on: ubuntu-20.04 16 | steps: 17 | - name: Generate Authentication Token 18 | id: get_workflow_token 19 | uses: peter-murray/workflow-application-token-action@v1 20 | with: 21 | application_id: ${{ secrets.AUTHORIZER_APPLICATION_ID }} 22 | application_private_key: ${{ secrets.AUTHORIZER_APPLICATION_PRIVATE_KEY }} 23 | 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | with: 27 | ref: main 28 | fetch-depth: 0 # Fetch all commits history, needed for changelog creation 29 | token: ${{ steps.get_workflow_token.outputs.token }} 30 | 31 | - name: Create New Version 32 | run: | 33 | git config --global user.name "${GITHUB_ACTOR}" 34 | git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" 35 | npx standard-version 36 | 37 | - name: Generate Release body 38 | run: npx extract-changelog-release > RELEASE_BODY.md 39 | 40 | - name: Push Changes and Tags 41 | id: publish_tag 42 | run: | 43 | git push --follow-tags 44 | echo ::set-output name=tag_name::$(git describe HEAD --abbrev=0) 45 | 46 | - name: Create GitHub Release 47 | uses: actions/create-release@v1 48 | with: 49 | release_name: ${{ steps.publish_tag.outputs.tag_name }} 50 | tag_name: ${{ steps.publish_tag.outputs.tag_name }} 51 | body_path: 'RELEASE_BODY.md' 52 | draft: ${{ github.event.inputs.publish != 'yes' }} 53 | env: 54 | GITHUB_TOKEN: ${{ steps.get_workflow_token.outputs.token }} 55 | -------------------------------------------------------------------------------- /src/lib/registry/redis.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | RedisClientType, 3 | RedisClientOptions, 4 | RedisModules, 5 | RedisFunctions, 6 | RedisScripts, 7 | } from 'redis' 8 | import { createClient } from 'redis' 9 | import { CacheTagsRegistry } from './type' 10 | 11 | /** 12 | * A Cache-Tags registry implemented using Redis. 13 | */ 14 | class RedisCacheTagsRegistry< 15 | M extends RedisModules = RedisModules, 16 | F extends RedisFunctions = RedisFunctions, 17 | S extends RedisScripts = RedisScripts 18 | > implements CacheTagsRegistry 19 | { 20 | private client: RedisClientType 21 | private acting = 0 22 | private connecting?: Promise 23 | private disconnecting?: Promise 24 | 25 | constructor(config: RedisClientOptions) { 26 | this.client = createClient(config) 27 | } 28 | 29 | /** 30 | * Perform async actions on the store with ensured read/write. 31 | */ 32 | act = async (action: (client: RedisClientType) => Promise) => { 33 | // Inform of ongoing action. 34 | this.acting++ 35 | 36 | // Ensure a closing connection ends it's process. 37 | await this.disconnecting 38 | 39 | // Establish connection 40 | await (this.connecting = this.connecting ?? this.client.connect()) 41 | 42 | // Execute action. 43 | const result = await action(this.client) 44 | 45 | // Only disconnect on last action. 46 | if (--this.acting === 0 && this.client.isOpen) { 47 | // Start disconnection process. 48 | this.disconnecting = this.client.quit() 49 | 50 | // Ensure new acts reconnect. 51 | delete this.connecting 52 | 53 | // Wait for disconnection before proceeding 54 | await this.disconnecting 55 | } 56 | 57 | return result 58 | } 59 | 60 | register = async (path: string, tags: string[]) => 61 | await this.act(async () => { 62 | const now = new Date().toISOString() 63 | const transaction = this.client.multi() 64 | 65 | for (const tag of tags) { 66 | // @ts-ignore 67 | transaction.HSET(tag, path, now) 68 | } 69 | 70 | await transaction.exec() 71 | }) 72 | 73 | extract = async (tag: string) => 74 | await this.act(async () => { 75 | // Retrieve all paths related to this key. 76 | const paths = Object.keys((await this.client.HGETALL(tag)) ?? {}) 77 | 78 | // Dispatch deletion. 79 | void this.client.DEL(tag) 80 | 81 | return paths as any as string[] 82 | }) 83 | } 84 | 85 | export { RedisCacheTagsRegistry } 86 | -------------------------------------------------------------------------------- /examples/redis/src/pages/alphabet/[[...letter]].tsx: -------------------------------------------------------------------------------- 1 | import type { GetStaticProps, GetStaticPaths, NextPage } from 'next' 2 | import classnames from 'classnames' 3 | 4 | import { alphabet } from '~/lib/alphabet' 5 | import type { TLetter } from '~/lib/alphabet' 6 | import { cacheTags } from '~/lib/cache-tags' 7 | import { useCacheInfo, createCacheTag } from '~/hooks/useCacheInfo' 8 | import { CacheStatus } from '~/components/CacheStatus' 9 | import { CacheUpdater } from '~/components/CacheUpdater' 10 | import { Letter } from '~/components/Letter' 11 | import { useCmdPressed } from '~/hooks/useCmdPressed' 12 | 13 | type TProps = { 14 | letters: [TLetter | null, TLetter | null, TLetter | null] 15 | } 16 | 17 | const AlphabetPage: NextPage = ({ letters }) => { 18 | const cacheInfo = useCacheInfo() 19 | const isCmdPressed = useCmdPressed() 20 | const [previous, current, next] = letters 21 | 22 | return ( 23 |
24 | 27 | 28 |
29 |

Cache Tags Alphabet

30 | 31 |

Every letter has a page. Every page depends on the letter and it's sibling letters.

32 | 33 |

34 | 1) Click a letter to navigate to it's page (shows underlined) 35 |
36 | 2) Cmd+click a letter to renew the cache of surrounding letter pages 37 |
38 | 3) Click on the current letter to navigate to home 39 |

40 | 41 |
    42 | {alphabet.map((letter) => ( 43 |
  • 44 | 51 |
  • 52 | ))} 53 |
54 |
55 | 56 | 59 | 60 | 67 |
68 | ) 69 | } 70 | 71 | const getStaticProps: GetStaticProps = (ctx) => { 72 | const curr = (ctx.params?.letter?.[0]?.toUpperCase() ?? null) as TLetter | null 73 | const prev = (curr && alphabet[alphabet.indexOf(curr) - 1]) ?? null 74 | const next = (curr && alphabet[alphabet.indexOf(curr) + 1]) ?? null 75 | 76 | // All letters relevant to this page 77 | const letters = [prev, curr, next] as TProps['letters'] 78 | const tags = letters.filter(Boolean).map(createCacheTag) 79 | 80 | // Register tags for this page. 81 | cacheTags.register(ctx, tags) 82 | 83 | return { props: { letters: letters } } 84 | } 85 | 86 | /** 87 | * Empty implementation of getStaticPaths for on-demand only generation of static pages. 88 | */ 89 | const getStaticPaths: GetStaticPaths = () => ({ 90 | paths: [], 91 | fallback: 'blocking', 92 | }) 93 | 94 | export { getStaticProps, getStaticPaths } 95 | 96 | export default AlphabetPage 97 | -------------------------------------------------------------------------------- /examples/redis/src/components/CacheUpdater/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { useState } from 'react' 3 | import { useRef } from 'react' 4 | import { useEffect } from 'react' 5 | import { CircularProgressbarWithChildren, buildStyles } from 'react-circular-progressbar' 6 | import 'react-circular-progressbar/dist/styles.css' 7 | 8 | import type { TCacheInfo } from '~/hooks/useCacheInfo' 9 | import { usePrevious } from '~/hooks/usePrevious' 10 | 11 | type TProps = { 12 | cacheInfo: TCacheInfo 13 | 14 | /** 15 | * Update cache every "frequency" milliseconds. 16 | */ 17 | frequency: number 18 | } 19 | 20 | let init = false 21 | const styles = buildStyles({ strokeLinecap: 'butt', pathColor: '#666', pathTransitionDuration: 0 }) 22 | 23 | const CacheUpdater: React.FC = ({ cacheInfo, frequency }) => { 24 | const [state, setState] = useState<'initial' | 'updating' | 'waiting'>('initial') 25 | const [remaining, setRemaining] = useState(0) 26 | 27 | const previousTime = useRef() 28 | const animationFrame = useRef() 29 | 30 | const wasUpdating = usePrevious(cacheInfo.updating) 31 | 32 | // Initial load. 33 | useEffect(() => void (init ? null : ((init = true), cacheInfo.update())), []) 34 | 35 | /** 36 | * Reactive state handler. 37 | */ 38 | useEffect(() => { 39 | // Execute when started updating 40 | if (cacheInfo.updating && !wasUpdating) { 41 | setState('updating') 42 | setRemaining(0) 43 | } 44 | 45 | // Execute when finished updating 46 | if (!cacheInfo.updating && wasUpdating) { 47 | setState('waiting') 48 | setRemaining(frequency) 49 | } 50 | }, [cacheInfo.updating, wasUpdating]) 51 | 52 | /** 53 | * Reactive update dispatcher. 54 | */ 55 | useEffect( 56 | () => void (!cacheInfo.updating && remaining === 0 ? cacheInfo.update() : null), 57 | [remaining] 58 | ) 59 | 60 | /** 61 | * Reset state upon cache updating. 62 | */ 63 | useEffect(() => { 64 | // Execute when started updating 65 | if (cacheInfo.updating && !wasUpdating) { 66 | setState('updating') 67 | setRemaining(0) 68 | } 69 | }, [cacheInfo.updating]) 70 | 71 | /** 72 | * Countdown animation logic. 73 | */ 74 | useEffect(() => { 75 | const iterate = (time: number) => { 76 | if (previousTime.current !== undefined) { 77 | const elapsed = time - previousTime.current 78 | 79 | // Ensure we won't update the state past zero marker. 80 | setRemaining((current) => Math.max(current - elapsed, 0)) 81 | } 82 | 83 | previousTime.current = time 84 | animationFrame.current = requestAnimationFrame(iterate) 85 | } 86 | 87 | // Start animating: 88 | animationFrame.current = requestAnimationFrame(iterate) 89 | 90 | // Stop animating on unmount. 91 | return () => void (animationFrame.current ? cancelAnimationFrame(animationFrame.current) : null) 92 | }, []) 93 | 94 | let text = '' 95 | if (state === 'waiting') text = `${(remaining / 1000).toFixed(1)}s` 96 | if (state === 'updating') text = 'loading' 97 | 98 | return ( 99 |
100 | 104 | {text} 105 | 106 |
107 | ) 108 | } 109 | 110 | export { CacheUpdater } 111 | -------------------------------------------------------------------------------- /examples/redis/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Permanent+Marker&display=swap'); 2 | 3 | html, 4 | body { 5 | padding: 0; 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, 8 | Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 9 | text-align: center; 10 | } 11 | 12 | a { 13 | color: inherit; 14 | text-decoration: none; 15 | } 16 | 17 | * { 18 | box-sizing: border-box; 19 | } 20 | 21 | #page { 22 | position: relative; 23 | display: flex; 24 | height: 100vh; 25 | overflow: hidden; 26 | } 27 | 28 | #page > aside { 29 | padding: 1rem; 30 | } 31 | 32 | #page > main { 33 | flex-grow: 1; 34 | overflow: auto; 35 | } 36 | 37 | #cover { 38 | position: absolute; 39 | inset: 0; 40 | display: flex; 41 | flex-direction: column; 42 | justify-content: center; 43 | background: white; 44 | } 45 | 46 | #cover > p:first-child { 47 | font-weight: bold; 48 | } 49 | 50 | #cover > p:last-child { 51 | margin: 0 0 10vh; 52 | } 53 | 54 | #cover .icon { 55 | font-size: 4em; 56 | } 57 | 58 | @media (min-width: 1000px) { 59 | #cover { 60 | display: none; 61 | } 62 | } 63 | 64 | #alphabet { 65 | margin: 0 auto; 66 | padding: 0; 67 | list-style: none; 68 | 69 | font-family: 'Permanent Marker', cursive; 70 | font-size: 6em; 71 | 72 | max-width: 6em; 73 | } 74 | 75 | #alphabet > li { 76 | position: relative; 77 | height: 1em; 78 | width: 1em; 79 | flex-shrink: 0; 80 | 81 | display: inline-flex; 82 | justify-content: center; 83 | align-items: center; 84 | } 85 | 86 | #alphabet .letter { 87 | --time-color: initial; 88 | color: var(--time-color); 89 | } 90 | 91 | #alphabet .letter > small { 92 | position: absolute; 93 | bottom: 0em; 94 | left: 50%; 95 | transform: translateX(-50%); 96 | font-family: monospace; 97 | font-size: 1rem; 98 | color: initial; 99 | opacity: 0; 100 | transition: all 250ms; 101 | } 102 | 103 | #alphabet:not(.isCmdPressed) .letter:hover .age { 104 | bottom: -2em; 105 | opacity: 1; 106 | } 107 | 108 | #alphabet.isCmdPressed .letter:hover .invalidate { 109 | bottom: -2em; 110 | opacity: 1; 111 | } 112 | 113 | #alphabet.hasCurrent .letter:not(.highlighted) { 114 | color: #ddd; 115 | } 116 | 117 | #alphabet .letter::after { 118 | content: ''; 119 | position: absolute; 120 | z-index: -1; 121 | left: 50%; 122 | top: 50%; 123 | background: #ddd; 124 | opacity: 0.5; 125 | transform: translate3d(-50%, -50%, 0); 126 | 127 | border-radius: 50%; 128 | height: 0; 129 | width: 0; 130 | 131 | transition: 250ms; 132 | } 133 | 134 | #alphabet .letter:hover::after { 135 | height: 150%; 136 | width: 150%; 137 | } 138 | 139 | #alphabet .letter.current { 140 | text-decoration: underline; 141 | } 142 | 143 | #cache-status { 144 | position: relative; 145 | padding: 1rem; 146 | width: 16rem; 147 | border: 1px solid #ccc; 148 | font-family: monospace; 149 | background: #eee; 150 | } 151 | 152 | #cache-status h3 { 153 | margin: 0 0 1em; 154 | } 155 | 156 | #cache-status ul { 157 | margin: 0; 158 | padding: 0; 159 | list-style: none; 160 | text-align: left; 161 | overflow: auto; 162 | } 163 | 164 | .cache-status-item { 165 | --time-color: initial; 166 | transition: opacity 250ms; 167 | } 168 | 169 | .cache-status-item > strong { 170 | color: var(--time-color); 171 | } 172 | 173 | #cache-update-status { 174 | width: 10rem; 175 | } 176 | 177 | #cache-update-status.updating { 178 | border-radius: 50%; 179 | box-shadow: 0 0 0 50px rgba(0, 255, 0, 0.4); 180 | animation: pulse 1s infinite; 181 | } 182 | 183 | @keyframes pulse { 184 | 0% { 185 | box-shadow: 0 0 0 0 rgba(0, 255, 0, 0.6); 186 | } 187 | 70% { 188 | box-shadow: 0 0 0 4rem rgba(0, 255, 0, 0); 189 | } 190 | 100% { 191 | box-shadow: 0 0 0 0 rgba(0, 255, 0, 0); 192 | } 193 | } 194 | 195 | /* #cache-update-status { 196 | width: 10rem; 197 | height: 10rem; 198 | border: 1px solid #ccc; 199 | border-radius: 50%; 200 | background-color: blue; 201 | 202 | display: flex; 203 | overflow: hidden; 204 | } 205 | 206 | #cache-update-status > div { 207 | background: #ccc; 208 | flex-grow: 1; 209 | background-image: linear-gradient(290deg, transparent 50%, white 50%), 210 | linear-gradient(90deg, white 50%, transparent 50%); 211 | } */ 212 | -------------------------------------------------------------------------------- /src/lib/cache-tags.ts: -------------------------------------------------------------------------------- 1 | import type * as Next from 'next' 2 | import Router from 'next/router' 3 | import { resolveHref } from 'next/dist/shared/lib/router/router' 4 | 5 | import { get } from './stack' 6 | import type { CacheTagsRegistry } from './registry/type' 7 | import { defaultGenerateHash, noHash } from './hash' 8 | 9 | type Config = { 10 | /** 11 | * Disable/enable logging of cache-tag actions. 12 | */ 13 | log?: boolean | ((message: string) => void) 14 | 15 | /** 16 | * A cache-tag registry/store. 17 | */ 18 | registry: R 19 | 20 | /** 21 | * An indepotent cache-tag hash generator. 22 | */ 23 | generateHash?: typeof defaultGenerateHash | false 24 | } 25 | 26 | interface TagsResolver { 27 | (req: Next.NextApiRequest, res: Next.NextApiResponse): 28 | | Promise 29 | | string[] 30 | } 31 | 32 | type InvalidatorConfig = { 33 | /** 34 | * Wheter the invalidator route should wait for it to operate. 35 | * 36 | * Defaults to false, and should be used carefully not to exceed lambda execution time limit. 37 | */ 38 | wait?: boolean 39 | 40 | /** 41 | * Cache-tags resolver for an upcoming invalidation call. 42 | */ 43 | resolver: TagsResolver 44 | 45 | /** 46 | * Controls how the API Route should be resolved in case of invalidation success. 47 | */ 48 | onSuccess?: ( 49 | req: Parameters[0], 50 | res: Parameters[1], 51 | tags: string[] 52 | ) => Promise | void 53 | 54 | /** 55 | * Controls how the API Route should be resolved in case of invalidation error. 56 | */ 57 | onError?: ( 58 | error: any, 59 | req: Parameters[0], 60 | res: Parameters[1], 61 | tags?: string[] 62 | ) => Promise | void 63 | } 64 | 65 | const regex = { 66 | pathname: /(pages|app)\/(?.+)(\.index)?\.[tj]sx?$/u, 67 | } 68 | 69 | /** 70 | * Cache-tags service for Next.js. 71 | */ 72 | class CacheTags { 73 | public log: (message: string) => void 74 | public registry: R 75 | public generateHash: typeof defaultGenerateHash 76 | 77 | constructor(config: Config) { 78 | const logger = typeof config.log === 'function' ? config.log : console.log 79 | 80 | this.log = config.log ? logger : () => {} 81 | this.registry = config.registry 82 | this.generateHash = 83 | config.generateHash === false 84 | ? noHash 85 | : config.generateHash ?? defaultGenerateHash 86 | } 87 | 88 | /** 89 | * Predicate for getStaticProps call-site. 90 | */ 91 | private isGetStaticPropsTrace = (callSite: NodeJS.CallSite) => 92 | callSite.getFunctionName() === 'getStaticProps' 93 | 94 | /** 95 | * Resolve the current page's pathname from Next.js static props context info. 96 | */ 97 | private getPagePathname(ctx: Next.GetStaticPropsContext) { 98 | const stack = get() 99 | const trace = stack.find(this.isGetStaticPropsTrace) 100 | const path = trace?.getFileName() ?? trace?.getEvalOrigin() 101 | const { pathname } = path?.match(regex.pathname)?.groups ?? {} 102 | 103 | if (!pathname) { 104 | throw new Error('Could not resolve page pathname') 105 | } 106 | 107 | return resolveHref(Router, { pathname, query: ctx.params }, true)[1] 108 | } 109 | 110 | /** 111 | * Register tags into a Next.js extracted path. 112 | */ 113 | public register(ctx: Next.GetStaticPropsContext, tags: string[]) { 114 | return this.registerPath(this.getPagePathname(ctx), tags) 115 | } 116 | 117 | /** 118 | * Register a tags/path relationship. 119 | */ 120 | public registerPath(path: string, tags: string[]) { 121 | const hashed = tags.map((tag) => this.generateHash(tag)) 122 | 123 | this.log(`[next-cache-tags] Regenerating ${path}:`) 124 | this.log(` - Cache-tags: ${hashed.length}`) 125 | 126 | return this.registry.register(path, hashed) 127 | } 128 | 129 | /** 130 | * Triggers the invalidation of set of tags. 131 | */ 132 | public async invalidate( 133 | res: Next.NextApiResponse, 134 | tags: string[], 135 | wait?: boolean 136 | ) { 137 | const invalidating: Array> = [] 138 | 139 | for (const tag of tags) { 140 | this.log(`[next-cache-tags] Invalidating "${tag}":`) 141 | 142 | // Fetch the paths related to the invalidating cache-tag. 143 | const paths = (await this.registry.extract(this.generateHash(tag))) ?? [] 144 | 145 | for (const path of paths) { 146 | this.log(` - ${path}`) 147 | 148 | // Dispatch revalidation. 149 | invalidating.push(res.revalidate(path)) 150 | } 151 | } 152 | 153 | /* istanbul ignore next */ 154 | if (wait) { 155 | await Promise.allSettled(invalidating) 156 | } 157 | } 158 | 159 | /** 160 | * Generates a Next.js API Route for cache-tag invalidation. 161 | */ 162 | public invalidator({ 163 | wait = false, 164 | resolver, 165 | onSuccess = (_req, res) => res.send('ok'), 166 | onError = (error, _req, res) => { 167 | console.error(error) 168 | res.status(500).json({ 169 | message: error instanceof Error ? error.message : 'Unknown error', 170 | }) 171 | }, 172 | }: InvalidatorConfig): Next.NextApiHandler { 173 | return async (req, res) => { 174 | let tags: string[] | undefined = undefined 175 | 176 | try { 177 | // Retrieves tags 178 | const tags = await resolver(req, res) 179 | 180 | // Perform invalidation. 181 | await this.invalidate(res, tags, wait) 182 | 183 | await onSuccess(req, res, tags) 184 | } catch (err) { 185 | await onError(err, req, res, tags) 186 | } 187 | } 188 | } 189 | } 190 | 191 | // Expose tags resolver so that it can be easily extended. 192 | export type { TagsResolver } 193 | 194 | export { CacheTags } 195 | -------------------------------------------------------------------------------- /src/lib/cache-tags.test.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | import { createMocks } from 'node-mocks-http' 3 | 4 | import { CacheTags } from './cache-tags' 5 | import type { TagsResolver } from './cache-tags' 6 | import { CacheTagsRegistry } from './registry/type' 7 | 8 | /** 9 | * Creates a mocked registry. 10 | */ 11 | const getRegistryMock = (): CacheTagsRegistry => { 12 | const registry: { [key: string]: string[] } = {} 13 | 14 | return { 15 | register: jest.fn((path, tags) => { 16 | for (const tag of tags) { 17 | registry[tag] = (registry[tag] ?? []).concat(path) 18 | } 19 | }), 20 | 21 | extract: jest.fn((tag) => { 22 | const paths = registry[tag] ?? [] 23 | delete registry[tag] 24 | return paths 25 | }), 26 | } 27 | } 28 | 29 | /** 30 | * Create network mock. 31 | */ 32 | const getNetworkMock = (...params: Parameters) => { 33 | const { req, res } = createMocks(...params) 34 | 35 | res.revalidate = jest.fn() 36 | 37 | return { req, res } 38 | } 39 | 40 | describe('CacheTags', () => { 41 | let registry: CacheTagsRegistry 42 | 43 | beforeEach(() => (registry = getRegistryMock())) 44 | 45 | it('should be possible to create an instance of CacheTags', () => { 46 | expect(() => new CacheTags({ registry })).not.toThrow() 47 | expect(new CacheTags({ registry })).toBeInstanceOf(CacheTags) 48 | }) 49 | 50 | describe('::generateHash', () => { 51 | it('should be possible to use the default hash generator', () => { 52 | const cacheTags = new CacheTags({ registry }) 53 | expect(typeof cacheTags.generateHash('some-id')).toBe('string') 54 | }) 55 | 56 | it('should be possible to pass a custom hash generator', () => { 57 | const cacheTags = new CacheTags({ registry, generateHash: () => 'tag' }) 58 | expect(cacheTags.generateHash('some-id')).toBe('tag') 59 | }) 60 | 61 | it('should be possible to disable hash generator', () => { 62 | const cacheTags = new CacheTags({ registry, generateHash: false }) 63 | expect(cacheTags.generateHash('some-id')).toBe('some-id') 64 | }) 65 | }) 66 | 67 | describe('::register', () => { 68 | it('should register a cache tags for a path', () => { 69 | const cacheTags = new CacheTags({ registry, generateHash: false }) 70 | cacheTags.registerPath('/some-path', ['tag-1']) 71 | 72 | expect(registry.register).toHaveBeenCalledWith('/some-path', ['tag-1']) 73 | }) 74 | 75 | it('should hash registering cache', () => { 76 | const generateHash = jest.fn(() => 'hashed') 77 | const cacheTags = new CacheTags({ registry, generateHash }) 78 | 79 | cacheTags.registerPath('/some-path', ['tag-1']) 80 | 81 | expect(generateHash).toHaveBeenCalledWith('tag-1') 82 | expect(registry.register).toHaveBeenCalledWith('/some-path', ['hashed']) 83 | }) 84 | }) 85 | 86 | describe('::invalidator', () => { 87 | const resolver: TagsResolver = jest.fn((req) => [req.query.tag as string]) 88 | 89 | it('should create a handler function', () => { 90 | const cacheTags = new CacheTags({ registry }) 91 | const invalidator = cacheTags.invalidator({ resolver }) 92 | 93 | expect(invalidator).toBeInstanceOf(Function) 94 | }) 95 | 96 | it('should use tags resolver', async () => { 97 | const resolver: TagsResolver = jest.fn(() => []) 98 | const cacheTags = new CacheTags({ registry }) 99 | const invalidator = cacheTags.invalidator({ resolver }) 100 | 101 | const { req, res } = createMocks() 102 | await invalidator(req, res) 103 | 104 | expect(resolver).toHaveBeenCalledWith(req, res) 105 | }) 106 | 107 | it('should use hash generator for resolved tags', async () => { 108 | const resolver = () => ['tag-1', 'tag-2'] 109 | const generateHash = jest.fn(() => 'hash-1') 110 | const cacheTags = new CacheTags({ registry, generateHash }) 111 | const invalidator = cacheTags.invalidator({ resolver }) 112 | 113 | const { req, res } = getNetworkMock() 114 | await invalidator(req, res) 115 | 116 | expect(generateHash).toHaveBeenCalledWith('tag-1') 117 | expect(generateHash).toHaveBeenCalledWith('tag-2') 118 | }) 119 | 120 | it('should execute revalidation for related paths', async () => { 121 | const cacheTags = new CacheTags({ registry, generateHash: false }) 122 | const invalidator = cacheTags.invalidator({ resolver }) 123 | 124 | cacheTags.registerPath('/some-path', ['tag-1', 'tag-2']) 125 | cacheTags.registerPath('/other-path', ['tag-1']) 126 | cacheTags.registerPath('/unaffected-path', ['tag-3']) 127 | 128 | const { req, res } = getNetworkMock({ query: { tag: 'tag-1' } }) 129 | 130 | await invalidator(req, res) 131 | 132 | expect(res.revalidate).toHaveBeenCalledWith('/some-path') 133 | expect(res.revalidate).toHaveBeenCalledWith('/other-path') 134 | }) 135 | 136 | it('should execute onSuccess callback with invalidating tags', async () => { 137 | const onSuccess = jest.fn() 138 | const cacheTags = new CacheTags({ registry, generateHash: false }) 139 | const invalidator = cacheTags.invalidator({ resolver, onSuccess }) 140 | 141 | cacheTags.registerPath('/some-path', ['tag-1', 'tag-2']) 142 | 143 | const { req, res } = getNetworkMock({ query: { tag: 'tag-1' } }) 144 | 145 | await invalidator(req, res) 146 | 147 | expect(onSuccess).toHaveBeenCalledWith(req, res, ['tag-1']) 148 | }) 149 | 150 | it('should execute onError callback upon invalidating error', async () => { 151 | const onError = jest.fn() 152 | const cacheTags = new CacheTags({ 153 | registry: { 154 | ...registry, 155 | extract: () => { 156 | throw new Error('Simulated error') 157 | }, 158 | }, 159 | generateHash: false, 160 | }) 161 | 162 | const invalidator = cacheTags.invalidator({ resolver, onError }) 163 | 164 | cacheTags.registerPath('/some-path', ['tag-1', 'tag-2']) 165 | cacheTags.registerPath('/other-path', ['tag-1']) 166 | 167 | const { req, res } = getNetworkMock({ query: { tag: 'tag-1' } }) 168 | 169 | await invalidator(req, res) 170 | 171 | expect(onError).toHaveBeenCalledWith( 172 | expect.any(Error), 173 | req, 174 | res, 175 | undefined 176 | ) 177 | }) 178 | 179 | it('should send default successful response', async () => { 180 | const cacheTags = new CacheTags({ registry }) 181 | const invalidator = cacheTags.invalidator({ resolver }) 182 | 183 | const { req, res } = getNetworkMock({ query: { tag: 'tag-1' } }) 184 | res.send = jest.fn(res.send) 185 | 186 | await invalidator(req, res) 187 | 188 | expect(res.send).toHaveBeenCalledWith('ok') 189 | }) 190 | 191 | it('should send default error response', async () => { 192 | const extract = () => Promise.reject(new Error('Simulated error')) 193 | const cacheTags = new CacheTags({ registry: { ...registry, extract } }) 194 | const invalidator = cacheTags.invalidator({ resolver }) 195 | 196 | const { req, res } = getNetworkMock({ query: { tag: 'tag-1' } }) 197 | 198 | res.status = jest.fn(res.status) 199 | res.json = jest.fn(res.json) 200 | 201 | await invalidator(req, res) 202 | 203 | expect(res.status).toHaveBeenCalledWith(500) 204 | expect(res.json).toHaveBeenCalledWith({ message: 'Simulated error' }) 205 | }) 206 | }) 207 | }) 208 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Next.js Cache Tags 4 | 5 | Active ISR revalidation based on surrogate keys for Next.js 6 | 7 | [![NPM version](https://badge.fury.io/js/next-cache-tags.svg)](https://badge.fury.io/js/next-cache-tags) [![Test Coverage](https://api.codeclimate.com/v1/badges/24784ad6c2db3229d036/test_coverage)](https://codeclimate.com/github/lucasconstantino/next-cache-tags/test_coverage) [![Maintainability](https://api.codeclimate.com/v1/badges/24784ad6c2db3229d036/maintainability)](https://codeclimate.com/github/lucasconstantino/next-cache-tags/maintainability) 8 | 9 |
10 | 11 | ## Motivation & Background 12 | 13 | This library intends to simplify the adoption of a caching and active invalidation strategy meant for applications that have constant updates to non-personalized data/content. 14 | 15 |
16 | Read more 17 | 18 | --- 19 | 20 | Caching is a must for any serious application. Processing outcomes every time they are requested is not only a waste of resources that can lead to insane costs once user bases grow, it also damages the user experience: poor performance, instability, unreliability, and so on. On the context of web applications, this problem is even bigger as we entirely rely on client/server communication. 21 | 22 | Vercel's Next.js is heavily dependent and encouraging of caching. Don't be mistaken: caching doesn't mean you need headers, CDNs, etc: statically built web pages that are served as is, with no further server processing, are perhaps the most aggressive form of caching we have today – and Next.js is a master at it. Anything it can transform into static files, it will. 23 | 24 | But, any sort of caching has a huge drawback: it utterly kills dynamicity. 25 | 26 | ### ♻️ Cache renewal 27 | 28 | The only way to overcome the dynamicity loss, is to renew the cache. Putting it simple, it generally means _removing_ a cache so that further requests for that piece of information get dynamically created by the server from scratch – and eventually cached once again. But there are many competing terms and strategies here, so let's bring some clarity: 29 | 30 | - **Purge**: means _remove_ or _delete_. Upon a subsequent request, there is simply no cache and the system will naturally hit the server for a fresh data. 31 | - **Invalidate**: means _marking_ the cache as outdated. Upon a subsequent request, there are three usual response behaviors depending on the consumer system needs: 32 | - Renew: the request goes through, acting like if no cache was there. 33 | - Stale: the cache is returned, acting like if the cache was valid still. 34 | - Stale while revalidate: the cached value is returned, but a parallel process goes through to the server, ensuring the cache is eventually renewed for posterior requests. 35 | - **Revalidate**: means actively _recreating_ a cache, even if no consumer requested the data. This is a common strategy on backend in general, when it populates a cache system – often using a Redis store – so that the computed information is promptly available for further operations that may need it. 36 | 37 | ### ⚡ Fast vs. Fresh 🌱 38 | 39 | We want ([and need](https://www.portent.com/blog/analytics/research-site-speed-hurting-everyones-revenue.htm)) websites to be _fast_. As immediate as possible. But, we also want (and need) websites to be _fresh_: outdated content being shown can cause confusion, bugs, and even direct conversion losses. Caching heavily, but renewing the cache immediately when information changes, is the solution; but it isn't an easy one to achieve. 40 | 41 | The problem can be narrowed down to this: 42 | 43 | > How can one ensure the most amount of **cache hits** possible, while also ensuring the delivery of the **latest available data** possible? 44 | 45 | You have probably heard this quote before: 46 | 47 |
48 |

There are only two hard things in Computer Science: cache invalidation and naming things.

49 | –– Phil Karlton 50 |
51 | 52 | This quote might be controversial, but it summarizes well how much software engineers see this problem's complexity as a consensus. 53 | 54 | ### ♜ Strategies 55 | 56 | There are infinite ways to be smart about the invalidation problem. Different strategies for both caching and for invalidation. Their core concept will usually be: _some data changed on the data store, thus the cache must be renewed_. We'll cover a couple of common options supported by Next.js 57 | 58 | #### 1. Static Pages 59 | 60 | Next.js will [_always_](https://nextjs.org/docs/advanced-features/automatic-static-optimization) try to prerender pages on build time, and leave them be. On this strategy, the only way to update the pages is by triggering a new build – which is completely fine for small websites, but terrifying when you have thousands of pages based on content that can change regularly. 61 | 62 | #### 2. Expiration Time 63 | 64 | The easist way possible is also the most widely used one: invalidating the cache on a fixed interval. This is what we know as Time to Live (TTL). 65 | 66 | In Next.js, there are two main ways to implement TTL cache: 67 | 68 | ##### A) `Cache-Control` header: 69 | 70 | Either set via [`headers`](https://nextjs.org/docs/api-reference/next.config.js/headers) config on `next.config.js`, or via `res.setHeader` on SSR pages, API Routes, and middlewares. 71 | 72 | ##### B) `revalidate` on `getStaticProps`: 73 | 74 | The [`revalidate`](https://nextjs.org/docs/api-reference/data-fetching/get-static-props#revalidate) return property from `getStaticProps` functions determine the amount in seconds after which the page will be re-generated. That's generally a great solution for data that doesn't change often, such as blog pages, etc. 75 | 76 | > Keep in mind that this setting works using `stale-while-revalidate`, meaning that past the number of seconds set here, the first request will _trigger_ a rebuild, while still returning the stale output. Only subsequent requests will benefit from the revalidation. 77 | 78 | #### 3. On-demand Revalidation 79 | 80 | Since Next.js 12.1 [introduced on-demand Incremental Static Regeneration](https://nextjs.org/blog/next-12-1#on-demand-incremental-static-regeneration-beta), it's now possible to actively rebuild prerendered pages. This is done using the `res.revalidate` method inside API Route handlers. Usually, this means that your data store – a CMS, for instance – will dispatch a request to an API Route in your system (aka a "webhook"), sending as payload some information about the change made to the data, and your API Route will trigger a rebuild to any page that may have being affected by that change. 81 | 82 | ## The problem 83 | 84 | Definiting the exact pages that need rebuild upon specific data changes is a pretty complex thing to do. When you have an ecommerce, for instance, it might be very hard to determine that a product page should be rebuild when the product's price gets updated on your store, but what about other pages where this product might also be shown, such as listing pages, or even other product pages in a "related product" session? 85 | 86 | ## The solution 87 | 88 | Although there are many ways to tackle this kind of problem, one of them has being widely adopted by CDNs and caching layers such as reverse proxies: tagging the cached resource with tags that identify the source data used to generate the cache. Basically, the idea consists of creating a map of tags to cached resources, so that if some data changes, we can resolve which tags were affected, and thus renew every single cached item that was originally generated using that specific data. 89 | 90 | The following table showcases a map of cached resources (in our case, pages identified by their pathnames) and the tags used for each resource: 91 | 92 | - Given that there are 3 products in the system, 93 | - Given that "Product One" is related to "Product Two" 94 | - Given that all products are listed in the home-page 95 | 96 | | Resource\Tag | `products` | `product:1` | `product:2` | `product:3` | `home` | 97 | | ---------------- | ---------- | ----------- | ----------- | ----------- | ------ | 98 | | `/product-one` | ✅ | ✅ | ✅ | ❌ | ❌ | 99 | | `/product-two` | ✅ | ✅ | ✅ | ❌ | ❌ | 100 | | `/product-three` | ✅ | ❌ | ❌ | ✅ | ❌ | 101 | | `/` | ✅ | ✅ | ✅ | ✅ | ✅ | 102 | 103 | - Invalidating `product:1` tag would re-render pages `/product-one`, `/product-two`, and `/` 104 | - Invalidating `product:2` tag would re-render pages `/product-one`, `/product-two`, and `/` 105 | - Invalidating `product:3` tag would re-render pages `/product-three` and `/` 106 | - Invalidating `products` would re-render all pages 107 | - Invalidating `home` tag would re-render page `/` only 108 | 109 | > [Fastly](https://docs.fastly.com/en/guides/working-with-surrogate-keys) has a CDN well know for early supporting this technique for invalidation, and is a great source for understanding the concepts around it. While other CDNs do support it, some have being way behind in this matter for ages, such as AWS's CloudFront. In fact, [Varnish Cache](http://varnish-cache.org/) (not a scam! just an ugly website...) open-source project was perhaps the first to provide such feature, and Fastly being build on top of it is what brings it to that CDN. 110 | 111 | ## This library 112 | 113 | `next-cache-tags` introduces a way to use the same strategy, but instead of depending on a reverse-proxy/CDN, it achieves that by using Next.js ISR to re-render pages statically upon data changes. 114 | 115 | This library provides a [Redis](./src/lib/registry/redis.ts) based data-source, but you can create any other adaptor so long as it implements [`CacheTagsRegistry`](./src/lib/registry/type.ts) interface. 116 | 117 |
118 | 119 | --- 120 | 121 | ## Getting Started 122 | 123 | ### 1. Install 124 | 125 | ```shell 126 | yarn add next-cache-tags redis 127 | ``` 128 | 129 | > In case you intend to create your own data-source, you don't need to install `redis`. 130 | 131 | ### 2. Instantiate a client 132 | 133 | ```ts 134 | // /src/lib/cache-tags.ts 135 | 136 | import { CacheTags, RedisCacheTagsRegistry } from 'next-cache-tags' 137 | 138 | export const cacheTags = new CacheTags({ 139 | registry: new RedisCacheTagsRegistry({ 140 | url: process.env.CACHE_TAGS_REDIS_URL 141 | }) 142 | }) 143 | ``` 144 | 145 | ### 3. Tag pages 146 | 147 | On any page that implements `getStaticProps`, register the page with cache tags. Usually, those tags will be related to the page's content – such as a product page and related products: 148 | 149 | ```ts 150 | // /src/pages/product/[id].tsx 151 | 152 | import { cacheTags } from '../../lib/cache-tags' 153 | 154 | type Product = { 155 | id: string 156 | name: string 157 | relatedProducts: string[] 158 | } 159 | 160 | export const getStaticProps = async (ctx) => { 161 | const product: Product = await loadProduct(ctx.param.id) 162 | const relatedProducts: Product[] = await loadProducts(product.relatedProducts) 163 | 164 | const ids = [product.id, ...product.relatedProducts] 165 | const tags = ids.map(id => `product:${id}`) 166 | 167 | cacheTags.register(ctx, tags) 168 | 169 | return { props: { product, relatedProducts } } 170 | } 171 | ``` 172 | 173 | ### 4. Create an invalidator 174 | 175 | Upon content updates, usually through webhooks, an API Route should be executed and should process the tags to invalidate. 176 | 177 | `next-cache-tags` provides a factory to create tag invalidation API Routes with ease: 178 | 179 | ```ts 180 | // /src/pages/api/webhook.ts 181 | 182 | import { cacheTags } from '../../lib/cache-tags' 183 | 184 | export default cacheTags.invalidator({ 185 | resolver: (req) => [req.body.product.id], 186 | }) 187 | ``` 188 | 189 | The `resolve` configuration is a function that receives the original request, and should resolve to the list of cache-tags to be invalidated. 190 | 191 | Alternatively, you can execute such invalidations manually in any API Route: 192 | 193 | ```ts 194 | // /src/pages/api/webhook.ts 195 | 196 | import { cacheTags } from '../../lib/cache-tags' 197 | 198 | const handler = (req, res) => { 199 | const tags = [req.body.product.id] 200 | 201 | // Dispatch revalidation processes. 202 | cacheTags.invalidate(res, tags) 203 | 204 | // ...do any other API Route logic 205 | } 206 | 207 | export default handler 208 | ``` 209 | 210 | ## Example 211 | 212 | Checkout the [./examples/redis](./examples/redis/) project for a complete, yet simple, use case. This project is deployed [here](https://next-cache-tags-redis-example.vercel.app/alphabet). 213 | 214 | ## Future vision 215 | 216 | ### 2023-01 217 | 218 | Vercel implemented `revalidateTag` around [March 2023](https://github.com/vercel/next.js/pull/47720). 219 | 220 |
221 | See previous comments 222 | 223 | I expect that eventually Next.js will provide an API for tagging pages. As of data-source for the cache-tags registry, it could the same storage where it stores rendered pages (S3 bucket? Probably...). Alternatively, it could integrate with [Edge Config](https://vercel.com/docs/concepts/edge-network/edge-config) for ultimate availability and performance on writting/reading from the cache-tags registry. 224 | 225 | I can imagine that this could become as simple as adding an extra property to the returned object from `getStaticProps`. Something on these lines: 226 | 227 | ```js 228 | // /src/pages/products.tsx 229 | 230 | export const getStaticProps = async () => { 231 | const products = await loadProducts() 232 | const tags = products.map(product => product.id) 233 | 234 | return { 235 | tags, 236 | props: { 237 | products 238 | } 239 | } 240 | } 241 | 242 | // /src/pages/api/revalidate.ts 243 | 244 | export default async function handler(req, res) { 245 | await res.revalidate({ tags: [req.query.tag] }) 246 | return res.status(200) 247 | } 248 | 249 | ``` 250 | 251 |
252 | 253 | ### 2023-08 254 | 255 | Vercel's implementation of cache tags is **very limited** as of now. In summary, it allows one to tag resource *requests* with tags in order to revalidate them later on: 256 | 257 | ```js 258 | // server-components 259 | fetch('https://...', { next: { tags: ['example'] } }) 260 | 261 | // /app/api/revalidate/route.ts 262 | import { revalidateTag } from 'next/cache' 263 | 264 | export async function GET(request) { 265 | revalidateTag('example') 266 | return NextResponse.json({ revalidated: true, now: Date.now() }) 267 | } 268 | ``` 269 | 270 | This approach is falty, in that it is currently not possible to tag cache items based on their *content*, but rather on their *requesting*: by the time a homepage, for instance, requests the "latest news", the system has no clue which news will be returned on the response, and therefore I cannot objectively revalidate the homepage based on changes made to the news that are displayed. If a change some news title in the system, I cannot be sure this news title is shown in the homepage, therefore any news title change will need to revalidate the homepage – even changes to news items that are *not* shown in the homepage. 271 | 272 | Fastly, or any reverse-proxy CDN for that matter, will always consume caching tags [based on the *response*](https://docs.fastly.com/en/guides/working-with-surrogate-keys#about-the-surrogate-key-header). 273 | 274 | To cricumvent this problem, I can imagine something similar to the following: 275 | 276 | ```js 277 | fetch('https://...', { next: { tags: (res) => [...] } }) 278 | ``` 279 | 280 | This would give the possibility to create precise cache tags that are based on any information responsed – be it headers, or the content payload itself. 281 | -------------------------------------------------------------------------------- /examples/redis/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@next/env@12.3.4": 6 | version "12.3.4" 7 | resolved "https://registry.yarnpkg.com/@next/env/-/env-12.3.4.tgz#c787837d36fcad75d72ff8df6b57482027d64a47" 8 | integrity sha512-H/69Lc5Q02dq3o+dxxy5O/oNxFsZpdL6WREtOOtOM1B/weonIwDXkekr1KV5DPVPr12IHFPrMrcJQ6bgPMfn7A== 9 | 10 | "@next/swc-android-arm-eabi@12.3.4": 11 | version "12.3.4" 12 | resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.3.4.tgz#fd1c2dafe92066c6120761c6a39d19e666dc5dd0" 13 | integrity sha512-cM42Cw6V4Bz/2+j/xIzO8nK/Q3Ly+VSlZJTa1vHzsocJRYz8KT6MrreXaci2++SIZCF1rVRCDgAg5PpqRibdIA== 14 | 15 | "@next/swc-android-arm64@12.3.4": 16 | version "12.3.4" 17 | resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.3.4.tgz#11a146dae7b8bca007239b21c616e83f77b19ed4" 18 | integrity sha512-5jf0dTBjL+rabWjGj3eghpLUxCukRhBcEJgwLedewEA/LJk2HyqCvGIwj5rH+iwmq1llCWbOky2dO3pVljrapg== 19 | 20 | "@next/swc-darwin-arm64@12.3.4": 21 | version "12.3.4" 22 | resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.3.4.tgz#14ac8357010c95e67327f47082af9c9d75d5be79" 23 | integrity sha512-DqsSTd3FRjQUR6ao0E1e2OlOcrF5br+uegcEGPVonKYJpcr0MJrtYmPxd4v5T6UCJZ+XzydF7eQo5wdGvSZAyA== 24 | 25 | "@next/swc-darwin-x64@12.3.4": 26 | version "12.3.4" 27 | resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.3.4.tgz#e7dc63cd2ac26d15fb84d4d2997207fb9ba7da0f" 28 | integrity sha512-PPF7tbWD4k0dJ2EcUSnOsaOJ5rhT3rlEt/3LhZUGiYNL8KvoqczFrETlUx0cUYaXe11dRA3F80Hpt727QIwByQ== 29 | 30 | "@next/swc-freebsd-x64@12.3.4": 31 | version "12.3.4" 32 | resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.3.4.tgz#fe7ceec58746fdf03f1fcb37ec1331c28e76af93" 33 | integrity sha512-KM9JXRXi/U2PUM928z7l4tnfQ9u8bTco/jb939pdFUHqc28V43Ohd31MmZD1QzEK4aFlMRaIBQOWQZh4D/E5lQ== 34 | 35 | "@next/swc-linux-arm-gnueabihf@12.3.4": 36 | version "12.3.4" 37 | resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.3.4.tgz#d7016934d02bfc8bd69818ffb0ae364b77b17af7" 38 | integrity sha512-3zqD3pO+z5CZyxtKDTnOJ2XgFFRUBciOox6EWkoZvJfc9zcidNAQxuwonUeNts6Xbm8Wtm5YGIRC0x+12YH7kw== 39 | 40 | "@next/swc-linux-arm64-gnu@12.3.4": 41 | version "12.3.4" 42 | resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.3.4.tgz#43a7bc409b03487bff5beb99479cacdc7bd29af5" 43 | integrity sha512-kiX0vgJGMZVv+oo1QuObaYulXNvdH/IINmvdZnVzMO/jic/B8EEIGlZ8Bgvw8LCjH3zNVPO3mGrdMvnEEPEhKA== 44 | 45 | "@next/swc-linux-arm64-musl@12.3.4": 46 | version "12.3.4" 47 | resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.3.4.tgz#4d1db6de6dc982b974cd1c52937111e3e4a34bd3" 48 | integrity sha512-EETZPa1juczrKLWk5okoW2hv7D7WvonU+Cf2CgsSoxgsYbUCZ1voOpL4JZTOb6IbKMDo6ja+SbY0vzXZBUMvkQ== 49 | 50 | "@next/swc-linux-x64-gnu@12.3.4": 51 | version "12.3.4" 52 | resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.3.4.tgz#c3b414d77bab08b35f7dd8943d5586f0adb15e38" 53 | integrity sha512-4csPbRbfZbuWOk3ATyWcvVFdD9/Rsdq5YHKvRuEni68OCLkfy4f+4I9OBpyK1SKJ00Cih16NJbHE+k+ljPPpag== 54 | 55 | "@next/swc-linux-x64-musl@12.3.4": 56 | version "12.3.4" 57 | resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.3.4.tgz#187a883ec09eb2442a5ebf126826e19037313c61" 58 | integrity sha512-YeBmI+63Ro75SUiL/QXEVXQ19T++58aI/IINOyhpsRL1LKdyfK/35iilraZEFz9bLQrwy1LYAR5lK200A9Gjbg== 59 | 60 | "@next/swc-win32-arm64-msvc@12.3.4": 61 | version "12.3.4" 62 | resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.3.4.tgz#89befa84e453ed2ef9a888f375eba565a0fde80b" 63 | integrity sha512-Sd0qFUJv8Tj0PukAYbCCDbmXcMkbIuhnTeHm9m4ZGjCf6kt7E/RMs55Pd3R5ePjOkN7dJEuxYBehawTR/aPDSQ== 64 | 65 | "@next/swc-win32-ia32-msvc@12.3.4": 66 | version "12.3.4" 67 | resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.3.4.tgz#cb50c08f0e40ead63642a7f269f0c8254261f17c" 68 | integrity sha512-rt/vv/vg/ZGGkrkKcuJ0LyliRdbskQU+91bje+PgoYmxTZf/tYs6IfbmgudBJk6gH3QnjHWbkphDdRQrseRefQ== 69 | 70 | "@next/swc-win32-x64-msvc@12.3.4": 71 | version "12.3.4" 72 | resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.4.tgz#d28ea15a72cdcf96201c60a43e9630cd7fda168f" 73 | integrity sha512-DQ20JEfTBZAgF8QCjYfJhv2/279M6onxFjdG/+5B0Cyj00/EdBxiWb2eGGFgQhrBbNv/lsvzFbbi0Ptf8Vw/bg== 74 | 75 | "@redis/bloom@1.1.0": 76 | version "1.1.0" 77 | resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.1.0.tgz#64e310ddee72010676e14296076329e594a1f6c7" 78 | integrity sha512-9QovlxmpRtvxVbN0UBcv8WfdSMudNZZTFqCsnBszcQXqaZb/TVe30ScgGEO7u1EAIacTPAo7/oCYjYAxiHLanQ== 79 | 80 | "@redis/client@1.4.0": 81 | version "1.4.0" 82 | resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.4.0.tgz#d2c56ce26c3e2fe3412db5cfb1814169662167eb" 83 | integrity sha512-1gEj1AkyXPlkcC/9/T5xpDcQF8ntERURjLBgEWMTdUZqe181zfI9BY3jc2OzjTLkvZh5GV7VT4ktoJG2fV2ufw== 84 | dependencies: 85 | cluster-key-slot "1.1.1" 86 | generic-pool "3.9.0" 87 | yallist "4.0.0" 88 | 89 | "@redis/graph@1.1.0": 90 | version "1.1.0" 91 | resolved "https://registry.yarnpkg.com/@redis/graph/-/graph-1.1.0.tgz#cc2b82e5141a29ada2cce7d267a6b74baa6dd519" 92 | integrity sha512-16yZWngxyXPd+MJxeSr0dqh2AIOi8j9yXKcKCwVaKDbH3HTuETpDVPcLujhFYVPtYrngSco31BUcSa9TH31Gqg== 93 | 94 | "@redis/json@1.0.4": 95 | version "1.0.4" 96 | resolved "https://registry.yarnpkg.com/@redis/json/-/json-1.0.4.tgz#f372b5f93324e6ffb7f16aadcbcb4e5c3d39bda1" 97 | integrity sha512-LUZE2Gdrhg0Rx7AN+cZkb1e6HjoSKaeeW8rYnt89Tly13GBI5eP4CwDVr+MY8BAYfCg4/N15OUrtLoona9uSgw== 98 | 99 | "@redis/search@1.1.0": 100 | version "1.1.0" 101 | resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.1.0.tgz#7abb18d431f27ceafe6bcb4dd83a3fa67e9ab4df" 102 | integrity sha512-NyFZEVnxIJEybpy+YskjgOJRNsfTYqaPbK/Buv6W2kmFNaRk85JiqjJZA5QkRmWvGbyQYwoO5QfDi2wHskKrQQ== 103 | 104 | "@redis/time-series@1.0.4": 105 | version "1.0.4" 106 | resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.0.4.tgz#af85eb080f6934580e4d3b58046026b6c2b18717" 107 | integrity sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng== 108 | 109 | "@swc/helpers@0.4.11": 110 | version "0.4.11" 111 | resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.11.tgz#db23a376761b3d31c26502122f349a21b592c8de" 112 | integrity sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw== 113 | dependencies: 114 | tslib "^2.4.0" 115 | 116 | "@types/node@^18.11.9": 117 | version "18.11.9" 118 | resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4" 119 | integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg== 120 | 121 | "@types/prop-types@*": 122 | version "15.7.5" 123 | resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" 124 | integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== 125 | 126 | "@types/react@18.0.25": 127 | version "18.0.25" 128 | resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.25.tgz#8b1dcd7e56fe7315535a4af25435e0bb55c8ae44" 129 | integrity sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g== 130 | dependencies: 131 | "@types/prop-types" "*" 132 | "@types/scheduler" "*" 133 | csstype "^3.0.2" 134 | 135 | "@types/scheduler@*": 136 | version "0.16.2" 137 | resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" 138 | integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== 139 | 140 | "@vercel/analytics@^0.1.6": 141 | version "0.1.6" 142 | resolved "https://registry.yarnpkg.com/@vercel/analytics/-/analytics-0.1.6.tgz#a1ce184168d8f5ec02e35ec954d84ee68ea01f4b" 143 | integrity sha512-zNd5pj3iDvq8IMBQHa1YRcIteiw6ZiPB8AsONHd8ieFXlNpLqhXfIYnf4WvTfZ7S1NSJ++mIM14aJnNac/VMXQ== 144 | 145 | caniuse-lite@^1.0.30001406: 146 | version "1.0.30001431" 147 | resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001431.tgz#e7c59bd1bc518fae03a4656be442ce6c4887a795" 148 | integrity sha512-zBUoFU0ZcxpvSt9IU66dXVT/3ctO1cy4y9cscs1szkPlcWb6pasYM144GqrUygUbT+k7cmUCW61cvskjcv0enQ== 149 | 150 | classnames@^2.3.2: 151 | version "2.3.2" 152 | resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" 153 | integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== 154 | 155 | cluster-key-slot@1.1.1: 156 | version "1.1.1" 157 | resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz#10ccb9ded0729464b6d2e7d714b100a2d1259d43" 158 | integrity sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw== 159 | 160 | csstype@^3.0.2: 161 | version "3.1.1" 162 | resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" 163 | integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw== 164 | 165 | dotenv@^16.0.3: 166 | version "16.0.3" 167 | resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" 168 | integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== 169 | 170 | generic-pool@3.9.0: 171 | version "3.9.0" 172 | resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.9.0.tgz#36f4a678e963f4fdb8707eab050823abc4e8f5e4" 173 | integrity sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g== 174 | 175 | "js-tokens@^3.0.0 || ^4.0.0": 176 | version "4.0.0" 177 | resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 178 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 179 | 180 | loose-envify@^1.1.0: 181 | version "1.4.0" 182 | resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 183 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 184 | dependencies: 185 | js-tokens "^3.0.0 || ^4.0.0" 186 | 187 | nanoid@^3.3.4: 188 | version "3.3.4" 189 | resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" 190 | integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== 191 | 192 | next-cache-tags@x.x.x: 193 | version "0.2.2" 194 | resolved "https://registry.yarnpkg.com/next-cache-tags/-/next-cache-tags-0.2.2.tgz#55dfb0ce901540114f62863971dd53ad7d3e85cb" 195 | integrity sha512-UNtJ1zZ6tJ8gpKLSCPDo6LdH6ZeGozdYoEuDC3hVAHTfrra3MSi7pFRE5yG2gsIsMBMecfbSWd8AotkFfGgplw== 196 | 197 | next@12.x.x: 198 | version "12.3.4" 199 | resolved "https://registry.yarnpkg.com/next/-/next-12.3.4.tgz#f2780a6ebbf367e071ce67e24bd8a6e05de2fcb1" 200 | integrity sha512-VcyMJUtLZBGzLKo3oMxrEF0stxh8HwuW976pAzlHhI3t8qJ4SROjCrSh1T24bhrbjw55wfZXAbXPGwPt5FLRfQ== 201 | dependencies: 202 | "@next/env" "12.3.4" 203 | "@swc/helpers" "0.4.11" 204 | caniuse-lite "^1.0.30001406" 205 | postcss "8.4.14" 206 | styled-jsx "5.0.7" 207 | use-sync-external-store "1.2.0" 208 | optionalDependencies: 209 | "@next/swc-android-arm-eabi" "12.3.4" 210 | "@next/swc-android-arm64" "12.3.4" 211 | "@next/swc-darwin-arm64" "12.3.4" 212 | "@next/swc-darwin-x64" "12.3.4" 213 | "@next/swc-freebsd-x64" "12.3.4" 214 | "@next/swc-linux-arm-gnueabihf" "12.3.4" 215 | "@next/swc-linux-arm64-gnu" "12.3.4" 216 | "@next/swc-linux-arm64-musl" "12.3.4" 217 | "@next/swc-linux-x64-gnu" "12.3.4" 218 | "@next/swc-linux-x64-musl" "12.3.4" 219 | "@next/swc-win32-arm64-msvc" "12.3.4" 220 | "@next/swc-win32-ia32-msvc" "12.3.4" 221 | "@next/swc-win32-x64-msvc" "12.3.4" 222 | 223 | picocolors@^1.0.0: 224 | version "1.0.0" 225 | resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" 226 | integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== 227 | 228 | postcss@8.4.14: 229 | version "8.4.14" 230 | resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf" 231 | integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== 232 | dependencies: 233 | nanoid "^3.3.4" 234 | picocolors "^1.0.0" 235 | source-map-js "^1.0.2" 236 | 237 | prettier@^2.7.1: 238 | version "2.7.1" 239 | resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" 240 | integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== 241 | 242 | react-circular-progressbar@^2.1.0: 243 | version "2.1.0" 244 | resolved "https://registry.yarnpkg.com/react-circular-progressbar/-/react-circular-progressbar-2.1.0.tgz#99e5ae499c21de82223b498289e96f66adb8fa3a" 245 | integrity sha512-xp4THTrod4aLpGy68FX/k1Q3nzrfHUjUe5v6FsdwXBl3YVMwgeXYQKDrku7n/D6qsJA9CuunarAboC2xCiKs1g== 246 | 247 | react-dom@18.2.0: 248 | version "18.2.0" 249 | resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" 250 | integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== 251 | dependencies: 252 | loose-envify "^1.1.0" 253 | scheduler "^0.23.0" 254 | 255 | react@18.2.0: 256 | version "18.2.0" 257 | resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" 258 | integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== 259 | dependencies: 260 | loose-envify "^1.1.0" 261 | 262 | redis@^4.5.0: 263 | version "4.5.0" 264 | resolved "https://registry.yarnpkg.com/redis/-/redis-4.5.0.tgz#8a461c8718e380ea899ba3711aa0bb217b112089" 265 | integrity sha512-oZGAmOKG+RPnHo0UxM5GGjJ0dBd/Vi4fs3MYwM1p2baDoXC0wpm0yOdpxVS9K+0hM84ycdysp2eHg2xGoQ4FEw== 266 | dependencies: 267 | "@redis/bloom" "1.1.0" 268 | "@redis/client" "1.4.0" 269 | "@redis/graph" "1.1.0" 270 | "@redis/json" "1.0.4" 271 | "@redis/search" "1.1.0" 272 | "@redis/time-series" "1.0.4" 273 | 274 | scheduler@^0.23.0: 275 | version "0.23.0" 276 | resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" 277 | integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== 278 | dependencies: 279 | loose-envify "^1.1.0" 280 | 281 | source-map-js@^1.0.2: 282 | version "1.0.2" 283 | resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" 284 | integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== 285 | 286 | styled-jsx@5.0.7: 287 | version "5.0.7" 288 | resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.7.tgz#be44afc53771b983769ac654d355ca8d019dff48" 289 | integrity sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA== 290 | 291 | tslib@^2.4.0: 292 | version "2.4.1" 293 | resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" 294 | integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== 295 | 296 | typescript@4.9.3: 297 | version "4.9.3" 298 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.3.tgz#3aea307c1746b8c384435d8ac36b8a2e580d85db" 299 | integrity sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA== 300 | 301 | use-sync-external-store@1.2.0: 302 | version "1.2.0" 303 | resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" 304 | integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== 305 | 306 | yallist@4.0.0: 307 | version "4.0.0" 308 | resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" 309 | integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== 310 | --------------------------------------------------------------------------------