Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): inject event-dispatch in SSR HTM…
Browse files Browse the repository at this point in the history
…L page

This commit add support in the Angular CLI to inject the event-dispatcher script when using the application builder.
  • Loading branch information
alan-agius4 committed Apr 17, 2024
1 parent adf1197 commit d51cb59
Show file tree
Hide file tree
Showing 10 changed files with 178 additions and 93 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ export async function executePostBundleSteps(
indexHtmlOptions,
optimizationOptions,
sourcemapOptions,
ssrOptions,
prerenderOptions,
appShellOptions,
workspaceRoot,
Expand All @@ -64,7 +63,7 @@ export async function executePostBundleSteps(
*
* NOTE: we don't perform critical CSS inlining as this will be done during server rendering.
*/
let indexContentOutputNoCssInlining: string | undefined;
let ssrIndexContent: string | undefined;

// When using prerender/app-shell the index HTML file can be regenerated.
// Thus, we use a Map so that we do not generate 2 files with the same filename.
Expand All @@ -73,43 +72,37 @@ export async function executePostBundleSteps(
// Generate index HTML file
// If localization is enabled, index generation is handled in the inlining process.
if (indexHtmlOptions) {
const { content, contentWithoutCriticalCssInlined, errors, warnings } = await generateIndexHtml(
const { csrContent, ssrContent, errors, warnings } = await generateIndexHtml(
initialFiles,
outputFiles,
{
...options,
optimizationOptions,
},
options,
locale,
);

indexContentOutputNoCssInlining = contentWithoutCriticalCssInlined;
allErrors.push(...errors);
allWarnings.push(...warnings);

additionalHtmlOutputFiles.set(
indexHtmlOptions.output,
createOutputFileFromText(indexHtmlOptions.output, content, BuildOutputFileType.Browser),
createOutputFileFromText(indexHtmlOptions.output, csrContent, BuildOutputFileType.Browser),
);

if (ssrOptions) {
if (ssrContent) {
const serverIndexHtmlFilename = 'index.server.html';
additionalHtmlOutputFiles.set(
serverIndexHtmlFilename,
createOutputFileFromText(
serverIndexHtmlFilename,
contentWithoutCriticalCssInlined,
BuildOutputFileType.Server,
),
createOutputFileFromText(serverIndexHtmlFilename, ssrContent, BuildOutputFileType.Server),
);

ssrIndexContent = ssrContent;
}
}

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

Expand All @@ -124,7 +117,7 @@ export async function executePostBundleSteps(
prerenderOptions,
outputFiles,
assetFiles,
indexContentOutputNoCssInlining,
ssrIndexContent,
sourcemapOptions.scripts,
optimizationOptions.styles.inlineCritical,
maxWorkers,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,11 @@ export function buildWebpackBrowser(
let hasErrors = false;
for (const [locale, outputPath] of outputPaths.entries()) {
try {
const { content, warnings, errors } = await indexHtmlGenerator.process({
const {
csrContent: content,
warnings,
errors,
} = await indexHtmlGenerator.process({
baseHref: getLocaleBaseHref(i18n, locale) ?? options.baseHref,
// i18nLocale is used when Ivy is disabled
lang: locale || undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export async function generateIndexHtml(
buildOptions: NormalizedApplicationBuildOptions,
lang?: string,
): Promise<{
content: string;
contentWithoutCriticalCssInlined: string;
csrContent: string;
ssrContent?: string;
warnings: string[];
errors: string[];
}> {
Expand Down Expand Up @@ -74,21 +74,20 @@ export async function generateIndexHtml(
indexPath: indexHtmlOptions.input,
entrypoints: indexHtmlOptions.insertionOrder,
sri: subresourceIntegrity,
optimization: {
...optimizationOptions,
styles: {
...optimizationOptions.styles,
inlineCritical: false, // Disable critical css inline as for SSR and SSG this will be done during rendering.
},
},
optimization: optimizationOptions,
crossOrigin: crossOrigin,
deployUrl: buildOptions.publicPath,
postTransform: indexHtmlOptions.transformer,
generateDedicatedSSRContent: !!(
buildOptions.ssrOptions ||
buildOptions.prerenderOptions ||
buildOptions.appShellOptions
),
});

indexHtmlGenerator.readAsset = readAsset;

const transformResult = await indexHtmlGenerator.process({
return indexHtmlGenerator.process({
baseHref,
lang,
outputPath: virtualOutputPath,
Expand All @@ -101,34 +100,4 @@ export async function generateIndexHtml(
})),
hints,
});

const contentWithoutCriticalCssInlined = transformResult.content;
if (!optimizationOptions.styles.inlineCritical) {
return {
...transformResult,
contentWithoutCriticalCssInlined,
};
}

const { InlineCriticalCssProcessor } = await import('../../utils/index-file/inline-critical-css');

const inlineCriticalCssProcessor = new InlineCriticalCssProcessor({
minify: false, // CSS has already been minified during the build.
deployUrl: buildOptions.publicPath,
readAsset,
});

const { content, errors, warnings } = await inlineCriticalCssProcessor.process(
contentWithoutCriticalCssInlined,
{
outputPath: virtualOutputPath,
},
);

return {
errors: [...transformResult.errors, ...errors],
warnings: [...transformResult.warnings, ...warnings],
content,
contentWithoutCriticalCssInlined,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ export class IndexHtmlWebpackPlugin extends IndexHtmlGenerator {
}
}

const { content, warnings, errors } = await this.process({
const {
csrContent: content,
warnings,
errors,
} = await this.process({
files,
outputPath: dirname(this.options.outputPath),
baseHref: this.options.baseHref,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* @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
*/

import { readFile } from 'node:fs/promises';
import { htmlRewritingStream } from './html-rewriting-stream';

let jsActionContractScript: string;

export async function addEventDispatchContract(html: string): Promise<string> {
const { rewriter, transformedContent } = await htmlRewritingStream(html);

jsActionContractScript ??=
'<script type="text/javascript" id="ng-event-dispatch-contract">' +
(await readFile(require.resolve('@angular/core/event-dispatch-contract.min.js'), 'utf-8')) +

This comment has been minimized.

Copy link
@Platonn

Platonn May 10, 2024

Contributor

Hi @alan-agius4
Will this readFile happen on every page render in SSR?
If yes, won't it affect performance negatively? (then perhaps we might want to put the file contents into some in-memory cache to reuse in between renders)

This comment has been minimized.

Copy link
@alan-agius4

alan-agius4 May 10, 2024

Author Collaborator

No, this only happens one time during the build.

'</script>';

rewriter.on('startTag', (tag) => {
rewriter.emitStartTag(tag);

if (tag.tagName === 'body') {
rewriter.emitRaw(jsActionContractScript);
}
});

return transformedContent();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @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
*/

import { addEventDispatchContract } from './add-event-dispatch-contract';

describe('addEventDispatchContract', () => {
it('should inline event dispatcher script', async () => {
const result = await addEventDispatchContract(`
<html>
<head></head>
<body>
<h1>Hello World!</h1>
</body>
</html>
`);

expect(result).toMatch(
/<body>\s*<script type="text\/javascript" id="ng-event-dispatch-contract">.+<\/script>\s*<h1>/,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export async function augmentIndexHtml(
foundPreconnects.add(href);
}
}
break;
}

rewriter.emitStartTag(tag);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { NormalizedCachedOptions } from '../normalize-cache';
import { NormalizedOptimizationOptions } from '../normalize-optimization';
import { addEventDispatchContract } from './add-event-dispatch-contract';
import { CrossOriginValue, Entrypoint, FileInfo, augmentIndexHtml } from './augment-index-html';
import { InlineCriticalCssProcessor } from './inline-critical-css';
import { InlineFontsProcessor } from './inline-fonts';
import { addStyleNonce } from './style-nonce';
import { addNonce } from './nonce';

type IndexHtmlGeneratorPlugin = (
html: string,
options: IndexHtmlGeneratorProcessOptions,
) => Promise<string | IndexHtmlTransformResult>;
) => Promise<string | IndexHtmlPluginTransformResult> | string;

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

Expand All @@ -40,45 +41,78 @@ export interface IndexHtmlGeneratorOptions {
optimization?: NormalizedOptimizationOptions;
cache?: NormalizedCachedOptions;
imageDomains?: string[];
generateDedicatedSSRContent?: boolean;
}

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

export interface IndexHtmlTransformResult {
export interface IndexHtmlPluginTransformResult {
content: string;
warnings: string[];
errors: string[];
}

export interface IndexHtmlProcessResult {
csrContent: string;
ssrContent?: string;
warnings: string[];
errors: string[];
}

export class IndexHtmlGenerator {
private readonly plugins: IndexHtmlGeneratorPlugin[];
private readonly csrPlugins: IndexHtmlGeneratorPlugin[] = [];
private readonly ssrPlugins: IndexHtmlGeneratorPlugin[] = [];

constructor(readonly options: IndexHtmlGeneratorOptions) {
const extraPlugins: IndexHtmlGeneratorPlugin[] = [];
if (this.options.optimization?.fonts.inline) {
extraPlugins.push(inlineFontsPlugin(this));
const extraCommonPlugins: IndexHtmlGeneratorPlugin[] = [];
if (options?.optimization?.fonts.inline) {
extraCommonPlugins.push(inlineFontsPlugin(this), addNonce);
}

if (this.options.optimization?.styles.inlineCritical) {
extraPlugins.push(inlineCriticalCssPlugin(this));
// Common plugins
this.plugins = [augmentIndexHtmlPlugin(this), ...extraCommonPlugins, postTransformPlugin(this)];

// CSR plugins
if (options?.optimization?.styles?.inlineCritical) {
this.csrPlugins.push(inlineCriticalCssPlugin(this));
}

this.plugins = [
augmentIndexHtmlPlugin(this),
...extraPlugins,
// Runs after the `extraPlugins` to capture any nonce or
// `style` tags that might've been added by them.
addStyleNoncePlugin(),
postTransformPlugin(this),
];
// SSR plugins
if (options.generateDedicatedSSRContent) {
this.ssrPlugins.push(addEventDispatchContractPlugin(), addNoncePlugin());
}
}

async process(options: IndexHtmlGeneratorProcessOptions): Promise<IndexHtmlTransformResult> {
async process(options: IndexHtmlGeneratorProcessOptions): Promise<IndexHtmlProcessResult> {
let content = await this.readIndex(this.options.indexPath);
const warnings: string[] = [];
const errors: string[] = [];

for (const plugin of this.plugins) {
content = await this.runPlugins(content, this.plugins, options, warnings, errors);
const [csrContent, ssrContent] = await Promise.all([
this.runPlugins(content, this.csrPlugins, options, warnings, errors),
this.ssrPlugins.length
? this.runPlugins(content, this.ssrPlugins, options, warnings, errors)
: undefined,
]);

return {
ssrContent,
csrContent,
warnings,
errors,
};
}

private async runPlugins(
content: string,
plugins: IndexHtmlGeneratorPlugin[],
options: IndexHtmlGeneratorProcessOptions,
warnings: string[],
errors: string[],
): Promise<string> {
for (const plugin of plugins) {
const result = await plugin(content, options);
if (typeof result === 'string') {
content = result;
Expand All @@ -95,11 +129,7 @@ export class IndexHtmlGenerator {
}
}

return {
content,
warnings,
errors,
};
return content;
}

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

function addStyleNoncePlugin(): IndexHtmlGeneratorPlugin {
return (html) => addStyleNonce(html);
function addNoncePlugin(): IndexHtmlGeneratorPlugin {
return (html) => addNonce(html);
}

function postTransformPlugin({ options }: IndexHtmlGenerator): IndexHtmlGeneratorPlugin {
return async (html) => (options.postTransform ? options.postTransform(html) : html);
}

function addEventDispatchContractPlugin(): IndexHtmlGeneratorPlugin {
return (html) => addEventDispatchContract(html);
}
Loading

0 comments on commit d51cb59

Please sign in to comment.