From 6392018723db16d1a7388bde76baf3dabe121daa Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:43:16 +0000 Subject: [PATCH] perf(@angular/build): reuse precomputed hash for in-memory build asset ETags Reuse the precomputed outputFile.hash on in-memory ResultFile entries in createBuildAssetsMiddleware instead of recomputing a SHA256 digest on every HTTP request. --- .../vite/middlewares/assets-middleware.ts | 3 +- .../middlewares/assets-middleware_spec.ts | 162 ++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 packages/angular/build/src/tools/vite/middlewares/assets-middleware_spec.ts 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 4af44ce2dd6b..04093e520448 100644 --- a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts @@ -237,8 +237,9 @@ export function createBuildAssetsMiddleware( if (outputFile) { const contents = outputFile.origin === 'memory' ? outputFile.contents : readHandler(outputFile.source); + const hash = outputFile.origin === 'memory' ? outputFile.hash : calculateHash(contents); - const etag = `W/"${calculateHash(contents)}"`; + const etag = `W/"${hash}"`; if (checkAndHandleEtag(req, res, etag)) { return; } diff --git a/packages/angular/build/src/tools/vite/middlewares/assets-middleware_spec.ts b/packages/angular/build/src/tools/vite/middlewares/assets-middleware_spec.ts new file mode 100644 index 000000000000..f8bf6e94584e --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware_spec.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 + */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { ResultFile } from '../../../builders/application/results'; +import { calculateHash, initializeHash } from '../../../utils/hash'; +import { BuildOutputFileType } from '../../esbuild/bundler-files'; +import { createBuildAssetsMiddleware } from './assets-middleware'; + +describe('createBuildAssetsMiddleware', () => { + beforeAll(async () => { + await initializeHash(); + }); + + function createMockReqRes( + url: string, + headers: Record = {}, + ): { + req: IncomingMessage; + res: ServerResponse & { setHeader: jasmine.Spy; end: jasmine.Spy }; + } { + const req = { + url, + method: 'GET', + headers, + } as unknown as IncomingMessage; + + const res = { + statusCode: 200, + writableEnded: false, + setHeader: jasmine.createSpy('setHeader'), + end: jasmine.createSpy('end'), + } as unknown as ServerResponse & { setHeader: jasmine.Spy; end: jasmine.Spy }; + + return { req, res }; + } + + it('uses precomputed outputFile.hash for memory files instead of recalculating hash', () => { + const contents = new TextEncoder().encode('body { color: red; }'); + const precomputedHash = 'precomputed-memory-hash'; + const buildResultFiles = new Map([ + [ + 'styles.css', + { + origin: 'memory', + path: 'styles.css', + contents, + hash: precomputedHash, + type: BuildOutputFileType.Browser, + }, + ], + ]); + + const readHandler = jasmine.createSpy('readHandler'); + const middleware = createBuildAssetsMiddleware('/', buildResultFiles, readHandler); + const { req, res } = createMockReqRes('/styles.css'); + const next = jasmine.createSpy('next'); + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(readHandler).not.toHaveBeenCalled(); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/css'); + expect(res.setHeader).toHaveBeenCalledWith('ETag', `W/"${precomputedHash}"`); + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); + expect(res.end).toHaveBeenCalledWith(contents); + }); + + it('returns 304 for memory files when If-None-Match matches precomputed outputFile.hash', () => { + const contents = new TextEncoder().encode('body { color: red; }'); + const precomputedHash = 'precomputed-memory-hash'; + const buildResultFiles = new Map([ + [ + 'styles.css', + { + origin: 'memory', + path: 'styles.css', + contents, + hash: precomputedHash, + type: BuildOutputFileType.Browser, + }, + ], + ]); + + const middleware = createBuildAssetsMiddleware('/', buildResultFiles); + const { req, res } = createMockReqRes('/styles.css', { + 'if-none-match': `W/"${precomputedHash}"`, + }); + const next = jasmine.createSpy('next'); + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(304); + expect(res.end).toHaveBeenCalledWith(); + }); + + it('calculates hash from disk contents when outputFile.origin is disk', () => { + const diskBuffer = Buffer.from('console.log("asset");', 'utf-8'); + const expectedHash = calculateHash(diskBuffer); + const buildResultFiles = new Map([ + [ + 'assets/data.json', + { + origin: 'disk', + path: 'assets/data.json', + source: '/workspace/src/assets/data.json', + type: BuildOutputFileType.Browser, + }, + ], + ]); + + const readHandler = jasmine.createSpy('readHandler').and.returnValue(diskBuffer); + const middleware = createBuildAssetsMiddleware('/', buildResultFiles, readHandler); + const { req, res } = createMockReqRes('/assets/data.json'); + const next = jasmine.createSpy('next'); + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(readHandler).toHaveBeenCalledOnceWith('/workspace/src/assets/data.json'); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); + expect(res.setHeader).toHaveBeenCalledWith('ETag', `W/"${expectedHash}"`); + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); + expect(res.end).toHaveBeenCalledWith(diskBuffer); + }); + + it('returns 304 for disk files when If-None-Match matches calculated hash', () => { + const diskBuffer = Buffer.from('{"ok":true}', 'utf-8'); + const expectedHash = calculateHash(diskBuffer); + const buildResultFiles = new Map([ + [ + 'assets/data.json', + { + origin: 'disk', + path: 'assets/data.json', + source: '/workspace/src/assets/data.json', + type: BuildOutputFileType.Browser, + }, + ], + ]); + + const readHandler = jasmine.createSpy('readHandler').and.returnValue(diskBuffer); + const middleware = createBuildAssetsMiddleware('/', buildResultFiles, readHandler); + const { req, res } = createMockReqRes('/assets/data.json', { + 'if-none-match': `W/"${expectedHash}"`, + }); + const next = jasmine.createSpy('next'); + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(readHandler).toHaveBeenCalledOnceWith('/workspace/src/assets/data.json'); + expect(res.statusCode).toBe(304); + expect(res.end).toHaveBeenCalledWith(); + }); +});