forked from angular/angular-cli
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcommon-engine.ts
203 lines (162 loc) · 6.42 KB
/
common-engine.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
/**
* @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.dev/license
*/
import { ApplicationRef, StaticProvider, Type } from '@angular/core';
import { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';
import * as fs from 'node:fs';
import { dirname, join, normalize, resolve } from 'node:path';
import { URL } from 'node:url';
import { attachNodeGlobalErrorHandlers } from '../errors';
import { CommonEngineInlineCriticalCssProcessor } 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. */
enablePerformanceProfiler?: 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;
documentFilePath?: string;
/**
* Reduce render blocking requests by inlining critical CSS.
* Defaults to true.
*/
inlineCriticalCss?: boolean;
/**
* Base path location of index file.
* Defaults to the 'documentFilePath' dirname when not provided.
*/
publicPath?: string;
}
/**
* A common engine to use to server render an application.
*/
export class CommonEngine {
private readonly templateCache = new Map<string, string>();
private readonly inlineCriticalCssProcessor = new CommonEngineInlineCriticalCssProcessor();
private readonly pageIsSSG = new Map<string, boolean>();
constructor(private options?: CommonEngineOptions) {
attachNodeGlobalErrorHandlers();
}
/**
* Render an HTML document for a specific URL with specified
* render options
*/
async render(opts: CommonEngineRenderOptions): Promise<string> {
const enablePerformanceProfiler = this.options?.enablePerformanceProfiler;
const runMethod = enablePerformanceProfiler
? 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 = await runMethod('Inline Critical CSS', () =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.inlineCriticalCss(html!, opts),
);
html = content;
}
}
if (enablePerformanceProfiler) {
printPerformanceLogs();
}
return html;
}
private inlineCriticalCss(html: string, opts: CommonEngineRenderOptions): Promise<string> {
const outputPath =
opts.publicPath ?? (opts.documentFilePath ? dirname(opts.documentFilePath) : '');
return this.inlineCriticalCssProcessor.process(html, outputPath);
}
private async retrieveSSGPage(opts: CommonEngineRenderOptions): Promise<string | undefined> {
const { publicPath, documentFilePath, url } = opts;
if (!publicPath || !documentFilePath || url === undefined) {
return undefined;
}
const { pathname } = new URL(url, 'resolve://');
// Do not use `resolve` here as otherwise it can lead to path traversal vulnerability.
// See: https://portswigger.net/web-security/file-path-traversal
const pagePath = join(publicPath, pathname, 'index.html');
if (this.pageIsSSG.get(pagePath)) {
// Serve pre-rendered page.
return fs.promises.readFile(pagePath, 'utf-8');
}
if (!pagePath.startsWith(normalize(publicPath))) {
// Potential path traversal detected.
return undefined;
}
if (pagePath === resolve(documentFilePath) || !(await exists(pagePath))) {
// View matches with prerender path or file does not exist.
this.pageIsSSG.set(pagePath, false);
return undefined;
}
// Static file exists.
const content = await fs.promises.readFile(pagePath, 'utf-8');
const isSSG = SSG_MARKER_REGEXP.test(content);
this.pageIsSSG.set(pagePath, isSSG);
return isSSG ? content : undefined;
}
private async renderApplication(opts: CommonEngineRenderOptions): Promise<string> {
const moduleOrFactory = this.options?.bootstrap ?? opts.bootstrap;
if (!moduleOrFactory) {
throw new Error('A module or bootstrap option must be provided.');
}
const extraProviders: StaticProvider[] = [
{ provide: ɵSERVER_CONTEXT, useValue: 'ssr' },
...(opts.providers ?? []),
...(this.options?.providers ?? []),
];
let document = opts.document;
if (!document && opts.documentFilePath) {
document = await this.getDocument(opts.documentFilePath);
}
const commonRenderingOptions = {
url: opts.url,
document,
};
return isBootstrapFn(moduleOrFactory)
? renderApplication(moduleOrFactory, {
platformProviders: extraProviders,
...commonRenderingOptions,
})
: renderModule(moduleOrFactory, { extraProviders, ...commonRenderingOptions });
}
/** Retrieve the document from the cache or the filesystem */
private async getDocument(filePath: string): Promise<string> {
let doc = this.templateCache.get(filePath);
if (!doc) {
doc = await fs.promises.readFile(filePath, 'utf-8');
this.templateCache.set(filePath, doc);
}
return doc;
}
}
async function exists(path: fs.PathLike): Promise<boolean> {
try {
await fs.promises.access(path, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
return typeof value === 'function' && !('ɵmod' in value);
}