@react-native-harness/cache 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +26 -0
  3. package/dist/__tests__/boundary.test.d.ts +2 -0
  4. package/dist/__tests__/boundary.test.d.ts.map +1 -0
  5. package/dist/__tests__/boundary.test.js +77 -0
  6. package/dist/__tests__/domains/metro.test.d.ts +2 -0
  7. package/dist/__tests__/domains/metro.test.d.ts.map +1 -0
  8. package/dist/__tests__/domains/metro.test.js +234 -0
  9. package/dist/__tests__/index.test.d.ts +2 -0
  10. package/dist/__tests__/index.test.d.ts.map +1 -0
  11. package/dist/__tests__/index.test.js +40 -0
  12. package/dist/__tests__/paths.test.d.ts +2 -0
  13. package/dist/__tests__/paths.test.d.ts.map +1 -0
  14. package/dist/__tests__/paths.test.js +12 -0
  15. package/dist/__tests__/plan.test.d.ts +2 -0
  16. package/dist/__tests__/plan.test.d.ts.map +1 -0
  17. package/dist/__tests__/plan.test.js +197 -0
  18. package/dist/__tests__/warmth.test.d.ts +2 -0
  19. package/dist/__tests__/warmth.test.d.ts.map +1 -0
  20. package/dist/__tests__/warmth.test.js +36 -0
  21. package/dist/domains/metro.d.ts +8 -0
  22. package/dist/domains/metro.d.ts.map +1 -0
  23. package/dist/domains/metro.js +151 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +29 -0
  27. package/dist/paths.d.ts +14 -0
  28. package/dist/paths.d.ts.map +1 -0
  29. package/dist/paths.js +26 -0
  30. package/dist/plan.d.ts +54 -0
  31. package/dist/plan.d.ts.map +1 -0
  32. package/dist/plan.js +177 -0
  33. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  34. package/dist/warmth.d.ts +8 -0
  35. package/dist/warmth.d.ts.map +1 -0
  36. package/dist/warmth.js +40 -0
  37. package/eslint.config.mjs +20 -0
  38. package/package.json +22 -0
  39. package/src/__tests__/boundary.test.ts +95 -0
  40. package/src/__tests__/domains/metro.test.ts +298 -0
  41. package/src/__tests__/index.test.ts +54 -0
  42. package/src/__tests__/paths.test.ts +18 -0
  43. package/src/__tests__/plan.test.ts +330 -0
  44. package/src/__tests__/warmth.test.ts +47 -0
  45. package/src/domains/metro.ts +202 -0
  46. package/src/index.ts +84 -0
  47. package/src/paths.ts +43 -0
  48. package/src/plan.ts +285 -0
  49. package/src/warmth.ts +59 -0
  50. package/tsconfig.json +13 -0
  51. package/tsconfig.lib.json +18 -0
  52. package/vite.config.ts +18 -0
