Skip to content

Commit

Permalink
feat(common): provide an ability to exclude origins from preconnect c…
Browse files Browse the repository at this point in the history
…hecks in NgOptimizedImage (#47082)

This commit adds an extra logic to add an ability to exclude origins from preconnect checks in NgOptimizedImage by configuring the `PRECONNECT_CHECK_BLOCKLIST` multi-provider.

PR Close #47082
  • Loading branch information
AndrewKushnir authored and Pawel Kozlowski committed Aug 16, 2022
1 parent 7baf9a4 commit 586274f
Show file tree
Hide file tree
Showing 14 changed files with 260 additions and 42 deletions.
4 changes: 4 additions & 0 deletions goldens/public-api/common/errors.md
Expand Up @@ -11,6 +11,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
INVALID_PIPE_ARGUMENT = 2100,
// (undocumented)
INVALID_PRECONNECT_CHECK_BLOCKLIST = 2957,
// (undocumented)
LCP_IMG_MISSING_PRIORITY = 2955,
// (undocumented)
NG_FOR_MISSING_DIFFER = -2200,
Expand All @@ -21,6 +23,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
REQUIRED_INPUT_MISSING = 2954,
// (undocumented)
UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
// (undocumented)
UNEXPECTED_INPUT_CHANGE = 2953,
// (undocumented)
UNEXPECTED_SRC_ATTR = 2950,
Expand Down
25 changes: 25 additions & 0 deletions packages/common/src/directives/ng_optimized_image/asserts.ts
@@ -0,0 +1,25 @@
/**
* @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 {ɵRuntimeError as RuntimeError} from '@angular/core';

import {RuntimeErrorCode} from '../../errors';

/**
* Asserts whether an ngDevMode is enabled and throws an error if it's not the case.
* This assert can be used to make sure that there is no dev-mode code invoked in
* the prod mode accidentally.
*/
export function assertDevMode(check: string) {
if (!ngDevMode) {
throw new RuntimeError(
RuntimeErrorCode.UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE,
`Unexpected invocation of the ${check} in the prod mode. ` +
`Please make sure that the prod mode is enabled for production builds.`);
}
}
1 change: 1 addition & 0 deletions packages/common/src/directives/ng_optimized_image/index.ts
Expand Up @@ -8,3 +8,4 @@
export {IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from './image_loaders/image_loader';
export {provideImgixLoader} from './image_loaders/imgix_loader';
export {NgOptimizedImage, NgOptimizedImageModule} from './ng_optimized_image';
export {PRECONNECT_CHECK_BLOCKLIST} from './preconnect_link_checker';
Expand Up @@ -11,6 +11,7 @@ import {Directive, ElementRef, Inject, Injectable, Injector, Input, NgModule, Ng
import {DOCUMENT} from '../../dom_tokens';
import {RuntimeErrorCode} from '../../errors';

import {assertDevMode} from './asserts';
import {IMAGE_LOADER, ImageLoader} from './image_loaders/image_loader';
import {PreconnectLinkChecker} from './preconnect_link_checker';
import {getUrl, imgDirectiveDetails} from './util';
Expand Down Expand Up @@ -57,6 +58,7 @@ export class LCPImageObserver implements OnDestroy {
private observer: PerformanceObserver|null = null;

constructor(@Inject(DOCUMENT) doc: Document) {
assertDevMode('LCP checker');
const win = doc.defaultView;
if (typeof win !== 'undefined' && typeof PerformanceObserver !== 'undefined') {
this.window = win;
Expand Down
Expand Up @@ -6,12 +6,35 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Inject, Injectable, NgZone, ɵformatRuntimeError as formatRuntimeError} from '@angular/core';
import {Inject, Injectable, InjectionToken, Optional, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';

import {DOCUMENT} from '../../dom_tokens';
import {RuntimeErrorCode} from '../../errors';

import {getUrl, imgDirectiveDetails} from './util';
import {assertDevMode} from './asserts';
import {deepForEach, extractHostname, getUrl, imgDirectiveDetails} from './util';

// Set of origins that are always excluded from the preconnect checks.
const INTERNAL_PRECONNECT_CHECK_BLOCKLIST = new Set(['localhost', '127.0.0.1', '0.0.0.0']);

/**
* Multi-provider injection token to configure which origins should be excluded
* from the preconnect checks. If can either be a single string or an array of strings
* to represent a group of origins, for example:
*
* ```typescript
* {provide: PRECONNECT_CHECK_BLOCKLIST, multi: true, useValue: 'https://your-domain.com'}
* ```
*
* or:
*
* ```typescript
* {provide: PRECONNECT_CHECK_BLOCKLIST, multi: true,
* useValue: ['https://your-domain-1.com', 'https://your-domain-2.com']}
* ```
*/
export const PRECONNECT_CHECK_BLOCKLIST =
new InjectionToken<Array<string|string[]>>('PRECONNECT_CHECK_BLOCKLIST');

/**
* Contains the logic to detect whether an image, marked with the "priority" attribute
Expand All @@ -31,18 +54,39 @@ export class PreconnectLinkChecker {

private window: Window|null = null;

constructor(@Inject(DOCUMENT) private doc: Document, private ngZone: NgZone) {
private blocklist = new Set<string>(INTERNAL_PRECONNECT_CHECK_BLOCKLIST);

constructor(
@Inject(DOCUMENT) private doc: Document,
@Optional() @Inject(PRECONNECT_CHECK_BLOCKLIST) blocklist: Array<string|string[]>|null) {
assertDevMode('preconnect link checker');
const win = doc.defaultView;
if (typeof win !== 'undefined') {
this.window = win;
}
if (blocklist) {
this.pupulateBlocklist(blocklist);
}
}

private pupulateBlocklist(origins: Array<string|string[]>) {
if (Array.isArray(origins)) {
deepForEach(origins, origin => {
this.blocklist.add(extractHostname(origin));
});
} else {
throw new RuntimeError(
RuntimeErrorCode.INVALID_PRECONNECT_CHECK_BLOCKLIST,
`The blocklist for the preconnect check was not provided as an array. ` +
`Check that the \`PRECONNECT_CHECK_BLOCKLIST\` token is configured as a \`multi: true\` provider.`);
}
}

check(rewrittenSrc: string, rawSrc: string) {
if (!this.window) return;

const imgUrl = getUrl(rewrittenSrc, this.window);
if (this.alreadySeen.has(imgUrl.origin)) return;
if (this.blocklist.has(imgUrl.hostname) || this.alreadySeen.has(imgUrl.origin)) return;

// Register this origin as seen, so we don't check it again later.
this.alreadySeen.add(imgUrl.origin);
Expand Down
24 changes: 22 additions & 2 deletions packages/common/src/directives/ng_optimized_image/util.ts
Expand Up @@ -8,13 +8,33 @@

// Converts a string that represents a URL into a URL class instance.
export function getUrl(src: string, win: Window): URL {
const isAbsolute = /^https?:\/\//.test(src);
// Don't use a base URL is the URL is absolute.
return isAbsolute ? new URL(src) : new URL(src, win.location.href);
return isAbsoluteURL(src) ? new URL(src) : new URL(src, win.location.href);
}

// Checks whether a URL is absolute (i.e. starts with `http://` or `https://`).
export function isAbsoluteURL(src: string): boolean {
return /^https?:\/\//.test(src);
}

// Assembles directive details string, useful for error messages.
export function imgDirectiveDetails(rawSrc: string) {
return `The NgOptimizedImage directive (activated on an <img> element ` +
`with the \`rawSrc="${rawSrc}"\`)`;
}

// Invokes a callback for each element in the array. Also invokes a callback
// recursively for each nested array.
export function deepForEach<T>(input: (T|any[])[], fn: (value: T) => void): void {
input.forEach(value => Array.isArray(value) ? deepForEach(value, fn) : fn(value));
}

// Given a URL, extract the hostname part.
// If a URL is a relative one - the URL is returned as is.
export function extractHostname(url: string): string {
if (isAbsoluteURL(url)) {
const instance = new URL(url);
return instance.hostname;
}
return url;
}
2 changes: 2 additions & 0 deletions packages/common/src/errors.ts
Expand Up @@ -28,4 +28,6 @@ export const enum RuntimeErrorCode {
REQUIRED_INPUT_MISSING = 2954,
LCP_IMG_MISSING_PRIORITY = 2955,
PRIORITY_IMG_MISSING_PRECONNECT_TAG = 2956,
INVALID_PRECONNECT_CHECK_BLOCKLIST = 2957,
UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
}
2 changes: 1 addition & 1 deletion packages/common/src/private_export.ts
Expand Up @@ -6,6 +6,6 @@
* found in the LICENSE file at https://angular.io/license
*/

export {IMAGE_LOADER as ɵIMAGE_LOADER, ImageLoader as ɵImageLoader, ImageLoaderConfig as ɵImageLoaderConfig, NgOptimizedImage as ɵNgOptimizedImage, NgOptimizedImageModule as ɵNgOptimizedImageModule, provideImgixLoader as ɵprovideImgixLoader} from './directives/ng_optimized_image';
export {IMAGE_LOADER as ɵIMAGE_LOADER, ImageLoader as ɵImageLoader, ImageLoaderConfig as ɵImageLoaderConfig, NgOptimizedImage as ɵNgOptimizedImage, NgOptimizedImageModule as ɵNgOptimizedImageModule, PRECONNECT_CHECK_BLOCKLIST as ɵPRECONNECT_CHECK_BLOCKLIST, provideImgixLoader as ɵprovideImgixLoader} from './directives/ng_optimized_image/index';
export {DomAdapter as ɵDomAdapter, getDOM as ɵgetDOM, setRootDomAdapter as ɵsetRootDomAdapter} from './dom_adapter';
export {BrowserPlatformLocation as ɵBrowserPlatformLocation} from './location/platform_location';

0 comments on commit 586274f

Please sign in to comment.