diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index f2a5e3bebd2d..289f59f44e10 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -13,9 +13,17 @@ import { getAngularAppManifest } from './manifest'; import { RenderMode } from './routes/route-config'; import { ServerRouter } from './routes/router'; import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens'; +import { sha256 } from './utils/crypto'; import { InlineCriticalCssProcessor } from './utils/inline-critical-css'; +import { LRUCache } from './utils/lru-cache'; import { AngularBootstrap, renderAngular } from './utils/ng'; +/** + * Maximum number of critical CSS entries the cache can store. + * This value determines the capacity of the LRU (Least Recently Used) cache, which stores critical CSS for pages. + */ +const MAX_INLINE_CSS_CACHE_ENTRIES = 50; + /** * A mapping of `RenderMode` enum values to corresponding string representations. * @@ -72,6 +80,15 @@ export class AngularServerApp { */ private boostrap: AngularBootstrap | undefined; + /** + * Cache for storing critical CSS for pages. + * Stores a maximum of MAX_INLINE_CSS_CACHE_ENTRIES entries. + * + * Uses an LRU (Least Recently Used) eviction policy, meaning that when the cache is full, + * the least recently accessed page's critical CSS will be removed to make space for new entries. + */ + private readonly criticalCssLRUCache = new LRUCache(MAX_INLINE_CSS_CACHE_ENTRIES); + /** * Renders a response for the given HTTP request using the server application. * @@ -237,7 +254,19 @@ export class AngularServerApp { return this.assets.getServerAsset(fileName); }); - html = await this.inlineCriticalCssProcessor.process(html); + if (isSsrMode) { + // Only cache if we are running in SSR Mode. + const cacheKey = await sha256(html); + let htmlWithCriticalCss = this.criticalCssLRUCache.get(cacheKey); + if (htmlWithCriticalCss === undefined) { + htmlWithCriticalCss = await this.inlineCriticalCssProcessor.process(html); + this.criticalCssLRUCache.put(cacheKey, htmlWithCriticalCss); + } + + html = htmlWithCriticalCss; + } else { + html = await this.inlineCriticalCssProcessor.process(html); + } } return new Response(html, responseInit); diff --git a/packages/angular/ssr/src/utils/crypto.ts b/packages/angular/ssr/src/utils/crypto.ts new file mode 100644 index 000000000000..c17dba4e6ace --- /dev/null +++ b/packages/angular/ssr/src/utils/crypto.ts @@ -0,0 +1,35 @@ +/** + * @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 + */ + +/** + * Generates a SHA-256 hash of the provided string. + * + * @param data - The input string to be hashed. + * @returns A promise that resolves to the SHA-256 hash of the input, + * represented as a hexadecimal string. + */ +export async function sha256(data: string): Promise { + if (typeof crypto === 'undefined') { + // TODO(alanagius): remove once Node.js version 18 is no longer supported. + throw new Error( + `The global 'crypto' module is unavailable. ` + + `If you are running on Node.js, please ensure you are using version 20 or later, ` + + `which includes built-in support for the Web Crypto module.`, + ); + } + + const encodedData = new TextEncoder().encode(data); + const hashBuffer = await crypto.subtle.digest('SHA-256', encodedData); + const hashParts: string[] = []; + + for (const h of new Uint8Array(hashBuffer)) { + hashParts.push(h.toString(16).padStart(2, '0')); + } + + return hashParts.join(''); +} diff --git a/packages/angular/ssr/src/utils/lru-cache.ts b/packages/angular/ssr/src/utils/lru-cache.ts new file mode 100644 index 000000000000..89824e5e1226 --- /dev/null +++ b/packages/angular/ssr/src/utils/lru-cache.ts @@ -0,0 +1,162 @@ +/** + * @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 + */ + +/** + * Represents a node in the doubly linked list. + */ +interface Node { + key: Key; + value: Value; + prev: Node | undefined; + next: Node | undefined; +} + +/** + * A Least Recently Used (LRU) cache implementation. + * + * This cache stores a fixed number of key-value pairs, and when the cache exceeds its capacity, + * the least recently accessed items are evicted. + * + * @template Key - The type of the cache keys. + * @template Value - The type of the cache values. + */ +export class LRUCache { + /** + * The maximum number of items the cache can hold. + */ + capacity: number; + + /** + * Internal storage for the cache, mapping keys to their associated nodes in the linked list. + */ + private readonly cache = new Map>(); + + /** + * Head of the doubly linked list, representing the most recently used item. + */ + private head: Node | undefined; + + /** + * Tail of the doubly linked list, representing the least recently used item. + */ + private tail: Node | undefined; + + /** + * Creates a new LRUCache instance. + * @param capacity The maximum number of items the cache can hold. + */ + constructor(capacity: number) { + this.capacity = capacity; + } + + /** + * Gets the value associated with the given key. + * @param key The key to retrieve the value for. + * @returns The value associated with the key, or undefined if the key is not found. + */ + get(key: Key): Value | undefined { + const node = this.cache.get(key); + if (node) { + this.moveToHead(node); + + return node.value; + } + + return undefined; + } + + /** + * Puts a key-value pair into the cache. + * If the key already exists, the value is updated. + * If the cache is full, the least recently used item is evicted. + * @param key The key to insert or update. + * @param value The value to associate with the key. + */ + put(key: Key, value: Value): void { + const cachedNode = this.cache.get(key); + if (cachedNode) { + // Update existing node + cachedNode.value = value; + this.moveToHead(cachedNode); + + return; + } + + // Create a new node + const newNode: Node = { key, value, prev: undefined, next: undefined }; + this.cache.set(key, newNode); + this.addToHead(newNode); + + if (this.cache.size > this.capacity) { + // Evict the LRU item + const tail = this.removeTail(); + if (tail) { + this.cache.delete(tail.key); + } + } + } + + /** + * Adds a node to the head of the linked list. + * @param node The node to add. + */ + private addToHead(node: Node): void { + node.next = this.head; + node.prev = undefined; + + if (this.head) { + this.head.prev = node; + } + + this.head = node; + + if (!this.tail) { + this.tail = node; + } + } + + /** + * Removes a node from the linked list. + * @param node The node to remove. + */ + private removeNode(node: Node): void { + if (node.prev) { + node.prev.next = node.next; + } else { + this.head = node.next; + } + + if (node.next) { + node.next.prev = node.prev; + } else { + this.tail = node.prev; + } + } + + /** + * Moves a node to the head of the linked list. + * @param node The node to move. + */ + private moveToHead(node: Node): void { + this.removeNode(node); + this.addToHead(node); + } + + /** + * Removes the tail node from the linked list. + * @returns The removed tail node, or undefined if the list is empty. + */ + private removeTail(): Node | undefined { + const node = this.tail; + if (node) { + this.removeNode(node); + } + + return node; + } +} diff --git a/packages/angular/ssr/test/utils/lru_cache_spec.ts b/packages/angular/ssr/test/utils/lru_cache_spec.ts new file mode 100644 index 000000000000..5238f6b1de5b --- /dev/null +++ b/packages/angular/ssr/test/utils/lru_cache_spec.ts @@ -0,0 +1,68 @@ +/** + * @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 { LRUCache } from '../../src/utils/lru-cache'; + +describe('LRUCache', () => { + let cache: LRUCache; + + beforeEach(() => { + cache = new LRUCache(3); + }); + + it('should create a cache with the correct capacity', () => { + expect(cache.capacity).toBe(3); // Test internal capacity + }); + + it('should store and retrieve a key-value pair', () => { + cache.put('a', 1); + expect(cache.get('a')).toBe(1); + }); + + it('should return undefined for non-existent keys', () => { + expect(cache.get('nonExistentKey')).toBeUndefined(); + }); + + it('should remove the least recently used item when capacity is exceeded', () => { + cache.put('a', 1); + cache.put('b', 2); + cache.put('c', 3); + + // Cache is full now, adding another item should evict the least recently used ('a') + cache.put('d', 4); + + expect(cache.get('a')).toBeUndefined(); // 'a' should be evicted + expect(cache.get('b')).toBe(2); // 'b', 'c', 'd' should remain + expect(cache.get('c')).toBe(3); + expect(cache.get('d')).toBe(4); + }); + + it('should update the value if the key already exists', () => { + cache.put('a', 1); + cache.put('a', 10); // Update the value of 'a' + + expect(cache.get('a')).toBe(10); // 'a' should have the updated value + }); + + it('should move the accessed key to the most recently used position', () => { + cache.put('a', 1); + cache.put('b', 2); + cache.put('c', 3); + + // Access 'a', it should be moved to the most recently used position + expect(cache.get('a')).toBe(1); + + // Adding 'd' should now evict 'b', since 'a' was just accessed + cache.put('d', 4); + + expect(cache.get('b')).toBeUndefined(); // 'b' should be evicted + expect(cache.get('a')).toBe(1); // 'a' should still be present + expect(cache.get('c')).toBe(3); + expect(cache.get('d')).toBe(4); + }); +}); diff --git a/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n-base-href.ts b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n-base-href.ts index 786978748beb..e06e50b53444 100644 --- a/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n-base-href.ts +++ b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n-base-href.ts @@ -6,6 +6,11 @@ import { setupProjectWithSSRAppEngine, spawnServer } from './setup'; import { langTranslations, setupI18nConfig } from '../i18n/setup'; export default async function () { + if (process.version.startsWith('v18')) { + // This is not supported in Node.js version 18 as global web crypto module is not available. + return; + } + // Setup project await setupI18nConfig(); await setupProjectWithSSRAppEngine(); diff --git a/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n.ts b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n.ts index e06844f3072e..98c041bd65ba 100644 --- a/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n.ts +++ b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n.ts @@ -6,6 +6,11 @@ import { setupProjectWithSSRAppEngine, spawnServer } from './setup'; import { langTranslations, setupI18nConfig } from '../i18n/setup'; export default async function () { + if (process.version.startsWith('v18')) { + // This is not supported in Node.js version 18 as global web crypto module is not available. + return; + } + // Setup project await setupI18nConfig(); await setupProjectWithSSRAppEngine(); diff --git a/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server.ts b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server.ts index f5d38b2a09ce..bf6b36fa1c49 100644 --- a/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server.ts +++ b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server.ts @@ -6,6 +6,11 @@ import { noSilentNg, silentNg } from '../../utils/process'; import { setupProjectWithSSRAppEngine, spawnServer } from './setup'; export default async function () { + if (process.version.startsWith('v18')) { + // This is not supported in Node.js version 18 as global web crypto module is not available. + return; + } + // Setup project await setupProjectWithSSRAppEngine();