Skip to content

Commit dcf3fdd

Browse files
committed
feat(@angular/ssr): add performance profiler to CommonEngine
This commit adds an option to the `CommonEngine` to enable performance profiling. When enabled, timings of a number of steps will be outputted in the server console. Example: ``` ********** Performance results ********** Retrieve SSG Page: 0.3ms Render Page: 25.4ms Inline Critical CSS: 2.3ms ***************************************** ``` To enable profiling set `enablePeformanceProfiler: true` in the `CommonEngine` options. ```ts const commonEngine = new CommonEngine({ enablePeformanceProfiler: true }); ```
1 parent e516a4b commit dcf3fdd

File tree

4 files changed

+170
-56
lines changed

4 files changed

+170
-56
lines changed

goldens/public-api/angular/ssr/index.md

+8-3
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,25 @@ import { Type } from '@angular/core';
1010

1111
// @public
1212
export class CommonEngine {
13-
constructor(bootstrap?: Type<{}> | (() => Promise<ApplicationRef>) | undefined, providers?: StaticProvider[]);
13+
constructor(options?: CommonEngineOptions | undefined);
1414
render(opts: CommonEngineRenderOptions): Promise<string>;
1515
}
1616

17+
// @public (undocumented)
18+
export interface CommonEngineOptions {
19+
bootstrap?: Type<{}> | (() => Promise<ApplicationRef>);
20+
enablePeformanceProfiler?: boolean;
21+
providers?: StaticProvider[];
22+
}
23+
1724
// @public (undocumented)
1825
export interface CommonEngineRenderOptions {
19-
// (undocumented)
2026
bootstrap?: Type<{}> | (() => Promise<ApplicationRef>);
2127
// (undocumented)
2228
document?: string;
2329
// (undocumented)
2430
documentFilePath?: string;
2531
inlineCriticalCss?: boolean;
26-
// (undocumented)
2732
providers?: StaticProvider[];
2833
publicPath?: string;
2934
// (undocumented)

packages/angular/ssr/public_api.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
export { CommonEngine, CommonEngineRenderOptions } from './src/common-engine';
9+
export { CommonEngine, CommonEngineRenderOptions, CommonEngineOptions } from './src/common-engine';

packages/angular/ssr/src/common-engine.ts

+96-52
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,28 @@ import {
1616
import * as fs from 'node:fs';
1717
import { dirname, resolve } from 'node:path';
1818
import { URL } from 'node:url';
19-
import { InlineCriticalCssProcessor } from './inline-css-processor';
19+
import { InlineCriticalCssProcessor, InlineCriticalCssResult } from './inline-css-processor';
20+
import {
21+
noopRunMethodAndMeasurePerf,
22+
printPerformanceLogs,
23+
runMethodAndMeasurePerf,
24+
} from './peformance-profiler';
2025

2126
const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/;
2227

28+
export interface CommonEngineOptions {
29+
/** A method that when invoked returns a promise that returns an `ApplicationRef` instance once resolved or an NgModule. */
30+
bootstrap?: Type<{}> | (() => Promise<ApplicationRef>);
31+
/** A set of platform level providers for all requests. */
32+
providers?: StaticProvider[];
33+
/** Enable request performance profiling data collection and printing the results in the server console. */
34+
enablePeformanceProfiler?: boolean;
35+
}
36+
2337
export interface CommonEngineRenderOptions {
38+
/** A method that when invoked returns a promise that returns an `ApplicationRef` instance once resolved or an NgModule. */
2439
bootstrap?: Type<{}> | (() => Promise<ApplicationRef>);
40+
/** A set of platform level providers for the current request. */
2541
providers?: StaticProvider[];
2642
url?: string;
2743
document?: string;
@@ -39,19 +55,15 @@ export interface CommonEngineRenderOptions {
3955
}
4056

4157
/**
42-
* A common rendering engine utility. This abstracts the logic
43-
* for handling the platformServer compiler, the module cache, and
44-
* the document loader
58+
* A common engine to use to server render an application.
4559
*/
60+
4661
export class CommonEngine {
4762
private readonly templateCache = new Map<string, string>();
4863
private readonly inlineCriticalCssProcessor: InlineCriticalCssProcessor;
4964
private readonly pageIsSSG = new Map<string, boolean>();
5065

51-
constructor(
52-
private bootstrap?: Type<{}> | (() => Promise<ApplicationRef>),
53-
private providers: StaticProvider[] = [],
54-
) {
66+
constructor(private options?: CommonEngineOptions) {
5567
this.inlineCriticalCssProcessor = new InlineCriticalCssProcessor({
5668
minify: false,
5769
});
@@ -62,40 +74,87 @@ export class CommonEngine {
6274
* render options
6375
*/
6476
async render(opts: CommonEngineRenderOptions): Promise<string> {
65-
const { inlineCriticalCss = true, url } = opts;
66-
67-
if (opts.publicPath && opts.documentFilePath && url !== undefined) {
68-
const pathname = canParseUrl(url) ? new URL(url).pathname : url;
69-
// Remove leading forward slash.
70-
const pagePath = resolve(opts.publicPath, pathname.substring(1), 'index.html');
71-
72-
if (pagePath !== resolve(opts.documentFilePath)) {
73-
// View path doesn't match with prerender path.
74-
const pageIsSSG = this.pageIsSSG.get(pagePath);
75-
if (pageIsSSG === undefined) {
76-
if (await exists(pagePath)) {
77-
const content = await fs.promises.readFile(pagePath, 'utf-8');
78-
const isSSG = SSG_MARKER_REGEXP.test(content);
79-
this.pageIsSSG.set(pagePath, isSSG);
80-
81-
if (isSSG) {
82-
return content;
83-
}
84-
} else {
85-
this.pageIsSSG.set(pagePath, false);
77+
const enablePeformanceProfiler = this.options?.enablePeformanceProfiler;
78+
79+
const runMethod = enablePeformanceProfiler
80+
? runMethodAndMeasurePerf
81+
: noopRunMethodAndMeasurePerf;
82+
83+
let html = await runMethod('Retrieve SSG Page', () => this.retrieveSSGPage(opts));
84+
85+
if (html === undefined) {
86+
html = await runMethod('Render Page', () => this.renderApplication(opts));
87+
88+
if (opts.inlineCriticalCss !== false) {
89+
const { content, errors, warnings } = await runMethod('Inline Critical CSS', () =>
90+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
91+
this.inlineCriticalCss(html!, opts),
92+
);
93+
94+
html = content;
95+
96+
// eslint-disable-next-line no-console
97+
warnings?.forEach((m) => console.warn(m));
98+
// eslint-disable-next-line no-console
99+
errors?.forEach((m) => console.error(m));
100+
}
101+
}
102+
103+
if (enablePeformanceProfiler) {
104+
printPerformanceLogs();
105+
}
106+
107+
return html;
108+
}
109+
110+
private inlineCriticalCss(
111+
html: string,
112+
opts: CommonEngineRenderOptions,
113+
): Promise<InlineCriticalCssResult> {
114+
return this.inlineCriticalCssProcessor.process(html, {
115+
outputPath: opts.publicPath ?? (opts.documentFilePath ? dirname(opts.documentFilePath) : ''),
116+
});
117+
}
118+
119+
private async retrieveSSGPage(opts: CommonEngineRenderOptions): Promise<string | undefined> {
120+
const { publicPath, documentFilePath, url } = opts;
121+
if (!publicPath || !documentFilePath || url === undefined) {
122+
return undefined;
123+
}
124+
125+
const pathname = canParseUrl(url) ? new URL(url).pathname : url;
126+
// Remove leading forward slash.
127+
const pagePath = resolve(publicPath, pathname.substring(1), 'index.html');
128+
129+
if (pagePath !== resolve(documentFilePath)) {
130+
// View path doesn't match with prerender path.
131+
const pageIsSSG = this.pageIsSSG.get(pagePath);
132+
if (pageIsSSG === undefined) {
133+
if (await exists(pagePath)) {
134+
const content = await fs.promises.readFile(pagePath, 'utf-8');
135+
const isSSG = SSG_MARKER_REGEXP.test(content);
136+
this.pageIsSSG.set(pagePath, isSSG);
137+
138+
if (isSSG) {
139+
return content;
86140
}
87-
} else if (pageIsSSG) {
88-
// Serve pre-rendered page.
89-
return fs.promises.readFile(pagePath, 'utf-8');
141+
} else {
142+
this.pageIsSSG.set(pagePath, false);
90143
}
144+
} else if (pageIsSSG) {
145+
// Serve pre-rendered page.
146+
return fs.promises.readFile(pagePath, 'utf-8');
91147
}
92148
}
93149

94-
// if opts.document dosen't exist then opts.documentFilePath must
150+
return undefined;
151+
}
152+
153+
private async renderApplication(opts: CommonEngineRenderOptions): Promise<string> {
95154
const extraProviders: StaticProvider[] = [
96155
{ provide: ɵSERVER_CONTEXT, useValue: 'ssr' },
97156
...(opts.providers ?? []),
98-
...this.providers,
157+
...(this.options?.providers ?? []),
99158
];
100159

101160
let document = opts.document;
@@ -113,29 +172,14 @@ export class CommonEngine {
113172
});
114173
}
115174

116-
const moduleOrFactory = this.bootstrap || opts.bootstrap;
175+
const moduleOrFactory = this.options?.bootstrap ?? opts.bootstrap;
117176
if (!moduleOrFactory) {
118177
throw new Error('A module or bootstrap option must be provided.');
119178
}
120179

121-
const html = await (isBootstrapFn(moduleOrFactory)
180+
return isBootstrapFn(moduleOrFactory)
122181
? renderApplication(moduleOrFactory, { platformProviders: extraProviders })
123-
: renderModule(moduleOrFactory, { extraProviders }));
124-
125-
if (!inlineCriticalCss) {
126-
return html;
127-
}
128-
129-
const { content, errors, warnings } = await this.inlineCriticalCssProcessor.process(html, {
130-
outputPath: opts.publicPath ?? (opts.documentFilePath ? dirname(opts.documentFilePath) : ''),
131-
});
132-
133-
// eslint-disable-next-line no-console
134-
warnings?.forEach((m) => console.warn(m));
135-
// eslint-disable-next-line no-console
136-
errors?.forEach((m) => console.error(m));
137-
138-
return content;
182+
: renderModule(moduleOrFactory, { extraProviders });
139183
}
140184

141185
/** Retrieve the document from the cache or the filesystem */
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
const PERFORMANCE_MARK_PREFIX = '🅰️';
10+
11+
export function printPerformanceLogs(): void {
12+
let maxWordLength = 0;
13+
const benchmarks: [step: string, value: string][] = [];
14+
15+
for (const { name, duration } of performance.getEntriesByType('measure')) {
16+
if (!name.startsWith(PERFORMANCE_MARK_PREFIX)) {
17+
continue;
18+
}
19+
20+
// `🅰️:Retrieve SSG Page` -> `Retrieve SSG Page:`
21+
const step = name.slice(PERFORMANCE_MARK_PREFIX.length + 1) + ':';
22+
if (step.length > maxWordLength) {
23+
maxWordLength = step.length;
24+
}
25+
26+
benchmarks.push([step, `${duration.toFixed(1)}ms`]);
27+
performance.clearMeasures(name);
28+
}
29+
30+
/* eslint-disable no-console */
31+
console.log('********** Performance results **********');
32+
for (const [step, value] of benchmarks) {
33+
const spaces = maxWordLength - step.length + 5;
34+
console.log(step + ' '.repeat(spaces) + value);
35+
}
36+
console.log('*****************************************');
37+
/* eslint-enable no-console */
38+
}
39+
40+
export async function runMethodAndMeasurePerf<T>(
41+
label: string,
42+
asyncMethod: () => Promise<T>,
43+
): Promise<T> {
44+
const labelName = `${PERFORMANCE_MARK_PREFIX}:${label}`;
45+
const startLabel = `start:${labelName}`;
46+
const endLabel = `end:${labelName}`;
47+
48+
try {
49+
performance.mark(startLabel);
50+
51+
return await asyncMethod();
52+
} finally {
53+
performance.mark(endLabel);
54+
performance.measure(labelName, startLabel, endLabel);
55+
performance.clearMarks(startLabel);
56+
performance.clearMarks(endLabel);
57+
}
58+
}
59+
60+
export function noopRunMethodAndMeasurePerf<T>(
61+
label: string,
62+
asyncMethod: () => Promise<T>,
63+
): Promise<T> {
64+
return asyncMethod();
65+
}

0 commit comments

Comments
 (0)