Skip to content

Commit

Permalink
feat(@ngtools/webpack): add automated preconnects for image domains
Browse files Browse the repository at this point in the history
use TypeScript AST to find image domains and add preconnects to generated index.html
  • Loading branch information
atcastle authored and alan-agius4 committed Sep 11, 2023
1 parent 2204334 commit f437545
Show file tree
Hide file tree
Showing 10 changed files with 437 additions and 7 deletions.
Expand Up @@ -8,6 +8,7 @@

import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { EmittedFiles, WebpackLoggingCallback, runWebpack } from '@angular-devkit/build-webpack';
import { imageDomains } from '@ngtools/webpack';
import * as fs from 'fs';
import * as path from 'path';
import { Observable, concatMap, from, map, switchMap } from 'rxjs';
Expand Down Expand Up @@ -310,6 +311,7 @@ export function buildWebpackBrowser(
optimization: normalizedOptimization,
crossOrigin: options.crossOrigin,
postTransform: transforms.indexHtml,
imageDomains: Array.from(imageDomains),
});

let hasErrors = false;
Expand Down Expand Up @@ -412,7 +414,7 @@ export function buildWebpackBrowser(
path: baseOutputPath,
baseHref: options.baseHref,
},
} as BrowserBuilderOutput),
}) as BrowserBuilderOutput,
),
);
},
Expand Down
Expand Up @@ -40,6 +40,7 @@ export interface AugmentIndexHtmlOptions {
/** Used to set the document default locale */
lang?: string;
hints?: { url: string; mode: string; as?: string }[];
imageDomains?: string[];
}

