diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index b2f0cdfd63f1..d2203baebb43 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -109,6 +109,7 @@ ts_project( ":node_modules/vite", ":node_modules/vitest", ":node_modules/watchpack", + ":node_modules/xxhash-wasm", "//:node_modules/@angular/common", "//:node_modules/@angular/compiler", "//:node_modules/@angular/compiler-cli", diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index 9b1fdce6b518..baa0faf2a35a 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.json @@ -41,7 +41,8 @@ "source-map-support": "0.5.21", "tinyglobby": "0.2.17", "vite": "8.2.0", - "watchpack": "2.5.2" + "watchpack": "2.5.2", + "xxhash-wasm": "1.1.0" }, "optionalDependencies": { "lmdb": "3.5.6" diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts index dbec8d687b9f..07ed300789d2 100644 --- a/packages/angular/build/src/builders/application/build-action.ts +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -15,6 +15,7 @@ import { shutdownSassWorkerPool } from '../../tools/esbuild/stylesheets/sass-lan import { logMessages, withNoProgress, withSpinner } from '../../tools/esbuild/utils'; import { ChangedFiles } from '../../tools/esbuild/watcher'; import { shouldWatchRoot } from '../../utils/environment-options'; +import { initializeHash } from '../../utils/hash'; import { NormalizedCachedOptions } from '../../utils/normalize-cache'; import { toPosixPath } from '../../utils/path'; import { NormalizedApplicationBuildOptions, NormalizedOutputOptions } from './options'; @@ -78,6 +79,8 @@ export async function* runEsBuildBuildAction( incrementalResults, } = options; + await initializeHash(); + const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress; // Initial build diff --git a/packages/angular/build/src/builders/dev-server/vite/server.ts b/packages/angular/build/src/builders/dev-server/vite/server.ts index 868590edab95..b1826383fccb 100644 --- a/packages/angular/build/src/builders/dev-server/vite/server.ts +++ b/packages/angular/build/src/builders/dev-server/vite/server.ts @@ -22,6 +22,7 @@ import { } from '../../../tools/vite/plugins'; import { RolldownLoaderOption, getDepOptimizationConfig } from '../../../tools/vite/utils'; import { loadProxyConfiguration } from '../../../utils'; +import { initializeHash } from '../../../utils/hash'; import { type ApplicationBuilderInternalOptions, JavaScriptTransformer } from '../internal'; import type { NormalizedDevServerOptions } from '../options'; import { DevServerExternalResultMetadata, OutputAssetRecord, OutputFileRecord } from './utils'; @@ -147,6 +148,7 @@ export async function setupServer( indexHtmlTransformer?: (content: string) => Promise, thirdPartySourcemaps = false, ): Promise { + await initializeHash(); const { normalizePath } = (await import('vite' as string)) as typeof Vite; // Path will not exist on disk and only used to provide separate path for Vite requests diff --git a/packages/angular/build/src/builders/unit-test/test-discovery.ts b/packages/angular/build/src/builders/unit-test/test-discovery.ts index 7bad7079dc90..f2fc8c221646 100644 --- a/packages/angular/build/src/builders/unit-test/test-discovery.ts +++ b/packages/angular/build/src/builders/unit-test/test-discovery.ts @@ -6,11 +6,11 @@ * found in the LICENSE file at https://angular.dev/license */ -import { createHash } from 'node:crypto'; import { type PathLike, constants, promises as fs } from 'node:fs'; import os from 'node:os'; import { basename, dirname, extname, isAbsolute, join, relative } from 'node:path'; import { glob, isDynamicPattern } from 'tinyglobby'; +import { calculateHash, initializeHash } from '../../utils/hash'; import { toPosixPath } from '../../utils/path'; /** @@ -41,6 +41,7 @@ export async function findTests( workspaceRoot: string, projectSourceRoot: string, ): Promise { + await initializeHash(); const resolvedTestFiles = new Set(); const dynamicPatterns: string[] = []; @@ -194,7 +195,7 @@ function truncateName(name: string, originalPath: string): string { return name; } - const hash = createHash('sha256').update(originalPath).digest('hex').substring(0, 8); + const hash = calculateHash(originalPath).substring(0, 8); const availableLength = MAX_FILENAME_LENGTH - hash.length - 2; // 2 for '-' separators const prefixLength = Math.floor(availableLength / 2); const suffixLength = availableLength - prefixLength; diff --git a/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts b/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts index dcee1718a976..764924d9552b 100644 --- a/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts +++ b/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts @@ -6,9 +6,14 @@ * found in the LICENSE file at https://angular.dev/license */ +import { initializeHash } from '../../utils/hash'; import { generateNameFromPath, getTestEntrypoints } from './test-discovery'; describe('getTestEntrypoints', () => { + beforeAll(async () => { + await initializeHash(); + }); + const workspaceRoot = '/project'; const projectSourceRoot = '/project/src'; const options = { workspaceRoot, projectSourceRoot }; @@ -81,6 +86,10 @@ describe('getTestEntrypoints', () => { }); describe('generateNameFromPath', () => { + beforeAll(async () => { + await initializeHash(); + }); + const roots = ['/project/src/', '/project/']; it('should generate a dash-cased name from a simple path', () => { @@ -127,7 +136,7 @@ describe('generateNameFromPath', () => { expect(result.length).toBeLessThanOrEqual(128); expect(result).toBe( - 'a-very-long-path-that-definitely-exceeds-the-maximum-allowe-9cf40291-me-in-order-to-trigger-the-truncation-logic-in-the-function', + 'a-very-long-path-that-definitely-exceeds-the-maximum-allowe-4af8113d-me-in-order-to-trigger-the-truncation-logic-in-the-function', ); // eslint-disable-line max-len }); diff --git a/packages/angular/build/src/tools/angular/angular-host.ts b/packages/angular/build/src/tools/angular/angular-host.ts index 874d66fe2b41..22ac345d413e 100644 --- a/packages/angular/build/src/tools/angular/angular-host.ts +++ b/packages/angular/build/src/tools/angular/angular-host.ts @@ -8,9 +8,9 @@ import type * as ng from '@angular/compiler-cli'; import assert from 'node:assert'; -import { createHash } from 'node:crypto'; import nodePath from 'node:path'; import type ts from 'typescript'; +import { calculateHash } from '../../utils/hash'; export type AngularCompilerOptions = ng.CompilerOptions; export type AngularCompilerHost = ng.CompilerHost; @@ -46,7 +46,7 @@ export function ensureSourceFileVersions(program: ts.Program): void { for (const file of files) { if (file.version === undefined) { - file.version = createHash('sha256').update(file.text).digest('hex'); + file.version = calculateHash(file.text); } } @@ -227,7 +227,7 @@ export function createAngularCompilerHost( // For external stylesheets, create a unique identifier and store the mapping let externalId = hostOptions.externalStylesheets.get(resolvedPath); if (externalId === undefined) { - externalId = createHash('sha256').update(resolvedPath).digest('hex'); + externalId = calculateHash(resolvedPath); hostOptions.externalStylesheets.set(resolvedPath, externalId); } diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts index 9e41b7940989..95719bf1e3b2 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts @@ -10,6 +10,7 @@ import type { PartialMessage } from 'esbuild'; import assert from 'node:assert'; import { randomUUID } from 'node:crypto'; import { type MessagePort, receiveMessageOnPort } from 'node:worker_threads'; +import { initializeHash } from '../../../utils/hash'; import { SourceFileCache } from '../../esbuild/angular/source-file-cache'; import { getAndClearCumulativeDurations } from '../../esbuild/profiling'; import type { AngularCompilation, DiagnosticModes } from './angular-compilation'; @@ -33,6 +34,7 @@ let compilation: AngularCompilation | undefined; const sourceFileCache = new SourceFileCache(); export async function initialize(request: InitRequest) { + await initializeHash(); compilation ??= request.jit ? new JitCompilation(request.browserOnlyBuild) : new AotCompilation(request.browserOnlyBuild); diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index 131547f78366..e38b43533790 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -18,10 +18,10 @@ import type { PluginBuild, } from 'esbuild'; import assert from 'node:assert'; -import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; import { maxWorkers, useTypeChecking } from '../../../utils/environment-options'; +import { calculateHash, initializeHash } from '../../../utils/hash'; import { AngularHostOptions } from '../../angular/angular-host'; import { AngularCompilation, DiagnosticModes, NoopCompilation } from '../../angular/compilation'; import { type PersistentCacheStore, createPersistentCacheStore } from '../cache'; @@ -149,6 +149,7 @@ export function createCompilerPlugin( // eslint-disable-next-line max-lines-per-function build.onStart(async () => { + await initializeHash(); angularCompilationContext.markAsInProgress(); const result: OnStartResult = { @@ -205,11 +206,7 @@ export function createCompilerPlugin( // invalid the output and force a full page reload for HMR cases. The containing file and order // of the style within the containing file is used. pluginOptions.externalRuntimeStyles - ? createHash('sha256') - .update(containingFile) - .update((order ?? 0).toString()) - .update(className ?? '') - .digest('hex') + ? calculateHash(`${containingFile}${order ?? 0}${className ?? ''}`) : undefined, ); // Adjust result source for inline styles. diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts index 79008d140729..60c80ce057c6 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -7,8 +7,8 @@ */ import assert from 'node:assert'; -import { createHash } from 'node:crypto'; import path from 'node:path'; +import { createContentHash } from '../../../utils/hash'; import { BundleContextResult, BundlerContext } from '../bundler-context'; import { type BuildOutputFile, BuildOutputFileType } from '../bundler-files'; import { MemoryCache } from '../cache'; @@ -103,11 +103,10 @@ export class ComponentStylesheetBundler { ): Promise { // Use a hash of the inline stylesheet content to ensure a consistent identifier. External stylesheets will resolve // to the actual stylesheet file path. - // TODO: Consider xxhash instead for hashing - const id = createHash('sha256') - .update(data) - .update(externalId ?? '') - .digest('hex'); + const hasher = createContentHash(); + hasher.update(data); + hasher.update(externalId ?? ''); + const id = hasher.digest(); const entry = [language, id, filename].join(';'); const bundlerContext = await this.#inlineContexts.getOrCreate(entry, () => { diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts index 37ff846c7400..4e6ddc0fee21 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -8,11 +8,11 @@ import type { BuildOptions, Plugin } from 'esbuild'; import assert from 'node:assert'; -import { createHash } from 'node:crypto'; import { extname, relative } from 'node:path'; import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { Platform } from '../../builders/application/schema'; import { allowMangle } from '../../utils/environment-options'; +import { calculateHash } from '../../utils/hash'; import { toPosixPath } from '../../utils/path'; import { SERVER_APP_ENGINE_MANIFEST_FILENAME, @@ -566,7 +566,7 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu '', ); - footer = { js: `/**i18n:${createHash('sha256').update(i18nHash).digest('hex')}*/` }; + footer = { js: `/**i18n:${calculateHash(i18nHash)}*/` }; } // Core conditions that are always included diff --git a/packages/angular/build/src/tools/esbuild/bundler-files.ts b/packages/angular/build/src/tools/esbuild/bundler-files.ts index ac33d471a395..168c41e65940 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-files.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-files.ts @@ -7,7 +7,7 @@ */ import type { OutputFile } from 'esbuild'; -import { createHash } from 'node:crypto'; +import { calculateHash } from '../../utils/hash'; export interface InitialFileRecord { entrypoint: boolean; @@ -63,9 +63,7 @@ export function createOutputFile( return this.contents.byteLength; }, get hash(): string { - cachedHash ??= createHash('sha256') - .update(cachedText ?? this.contents) - .digest('hex'); + cachedHash ??= calculateHash(cachedText ?? this.contents); return cachedHash; }, @@ -97,7 +95,7 @@ export function createOutputFile( return cachedText; }, get hash(): string { - cachedHash ??= createHash('sha256').update(this.contents).digest('hex'); + cachedHash ??= calculateHash(this.contents); return cachedHash; }, diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 071097315e5b..678511612afb 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -7,9 +7,9 @@ */ import assert from 'node:assert'; -import { createHash } from 'node:crypto'; import { extname, join } from 'node:path'; import { serialize } from 'node:v8'; +import { calculateHash, createContentHash } from '../../utils/hash'; import { WorkerPool } from '../../utils/worker-pool'; import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; import { type PersistentCacheStore, createPersistentCacheStore } from './cache'; @@ -147,7 +147,7 @@ export class I18nInliner { // Request inlining for each file that contains localize calls const requests = []; - let fileCacheKeyBase: Uint8Array | undefined; + let fileCacheKeyBase: string | undefined; for (const [filename, file] of this.#localizeFiles) { let cacheKey: string | undefined; @@ -160,17 +160,16 @@ export class I18nInliner { // The options are digested here so that each file's key is derived from a fixed number // of bytes. Hashing the options directly would re-hash the full set of messages, which // can be several megabytes, once for every file. - fileCacheKeyBase ??= createHash('sha256') - .update(JSON.stringify({ locale, translation, missingTranslation, shouldOptimize })) - .digest(); + fileCacheKeyBase ??= calculateHash( + JSON.stringify({ locale, translation, missingTranslation, shouldOptimize }), + ); // NOTE: If additional options are added, this may need to be updated. - // TODO: Consider xxhash or similar instead of SHA256 - cacheKey = createHash('sha256') - .update(file.hash) - .update(filename) - .update(fileCacheKeyBase) - .digest('hex'); + const hasher = createContentHash(); + hasher.update(file.hash); + hasher.update(filename); + hasher.update(fileCacheKeyBase); + cacheKey = hasher.digest(); // Failure to get the value should not fail the transform cacheResultPromise = this.#cache.get(cacheKey).catch(() => null); diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index bb5c432c25b8..32a0b2b8d07d 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; +import { createContentHash } from '../../utils/hash'; import { IMPORT_EXEC_ARGV } from '../../utils/server-rendering/esm-in-memory-loader/utils'; import { removeSourceMappingURL } from '../../utils/source-map'; import { WorkerPool, WorkerPoolOptions } from '../../utils/worker-pool'; @@ -138,11 +138,11 @@ export class JavaScriptTransformer { if (this.cache) { // Create a cache key from the file data and options that effect the output. // NOTE: If additional options are added, this may need to be updated. - const hash = createHash('sha256'); - hash.update(`${!!skipLinker}--${!!sideEffects}`); - hash.update(data); - hash.update(this.#fileCacheKeyBase); - cacheKey = hash.digest('hex'); + const hasher = createContentHash(); + hasher.update(`${!!skipLinker}--${!!sideEffects}`); + hasher.update(data); + hasher.update(this.#fileCacheKeyBase); + cacheKey = hasher.digest(); try { const cached = await this.cache.get(cacheKey); diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts index 82f2b70ed25d..9d1e3ff5cfba 100644 --- a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts +++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts @@ -28,10 +28,10 @@ */ import type { Loader, OnLoadResult, PartialMessage } from 'esbuild'; -import { createHash } from 'node:crypto'; import { readFile, stat } from 'node:fs/promises'; import { isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { calculateHash, createContentHash } from '../../utils/hash'; import type { Cache as PersistentCacheStore } from './cache'; import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache'; @@ -70,10 +70,6 @@ export interface CachedLoadResultEntry { errors?: PartialMessage[]; } -function hashContent(content: string | Uint8Array): string { - return createHash('sha256').update(content).digest('hex'); -} - /** * Calculates a unique cache key by updating the hash incrementally. * This prevents implicit string coercion of large binary content buffers. @@ -83,13 +79,14 @@ function calculateCacheKey( path: string, content: string | Uint8Array, ): string { - return createHash('sha256') - .update(globalConfigHash) - .update('\0') - .update(path) - .update('\0') - .update(content) - .digest('hex'); + const hasher = createContentHash(); + hasher.update(globalConfigHash); + hasher.update('\0'); + hasher.update(path); + hasher.update('\0'); + hasher.update(content); + + return hasher.digest(); } /** @@ -193,7 +190,7 @@ async function validateAndHealCacheEntry( // 3. Slow Path for dependencies: content hash fallback const currentContent = await readFile(filePath); - const currentHash = hashContent(currentContent); + const currentHash = calculateHash(currentContent); if (currentHash === expected.hash) { // Heal cache entry with new metadata watchFilesMetadata[filePath] = { @@ -244,8 +241,9 @@ async function computeMetadataForWatchFiles( knownContent !== undefined ? knownContent : readFile(filePath), stat(filePath), ]); + const hash = calculateHash(content); watchFilesMetadata[filePath] = { - hash: hashContent(content), + hash, mtimeMs: stats.mtimeMs, size: stats.size, }; diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts index 75b0915daa64..22397ab189b3 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { createHash } from 'node:crypto'; +import { calculateHash } from '../../../utils/hash'; import type { BundleStylesheetOptions } from './bundle-options'; /** @@ -39,34 +39,30 @@ export function calculateGlobalStylesheetConfigHash( options: BundleStylesheetOptions, packageVersion: string = '', ): string { - return createHash('sha256') - .update( - JSON.stringify({ - optimization: options.optimization, - sourcemap: options.sourcemap, - sourcesContent: options.sourcesContent, - includePaths: options.includePaths, - sassOptions: options.sass - ? { - futureDeprecations: options.sass.futureDeprecations, - fatalDeprecations: options.sass.fatalDeprecations, - silenceDeprecations: options.sass.silenceDeprecations, - } - : undefined, - target: options.target, - publicPath: options.publicPath, - outputNames: options.outputNames, - inlineFonts: options.inlineFonts, - preserveSymlinks: options.preserveSymlinks, - externalDependencies: options.externalDependencies, - postcssConfig: options.postcssConfiguration?.configPath - ? options.postcssConfiguration.configPath - : '', - tailwindConfig: options.tailwindConfiguration?.file - ? options.tailwindConfiguration.file - : '', - packageVersion, - }), - ) - .digest('hex'); + return calculateHash( + JSON.stringify({ + optimization: options.optimization, + sourcemap: options.sourcemap, + sourcesContent: options.sourcesContent, + includePaths: options.includePaths, + sassOptions: options.sass + ? { + futureDeprecations: options.sass.futureDeprecations, + fatalDeprecations: options.sass.fatalDeprecations, + silenceDeprecations: options.sass.silenceDeprecations, + } + : undefined, + target: options.target, + publicPath: options.publicPath, + outputNames: options.outputNames, + inlineFonts: options.inlineFonts, + preserveSymlinks: options.preserveSymlinks, + externalDependencies: options.externalDependencies, + postcssConfig: options.postcssConfiguration?.configPath + ? options.postcssConfiguration.configPath + : '', + tailwindConfig: options.tailwindConfiguration?.file ? options.tailwindConfiguration.file : '', + packageVersion, + }), + ); } diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts index 4e674b968fdd..c524a2d0c36b 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts @@ -6,10 +6,15 @@ * found in the LICENSE file at https://angular.dev/license */ +import { initializeHash } from '../../../utils/hash'; import type { BundleStylesheetOptions } from './bundle-options'; import { calculateGlobalStylesheetConfigHash } from './stylesheet-cache-key'; describe('Stylesheet Global Config Hash', () => { + beforeAll(async () => { + await initializeHash(); + }); + const baseOptions: BundleStylesheetOptions = { workspaceRoot: '/root', optimization: true, diff --git a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts index cf98614bc55b..02f54756eaff 100644 --- a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts @@ -7,7 +7,6 @@ */ import { lookup as lookupMimeType } from 'mrmime'; -import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import type { ServerResponse } from 'node:http'; import { extname } from 'node:path'; @@ -15,6 +14,7 @@ import type { Connect, ViteDevServer } from 'vite' with { 'resolution-mode': 'import', }; import { ResultFile } from '../../../builders/application/results'; +import { calculateHash } from '../../../utils/hash'; import { AngularMemoryOutputFiles, AngularOutputAssets, pathnameWithoutBasePath } from '../utils'; export interface ComponentStyleRecord { @@ -50,7 +50,7 @@ export function createAngularAssetsMiddleware( // This is a workaround to serve extensionless, CSS, JS and TS files without Vite transformations. if (!extension || JS_TS_REGEXP.test(extension) || CSS_PREPROCESSOR_REGEXP.test(extension)) { const contents = readFileSync(asset.source); - const etag = `W/${createHash('sha256').update(contents).digest('hex')}`; + const etag = `W/${calculateHash(contents)}`; if (checkAndHandleEtag(req, res, etag)) { return; } @@ -238,7 +238,7 @@ export function createBuildAssetsMiddleware( const contents = outputFile.origin === 'memory' ? outputFile.contents : readHandler(outputFile.inputPath); - const etag = `W/${createHash('sha256').update(contents).digest('hex')}`; + const etag = `W/${calculateHash(contents)}`; if (checkAndHandleEtag(req, res, etag)) { return; } diff --git a/packages/angular/build/src/utils/hash.ts b/packages/angular/build/src/utils/hash.ts new file mode 100644 index 000000000000..fdc5565afbb1 --- /dev/null +++ b/packages/angular/build/src/utils/hash.ts @@ -0,0 +1,74 @@ +/** + * @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 assert from 'node:assert'; +import type { XXHashAPI } from 'xxhash-wasm'; + +let xxhashInstance: XXHashAPI | undefined; +let xxhashPromise: Promise | undefined; + +/** + * Initializes the xxHash WASM instance early to ensure synchronous hashing uses xxHash. + */ +export async function initializeHash(): Promise { + if (xxhashInstance) { + return; + } + + xxhashPromise ??= import('xxhash-wasm').then((m) => m.default()); + xxhashInstance = await xxhashPromise; +} + +function getXxhash(): XXHashAPI { + assert( + xxhashInstance, + 'Hash utility must be initialized by awaiting `initializeHash()` before use.', + ); + + return xxhashInstance; +} + +/** + * Calculates a fast 64-bit non-cryptographic hash of the provided content. + * Suitable for cache keys, ETags, and change detection. + */ +export function calculateHash(data: string | Uint8Array): string { + const instance = getXxhash(); + + if (typeof data === 'string') { + return instance.h64ToString(data); + } + + return instance.h64Raw(data).toString(16).padStart(16, '0'); +} + +export interface ContentHasher { + update(data: string | Uint8Array): ContentHasher; + digest(): string; +} + +/** + * Creates a streaming 64-bit non-cryptographic content hasher. + */ +export function createContentHash(): ContentHasher { + const instance = getXxhash(); + const hasher = instance.create64(); + + const contentHasher: ContentHasher = { + update(data: string | Uint8Array): ContentHasher { + hasher.update(data); + + return contentHasher; + }, + digest(): string { + return hasher.digest().toString(16).padStart(16, '0'); + }, + }; + + return contentHasher; +} diff --git a/packages/angular/build/src/utils/hash_spec.ts b/packages/angular/build/src/utils/hash_spec.ts new file mode 100644 index 000000000000..77fcbc909403 --- /dev/null +++ b/packages/angular/build/src/utils/hash_spec.ts @@ -0,0 +1,60 @@ +/** + * @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 { calculateHash, createContentHash, initializeHash } from './hash'; + +describe('hash utility', () => { + beforeAll(async () => { + await initializeHash(); + }); + + it('should calculate identical 64-bit hex hash for string and Buffer with same content', () => { + const text = 'export const message = "hello world";'; + const buffer = Buffer.from(text, 'utf-8'); + + const stringHash = calculateHash(text); + const bufferHash = calculateHash(buffer); + + expect(typeof stringHash).toBe('string'); + expect(stringHash.length).toBe(16); + expect(stringHash).toBe(bufferHash); + }); + + it('should calculate different hashes for different contents', () => { + const hash1 = calculateHash('const a = 1;'); + const hash2 = calculateHash('const a = 2;'); + + expect(hash1).not.toBe(hash2); + }); + + it('should support streaming multi-part hashing matching combined single-shot hash', () => { + const part1 = 'header: '; + const part2 = 'body content: '; + const part3 = 'footer'; + + const hasher = createContentHash(); + hasher.update(part1); + hasher.update(part2); + hasher.update(Buffer.from(part3, 'utf-8')); + const streamingHash = hasher.digest(); + + const singleShotHash = calculateHash(part1 + part2 + part3); + + expect(streamingHash.length).toBe(16); + expect(streamingHash).toBe(singleShotHash); + }); + + it('should handle Uint8Array chunks in streaming hasher', () => { + const hasher = createContentHash(); + hasher.update(new Uint8Array([1, 2, 3, 4])).update('some-string'); + const digest = hasher.digest(); + + expect(typeof digest).toBe('string'); + expect(digest.length).toBe(16); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f26c9fa085e9..32052321a9c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -400,6 +400,9 @@ importers: watchpack: specifier: 2.5.2 version: 2.5.2 + xxhash-wasm: + specifier: 1.1.0 + version: 1.1.0 devDependencies: '@angular-devkit/core': specifier: workspace:* @@ -8257,6 +8260,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -16665,6 +16671,8 @@ snapshots: xtend@4.0.2: {} + xxhash-wasm@1.1.0: {} + y18n@5.0.8: {} yallist@3.1.1: {}