Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat(isr): add compression #1763

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

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
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
23 changes: 17 additions & 6 deletions 23 apps/docs/docs/isr/cache-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,24 @@ server.get(
return `${cachedHtml}<!-- Hello, I'm a modification to the original cache! -->`;
},
}),

// Server side render the page and add to cache if needed
async (req, res, next) =>
await isr.render(req, res, next, {
modifyGeneratedHtml: (req, html) => {
return `${html}<!-- Hello, I'm modifying the generatedHtml before caching it! -->`;
},
})
const isr = new ISRHandler({
indexHtml,
invalidateSecretToken: 'MY_TOKEN', // replace with env secret key ex. process.env.REVALIDATE_SECRET_TOKEN
enableLogging: true,
serverDistFolder,
browserDistFolder,
bootstrap,
commonEngine,
modifyGeneratedHtml: (req, html) => {
return `${html}<!-- Hello, I'm modifying the generatedHtml before caching it! -->`;
},
// cache: fsCacheHandler,
});


async (req, res, next) => await isr.render(req, res, next),
);
```

Expand Down
34 changes: 31 additions & 3 deletions 34 apps/ssr-isr/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { CommonEngine } from '@angular/ssr';
import { ISRHandler } from '@rx-angular/isr/server';
import express from 'express';
import { modifyHtmlCallbackFn } from '@rx-angular/isr/models';
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';
import { RESPONSE } from './src/app/redirect.component';
Expand Down Expand Up @@ -30,9 +36,15 @@ export function app(): express.Express {
browserDistFolder,
bootstrap,
commonEngine,
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 Expand Up @@ -72,6 +84,22 @@ export function app(): express.Express {
return server;
}

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

let msg = '<!-- ';
msg += `\n🚀 ISR: Served from cache! \n⌛ Last updated: ${time}. `;
if (revalidateTime)
msg += `\n⏭️ Next refresh is after ${revalidateTime} seconds. `;
msg += ' \n-->';
html = html.replace('Original content', 'Modified content');
return html + msg;
};

function run(): void {
const port = process.env['PORT'] || 4000;

Expand Down
21 changes: 14 additions & 7 deletions 21 apps/ssr-isr/src/app/dynamic.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@ import { map, switchMap } from 'rxjs';
selector: 'app-dynamic-page',
template: `
@if (post$ | async; as post) {
<div>
<h2>{{ post.title }}</h2>
<p>{{ post.body }}</p>
</div>
}
<div>
<h2>{{ post.title }}</h2>
<p>{{ post.body }}</p>
<h2>
Dynamically Modification (controlled by modifyGeneratedHtml in
ISRHandlerConfig)
</h2>
<p>Original content</p>
</div>
}
`,
imports: [AsyncPipe],
standalone: true,
Expand All @@ -22,14 +29,14 @@ export class DynamicPageComponent {
private http = inject(HttpClient);

private postId$ = inject(ActivatedRoute).params.pipe(
map((p) => p['id'] as string)
map((p) => p['id'] as string),
);

post$ = this.postId$.pipe(
switchMap((id) =>
this.http.get<{ title: string; body: string }>(
`https://jsonplaceholder.typicode.com/posts/${id}`
)
)
`https://jsonplaceholder.typicode.com/posts/${id}`,
),
),
);
}
2 changes: 1 addition & 1 deletion 2 libs/isr/models/src/cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export abstract class CacheHandler {
abstract add(
url: string,
html: string,
config?: CacheISRConfig
config?: CacheISRConfig,
): Promise<void>;

abstract get(url: string): Promise<CacheData>;
Expand Down
2 changes: 2 additions & 0 deletions 2 libs/isr/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ export {
VariantRebuildItem,
} from './cache-handler';
export {
CompressHtmlFn,
InvalidateConfig,
ISRHandlerConfig,
modifyHtmlCallbackFn,
RenderConfig,
RouteISRConfig,
ServeFromCacheConfig,
Expand Down
54 changes: 48 additions & 6 deletions 54 libs/isr/models/src/isr-handler-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,46 @@ export interface ISRHandlerConfig {
* ],
*/
variants?: RenderVariant[];

/**
* This array of query params will be allowed to be part of the cache key.
* If not provided, which is null, all query params will be part of the cache key.
* If provided as an empty array, no query params will be part of the cache key.
*/
allowedQueryParams?: string[];

/**
* This callback lets you hook into the generated html and provide any modifications
* necessary on-the-fly.
* Use with caution as this may lead to a performance loss on serving the html.
* if null, it will use defaultModifyGeneratedHtml function,
* which only add commented text to the html to indicate when it was generated with very low performance impact
*/
modifyGeneratedHtml?: modifyHtmlCallbackFn;

/**
* If set to true, the server will not wait for storing the rendered page to the cache storage first and will return the rendered HTML as soon as possible.
* This can avoid client-side waiting times if the remote cache storage is down.
*/
nonBlockingRender?: boolean;

/**
* If set to true, the server will provide the cached HTML as soon as possible and will revalidate the cache in the background.
*/
backgroundRevalidation?: boolean;

/**
* compression callback to compress the html before storing it in the cache.
* If not provided, the html will not be compressed.
* If provided, the html will be compressed before storing it as base64 in the cache,
* also this will disable the modifyCachedHtml callback, because html is compressed and can't be modified.
*/
compressHtml?: CompressHtmlFn;

/**
* Cached Html compression method, it will use gzip by default if not provided.
*/
htmlCompressionMethod?: string;
}

export interface ServeFromCacheConfig {
Expand All @@ -117,14 +157,14 @@ export interface InvalidateConfig {
providers?: Provider[];
}

export type modifyHtmlCallbackFn = (
req: Request,
html: string,
revalidateTime?: number | null,
) => string;

export interface RenderConfig {
providers?: Provider[];
/**
* This callback lets you hook into the generated html and provide any modifications
* necessary on-the-fly.
* Use with caution as this may lead to a performance loss on serving the html.
*/
modifyGeneratedHtml?: (req: Request, html: string) => string;
}

/**
Expand All @@ -145,3 +185,5 @@ export interface RenderConfig {
export interface RouteISRConfig {
revalidate?: number | null;
}

export type CompressHtmlFn = (html: string) => Promise<Buffer>;
6 changes: 3 additions & 3 deletions 6 libs/isr/models/src/isr-service.interface.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
export interface IsrState {
revalidate: number | null;
errors: Error[];
extra: Record<string, any>;
extra: Record<string, unknown>;
}

export interface IsrServiceInterface {
getState(): IsrState;
patchState(partialState: Partial<IsrState>): void;
getExtra(): Record<string, any>;
getExtra(): Record<string, unknown>;
activate(): void;
addError(error: Error): void;
addExtra(extra?: Record<string, any>): void;
addExtra(extra?: Record<string, unknown>): void;
}
135 changes: 135 additions & 0 deletions 135 libs/isr/server/src/cache-generation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { Provider } from '@angular/core';
import { CacheHandler, ISRHandlerConfig } from '@rx-angular/isr/models';
import { Request, Response } from 'express';
import { ISRLogger } from './isr-logger';
import { defaultModifyGeneratedHtml } from './modify-generated-html';
import { getCacheKey, getVariant } from './utils/cache-utils';
import { bufferToString } from './utils/compression-utils';
import { getRouteISRDataFromHTML } from './utils/get-isr-options';
import { renderUrl, RenderUrlConfig } from './utils/render-url';

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

export class CacheGeneration {
// TODO: make this pluggable because on serverless environments we can't share memory between functions
// so we need to use a database or redis cache to store the urls that are on hold if we want to use this feature
private urlsOnHold: string[] = []; // urls that have regeneration loading

constructor(
public isrConfig: ISRHandlerConfig,
public cache: CacheHandler,
public logger: ISRLogger,
) {}
async generate(
req: Request,
res: Response,
providers?: Provider[],
mode: 'regenerate' | 'generate' = 'regenerate',
): Promise<IGeneratedResult | void> {
const { url } = req;
const variant = getVariant(req, this.isrConfig.variants);
const cacheKey = getCacheKey(
url,
this.isrConfig.allowedQueryParams,
variant,
);

return this.generateWithCacheKey(req, res, cacheKey, providers, mode);
}
async generateWithCacheKey(
req: Request,
res: Response,
cacheKey: string,
providers?: Provider[],
mode: 'regenerate' | 'generate' = 'regenerate',
): Promise<IGeneratedResult | void> {
const { url } = req;

if (mode === 'regenerate') {
// only regenerate will use queue to avoid multiple regenerations for the same url
// generate mode is used for the request without cache
if (this.urlsOnHold.includes(cacheKey)) {
this.logger.log('Another generation is on-going for this url...');
return;
}
this.logger.log(`The url: ${cacheKey} is being generated.`);

this.urlsOnHold.push(cacheKey);
}
const renderUrlConfig: RenderUrlConfig = {
req,
res,
url,
indexHtml: this.isrConfig.indexHtml,
providers,
commonEngine: this.isrConfig.commonEngine,
bootstrap: this.isrConfig.bootstrap,
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
let finalHtml: string | Buffer = this.isrConfig.modifyGeneratedHtml
? this.isrConfig.modifyGeneratedHtml(req, html, revalidate)
: defaultModifyGeneratedHtml(req, html, revalidate);
let cacheString: string = finalHtml;
// Apply the compressHtml callback
if (this.isrConfig.compressHtml) {
finalHtml = await this.isrConfig.compressHtml(finalHtml);
cacheString = bufferToString(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
if (mode === 'regenerate') {
this.urlsOnHold = this.urlsOnHold.filter((x) => x !== cacheKey);
}
this.logger.log(
`💥 ERROR: Url: ${cacheKey} was not regenerated!`,
errors,
);
return { html: finalHtml, errors };
}

// if revalidate is null we won't cache it
// if revalidate is 0, we will never clear the cache automatically
// if revalidate is x, we will clear cache every x seconds (after the last request) for that url
if (revalidate === null || revalidate === undefined) {
// don't do !revalidate because it will also catch "0"
return { html: finalHtml };
}
// add the regenerated page to cache
if (this.isrConfig.nonBlockingRender) {
this.cache.add(cacheKey, cacheString, {
revalidate,
buildId: this.isrConfig.buildId,
});
} else {
await this.cache.add(cacheKey, cacheString, {
revalidate,
buildId: this.isrConfig.buildId,
});
}
if (mode === 'regenerate') {
// remove from urlsOnHold because we are done
this.urlsOnHold = this.urlsOnHold.filter((x) => x !== cacheKey);
this.logger.log(`Url: ${cacheKey} was regenerated!`);
}
return { html: finalHtml };
} catch (error) {
this.logger.log(`Error regenerating url: ${cacheKey}`, error);
if (mode === 'regenerate') {
// Ensure removal from urlsOnHold in case of error
this.urlsOnHold = this.urlsOnHold.filter((x) => x !== cacheKey);
}
throw error;
}
}
}
Loading
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.