Skip to content

Commit 4af3852

Browse files
committed
feat(@angular/ssr): introduce BootstrapContext for isolated server-side rendering
This commit introduces a number of changes to the server bootstrapping process to make it more robust and less error-prone, especially for concurrent requests. Previously, the server rendering process relied on a module-level global platform injector. This could lead to issues in server-side rendering environments where multiple requests are processed concurrently, as they could inadvertently share or overwrite the global injector state. The new approach introduces a `BootstrapContext` that is passed to the `bootstrapApplication` function. This context provides a platform reference that is scoped to the individual request, ensuring that each server-side render has an isolated platform injector. This prevents state leakage between concurrent requests and makes the overall process more reliable. BREAKING CHANGE: The server-side bootstrapping process has been changed to eliminate the reliance on a global platform injector. Before: ```ts const bootstrap = () => bootstrapApplication(AppComponent, config); ``` After: ```ts const bootstrap = (context: BootstrapContext) => bootstrapApplication(AppComponent, config, context); ```
1 parent 5d82d44 commit 4af3852

File tree

8 files changed

+232
-12
lines changed

8 files changed

+232
-12
lines changed

packages/angular/build/src/utils/routes-extractor/extractor.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ɵwhenStable as whenStable,
1717
ɵConsole,
1818
} from '@angular/core';
19+
import { BootstrapContext } from '@angular/platform-browser';
1920
import {
2021
INITIAL_CONFIG,
2122
ɵINTERNAL_SERVER_PLATFORM_PROVIDERS as INTERNAL_SERVER_PLATFORM_PROVIDERS,
@@ -76,7 +77,7 @@ async function* getRoutesFromRouterConfig(
7677
}
7778

7879
export async function* extractRoutes(
79-
bootstrapAppFnOrModule: (() => Promise<ApplicationRef>) | Type<unknown>,
80+
bootstrapAppFnOrModule: ((context: BootstrapContext) => Promise<ApplicationRef>) | Type<unknown>,
8081
document: string,
8182
): AsyncIterableIterator<RouterResult> {
8283
const platformRef = createPlatformFactory(platformCore, 'server', [
@@ -106,7 +107,7 @@ export async function* extractRoutes(
106107
try {
107108
let applicationRef: ApplicationRef;
108109
if (isBootstrapFn(bootstrapAppFnOrModule)) {
109-
applicationRef = await bootstrapAppFnOrModule();
110+
applicationRef = await bootstrapAppFnOrModule({ platformRef });
110111
} else {
111112
const moduleRef = await platformRef.bootstrapModule(bootstrapAppFnOrModule);
112113
applicationRef = moduleRef.injector.get(ApplicationRef);
@@ -131,7 +132,9 @@ export async function* extractRoutes(
131132
}
132133
}
133134

134-
function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
135+
function isBootstrapFn(
136+
value: unknown,
137+
): value is (context: BootstrapContext) => Promise<ApplicationRef> {
135138
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
136139
return typeof value === 'function' && !('ɵmod' in value);
137140
}

packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88

99
import type { ApplicationRef, Type, ɵConsole } from '@angular/core';
1010
import type { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';
11+
import type { BootstrapContext } from '@angular/platform-browser';
1112
import type { extractRoutes } from '../routes-extractor/extractor';
1213

1314
export interface MainServerBundleExports {
1415
/** Standalone application bootstrapping function. */
15-
default: (() => Promise<ApplicationRef>) | Type<unknown>;
16+
default: ((context: BootstrapContext) => Promise<ApplicationRef>) | Type<unknown>;
1617
}
1718

1819
export interface RenderUtilsServerBundleExports {

packages/angular/build/src/utils/server-rendering/render-page.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import type { ApplicationRef, StaticProvider } from '@angular/core';
10+
import type { BootstrapContext } from '@angular/platform-browser';
1011
import assert from 'node:assert';
1112
import { basename } from 'node:path';
1213
import { loadEsmModuleFromMemory } from './load-esm-from-memory';
@@ -139,7 +140,9 @@ export async function renderPage({
139140
};
140141
}
141142

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

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { ApplicationRef, StaticProvider, Type } from '@angular/core';
10+
import { BootstrapContext } from '@angular/platform-browser';
1011
import { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';
1112
import * as fs from 'node:fs';
1213
import { dirname, join, normalize, resolve } from 'node:path';
@@ -22,7 +23,8 @@ const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/;
2223

2324
export interface CommonEngineOptions {
2425
/** A method that when invoked returns a promise that returns an `ApplicationRef` instance once resolved or an NgModule. */
25-
bootstrap?: Type<{}> | (() => Promise<ApplicationRef>);
26+
bootstrap?: Type<{}> | ((context: BootstrapContext) => Promise<ApplicationRef>);
27+
2628
/** A set of platform level providers for all requests. */
2729
providers?: StaticProvider[];
2830
/** Enable request performance profiling data collection and printing the results in the server console. */
@@ -200,7 +202,9 @@ async function exists(path: fs.PathLike): Promise<boolean> {
200202
}
201203
}
202204

203-
function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
205+
function isBootstrapFn(
206+
value: unknown,
207+
): value is (context: BootstrapContext) => Promise<ApplicationRef> {
204208
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
205209
return typeof value === 'function' && !('ɵmod' in value);
206210
}

packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import type { ApplicationRef, StaticProvider, Type } from '@angular/core';
10+
import type { BootstrapContext } from '@angular/platform-browser';
1011
import type { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';
1112
import assert from 'node:assert';
1213
import { workerData } from 'node:worker_threads';
@@ -119,7 +120,9 @@ async function render({ serverBundlePath, document, url }: RenderRequest): Promi
119120
return Promise.race([renderAppPromise, renderingTimeout]).finally(() => clearTimeout(timer));
120121
}
121122

122-
function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
123+
function isBootstrapFn(
124+
value: unknown,
125+
): value is (context: BootstrapContext) => Promise<ApplicationRef> {
123126
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
124127
return typeof value === 'function' && !('ɵmod' in value);
125128
}

packages/angular_devkit/build_angular/src/builders/prerender/render-worker.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import type { ApplicationRef, StaticProvider, Type } from '@angular/core';
10+
import type { BootstrapContext } from '@angular/platform-browser';
1011
import type { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';
1112
import assert from 'node:assert';
1213
import * as fs from 'node:fs';
@@ -148,11 +149,12 @@ async function render({
148149
return result;
149150
}
150151

151-
function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
152+
function isBootstrapFn(
153+
value: unknown,
154+
): value is (context: BootstrapContext) => Promise<ApplicationRef> {
152155
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
153156
return typeof value === 'function' && !('ɵmod' in value);
154157
}
155-
156158
/**
157159
* Initializes the worker when it is first created by loading the Zone.js package
158160
* into the worker instance.
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { bootstrapApplication } from '@angular/platform-browser';
1+
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
22
import { AppComponent } from './app/app.component';
33
import { config } from './app/app.config.server';
44

5-
const bootstrap = () => bootstrapApplication(AppComponent, config);
5+
const bootstrap = (context: BootstrapContext) =>
6+
bootstrapApplication(AppComponent, config, BootstrapContext);
67

78
export default bootstrap;

0 commit comments

Comments
 (0)