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

Skip to content

fix(isr): use modifyGeneratedHtml in all cache generation process #1760

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

Merged
merged 8 commits into from
Aug 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 17 additions & 7 deletions apps/docs/docs/isr/cache-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,23 @@ 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,
});

// Server side render the page and add to cache if needed
async (req, res, next) => await isr.render(req, res, next),
);
```

Expand Down
35 changes: 22 additions & 13 deletions apps/ssr-isr/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CommonEngine } from '@angular/ssr';
import { ModifyHtmlCallbackFn } from '@rx-angular/isr/models';
import { ISRHandler } from '@rx-angular/isr/server';
import express from 'express';
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,6 +31,7 @@ export function app(): express.Express {
browserDistFolder,
bootstrap,
commonEngine,
modifyGeneratedHtml: defaultModifyGeneratedHtml,
// cache: fsCacheHandler,
});

Expand All @@ -46,32 +48,39 @@ export function app(): express.Express {
// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
server.get(
'*.*',
express.static(browserDistFolder, {
maxAge: '1y',
}),
);
server.get('*.*', express.static(browserDistFolder, { maxAge: '1y' }));

server.get(
'*',
// Serve page if it exists in cache
async (req, res, next) => await isr.serveFromCache(req, res, next),

// Server side render the page and add to cache if needed
async (req, res, next) =>
await isr.render(req, res, next, {
providers: [
{
provide: RESPONSE,
useValue: res,
},
],
providers: [{ provide: RESPONSE, useValue: res }],
}),
);

return server;
}

const defaultModifyGeneratedHtml: 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 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}`,
),
),
);
}
1 change: 1 addition & 0 deletions libs/isr/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
export {
InvalidateConfig,
ISRHandlerConfig,
ModifyHtmlCallbackFn,
RenderConfig,
RouteISRConfig,
ServeFromCacheConfig,
Expand Down
21 changes: 15 additions & 6 deletions libs/isr/models/src/isr-handler-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ export interface ISRHandlerConfig {
* 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.
*/
modifyGeneratedHtml?: ModifyHtmlCallbackFn;
}

export interface ServeFromCacheConfig {
Expand All @@ -124,14 +133,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 Down
122 changes: 122 additions & 0 deletions libs/isr/server/src/cache-generation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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 { getRouteISRDataFromHTML } from './utils/get-isr-options';
import { renderUrl, RenderUrlConfig } from './utils/render-url';

export interface IGeneratedResult {
html?: string;
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
const finalHtml = this.isrConfig.modifyGeneratedHtml
? this.isrConfig.modifyGeneratedHtml(req, html, revalidate)
: defaultModifyGeneratedHtml(req, html, revalidate);

// 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
await this.cache.add(cacheKey, finalHtml, {
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;
}
}
}
15 changes: 1 addition & 14 deletions libs/isr/server/src/cache-handlers/in-memory-cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,9 @@ export class InMemoryCacheHandler extends CacheHandler {
html: string,
config: CacheISRConfig = defaultCacheISRConfig,
): Promise<void> {
const htmlWithMsg = html + cacheMsg(config.revalidate);

return new Promise((resolve) => {
const cacheData: CacheData = {
html: htmlWithMsg,
html,
options: config,
createdAt: Date.now(),
};
Expand Down Expand Up @@ -67,14 +65,3 @@ export class InMemoryCacheHandler extends CacheHandler {
});
}
}

const cacheMsg = (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-->';
return msg;
};
Loading
Loading