From e2f3059b48a779c2a755c21da26570d251305c01 Mon Sep 17 00:00:00 2001 From: Arseny <132773930+better-salmon@users.noreply.github.com> Date: Thu, 9 Nov 2023 17:29:11 +0300 Subject: [PATCH] Add a self-hosted shared cache example (#58000) ### What? This pull request integrates the exemplary setup for a self-hosted Next.js application utilizing Redis as a shared cache storage. The solution supports caching at both the App and Pages routers in default and standalone modes, as well as partial pre-rendering, facilitated by the [`@neshca/cache-handler`](https://github.com/caching-tools/next-shared-cache/tree/canary/packages/cache-handler) package. The package enables customizing cache handlers and replacing the default cache provided by Next.js seamlessly. ### Why? The motivation behind this pull request is to provide an example demonstrating how Redis can be used as a shared cache in a self-hosted environment, thereby improving the scalability of hosting multiple instances of a Next.js application. --- .../08-deploying/index.mdx | 5 +- examples/cache-handler-redis/.gitignore | 36 ++++++ examples/cache-handler-redis/README.md | 77 +++++++++++++ .../app/[timezone]/page.tsx | 68 ++++++++++++ .../app/cache-state-watcher.tsx | 50 +++++++++ examples/cache-handler-redis/app/global.css | 102 +++++++++++++++++ examples/cache-handler-redis/app/layout.tsx | 13 +++ .../app/revalidate-from.tsx | 27 +++++ .../cache-handler-redis/app/server-actions.ts | 7 ++ .../cache-handler-redis-stack.js | 98 +++++++++++++++++ .../cache-handler-redis.js | 103 ++++++++++++++++++ .../cache-handler-redis/docker-compose.yml | 9 ++ examples/cache-handler-redis/next.config.js | 18 +++ examples/cache-handler-redis/package.json | 22 ++++ .../cache-handler-redis/public/favicon.ico | Bin 0 -> 15086 bytes examples/cache-handler-redis/tsconfig.json | 25 +++++ 16 files changed, 657 insertions(+), 3 deletions(-) create mode 100644 examples/cache-handler-redis/.gitignore create mode 100644 examples/cache-handler-redis/README.md create mode 100644 examples/cache-handler-redis/app/[timezone]/page.tsx create mode 100644 examples/cache-handler-redis/app/cache-state-watcher.tsx create mode 100644 examples/cache-handler-redis/app/global.css create mode 100644 examples/cache-handler-redis/app/layout.tsx create mode 100644 examples/cache-handler-redis/app/revalidate-from.tsx create mode 100644 examples/cache-handler-redis/app/server-actions.ts create mode 100644 examples/cache-handler-redis/cache-handler-redis-stack.js create mode 100644 examples/cache-handler-redis/cache-handler-redis.js create mode 100644 examples/cache-handler-redis/docker-compose.yml create mode 100644 examples/cache-handler-redis/next.config.js create mode 100644 examples/cache-handler-redis/package.json create mode 100644 examples/cache-handler-redis/public/favicon.ico create mode 100644 examples/cache-handler-redis/tsconfig.json diff --git a/docs/02-app/01-building-your-application/08-deploying/index.mdx b/docs/02-app/01-building-your-application/08-deploying/index.mdx index e9272ab7486e3..db0d4a0ea958e 100644 --- a/docs/02-app/01-building-your-application/08-deploying/index.mdx +++ b/docs/02-app/01-building-your-application/08-deploying/index.mdx @@ -153,7 +153,7 @@ module.exports = { } ``` -Then, create `cache-handler.js` in the root of your project. This file can save the cached values anywhere, like Redis or AWS S3, for example: +Then, create `cache-handler.js` in the root of your project, for example: ```jsx filename="cache-handler.js" const cache = new Map() @@ -161,7 +161,6 @@ const cache = new Map() module.exports = class CacheHandler { constructor(options) { this.options = options - this.cache = {} } async get(key) { @@ -190,7 +189,7 @@ module.exports = class CacheHandler { } ``` -Using a custom cache handler will allow you to ensure consistency across all pods hosting your Next.js application. +Using a custom cache handler will allow you to ensure consistency across all pods hosting your Next.js application. For instance, you can save the cached values anywhere, like [Redis](https://github.com/vercel/next.js/tree/canary/examples/cache-handler-redis) or AWS S3. > **Good to know:** > diff --git a/examples/cache-handler-redis/.gitignore b/examples/cache-handler-redis/.gitignore new file mode 100644 index 0000000000000..fd3dbb571a12a --- /dev/null +++ b/examples/cache-handler-redis/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/cache-handler-redis/README.md b/examples/cache-handler-redis/README.md new file mode 100644 index 0000000000000..b541892b6349b --- /dev/null +++ b/examples/cache-handler-redis/README.md @@ -0,0 +1,77 @@ +# Next.js Redis Cache Integration Example + +This repository provides a production-ready example of how to enhance the caching capabilities of Next.js and use Redis to share the cache for multiple instances of your app. It's made possible by the [`@neshca/cache-handler`](https://github.com/caching-tools/next-shared-cache/tree/canary/packages/cache-handler) package, which replaces the default Next.js cache handler while preserving the original functionality of reading pre-rendered pages from the file system. + +This particular example is designed to be self-hosted. + +## How to use + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example: + +```bash + +npx create-next-app --example cache-handler-redis cache-handler-redis-app +``` + +```bash +yarn create next-app --example cache-handler-redis cache-handler-redis-app +``` + +```bash +pnpm create next-app --example cache-handler-redis cache-handler-redis-app +``` + +Once you have installed the dependencies, you can begin running the example Redis Stack server by using the following command: + +```bash +docker-compose up -d +``` + +Then, build and start the Next.js app as usual. + +## Notes + +- **Think different:** Ensure that your Redis server is operational and accessible before starting your Next.js application to prevent any connection errors. Remember to flush the cache or use namespacing if you preserve the Redis instance between builds. + +- **Configure:** Add your Redis credentials to the provided `cache-handler-redis*` files. Learn more about connecting to Redis with Node.js [here](https://redis.io/docs/connect/clients/nodejs/). + +- **Opt out of Redis during build if needed:** + To build your Next.js app without connecting to Redis, wrap the `onCreation` callback with a condition as shown below: + + ```js + if (process.env.SERVER_STARTED) { + IncrementalCache.onCreation(() => { + // Your code here + }) + } + ``` + + This condition helps avoid potential issues if your Redis server is deployed concurrently with the app build. + +- **Opt out file system reads, writes or both:** + By default, the `@neshca/cache-handler` uses the file system to preserve the original behavior of Next.js, for instance, reading pre-rendered pages from the Pages dir. To opt out of this functionality, add the `diskAccessMode` option: + + ```js + IncrementalCache.onCreation(() => { + return { + diskAccessMode: 'read-no/write-no', // Default is 'read-yes/write-yes' + cache: { + // The same cache configuration as in the example + }, + } + }) + ``` + + This may be useful if you use only App dir and don't mind if Redis instance fails. + +Provided `docker-compose.yml` is for local development only. It is not suitable for production use. Read more about [Redis installation](https://redis.io/docs/install/) and [management](https://redis.io/docs/management/) before deploying your application to production. + +### How to clear the Redis cache + +If you need to clear the Redis cache, use RedisInsight Workbench or run the following command: + +```bash +docker exec -it redis-stack redis-cli +127.0.0.1:6379> flushall +OK +``` diff --git a/examples/cache-handler-redis/app/[timezone]/page.tsx b/examples/cache-handler-redis/app/[timezone]/page.tsx new file mode 100644 index 0000000000000..8c518c7b23863 --- /dev/null +++ b/examples/cache-handler-redis/app/[timezone]/page.tsx @@ -0,0 +1,68 @@ +import { notFound } from 'next/navigation' +import { CacheStateWatcher } from '../cache-state-watcher' +import { Suspense } from 'react' +import { RevalidateFrom } from '../revalidate-from' +import Link from 'next/link' + +type TimeData = { + unixtime: number + datetime: string + timezone: string +} + +const timeZones = ['cet', 'gmt'] + +export const revalidate = 10 + +export async function generateStaticParams() { + return timeZones.map((timezone) => ({ timezone })) +} + +export default async function Page({ params: { timezone } }) { + const data = await fetch( + `https://worldtimeapi.org/api/timezone/${timezone}`, + { + next: { tags: ['time-data'] }, + } + ) + + if (!data.ok) { + notFound() + } + + const timeData: TimeData = await data.json() + + return ( + <> +
+ {timeZones.map((timeZone) => ( + + {timeZone.toUpperCase()} Time + + ))} +
+
+
+ {timeData.timezone} Time {timeData.datetime} +
+ + + + +
+ + + ) +} diff --git a/examples/cache-handler-redis/app/cache-state-watcher.tsx b/examples/cache-handler-redis/app/cache-state-watcher.tsx new file mode 100644 index 0000000000000..62ea8d321c9fd --- /dev/null +++ b/examples/cache-handler-redis/app/cache-state-watcher.tsx @@ -0,0 +1,50 @@ +'use client' + +import { useEffect, useState } from 'react' + +type CacheStateWatcherProps = { time: number; revalidateAfter: number } + +export function CacheStateWatcher({ + time, + revalidateAfter, +}: CacheStateWatcherProps): JSX.Element { + const [cacheState, setCacheState] = useState('') + const [countDown, setCountDown] = useState('') + + useEffect(() => { + let id = -1 + + function check(): void { + const now = Date.now() + + setCountDown( + Math.max(0, (time + revalidateAfter - now) / 1000).toFixed(3) + ) + + if (now > time + revalidateAfter) { + setCacheState('stale') + + return + } + + setCacheState('fresh') + + id = requestAnimationFrame(check) + } + + id = requestAnimationFrame(check) + + return () => { + cancelAnimationFrame(id) + } + }, [revalidateAfter, time]) + + return ( + <> +
+ Cache state: {cacheState} +
+
Stale in: {countDown}
+ + ) +} diff --git a/examples/cache-handler-redis/app/global.css b/examples/cache-handler-redis/app/global.css new file mode 100644 index 0000000000000..4067a2fffd44c --- /dev/null +++ b/examples/cache-handler-redis/app/global.css @@ -0,0 +1,102 @@ +*, +*:before, +*:after { + box-sizing: border-box; +} + +body, +html { + margin: 0; + padding: 0; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + color: #333; + line-height: 1.6; + background-color: #f4f4f4; +} + +.widget { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + margin: 20px auto; + padding: 20px; + max-width: 600px; + text-align: center; +} + +.pre-rendered-at, +.cache-state, +.stale-after { + font-size: 0.9em; + color: #666; + margin: 5px 0; +} + +.cache-state.fresh { + color: #4caf50; +} + +.cache-state.stale { + color: #f44336; +} + +.revalidate-from { + margin-top: 20px; +} + +.revalidate-from-button { + background-color: #008cba; + color: white; + border: none; + border-radius: 4px; + padding: 10px 20px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.revalidate-from-button:hover { + background-color: #005f73; +} + +.revalidate-from-button:active { + transform: translateY(2px); +} + +.revalidate-from-button[aria-disabled='true'] { + background-color: #ccc; + cursor: not-allowed; +} + +.footer, +.header { + padding: 10px; + position: relative; + place-items: center; + grid-auto-flow: column; + bottom: 0; + grid-gap: 20px; + width: 100%; + display: grid; + justify-content: center; +} + +.link { + color: #09f; + text-decoration: none; + transition: color 0.3s ease; +} + +.link:hover { + color: #07c; +} + +@media (max-width: 768px) { + .widget { + width: 90%; + margin: 20px auto; + } + + .footer { + padding: 20px; + } +} diff --git a/examples/cache-handler-redis/app/layout.tsx b/examples/cache-handler-redis/app/layout.tsx new file mode 100644 index 0000000000000..4eafeb7f13cf2 --- /dev/null +++ b/examples/cache-handler-redis/app/layout.tsx @@ -0,0 +1,13 @@ +import './global.css' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/examples/cache-handler-redis/app/revalidate-from.tsx b/examples/cache-handler-redis/app/revalidate-from.tsx new file mode 100644 index 0000000000000..9ae1ee479c625 --- /dev/null +++ b/examples/cache-handler-redis/app/revalidate-from.tsx @@ -0,0 +1,27 @@ +'use client' + +import { useFormStatus } from 'react-dom' +import revalidate from './server-actions' + +function RevalidateButton() { + const { pending } = useFormStatus() + + return ( + + ) +} + +export function RevalidateFrom() { + return ( +
+ + + ) +} diff --git a/examples/cache-handler-redis/app/server-actions.ts b/examples/cache-handler-redis/app/server-actions.ts new file mode 100644 index 0000000000000..cbe24b156b6b8 --- /dev/null +++ b/examples/cache-handler-redis/app/server-actions.ts @@ -0,0 +1,7 @@ +'use server' + +import { revalidateTag } from 'next/cache' + +export default async function revalidate() { + revalidateTag('time-data') +} diff --git a/examples/cache-handler-redis/cache-handler-redis-stack.js b/examples/cache-handler-redis/cache-handler-redis-stack.js new file mode 100644 index 0000000000000..6390f16cd6f45 --- /dev/null +++ b/examples/cache-handler-redis/cache-handler-redis-stack.js @@ -0,0 +1,98 @@ +const { IncrementalCache } = require('@neshca/cache-handler') +const { createClient } = require('redis') + +/** @type {import('@neshca/cache-handler').TagsManifest} */ +let localTagsManifest = { + version: 1, + items: {}, +} + +const TAGS_MANIFEST_KEY = 'sharedTagsManifest' + +function createRedisClient(url) { + const client = createClient({ + url, + }) + + client.on('error', (error) => { + console.error('Redis error:', error.message) + }) + + return client +} + +async function connectAndSetManifest(client) { + try { + await client.connect() + } catch (error) { + console.error('Redis connection error:', error.message) + } + + try { + await client.json.set(TAGS_MANIFEST_KEY, '.', localTagsManifest, { + NX: true, + }) + } catch (error) { + console.error('Redis set tagsManifest error:', error.message) + } +} + +const client = createRedisClient( + process.env.REDIS_URL ?? 'redis://localhost:6379' +) + +connectAndSetManifest(client).then(() => { + console.log('Redis connected') +}) + +IncrementalCache.onCreation(() => { + return { + cache: { + async get(key) { + try { + const value = (await client.json.get(key)) ?? null + + if (value && value.kind === 'ROUTE' && value.body.type === 'Buffer') { + value.body = Buffer.from(value.body) + } + + return value + } catch (error) { + return null + } + }, + async set(key, value) { + try { + await client.json.set(key, '.', value) + } catch (error) { + // ignore because value will be written to disk + } + }, + async getTagsManifest() { + try { + const sharedTagsManifest = + (await client.json.get(TAGS_MANIFEST_KEY)) ?? null + + if (sharedTagsManifest) { + localTagsManifest = sharedTagsManifest + } + + return sharedTagsManifest + } catch (error) { + return localTagsManifest + } + }, + async revalidateTag(tag, revalidatedAt) { + try { + await client.json.set(TAGS_MANIFEST_KEY, `.items.${tag}`, { + revalidatedAt, + }) + } catch (error) { + localTagsManifest.items[tag] = { revalidatedAt } + } + }, + }, + } +}) + +module.exports = IncrementalCache diff --git a/examples/cache-handler-redis/cache-handler-redis.js b/examples/cache-handler-redis/cache-handler-redis.js new file mode 100644 index 0000000000000..811861edd08d9 --- /dev/null +++ b/examples/cache-handler-redis/cache-handler-redis.js @@ -0,0 +1,103 @@ +const { + reviveFromBase64Representation, + replaceJsonWithBase64, +} = require('@neshca/json-replacer-reviver') +const { IncrementalCache } = require('@neshca/cache-handler') +const { createClient } = require('redis') + +/** @type {import('@neshca/cache-handler').TagsManifest} */ +const localTagsManifest = { + version: 1, + items: {}, +} + +const TAGS_MANIFEST_KEY = 'sharedTagsManifest' + +function createRedisClient(url) { + const client = createClient({ + url, + }) + + client.on('error', (error) => { + console.error('Redis error:', error.message) + }) + + return client +} + +async function connect(client) { + try { + await client.connect() + } catch (error) { + console.error('Redis connection error:', error.message) + } +} + +IncrementalCache.onCreation(() => { + const client = createRedisClient( + process.env.REDIS_URL ?? 'redis://localhost:6379' + ) + + connect(client).then(() => { + console.log('Redis connected') + }) + + return { + cache: { + async get(key) { + try { + const result = (await client.get(key)) ?? null + + if (!result) { + return null + } + + // use reviveFromBase64Representation to restore binary data from Base64 + return JSON.parse(result, reviveFromBase64Representation) + } catch (error) { + return null + } + }, + async set(key, value) { + try { + // use replaceJsonWithBase64 to store binary data in Base64 and save space + await client.set(key, JSON.stringify(value, replaceJsonWithBase64)) + } catch (error) { + // ignore because value will be written to disk + } + }, + async getTagsManifest() { + try { + const remoteTagsManifest = await client.hGetAll(TAGS_MANIFEST_KEY) + + if (!remoteTagsManifest) { + return localTagsManifest + } + + Object.entries(remoteTagsManifest).reduce( + (acc, [tag, revalidatedAt]) => { + acc[tag] = { revalidatedAt: parseInt(revalidatedAt ?? '0', 10) } + return acc + }, + localTagsManifest.items + ) + + return localTagsManifest + } catch (error) { + return localTagsManifest + } + }, + async revalidateTag(tag, revalidatedAt) { + try { + await client.hSet(TAGS_MANIFEST_KEY, { + [tag]: revalidatedAt, + }) + } catch (error) { + localTagsManifest.items[tag] = { revalidatedAt } + } + }, + }, + } +}) + +module.exports = IncrementalCache diff --git a/examples/cache-handler-redis/docker-compose.yml b/examples/cache-handler-redis/docker-compose.yml new file mode 100644 index 0000000000000..49bc0399c44b0 --- /dev/null +++ b/examples/cache-handler-redis/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.8' + +services: + redis-stack: + image: redis/redis-stack:latest + container_name: cache-handler-redis + ports: + - '6379:6379' + - '8001:8001' diff --git a/examples/cache-handler-redis/next.config.js b/examples/cache-handler-redis/next.config.js new file mode 100644 index 0000000000000..3f3b96974cb4a --- /dev/null +++ b/examples/cache-handler-redis/next.config.js @@ -0,0 +1,18 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + incrementalCacheHandlerPath: + process.env.NODE_ENV === 'production' + ? require.resolve( + // './cache-handler-redis.js' // if you're using Redis without JSON support + './cache-handler-redis-stack.js' + ) + : undefined, + }, + env: { + NEXT_PUBLIC_REDIS_INSIGHT_URL: + process.env.REDIS_INSIGHT_URL ?? 'http://localhost:8001', + }, +} + +module.exports = nextConfig diff --git a/examples/cache-handler-redis/package.json b/examples/cache-handler-redis/package.json new file mode 100644 index 0000000000000..1443815ff8ad7 --- /dev/null +++ b/examples/cache-handler-redis/package.json @@ -0,0 +1,22 @@ +{ + "private": true, + "scripts": { + "dev": "next", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "latest", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@neshca/cache-handler": "latest", + "@neshca/json-replacer-reviver": "latest", + "@types/node": "^20.9.0", + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "redis": "latest", + "typescript": "^5.2.2" + } +} diff --git a/examples/cache-handler-redis/public/favicon.ico b/examples/cache-handler-redis/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4965832f2c9b0605eaa189b7c7fb11124d24e48a GIT binary patch literal 15086 zcmeHOOH5Q(7(R0cc?bh2AT>N@1PWL!LLfZKyG5c!MTHoP7_p!sBz0k$?pjS;^lmgJ zU6^i~bWuZYHL)9$wuvEKm~qo~(5=Lvx5&Hv;?X#m}i|`yaGY4gX+&b>tew;gcnRQA1kp zBbm04SRuuE{Hn+&1wk%&g;?wja_Is#1gKoFlI7f`Gt}X*-nsMO30b_J@)EFNhzd1QM zdH&qFb9PVqQOx@clvc#KAu}^GrN`q5oP(8>m4UOcp`k&xwzkTio*p?kI4BPtIwX%B zJN69cGsm=x90<;Wmh-bs>43F}ro$}Of@8)4KHndLiR$nW?*{Rl72JPUqRr3ta6e#A z%DTEbi9N}+xPtd1juj8;(CJt3r9NOgb>KTuK|z7!JB_KsFW3(pBN4oh&M&}Nb$Ee2 z$-arA6a)CdsPj`M#1DS>fqj#KF%0q?w50GN4YbmMZIoF{e1yTR=4ablqXHBB2!`wM z1M1ke9+<);|AI;f=2^F1;G6Wfpql?1d5D4rMr?#f(=hkoH)U`6Gb)#xDLjoKjp)1;Js@2Iy5yk zMXUqj+gyk1i0yLjWS|3sM2-1ECc;MAz<4t0P53%7se$$+5Ex`L5TQO_MMXXi04UDIU+3*7Ez&X|mj9cFYBXqM{M;mw_ zpw>azP*qjMyNSD4hh)XZt$gqf8f?eRSFX8VQ4Y+H3jAtvyTrXr`qHAD6`m;aYmH2zOhJC~_*AuT} zvUxC38|JYN94i(05R)dVKgUQF$}#cxV7xZ4FULqFCNX*Forhgp*yr6;DsIk=ub0Hv zpk2L{9Q&|uI^b<6@i(Y+iSxeO_n**4nRLc`P!3ld5jL=nZRw6;DEJ*1z6Pvg+eW|$lnnjO zjd|8>6l{i~UxI244CGn2kK@cJ|#ecwgSyt&HKA2)z zrOO{op^o*-