export interface FileInfo {
Expand All @@ -53,10 +54,21 @@ export interface FileInfo {
* after processing several configurations in order to build different sets of
* bundles for differential serving.
*/
// eslint-disable-next-line max-lines-per-function
export async function augmentIndexHtml(
params: AugmentIndexHtmlOptions,
): Promise<{ content: string; warnings: string[]; errors: string[] }> {
const { loadOutputFile, files, entrypoints, sri, deployUrl = '', lang, baseHref, html } = params;
const {
loadOutputFile,
files,
entrypoints,
sri,
deployUrl = '',
lang,
baseHref,
html,
imageDomains,
} = params;

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

rewriter
.on('startTag', (tag) => {
Expand Down Expand Up @@ -204,6 +217,13 @@ export async function augmentIndexHtml(
updateAttribute(tag, 'href', baseHref);
}
break;
case 'link':
if (readAttribute(tag, 'rel') === 'preconnect') {
const href = readAttribute(tag, 'href');
if (href) {
foundPreconnects.add(href);
}
}
}

rewriter.emitStartTag(tag);
Expand All @@ -214,7 +234,13 @@ export async function augmentIndexHtml(
for (const linkTag of linkTags) {
rewriter.emitRaw(linkTag);
}

if (imageDomains) {
for (const imageDomain of imageDomains) {
if (!foundPreconnects.has(imageDomain)) {
rewriter.emitRaw(`<link rel="preconnect" href="${imageDomain}" data-ngimg>`);
}
}
}
linkTags = [];
break;
case 'body':
Expand Down Expand Up @@ -265,6 +291,15 @@ function updateAttribute(
}
}

function readAttribute(
tag: { attrs: { name: string; value: string }[] },
name: string,
): string | undefined {
const targetAttr = tag.attrs.find((attr) => attr.name === name);

return targetAttr ? targetAttr.value : undefined;
}

function isString(value: unknown): value is string {
return typeof value === 'string';
}
Expand Down
Expand Up @@ -419,4 +419,85 @@ describe('augment-index-html', () => {
'`.mjs` files *must* set `isModule` to `true`.',
);
});

it('should add image domain preload tags', async () => {
const imageDomains = ['https://www.example.com', 'https://www.example2.com'];
const { content, warnings } = await augmentIndexHtml({
...indexGeneratorOptions,
imageDomains,
});

expect(content).toEqual(oneLineHtml`
<html>
<head>
<base href="/">
<link rel="preconnect" href="https://www.example.com" data-ngimg>
<link rel="preconnect" href="https://www.example2.com" data-ngimg>
</head>
<body>
</body>
</html>
`);
});

it('should add no image preconnects if provided empty domain list', async () => {
const imageDomains: Array<string> = [];
const { content, warnings } = await augmentIndexHtml({
...indexGeneratorOptions,
imageDomains,
});

expect(content).toEqual(oneLineHtml`
<html>
<head>
<base href="/">
</head>
<body>
</body>
</html>
`);
});

it('should not add duplicate preconnects', async () => {
const imageDomains = ['https://www.example1.com', 'https://www.example2.com'];
const { content, warnings } = await augmentIndexHtml({
...indexGeneratorOptions,
html: '<html><head><link rel="preconnect" href="https://www.example1.com"></head><body></body></html>',
imageDomains,
});

expect(content).toEqual(oneLineHtml`
<html>
<head>
<base href="/">
<link rel="preconnect" href="https://www.example1.com">
<link rel="preconnect" href="https://www.example2.com" data-ngimg>
</head>
<body>
</body>
</html>
`);
});

it('should add image preconnects if it encounters preconnect elements for other resources', async () => {
const imageDomains = ['https://www.example2.com', 'https://www.example3.com'];
const { content, warnings } = await augmentIndexHtml({
...indexGeneratorOptions,
html: '<html><head><link rel="preconnect" href="https://www.example1.com"></head><body></body></html>',
imageDomains,
});

expect(content).toEqual(oneLineHtml`
<html>
<head>
<base href="/">
<link rel="preconnect" href="https://www.example1.com">
<link rel="preconnect" href="https://www.example2.com" data-ngimg>
<link rel="preconnect" href="https://www.example3.com" data-ngimg>
</head>
<body>
</body>
</html>
`);
});
});
Expand Up @@ -40,6 +40,7 @@ export interface IndexHtmlGeneratorOptions {
crossOrigin?: CrossOriginValue;
optimization?: NormalizedOptimizationOptions;
cache?: NormalizedCachedOptions;
imageDomains?: string[];
}

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

function augmentIndexHtmlPlugin(generator: IndexHtmlGenerator): IndexHtmlGeneratorPlugin {
const { deployUrl, crossOrigin, sri = false, entrypoints } = generator.options;
const { deployUrl, crossOrigin, sri = false, entrypoints, imageDomains } = generator.options;

return async (html, options) => {
const { lang, baseHref, outputPath = '', files, hints } = options;
Expand All @@ -126,6 +127,7 @@ function augmentIndexHtmlPlugin(generator: IndexHtmlGenerator): IndexHtmlGenerat
lang,
entrypoints,
loadOutputFile: (filePath) => generator.readAsset(join(outputPath, filePath)),
imageDomains,
files,
hints,
});
Expand Down
1 change: 1 addition & 0 deletions packages/ngtools/webpack/src/index.ts
Expand Up @@ -10,5 +10,6 @@ export {
AngularWebpackLoaderPath,
AngularWebpackPlugin,
AngularWebpackPluginOptions,
imageDomains,
default,
} from './ivy';
2 changes: 1 addition & 1 deletion packages/ngtools/webpack/src/ivy/index.ts
Expand Up @@ -7,6 +7,6 @@
*/

export { angularWebpackLoader as default } from './loader';
export { AngularWebpackPluginOptions, AngularWebpackPlugin } from './plugin';
export { AngularWebpackPluginOptions, AngularWebpackPlugin, imageDomains } from './plugin';

export const AngularWebpackLoaderPath = __filename;
4 changes: 3 additions & 1 deletion packages/ngtools/webpack/src/ivy/plugin.ts
Expand Up @@ -39,6 +39,8 @@ import { createAotTransformers, createJitTransformers, mergeTransformers } from
*/
const DIAGNOSTICS_AFFECTED_THRESHOLD = 1;

export const imageDomains = new Set<string>();

export interface AngularWebpackPluginOptions {
tsconfig: string;
compilerOptions?: CompilerOptions;
Expand Down Expand Up @@ -502,7 +504,7 @@ export class AngularWebpackPlugin {
}
}

const transformers = createAotTransformers(builder, this.pluginOptions);
const transformers = createAotTransformers(builder, this.pluginOptions, imageDomains);

const getDependencies = (sourceFile: ts.SourceFile) => {
const dependencies = [];
Expand Down
4 changes: 3 additions & 1 deletion packages/ngtools/webpack/src/ivy/transformation.ts
Expand Up @@ -8,16 +8,18 @@

import * as ts from 'typescript';
import { elideImports } from '../transformers/elide_imports';
import { findImageDomains } from '../transformers/find_image_domains';
import { removeIvyJitSupportCalls } from '../transformers/remove-ivy-jit-support-calls';
import { replaceResources } from '../transformers/replace_resources';

export function createAotTransformers(
builder: ts.BuilderProgram,
options: { emitClassMetadata?: boolean; emitNgModuleScope?: boolean },
imageDomains: Set<string>,
): ts.CustomTransformers {
const getTypeChecker = () => builder.getProgram().getTypeChecker();
const transformers: ts.CustomTransformers = {
before: [replaceBootstrap(getTypeChecker)],
before: [findImageDomains(imageDomains), replaceBootstrap(getTypeChecker)],
after: [],
};

Expand Down
126 changes: 126 additions & 0 deletions packages/ngtools/webpack/src/transformers/find_image_domains.ts
@@ -0,0 +1,126 @@
/**
* @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 * as ts from 'typescript';

const TARGET_TEXT = '@NgModule';
const BUILTIN_LOADERS = new Set([
'provideCloudflareLoader',
'provideCloudinaryLoader',
'provideImageKitLoader',
'provideImgixLoader',
]);
const URL_REGEX = /(https?:\/\/[^/]*)\//g;

export function findImageDomains(imageDomains: Set<string>): ts.TransformerFactory<ts.SourceFile> {
return (context: ts.TransformationContext) => {
return (sourceFile: ts.SourceFile) => {
const isBuiltinImageLoader = (node: ts.CallExpression): Boolean => {
return BUILTIN_LOADERS.has(node.expression.getText());
};

const findDomainString = (node: ts.Node) => {
if (
ts.isStringLiteral(node) ||
ts.isTemplateHead(node) ||
ts.isTemplateMiddle(node) ||
ts.isTemplateTail(node)
) {
const domain = node.text.match(URL_REGEX);
if (domain && domain[0]) {
imageDomains.add(domain[0]);

return node;
}
}
ts.visitEachChild(node, findDomainString, context);

return node;
};

function isImageProviderKey(property: ts.ObjectLiteralElementLike): boolean {
return (
ts.isPropertyAssignment(property) &&
property.name.getText() === 'provide' &&
property.initializer.getText() === 'IMAGE_LOADER'
);
}

function isImageProviderValue(property: ts.ObjectLiteralElementLike): boolean {
return ts.isPropertyAssignment(property) && property.name.getText() === 'useValue';
}

function checkForDomain(node: ts.ObjectLiteralExpression) {
if (node.properties.find(isImageProviderKey)) {
const value = node.properties.find(isImageProviderValue);
if (value && ts.isPropertyAssignment(value)) {
if (
ts.isArrowFunction(value.initializer) ||
ts.isFunctionExpression(value.initializer)
) {
ts.visitEachChild(node, findDomainString, context);
}
}
}
}

function findImageLoaders(node: ts.Node) {
if (ts.isCallExpression(node)) {
if (isBuiltinImageLoader(node)) {
const firstArg = node.arguments[0];
if (ts.isStringLiteralLike(firstArg)) {
imageDomains.add(firstArg.text);
}
}
} else if (ts.isObjectLiteralExpression(node)) {
checkForDomain(node);
}

return node;
}

function findPropertyAssignment(node: ts.Node) {
if (ts.isPropertyAssignment(node)) {
if (ts.isIdentifier(node.name) && node.name.escapedText === 'providers') {
ts.visitEachChild(node.initializer, findImageLoaders, context);
}
}

return node;
}

function findPropertyDeclaration(node: ts.Node) {
if (
ts.isPropertyDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.escapedText === 'ɵinj' &&
node.initializer &&
ts.isCallExpression(node.initializer) &&
node.initializer.arguments[0]
) {
ts.visitEachChild(node.initializer.arguments[0], findPropertyAssignment, context);
}

return node;
}

// Continue traversal if node is ClassDeclaration and has name "AppModule"
function findClassDeclaration(node: ts.Node) {
if (ts.isClassDeclaration(node)) {
ts.visitEachChild(node, findPropertyDeclaration, context);
}

return node;
}

ts.visitEachChild(sourceFile, findClassDeclaration, context);

return sourceFile;
};
};
}

0 comments on commit f437545

Please sign in to comment.