Skip to content

Commit

Permalink
refactor(@angular/ssr): bundle Critters
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
alan-agius4 committed Aug 20, 2024
1 parent 615ee6c commit 2001375
Show file tree
Hide file tree
Showing 16 changed files with 630 additions and 288 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/angular/build/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ ts_library(
"@npm//browserslist",
"@npm//critters",
"@npm//esbuild",
"@npm//esbuild-wasm",
"@npm//fast-glob",
"@npm//https-proxy-agent",
"@npm//listr2",
Expand Down
19 changes: 19 additions & 0 deletions packages/angular/build/src/typings.d.ts
Original file line number Diff line number Diff line change
@@ -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';
}
11 changes: 9 additions & 2 deletions packages/angular/ssr/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
)

Expand All @@ -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",
Expand Down
1 change: 0 additions & 1 deletion packages/angular/ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
"save": "dependencies"
},
"dependencies": {
"critters": "0.0.24",
"tslib": "^2.3.0"
},
"peerDependencies": {
Expand Down
7 changes: 3 additions & 4 deletions packages/angular/ssr/src/common-engine/common-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -59,9 +60,7 @@ export class CommonEngine {
private readonly pageIsSSG = new Map<string, boolean>();

constructor(private options?: CommonEngineOptions) {
this.inlineCriticalCssProcessor = new InlineCriticalCssProcessor({
minify: false,
});
this.inlineCriticalCssProcessor = new InlineCriticalCssProcessor();
}

/**
Expand Down
253 changes: 16 additions & 237 deletions packages/angular/ssr/src/common-engine/inline-css-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;

class CrittersExtended extends Critters {
readonly warnings: string[] = [];
readonly errors: string[] = [];
private initialEmbedLinkedStylesheet: EmbedLinkedStylesheetFn;
private addedCspScriptsDocuments = new WeakSet<PartialDocument>();
private documentNonces = new WeakMap<PartialDocument, string | null>();

// Inherited from `Critters`, but not exposed in the typings.
protected declare embedLinkedStylesheet: EmbedLinkedStylesheetFn;

constructor(
readonly optionsExtended: InlineCriticalCssProcessorOptions & InlineCriticalCssProcessOptions,
private readonly resourceCache: Map<string, string>,
) {
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<string> {
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 `<head>` 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<string, string>();

constructor(protected readonly options: InlineCriticalCssProcessorOptions) {}

async process(
html: string,
options: InlineCriticalCssProcessOptions,
): Promise<InlineCriticalCssResult> {
const critters = new CrittersExtended({ ...this.options, ...options }, this.resourceCache);
const critters = new CrittersExtended(options);
critters.readFile = async (path: string): Promise<string> => {
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 {
Expand Down
Loading

0 comments on commit 2001375

Please sign in to comment.