From 0b7679de73005407dbce7b248daa27d628c96a7c Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Thu, 22 Aug 2024 18:38:07 +0000 Subject: [PATCH 1/4] refactor(@angular/ssr): bundle Critters This commit bundles the Critters library to ensure compatibility with Nodeless environments. Additionally, all licenses for bundled libraries, including Critters, are now included in the package. This helps maintain compliance with open-source license requirements. --- package.json | 1 + packages/angular/build/BUILD.bazel | 1 + packages/angular/build/src/typings.d.ts | 19 ++ packages/angular/ssr/BUILD.bazel | 11 +- packages/angular/ssr/package.json | 1 - packages/angular/ssr/src/app.ts | 42 ++- .../ssr/src/common-engine/common-engine.ts | 29 +- .../src/common-engine/inline-css-processor.ts | 260 +----------------- .../ssr/src/utils/inline-critical-css.ts | 214 ++++++++++++++ .../ssr/third_party/critters/BUILD.bazel | 55 ++++ .../third_party/critters/esbuild.config.mjs | 197 +++++++++++++ .../ssr/third_party/critters/index.d.ts | 9 + scripts/build.mts | 4 +- tools/defaults.bzl | 23 +- yarn.lock | 45 ++- 15 files changed, 619 insertions(+), 292 deletions(-) create mode 100644 packages/angular/build/src/typings.d.ts create mode 100644 packages/angular/ssr/src/utils/inline-critical-css.ts create mode 100644 packages/angular/ssr/third_party/critters/BUILD.bazel create mode 100644 packages/angular/ssr/third_party/critters/esbuild.config.mjs create mode 100644 packages/angular/ssr/third_party/critters/index.d.ts diff --git a/package.json b/package.json index b1623b0d07fd..d3aef362d9fe 100644 --- a/package.json +++ b/package.json @@ -199,6 +199,7 @@ "tslib": "2.6.3", "typescript": "5.5.4", "undici": "6.19.8", + "unenv": "^1.10.0", "verdaccio": "5.32.1", "verdaccio-auth-memory": "^10.0.0", "vite": "5.4.2", diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index 49481ff36e87..b60ab7d83299 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -79,6 +79,7 @@ ts_library( "@npm//browserslist", "@npm//critters", "@npm//esbuild", + "@npm//esbuild-wasm", "@npm//fast-glob", "@npm//https-proxy-agent", "@npm//listr2", diff --git a/packages/angular/build/src/typings.d.ts b/packages/angular/build/src/typings.d.ts new file mode 100644 index 000000000000..c784b3c4220a --- /dev/null +++ b/packages/angular/build/src/typings.d.ts @@ -0,0 +1,19 @@ +/** + * @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.dev/license + */ + +// The `bundled_critters` causes issues with module mappings in Bazel, +// leading to unexpected behavior with esbuild. Specifically, the problem occurs +// when esbuild resolves to a different module or version than expected, due to +// how Bazel handles module mappings. +// +// This change aims to resolve esbuild types correctly and maintain consistency +// in the Bazel build process. + +declare module 'esbuild' { + export * from 'esbuild-wasm'; +} diff --git a/packages/angular/ssr/BUILD.bazel b/packages/angular/ssr/BUILD.bazel index dbd1662aee0f..0d0dea781e32 100644 --- a/packages/angular/ssr/BUILD.bazel +++ b/packages/angular/ssr/BUILD.bazel @@ -18,12 +18,12 @@ ts_library( ), module_name = "@angular/ssr", deps = [ + "//packages/angular/ssr/third_party/critters:bundled_critters_lib", "@npm//@angular/common", "@npm//@angular/core", "@npm//@angular/platform-server", "@npm//@angular/router", "@npm//@types/node", - "@npm//critters", ], ) @@ -32,8 +32,15 @@ ng_package( package_name = "@angular/ssr", srcs = [ ":package.json", + "//packages/angular/ssr/third_party/critters:bundled_critters_lib", + ], + externals = [ + "express", + "../../third_party/critters", + ], + nested_packages = [ + "//packages/angular/ssr/schematics:npm_package", ], - nested_packages = ["//packages/angular/ssr/schematics:npm_package"], tags = ["release-package"], deps = [ ":ssr", diff --git a/packages/angular/ssr/package.json b/packages/angular/ssr/package.json index 608de72116e1..e449d89cb981 100644 --- a/packages/angular/ssr/package.json +++ b/packages/angular/ssr/package.json @@ -13,7 +13,6 @@ "save": "dependencies" }, "dependencies": { - "critters": "0.0.24", "tslib": "^2.3.0" }, "peerDependencies": { diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index f8bc12fa17f0..bc116876aef3 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -14,6 +14,7 @@ import { Hooks } from './hooks'; import { getAngularAppManifest } from './manifest'; import { ServerRouter } from './routes/router'; import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens'; +import { InlineCriticalCssProcessor } from './utils/inline-critical-css'; import { renderAngular } from './utils/ng'; /** @@ -52,6 +53,11 @@ export class AngularServerApp { */ private router: ServerRouter | undefined; + /** + * The `inlineCriticalCssProcessor` is responsible for handling critical CSS inlining. + */ + private inlineCriticalCssProcessor: InlineCriticalCssProcessor | undefined; + /** * Renders a response for the given HTTP request using the server application. * @@ -177,10 +183,38 @@ export class AngularServerApp { html = await hooks.run('html:transform:pre', { html }); } - return new Response( - await renderAngular(html, manifest.bootstrap(), new URL(request.url), platformProviders), - responseInit, - ); + html = await renderAngular(html, manifest.bootstrap(), new URL(request.url), platformProviders); + + if (manifest.inlineCriticalCss) { + // Optionally inline critical CSS. + const inlineCriticalCssProcessor = this.getOrCreateInlineCssProcessor(); + html = await inlineCriticalCssProcessor.process(html); + } + + return new Response(html, responseInit); + } + + /** + * Retrieves or creates the inline critical CSS processor. + * If one does not exist, it initializes a new instance. + * + * @returns The inline critical CSS processor instance. + */ + private getOrCreateInlineCssProcessor(): InlineCriticalCssProcessor { + let inlineCriticalCssProcessor = this.inlineCriticalCssProcessor; + + if (!inlineCriticalCssProcessor) { + inlineCriticalCssProcessor = new InlineCriticalCssProcessor(); + inlineCriticalCssProcessor.readFile = (path: string) => { + const fileName = path.split('/').pop() ?? path; + + return this.assets.getServerAsset(fileName); + }; + + this.inlineCriticalCssProcessor = inlineCriticalCssProcessor; + } + + return inlineCriticalCssProcessor; } } diff --git a/packages/angular/ssr/src/common-engine/common-engine.ts b/packages/angular/ssr/src/common-engine/common-engine.ts index 02a7ba6c55a6..acbfb277cc1e 100644 --- a/packages/angular/ssr/src/common-engine/common-engine.ts +++ b/packages/angular/ssr/src/common-engine/common-engine.ts @@ -11,7 +11,7 @@ import { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/plat import * as fs from 'node:fs'; import { dirname, join, normalize, resolve } from 'node:path'; import { URL } from 'node:url'; -import { InlineCriticalCssProcessor, InlineCriticalCssResult } from './inline-css-processor'; +import { CommonEngineInlineCriticalCssProcessor } from './inline-css-processor'; import { noopRunMethodAndMeasurePerf, printPerformanceLogs, @@ -55,14 +55,10 @@ export interface CommonEngineRenderOptions { export class CommonEngine { private readonly templateCache = new Map(); - private readonly inlineCriticalCssProcessor: InlineCriticalCssProcessor; + private readonly inlineCriticalCssProcessor = new CommonEngineInlineCriticalCssProcessor(); private readonly pageIsSSG = new Map(); - constructor(private options?: CommonEngineOptions) { - this.inlineCriticalCssProcessor = new InlineCriticalCssProcessor({ - minify: false, - }); - } + constructor(private options?: CommonEngineOptions) {} /** * Render an HTML document for a specific URL with specified @@ -81,17 +77,12 @@ export class CommonEngine { html = await runMethod('Render Page', () => this.renderApplication(opts)); if (opts.inlineCriticalCss !== false) { - const { content, errors, warnings } = await runMethod('Inline Critical CSS', () => + const content = await runMethod('Inline Critical CSS', () => // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.inlineCriticalCss(html!, opts), ); html = content; - - // eslint-disable-next-line no-console - warnings?.forEach((m) => console.warn(m)); - // eslint-disable-next-line no-console - errors?.forEach((m) => console.error(m)); } } @@ -102,13 +93,11 @@ export class CommonEngine { return html; } - private inlineCriticalCss( - html: string, - opts: CommonEngineRenderOptions, - ): Promise { - return this.inlineCriticalCssProcessor.process(html, { - outputPath: opts.publicPath ?? (opts.documentFilePath ? dirname(opts.documentFilePath) : ''), - }); + private inlineCriticalCss(html: string, opts: CommonEngineRenderOptions): Promise { + const outputPath = + opts.publicPath ?? (opts.documentFilePath ? dirname(opts.documentFilePath) : ''); + + return this.inlineCriticalCssProcessor.process(html, outputPath); } private async retrieveSSGPage(opts: CommonEngineRenderOptions): Promise { diff --git a/packages/angular/ssr/src/common-engine/inline-css-processor.ts b/packages/angular/ssr/src/common-engine/inline-css-processor.ts index 9664621f02d3..e19a522ec401 100644 --- a/packages/angular/ssr/src/common-engine/inline-css-processor.ts +++ b/packages/angular/ssr/src/common-engine/inline-css-processor.ts @@ -6,258 +6,24 @@ * found in the LICENSE file at https://angular.dev/license */ -import Critters from 'critters'; import { readFile } from 'node:fs/promises'; +import { InlineCriticalCssProcessor } from '../utils/inline-critical-css'; -/** - * Pattern used to extract the media query set by Critters in an `onload` handler. - */ -const MEDIA_SET_HANDLER_PATTERN = /^this\.media=["'](.*)["'];?$/; - -/** - * Name of the attribute used to save the Critters media query so it can be re-assigned on load. - */ -const CSP_MEDIA_ATTR = 'ngCspMedia'; - -/** - * Script text used to change the media value of the link tags. - * - * NOTE: - * We do not use `document.querySelectorAll('link').forEach((s) => s.addEventListener('load', ...)` - * because this does not always fire on Chome. - * See: https://github.com/angular/angular-cli/issues/26932 and https://crbug.com/1521256 - */ -const LINK_LOAD_SCRIPT_CONTENT = [ - '(() => {', - ` const CSP_MEDIA_ATTR = '${CSP_MEDIA_ATTR}';`, - ' const documentElement = document.documentElement;', - ' const listener = (e) => {', - ' const target = e.target;', - ` if (!target || target.tagName !== 'LINK' || !target.hasAttribute(CSP_MEDIA_ATTR)) {`, - ' return;', - ' }', - - ' target.media = target.getAttribute(CSP_MEDIA_ATTR);', - ' target.removeAttribute(CSP_MEDIA_ATTR);', - - // Remove onload listener when there are no longer styles that need to be loaded. - ' if (!document.head.querySelector(`link[${CSP_MEDIA_ATTR}]`)) {', - ` documentElement.removeEventListener('load', listener);`, - ' }', - ' };', - - // We use an event with capturing (the true parameter) because load events don't bubble. - ` documentElement.addEventListener('load', listener, true);`, - '})();', -].join('\n'); - -export interface InlineCriticalCssProcessOptions { - outputPath?: string; -} - -export interface InlineCriticalCssProcessorOptions { - minify?: boolean; - deployUrl?: string; -} - -export interface InlineCriticalCssResult { - content: string; - warnings?: string[]; - errors?: string[]; -} - -/** Partial representation of an `HTMLElement`. */ -interface PartialHTMLElement { - getAttribute(name: string): string | null; - setAttribute(name: string, value: string): void; - hasAttribute(name: string): boolean; - removeAttribute(name: string): void; - appendChild(child: PartialHTMLElement): void; - insertBefore(newNode: PartialHTMLElement, referenceNode?: PartialHTMLElement): void; - remove(): void; - name: string; - textContent: string; - tagName: string | null; - children: PartialHTMLElement[]; - next: PartialHTMLElement | null; - prev: PartialHTMLElement | null; -} - -/** Partial representation of an HTML `Document`. */ -interface PartialDocument { - head: PartialHTMLElement; - createElement(tagName: string): PartialHTMLElement; - querySelector(selector: string): PartialHTMLElement | null; -} - -/** Signature of the `Critters.embedLinkedStylesheet` method. */ -type EmbedLinkedStylesheetFn = ( - link: PartialHTMLElement, - document: PartialDocument, -) => Promise; - -class CrittersExtended extends Critters { - readonly warnings: string[] = []; - readonly errors: string[] = []; - private initialEmbedLinkedStylesheet: EmbedLinkedStylesheetFn; - private addedCspScriptsDocuments = new WeakSet(); - private documentNonces = new WeakMap(); - - // Inherited from `Critters`, but not exposed in the typings. - protected declare embedLinkedStylesheet: EmbedLinkedStylesheetFn; - - constructor( - readonly optionsExtended: InlineCriticalCssProcessorOptions & InlineCriticalCssProcessOptions, - private readonly resourceCache: Map, - ) { - super({ - logger: { - warn: (s: string) => this.warnings.push(s), - error: (s: string) => this.errors.push(s), - info: () => {}, - }, - logLevel: 'warn', - path: optionsExtended.outputPath, - publicPath: optionsExtended.deployUrl, - compress: !!optionsExtended.minify, - pruneSource: false, - reduceInlineStyles: false, - mergeStylesheets: false, - // Note: if `preload` changes to anything other than `media`, the logic in - // `embedLinkedStylesheetOverride` will have to be updated. - preload: 'media', - noscriptFallback: true, - inlineFonts: true, - }); - - // We can't use inheritance to override `embedLinkedStylesheet`, because it's not declared in - // the `Critters` .d.ts which means that we can't call the `super` implementation. TS doesn't - // allow for `super` to be cast to a different type. - this.initialEmbedLinkedStylesheet = this.embedLinkedStylesheet; - this.embedLinkedStylesheet = this.embedLinkedStylesheetOverride; - } - - public override async readFile(path: string): Promise { - let resourceContent = this.resourceCache.get(path); - if (resourceContent === undefined) { - resourceContent = await readFile(path, 'utf-8'); - this.resourceCache.set(path, resourceContent); - } - - return resourceContent; - } - - /** - * Override of the Critters `embedLinkedStylesheet` method - * that makes it work with Angular's CSP APIs. - */ - private embedLinkedStylesheetOverride: EmbedLinkedStylesheetFn = async (link, document) => { - if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') { - // Workaround for https://github.com/GoogleChromeLabs/critters/issues/64 - // NB: this is only needed for the webpack based builders. - const media = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); - if (media) { - link.removeAttribute('onload'); - link.setAttribute('media', media[1]); - link?.next?.remove(); - } - } - - const returnValue = await this.initialEmbedLinkedStylesheet(link, document); - const cspNonce = this.findCspNonce(document); - - if (cspNonce) { - const crittersMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); - - if (crittersMedia) { - // If there's a Critters-generated `onload` handler and the file has an Angular CSP nonce, - // we have to remove the handler, because it's incompatible with CSP. We save the value - // in a different attribute and we generate a script tag with the nonce that uses - // `addEventListener` to apply the media query instead. - link.removeAttribute('onload'); - link.setAttribute(CSP_MEDIA_ATTR, crittersMedia[1]); - this.conditionallyInsertCspLoadingScript(document, cspNonce, link); - } - - // Ideally we would hook in at the time Critters inserts the `style` tags, but there isn't - // a way of doing that at the moment so we fall back to doing it any time a `link` tag is - // inserted. We mitigate it by only iterating the direct children of the `` which - // should be pretty shallow. - document.head.children.forEach((child) => { - if (child.tagName === 'style' && !child.hasAttribute('nonce')) { - child.setAttribute('nonce', cspNonce); - } - }); - } - - return returnValue; - }; - - /** - * Finds the CSP nonce for a specific document. - */ - private findCspNonce(document: PartialDocument): string | null { - if (this.documentNonces.has(document)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.documentNonces.get(document)!; - } - - // HTML attribute are case-insensitive, but the parser used by Critters is case-sensitive. - const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]'); - const cspNonce = - nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce') || null; - - this.documentNonces.set(document, cspNonce); - - return cspNonce; - } - - /** - * Inserts the `script` tag that swaps the critical CSS at runtime, - * if one hasn't been inserted into the document already. - */ - private conditionallyInsertCspLoadingScript( - document: PartialDocument, - nonce: string, - link: PartialHTMLElement, - ): void { - if (this.addedCspScriptsDocuments.has(document)) { - return; - } - - if (document.head.textContent.includes(LINK_LOAD_SCRIPT_CONTENT)) { - // Script was already added during the build. - this.addedCspScriptsDocuments.add(document); - - return; - } - - const script = document.createElement('script'); - script.setAttribute('nonce', nonce); - script.textContent = LINK_LOAD_SCRIPT_CONTENT; - // Prepend the script to the head since it needs to - // run as early as possible, before the `link` tags. - document.head.insertBefore(script, link); - this.addedCspScriptsDocuments.add(document); - } -} - -export class InlineCriticalCssProcessor { +export class CommonEngineInlineCriticalCssProcessor { private readonly resourceCache = new Map(); - constructor(protected readonly options: InlineCriticalCssProcessorOptions) {} - - async process( - html: string, - options: InlineCriticalCssProcessOptions, - ): Promise { - const critters = new CrittersExtended({ ...this.options, ...options }, this.resourceCache); - const content = await critters.process(html); + async process(html: string, outputPath: string | undefined): Promise { + const critters = new InlineCriticalCssProcessor(outputPath); + critters.readFile = async (path: string): Promise => { + let resourceContent = this.resourceCache.get(path); + if (resourceContent === undefined) { + resourceContent = await readFile(path, 'utf-8'); + this.resourceCache.set(path, resourceContent); + } - return { - content, - errors: critters.errors.length ? critters.errors : undefined, - warnings: critters.warnings.length ? critters.warnings : undefined, + return resourceContent; }; + + return critters.process(html); } } diff --git a/packages/angular/ssr/src/utils/inline-critical-css.ts b/packages/angular/ssr/src/utils/inline-critical-css.ts new file mode 100644 index 000000000000..36ad4f47b2a5 --- /dev/null +++ b/packages/angular/ssr/src/utils/inline-critical-css.ts @@ -0,0 +1,214 @@ +/** + * @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.dev/license + */ + +import Critters from '../../third_party/critters'; + +/** + * Pattern used to extract the media query set by Critters in an `onload` handler. + */ +const MEDIA_SET_HANDLER_PATTERN = /^this\.media=["'](.*)["'];?$/; + +/** + * Name of the attribute used to save the Critters media query so it can be re-assigned on load. + */ +const CSP_MEDIA_ATTR = 'ngCspMedia'; + +/** + * Script text used to change the media value of the link tags. + * + * NOTE: + * We do not use `document.querySelectorAll('link').forEach((s) => s.addEventListener('load', ...)` + * because this does not always fire on Chome. + * See: https://github.com/angular/angular-cli/issues/26932 and https://crbug.com/1521256 + */ +const LINK_LOAD_SCRIPT_CONTENT = [ + '(() => {', + ` const CSP_MEDIA_ATTR = '${CSP_MEDIA_ATTR}';`, + ' const documentElement = document.documentElement;', + ' const listener = (e) => {', + ' const target = e.target;', + ` if (!target || target.tagName !== 'LINK' || !target.hasAttribute(CSP_MEDIA_ATTR)) {`, + ' return;', + ' }', + + ' target.media = target.getAttribute(CSP_MEDIA_ATTR);', + ' target.removeAttribute(CSP_MEDIA_ATTR);', + + // Remove onload listener when there are no longer styles that need to be loaded. + ' if (!document.head.querySelector(`link[${CSP_MEDIA_ATTR}]`)) {', + ` documentElement.removeEventListener('load', listener);`, + ' }', + ' };', + + // We use an event with capturing (the true parameter) because load events don't bubble. + ` documentElement.addEventListener('load', listener, true);`, + '})();', +].join('\n'); + +/** Partial representation of an `HTMLElement`. */ +interface PartialHTMLElement { + getAttribute(name: string): string | null; + setAttribute(name: string, value: string): void; + hasAttribute(name: string): boolean; + removeAttribute(name: string): void; + appendChild(child: PartialHTMLElement): void; + insertBefore(newNode: PartialHTMLElement, referenceNode?: PartialHTMLElement): void; + remove(): void; + name: string; + textContent: string; + tagName: string | null; + children: PartialHTMLElement[]; + next: PartialHTMLElement | null; + prev: PartialHTMLElement | null; +} + +/** Partial representation of an HTML `Document`. */ +interface PartialDocument { + head: PartialHTMLElement; + createElement(tagName: string): PartialHTMLElement; + querySelector(selector: string): PartialHTMLElement | null; +} + +/** Signature of the `Critters.embedLinkedStylesheet` method. */ +type EmbedLinkedStylesheetFn = ( + link: PartialHTMLElement, + document: PartialDocument, +) => Promise; + +export class InlineCriticalCssProcessor extends Critters { + private initialEmbedLinkedStylesheet: EmbedLinkedStylesheetFn; + private addedCspScriptsDocuments = new WeakSet(); + private documentNonces = new WeakMap(); + + // Inherited from `Critters`, but not exposed in the typings. + protected declare embedLinkedStylesheet: EmbedLinkedStylesheetFn; + + constructor(readonly outputPath?: string) { + super({ + logger: { + // eslint-disable-next-line no-console + warn: (s: string) => console.warn(s), + // eslint-disable-next-line no-console + error: (s: string) => console.error(s), + info: () => {}, + }, + logLevel: 'warn', + path: outputPath, + publicPath: undefined, + compress: false, + pruneSource: false, + reduceInlineStyles: false, + mergeStylesheets: false, + // Note: if `preload` changes to anything other than `media`, the logic in + // `embedLinkedStylesheetOverride` will have to be updated. + preload: 'media', + noscriptFallback: true, + inlineFonts: true, + }); + + // We can't use inheritance to override `embedLinkedStylesheet`, because it's not declared in + // the `Critters` .d.ts which means that we can't call the `super` implementation. TS doesn't + // allow for `super` to be cast to a different type. + this.initialEmbedLinkedStylesheet = this.embedLinkedStylesheet; + this.embedLinkedStylesheet = this.embedLinkedStylesheetOverride; + } + + /** + * Override of the Critters `embedLinkedStylesheet` method + * that makes it work with Angular's CSP APIs. + */ + private embedLinkedStylesheetOverride: EmbedLinkedStylesheetFn = async (link, document) => { + if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') { + // Workaround for https://github.com/GoogleChromeLabs/critters/issues/64 + // NB: this is only needed for the webpack based builders. + const media = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); + if (media) { + link.removeAttribute('onload'); + link.setAttribute('media', media[1]); + link?.next?.remove(); + } + } + + const returnValue = await this.initialEmbedLinkedStylesheet(link, document); + const cspNonce = this.findCspNonce(document); + + if (cspNonce) { + const crittersMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); + + if (crittersMedia) { + // If there's a Critters-generated `onload` handler and the file has an Angular CSP nonce, + // we have to remove the handler, because it's incompatible with CSP. We save the value + // in a different attribute and we generate a script tag with the nonce that uses + // `addEventListener` to apply the media query instead. + link.removeAttribute('onload'); + link.setAttribute(CSP_MEDIA_ATTR, crittersMedia[1]); + this.conditionallyInsertCspLoadingScript(document, cspNonce, link); + } + + // Ideally we would hook in at the time Critters inserts the `style` tags, but there isn't + // a way of doing that at the moment so we fall back to doing it any time a `link` tag is + // inserted. We mitigate it by only iterating the direct children of the `` which + // should be pretty shallow. + document.head.children.forEach((child) => { + if (child.tagName === 'style' && !child.hasAttribute('nonce')) { + child.setAttribute('nonce', cspNonce); + } + }); + } + + return returnValue; + }; + + /** + * Finds the CSP nonce for a specific document. + */ + private findCspNonce(document: PartialDocument): string | null { + if (this.documentNonces.has(document)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.documentNonces.get(document)!; + } + + // HTML attribute are case-insensitive, but the parser used by Critters is case-sensitive. + const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]'); + const cspNonce = + nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce') || null; + + this.documentNonces.set(document, cspNonce); + + return cspNonce; + } + + /** + * Inserts the `script` tag that swaps the critical CSS at runtime, + * if one hasn't been inserted into the document already. + */ + private conditionallyInsertCspLoadingScript( + document: PartialDocument, + nonce: string, + link: PartialHTMLElement, + ): void { + if (this.addedCspScriptsDocuments.has(document)) { + return; + } + + if (document.head.textContent.includes(LINK_LOAD_SCRIPT_CONTENT)) { + // Script was already added during the build. + this.addedCspScriptsDocuments.add(document); + + return; + } + + const script = document.createElement('script'); + script.setAttribute('nonce', nonce); + script.textContent = LINK_LOAD_SCRIPT_CONTENT; + // Prepend the script to the head since it needs to + // run as early as possible, before the `link` tags. + document.head.insertBefore(script, link); + this.addedCspScriptsDocuments.add(document); + } +} diff --git a/packages/angular/ssr/third_party/critters/BUILD.bazel b/packages/angular/ssr/third_party/critters/BUILD.bazel new file mode 100644 index 000000000000..8dde77495c4e --- /dev/null +++ b/packages/angular/ssr/third_party/critters/BUILD.bazel @@ -0,0 +1,55 @@ +load("@npm//@angular/build-tooling/bazel/esbuild:index.bzl", "esbuild", "esbuild_config") +load("//tools:defaults.bzl", "js_library") + +package(default_visibility = ["//visibility:public"]) + +esbuild( + name = "bundled_critters", + config = ":esbuild_config", + entry_point = "@npm//:node_modules/critters/dist/critters.mjs", + metafile = True, + splitting = True, + deps = [ + "@npm//critters", + "@npm//unenv", + ], +) + +esbuild_config( + name = "esbuild_config", + config_file = "esbuild.config.mjs", +) + +js_library( + name = "bundled_critters_lib", + srcs = [ + "index.d.ts", + ":bundled_critters_files", + ], + deps = [ + "@npm//critters", + ], +) + +# Filter out esbuild metadata files and only copy the necessary files +genrule( + name = "bundled_critters_files", + srcs = [ + ":bundled_critters", + ], + outs = [ + "index.js", + "index.js.map", + "THIRD_PARTY_LICENSES.txt", + ], + cmd = """ + for f in $(locations :bundled_critters); do + # Only process files inside the bundled_critters directory + if [[ "$${f}" == *bundled_critters ]]; then + cp "$${f}/index.js" $(location :index.js) + cp "$${f}/index.js.map" $(location :index.js.map) + cp "$${f}/THIRD_PARTY_LICENSES.txt" $(location :THIRD_PARTY_LICENSES.txt) + fi + done + """, +) diff --git a/packages/angular/ssr/third_party/critters/esbuild.config.mjs b/packages/angular/ssr/third_party/critters/esbuild.config.mjs new file mode 100644 index 000000000000..b50e9b61524a --- /dev/null +++ b/packages/angular/ssr/third_party/critters/esbuild.config.mjs @@ -0,0 +1,197 @@ +/** + * @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.dev/license + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { join, basename, dirname } from 'node:path'; +import { nodeless } from 'unenv'; + +const { path, fs } = nodeless.alias; + +export default { + format: 'esm', + platform: 'browser', + entryNames: 'index', + legalComments: 'eof', + alias: { + fs, + path, + }, + plugins: [ + { + name: 'angular-license-extractor', + setup(build) { + build.onEnd(async (result) => { + const licenses = await extractLicenses(result.metafile); + + return writeFile(join(build.initialOptions.outdir, 'THIRD_PARTY_LICENSES.txt'), licenses); + }); + }, + }, + ], +}; + +// Note: The following content has been adapted from the Angular CLI license extractor. + +/** + * The path segment used to signify that a file is part of a package. + */ +const NODE_MODULE_SEGMENT = 'node_modules'; + +/** + * String constant for the NPM recommended custom license wording. + * + * See: https://docs.npmjs.com/cli/v9/configuring-npm/package-json#license + * + * Example: + * ``` + * { + * "license" : "SEE LICENSE IN " + * } + * ``` + */ +const CUSTOM_LICENSE_TEXT = 'SEE LICENSE IN '; + +/** + * A list of commonly named license files found within packages. + */ +const LICENSE_FILES = ['LICENSE', 'LICENSE.txt', 'LICENSE.md']; + +/** + * Header text that will be added to the top of the output license extraction file. + */ +const EXTRACTION_FILE_HEADER = ''; + +/** + * The package entry separator to use within the output license extraction file. + */ +const EXTRACTION_FILE_SEPARATOR = '-'.repeat(80) + '\n'; + +/** + * Extracts license information for each node module package included in the output + * files of the built code. This includes JavaScript and CSS output files. The esbuild + * metafile generated during the bundling steps is used as the source of information + * regarding what input files where included and where they are located. A path segment + * of `node_modules` is used to indicate that a file belongs to a package and its license + * should be include in the output licenses file. + * + * The package name and license field are extracted from the `package.json` file for the + * package. If a license file (e.g., `LICENSE`) is present in the root of the package, it + * will also be included in the output licenses file. + * + * @param metafile An esbuild metafile object. + * @returns A string containing the content of the output licenses file. + */ +async function extractLicenses(metafile) { + let extractedLicenseContent = `${EXTRACTION_FILE_HEADER}\n${EXTRACTION_FILE_SEPARATOR}`; + + const seenPaths = new Set(); + const seenPackages = new Set(); + + for (const entry of Object.values(metafile.outputs)) { + for (const [inputPath, { bytesInOutput }] of Object.entries(entry.inputs)) { + // Skip if not included in output + if (bytesInOutput <= 0) { + continue; + } + + // Skip already processed paths + if (seenPaths.has(inputPath)) { + continue; + } + seenPaths.add(inputPath); + + // Skip non-package paths + if (!inputPath.includes(NODE_MODULE_SEGMENT)) { + continue; + } + + // Extract the package name from the path + let baseDirectory = join(process.cwd(), inputPath); + let nameOrScope, nameOrFile; + let found = false; + while (baseDirectory !== dirname(baseDirectory)) { + const segment = basename(baseDirectory); + if (segment === NODE_MODULE_SEGMENT) { + found = true; + break; + } + + nameOrFile = nameOrScope; + nameOrScope = segment; + baseDirectory = dirname(baseDirectory); + } + + // Skip non-package path edge cases that are not caught in the includes check above + if (!found || !nameOrScope) { + continue; + } + + const packageName = nameOrScope.startsWith('@') + ? `${nameOrScope}/${nameOrFile}` + : nameOrScope; + const packageDirectory = join(baseDirectory, packageName); + + // Load the package's metadata to find the package's name, version, and license type + const packageJsonPath = join(packageDirectory, 'package.json'); + let packageJson; + try { + packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')); + } catch { + // Invalid package + continue; + } + + // Skip already processed packages + const packageId = `${packageName}@${packageJson.version}`; + if (seenPackages.has(packageId)) { + continue; + } + seenPackages.add(packageId); + + // Attempt to find license text inside package + let licenseText = ''; + if ( + typeof packageJson.license === 'string' && + packageJson.license.toLowerCase().startsWith(CUSTOM_LICENSE_TEXT) + ) { + // Attempt to load the package's custom license + let customLicensePath; + const customLicenseFile = normalize( + packageJson.license.slice(CUSTOM_LICENSE_TEXT.length + 1).trim(), + ); + if (customLicenseFile.startsWith('..') || isAbsolute(customLicenseFile)) { + // Path is attempting to access files outside of the package + // TODO: Issue warning? + } else { + customLicensePath = join(packageDirectory, customLicenseFile); + try { + licenseText = await readFile(customLicensePath, 'utf-8'); + break; + } catch {} + } + } else { + // Search for a license file within the root of the package + for (const potentialLicense of LICENSE_FILES) { + const packageLicensePath = join(packageDirectory, potentialLicense); + try { + licenseText = await readFile(packageLicensePath, 'utf-8'); + break; + } catch {} + } + } + + // Generate the package's license entry in the output content + extractedLicenseContent += `Package: ${packageJson.name}\n`; + extractedLicenseContent += `License: ${JSON.stringify(packageJson.license, null, 2)}\n`; + extractedLicenseContent += `\n${licenseText}\n`; + extractedLicenseContent += EXTRACTION_FILE_SEPARATOR; + } + } + + return extractedLicenseContent; +} diff --git a/packages/angular/ssr/third_party/critters/index.d.ts b/packages/angular/ssr/third_party/critters/index.d.ts new file mode 100644 index 000000000000..c53119f0a037 --- /dev/null +++ b/packages/angular/ssr/third_party/critters/index.d.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export {default} from 'critters'; diff --git a/scripts/build.mts b/scripts/build.mts index 828d73a822f6..6c5312759df6 100644 --- a/scripts/build.mts +++ b/scripts/build.mts @@ -95,9 +95,7 @@ async function _build(logger: Console, mode: BuildMode): Promise { logger.group(`Building (mode=${mode})...`); logger.group('Finding targets...'); - const queryTargetsCmd = - `${bazelCmd} query --output=label "attr(name, npm_package_archive, //packages/...` + - ' except //packages/angular/ssr/schematics/...)"'; + const queryTargetsCmd = `${bazelCmd} query --output=label "attr(name, npm_package_archive, //packages/...)"`; const targets = (await _exec(queryTargetsCmd, true, logger)).split(/\r?\n/); logger.groupEnd(); diff --git a/tools/defaults.bzl b/tools/defaults.bzl index 20e20d699b13..5b385d8b7614 100644 --- a/tools/defaults.bzl +++ b/tools/defaults.bzl @@ -24,6 +24,8 @@ NPM_PACKAGE_SUBSTITUTIONS = { "0.0.0-ENGINES-NODE": RELEASE_ENGINES_NODE, "0.0.0-ENGINES-NPM": RELEASE_ENGINES_NPM, "0.0.0-ENGINES-YARN": RELEASE_ENGINES_YARN, + # The below is needed for @angular/ssr FESM file. + "\\./(.+)/packages/angular/ssr/third_party/critters": "../third_party/critters/index.js", } NO_STAMP_PACKAGE_SUBSTITUTIONS = dict(NPM_PACKAGE_SUBSTITUTIONS, **{ @@ -218,13 +220,14 @@ def pkg_npm(name, pkg_deps = [], use_prodmode_output = False, **kwargs): **kwargs ) - pkg_tar( - name = name + "_archive", - srcs = [":%s" % name], - extension = "tgz", - strip_prefix = "./%s" % name, - visibility = visibility, - ) + if pkg_json: + pkg_tar( + name = name + "_archive", + srcs = [":%s" % name], + extension = "tgz", + strip_prefix = "./%s" % name, + visibility = visibility, + ) def ng_module(name, tsconfig = None, entry_point = None, testonly = False, deps = [], module_name = None, package_name = None, **kwargs): """Default values for ng_module""" @@ -268,12 +271,6 @@ def ng_module(name, tsconfig = None, entry_point = None, testonly = False, deps def ng_package(deps = [], **kwargs): _ng_package( deps = deps, - externals = [ - "xhr2", - "critters", - "express-engine", - "express", - ], substitutions = select({ "//:stamp": NPM_PACKAGE_SUBSTITUTIONS, "//conditions:default": NO_STAMP_PACKAGE_SUBSTITUTIONS, diff --git a/yarn.lock b/yarn.lock index 78d3301b868d..a4ee0a2e6537 100644 --- a/yarn.lock +++ b/yarn.lock @@ -775,6 +775,7 @@ __metadata: tslib: "npm:2.6.3" typescript: "npm:5.5.4" undici: "npm:6.19.8" + unenv: "npm:^1.10.0" verdaccio: "npm:5.32.1" verdaccio-auth-memory: "npm:^10.0.0" vite: "npm:5.4.2" @@ -964,7 +965,6 @@ __metadata: "@angular/platform-browser": "npm:19.0.0-next.1" "@angular/platform-server": "npm:19.0.0-next.1" "@angular/router": "npm:19.0.0-next.1" - critters: "npm:0.0.24" tslib: "npm:^2.3.0" zone.js: "npm:^0.15.0" peerDependencies: @@ -7923,6 +7923,13 @@ __metadata: languageName: node linkType: hard +"consola@npm:^3.2.3": + version: 3.2.3 + resolution: "consola@npm:3.2.3" + checksum: 10c0/c606220524ec88a05bb1baf557e9e0e04a0c08a9c35d7a08652d99de195c4ddcb6572040a7df57a18ff38bbc13ce9880ad032d56630cef27bef72768ef0ac078 + languageName: node + linkType: hard + "content-disposition@npm:0.5.4, content-disposition@npm:~0.5.2": version: 0.5.4 resolution: "content-disposition@npm:0.5.4" @@ -8402,6 +8409,13 @@ __metadata: languageName: node linkType: hard +"defu@npm:^6.1.4": + version: 6.1.4 + resolution: "defu@npm:6.1.4" + checksum: 10c0/2d6cc366262dc0cb8096e429368e44052fdf43ed48e53ad84cc7c9407f890301aa5fcb80d0995abaaf842b3949f154d060be4160f7a46cb2bc2f7726c81526f5 + languageName: node + linkType: hard + "degenerator@npm:^5.0.0": version: 5.0.1 resolution: "degenerator@npm:5.0.1" @@ -12974,7 +12988,7 @@ __metadata: languageName: node linkType: hard -"mime@npm:3.0.0": +"mime@npm:3.0.0, mime@npm:^3.0.0": version: 3.0.0 resolution: "mime@npm:3.0.0" bin: @@ -13437,6 +13451,13 @@ __metadata: languageName: node linkType: hard +"node-fetch-native@npm:^1.6.4": + version: 1.6.4 + resolution: "node-fetch-native@npm:1.6.4" + checksum: 10c0/78334dc6def5d1d95cfe87b33ac76c4833592c5eb84779ad2b0c23c689f9dd5d1cfc827035ada72d6b8b218f717798968c5a99aeff0a1a8bf06657e80592f9c3 + languageName: node + linkType: hard + "node-fetch@npm:*": version: 3.3.2 resolution: "node-fetch@npm:3.3.2" @@ -14331,6 +14352,13 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^1.1.2": + version: 1.1.2 + resolution: "pathe@npm:1.1.2" + checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897 + languageName: node + linkType: hard + "peek-stream@npm:^1.1.0": version: 1.1.3 resolution: "peek-stream@npm:1.1.3" @@ -17451,6 +17479,19 @@ __metadata: languageName: node linkType: hard +"unenv@npm:^1.10.0": + version: 1.10.0 + resolution: "unenv@npm:1.10.0" + dependencies: + consola: "npm:^3.2.3" + defu: "npm:^6.1.4" + mime: "npm:^3.0.0" + node-fetch-native: "npm:^1.6.4" + pathe: "npm:^1.1.2" + checksum: 10c0/354180647e21204b6c303339e7364b920baadb2672b540a88af267bc827636593e0bf79f59753dcc6b7ab5d4c83e71d69a9171a3596befb8bf77e0bb3c7612b9 + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.0 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0" From 99c9e7a47f6feecdc625bb7449b63dde27479fe8 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Fri, 23 Aug 2024 07:02:37 +0000 Subject: [PATCH 2/4] refactor: add TypeScript declaration merging for Critters This refactor adds a workaround to enable TypeScript declaration merging for the Critters class. We initially tried using interface merging on the default-exported Critters class: ```typescript interface Critters { embedLinkedStylesheet(link: PartialHTMLElement, document: PartialDocument): Promise; } ``` However, since Critters is exported as a default class, TypeScript's declaration merging does not apply. To solve this, we introduced a new class, CrittersBase, which extends Critters, and added the required method in the CrittersBase interface: ```typescript interface CrittersBase { embedLinkedStylesheet(link: PartialHTMLElement, document: PartialDocument): Promise; } class CrittersBase extends Critters {} ``` --- .../utils/index-file/inline-critical-css.ts | 97 +++++++++-------- packages/angular/ssr/src/app.ts | 26 +---- .../src/common-engine/inline-css-processor.ts | 5 +- .../ssr/src/utils/inline-critical-css.ts | 102 ++++++++++-------- 4 files changed, 114 insertions(+), 116 deletions(-) diff --git a/packages/angular/build/src/utils/index-file/inline-critical-css.ts b/packages/angular/build/src/utils/index-file/inline-critical-css.ts index fe68c8abe105..ad531422989c 100644 --- a/packages/angular/build/src/utils/index-file/inline-critical-css.ts +++ b/packages/angular/build/src/utils/index-file/inline-critical-css.ts @@ -20,36 +20,46 @@ const MEDIA_SET_HANDLER_PATTERN = /^this\.media=["'](.*)["'];?$/; const CSP_MEDIA_ATTR = 'ngCspMedia'; /** - * Script text used to change the media value of the link tags. + * Script that dynamically updates the `media` attribute of `` tags based on a custom attribute (`CSP_MEDIA_ATTR`). * * NOTE: * We do not use `document.querySelectorAll('link').forEach((s) => s.addEventListener('load', ...)` - * because this does not always fire on Chome. + * because load events are not always triggered reliably on Chrome. * See: https://github.com/angular/angular-cli/issues/26932 and https://crbug.com/1521256 + * + * The script: + * - Ensures the event target is a `` tag with the `CSP_MEDIA_ATTR` attribute. + * - Updates the `media` attribute with the value of `CSP_MEDIA_ATTR` and then removes the attribute. + * - Removes the event listener when all relevant `` tags have been processed. + * - Uses event capturing (the `true` parameter) since load events do not bubble up the DOM. */ -const LINK_LOAD_SCRIPT_CONTENT = [ - '(() => {', - ` const CSP_MEDIA_ATTR = '${CSP_MEDIA_ATTR}';`, - ' const documentElement = document.documentElement;', - ' const listener = (e) => {', - ' const target = e.target;', - ` if (!target || target.tagName !== 'LINK' || !target.hasAttribute(CSP_MEDIA_ATTR)) {`, - ' return;', - ' }', - - ' target.media = target.getAttribute(CSP_MEDIA_ATTR);', - ' target.removeAttribute(CSP_MEDIA_ATTR);', - - // Remove onload listener when there are no longer styles that need to be loaded. - ' if (!document.head.querySelector(`link[${CSP_MEDIA_ATTR}]`)) {', - ` documentElement.removeEventListener('load', listener);`, - ' }', - ' };', - - // We use an event with capturing (the true parameter) because load events don't bubble. - ` documentElement.addEventListener('load', listener, true);`, - '})();', -].join('\n'); +const LINK_LOAD_SCRIPT_CONTENT = ` +(() => { + const CSP_MEDIA_ATTR = '${CSP_MEDIA_ATTR}'; + const documentElement = document.documentElement; + + // Listener for load events on link tags. + const listener = (e) => { + const target = e.target; + if ( + !target || + target.tagName !== 'LINK' || + !target.hasAttribute(CSP_MEDIA_ATTR) + ) { + return; + } + + target.media = target.getAttribute(CSP_MEDIA_ATTR); + target.removeAttribute(CSP_MEDIA_ATTR); + + if (!document.head.querySelector(\`link[\${CSP_MEDIA_ATTR}]\`)) { + documentElement.removeEventListener('load', listener); + } + }; + + documentElement.addEventListener('load', listener, true); +})(); +`.trim(); export interface InlineCriticalCssProcessOptions { outputPath: string; @@ -85,22 +95,22 @@ interface PartialDocument { querySelector(selector: string): PartialHTMLElement | null; } -/** Signature of the `Critters.embedLinkedStylesheet` method. */ -type EmbedLinkedStylesheetFn = ( - link: PartialHTMLElement, - document: PartialDocument, -) => Promise; +/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ -class CrittersExtended extends Critters { +// We use Typescript declaration merging because `embedLinkedStylesheet` it's not declared in +// the `Critters` types which means that we can't call the `super` implementation. +interface CrittersBase { + embedLinkedStylesheet(link: PartialHTMLElement, document: PartialDocument): Promise; +} +class CrittersBase extends Critters {} +/* eslint-enable @typescript-eslint/no-unsafe-declaration-merging */ + +class CrittersExtended extends CrittersBase { readonly warnings: string[] = []; readonly errors: string[] = []; - private initialEmbedLinkedStylesheet: EmbedLinkedStylesheetFn; private addedCspScriptsDocuments = new WeakSet(); private documentNonces = new WeakMap(); - // Inherited from `Critters`, but not exposed in the typings. - protected declare embedLinkedStylesheet: EmbedLinkedStylesheetFn; - constructor( private readonly optionsExtended: InlineCriticalCssProcessorOptions & InlineCriticalCssProcessOptions, @@ -119,17 +129,11 @@ class CrittersExtended extends Critters { reduceInlineStyles: false, mergeStylesheets: false, // Note: if `preload` changes to anything other than `media`, the logic in - // `embedLinkedStylesheetOverride` will have to be updated. + // `embedLinkedStylesheet` will have to be updated. preload: 'media', noscriptFallback: true, inlineFonts: true, }); - - // We can't use inheritance to override `embedLinkedStylesheet`, because it's not declared in - // the `Critters` .d.ts which means that we can't call the `super` implementation. TS doesn't - // allow for `super` to be cast to a different type. - this.initialEmbedLinkedStylesheet = this.embedLinkedStylesheet; - this.embedLinkedStylesheet = this.embedLinkedStylesheetOverride; } public override readFile(path: string): Promise { @@ -142,7 +146,10 @@ class CrittersExtended extends Critters { * Override of the Critters `embedLinkedStylesheet` method * that makes it work with Angular's CSP APIs. */ - private embedLinkedStylesheetOverride: EmbedLinkedStylesheetFn = async (link, document) => { + override async embedLinkedStylesheet( + link: PartialHTMLElement, + document: PartialDocument, + ): Promise { if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') { // Workaround for https://github.com/GoogleChromeLabs/critters/issues/64 // NB: this is only needed for the webpack based builders. @@ -154,7 +161,7 @@ class CrittersExtended extends Critters { } } - const returnValue = await this.initialEmbedLinkedStylesheet(link, document); + const returnValue = await super.embedLinkedStylesheet(link, document); const cspNonce = this.findCspNonce(document); if (cspNonce) { @@ -182,7 +189,7 @@ class CrittersExtended extends Critters { } return returnValue; - }; + } /** * Finds the CSP nonce for a specific document. diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index bc116876aef3..06925a239a64 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -187,34 +187,16 @@ export class AngularServerApp { if (manifest.inlineCriticalCss) { // Optionally inline critical CSS. - const inlineCriticalCssProcessor = this.getOrCreateInlineCssProcessor(); - html = await inlineCriticalCssProcessor.process(html); - } - - return new Response(html, responseInit); - } - - /** - * Retrieves or creates the inline critical CSS processor. - * If one does not exist, it initializes a new instance. - * - * @returns The inline critical CSS processor instance. - */ - private getOrCreateInlineCssProcessor(): InlineCriticalCssProcessor { - let inlineCriticalCssProcessor = this.inlineCriticalCssProcessor; - - if (!inlineCriticalCssProcessor) { - inlineCriticalCssProcessor = new InlineCriticalCssProcessor(); - inlineCriticalCssProcessor.readFile = (path: string) => { + this.inlineCriticalCssProcessor ??= new InlineCriticalCssProcessor((path: string) => { const fileName = path.split('/').pop() ?? path; return this.assets.getServerAsset(fileName); - }; + }); - this.inlineCriticalCssProcessor = inlineCriticalCssProcessor; + html = await this.inlineCriticalCssProcessor.process(html); } - return inlineCriticalCssProcessor; + return new Response(html, responseInit); } } diff --git a/packages/angular/ssr/src/common-engine/inline-css-processor.ts b/packages/angular/ssr/src/common-engine/inline-css-processor.ts index e19a522ec401..d9e8d0e2c2d3 100644 --- a/packages/angular/ssr/src/common-engine/inline-css-processor.ts +++ b/packages/angular/ssr/src/common-engine/inline-css-processor.ts @@ -13,8 +13,7 @@ export class CommonEngineInlineCriticalCssProcessor { private readonly resourceCache = new Map(); async process(html: string, outputPath: string | undefined): Promise { - const critters = new InlineCriticalCssProcessor(outputPath); - critters.readFile = async (path: string): Promise => { + const critters = new InlineCriticalCssProcessor(async (path) => { let resourceContent = this.resourceCache.get(path); if (resourceContent === undefined) { resourceContent = await readFile(path, 'utf-8'); @@ -22,7 +21,7 @@ export class CommonEngineInlineCriticalCssProcessor { } return resourceContent; - }; + }, outputPath); return critters.process(html); } diff --git a/packages/angular/ssr/src/utils/inline-critical-css.ts b/packages/angular/ssr/src/utils/inline-critical-css.ts index 36ad4f47b2a5..648a4d51577c 100644 --- a/packages/angular/ssr/src/utils/inline-critical-css.ts +++ b/packages/angular/ssr/src/utils/inline-critical-css.ts @@ -19,36 +19,46 @@ const MEDIA_SET_HANDLER_PATTERN = /^this\.media=["'](.*)["'];?$/; const CSP_MEDIA_ATTR = 'ngCspMedia'; /** - * Script text used to change the media value of the link tags. + * Script that dynamically updates the `media` attribute of `` tags based on a custom attribute (`CSP_MEDIA_ATTR`). * * NOTE: * We do not use `document.querySelectorAll('link').forEach((s) => s.addEventListener('load', ...)` - * because this does not always fire on Chome. + * because load events are not always triggered reliably on Chrome. * See: https://github.com/angular/angular-cli/issues/26932 and https://crbug.com/1521256 + * + * The script: + * - Ensures the event target is a `` tag with the `CSP_MEDIA_ATTR` attribute. + * - Updates the `media` attribute with the value of `CSP_MEDIA_ATTR` and then removes the attribute. + * - Removes the event listener when all relevant `` tags have been processed. + * - Uses event capturing (the `true` parameter) since load events do not bubble up the DOM. */ -const LINK_LOAD_SCRIPT_CONTENT = [ - '(() => {', - ` const CSP_MEDIA_ATTR = '${CSP_MEDIA_ATTR}';`, - ' const documentElement = document.documentElement;', - ' const listener = (e) => {', - ' const target = e.target;', - ` if (!target || target.tagName !== 'LINK' || !target.hasAttribute(CSP_MEDIA_ATTR)) {`, - ' return;', - ' }', - - ' target.media = target.getAttribute(CSP_MEDIA_ATTR);', - ' target.removeAttribute(CSP_MEDIA_ATTR);', - - // Remove onload listener when there are no longer styles that need to be loaded. - ' if (!document.head.querySelector(`link[${CSP_MEDIA_ATTR}]`)) {', - ` documentElement.removeEventListener('load', listener);`, - ' }', - ' };', - - // We use an event with capturing (the true parameter) because load events don't bubble. - ` documentElement.addEventListener('load', listener, true);`, - '})();', -].join('\n'); +const LINK_LOAD_SCRIPT_CONTENT = ` +(() => { + const CSP_MEDIA_ATTR = '${CSP_MEDIA_ATTR}'; + const documentElement = document.documentElement; + + // Listener for load events on link tags. + const listener = (e) => { + const target = e.target; + if ( + !target || + target.tagName !== 'LINK' || + !target.hasAttribute(CSP_MEDIA_ATTR) + ) { + return; + } + + target.media = target.getAttribute(CSP_MEDIA_ATTR); + target.removeAttribute(CSP_MEDIA_ATTR); + + if (!document.head.querySelector(\`link[\${CSP_MEDIA_ATTR}]\`)) { + documentElement.removeEventListener('load', listener); + } + }; + + documentElement.addEventListener('load', listener, true); +})(); +`.trim(); /** Partial representation of an `HTMLElement`. */ interface PartialHTMLElement { @@ -74,21 +84,24 @@ interface PartialDocument { querySelector(selector: string): PartialHTMLElement | null; } -/** Signature of the `Critters.embedLinkedStylesheet` method. */ -type EmbedLinkedStylesheetFn = ( - link: PartialHTMLElement, - document: PartialDocument, -) => Promise; +/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ -export class InlineCriticalCssProcessor extends Critters { - private initialEmbedLinkedStylesheet: EmbedLinkedStylesheetFn; +// We use Typescript declaration merging because `embedLinkedStylesheet` it's not declared in +// the `Critters` types which means that we can't call the `super` implementation. +interface CrittersBase { + embedLinkedStylesheet(link: PartialHTMLElement, document: PartialDocument): Promise; +} +class CrittersBase extends Critters {} +/* eslint-enable @typescript-eslint/no-unsafe-declaration-merging */ + +export class InlineCriticalCssProcessor extends CrittersBase { private addedCspScriptsDocuments = new WeakSet(); private documentNonces = new WeakMap(); - // Inherited from `Critters`, but not exposed in the typings. - protected declare embedLinkedStylesheet: EmbedLinkedStylesheetFn; - - constructor(readonly outputPath?: string) { + constructor( + public override readFile: (path: string) => Promise, + readonly outputPath?: string, + ) { super({ logger: { // eslint-disable-next-line no-console @@ -105,24 +118,21 @@ export class InlineCriticalCssProcessor extends Critters { reduceInlineStyles: false, mergeStylesheets: false, // Note: if `preload` changes to anything other than `media`, the logic in - // `embedLinkedStylesheetOverride` will have to be updated. + // `embedLinkedStylesheet` will have to be updated. preload: 'media', noscriptFallback: true, inlineFonts: true, }); - - // We can't use inheritance to override `embedLinkedStylesheet`, because it's not declared in - // the `Critters` .d.ts which means that we can't call the `super` implementation. TS doesn't - // allow for `super` to be cast to a different type. - this.initialEmbedLinkedStylesheet = this.embedLinkedStylesheet; - this.embedLinkedStylesheet = this.embedLinkedStylesheetOverride; } /** * Override of the Critters `embedLinkedStylesheet` method * that makes it work with Angular's CSP APIs. */ - private embedLinkedStylesheetOverride: EmbedLinkedStylesheetFn = async (link, document) => { + override async embedLinkedStylesheet( + link: PartialHTMLElement, + document: PartialDocument, + ): Promise { if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') { // Workaround for https://github.com/GoogleChromeLabs/critters/issues/64 // NB: this is only needed for the webpack based builders. @@ -134,7 +144,7 @@ export class InlineCriticalCssProcessor extends Critters { } } - const returnValue = await this.initialEmbedLinkedStylesheet(link, document); + const returnValue = await super.embedLinkedStylesheet(link, document); const cspNonce = this.findCspNonce(document); if (cspNonce) { @@ -162,7 +172,7 @@ export class InlineCriticalCssProcessor extends Critters { } return returnValue; - }; + } /** * Finds the CSP nonce for a specific document. From f1d3fe0bb3723e3d84a4b4eb39dca3a58f4251cc Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Fri, 23 Aug 2024 11:52:08 +0000 Subject: [PATCH 3/4] build: add function to validate and update 3rd party license files - Implemented functionality to compare and update third-party license files - Added handling for '--accept' argument to update the golden license file - Included tests to ensure the Critters license file matches the golden reference This update ensures license file consistency and provides an easy way to update the reference file when necessary. --- .prettierignore | 4 +- package.json | 3 + packages/angular/ssr/package.json | 2 + packages/angular/ssr/test/BUILD.bazel | 2 +- .../angular/ssr/test/npm_package/BUILD.bazel | 35 ++ .../THIRD_PARTY_LICENSES.txt.golden | 350 ++++++++++++++++++ .../ssr/test/npm_package/package_spec.ts | 62 ++++ .../test/npm_package/update-package-golden.ts | 15 + .../angular/ssr/test/npm_package/utils.ts | 32 ++ .../third_party/critters/esbuild.config.mjs | 19 +- .../ssr/third_party/critters/index.d.ts | 2 +- yarn.lock | 14 +- 12 files changed, 529 insertions(+), 11 deletions(-) create mode 100644 packages/angular/ssr/test/npm_package/BUILD.bazel create mode 100644 packages/angular/ssr/test/npm_package/THIRD_PARTY_LICENSES.txt.golden create mode 100644 packages/angular/ssr/test/npm_package/package_spec.ts create mode 100644 packages/angular/ssr/test/npm_package/update-package-golden.ts create mode 100644 packages/angular/ssr/test/npm_package/utils.ts diff --git a/.prettierignore b/.prettierignore index c78211cd0633..ae6046eb4baf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,6 +12,6 @@ /CONTRIBUTING.md .yarn/ dist/ -third_party/ +third_party/github.com/ /tests/legacy-cli/e2e/assets/ -/tools/test/*.json \ No newline at end of file +/tools/test/*.json diff --git a/package.json b/package.json index d3aef362d9fe..8974f67de22a 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@bazel/buildifier": "7.1.2", "@bazel/concatjs": "patch:@bazel/concatjs@npm%3A5.8.1#~/.yarn/patches/@bazel-concatjs-npm-5.8.1-1bf81df846.patch", "@bazel/jasmine": "patch:@bazel/jasmine@npm%3A5.8.1#~/.yarn/patches/@bazel-jasmine-npm-5.8.1-3370fee155.patch", + "@bazel/runfiles": "^5.8.1", "@discoveryjs/json-ext": "0.6.1", "@inquirer/confirm": "3.1.22", "@inquirer/prompts": "5.3.8", @@ -91,6 +92,7 @@ "@rollup/plugin-node-resolve": "^13.0.5", "@types/babel__core": "7.20.5", "@types/browser-sync": "^2.27.0", + "@types/diff": "^5.2.1", "@types/express": "^4.16.0", "@types/http-proxy": "^1.17.4", "@types/ini": "^4.0.0", @@ -130,6 +132,7 @@ "critters": "0.0.24", "css-loader": "7.1.2", "debug": "^4.1.1", + "diff": "^5.2.0", "esbuild": "0.23.1", "esbuild-wasm": "0.23.1", "eslint": "8.57.0", diff --git a/packages/angular/ssr/package.json b/packages/angular/ssr/package.json index e449d89cb981..811944267e97 100644 --- a/packages/angular/ssr/package.json +++ b/packages/angular/ssr/package.json @@ -28,6 +28,8 @@ "@angular/platform-browser": "19.0.0-next.1", "@angular/platform-server": "19.0.0-next.1", "@angular/router": "19.0.0-next.1", + "@bazel/runfiles": "^5.8.1", + "diff": "^5.2.0", "zone.js": "^0.15.0" }, "schematics": "./schematics/collection.json", diff --git a/packages/angular/ssr/test/BUILD.bazel b/packages/angular/ssr/test/BUILD.bazel index abc2a4eefdb1..75adb017fb96 100644 --- a/packages/angular/ssr/test/BUILD.bazel +++ b/packages/angular/ssr/test/BUILD.bazel @@ -13,7 +13,7 @@ ts_library( testonly = True, srcs = glob( include = ["**/*_spec.ts"], - exclude = ESM_TESTS, + exclude = ESM_TESTS + ["npm_package/**"], ), deps = [ "//packages/angular/ssr", diff --git a/packages/angular/ssr/test/npm_package/BUILD.bazel b/packages/angular/ssr/test/npm_package/BUILD.bazel new file mode 100644 index 000000000000..bd8f302e5019 --- /dev/null +++ b/packages/angular/ssr/test/npm_package/BUILD.bazel @@ -0,0 +1,35 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary") +load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test") +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "unit_test_lib", + testonly = True, + srcs = glob(["**/*.ts"]), + deps = [ + "@npm//@bazel/runfiles", + "@npm//@types/diff", + ], +) + +jasmine_node_test( + name = "test", + srcs = [":unit_test_lib"], + data = [ + "THIRD_PARTY_LICENSES.txt.golden", + "//packages/angular/ssr:npm_package", + "@npm//diff", + ], +) + +nodejs_binary( + name = "test.accept", + testonly = True, + data = [ + "THIRD_PARTY_LICENSES.txt.golden", + ":unit_test_lib", + "//packages/angular/ssr:npm_package", + "@npm//diff", + ], + entry_point = ":update-package-golden.ts", +) diff --git a/packages/angular/ssr/test/npm_package/THIRD_PARTY_LICENSES.txt.golden b/packages/angular/ssr/test/npm_package/THIRD_PARTY_LICENSES.txt.golden new file mode 100644 index 000000000000..067f2370fd4c --- /dev/null +++ b/packages/angular/ssr/test/npm_package/THIRD_PARTY_LICENSES.txt.golden @@ -0,0 +1,350 @@ + +-------------------------------------------------------------------------------- +Package: ansi-styles +License: "MIT" + + +-------------------------------------------------------------------------------- +Package: boolbase +License: "ISC" + + +-------------------------------------------------------------------------------- +Package: chalk +License: "MIT" + + +-------------------------------------------------------------------------------- +Package: color-convert +License: "MIT" + +Copyright (c) 2011-2016 Heather Arthur + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +Package: color-name +License: "MIT" + +The MIT License (MIT) +Copyright (c) 2015 Dmitry Ivanov + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +Package: critters +License: "Apache-2.0" + + +-------------------------------------------------------------------------------- +Package: css-select +License: "BSD-2-Clause" + +Copyright (c) Felix Böhm +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +Package: css-what +License: "BSD-2-Clause" + +Copyright (c) Felix Böhm +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +Package: dom-serializer +License: "MIT" + +License + +(The MIT License) + +Copyright (c) 2014 The cheeriojs contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +Package: domelementtype +License: "BSD-2-Clause" + +Copyright (c) Felix Böhm +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +Package: domhandler +License: "BSD-2-Clause" + +Copyright (c) Felix Böhm +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +Package: domutils +License: "BSD-2-Clause" + +Copyright (c) Felix Böhm +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +Package: entities +License: "BSD-2-Clause" + +Copyright (c) Felix Böhm +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +Package: htmlparser2 +License: "MIT" + +Copyright 2010, 2011, Chris Winberry . All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. +-------------------------------------------------------------------------------- +Package: nanoid +License: "MIT" + +The MIT License (MIT) + +Copyright 2017 Andrey Sitnik + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +Package: nth-check +License: "BSD-2-Clause" + +Copyright (c) Felix Böhm +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +Package: pathe +License: "MIT" + +MIT License + +Copyright (c) Pooya Parsa - Daniel Roe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- + +Copyright Joyent, Inc. and other Node contributors. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +Package: picocolors +License: "ISC" + +ISC License + +Copyright (c) 2021 Alexey Raspopov, Kostiantyn Denysov, Anton Verinov + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-------------------------------------------------------------------------------- +Package: postcss +License: "MIT" + +The MIT License (MIT) + +Copyright 2013 Andrey Sitnik + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +Package: postcss-media-query-parser +License: "MIT" + + +-------------------------------------------------------------------------------- +Package: supports-color +License: "MIT" + + +-------------------------------------------------------------------------------- +Package: unenv +License: "MIT" + +MIT License + +Copyright (c) Pooya Parsa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- diff --git a/packages/angular/ssr/test/npm_package/package_spec.ts b/packages/angular/ssr/test/npm_package/package_spec.ts new file mode 100644 index 000000000000..2aadbe6540cc --- /dev/null +++ b/packages/angular/ssr/test/npm_package/package_spec.ts @@ -0,0 +1,62 @@ +/** + * @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.dev/license + */ + +import { createPatch } from 'diff'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + ANGULAR_SSR_PACKAGE_PATH, + CRITTERS_ACTUAL_LICENSE_FILE_PATH, + CRITTERS_GOLDEN_LICENSE_FILE_PATH, +} from './utils'; + +describe('NPM Package Tests', () => { + it('should not include the contents of third_party/critters/index.js in the FESM bundle', async () => { + const fesmFilePath = join(ANGULAR_SSR_PACKAGE_PATH, 'fesm2022/ssr.mjs'); + const fesmContent = await readFile(fesmFilePath, 'utf-8'); + expect(fesmContent).toContain(`import Critters from '../third_party/critters/index.js'`); + }); + + describe('third_party/critters/THIRD_PARTY_LICENSES.txt', () => { + it('should exist', () => { + expect(existsSync(CRITTERS_ACTUAL_LICENSE_FILE_PATH)).toBe(true); + }); + + it('should match the expected golden file', async () => { + const [expectedContent, actualContent] = await Promise.all([ + readFile(CRITTERS_GOLDEN_LICENSE_FILE_PATH, 'utf-8'), + readFile(CRITTERS_ACTUAL_LICENSE_FILE_PATH, 'utf-8'), + ]); + + if (expectedContent.trim() === actualContent.trim()) { + return; + } + + const patch = createPatch( + CRITTERS_GOLDEN_LICENSE_FILE_PATH, + expectedContent, + actualContent, + 'Golden License File', + 'Current License File', + { context: 5 }, + ); + + const errorMessage = `The content of the actual license file differs from the expected golden reference. + Diff: + ${patch} + To accept the new golden file, execute: + yarn bazel run ${process.env['BAZEL_TARGET']}.accept + `; + + const error = new Error(errorMessage); + error.stack = error.stack?.replace(` Diff:\n ${patch}`, ''); + throw error; + }); + }); +}); diff --git a/packages/angular/ssr/test/npm_package/update-package-golden.ts b/packages/angular/ssr/test/npm_package/update-package-golden.ts new file mode 100644 index 000000000000..0754d4d89249 --- /dev/null +++ b/packages/angular/ssr/test/npm_package/update-package-golden.ts @@ -0,0 +1,15 @@ +/** + * @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.dev/license + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { CRITTERS_ACTUAL_LICENSE_FILE_PATH, CRITTERS_GOLDEN_LICENSE_FILE_PATH } from './utils'; + +/** + * Updates the golden reference license file. + */ +writeFileSync(CRITTERS_GOLDEN_LICENSE_FILE_PATH, readFileSync(CRITTERS_ACTUAL_LICENSE_FILE_PATH)); diff --git a/packages/angular/ssr/test/npm_package/utils.ts b/packages/angular/ssr/test/npm_package/utils.ts new file mode 100644 index 000000000000..fc1a3adfc518 --- /dev/null +++ b/packages/angular/ssr/test/npm_package/utils.ts @@ -0,0 +1,32 @@ +/** + * @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.dev/license + */ + +import { runfiles } from '@bazel/runfiles'; +import { dirname, join } from 'node:path'; + +/** + * Resolve paths for the Critters license file and the golden reference file. + */ +export const ANGULAR_SSR_PACKAGE_PATH = dirname( + runfiles.resolve('angular_cli/packages/angular/ssr/npm_package/package.json'), +); + +/** + * Path to the actual license file for the Critters library. + * This file is located in the `third_party/critters` directory of the Angular CLI npm package. + */ +export const CRITTERS_ACTUAL_LICENSE_FILE_PATH = join( + ANGULAR_SSR_PACKAGE_PATH, + 'third_party/critters/THIRD_PARTY_LICENSES.txt', +); + +/** + * Path to the golden reference license file for the Critters library. + * This file is used as a reference for comparison and is located in the same directory as this script. + */ +export const CRITTERS_GOLDEN_LICENSE_FILE_PATH = join(__dirname, 'THIRD_PARTY_LICENSES.txt.golden'); diff --git a/packages/angular/ssr/third_party/critters/esbuild.config.mjs b/packages/angular/ssr/third_party/critters/esbuild.config.mjs index b50e9b61524a..31b367d33965 100644 --- a/packages/angular/ssr/third_party/critters/esbuild.config.mjs +++ b/packages/angular/ssr/third_party/critters/esbuild.config.mjs @@ -87,10 +87,9 @@ const EXTRACTION_FILE_SEPARATOR = '-'.repeat(80) + '\n'; * @returns A string containing the content of the output licenses file. */ async function extractLicenses(metafile) { - let extractedLicenseContent = `${EXTRACTION_FILE_HEADER}\n${EXTRACTION_FILE_SEPARATOR}`; - const seenPaths = new Set(); const seenPackages = new Set(); + const extractedLicenses = {}; for (const entry of Object.values(metafile.outputs)) { for (const [inputPath, { bytesInOutput }] of Object.entries(entry.inputs)) { @@ -186,12 +185,20 @@ async function extractLicenses(metafile) { } // Generate the package's license entry in the output content - extractedLicenseContent += `Package: ${packageJson.name}\n`; - extractedLicenseContent += `License: ${JSON.stringify(packageJson.license, null, 2)}\n`; - extractedLicenseContent += `\n${licenseText}\n`; + let extractedLicenseContent = `Package: ${packageJson.name}\n`; + extractedLicenseContent += `License: ${JSON.stringify(packageJson.license, undefined, 2)}\n`; + extractedLicenseContent += `\n${licenseText.trim().replace(/\r?\n/g, '\n')}\n`; extractedLicenseContent += EXTRACTION_FILE_SEPARATOR; + + extractedLicenses[packageJson.name] = extractedLicenseContent; } } - return extractedLicenseContent; + // Get the keys of the object and sort them and etract and join the values corresponding to the sorted keys + const joinedLicenseContent = Object.keys(extractedLicenses) + .sort() + .map((pkgName) => extractedLicenses[pkgName]) + .join(''); + + return `${EXTRACTION_FILE_HEADER}\n${EXTRACTION_FILE_SEPARATOR}${joinedLicenseContent}`; } diff --git a/packages/angular/ssr/third_party/critters/index.d.ts b/packages/angular/ssr/third_party/critters/index.d.ts index c53119f0a037..2d7efd309d20 100644 --- a/packages/angular/ssr/third_party/critters/index.d.ts +++ b/packages/angular/ssr/third_party/critters/index.d.ts @@ -6,4 +6,4 @@ * found in the LICENSE file at https://angular.io/license */ -export {default} from 'critters'; +export { default } from 'critters'; diff --git a/yarn.lock b/yarn.lock index a4ee0a2e6537..4e6d02c37ab2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -659,6 +659,7 @@ __metadata: "@bazel/buildifier": "npm:7.1.2" "@bazel/concatjs": "patch:@bazel/concatjs@npm%3A5.8.1#~/.yarn/patches/@bazel-concatjs-npm-5.8.1-1bf81df846.patch" "@bazel/jasmine": "patch:@bazel/jasmine@npm%3A5.8.1#~/.yarn/patches/@bazel-jasmine-npm-5.8.1-3370fee155.patch" + "@bazel/runfiles": "npm:^5.8.1" "@discoveryjs/json-ext": "npm:0.6.1" "@inquirer/confirm": "npm:3.1.22" "@inquirer/prompts": "npm:5.3.8" @@ -667,6 +668,7 @@ __metadata: "@rollup/plugin-node-resolve": "npm:^13.0.5" "@types/babel__core": "npm:7.20.5" "@types/browser-sync": "npm:^2.27.0" + "@types/diff": "npm:^5.2.1" "@types/express": "npm:^4.16.0" "@types/http-proxy": "npm:^1.17.4" "@types/ini": "npm:^4.0.0" @@ -706,6 +708,7 @@ __metadata: critters: "npm:0.0.24" css-loader: "npm:7.1.2" debug: "npm:^4.1.1" + diff: "npm:^5.2.0" esbuild: "npm:0.23.1" esbuild-wasm: "npm:0.23.1" eslint: "npm:8.57.0" @@ -965,6 +968,8 @@ __metadata: "@angular/platform-browser": "npm:19.0.0-next.1" "@angular/platform-server": "npm:19.0.0-next.1" "@angular/router": "npm:19.0.0-next.1" + "@bazel/runfiles": "npm:^5.8.1" + diff: "npm:^5.2.0" tslib: "npm:^2.3.0" zone.js: "npm:^0.15.0" peerDependencies: @@ -4866,6 +4871,13 @@ __metadata: languageName: node linkType: hard +"@types/diff@npm:^5.2.1": + version: 5.2.1 + resolution: "@types/diff@npm:5.2.1" + checksum: 10c0/62dcab32197ac67f212939cdd79aa3953327a482bec55c6a38ad9de8a0662a9f920b59504609a322fc242593bd9afb3d2704702f4bc98087a13171234b952361 + languageName: node + linkType: hard + "@types/estree@npm:*, @types/estree@npm:1.0.5, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.5": version: 1.0.5 resolution: "@types/estree@npm:1.0.5" @@ -8549,7 +8561,7 @@ __metadata: languageName: node linkType: hard -"diff@npm:^5.0.0, diff@npm:^5.1.0": +"diff@npm:^5.0.0, diff@npm:^5.1.0, diff@npm:^5.2.0": version: 5.2.0 resolution: "diff@npm:5.2.0" checksum: 10c0/aed0941f206fe261ecb258dc8d0ceea8abbde3ace5827518ff8d302f0fc9cc81ce116c4d8f379151171336caf0516b79e01abdc1ed1201b6440d895a66689eb4 From 1c70f470dd59c1732152a9a7a5791a3a751bcf7d Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Fri, 23 Aug 2024 21:16:36 +0000 Subject: [PATCH 4/4] build: use Bazel `diff_test` to compare file differences Leverage the built-in `diff_test` feature from Bazel to check for file changes. For details, see: https://github.com/bazelbuild/bazel-skylib/blob/main/docs/diff_test_doc.md --- package.json | 2 - packages/angular/ssr/package.json | 1 - .../angular/ssr/test/npm_package/BUILD.bazel | 47 +++++++++++---- .../ssr/test/npm_package/package_spec.ts | 60 +++++++------------ .../test/npm_package/update-package-golden.ts | 15 ----- .../angular/ssr/test/npm_package/utils.ts | 32 ---------- yarn.lock | 12 +--- 7 files changed, 59 insertions(+), 110 deletions(-) delete mode 100644 packages/angular/ssr/test/npm_package/update-package-golden.ts delete mode 100644 packages/angular/ssr/test/npm_package/utils.ts diff --git a/package.json b/package.json index 8974f67de22a..5e380d55cd2a 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,6 @@ "@rollup/plugin-node-resolve": "^13.0.5", "@types/babel__core": "7.20.5", "@types/browser-sync": "^2.27.0", - "@types/diff": "^5.2.1", "@types/express": "^4.16.0", "@types/http-proxy": "^1.17.4", "@types/ini": "^4.0.0", @@ -132,7 +131,6 @@ "critters": "0.0.24", "css-loader": "7.1.2", "debug": "^4.1.1", - "diff": "^5.2.0", "esbuild": "0.23.1", "esbuild-wasm": "0.23.1", "eslint": "8.57.0", diff --git a/packages/angular/ssr/package.json b/packages/angular/ssr/package.json index 811944267e97..747189433350 100644 --- a/packages/angular/ssr/package.json +++ b/packages/angular/ssr/package.json @@ -29,7 +29,6 @@ "@angular/platform-server": "19.0.0-next.1", "@angular/router": "19.0.0-next.1", "@bazel/runfiles": "^5.8.1", - "diff": "^5.2.0", "zone.js": "^0.15.0" }, "schematics": "./schematics/collection.json", diff --git a/packages/angular/ssr/test/npm_package/BUILD.bazel b/packages/angular/ssr/test/npm_package/BUILD.bazel index bd8f302e5019..48a239493667 100644 --- a/packages/angular/ssr/test/npm_package/BUILD.bazel +++ b/packages/angular/ssr/test/npm_package/BUILD.bazel @@ -1,4 +1,5 @@ -load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary") +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") +load("@bazel_skylib//rules:write_file.bzl", "write_file") load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test") load("//tools:defaults.bzl", "ts_library") @@ -8,7 +9,6 @@ ts_library( srcs = glob(["**/*.ts"]), deps = [ "@npm//@bazel/runfiles", - "@npm//@types/diff", ], ) @@ -16,20 +16,43 @@ jasmine_node_test( name = "test", srcs = [":unit_test_lib"], data = [ - "THIRD_PARTY_LICENSES.txt.golden", "//packages/angular/ssr:npm_package", - "@npm//diff", ], ) -nodejs_binary( - name = "test.accept", - testonly = True, - data = [ - "THIRD_PARTY_LICENSES.txt.golden", - ":unit_test_lib", +genrule( + name = "critters_license_file", + srcs = [ "//packages/angular/ssr:npm_package", - "@npm//diff", ], - entry_point = ":update-package-golden.ts", + outs = [ + "THIRD_PARTY_LICENSES.txt", + ], + cmd = """ + cp $(location //packages/angular/ssr:npm_package)/third_party/critters/THIRD_PARTY_LICENSES.txt $(location :THIRD_PARTY_LICENSES.txt) + """, +) + +diff_test( + name = "critters_license_test", + failure_message = """ + + To accept the new golden file, execute: + yarn bazel run //packages/angular/ssr/test/npm_package:critters_license_test.accept + """, + file1 = ":THIRD_PARTY_LICENSES.txt.golden", + file2 = ":critters_license_file", +) + +write_file( + name = "critters_license_test.accept", + out = "critters_license_file_accept.sh", + content = + [ + "#!/usr/bin/env bash", + "cd ${BUILD_WORKSPACE_DIRECTORY}", + "yarn bazel build //packages/angular/ssr:npm_package", + "cp -fv dist/bin/packages/angular/ssr/npm_package/third_party/critters/THIRD_PARTY_LICENSES.txt packages/angular/ssr/test/npm_package/THIRD_PARTY_LICENSES.txt.golden", + ], + is_executable = True, ) diff --git a/packages/angular/ssr/test/npm_package/package_spec.ts b/packages/angular/ssr/test/npm_package/package_spec.ts index 2aadbe6540cc..c4f9fad7f402 100644 --- a/packages/angular/ssr/test/npm_package/package_spec.ts +++ b/packages/angular/ssr/test/npm_package/package_spec.ts @@ -6,15 +6,32 @@ * found in the LICENSE file at https://angular.dev/license */ -import { createPatch } from 'diff'; +import { runfiles } from '@bazel/runfiles'; import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { +import { dirname, join } from 'node:path'; + +/** + * Resolve paths for the Critters license file and the golden reference file. + */ +const ANGULAR_SSR_PACKAGE_PATH = dirname( + runfiles.resolve('angular_cli/packages/angular/ssr/npm_package/package.json'), +); + +/** + * Path to the actual license file for the Critters library. + * This file is located in the `third_party/critters` directory of the Angular CLI npm package. + */ +const CRITTERS_ACTUAL_LICENSE_FILE_PATH = join( ANGULAR_SSR_PACKAGE_PATH, - CRITTERS_ACTUAL_LICENSE_FILE_PATH, - CRITTERS_GOLDEN_LICENSE_FILE_PATH, -} from './utils'; + 'third_party/critters/THIRD_PARTY_LICENSES.txt', +); + +/** + * Path to the golden reference license file for the Critters library. + * This file is used as a reference for comparison and is located in the same directory as this script. + */ +const CRITTERS_GOLDEN_LICENSE_FILE_PATH = join(__dirname, 'THIRD_PARTY_LICENSES.txt.golden'); describe('NPM Package Tests', () => { it('should not include the contents of third_party/critters/index.js in the FESM bundle', async () => { @@ -27,36 +44,5 @@ describe('NPM Package Tests', () => { it('should exist', () => { expect(existsSync(CRITTERS_ACTUAL_LICENSE_FILE_PATH)).toBe(true); }); - - it('should match the expected golden file', async () => { - const [expectedContent, actualContent] = await Promise.all([ - readFile(CRITTERS_GOLDEN_LICENSE_FILE_PATH, 'utf-8'), - readFile(CRITTERS_ACTUAL_LICENSE_FILE_PATH, 'utf-8'), - ]); - - if (expectedContent.trim() === actualContent.trim()) { - return; - } - - const patch = createPatch( - CRITTERS_GOLDEN_LICENSE_FILE_PATH, - expectedContent, - actualContent, - 'Golden License File', - 'Current License File', - { context: 5 }, - ); - - const errorMessage = `The content of the actual license file differs from the expected golden reference. - Diff: - ${patch} - To accept the new golden file, execute: - yarn bazel run ${process.env['BAZEL_TARGET']}.accept - `; - - const error = new Error(errorMessage); - error.stack = error.stack?.replace(` Diff:\n ${patch}`, ''); - throw error; - }); }); }); diff --git a/packages/angular/ssr/test/npm_package/update-package-golden.ts b/packages/angular/ssr/test/npm_package/update-package-golden.ts deleted file mode 100644 index 0754d4d89249..000000000000 --- a/packages/angular/ssr/test/npm_package/update-package-golden.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @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.dev/license - */ - -import { readFileSync, writeFileSync } from 'node:fs'; -import { CRITTERS_ACTUAL_LICENSE_FILE_PATH, CRITTERS_GOLDEN_LICENSE_FILE_PATH } from './utils'; - -/** - * Updates the golden reference license file. - */ -writeFileSync(CRITTERS_GOLDEN_LICENSE_FILE_PATH, readFileSync(CRITTERS_ACTUAL_LICENSE_FILE_PATH)); diff --git a/packages/angular/ssr/test/npm_package/utils.ts b/packages/angular/ssr/test/npm_package/utils.ts deleted file mode 100644 index fc1a3adfc518..000000000000 --- a/packages/angular/ssr/test/npm_package/utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @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.dev/license - */ - -import { runfiles } from '@bazel/runfiles'; -import { dirname, join } from 'node:path'; - -/** - * Resolve paths for the Critters license file and the golden reference file. - */ -export const ANGULAR_SSR_PACKAGE_PATH = dirname( - runfiles.resolve('angular_cli/packages/angular/ssr/npm_package/package.json'), -); - -/** - * Path to the actual license file for the Critters library. - * This file is located in the `third_party/critters` directory of the Angular CLI npm package. - */ -export const CRITTERS_ACTUAL_LICENSE_FILE_PATH = join( - ANGULAR_SSR_PACKAGE_PATH, - 'third_party/critters/THIRD_PARTY_LICENSES.txt', -); - -/** - * Path to the golden reference license file for the Critters library. - * This file is used as a reference for comparison and is located in the same directory as this script. - */ -export const CRITTERS_GOLDEN_LICENSE_FILE_PATH = join(__dirname, 'THIRD_PARTY_LICENSES.txt.golden'); diff --git a/yarn.lock b/yarn.lock index 4e6d02c37ab2..2f7f0984ca0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -668,7 +668,6 @@ __metadata: "@rollup/plugin-node-resolve": "npm:^13.0.5" "@types/babel__core": "npm:7.20.5" "@types/browser-sync": "npm:^2.27.0" - "@types/diff": "npm:^5.2.1" "@types/express": "npm:^4.16.0" "@types/http-proxy": "npm:^1.17.4" "@types/ini": "npm:^4.0.0" @@ -708,7 +707,6 @@ __metadata: critters: "npm:0.0.24" css-loader: "npm:7.1.2" debug: "npm:^4.1.1" - diff: "npm:^5.2.0" esbuild: "npm:0.23.1" esbuild-wasm: "npm:0.23.1" eslint: "npm:8.57.0" @@ -969,7 +967,6 @@ __metadata: "@angular/platform-server": "npm:19.0.0-next.1" "@angular/router": "npm:19.0.0-next.1" "@bazel/runfiles": "npm:^5.8.1" - diff: "npm:^5.2.0" tslib: "npm:^2.3.0" zone.js: "npm:^0.15.0" peerDependencies: @@ -4871,13 +4868,6 @@ __metadata: languageName: node linkType: hard -"@types/diff@npm:^5.2.1": - version: 5.2.1 - resolution: "@types/diff@npm:5.2.1" - checksum: 10c0/62dcab32197ac67f212939cdd79aa3953327a482bec55c6a38ad9de8a0662a9f920b59504609a322fc242593bd9afb3d2704702f4bc98087a13171234b952361 - languageName: node - linkType: hard - "@types/estree@npm:*, @types/estree@npm:1.0.5, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.5": version: 1.0.5 resolution: "@types/estree@npm:1.0.5" @@ -8561,7 +8551,7 @@ __metadata: languageName: node linkType: hard -"diff@npm:^5.0.0, diff@npm:^5.1.0, diff@npm:^5.2.0": +"diff@npm:^5.0.0, diff@npm:^5.1.0": version: 5.2.0 resolution: "diff@npm:5.2.0" checksum: 10c0/aed0941f206fe261ecb258dc8d0ceea8abbde3ace5827518ff8d302f0fc9cc81ce116c4d8f379151171336caf0516b79e01abdc1ed1201b6440d895a66689eb4