|
| 1 | +--- |
| 2 | +sidebar_label: Cache Handlers |
| 3 | +sidebar_position: 6 |
| 4 | +title: Cache Handlers |
| 5 | +--- |
| 6 | + |
| 7 | +## Cache Handlers |
| 8 | + |
| 9 | +Cache handlers are classes that extend the `CacheHandler` abstract class. They are responsible for handling the cache of the pages. |
| 10 | + |
| 11 | +### InMemoryCacheHandler (default) |
| 12 | + |
| 13 | +The default cache handler is the `InMemoryCacheHandler`. It stores the cached pages in memory (RAM). It uses the `Map` data structure to store the pages. |
| 14 | + |
| 15 | +### FileSystemCacheHandler (prerendering on steroids) |
| 16 | + |
| 17 | +There are cases where you want to store the cached pages in the file system. For example, if you want to deploy your app to a serverless environment, you can't use the `InMemoryCacheHandler` because the memory is not persistent. In this case, you can use the `FileSystemCacheHandler`. |
| 18 | + |
| 19 | +The `FileSystemCacheHandler` stores the cached pages in the file system. It uses the `fs` module to read and write files. It stores the cached pages in the directory that you provide in the `cacheFolderPath` field. |
| 20 | + |
| 21 | +The `FileSystemCacheHandler` has a field called `addPrerenderedPagesToCache`. If you set it to `true`, it will add the prerendered pages (from the path that you provide in the `prerenderedPagesPath` field) to the cache. If you set it to `false`, it will only add the pages that are cached using normal ISR. The default value is `false`. |
| 22 | + |
| 23 | +```typescript |
| 24 | +const fsCacheHandler = new FileSystemCacheHandler({ |
| 25 | + cacheFolderPath: join(distFolder, '/cache'), |
| 26 | + prerenderedPagesPath: distFolder, |
| 27 | + addPrerenderedPagesToCache: true, |
| 28 | +}); |
| 29 | +``` |
| 30 | + |
| 31 | +And then, to register the cache handler, you need to pass it to the `cache` field in ISRHandler: |
| 32 | + |
| 33 | +```typescript |
| 34 | +const isr = new ISRHandler({ |
| 35 | + ... |
| 36 | + // highlight-next-line |
| 37 | + cache: fsCacheHandler, |
| 38 | +}); |
| 39 | +``` |
| 40 | + |
| 41 | +Now, the prerendered pages will be added to the cache. This means that the first request to a page will be served from the cache. And then, ISR will take over and revalidate the cache based on the `revalidate` field in the routes. |
| 42 | + |
| 43 | +### Custom Cache Handler |
| 44 | + |
| 45 | +The cache handling in ISR is **pluggable**. This means that you can use any cache handler that you want. You can also create your own cache handler. |
| 46 | + |
| 47 | +To do that, you need to extend the `CacheHandler` abstract class. |
| 48 | + |
| 49 | +To give you an idea of how to create a custom cache handler, let's take a look at this example of a custom cache handler that stores the cached pages in **redis**: |
| 50 | + |
| 51 | +```typescript |
| 52 | +import Redis from 'ioredis'; |
| 53 | +import { CacheData, CacheHandler, ISROptions } from '@rx-angular/isr/models'; |
| 54 | + |
| 55 | +type RedisCacheHandlerOptions = { |
| 56 | + /** |
| 57 | + * Redis connection string, e.g. "redis://localhost:6379" |
| 58 | + */ |
| 59 | + connectionString: string; |
| 60 | + /** |
| 61 | + * Redis key prefix, defaults to "isr:" |
| 62 | + */ |
| 63 | + keyPrefix?: string; |
| 64 | +}; |
| 65 | + |
| 66 | +export class RedisCacheHandler extends CacheHandler { |
| 67 | + private redis: Redis; |
| 68 | + |
| 69 | + constructor(private readonly options: RedisCacheHandlerOptions) { |
| 70 | + super(); |
| 71 | + |
| 72 | + this.redis = new Redis(this.options.connectionString); |
| 73 | + console.log('RedisCacheHandler initialized 🚀'); |
| 74 | + } |
| 75 | + |
| 76 | + add( |
| 77 | + url: string, |
| 78 | + html: string, |
| 79 | + options: ISROptions = { revalidate: null } |
| 80 | + ): Promise<void> { |
| 81 | + const htmlWithMsg = html + cacheMsg(options.revalidate); |
| 82 | + |
| 83 | + return new Promise((resolve, reject) => { |
| 84 | + const cacheData: CacheData = { |
| 85 | + html: htmlWithMsg, |
| 86 | + options, |
| 87 | + createdAt: Date.now(), |
| 88 | + }; |
| 89 | + const key = this.createKey(url); |
| 90 | + this.redis.set(key, JSON.stringify(cacheData)).then(() => { |
| 91 | + resolve(); |
| 92 | + }); |
| 93 | + }); |
| 94 | + } |
| 95 | + |
| 96 | + get(url: string): Promise<CacheData> { |
| 97 | + return new Promise((resolve, reject) => { |
| 98 | + const key = this.createKey(url); |
| 99 | + this.redis.get(key, (err, result) => { |
| 100 | + if (err || result === null || result === undefined) { |
| 101 | + reject('This url does not exist in cache!'); |
| 102 | + } else { |
| 103 | + resolve(JSON.parse(result)); |
| 104 | + } |
| 105 | + }); |
| 106 | + }); |
| 107 | + } |
| 108 | + |
| 109 | + getAll(): Promise<string[]> { |
| 110 | + console.log('getAll() is not implemented for RedisCacheHandler'); |
| 111 | + return Promise.resolve([]); |
| 112 | + } |
| 113 | + |
| 114 | + has(url: string): Promise<boolean> { |
| 115 | + return new Promise((resolve, reject) => { |
| 116 | + const key = this.createKey(url); |
| 117 | + resolve(this.redis.exists(key).then((exists) => exists === 1)); |
| 118 | + }); |
| 119 | + } |
| 120 | + |
| 121 | + delete(url: string): Promise<boolean> { |
| 122 | + return new Promise((resolve, reject) => { |
| 123 | + const key = this.createKey(url); |
| 124 | + resolve(this.redis.del(key).then((deleted) => deleted === 1)); |
| 125 | + }); |
| 126 | + } |
| 127 | + |
| 128 | + clearCache?(): Promise<boolean> { |
| 129 | + throw new Error('Method not implemented.'); |
| 130 | + } |
| 131 | + |
| 132 | + private createKey(url: string): string { |
| 133 | + const prefix = this.options.keyPrefix || 'isr'; |
| 134 | + return `${prefix}:${url}`; |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +const cacheMsg = (revalidateTime?: number | null): string => { |
| 139 | + const time = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, ''); |
| 140 | + |
| 141 | + let msg = '<!-- '; |
| 142 | + |
| 143 | + msg += `\n🚀 ISR: Served from Redis Cache! \n⌛ Last updated: ${time}. `; |
| 144 | + |
| 145 | + if (revalidateTime) { |
| 146 | + msg += `\n⏭️ Next refresh is after ${revalidateTime} seconds. `; |
| 147 | + } |
| 148 | + |
| 149 | + msg += ' \n-->'; |
| 150 | + |
| 151 | + return msg; |
| 152 | +}; |
| 153 | +``` |
| 154 | + |
| 155 | +And then, to register the cache handler, you need to pass it to the `cache` field in ISRHandler: |
| 156 | + |
| 157 | +```typescript title="server.ts" |
| 158 | +const redisCacheHandler = new RedisCacheHandler({ |
| 159 | + connectionString: process.env['REDIS_CONNECTION_STRING'] || '' // e.g. "redis://localhost:6379" |
| 160 | +}); |
| 161 | + |
| 162 | +const isr = new ISRHandler({ |
| 163 | + ... |
| 164 | + // highlight-next-line |
| 165 | + cache: redisCacheHandler, // 👈 register the cache handler |
| 166 | +}); |
| 167 | +``` |
| 168 | + |
| 169 | +And that's it! Now you have a custom cache handler that stores the cached pages in redis. |
| 170 | + |
| 171 | +## Gotchas |
| 172 | + |
| 173 | +When using ISR, you need to be aware of the following gotcha. When storing the cached pages in a redis storage, you need to separate cache pages based on the build id. This is because the build id is different for each build. If you don't do that, you will get javascript loading errors in the browser. This is because the browser will try to load the javascript files from the previous build, which don't exist anymore. |
| 174 | + |
| 175 | +To solve this issue, we can use the `buildId` field in the `ISRHandler` options. This field is used to separate the cached pages based on the build id. |
| 176 | + |
| 177 | +Where can we get the build id? We can add it in our `environment.ts` (for dev/prod) file: |
| 178 | + |
| 179 | +```typescript |
| 180 | +export const environment = { |
| 181 | + ... |
| 182 | + buildTimestamp: new Date().getTime(), // 👈 add this |
| 183 | +}; |
| 184 | +``` |
| 185 | + |
| 186 | +And then, we can use it in our `ISRHandler` options: |
| 187 | + |
| 188 | +```typescript |
| 189 | +const isr = new ISRHandler({ |
| 190 | + ... |
| 191 | + // highlight-next-line |
| 192 | + buildId: environment.buildTimestamp, // 👈 use it here |
| 193 | +}); |
| 194 | +``` |
| 195 | + |
| 196 | +The buildId will help us separate the cached pages based on the build id. The moment the user ask for a page that we had cached in the previous build, we check if the build id of the cached page is the same as the current build id. If it's not, we server-side render the page again and cache it. If it's the same, we serve the cached page. |
| 197 | + |
| 198 | +And that's it! Now you have a working ISR with a custom cache handler that stores the cached pages in redis and separates them based on the build id. |
| 199 | + |
| 200 | +## Cache Handler API |
| 201 | + |
| 202 | +The `CacheHandler` abstract class has the following API: |
| 203 | + |
| 204 | +```typescript |
| 205 | +export abstract class CacheHandler { |
| 206 | + abstract add(url: string, html: string, options: ISROptions): Promise<void>; |
| 207 | + |
| 208 | + abstract get(url: string): Promise<CacheData>; |
| 209 | + |
| 210 | + abstract getAll(): Promise<string[]>; |
| 211 | + |
| 212 | + abstract has(url: string): Promise<boolean>; |
| 213 | + |
| 214 | + abstract delete(url: string): Promise<boolean>; |
| 215 | + |
| 216 | + abstract clearCache?(): Promise<boolean>; |
| 217 | +} |
| 218 | +``` |
| 219 | + |
| 220 | +## Cache Data |
| 221 | + |
| 222 | +The `CacheData` interface is used to store the cached pages in the cache handler. It has the following fields: |
| 223 | + |
| 224 | +```typescript |
| 225 | +export interface CacheData { |
| 226 | + html: string; |
| 227 | + options: ISROptions; |
| 228 | + createdAt: number; |
| 229 | +} |
| 230 | +``` |
0 commit comments