Skip to content

Commit f437545

Browse files
atcastlealan-agius4
authored andcommitted
feat(@ngtools/webpack): add automated preconnects for image domains
use TypeScript AST to find image domains and add preconnects to generated index.html
1 parent 2204334 commit f437545

File tree

10 files changed

+437
-7
lines changed

10 files changed

+437
-7
lines changed

packages/angular_devkit/build_angular/src/builders/browser/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
1010
import { EmittedFiles, WebpackLoggingCallback, runWebpack } from '@angular-devkit/build-webpack';
11+
import { imageDomains } from '@ngtools/webpack';
1112
import * as fs from 'fs';
1213
import * as path from 'path';
1314
import { Observable, concatMap, from, map, switchMap } from 'rxjs';
@@ -310,6 +311,7 @@ export function buildWebpackBrowser(
310311
optimization: normalizedOptimization,
311312
crossOrigin: options.crossOrigin,
312313
postTransform: transforms.indexHtml,
314+
imageDomains: Array.from(imageDomains),
313315
});
314316

315317
let hasErrors = false;
@@ -412,7 +414,7 @@ export function buildWebpackBrowser(
412414
path: baseOutputPath,
413415
baseHref: options.baseHref,
414416
},
415-
} as BrowserBuilderOutput),
417+
}) as BrowserBuilderOutput,
416418
),
417419
);
418420
},

packages/angular_devkit/build_angular/src/utils/index-file/augment-index-html.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface AugmentIndexHtmlOptions {
4040
/** Used to set the document default locale */
4141
lang?: string;
4242
hints?: { url: string; mode: string; as?: string }[];
43+
imageDomains?: string[];
4344
}
4445

