From 2001375b57c2ddb1ca98b8bc6a88e8ce15262d31 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Tue, 20 Aug 2024 13:41:19 +0000 Subject: [PATCH] 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 - .../ssr/src/common-engine/common-engine.ts | 7 +- .../src/common-engine/inline-css-processor.ts | 253 ++---------------- packages/angular/ssr/src/render.ts | 44 ++- .../ssr/src/utils/critters-extended.ts | 224 ++++++++++++++++ .../ssr/third_party/critters/BUILD.bazel | 56 ++++ .../third_party/critters/esbuild.config.mjs | 197 ++++++++++++++ .../ssr/third_party/critters/index.d.ts | 9 + scripts/build.mts | 4 +- tools/defaults.bzl | 23 +- tsconfig-build.json | 23 +- yarn.lock | 45 +++- 16 files changed, 630 insertions(+), 288 deletions(-) create mode 100644 packages/angular/build/src/typings.d.ts create mode 100644 packages/angular/ssr/src/utils/critters-extended.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 8b4ef08f76fd..ffd60d4b2284 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.1", 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 59d75e3fb6ad..a66802854387 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/common-engine/common-engine.ts b/packages/angular/ssr/src/common-engine/common-engine.ts index 02a7ba6c55a6..2535161cb877 100644 --- a/packages/angular/ssr/src/common-engine/common-engine.ts +++ b/packages/angular/ssr/src/common-engine/common-engine.ts @@ -11,7 +11,8 @@ 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 { InlineCriticalCssResult } from '../utils/critters-extended'; +import { InlineCriticalCssProcessor } from './inline-css-processor'; import { noopRunMethodAndMeasurePerf, printPerformanceLogs, @@ -59,9 +60,7 @@ export class CommonEngine { private readonly pageIsSSG = new Map(); constructor(private options?: CommonEngineOptions) { - this.inlineCriticalCssProcessor = new InlineCriticalCssProcessor({ - minify: false, - }); + this.inlineCriticalCssProcessor = new InlineCriticalCssProcessor(); } /** 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..0d9aff7bc8a5 100644 --- a/packages/angular/ssr/src/common-engine/inline-css-processor.ts +++ b/packages/angular/ssr/src/common-engine/inline-css-processor.ts @@ -6,252 +6,31 @@ * found in the LICENSE file at https://angular.dev/license */ -import Critters from 'critters'; import { readFile } from 'node:fs/promises'; - -/** - * 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); - } -} +import { + CrittersExtended, + InlineCriticalCssProcessOptions, + InlineCriticalCssResult, +} from '../utils/critters-extended'; export class InlineCriticalCssProcessor { 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 critters = new CrittersExtended(options); + 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 resourceContent; + }; + const content = await critters.process(html); return { diff --git a/packages/angular/ssr/src/render.ts b/packages/angular/ssr/src/render.ts index 2298ac7d5403..541d9d25206d 100644 --- a/packages/angular/ssr/src/render.ts +++ b/packages/angular/ssr/src/render.ts @@ -9,8 +9,10 @@ import { StaticProvider, ɵConsole, ɵresetCompiledComponents } from '@angular/core'; import { ɵSERVER_CONTEXT as SERVER_CONTEXT } from '@angular/platform-server'; import type { AngularServerApp } from './app'; +import { ServerAssets } from './assets'; import { Console } from './console'; import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens'; +import { CrittersExtended } from './utils/critters-extended'; import { renderAngular } from './utils/ng'; /** @@ -88,8 +90,42 @@ export async function render( 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. + html = await inlineCriticalCss(app.assets, html); + } + + return new Response(html, responseInit); +} + +/** + * Inlines critical CSS into the rendered HTML by processing the CSS using Critters. + * + * @param assets - The server assets instance used to fetch CSS files. + * @param html - The HTML content in which to inline critical CSS. + * @returns A promise that resolves to the HTML content with inlined critical CSS. + */ +async function inlineCriticalCss(assets: ServerAssets, html: string): Promise { + // Custom file reader for Critters to read CSS assets from the server. + const critters = new CrittersExtended(); + critters.readFile = (path: string) => { + const fileName = path.split('/').pop() ?? path; + + return assets.getServerAsset(fileName); + }; + + const content = await critters.process(html); + if (critters.errors.length) { + // eslint-disable-next-line no-console + console.error(critters.errors.join('\n')); + } + + if (critters.warnings.length) { + // eslint-disable-next-line no-console + console.warn(critters.warnings.join('\n')); + } + + return content; } diff --git a/packages/angular/ssr/src/utils/critters-extended.ts b/packages/angular/ssr/src/utils/critters-extended.ts new file mode 100644 index 000000000000..5f37dbbb7ba3 --- /dev/null +++ b/packages/angular/ssr/src/utils/critters-extended.ts @@ -0,0 +1,224 @@ +/** + * @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'); + +export interface InlineCriticalCssProcessOptions { + outputPath?: 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; + +export 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: InlineCriticalCssProcessOptions = {}) { + super({ + logger: { + warn: (s: string) => this.warnings.push(s), + error: (s: string) => this.errors.push(s), + info: () => {}, + }, + logLevel: 'warn', + path: optionsExtended.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..620dff68eb3d --- /dev/null +++ b/packages/angular/ssr/third_party/critters/BUILD.bazel @@ -0,0 +1,56 @@ +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 = """ + mkdir -p $(@D) + for f in $(locations :bundled_critters); do + # Only process files inside the bundled_critters directory + if [[ "$${f}" == *bundled_critters ]]; then + cp "$${f}/index.js" $(@D)/index.js + cp "$${f}/index.js.map" $(@D)/index.js.map + cp "$${f}/THIRD_PARTY_LICENSES.txt" $(@D)/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/tsconfig-build.json b/tsconfig-build.json index 4b248abd099c..177f584d2deb 100644 --- a/tsconfig-build.json +++ b/tsconfig-build.json @@ -2,26 +2,5 @@ "extends": "./tsconfig.json", "compilerOptions": { "types": ["node"] - }, - "exclude": [ - "packages/angular_devkit/build_angular/src/bazel-babel.d.ts", - "bazel-out/**/*", - "dist/**/*", - "dist-schema/**", - "goldens/**/*", - "**/node_modules/**/*", - "**/third_party/**/*", - "packages/angular_devkit/schematics_cli/blank/*-files/**/*", - "packages/angular_devkit/schematics_cli/schematic/files/**/*", - "packages/angular_devkit/build_angular/src/*/tests/**/*", - "packages/angular_devkit/build_angular/src/builders/*/tests/**/*", - "packages/angular_devkit/build_angular/src/testing/**/*", - "packages/angular_devkit/*/test/**/*", - "packages/schematics/*/*/*files/**/*", - "tests/**/*", - "tools/**/*", - ".ng-dev/**/*", - "**/*_spec.ts", - "scripts/**/*.mts" - ] + } } diff --git a/yarn.lock b/yarn.lock index cb3fc73de76b..9c3524bff1e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -772,6 +772,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.1" @@ -961,7 +962,6 @@ __metadata: "@angular/platform-browser": "npm:19.0.0-next.0" "@angular/platform-server": "npm:19.0.0-next.0" "@angular/router": "npm:19.0.0-next.0" - critters: "npm:0.0.24" tslib: "npm:^2.3.0" zone.js: "npm:^0.14.0" peerDependencies: @@ -7815,6 +7815,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" @@ -8294,6 +8301,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" @@ -12831,7 +12845,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: @@ -13294,6 +13308,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" @@ -14188,6 +14209,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" @@ -17308,6 +17336,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"