Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(isr): add compression (#1755) #1768

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 42 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
ba1cbbe
chore(isr): update CacheHandler and IsrServiceInterface typings
maxisam Aug 20, 2024
f7755ac
refactor: fix eslint issues
maxisam Aug 20, 2024
0af4f56
fix(isr): filter urlsOnHold by cacheKey instead of url
maxisam Aug 20, 2024
244bff7
feat: add allowed query params options #1743
maxisam Aug 22, 2024
50643b3
feat: handle query string for filesystem cache #1690
maxisam Aug 22, 2024
9dc5a70
fix(isr): in memory cache handler should use extends #1736
maxisam Aug 22, 2024
76aa916
Merge branch 'fix/instanceof' into dev
maxisam Aug 23, 2024
fcb59b2
Merge branch 'refactor/es-lint' into dev
maxisam Aug 23, 2024
cc19ee3
Merge branch 'feat/allowed-query-params' into dev
maxisam Aug 23, 2024
c37269b
refactor(isr): rename CacheRegeneration to CacheGeneration
maxisam Aug 23, 2024
039f09c
refactor(isr): rename CacheRegeneration to CacheGeneration
maxisam Aug 23, 2024
c95d1c6
fix(isr): handle modifyGeneratedHtml behavior consistantly #1758
maxisam Aug 29, 2024
e7ed6a5
Merge branch 'main' into dev
maxisam Aug 29, 2024
cb0261a
refactor(isr): use modifyGeneratedHtml instead
maxisam Aug 29, 2024
2dd9551
feat(isr): update the example to show modifyGeneratedHtml usage
maxisam Aug 29, 2024
9f4f0cd
Merge branch 'main' into feat/callback-cachemsg
maxisam Aug 29, 2024
5b7603e
Merge pull request #2 from maxisam/feat/consolidate-cache-generation
maxisam Aug 29, 2024
2c88c4f
feat(isr): add non-blocking-render option
maxisam Aug 29, 2024
dcc61f9
feat(isr): add background revalidation option
maxisam Aug 29, 2024
fe0bac2
feat(isr): enable background revalidation and non-blocking render
maxisam Aug 29, 2024
a88b5ca
Merge pull request #3 from maxisam/feat/response-first
maxisam Aug 29, 2024
2ae8857
feat(isr): add compression #1755
maxisam Aug 30, 2024
85b0dc3
chore(isr): rename HTML compression method
maxisam Aug 30, 2024
bdcc48b
Merge pull request #4 from maxisam/feat/compression
maxisam Aug 30, 2024
cd5c42d
Merge pull request #5 from maxisam/feat/compression
maxisam Aug 30, 2024
68f1d59
Merge branch 'main' into dev
maxisam Sep 3, 2024
a2192db
fix: merging issue
maxisam Sep 3, 2024
9ef7463
feat(isr): add compression
maxisam Sep 3, 2024
bb74688
fix(isr): fix eslint issue
maxisam Sep 3, 2024
d32eaf5
Merge pull request #8 from maxisam/chore/lint
maxisam Sep 5, 2024
0960536
Merge branch 'dev' into compress-new
maxisam Sep 5, 2024
55a7609
Merge branch 'main' into compress-new
maxisam Sep 18, 2024
d910520
chore: update yarn lock
maxisam Sep 18, 2024
b6f90a1
fix: format
maxisam Sep 19, 2024
ce45c32
feat: let cache handler handle cache html either buffer or string
maxisam Oct 7, 2024
65b0381
docs: how to use buffer in redis and compressHtml callback
maxisam Oct 7, 2024
7e8512b
docs(isr): add compression support for caching
maxisam Oct 16, 2024
28b86d2
Merge branch 'main' into compress-new
maxisam Nov 24, 2024
6c7670b
Merge branch 'main' into compress-new
maxisam Nov 24, 2024
f7fb152
refactor(isr): standardize terminology from 'route' to 'cacheKey'
maxisam Nov 27, 2024
bd14b62
docs(isr): update CacheHandler API doc
maxisam Nov 27, 2024
e08dd51
docs: revert
maxisam Nov 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 56 additions & 68 deletions apps/docs/docs/isr/cache-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,85 +71,63 @@ export class RedisCacheHandler extends CacheHandler {

this.redis = new Redis(this.options.connectionString);
console.log('RedisCacheHandler initialized 🚀');
options.keyPrefix = options.keyPrefix || 'isr';
}

add(
url: string,
html: string,
options: ISROptions = { revalidate: null }
): Promise<void> {
const htmlWithMsg = html + cacheMsg(options.revalidate);

return new Promise((resolve, reject) => {
const cacheData: CacheData = {
html: htmlWithMsg,
options,
createdAt: Date.now(),
};
const key = this.createKey(url);
this.redis.set(key, JSON.stringify(cacheData)).then(() => {
resolve();
});
add(cacheKey: string, html: string | Buffer, options: ISROptions = { revalidate: null }): Promise<void> {
const key = this.createKey(cacheKey);
const createdAt = Date.now().toString();
await this.redis.hmset(key, {
html,
revalidate: config.revalidate ? config.revalidate.toString() : '',
buildId: config.buildId || '',
createdAt,
});
}

get(url: string): Promise<CacheData> {
return new Promise((resolve, reject) => {
const key = this.createKey(url);
this.redis.get(key, (err, result) => {
if (err || result === null || result === undefined) {
reject('This url does not exist in cache!');
} else {
resolve(JSON.parse(result));
}
});
});
// in this example, it is assumed that the html is stored as a buffer, use hgetall if it is stored as a string
async get(cacheKey: string): Promise<CacheData> {
const key = this.createKey(cacheKey);
const data = await this.redis.hgetallBuffer(key);
if (Object.keys(data).length > 0) {
const revalidate = data['revalidate'] ? parseInt(data['revalidate'].toString(), 10) : null;
return {
html: data['html'],
options: {
revalidate,
buildId: data['buildId'].toString() || null,
},
createdAt: parseInt(data['createdAt'].toString(), 10),
} as CacheData;
} else {
this.logger.info(`Cache with key ${cacheKey} not found`);
throw new Error(`Cache with key ${cacheKey} not found`);
}
}

getAll(): Promise<string[]> {
console.log('getAll() is not implemented for RedisCacheHandler');
return Promise.resolve([]);
async getAll(): Promise<string[]> {
return await this.redis.keys(`${this.redisCacheOptions.keyPrefix}:*`);
}

has(url: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const key = this.createKey(url);
resolve(this.redis.exists(key).then((exists) => exists === 1));
});
async has(cacheKey: string): Promise<boolean> {
const key = this.createKey(cacheKey);
return (await this.redis.exists(key)) === 1;
}

delete(url: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const key = this.createKey(url);
resolve(this.redis.del(key).then((deleted) => deleted === 1));
});
async delete(cacheKey: string): Promise<boolean> {
const key = this.createKey(cacheKey);
return (await this.redis.del(key)) === 1;
}

clearCache?(): Promise<boolean> {
throw new Error('Method not implemented.');
async clearCache(): Promise<boolean> {
await this.redis.flushdb();
return true;
}

private createKey(url: string): string {
const prefix = this.options.keyPrefix || 'isr';
return `${prefix}:${url}`;
private createKey(cacheKey: string): string {
return `${this.redisCacheOptions.keyPrefix}:${cacheKey}`;
}
}

const cacheMsg = (revalidateTime?: number | null): string => {
const time = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '');

let msg = '<!-- ';

msg += `\n🚀 ISR: Served from Redis Cache! \n⌛ Last updated: ${time}. `;

if (revalidateTime) {
msg += `\n⏭️ Next refresh is after ${revalidateTime} seconds. `;
}

msg += ' \n-->';

return msg;
};
```

And then, to register the cache handler, you need to pass it to the `cache` field in ISRHandler:
Expand Down Expand Up @@ -203,15 +181,22 @@ The `CacheHandler` abstract class has the following API:

```typescript
export abstract class CacheHandler {
abstract add(url: string, html: string, options: ISROptions): Promise<void>;
// html could be a string or a buffer, it is depending on if `compressHtml` is set in `ISRHandler` config.
// if `compressHtml` is set, the html will be a buffer, otherwise it will be a string
abstract add(
cacheKey: string,
// it will be buffer when we use compressHtml
html: string | Buffer,
config?: CacheISRConfig,
): Promise<void>;

abstract get(url: string): Promise<CacheData>;
abstract get(cacheKey: string): Promise<CacheData>;

abstract getAll(): Promise<string[]>;
abstract has(cacheKey: string): Promise<boolean>;

abstract has(url: string): Promise<boolean>;
abstract delete(cacheKey: string): Promise<boolean>;

abstract delete(url: string): Promise<boolean>;
abstract getAll(): Promise<string[]>;

abstract clearCache?(): Promise<boolean>;
}
Expand All @@ -223,8 +208,11 @@ The `CacheData` interface is used to store the cached pages in the cache handler

```typescript
export interface CacheData {
html: string;
html: string | Buffer;
options: ISROptions;
createdAt: number;
}
```

**note**: The `html` field can be a string or a buffer. It depends on if you set `compressHtml` function in the `ISRHandler` options.
If it is set, the html will be compressed and stored as a buffer. If it is not set, the html will be stored as a string.
8 changes: 6 additions & 2 deletions apps/docs/docs/isr/cache-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ To do that, you can use the `modifyCachedHtml` and `modifyGeneratedHtml` callbac

### modifyCachedHtml

The `modifyCachedHtml` callback is called when the html is served from cache (on every user request). It receives the request and the cached html as parameters. It should return the modified html.
The `modifyCachedHtml` callback is called when the html is served from cache (on every user request). It receives the request and the cached html as parameters. It should return the modified html. However, if compressHtml is set, this callback will not be called, since the cached html is compressed and cannot be modified.

### modifyGeneratedHtml

The `modifyGeneratedHtml` callback is called when the html is generated on the fly (before the cache is stored). It receives the request and the generated html as parameters. It should return the modified html.

### Example
#### Example

```ts
server.get(
Expand Down Expand Up @@ -55,3 +55,7 @@ server.get(
:::caution **Important:**
Use these methods with caution as the logic written can increase the processing time.
:::

### compressHtml (> v18.1.0)

A compression callback can be provided to compress the HTML before storing it in the cache. If not provided, the HTML will be stored without compression. When provided, the HTML will be compressed and stored as Buffer | string in the cache (depending on how cache handler is implemented. Default examples use Buffer). Note that this will disable the modifyCachedHtml callback, as compressed HTML cannot be modified.
54 changes: 54 additions & 0 deletions apps/docs/docs/isr/compression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
sidebar_label: Compression
sidebar_position: 12
title: Compression
---

## Why Compression?

Caching pages on the server can lead to high memory usage, especially when caching a large number of pages. Even when caching pages on disk, reading them from disk and sending them to the client can result in high disk I/O usage.

Typically, reverse proxies like Nginx are used to compress responses before sending them to clients. However, if we compress cached pages and serve them as compressed responses, we eliminate the need to compress them every time in Nginx, reducing server load and improving performance.

## How to Use Compression

You can enable compression by setting the `compressHtml` property in `ISRHandlerConfig` to a compression callback function. This function will be called with the HTML content of the page and should return the compressed HTML content. The signature of the function is:

```typescript
export type CompressHtmlFn = (html: string) => Promise<Buffer>;
```

### Example

```typescript
import { ISRHandler } from '@rx-angular/isr';
import { CompressHtmlFn } from '@rx-angular/isr/models';
import * as zlib from 'zlib';

// Example compressHtml function
const compressHtml: CompressHtmlFn = (html: string): Promise<Buffer> => {
return new Promise((resolve, reject) => {
zlib.gzip(html, (err, buffer) => {
if (err) {
reject(err);
} else {
resolve(buffer);
}
});
});
};

// ISRHandler configuration
const isr = new ISRHandler({
indexHtml,
// other options omitted for brevity
compressHtml: compressHtml, // compress the HTML before storing in cache
htmlCompressionMethod: 'gzip', // specify the compression method, default is 'gzip'
});
```

## Important Notes

- **HTML Parameter Type**: With compression enabled, the type of the `html` parameter in `CacheHandler` will be `Buffer` instead of `string`.
- **Content-Encoding Header**: The `htmlCompressionMethod` property is used for the `Content-Encoding` header and should match the compression method used in the `compressHtml` function.
- **Modify Cached HTML**: The `modifyCachedHtml` function will be ignored when `compressHtml` is set, since it is not possible to modify the compressed cached HTML content without decompressing it first.
12 changes: 10 additions & 2 deletions apps/ssr-isr/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { CommonEngine } from '@angular/ssr';
import { ModifyHtmlCallbackFn } from '@rx-angular/isr/models';
import { ISRHandler } from '@rx-angular/isr/server';
import {
compressHtml,
CompressStaticFilter,
ISRHandler,
} from '@rx-angular/isr/server';
import compression from 'compression';
import express, { Request } from 'express';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -34,9 +39,12 @@ export function app(): express.Express {
backgroundRevalidation: true, // will revalidate in background and serve the cache page first
nonBlockingRender: true, // will serve page first and store in cache in background
modifyGeneratedHtml: customModifyGeneratedHtml,
compressHtml: compressHtml, // compress the html before storing in cache
// cacheHtmlCompressionMethod: 'gzip', // compression method for cache
// cache: fsCacheHandler,
});

// compress js|css files
server.use(compression({ filter: CompressStaticFilter }));
server.use(express.json());

server.post(
Expand Down
9 changes: 7 additions & 2 deletions libs/isr/models/src/cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ export interface CacheISRConfig {
errors?: string[];
}

// html could be a string or a buffer, it is depending on if `compressHtml` is set in `ISRHandler` config.
// if `compressHtml` is set, the html will be a buffer, otherwise it will be a string
export interface CacheData {
html: string;
html: string | Buffer;
options: CacheISRConfig;
createdAt: number;
}
Expand All @@ -29,9 +31,12 @@ export interface VariantRebuildItem {
}

export abstract class CacheHandler {
// html could be a string or a buffer, it is depending on if `compressHtml` is set in `ISRHandler` config.
// if `compressHtml` is set, the html will be a buffer, otherwise it will be a string
abstract add(
cacheKey: string,
html: string,
// it will be buffer when we use compressHtml
html: string | Buffer,
config?: CacheISRConfig,
): Promise<void>;

Expand Down
1 change: 1 addition & 0 deletions libs/isr/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
} from './cache-handler';
export {
CacheKeyGeneratorFn,
CompressHtmlFn,
InvalidateConfig,
ISRHandlerConfig,
ModifyHtmlCallbackFn,
Expand Down
15 changes: 15 additions & 0 deletions libs/isr/models/src/isr-handler-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,19 @@ export interface ISRHandlerConfig {
*/
backgroundRevalidation?: boolean;

/**
* A compression callback can be provided to compress the HTML before storing it in the cache.
* If not provided, the HTML will be stored without compression.
* When provided, the HTML will be compressed and stored as Buffer | string in the cache
* (depending on how cache handler is implemented. Default examples use Buffer)
* Note that this will disable the modifyCachedHtml callback, as compressed HTML cannot be modified.
**/
compressHtml?: CompressHtmlFn;

/**
* Cached Html compression method, it will use gzip by default if not provided.
*/
htmlCompressionMethod?: string;
/**
* This callback lets you use custom cache key generation logic. If not provided, it will use the default cache key generation logic.
*/
Expand Down Expand Up @@ -192,3 +205,5 @@ export interface RenderConfig {
export interface RouteISRConfig {
revalidate?: number | null;
}

export type CompressHtmlFn = (html: string) => Promise<Buffer>;
12 changes: 6 additions & 6 deletions libs/isr/server/src/cache-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { getRouteISRDataFromHTML } from './utils/get-isr-options';
import { renderUrl, RenderUrlConfig } from './utils/render-url';

export interface IGeneratedResult {
html?: string;
html?: string | Buffer;
errors?: string[];
}

Expand Down Expand Up @@ -56,7 +56,6 @@ export class CacheGeneration {

return this.generateWithCacheKey(req, res, cacheKey, providers, mode);
}

async generateWithCacheKey(
req: Request,
res: Response,
Expand All @@ -77,7 +76,6 @@ export class CacheGeneration {

this.urlsOnHold.push(cacheKey);
}

const renderUrlConfig: RenderUrlConfig = {
req,
res,
Expand All @@ -89,17 +87,19 @@ export class CacheGeneration {
browserDistFolder: this.isrConfig.browserDistFolder,
inlineCriticalCss: this.isrConfig.inlineCriticalCss,
};

try {
const html = await renderUrl(renderUrlConfig);
const { revalidate, errors } = getRouteISRDataFromHTML(html);

// Apply the modify generation callback
// If undefined, use the default modifyGeneratedHtml function
const finalHtml = this.isrConfig.modifyGeneratedHtml
let finalHtml: string | Buffer = this.isrConfig.modifyGeneratedHtml
? this.isrConfig.modifyGeneratedHtml(req, html, revalidate)
: defaultModifyGeneratedHtml(req, html, revalidate);

// Apply the compressHtml callback
if (this.isrConfig.compressHtml) {
finalHtml = await this.isrConfig.compressHtml(finalHtml);
}
// if there are errors, don't add the page to cache
if (errors?.length && this.isrConfig.skipCachingOnHttpError) {
// remove url from urlsOnHold because we want to try to regenerate it again
Expand Down
Loading
Loading