Skip to content
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

feat(@angular/ssr): add performance profiler to CommonEngine #25903

Merged
merged 1 commit into from Sep 28, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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();
}