Skip to content

Commit

Permalink
feat(@angular/ssr): add performance profiler to CommonEngine
Browse files Browse the repository at this point in the history
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
});
```
  • Loading branch information
alan-agius4 committed Sep 28, 2023
1 parent e516a4b commit dcf3fdd
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 56 deletions.
11 changes: 8 additions & 3 deletions goldens/public-api/angular/ssr/index.md
Expand Up @@ -10,20 +10,25 @@ import { Type } from '@angular/core';

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

// @public (undocumented)
export interface CommonEngineOptions {
bootstrap?: Type<{}> | (() => Promise<ApplicationRef>);
enablePeformanceProfiler?: boolean;
providers?: StaticProvider[];
}

// @public (undocumented)
export interface CommonEngineRenderOptions {
// (undocumented)
bootstrap?: Type<{}> | (() => Promise<ApplicationRef>);
// (undocumented)
document?: string;
// (undocumented)
documentFilePath?: string;
inlineCriticalCss?: boolean;
// (undocumented)
providers?: StaticProvider[];
publicPath?: string;
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/ssr/public_api.ts
Expand Up @@ -6,4 +6,4 @@
* found in the LICENSE file at https://angular.io/license
*/

export { CommonEngine, CommonEngineRenderOptions } from './src/common-engine';
export { CommonEngine, CommonEngineRenderOptions, CommonEngineOptions } from './src/common-engine';
148 changes: 96 additions & 52 deletions packages/angular/ssr/src/common-engine.ts
Expand Up @@ -16,12 +16,28 @@ import {
import * as fs from 'node:fs';
import { dirname, resolve } from 'node:path';
import { URL } from 'node:url';
import { InlineCriticalCssProcessor } from './inline-css-processor';
import { InlineCriticalCssProcessor, InlineCriticalCssResult } from './inline-css-processor';
import {
noopRunMethodAndMeasurePerf,
printPerformanceLogs,
runMethodAndMeasurePerf,
} from './peformance-profiler';

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

export interface CommonEngineOptions {
/** A method that when invoked returns a promise that returns an `ApplicationRef` instance once resolved or an NgModule. */
bootstrap?: Type<{}> | (() => Promise<ApplicationRef>);
/** A set of platform level providers for all requests. */
providers?: StaticProvider[];
/** Enable request performance profiling data collection and printing the results in the server console. */
enablePeformanceProfiler?: boolean;
}

export interface CommonEngineRenderOptions {
/** A method that when invoked returns a promise that returns an `ApplicationRef` instance once resolved or an NgModule. */
bootstrap?: Type<{}> | (() => Promise<ApplicationRef>);
/** A set of platform level providers for the current request. */
providers?: StaticProvider[];
url?: string;
document?: string;
Expand All @@ -39,19 +55,15 @@ export interface CommonEngineRenderOptions {
}

/**
* A common rendering engine utility. This abstracts the logic
* for handling the platformServer compiler, the module cache, and
* the document loader
* A common engine to use to server render an application.
*/

export class CommonEngine {
private readonly templateCache = new Map<string, string>();
private readonly inlineCriticalCssProcessor: InlineCriticalCssProcessor;
private readonly pageIsSSG = new Map<string, boolean>();

constructor(
private bootstrap?: Type<{}> | (() => Promise<ApplicationRef>),
private providers: StaticProvider[] = [],
) {
constructor(private options?: CommonEngineOptions) {
this.inlineCriticalCssProcessor = new InlineCriticalCssProcessor({
minify: false,
});
Expand All @@ -62,40 +74,87 @@ export class CommonEngine {
* render options
*/
async render(opts: CommonEngineRenderOptions): Promise<string> {
const { inlineCriticalCss = true, url } = opts;

if (opts.publicPath && opts.documentFilePath && url !== undefined) {
const pathname = canParseUrl(url) ? new URL(url).pathname : url;
// Remove leading forward slash.
const pagePath = resolve(opts.publicPath, pathname.substring(1), 'index.html');

if (pagePath !== resolve(opts.documentFilePath)) {
// View path doesn't match with prerender path.
const pageIsSSG = this.pageIsSSG.get(pagePath);
if (pageIsSSG === undefined) {
if (await exists(pagePath)) {
const content = await fs.promises.readFile(pagePath, 'utf-8');
const isSSG = SSG_MARKER_REGEXP.test(content);
this.pageIsSSG.set(pagePath, isSSG);

if (isSSG) {
return content;
}
} else {
this.pageIsSSG.set(pagePath, false);
const enablePeformanceProfiler = this.options?.enablePeformanceProfiler;

const runMethod = enablePeformanceProfiler
? runMethodAndMeasurePerf
: noopRunMethodAndMeasurePerf;

let html = await runMethod('Retrieve SSG Page', () => this.retrieveSSGPage(opts));

if (html === undefined) {
html = await runMethod('Render Page', () => this.renderApplication(opts));

if (opts.inlineCriticalCss !== false) {
const { content, errors, warnings } = await runMethod('Inline Critical CSS', () =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.inlineCriticalCss(html!, opts),
);

html = content;

// eslint-disable-next-line no-console
warnings?.forEach((m) => console.warn(m));
// eslint-disable-next-line no-console
errors?.forEach((m) => console.error(m));
}
}

if (enablePeformanceProfiler) {
printPerformanceLogs();
}

return html;
}

private inlineCriticalCss(
html: string,
opts: CommonEngineRenderOptions,
): Promise<InlineCriticalCssResult> {
return this.inlineCriticalCssProcessor.process(html, {
outputPath: opts.publicPath ?? (opts.documentFilePath ? dirname(opts.documentFilePath) : ''),
});
}

private async retrieveSSGPage(opts: CommonEngineRenderOptions): Promise<string | undefined> {
const { publicPath, documentFilePath, url } = opts;
if (!publicPath || !documentFilePath || url === undefined) {
return undefined;
}

const pathname = canParseUrl(url) ? new URL(url).pathname : url;
// Remove leading forward slash.
const pagePath = resolve(publicPath, pathname.substring(1), 'index.html');

if (pagePath !== resolve(documentFilePath)) {
// View path doesn't match with prerender path.
const pageIsSSG = this.pageIsSSG.get(pagePath);
if (pageIsSSG === undefined) {
if (await exists(pagePath)) {
const content = await fs.promises.readFile(pagePath, 'utf-8');
const isSSG = SSG_MARKER_REGEXP.test(content);
this.pageIsSSG.set(pagePath, isSSG);

if (isSSG) {
return content;
}
} else if (pageIsSSG) {
// Serve pre-rendered page.
return fs.promises.readFile(pagePath, 'utf-8');
} else {
this.pageIsSSG.set(pagePath, false);
}
} else if (pageIsSSG) {
// Serve pre-rendered page.
return fs.promises.readFile(pagePath, 'utf-8');
}
}

// if opts.document dosen't exist then opts.documentFilePath must
return undefined;
}

private async renderApplication(opts: CommonEngineRenderOptions): Promise<string> {
const extraProviders: StaticProvider[] = [
{ provide: ɵSERVER_CONTEXT, useValue: 'ssr' },
...(opts.providers ?? []),
...this.providers,
...(this.options?.providers ?? []),
];

let document = opts.document;
Expand All @@ -113,29 +172,14 @@ export class CommonEngine {
});
}

const moduleOrFactory = this.bootstrap || opts.bootstrap;
const moduleOrFactory = this.options?.bootstrap ?? opts.bootstrap;
if (!moduleOrFactory) {
throw new Error('A module or bootstrap option must be provided.');
}

const html = await (isBootstrapFn(moduleOrFactory)
return isBootstrapFn(moduleOrFactory)
? renderApplication(moduleOrFactory, { platformProviders: extraProviders })
: renderModule(moduleOrFactory, { extraProviders }));

if (!inlineCriticalCss) {
return html;
}

const { content, errors, warnings } = await this.inlineCriticalCssProcessor.process(html, {
outputPath: opts.publicPath ?? (opts.documentFilePath ? dirname(opts.documentFilePath) : ''),
});

// eslint-disable-next-line no-console
warnings?.forEach((m) => console.warn(m));
// eslint-disable-next-line no-console
errors?.forEach((m) => console.error(m));

return content;
: renderModule(moduleOrFactory, { extraProviders });
}

/** Retrieve the document from the cache or the filesystem */
Expand Down
65 changes: 65 additions & 0 deletions packages/angular/ssr/src/peformance-profiler.ts
@@ -0,0 +1,65 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

const PERFORMANCE_MARK_PREFIX = '🅰️';

export function printPerformanceLogs(): void {
let maxWordLength = 0;
const benchmarks: [step: string, value: string][] = [];

for (const { name, duration } of performance.getEntriesByType('measure')) {
if (!name.startsWith(PERFORMANCE_MARK_PREFIX)) {
continue;
}

// `🅰️:Retrieve SSG Page` -> `Retrieve SSG Page:`
const step = name.slice(PERFORMANCE_MARK_PREFIX.length + 1) + ':';
if (step.length > maxWordLength) {
maxWordLength = step.length;
}

benchmarks.push([step, `${duration.toFixed(1)}ms`]);
performance.clearMeasures(name);
}

/* eslint-disable no-console */
console.log('********** Performance results **********');
for (const [step, value] of benchmarks) {
const spaces = maxWordLength - step.length + 5;
console.log(step + ' '.repeat(spaces) + value);
}
console.log('*****************************************');
/* eslint-enable no-console */
}

export async function runMethodAndMeasurePerf<T>(
label: string,
asyncMethod: () => Promise<T>,
): Promise<T> {
const labelName = `${PERFORMANCE_MARK_PREFIX}:${label}`;
const startLabel = `start:${labelName}`;
const endLabel = `end:${labelName}`;

try {
performance.mark(startLabel);

return await asyncMethod();
} finally {
performance.mark(endLabel);
performance.measure(labelName, startLabel, endLabel);
performance.clearMarks(startLabel);
performance.clearMarks(endLabel);
}
}

export function noopRunMethodAndMeasurePerf<T>(
label: string,
asyncMethod: () => Promise<T>,
): Promise<T> {
return asyncMethod();
}

0 comments on commit dcf3fdd

Please sign in to comment.