4546
export interface FileInfo {
@@ -53,10 +54,21 @@ export interface FileInfo {
5354
* after processing several configurations in order to build different sets of
5455
* bundles for differential serving.
5556
*/
57+
// eslint-disable-next-line max-lines-per-function
5658
export async function augmentIndexHtml(
5759
params: AugmentIndexHtmlOptions,
5860
): Promise<{ content: string; warnings: string[]; errors: string[] }> {
59-
const { loadOutputFile, files, entrypoints, sri, deployUrl = '', lang, baseHref, html } = params;
61+
const {
62+
loadOutputFile,
63+
files,
64+
entrypoints,
65+
sri,
66+
deployUrl = '',
67+
lang,
68+
baseHref,
69+
html,
70+
imageDomains,
71+
} = params;
6072

6173
const warnings: string[] = [];
6274
const errors: string[] = [];
@@ -175,6 +187,7 @@ export async function augmentIndexHtml(
175187
const dir = lang ? await getLanguageDirection(lang, warnings) : undefined;
176188
const { rewriter, transformedContent } = await htmlRewritingStream(html);
177189
const baseTagExists = html.includes('<base');
190+
const foundPreconnects = new Set<string>();
178191

179192
rewriter
180193
.on('startTag', (tag) => {
@@ -204,6 +217,13 @@ export async function augmentIndexHtml(
204217
updateAttribute(tag, 'href', baseHref);
205218
}
206219
break;
220+
case 'link':
221+
if (readAttribute(tag, 'rel') === 'preconnect') {
222+
const href = readAttribute(tag, 'href');
223+
if (href) {
224+
foundPreconnects.add(href);
225+
}
226+
}
207227
}
208228

209229
rewriter.emitStartTag(tag);
@@ -214,7 +234,13 @@ export async function augmentIndexHtml(
214234
for (const linkTag of linkTags) {
215235
rewriter.emitRaw(linkTag);
216236
}
217-
237+
if (imageDomains) {
238+
for (const imageDomain of imageDomains) {
239+
if (!foundPreconnects.has(imageDomain)) {
240+
rewriter.emitRaw(`<link rel="preconnect" href="${imageDomain}" data-ngimg>`);
241+
}
242+
}
243+
}
218244
linkTags = [];
219245
break;
220246
case 'body':
@@ -265,6 +291,15 @@ function updateAttribute(
265291
}
266292
}
267293

294+
function readAttribute(
295+
tag: { attrs: { name: string; value: string }[] },
296+
name: string,
297+
): string | undefined {
298+
const targetAttr = tag.attrs.find((attr) => attr.name === name);
299+
300+
return targetAttr ? targetAttr.value : undefined;
301+
}
302+
268303
function isString(value: unknown): value is string {
269304
return typeof value === 'string';
270305
}

packages/angular_devkit/build_angular/src/utils/index-file/augment-index-html_spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,4 +419,85 @@ describe('augment-index-html', () => {
419419
'`.mjs` files *must* set `isModule` to `true`.',
420420
);
421421
});
422+
423+
it('should add image domain preload tags', async () => {
424+
const imageDomains = ['https://www.example.com', 'https://www.example2.com'];
425+
const { content, warnings } = await augmentIndexHtml({
426+
...indexGeneratorOptions,
427+
imageDomains,
428+
});
429+
430+
expect(content).toEqual(oneLineHtml`
431+
<html>
432+
<head>
433+
<base href="/">
434+
<link rel="preconnect" href="https://www.example.com" data-ngimg>
435+
<link rel="preconnect" href="https://www.example2.com" data-ngimg>
436+
</head>
437+
<body>
438+
</body>
439+
</html>
440+
`);
441+
});
442+
443+
it('should add no image preconnects if provided empty domain list', async () => {
444+
const imageDomains: Array<string> = [];
445+
const { content, warnings } = await augmentIndexHtml({
446+
...indexGeneratorOptions,
447+
imageDomains,
448+
});
449+
450+
expect(content).toEqual(oneLineHtml`
451+
<html>
452+
<head>
453+
<base href="/">
454+
</head>
455+
<body>
456+
</body>
457+
</html>
458+
`);
459+
});
460+
461+
it('should not add duplicate preconnects', async () => {
462+
const imageDomains = ['https://www.example1.com', 'https://www.example2.com'];
463+
const { content, warnings } = await augmentIndexHtml({
464+
...indexGeneratorOptions,
465+
html: '<html><head><link rel="preconnect" href="https://www.example1.com"></head><body></body></html>',
466+
imageDomains,
467+
});
468+
469+
expect(content).toEqual(oneLineHtml`
470+
<html>
471+
<head>
472+
<base href="/">
473+
<link rel="preconnect" href="https://www.example1.com">
474+
<link rel="preconnect" href="https://www.example2.com" data-ngimg>
475+
</head>
476+
<body>
477+
</body>
478+
</html>
479+
`);
480+
});
481+
482+
it('should add image preconnects if it encounters preconnect elements for other resources', async () => {
483+
const imageDomains = ['https://www.example2.com', 'https://www.example3.com'];
484+
const { content, warnings } = await augmentIndexHtml({
485+
...indexGeneratorOptions,
486+
html: '<html><head><link rel="preconnect" href="https://www.example1.com"></head><body></body></html>',
487+
imageDomains,
488+
});
489+
490+
expect(content).toEqual(oneLineHtml`
491+
<html>
492+
<head>
493+
<base href="/">
494+
<link rel="preconnect" href="https://www.example1.com">
495+
<link rel="preconnect" href="https://www.example2.com" data-ngimg>
496+
<link rel="preconnect" href="https://www.example3.com" data-ngimg>
497+
</head>
498+
<body>
499+
</body>
500+
</html>
501+
`);
502+
});
422503
});

packages/angular_devkit/build_angular/src/utils/index-file/index-html-generator.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface IndexHtmlGeneratorOptions {
4040
crossOrigin?: CrossOriginValue;
4141
optimization?: NormalizedOptimizationOptions;
4242
cache?: NormalizedCachedOptions;
43+
imageDomains?: string[];
4344
}
4445

4546
export type IndexHtmlTransform = (content: string) => Promise<string>;
@@ -112,7 +113,7 @@ export class IndexHtmlGenerator {
112113
}
113114

114115
function augmentIndexHtmlPlugin(generator: IndexHtmlGenerator): IndexHtmlGeneratorPlugin {
115-
const { deployUrl, crossOrigin, sri = false, entrypoints } = generator.options;
116+
const { deployUrl, crossOrigin, sri = false, entrypoints, imageDomains } = generator.options;
116117

117118
return async (html, options) => {
118119
const { lang, baseHref, outputPath = '', files, hints } = options;
@@ -126,6 +127,7 @@ function augmentIndexHtmlPlugin(generator: IndexHtmlGenerator): IndexHtmlGenerat
126127
lang,
127128
entrypoints,
128129
loadOutputFile: (filePath) => generator.readAsset(join(outputPath, filePath)),
130+
imageDomains,
129131
files,
130132
hints,
131133
});

packages/ngtools/webpack/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ export {
1010
AngularWebpackLoaderPath,
1111
AngularWebpackPlugin,
1212
AngularWebpackPluginOptions,
13+
imageDomains,
1314
default,
1415
} from './ivy';

packages/ngtools/webpack/src/ivy/index.ts

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

99
export { angularWebpackLoader as default } from './loader';
10-
export { AngularWebpackPluginOptions, AngularWebpackPlugin } from './plugin';
10+
export { AngularWebpackPluginOptions, AngularWebpackPlugin, imageDomains } from './plugin';
1111

1212
export const AngularWebpackLoaderPath = __filename;

packages/ngtools/webpack/src/ivy/plugin.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import { createAotTransformers, createJitTransformers, mergeTransformers } from
3939
*/
4040
const DIAGNOSTICS_AFFECTED_THRESHOLD = 1;
4141

42+
export const imageDomains = new Set<string>();
43+
4244
export interface AngularWebpackPluginOptions {
4345
tsconfig: string;
4446
compilerOptions?: CompilerOptions;
@@ -502,7 +504,7 @@ export class AngularWebpackPlugin {
502504
}
503505
}
504506

505-
const transformers = createAotTransformers(builder, this.pluginOptions);
507+
const transformers = createAotTransformers(builder, this.pluginOptions, imageDomains);
506508

507509
const getDependencies = (sourceFile: ts.SourceFile) => {
508510
const dependencies = [];

packages/ngtools/webpack/src/ivy/transformation.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@
88

99
import * as ts from 'typescript';
1010
import { elideImports } from '../transformers/elide_imports';
11+
import { findImageDomains } from '../transformers/find_image_domains';
1112
import { removeIvyJitSupportCalls } from '../transformers/remove-ivy-jit-support-calls';
1213
import { replaceResources } from '../transformers/replace_resources';
1314

1415
export function createAotTransformers(
1516
builder: ts.BuilderProgram,
1617
options: { emitClassMetadata?: boolean; emitNgModuleScope?: boolean },
18+
imageDomains: Set<string>,
1719
): ts.CustomTransformers {
1820
const getTypeChecker = () => builder.getProgram().getTypeChecker();
1921
const transformers: ts.CustomTransformers = {
20-
before: [replaceBootstrap(getTypeChecker)],
22+
before: [findImageDomains(imageDomains), replaceBootstrap(getTypeChecker)],
2123
after: [],
2224
};
2325

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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 * as ts from 'typescript';
10+
11+
const TARGET_TEXT = '@NgModule';
12+
const BUILTIN_LOADERS = new Set([
13+
'provideCloudflareLoader',
14+
'provideCloudinaryLoader',
15+
'provideImageKitLoader',
16+
'provideImgixLoader',
17+
]);
18+
const URL_REGEX = /(https?:\/\/[^/]*)\//g;
19+
20+
export function findImageDomains(imageDomains: Set<string>): ts.TransformerFactory<ts.SourceFile> {
21+
return (context: ts.TransformationContext) => {
22+
return (sourceFile: ts.SourceFile) => {
23+
const isBuiltinImageLoader = (node: ts.CallExpression): Boolean => {
24+
return BUILTIN_LOADERS.has(node.expression.getText());
25+
};
26+
27+
const findDomainString = (node: ts.Node) => {
28+
if (
29+
ts.isStringLiteral(node) ||
30+
ts.isTemplateHead(node) ||
31+
ts.isTemplateMiddle(node) ||
32+
ts.isTemplateTail(node)
33+
) {
34+
const domain = node.text.match(URL_REGEX);
35+
if (domain && domain[0]) {
36+
imageDomains.add(domain[0]);
37+
38+
return node;
39+
}
40+
}
41+
ts.visitEachChild(node, findDomainString, context);
42+
43+
return node;
44+
};
45+
46+
function isImageProviderKey(property: ts.ObjectLiteralElementLike): boolean {
47+
return (
48+
ts.isPropertyAssignment(property) &&
49+
property.name.getText() === 'provide' &&
50+
property.initializer.getText() === 'IMAGE_LOADER'
51+
);
52+
}
53+
54+
function isImageProviderValue(property: ts.ObjectLiteralElementLike): boolean {
55+
return ts.isPropertyAssignment(property) && property.name.getText() === 'useValue';
56+
}
57+
58+
function checkForDomain(node: ts.ObjectLiteralExpression) {
59+
if (node.properties.find(isImageProviderKey)) {
60+
const value = node.properties.find(isImageProviderValue);
61+
if (value && ts.isPropertyAssignment(value)) {
62+
if (
63+
ts.isArrowFunction(value.initializer) ||
64+
ts.isFunctionExpression(value.initializer)
65+
) {
66+
ts.visitEachChild(node, findDomainString, context);
67+
}
68+
}
69+
}
70+
}
71+
72+
function findImageLoaders(node: ts.Node) {
73+
if (ts.isCallExpression(node)) {
74+
if (isBuiltinImageLoader(node)) {
75+
const firstArg = node.arguments[0];
76+
if (ts.isStringLiteralLike(firstArg)) {
77+
imageDomains.add(firstArg.text);
78+
}
79+
}
80+
} else if (ts.isObjectLiteralExpression(node)) {
81+
checkForDomain(node);
82+
}
83+
84+
return node;
85+
}
86+
87+
function findPropertyAssignment(node: ts.Node) {
88+
if (ts.isPropertyAssignment(node)) {
89+
if (ts.isIdentifier(node.name) && node.name.escapedText === 'providers') {
90+
ts.visitEachChild(node.initializer, findImageLoaders, context);
91+
}
92+
}
93+
94+
return node;
95+
}
96+
97+
function findPropertyDeclaration(node: ts.Node) {
98+
if (
99+
ts.isPropertyDeclaration(node) &&
100+
ts.isIdentifier(node.name) &&
101+
node.name.escapedText === 'ɵinj' &&
102+
node.initializer &&
103+
ts.isCallExpression(node.initializer) &&
104+
node.initializer.arguments[0]
105+
) {
106+
ts.visitEachChild(node.initializer.arguments[0], findPropertyAssignment, context);
107+
}
108+
109+
return node;
110+
}
111+
112+
// Continue traversal if node is ClassDeclaration and has name "AppModule"
113+
function findClassDeclaration(node: ts.Node) {
114+
if (ts.isClassDeclaration(node)) {
115+
ts.visitEachChild(node, findPropertyDeclaration, context);
116+
}
117+
118+
return node;
119+
}
120+
121+
ts.visitEachChild(sourceFile, findClassDeclaration, context);
122+
123+
return sourceFile;
124+
};
125+
};
126+
}

0 commit comments

Comments
 (0)