Skip to content

Commit d51cb59

Browse files
committedApr 17, 2024·
feat(@angular-devkit/build-angular): inject event-dispatch in SSR HTML page
This commit add support in the Angular CLI to inject the event-dispatcher script when using the application builder.
1 parent adf1197 commit d51cb59

File tree

10 files changed

+178
-93
lines changed

10 files changed

+178
-93
lines changed
 

Diff for: ‎packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts

+10-17
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export async function executePostBundleSteps(
5252
indexHtmlOptions,
5353
optimizationOptions,
5454
sourcemapOptions,
55-
ssrOptions,
5655
prerenderOptions,
5756
appShellOptions,
5857
workspaceRoot,
@@ -64,7 +63,7 @@ export async function executePostBundleSteps(
6463
*
6564
* NOTE: we don't perform critical CSS inlining as this will be done during server rendering.
6665
*/
67-
let indexContentOutputNoCssInlining: string | undefined;
66+
let ssrIndexContent: string | undefined;
6867

6968
// When using prerender/app-shell the index HTML file can be regenerated.
7069
// Thus, we use a Map so that we do not generate 2 files with the same filename.
@@ -73,43 +72,37 @@ export async function executePostBundleSteps(
7372
// Generate index HTML file
7473
// If localization is enabled, index generation is handled in the inlining process.
7574
if (indexHtmlOptions) {
76-
const { content, contentWithoutCriticalCssInlined, errors, warnings } = await generateIndexHtml(
75+
const { csrContent, ssrContent, errors, warnings } = await generateIndexHtml(
7776
initialFiles,
7877
outputFiles,
79-
{
80-
...options,
81-
optimizationOptions,
82-
},
78+
options,
8379
locale,
8480
);
8581

86-
indexContentOutputNoCssInlining = contentWithoutCriticalCssInlined;
8782
allErrors.push(...errors);
8883
allWarnings.push(...warnings);
8984

9085
additionalHtmlOutputFiles.set(
9186
indexHtmlOptions.output,
92-
createOutputFileFromText(indexHtmlOptions.output, content, BuildOutputFileType.Browser),
87+
createOutputFileFromText(indexHtmlOptions.output, csrContent, BuildOutputFileType.Browser),
9388
);
9489

95-
if (ssrOptions) {
90+
if (ssrContent) {
9691
const serverIndexHtmlFilename = 'index.server.html';
9792
additionalHtmlOutputFiles.set(
9893
serverIndexHtmlFilename,
99-
createOutputFileFromText(
100-
serverIndexHtmlFilename,
101-
contentWithoutCriticalCssInlined,
102-
BuildOutputFileType.Server,
103-
),
94+
createOutputFileFromText(serverIndexHtmlFilename, ssrContent, BuildOutputFileType.Server),
10495
);
96+
97+
ssrIndexContent = ssrContent;
10598
}
10699
}
107100

108101
// Pre-render (SSG) and App-shell
109102
// If localization is enabled, prerendering is handled in the inlining process.
110103
if (prerenderOptions || appShellOptions) {
111104
assert(
112-
indexContentOutputNoCssInlining,
105+
ssrIndexContent,
113106
'The "index" option is required when using the "ssg" or "appShell" options.',
114107
);
115108

@@ -124,7 +117,7 @@ export async function executePostBundleSteps(
124117
prerenderOptions,
125118
outputFiles,
126119
assetFiles,
127-
indexContentOutputNoCssInlining,
120+
ssrIndexContent,
128121
sourcemapOptions.scripts,
129122
optimizationOptions.styles.inlineCritical,
130123
maxWorkers,

Diff for: ‎packages/angular_devkit/build_angular/src/builders/browser/index.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,11 @@ export function buildWebpackBrowser(
318318
let hasErrors = false;
319319
for (const [locale, outputPath] of outputPaths.entries()) {
320320
try {
321-
const { content, warnings, errors } = await indexHtmlGenerator.process({
321+
const {
322+
csrContent: content,
323+
warnings,
324+
errors,
325+
} = await indexHtmlGenerator.process({
322326
baseHref: getLocaleBaseHref(i18n, locale) ?? options.baseHref,
323327
// i18nLocale is used when Ivy is disabled
324328
lang: locale || undefined,

Diff for: ‎packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts

+9-40
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export async function generateIndexHtml(
1818
buildOptions: NormalizedApplicationBuildOptions,
1919
lang?: string,
2020
): Promise<{
21-
content: string;
22-
contentWithoutCriticalCssInlined: string;
21+
csrContent: string;
22+
ssrContent?: string;
2323
warnings: string[];
2424
errors: string[];
2525
}> {
@@ -74,21 +74,20 @@ export async function generateIndexHtml(
7474
indexPath: indexHtmlOptions.input,
7575
entrypoints: indexHtmlOptions.insertionOrder,
7676
sri: subresourceIntegrity,
77-
optimization: {
78-
...optimizationOptions,
79-
styles: {
80-
...optimizationOptions.styles,
81-
inlineCritical: false, // Disable critical css inline as for SSR and SSG this will be done during rendering.
82-
},
83-
},
77+
optimization: optimizationOptions,
8478
crossOrigin: crossOrigin,
8579
deployUrl: buildOptions.publicPath,
8680
postTransform: indexHtmlOptions.transformer,
81+
generateDedicatedSSRContent: !!(
82+
buildOptions.ssrOptions ||
83+
buildOptions.prerenderOptions ||
84+
buildOptions.appShellOptions
85+
),
8786
});
8887

8988
indexHtmlGenerator.readAsset = readAsset;
9089

91-
const transformResult = await indexHtmlGenerator.process({
90+
return indexHtmlGenerator.process({
9291
baseHref,
9392
lang,
9493
outputPath: virtualOutputPath,
@@ -101,34 +100,4 @@ export async function generateIndexHtml(
101100
})),
102101
hints,
103102
});
104-
105-
const contentWithoutCriticalCssInlined = transformResult.content;
106-
if (!optimizationOptions.styles.inlineCritical) {
107-
return {
108-
...transformResult,
109-
contentWithoutCriticalCssInlined,
110-
};
111-
}
112-
113-
const { InlineCriticalCssProcessor } = await import('../../utils/index-file/inline-critical-css');
114-
115-
const inlineCriticalCssProcessor = new InlineCriticalCssProcessor({
116-
minify: false, // CSS has already been minified during the build.
117-
deployUrl: buildOptions.publicPath,
118-
readAsset,
119-
});
120-
121-
const { content, errors, warnings } = await inlineCriticalCssProcessor.process(
122-
contentWithoutCriticalCssInlined,
123-
{
124-
outputPath: virtualOutputPath,
125-
},
126-
);
127-
128-
return {
129-
errors: [...transformResult.errors, ...errors],
130-
warnings: [...transformResult.warnings, ...warnings],
131-
content,
132-
contentWithoutCriticalCssInlined,
133-
};
134103
}

Diff for: ‎packages/angular_devkit/build_angular/src/tools/webpack/plugins/index-html-webpack-plugin.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ export class IndexHtmlWebpackPlugin extends IndexHtmlGenerator {
6767
}
6868
}
6969

70-
const { content, warnings, errors } = await this.process({
70+
const {
71+
csrContent: content,
72+
warnings,
73+
errors,
74+
} = await this.process({
7175
files,
7276
outputPath: dirname(this.options.outputPath),
7377
baseHref: this.options.baseHref,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
import { readFile } from 'node:fs/promises';
10+
import { htmlRewritingStream } from './html-rewriting-stream';
11+
12+
let jsActionContractScript: string;
13+
14+
export async function addEventDispatchContract(html: string): Promise<string> {
15+
const { rewriter, transformedContent } = await htmlRewritingStream(html);
16+
17+
jsActionContractScript ??=
18+
'<script type="text/javascript" id="ng-event-dispatch-contract">' +
19+
(await readFile(require.resolve('@angular/core/event-dispatch-contract.min.js'), 'utf-8')) +
Has conversations. Original line has conversations.
20+
'</script>';
21+
22+
rewriter.on('startTag', (tag) => {
23+
rewriter.emitStartTag(tag);
24+
25+
if (tag.tagName === 'body') {
26+
rewriter.emitRaw(jsActionContractScript);
27+
}
28+
});
29+
30+
return transformedContent();
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
import { addEventDispatchContract } from './add-event-dispatch-contract';
10+
11+
describe('addEventDispatchContract', () => {
12+
it('should inline event dispatcher script', async () => {
13+
const result = await addEventDispatchContract(`
14+
<html>
15+
<head></head>
16+
<body>
17+
<h1>Hello World!</h1>
18+
</body>
19+
</html>
20+
`);
21+
22+
expect(result).toMatch(
23+
/<body>\s*<script type="text\/javascript" id="ng-event-dispatch-contract">.+<\/script>\s*<h1>/,
24+
);
25+
});
26+
});

Diff for: ‎packages/angular_devkit/build_angular/src/utils/index-file/augment-index-html.ts

+1
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ export async function augmentIndexHtml(
224224
foundPreconnects.add(href);
225225
}
226226
}
227+
break;
227228
}
228229

229230
rewriter.emitStartTag(tag);

Diff for: ‎packages/angular_devkit/build_angular/src/utils/index-file/index-html-generator.ts

+59-25
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@ import { readFile } from 'node:fs/promises';
1010
import { join } from 'node:path';
1111
import { NormalizedCachedOptions } from '../normalize-cache';
1212
import { NormalizedOptimizationOptions } from '../normalize-optimization';
13+
import { addEventDispatchContract } from './add-event-dispatch-contract';
1314
import { CrossOriginValue, Entrypoint, FileInfo, augmentIndexHtml } from './augment-index-html';
1415
import { InlineCriticalCssProcessor } from './inline-critical-css';
1516
import { InlineFontsProcessor } from './inline-fonts';
16-
import { addStyleNonce } from './style-nonce';
17+
import { addNonce } from './nonce';
1718

1819
type IndexHtmlGeneratorPlugin = (
1920
html: string,
2021
options: IndexHtmlGeneratorProcessOptions,
21-
) => Promise<string | IndexHtmlTransformResult>;
22+
) => Promise<string | IndexHtmlPluginTransformResult> | string;
2223

2324
export type HintMode = 'prefetch' | 'preload' | 'modulepreload' | 'preconnect' | 'dns-prefetch';
2425

@@ -40,45 +41,78 @@ export interface IndexHtmlGeneratorOptions {
4041
optimization?: NormalizedOptimizationOptions;
4142
cache?: NormalizedCachedOptions;
4243
imageDomains?: string[];
44+
generateDedicatedSSRContent?: boolean;
4345
}
4446

4547
export type IndexHtmlTransform = (content: string) => Promise<string>;
4648

47-
export interface IndexHtmlTransformResult {
49+
export interface IndexHtmlPluginTransformResult {
4850
content: string;
4951
warnings: string[];
5052
errors: string[];
5153
}
5254

55+
export interface IndexHtmlProcessResult {
56+
csrContent: string;
57+
ssrContent?: string;
58+
warnings: string[];
59+
errors: string[];
60+
}
61+
5362
export class IndexHtmlGenerator {
5463
private readonly plugins: IndexHtmlGeneratorPlugin[];
64+
private readonly csrPlugins: IndexHtmlGeneratorPlugin[] = [];
65+
private readonly ssrPlugins: IndexHtmlGeneratorPlugin[] = [];
5566

5667
constructor(readonly options: IndexHtmlGeneratorOptions) {
57-
const extraPlugins: IndexHtmlGeneratorPlugin[] = [];
58-
if (this.options.optimization?.fonts.inline) {
59-
extraPlugins.push(inlineFontsPlugin(this));
68+
const extraCommonPlugins: IndexHtmlGeneratorPlugin[] = [];
69+
if (options?.optimization?.fonts.inline) {
70+
extraCommonPlugins.push(inlineFontsPlugin(this), addNonce);
6071
}
6172

62-
if (this.options.optimization?.styles.inlineCritical) {
63-
extraPlugins.push(inlineCriticalCssPlugin(this));
73+
// Common plugins
74+
this.plugins = [augmentIndexHtmlPlugin(this), ...extraCommonPlugins, postTransformPlugin(this)];
75+
76+
// CSR plugins
77+
if (options?.optimization?.styles?.inlineCritical) {
78+
this.csrPlugins.push(inlineCriticalCssPlugin(this));
6479
}
6580

66-
this.plugins = [
67-
augmentIndexHtmlPlugin(this),
68-
...extraPlugins,
69-
// Runs after the `extraPlugins` to capture any nonce or
70-
// `style` tags that might've been added by them.
71-
addStyleNoncePlugin(),
72-
postTransformPlugin(this),
73-
];
81+
// SSR plugins
82+
if (options.generateDedicatedSSRContent) {
83+
this.ssrPlugins.push(addEventDispatchContractPlugin(), addNoncePlugin());
84+
}
7485
}
7586

76-
async process(options: IndexHtmlGeneratorProcessOptions): Promise<IndexHtmlTransformResult> {
87+
async process(options: IndexHtmlGeneratorProcessOptions): Promise<IndexHtmlProcessResult> {
7788
let content = await this.readIndex(this.options.indexPath);
7889
const warnings: string[] = [];
7990
const errors: string[] = [];
8091

81-
for (const plugin of this.plugins) {
92+
content = await this.runPlugins(content, this.plugins, options, warnings, errors);
93+
const [csrContent, ssrContent] = await Promise.all([
94+
this.runPlugins(content, this.csrPlugins, options, warnings, errors),
95+
this.ssrPlugins.length
96+
? this.runPlugins(content, this.ssrPlugins, options, warnings, errors)
97+
: undefined,
98+
]);
99+
100+
return {
101+
ssrContent,
102+
csrContent,
103+
warnings,
104+
errors,
105+
};
106+
}
107+
108+
private async runPlugins(
109+
content: string,
110+
plugins: IndexHtmlGeneratorPlugin[],
111+
options: IndexHtmlGeneratorProcessOptions,
112+
warnings: string[],
113+
errors: string[],
114+
): Promise<string> {
115+
for (const plugin of plugins) {
82116
const result = await plugin(content, options);
83117
if (typeof result === 'string') {
84118
content = result;
@@ -95,11 +129,7 @@ export class IndexHtmlGenerator {
95129
}
96130
}
97131

98-
return {
99-
content,
100-
warnings,
101-
errors,
102-
};
132+
return content;
103133
}
104134

105135
async readAsset(path: string): Promise<string> {
@@ -160,10 +190,14 @@ function inlineCriticalCssPlugin(generator: IndexHtmlGenerator): IndexHtmlGenera
160190
inlineCriticalCssProcessor.process(html, { outputPath: options.outputPath });
161191
}
162192

163-
function addStyleNoncePlugin(): IndexHtmlGeneratorPlugin {
164-
return (html) => addStyleNonce(html);
193+
function addNoncePlugin(): IndexHtmlGeneratorPlugin {
194+
return (html) => addNonce(html);
165195
}
166196

167197
function postTransformPlugin({ options }: IndexHtmlGenerator): IndexHtmlGeneratorPlugin {
168198
return async (html) => (options.postTransform ? options.postTransform(html) : html);
169199
}
200+
201+
function addEventDispatchContractPlugin(): IndexHtmlGeneratorPlugin {
202+
return (html) => addEventDispatchContract(html);
203+
}

Diff for: ‎packages/angular_devkit/build_angular/src/utils/index-file/style-nonce.ts renamed to ‎packages/angular_devkit/build_angular/src/utils/index-file/nonce.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ import { htmlRewritingStream } from './html-rewriting-stream';
1515
const NONCE_ATTR_PATTERN = /ngCspNonce/i;
1616

1717
/**
18-
* Finds the `ngCspNonce` value and copies it to all inline `<style>` tags.
18+
* Finds the `ngCspNonce` value and copies it to all inline `<style>` and `<script> `tags.
1919
* @param html Markup that should be processed.
2020
*/
21-
export async function addStyleNonce(html: string): Promise<string> {
21+
export async function addNonce(html: string): Promise<string> {
2222
const nonce = await findNonce(html);
2323

2424
if (!nonce) {
@@ -28,7 +28,11 @@ export async function addStyleNonce(html: string): Promise<string> {
2828
const { rewriter, transformedContent } = await htmlRewritingStream(html);
2929

3030
rewriter.on('startTag', (tag) => {
31-
if (tag.tagName === 'style' && !tag.attrs.some((attr) => attr.name === 'nonce')) {
31+
if (
32+
(tag.tagName === 'style' ||
33+
(tag.tagName === 'script' && !tag.attrs.some((attr) => attr.name === 'src'))) &&
34+
!tag.attrs.some((attr) => attr.name === 'nonce')
35+
) {
3236
tag.attrs.push({ name: 'nonce', value: nonce });
3337
}
3438

Diff for: ‎packages/angular_devkit/build_angular/src/utils/index-file/style-nonce_spec.ts renamed to ‎packages/angular_devkit/build_angular/src/utils/index-file/nonce_spec.ts

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

9-
import { addStyleNonce } from './style-nonce';
9+
import { addNonce } from './nonce';
1010

11-
describe('add-style-nonce', () => {
11+
describe('addNonce', () => {
1212
it('should add the nonce expression to all inline style tags', async () => {
13-
const result = await addStyleNonce(`
13+
const result = await addNonce(`
1414
<html>
1515
<head>
1616
<style>.a {color: red;}</style>
@@ -27,7 +27,7 @@ describe('add-style-nonce', () => {
2727
});
2828

2929
it('should add a lowercase nonce expression to style tags', async () => {
30-
const result = await addStyleNonce(`
30+
const result = await addNonce(`
3131
<html>
3232
<head>
3333
<style>.a {color: red;}</style>
@@ -42,7 +42,7 @@ describe('add-style-nonce', () => {
4242
});
4343

4444
it('should preserve any pre-existing nonces', async () => {
45-
const result = await addStyleNonce(`
45+
const result = await addNonce(`
4646
<html>
4747
<head>
4848
<style>.a {color: red;}</style>
@@ -59,7 +59,7 @@ describe('add-style-nonce', () => {
5959
});
6060

6161
it('should use the first nonce that is defined on the page', async () => {
62-
const result = await addStyleNonce(`
62+
const result = await addNonce(`
6363
<html>
6464
<head>
6565
<style>.a {color: red;}</style>
@@ -73,4 +73,23 @@ describe('add-style-nonce', () => {
7373

7474
expect(result).toContain('<style nonce="{% nonce %}">.a {color: red;}</style>');
7575
});
76+
77+
it('should to all inline script tags', async () => {
78+
const result = await addNonce(`
79+
<html>
80+
<head>
81+
</head>
82+
<body>
83+
<app ngCspNonce="{% nonce %}"></app>
84+
<script>console.log('foo');</<script>
85+
<script src="./main.js"></script>
86+
<script>console.log('bar');</<script>
87+
</body>
88+
</html>
89+
`);
90+
91+
expect(result).toContain(`<script nonce="{% nonce %}">console.log('foo');</<script>`);
92+
expect(result).toContain('<script src="./main.js"></script>');
93+
expect(result).toContain(`<script nonce="{% nonce %}">console.log('bar');</<script>`);
94+
});
7695
});

0 commit comments

Comments
 (0)
Please sign in to comment.