package/src/index.ts ADDED
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs';
2
+ import { logger } from '@react-native-harness/tools';
3
+ import {
4
+ createHarnessCachePaths,
5
+ getDomainDirectories,
6
+ type CacheDomainId,
7
+ type HarnessCachePaths,
8
+ } from './paths.js';
9
+ import {
10
+ planRestore,
11
+ planSave,
12
+ snapshot,
13
+ writeKeysFile,
14
+ type CacheKeyInputs,
15
+ type CacheSnapshot,
16
+ type CacheSnapshotEntry,
17
+ type DomainRestorePlan,
18
+ type DomainSavePlan,
19
+ type RestorePlan,
20
+ type SavePlan,
21
+ type SavePolicy,
22
+ } from './plan.js';
23
+ import { isDomainWarm } from './warmth.js';
24
+ import { computeMetroStaticInputs, METRO_KEY_FILENAMES } from './domains/metro.js';
25
+
26
+ export { computeMetroStaticInputs, METRO_KEY_FILENAMES };
27
+
28
+ export type { CacheDomainId, HarnessCachePaths };
29
+ export type {
30
+ CacheKeyInputs,
31
+ CacheSnapshot,
32
+ CacheSnapshotEntry,
33
+ DomainRestorePlan,
34
+ DomainSavePlan,
35
+ RestorePlan,
36
+ SavePlan,
37
+ SavePolicy,
38
+ };
39
+
40
+ export interface HarnessCache {
41
+ readonly paths: HarnessCachePaths;
42
+ isWarm(domain: CacheDomainId): boolean;
43
+ ensureDomainDirectories(domain: CacheDomainId): void;
44
+ planRestore(
45
+ inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>
46
+ ): RestorePlan;
47
+ snapshot(): CacheSnapshot;
48
+ planSave(
49
+ before: CacheSnapshot,
50
+ policy: SavePolicy,
51
+ inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>
52
+ ): SavePlan;
53
+ writeKeysFile(plan: RestorePlan | SavePlan): void;
54
+ }
55
+
56
+ const cacheLogger = logger.child('cache');
57
+
58
+ export const createHarnessCache = (options: {
59
+ projectRoot: string;
60
+ }): HarnessCache => {
61
+ const paths = createHarnessCachePaths(options.projectRoot);
62
+
63
+ return {
64
+ paths,
65
+ isWarm: (domain) => isDomainWarm(domain, paths),
66
+ ensureDomainDirectories: (domain) => {
67
+ for (const directory of getDomainDirectories(domain, paths)) {
68
+ try {
69
+ fs.mkdirSync(directory, { recursive: true });
70
+ } catch (error) {
71
+ cacheLogger.warn(
72
+ `Failed to create cache directory "${directory}". Continuing with a cold cache.`,
73
+ error
74
+ );
75
+ }
76
+ }
77
+ },
78
+ planRestore: (inputs) => planRestore(paths, inputs),
79
+ snapshot: () => snapshot(paths),
80
+ planSave: (before, policy, inputs) =>
81
+ planSave(paths, before, policy, inputs),
82
+ writeKeysFile: (plan) => writeKeysFile(paths, plan),
83
+ };
84
+ };
package/src/paths.ts ADDED
@@ -0,0 +1,43 @@
1
+ import path from 'node:path';
2
+
3
+ const HARNESS_DIRNAME = '.harness';
4
+ const CACHE_DIRNAME = 'cache';
5
+ const METRO_TRANSFORM_DIRNAME = 'metro';
6
+ const METRO_FILE_MAP_DIRNAME = 'metro-file-map';
7
+
8
+ export type CacheDomainId = 'metro';
9
+
10
+ export interface HarnessCachePaths {
11
+ readonly root: string;
12
+ readonly metroTransform: string;
13
+ readonly metroFileMap: string;
14
+ }
15
+
16
+ export const createHarnessCachePaths = (
17
+ projectRoot: string
18
+ ): HarnessCachePaths => {
19
+ const root = path.join(projectRoot, HARNESS_DIRNAME, CACHE_DIRNAME);
20
+
21
+ return {
22
+ root,
23
+ metroTransform: path.join(root, METRO_TRANSFORM_DIRNAME),
24
+ metroFileMap: path.join(root, METRO_FILE_MAP_DIRNAME),
25
+ };
26
+ };
27
+
28
+ /**
29
+ * Directories that make up a cache domain. `metro` covers both the transform
30
+ * cache and the file-map (haste) cache so `ensureDomainDirectories` prepares
31
+ * both ahead of a run.
32
+ */
33
+ export const getDomainDirectories = (
34
+ domain: CacheDomainId,
35
+ paths: HarnessCachePaths
36
+ ): readonly string[] => {
37
+ switch (domain) {
38
+ case 'metro':
39
+ return [paths.metroTransform, paths.metroFileMap];
40
+ default:
41
+ return [];
42
+ }
43
+ };
package/src/plan.ts ADDED
@@ -0,0 +1,285 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { logger } from '@react-native-harness/tools';
5
+ import { getDomainDirectories } from './paths.js';
6
+ import type { CacheDomainId, HarnessCachePaths } from './paths.js';
7
+
8
+ const cacheLogger = logger.child('cache');
9
+
10
+ const KEYS_FILENAME = 'keys.json';
11
+ const HASH_LENGTH = 16;
12
+
13
+ export interface CacheKeyInputs {
14
+ os: string;
15
+ staticInputs: Record<string, string>;
16
+ }
17
+
18
+ export interface DomainRestorePlan {
19
+ paths: string[];
20
+ restoreKey: string;
21
+ restorePrefixes: string[];
22
+ accretive: boolean;
23
+ }
24
+
25
+ export interface RestorePlan {
26
+ version: 1;
27
+ domains: Partial<Record<CacheDomainId, DomainRestorePlan>>;
28
+ }
29
+
30
+ export interface CacheSnapshotEntry {
31
+ path: string;
32
+ sizeBytes: number;
33
+ mtimeMs: number;
34
+ }
35
+
36
+ export type CacheSnapshot = Partial<Record<CacheDomainId, CacheSnapshotEntry[]>>;
37
+
38
+ export interface SavePolicy {
39
+ mode: 'default-branch' | 'always' | 'never';
40
+ isDefaultBranch: boolean;
41
+ }
42
+
43
+ export interface DomainSavePlan {
44
+ shouldSave: boolean;
45
+ saveKey: string;
46
+ delta: {
47
+ addedEntries: number;
48
+ removedEntries: number;
49
+ addedBytes: number;
50
+ };
51
+ }
52
+
53
+ export interface SavePlan {
54
+ version: 1;
55
+ domains: Partial<Record<CacheDomainId, DomainSavePlan>>;
56
+ }
57
+
58
+ const ALL_DOMAINS: readonly CacheDomainId[] = ['metro'];
59
+
60
+ /**
61
+ * Accretive domains only ever grow (transform/file-map caches keyed by
62
+ * content hash) so a save is always safe to key off the static hash plus a
63
+ * content hash, and prior saves can be found via a prefix match on the
64
+ * static hash. Non-accretive domains would need an exact key match instead.
65
+ * A domain not yet handled here defaults to non-accretive (exact-match),
66
+ * matching the safe-fallback style of `getDomainDirectories`/
67
+ * `getWarmthDirectory`: prefix-matching into an unknown domain's saves would
68
+ * not be a neutral no-op, so the conservative default is to require an exact
69
+ * key match rather than silently opt a future domain into accretive lookup.
70
+ */
71
+ const isAccretiveDomain = (domain: CacheDomainId): boolean => {
72
+ switch (domain) {
73
+ case 'metro':
74
+ return true;
75
+ default:
76
+ return false;
77
+ }
78
+ };
79
+
80
+ const hashSortedEntries = (entries: Array<[string, string]>): string =>
81
+ createHash('sha256')
82
+ .update(
83
+ entries
84
+ .slice()
85
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
86
+ .map(([key, value]) => `${key}=${value}`)
87
+ .join('\n')
88
+ )
89
+ .digest('hex')
90
+ .slice(0, HASH_LENGTH);
91
+
92
+ const buildStaticHashKey = (
93
+ domain: CacheDomainId,
94
+ inputs: CacheKeyInputs
95
+ ): string => {
96
+ const hash = hashSortedEntries(Object.entries(inputs.staticInputs));
97
+ return `harness-${domain}-${inputs.os}-${hash}`;
98
+ };
99
+
100
+ const buildContentHash = (entries: CacheSnapshotEntry[]): string =>
101
+ hashSortedEntries(
102
+ entries.map((entry) => [entry.path, String(entry.sizeBytes)])
103
+ );
104
+
105
+ const walkDirectory = (
106
+ root: string,
107
+ directory: string,
108
+ entries: CacheSnapshotEntry[]
109
+ ): void => {
110
+ let dirents: fs.Dirent[];
111
+
112
+ try {
113
+ dirents = fs.readdirSync(directory, { withFileTypes: true });
114
+ } catch {
115
+ // Missing/unreadable directory is treated as empty rather than an error.
116
+ return;
117
+ }
118
+
119
+ for (const dirent of dirents) {
120
+ const fullPath = path.join(directory, dirent.name);
121
+
122
+ if (dirent.isDirectory()) {
123
+ walkDirectory(root, fullPath, entries);
124
+ } else if (dirent.isFile()) {
125
+ const stat = fs.statSync(fullPath);
126
+ entries.push({
127
+ path: path.relative(root, fullPath),
128
+ sizeBytes: stat.size,
129
+ mtimeMs: stat.mtimeMs,
130
+ });
131
+ }
132
+ }
133
+ };
134
+
135
+ export const planRestore = (
136
+ paths: HarnessCachePaths,
137
+ inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>
138
+ ): RestorePlan => {
139
+ const domains: RestorePlan['domains'] = {};
140
+
141
+ for (const domain of ALL_DOMAINS) {
142
+ const domainInputs = inputs[domain];
143
+ if (!domainInputs) {
144
+ continue;
145
+ }
146
+
147
+ const restoreKey = buildStaticHashKey(domain, domainInputs);
148
+ const accretive = isAccretiveDomain(domain);
149
+
150
+ domains[domain] = {
151
+ paths: [...getDomainDirectories(domain, paths)],
152
+ restoreKey,
153
+ restorePrefixes: accretive ? [`${restoreKey}-`] : [],
154
+ accretive,
155
+ };
156
+ }
157
+
158
+ return { version: 1, domains };
159
+ };
160
+
161
+ /**
162
+ * Synchronous snapshot of on-disk cache contents, keyed by domain. Never
163
+ * throws: a domain whose directories cannot be read is reported as empty.
164
+ */
165
+ export const snapshot = (paths: HarnessCachePaths): CacheSnapshot => {
166
+ const result: CacheSnapshot = {};
167
+
168
+ for (const domain of ALL_DOMAINS) {
169
+ try {
170
+ const entries: CacheSnapshotEntry[] = [];
171
+ for (const directory of getDomainDirectories(domain, paths)) {
172
+ walkDirectory(paths.root, directory, entries);
173
+ }
174
+ result[domain] = entries;
175
+ } catch (error) {
176
+ cacheLogger.warn(
177
+ `Failed to snapshot cache domain "${domain}". Treating as empty.`,
178
+ error
179
+ );
180
+ result[domain] = [];
181
+ }
182
+ }
183
+
184
+ return result;
185
+ };
186
+
187
+ const diffSnapshots = (
188
+ before: CacheSnapshotEntry[],
189
+ after: CacheSnapshotEntry[]
190
+ ): DomainSavePlan['delta'] => {
191
+ const beforeByPath = new Map(before.map((entry) => [entry.path, entry]));
192
+ const afterByPath = new Map(after.map((entry) => [entry.path, entry]));
193
+
194
+ let addedEntries = 0;
195
+ let removedEntries = 0;
196
+ let addedBytes = 0;
197
+
198
+ for (const [entryPath, afterEntry] of afterByPath) {
199
+ const beforeEntry = beforeByPath.get(entryPath);
200
+ if (!beforeEntry || beforeEntry.sizeBytes !== afterEntry.sizeBytes) {
201
+ addedEntries += 1;
202
+ addedBytes += afterEntry.sizeBytes;
203
+ }
204
+ }
205
+
206
+ for (const entryPath of beforeByPath.keys()) {
207
+ if (!afterByPath.has(entryPath)) {
208
+ removedEntries += 1;
209
+ }
210
+ }
211
+
212
+ return { addedEntries, removedEntries, addedBytes };
213
+ };
214
+
215
+ const shouldSaveForPolicy = (
216
+ policy: SavePolicy,
217
+ delta: DomainSavePlan['delta']
218
+ ): boolean => {
219
+ if (delta.addedEntries + delta.removedEntries === 0) {
220
+ return false;
221
+ }
222
+
223
+ switch (policy.mode) {
224
+ case 'always':
225
+ return true;
226
+ case 'default-branch':
227
+ return policy.isDefaultBranch;
228
+ case 'never':
229
+ return false;
230
+ }
231
+ };
232
+
233
+ export const planSave = (
234
+ paths: HarnessCachePaths,
235
+ before: CacheSnapshot,
236
+ policy: SavePolicy,
237
+ inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>
238
+ ): SavePlan => {
239
+ const after = snapshot(paths);
240
+ const domains: SavePlan['domains'] = {};
241
+
242
+ for (const domain of ALL_DOMAINS) {
243
+ const domainInputs = inputs[domain];
244
+ if (!domainInputs) {
245
+ continue;
246
+ }
247
+
248
+ const beforeEntries = before[domain] ?? [];
249
+ const afterEntries = after[domain] ?? [];
250
+ const delta = diffSnapshots(beforeEntries, afterEntries);
251
+ const staticHashKey = buildStaticHashKey(domain, domainInputs);
252
+ const accretive = isAccretiveDomain(domain);
253
+
254
+ domains[domain] = {
255
+ shouldSave: shouldSaveForPolicy(policy, delta),
256
+ saveKey: accretive
257
+ ? `${staticHashKey}-${buildContentHash(afterEntries)}`
258
+ : staticHashKey,
259
+ delta,
260
+ };
261
+ }
262
+
263
+ return { version: 1, domains };
264
+ };
265
+
266
+ /**
267
+ * Written to `<cache-root>/keys.json`, outside any domain's cached
268
+ * subdirectories, so it is never itself part of what gets restored/saved.
269
+ * It is a secondary/debug artifact; the actual data flow between CI steps
270
+ * goes through GITHUB_OUTPUT, not by re-reading this file. Cache I/O must
271
+ * never fail a run, so write failures are logged and swallowed.
272
+ */
273
+ export const writeKeysFile = (
274
+ paths: HarnessCachePaths,
275
+ plan: RestorePlan | SavePlan
276
+ ): void => {
277
+ const filePath = path.join(paths.root, KEYS_FILENAME);
278
+
279
+ try {
280
+ fs.mkdirSync(paths.root, { recursive: true });
281
+ fs.writeFileSync(filePath, JSON.stringify(plan, null, 2));
282
+ } catch (error) {
283
+ cacheLogger.warn(`Failed to write cache keys file "${filePath}".`, error);
284
+ }
285
+ };
package/src/warmth.ts ADDED
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs';
2
+ import { logger } from '@react-native-harness/tools';
3
+ import type { CacheDomainId, HarnessCachePaths } from './paths.js';
4
+
5
+ const cacheLogger = logger.child('cache');
6
+
7
+ /**
8
+ * A domain is warm when its cache directory exists and is non-empty. Cache
9
+ * I/O must never fail a run, so any error while inspecting the filesystem is
10
+ * logged and treated as cold.
11
+ */
12
+ export const isDomainWarm = (
13
+ domain: CacheDomainId,
14
+ paths: HarnessCachePaths
15
+ ): boolean => {
16
+ const directory = getWarmthDirectory(domain, paths);
17
+
18
+ if (!directory) {
19
+ return false;
20
+ }
21
+
22
+ try {
23
+ const stat = fs.statSync(directory);
24
+
25
+ if (!stat.isDirectory()) {
26
+ return false;
27
+ }
28
+
29
+ return fs.readdirSync(directory).length > 0;
30
+ } catch (error) {
31
+ if (isMissingPathError(error)) {
32
+ return false;
33
+ }
34
+
35
+ cacheLogger.warn(
36
+ `Failed to check cache warmth for "${domain}" at "${directory}". Treating as cold.`,
37
+ error
38
+ );
39
+ return false;
40
+ }
41
+ };
42
+
43
+ const getWarmthDirectory = (
44
+ domain: CacheDomainId,
45
+ paths: HarnessCachePaths
46
+ ): string | undefined => {
47
+ switch (domain) {
48
+ case 'metro':
49
+ return paths.metroTransform;
50
+ default:
51
+ return undefined;
52
+ }
53
+ };
54
+
55
+ const isMissingPathError = (error: unknown): boolean =>
56
+ typeof error === 'object' &&
57
+ error !== null &&
58
+ 'code' in error &&
59
+ (error as { code?: unknown }).code === 'ENOENT';
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "files": [],
4
+ "include": [],
5
+ "references": [
6
+ {
7
+ "path": "../tools"
8
+ },
9
+ {
10
+ "path": "./tsconfig.lib.json"
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "rootDir": "src",
6
+ "outDir": "dist",
7
+ "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
8
+ "emitDeclarationOnly": false,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "types": ["node"]
11
+ },
12
+ "include": ["src/**/*.ts"],
13
+ "references": [
14
+ {
15
+ "path": "../tools/tsconfig.lib.json"
16
+ }
17
+ ]
18
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,18 @@
1
+ /// <reference types='vitest' />
2
+ import { defineConfig } from 'vite';
3
+
4
+ export default defineConfig(() => ({
5
+ root: __dirname,
6
+ cacheDir: '../../node_modules/.vite/packages/cache',
7
+ test: {
8
+ watch: false,
9
+ globals: true,
10
+ environment: 'node',
11
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
12
+ reporters: ['default'],
13
+ coverage: {
14
+ reportsDirectory: './test-output/vitest/coverage',
15
+ provider: 'v8' as const,
16
+ },
17
+ },
18
+ }));