Skip to content

Commit 78e50e5

Browse files
committed
refactor(@angular/build): implement generic persistent load result cache infrastructure for build pipeline
This change implements a unified, generic persistent caching system for the esbuild builder pipeline. It introduces a two-tier caching mechanism combining in-memory caching with a persistent disk store. Integration into various aspects of the build system will be performed in future changes.
1 parent 9671cc6 commit 78e50e5

5 files changed

Lines changed: 391 additions & 3 deletions

File tree

packages/angular/build/src/tools/esbuild/load-result-cache.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { OnLoadResult, PluginBuild } from 'esbuild';
1010
import { normalize } from 'node:path';
1111

1212
export interface LoadResultCache {
13-
get(path: string): OnLoadResult | undefined;
13+
get(path: string): OnLoadResult | Promise<OnLoadResult | undefined> | undefined;
1414
put(path: string, result: OnLoadResult): Promise<void>;
1515
readonly watchFiles: ReadonlyArray<string>;
1616
}
@@ -25,7 +25,7 @@ export function createCachedLoad(
2525

2626
return async (args) => {
2727
const loadCacheKey = `${args.namespace}:${args.path}`;
28-
let result: OnLoadResult | null | undefined = cache.get(loadCacheKey);
28+
let result: OnLoadResult | null | undefined = await cache.get(loadCacheKey);
2929

3030
if (result === undefined) {
3131
result = await callback(args);
@@ -35,7 +35,9 @@ export function createCachedLoad(
3535
// Ensure requested path is included if it was a resolved file
3636
if (args.namespace === 'file') {
3737
result.watchFiles ??= [];
38-
result.watchFiles.push(args.path);
38+
if (!result.watchFiles.includes(args.path)) {
39+
result.watchFiles.push(args.path);
40+
}
3941
}
4042
await cache.put(loadCacheKey, result);
4143
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { Loader, OnLoadResult, PartialMessage } from 'esbuild';
10+
import { createHash } from 'node:crypto';
11+
import { existsSync, readFileSync } from 'node:fs';
12+
import type { Cache as PersistentCacheStore } from './cache';
13+
import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache';
14+
15+
/**
16+
* Serialized representation of any esbuild load result stored in persistent cache.
17+
*/
18+
export interface CachedLoadResultEntry {
19+
/** Compiled output string or binary data */
20+
contents: string | Uint8Array;
21+
22+
/** esbuild loader type */
23+
loader?: Loader;
24+
25+
/** Absolute paths of all imported/watched dependency files */
26+
watchFiles: string[];
27+
28+
/** Map of watchFile absolute paths to content hashes */
29+
watchFilesHashes: Record<string, string>;
30+
31+
/** Warnings emitted during load processing */
32+
warnings?: PartialMessage[];
33+
34+
/** Errors emitted during load processing */
35+
errors?: PartialMessage[];
36+
}
37+
38+
function hashContent(content: string | Uint8Array): string {
39+
return createHash('sha256').update(content).digest('hex');
40+
}
41+
42+
function isCacheEntryValid(watchFilesHashes: Record<string, string>): boolean {
43+
for (const [filePath, expectedHash] of Object.entries(watchFilesHashes)) {
44+
if (!existsSync(filePath)) {
45+
return false;
46+
}
47+
try {
48+
const currentContent = readFileSync(filePath);
49+
if (hashContent(currentContent) !== expectedHash) {
50+
return false;
51+
}
52+
} catch {
53+
return false;
54+
}
55+
}
56+
57+
return true;
58+
}
59+
60+
function computeHashesForWatchFiles(watchFiles: string[]): Record<string, string> {
61+
const watchFilesHashes: Record<string, string> = {};
62+
for (const filePath of watchFiles) {
63+
if (existsSync(filePath)) {
64+
try {
65+
const content = readFileSync(filePath);
66+
watchFilesHashes[filePath] = hashContent(content);
67+
} catch {
68+
// Ignore unreadable files
69+
}
70+
}
71+
}
72+
73+
return watchFilesHashes;
74+
}
75+
76+
export class PersistentLoadResultCache implements LoadResultCache {
77+
private readonly memoryCache = new MemoryLoadResultCache();
78+
79+
constructor(
80+
private readonly persistentStore?: PersistentCacheStore<CachedLoadResultEntry>,
81+
private readonly globalConfigHash: string = '',
82+
) {}
83+
84+
/**
85+
* Retrieves a load result from cache.
86+
* Checks L1 memory cache first for immediate watch-mode speed, falling back to L2 persistent disk
87+
* store on L1 cache miss. L2 persistent cache entries are validated against dependency content hashes.
88+
*/
89+
async get(path: string): Promise<OnLoadResult | undefined> {
90+
// 1. Check L1 Memory Cache
91+
const memoryResult = this.memoryCache.get(path);
92+
if (memoryResult) {
93+
return memoryResult;
94+
}
95+
96+
if (!this.persistentStore) {
97+
return undefined;
98+
}
99+
100+
// 2. Check L2 Persistent Disk Cache
101+
let content: string | Uint8Array = '';
102+
const filePath = path.startsWith('file:') ? path.slice(5) : path;
103+
if (existsSync(filePath)) {
104+
try {
105+
content = readFileSync(filePath);
106+
} catch {
107+
return undefined;
108+
}
109+
}
110+
111+
const cacheKey = hashContent(this.globalConfigHash + path + content);
112+
const cached = await this.persistentStore.get(cacheKey);
113+
114+
if (cached && isCacheEntryValid(cached.watchFilesHashes)) {
115+
const result: OnLoadResult = {
116+
contents: cached.contents,
117+
loader: cached.loader,
118+
watchFiles: cached.watchFiles,
119+
warnings: cached.warnings,
120+
errors: cached.errors,
121+
};
122+
123+
// Populate L1 Memory Cache for subsequent lookups
124+
await this.memoryCache.put(path, result);
125+
126+
return result;
127+
}
128+
129+
return undefined;
130+
}
131+
132+
/**
133+
* Stores a load result in both L1 memory cache and L2 persistent disk store.
134+
*/
135+
async put(path: string, result: OnLoadResult): Promise<void> {
136+
await this.memoryCache.put(path, result);
137+
138+
if (this.persistentStore && result.contents) {
139+
let content: string | Uint8Array = '';
140+
const filePath = path.startsWith('file:') ? path.slice(5) : path;
141+
if (existsSync(filePath)) {
142+
try {
143+
content = readFileSync(filePath);
144+
} catch {
145+
// ignore
146+
}
147+
}
148+
149+
const cacheKey = hashContent(this.globalConfigHash + path + content);
150+
const watchFilesHashes = computeHashesForWatchFiles(result.watchFiles ?? []);
151+
152+
await this.persistentStore.put(cacheKey, {
153+
contents: result.contents,
154+
loader: result.loader,
155+
watchFiles: result.watchFiles ?? [],
156+
watchFilesHashes,
157+
warnings: result.warnings,
158+
errors: result.errors,
159+
});
160+
}
161+
}
162+
163+
/**
164+
* Invalidates cached entries affected by a modified dependency file during watch mode.
165+
*
166+
* Note: Invalidation of L1 memory cache is sufficient for active watch mode.
167+
* Cross-process/cold start stale entries in L2 persistent store are automatically handled
168+
* during `get()` via dependency content hash verification (`isCacheEntryValid`).
169+
*/
170+
invalidate(path: string): boolean {
171+
return this.memoryCache.invalidate(path);
172+
}
173+
174+
get watchFiles(): ReadonlyArray<string> {
175+
return this.memoryCache.watchFiles;
176+
}
177+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { OnLoadResult } from 'esbuild';
10+
import fs from 'node:fs';
11+
import os from 'node:os';
12+
import path from 'node:path';
13+
import type { Cache as PersistentCacheStore } from './cache';
14+
import {
15+
type CachedLoadResultEntry,
16+
PersistentLoadResultCache,
17+
} from './persistent-load-result-cache';
18+
19+
describe('PersistentLoadResultCache', () => {
20+
let mockStore: Map<string, CachedLoadResultEntry>;
21+
let persistentStore: PersistentCacheStore<CachedLoadResultEntry>;
22+
let tmpDir: string;
23+
let file1: string;
24+
25+
beforeEach(() => {
26+
tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'persistent-cache-test-'));
27+
file1 = path.join(tmpDir, 'test.js');
28+
fs.writeFileSync(file1, 'console.log("hello");');
29+
30+
mockStore = new Map();
31+
persistentStore = {
32+
async get(key: string) {
33+
return mockStore.get(key);
34+
},
35+
async put(key: string, value: CachedLoadResultEntry) {
36+
mockStore.set(key, value);
37+
},
38+
} as unknown as PersistentCacheStore<CachedLoadResultEntry>;
39+
});
40+
41+
afterEach(() => {
42+
fs.rmSync(tmpDir, { recursive: true, force: true });
43+
});
44+
45+
it('should return undefined on L1 and L2 cache miss', async () => {
46+
const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
47+
const result = await cache.get(file1);
48+
expect(result).toBeUndefined();
49+
});
50+
51+
it('should hit L2 persistent store and return cached output when dependencies are valid', async () => {
52+
const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
53+
const sampleResult: OnLoadResult = {
54+
contents: 'console.log("hello");',
55+
loader: 'js',
56+
watchFiles: [file1],
57+
};
58+
59+
await cache.put(file1, sampleResult);
60+
61+
// Create a second cache instance (simulating cold start)
62+
const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
63+
const hit = await coldCache.get(file1);
64+
65+
expect(hit).toBeDefined();
66+
expect(hit?.contents).toBe('console.log("hello");');
67+
expect(hit?.loader).toBe('js');
68+
});
69+
70+
it('should invalidate L2 persistent cache hit if a watch dependency file is modified', async () => {
71+
const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
72+
const sampleResult: OnLoadResult = {
73+
contents: 'console.log("hello");',
74+
loader: 'js',
75+
watchFiles: [file1],
76+
};
77+
78+
await cache.put(file1, sampleResult);
79+
80+
// Modify dependency file
81+
fs.writeFileSync(file1, 'console.log("world");');
82+
83+
const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
84+
const hit = await coldCache.get(file1);
85+
86+
expect(hit).toBeUndefined();
87+
});
88+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { createHash } from 'node:crypto';
10+
import type { BundleStylesheetOptions } from './bundle-options';
11+
12+
/**
13+
* Generates a global hash based on all build options that affect stylesheet compilation.
14+
*
15+
* IMPORTANT: This hash acts as the root cache key prefix for all persistent stylesheet cache
16+
* entries. Any change in the values hashed below will invalidate all cached stylesheet outputs
17+
* across the entire application.
18+
*
19+
* Included build options and their rationale:
20+
* - `optimization`: Affects CSS minification, dead code elimination, and whitespace stripping.
21+
* - `sourcemap`: Toggles inline/external source map generation and comment insertion.
22+
* - `includePaths`: Changes Sass/Less `@import` and `@use` path resolution search directories.
23+
* - `sassOptions`: Changes Sass compiler deprecations and behavior flags.
24+
* - `target`: Affects CSS property lowering and browser vendor prefixing in esbuild.
25+
* - `publicPath`: Affects relative asset URL rewriting (`url('...')`) inside CSS output.
26+
* - `outputNames`: Affects asset output filename hashing schemes.
27+
* - `inlineFonts`: Controls whether external web font `@import` / `<link>` directives are inlined.
28+
* - `preserveSymlinks`: Controls symlink realpath resolution in monorepos/pnpm workspace packages.
29+
* - `externalDependencies`: Controls which CSS modules/urls are excluded from bundling.
30+
* - `postcssConfig`: Path to custom PostCSS configuration file.
31+
* - `tailwindConfig`: Path to Tailwind CSS configuration file.
32+
* - `packageVersion`: Invalidates cache across `@angular/build` compiler toolchain updates.
33+
*
34+
* @note Maintainers: If any new build option is added to `BundleStylesheetOptions` or `@angular/build`
35+
* that alters generated stylesheet code or asset outputs, it MUST be added to this hash object.
36+
*/
37+
export function calculateGlobalStylesheetConfigHash(
38+
options: BundleStylesheetOptions,
39+
packageVersion: string = '',
40+
): string {
41+
return createHash('sha256')
42+
.update(
43+
JSON.stringify({
44+
optimization: options.optimization,
45+
sourcemap: options.sourcemap,
46+
includePaths: options.includePaths,
47+
sassOptions: options.sass,
48+
target: options.target,
49+
publicPath: options.publicPath,
50+
outputNames: options.outputNames,
51+
inlineFonts: options.inlineFonts,
52+
preserveSymlinks: options.preserveSymlinks,
53+
externalDependencies: options.externalDependencies,
54+
postcssConfig: options.postcssConfiguration?.configPath
55+
? options.postcssConfiguration.configPath
56+
: '',
57+
tailwindConfig: options.tailwindConfiguration?.file
58+
? options.tailwindConfiguration.file
59+
: '',
60+
packageVersion,
61+
}),
62+
)
63+
.digest('hex');
64+
}

0 commit comments

Comments
 (0)