diff --git a/packages/angular/cli/lib/config/schema.json b/packages/angular/cli/lib/config/schema.json index 0381c91317ab..cf93bf4e4dad 100644 --- a/packages/angular/cli/lib/config/schema.json +++ b/packages/angular/cli/lib/config/schema.json @@ -890,6 +890,16 @@ "webWorkerTsConfig": { "type": "string", "description": "TypeScript configuration for Web Worker modules." + }, + "crossOrigin": { + "type": "string", + "description": "Define the crossorigin attribute setting of elements that provide CORS support.", + "default": "none", + "enum": [ + "none", + "anonymous", + "use-credentials" + ] } }, "additionalProperties": false, diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/index-html-webpack-plugin.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/index-html-webpack-plugin.ts index 6f91eab8cf21..7542d4178709 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/index-html-webpack-plugin.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/index-html-webpack-plugin.ts @@ -8,7 +8,11 @@ import * as path from 'path'; import { Compiler, compilation } from 'webpack'; import { RawSource } from 'webpack-sources'; -import { FileInfo, augmentIndexHtml } from '../utilities/index-file/augment-index-html'; +import { + CrossOriginValue, + FileInfo, + augmentIndexHtml, +} from '../utilities/index-file/augment-index-html'; import { IndexHtmlTransform } from '../utilities/index-file/write-index-html'; import { stripBom } from '../utilities/strip-bom'; @@ -22,6 +26,7 @@ export interface IndexHtmlWebpackPluginOptions { noModuleEntrypoints: string[]; moduleEntrypoints: string[]; postTransform?: IndexHtmlTransform; + crossOrigin?: CrossOriginValue; } function readFile(filename: string, compilation: compilation.Compilation): Promise { @@ -91,6 +96,7 @@ export class IndexHtmlWebpackPlugin { baseHref: this._options.baseHref, deployUrl: this._options.deployUrl, sri: this._options.sri, + crossOrigin: this._options.crossOrigin, files, noModuleFiles, loadOutputFile, diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/index-file/augment-index-html.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/index-file/augment-index-html.ts index 4abc7ba32bd0..3b42e21df687 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/index-file/augment-index-html.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/index-file/augment-index-html.ts @@ -11,9 +11,10 @@ import { RawSource, ReplaceSource } from 'webpack-sources'; const parse5 = require('parse5'); - export type LoadOutputFileFunctionType = (file: string) => Promise; +export type CrossOriginValue = 'none' | 'anonymous' | 'use-credentials'; + export interface AugmentIndexHtmlOptions { /* Input file name (e. g. index.html) */ input: string; @@ -22,6 +23,8 @@ export interface AugmentIndexHtmlOptions { baseHref?: string; deployUrl?: string; sri: boolean; + /** crossorigin attribute setting of elements that provide CORS support */ + crossOrigin?: CrossOriginValue; /* * Files emitted by the build. * Js files will be added without 'nomodule' nor 'module'. @@ -54,13 +57,12 @@ export interface FileInfo { * bundles for differential serving. */ export async function augmentIndexHtml(params: AugmentIndexHtmlOptions): Promise { - const { - loadOutputFile, - files, - noModuleFiles = [], - moduleFiles = [], - entrypoints, - } = params; + const { loadOutputFile, files, noModuleFiles = [], moduleFiles = [], entrypoints } = params; + + let { crossOrigin = 'none' } = params; + if (params.sri && crossOrigin === 'none') { + crossOrigin = 'anonymous'; + } const stylesheets = new Set(); const scripts = new Set(); @@ -69,7 +71,9 @@ export async function augmentIndexHtml(params: AugmentIndexHtmlOptions): Promise const mergedFiles = [...moduleFiles, ...noModuleFiles, ...files]; for (const entrypoint of entrypoints) { for (const { extension, file, name } of mergedFiles) { - if (name !== entrypoint) { continue; } + if (name !== entrypoint) { + continue; + } switch (extension) { case '.js': @@ -127,10 +131,14 @@ export async function augmentIndexHtml(params: AugmentIndexHtmlOptions): Promise let scriptElements = ''; for (const script of scripts) { - const attrs: { name: string, value: string | null }[] = [ + const attrs: { name: string; value: string | null }[] = [ { name: 'src', value: (params.deployUrl || '') + script }, ]; + if (crossOrigin !== 'none') { + attrs.push({ name: 'crossorigin', value: crossOrigin }); + } + // We want to include nomodule or module when a file is not common amongs all // such as runtime.js const scriptPredictor = ({ file }: FileInfo): boolean => file === script; @@ -154,15 +162,12 @@ export async function augmentIndexHtml(params: AugmentIndexHtmlOptions): Promise } const attributes = attrs - .map(attr => attr.value === null ? attr.name : `${attr.name}="${attr.value}"`) + .map(attr => (attr.value === null ? attr.name : `${attr.name}="${attr.value}"`)) .join(' '); scriptElements += ``; } - indexSource.insert( - scriptInsertionPoint, - scriptElements, - ); + indexSource.insert(scriptInsertionPoint, scriptElements); // Adjust base href if specified if (typeof params.baseHref == 'string') { @@ -176,13 +181,9 @@ export async function augmentIndexHtml(params: AugmentIndexHtmlOptions): Promise const baseFragment = treeAdapter.createDocumentFragment(); if (!baseElement) { - baseElement = treeAdapter.createElement( - 'base', - undefined, - [ - { name: 'href', value: params.baseHref }, - ], - ); + baseElement = treeAdapter.createElement('base', undefined, [ + { name: 'href', value: params.baseHref }, + ]); treeAdapter.appendChild(baseFragment, baseElement); indexSource.insert( @@ -218,6 +219,10 @@ export async function augmentIndexHtml(params: AugmentIndexHtmlOptions): Promise { name: 'href', value: (params.deployUrl || '') + stylesheet }, ]; + if (crossOrigin !== 'none') { + attrs.push({ name: 'crossorigin', value: crossOrigin }); + } + if (params.sri) { const content = await loadOutputFile(stylesheet); attrs.push(..._generateSriAttributes(content)); @@ -227,10 +232,7 @@ export async function augmentIndexHtml(params: AugmentIndexHtmlOptions): Promise treeAdapter.appendChild(styleElements, element); } - indexSource.insert( - styleInsertionPoint, - parse5.serialize(styleElements, { treeAdapter }), - ); + indexSource.insert(styleInsertionPoint, parse5.serialize(styleElements, { treeAdapter })); return indexSource.source(); } @@ -241,8 +243,5 @@ function _generateSriAttributes(content: string) { .update(content, 'utf8') .digest('base64'); - return [ - { name: 'integrity', value: `${algo}-${hash}` }, - { name: 'crossorigin', value: 'anonymous' }, - ]; + return [{ name: 'integrity', value: `${algo}-${hash}` }]; } diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/index-file/write-index-html.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/index-file/write-index-html.ts index f6a3e81bab6e..c5fb9e3fb701 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/index-file/write-index-html.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/index-file/write-index-html.ts @@ -13,7 +13,7 @@ import { map, switchMap } from 'rxjs/operators'; import { ExtraEntryPoint } from '../../../browser/schema'; import { generateEntryPoints } from '../package-chunk-sort'; import { stripBom } from '../strip-bom'; -import { FileInfo, augmentIndexHtml } from './augment-index-html'; +import { CrossOriginValue, FileInfo, augmentIndexHtml } from './augment-index-html'; type ExtensionFilter = '.js' | '.css'; @@ -30,6 +30,7 @@ export interface WriteIndexHtmlOptions { scripts?: ExtraEntryPoint[]; styles?: ExtraEntryPoint[]; postTransform?: IndexHtmlTransform; + crossOrigin?: CrossOriginValue; } export type IndexHtmlTransform = (content: string) => Promise; @@ -47,34 +48,34 @@ export function writeIndexHtml({ scripts = [], styles = [], postTransform, + crossOrigin, }: WriteIndexHtmlOptions): Observable { - - return host.read(indexPath) - .pipe( - map(content => stripBom(virtualFs.fileBufferToString(content))), - switchMap(content => augmentIndexHtml({ + return host.read(indexPath).pipe( + map(content => stripBom(virtualFs.fileBufferToString(content))), + switchMap(content => + augmentIndexHtml({ input: getSystemPath(outputPath), inputContent: content, baseHref, deployUrl, + crossOrigin, sri, entrypoints: generateEntryPoints({ scripts, styles }), files: filterAndMapBuildFiles(files, ['.js', '.css']), noModuleFiles: filterAndMapBuildFiles(noModuleFiles, '.js'), moduleFiles: filterAndMapBuildFiles(moduleFiles, '.js'), loadOutputFile: async filePath => { - return host.read(join(outputPath, filePath)) - .pipe( - map(data => virtualFs.fileBufferToString(data)), - ) + return host + .read(join(outputPath, filePath)) + .pipe(map(data => virtualFs.fileBufferToString(data))) .toPromise(); }, }), - ), - switchMap(content => postTransform ? postTransform(content) : of(content)), - map(content => virtualFs.stringToFileBuffer(content)), - switchMap(content => host.write(join(outputPath, basename(indexPath)), content)), - ); + ), + switchMap(content => (postTransform ? postTransform(content) : of(content))), + map(content => virtualFs.stringToFileBuffer(content)), + switchMap(content => host.write(join(outputPath, basename(indexPath)), content)), + ); } function filterAndMapBuildFiles( diff --git a/packages/angular_devkit/build_angular/src/browser/index.ts b/packages/angular_devkit/build_angular/src/browser/index.ts index fca271e2fafd..27a42bdbaeb9 100644 --- a/packages/angular_devkit/build_angular/src/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/browser/index.ts @@ -115,9 +115,8 @@ function getAnalyticsConfig( let category = 'build'; if (context.builder) { // We already vetted that this is a "safe" package, otherwise the analytics would be noop. - category = context.builder.builderName.split(':')[1] - || context.builder.builderName - || 'build'; + category = + context.builder.builderName.split(':')[1] || context.builder.builderName || 'build'; } // The category is the builder name if it's an angular builder. @@ -264,6 +263,7 @@ export function buildWebpackBrowser( scripts: options.scripts, styles: options.styles, postTransform: transforms.indexHtml, + crossOrigin: options.crossOrigin, }).pipe( map(() => ({ success: true })), catchError(error => of({ success: false, error: mapErrorToMessage(error) })), diff --git a/packages/angular_devkit/build_angular/src/browser/schema.json b/packages/angular_devkit/build_angular/src/browser/schema.json index fa4876246630..57122978fea1 100644 --- a/packages/angular_devkit/build_angular/src/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/browser/schema.json @@ -319,6 +319,16 @@ "webWorkerTsConfig": { "type": "string", "description": "TypeScript configuration for Web Worker modules." + }, + "crossOrigin": { + "type": "string", + "description": "Define the crossorigin attribute setting of elements that provide CORS support.", + "default": "none", + "enum": [ + "none", + "anonymous", + "use-credentials" + ] } }, "additionalProperties": false, diff --git a/packages/angular_devkit/build_angular/src/dev-server/index.ts b/packages/angular_devkit/build_angular/src/dev-server/index.ts index b7ac62ee4507..f9f39164e260 100644 --- a/packages/angular_devkit/build_angular/src/dev-server/index.ts +++ b/packages/angular_devkit/build_angular/src/dev-server/index.ts @@ -223,6 +223,7 @@ export function serveWebpackBrowser( sri: browserOptions.subresourceIntegrity, noModuleEntrypoints: ['polyfills-es5'], postTransform: transforms.indexHtml, + crossOrigin: browserOptions.crossOrigin, }), ); } diff --git a/packages/angular_devkit/build_angular/test/browser/cross-origin_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/cross-origin_spec_large.ts new file mode 100644 index 000000000000..01a03a460cb5 --- /dev/null +++ b/packages/angular_devkit/build_angular/test/browser/cross-origin_spec_large.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google Inc. 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 { Architect } from '@angular-devkit/architect'; +import { join, normalize, virtualFs } from '@angular-devkit/core'; +import { BrowserBuilderOutput } from '../../src/browser/index'; +import { CrossOrigin } from '../../src/browser/schema'; +import { createArchitect, host } from '../utils'; + +describe('Browser Builder crossOrigin', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + + host.writeMultipleFiles({ + 'src/index.html': Buffer.from( + '\ufeff', + 'utf8', + ), + }); + }); + + afterEach(async () => host.restore().toPromise()); + + it('works with use-credentials', async () => { + const overrides = { crossOrigin: CrossOrigin.UseCredentials }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = (await run.result) as BrowserBuilderOutput; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputPath), 'index.html'); + const content = virtualFs.fileBufferToString(await host.read(normalize(fileName)).toPromise()); + expect(content).toBe( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('works with anonymous', async () => { + const overrides = { crossOrigin: CrossOrigin.Anonymous }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = (await run.result) as BrowserBuilderOutput; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputPath), 'index.html'); + const content = virtualFs.fileBufferToString(await host.read(normalize(fileName)).toPromise()); + expect(content).toBe( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('works with none', async () => { + const overrides = { crossOrigin: CrossOrigin.None }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = (await run.result) as BrowserBuilderOutput; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputPath), 'index.html'); + const content = virtualFs.fileBufferToString(await host.read(normalize(fileName)).toPromise()); + expect(content).toBe( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); +});