@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.
- package/LICENSE +20 -0
- package/README.md +26 -0
- package/dist/__tests__/boundary.test.d.ts +2 -0
- package/dist/__tests__/boundary.test.d.ts.map +1 -0
- package/dist/__tests__/boundary.test.js +77 -0
- package/dist/__tests__/domains/metro.test.d.ts +2 -0
- package/dist/__tests__/domains/metro.test.d.ts.map +1 -0
- package/dist/__tests__/domains/metro.test.js +234 -0
- package/dist/__tests__/index.test.d.ts +2 -0
- package/dist/__tests__/index.test.d.ts.map +1 -0
- package/dist/__tests__/index.test.js +40 -0
- package/dist/__tests__/paths.test.d.ts +2 -0
- package/dist/__tests__/paths.test.d.ts.map +1 -0
- package/dist/__tests__/paths.test.js +12 -0
- package/dist/__tests__/plan.test.d.ts +2 -0
- package/dist/__tests__/plan.test.d.ts.map +1 -0
- package/dist/__tests__/plan.test.js +197 -0
- package/dist/__tests__/warmth.test.d.ts +2 -0
- package/dist/__tests__/warmth.test.d.ts.map +1 -0
- package/dist/__tests__/warmth.test.js +36 -0
- package/dist/domains/metro.d.ts +8 -0
- package/dist/domains/metro.d.ts.map +1 -0
- package/dist/domains/metro.js +151 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +26 -0
- package/dist/plan.d.ts +54 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +177 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/dist/warmth.d.ts +8 -0
- package/dist/warmth.d.ts.map +1 -0
- package/dist/warmth.js +40 -0
- package/eslint.config.mjs +20 -0
- package/package.json +22 -0
- package/src/__tests__/boundary.test.ts +95 -0
- package/src/__tests__/domains/metro.test.ts +298 -0
- package/src/__tests__/index.test.ts +54 -0
- package/src/__tests__/paths.test.ts +18 -0
- package/src/__tests__/plan.test.ts +330 -0
- package/src/__tests__/warmth.test.ts +47 -0
- package/src/domains/metro.ts +202 -0
- package/src/index.ts +84 -0
- package/src/paths.ts +43 -0
- package/src/plan.ts +285 -0
- package/src/warmth.ts +59 -0
- package/tsconfig.json +13 -0
- package/tsconfig.lib.json +18 -0
- package/vite.config.ts +18 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { createHarnessCachePaths } from '../paths.js';
|
|
6
|
+
import { isDomainWarm } from '../warmth.js';
|
|
7
|
+
describe('isDomainWarm', () => {
|
|
8
|
+
let projectRoot;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
it('is false when the domain directory does not exist', () => {
|
|
16
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
17
|
+
expect(isDomainWarm('metro', paths)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it('is false when the domain directory exists but is empty', () => {
|
|
20
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
21
|
+
fs.mkdirSync(paths.metroTransform, { recursive: true });
|
|
22
|
+
expect(isDomainWarm('metro', paths)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
it('is true when the domain directory exists and has entries', () => {
|
|
25
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
26
|
+
fs.mkdirSync(paths.metroTransform, { recursive: true });
|
|
27
|
+
fs.writeFileSync(path.join(paths.metroTransform, 'entry'), 'data');
|
|
28
|
+
expect(isDomainWarm('metro', paths)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
it('is false when the path exists but is a file, not a directory', () => {
|
|
31
|
+
const paths = createHarnessCachePaths(projectRoot);
|
|
32
|
+
fs.mkdirSync(path.dirname(paths.metroTransform), { recursive: true });
|
|
33
|
+
fs.writeFileSync(paths.metroTransform, 'not a directory');
|
|
34
|
+
expect(isDomainWarm('metro', paths)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const METRO_KEY_FILENAMES: readonly string[];
|
|
2
|
+
export declare const computeMetroStaticInputs: (options: {
|
|
3
|
+
projectRoot: string;
|
|
4
|
+
repoRoot: string;
|
|
5
|
+
bundlerMetroVersion: string;
|
|
6
|
+
salt?: string;
|
|
7
|
+
}) => Record<string, string>;
|
|
8
|
+
//# sourceMappingURL=metro.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metro.d.ts","sourceRoot":"","sources":["../../src/domains/metro.ts"],"names":[],"mappings":"AA0DA,eAAO,MAAM,mBAAmB,EAAE,SAAS,MAAM,EAKhD,CAAC;AAgGF,eAAO,MAAM,wBAAwB,GAAI,SAAS;IAChD,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAqCxB,CAAC"}
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
const cacheLogger = logger.child('cache');
|
|
6
|
+
// Metro resolves its own config by walking UP from the project root (see
|
|
7
|
+
// Metro's loadConfig docs), never by scanning the tree downward. Lockfiles in
|
|
8
|
+
// a monorepo live at the repo root, i.e. somewhere on projectRoot's ancestor
|
|
9
|
+
// chain. So instead of a downward `hashFiles('**/…')`-style walk (which also
|
|
10
|
+
// has to dodge node_modules/.git/dist/build to avoid vendored copies), we
|
|
11
|
+
// walk upward from projectRoot to repoRoot (inclusive) and hash every
|
|
12
|
+
// candidate key file that exists along that chain. Sibling workspace
|
|
13
|
+
// packages are never on this chain, so their configs can't spuriously
|
|
14
|
+
// invalidate this project's cache key.
|
|
15
|
+
const LOCKFILE_NAMES = [
|
|
16
|
+
'bun.lock',
|
|
17
|
+
'bun.lockb',
|
|
18
|
+
'package-lock.json',
|
|
19
|
+
'npm-shrinkwrap.json',
|
|
20
|
+
'pnpm-lock.yaml',
|
|
21
|
+
'yarn.lock',
|
|
22
|
+
];
|
|
23
|
+
// Metro config candidates: `metro.config.*` at the directory root, plus the
|
|
24
|
+
// `.config/metro.*` directory convention Metro also resolves.
|
|
25
|
+
const METRO_CONFIG_EXTENSIONS = [
|
|
26
|
+
'js',
|
|
27
|
+
'cjs',
|
|
28
|
+
'mjs',
|
|
29
|
+
'ts',
|
|
30
|
+
'cts',
|
|
31
|
+
'mts',
|
|
32
|
+
'json',
|
|
33
|
+
];
|
|
34
|
+
const METRO_CONFIG_NAMES = METRO_CONFIG_EXTENSIONS.map((extension) => `metro.config.${extension}`);
|
|
35
|
+
const METRO_CONFIG_DIR_NAMES = METRO_CONFIG_EXTENSIONS.map((extension) => `.config/metro.${extension}`);
|
|
36
|
+
// Babel only resolves root configs named `babel.config.*` (note: no `.ts` —
|
|
37
|
+
// Babel does not resolve `babel.config.ts`). File-relative `.babelrc` and the
|
|
38
|
+
// `package.json` `babel`/`metro` fields are intentionally out of scope.
|
|
39
|
+
const BABEL_CONFIG_NAMES = [
|
|
40
|
+
'babel.config.json',
|
|
41
|
+
'babel.config.js',
|
|
42
|
+
'babel.config.cjs',
|
|
43
|
+
'babel.config.mjs',
|
|
44
|
+
'babel.config.cts',
|
|
45
|
+
];
|
|
46
|
+
// Relative-to-examined-directory paths checked at every directory on the
|
|
47
|
+
// ancestor chain. All candidates that exist are hashed (rather than
|
|
48
|
+
// emulating Metro/Babel's own first-match-wins resolution), which is
|
|
49
|
+
// deterministic and strictly more conservative.
|
|
50
|
+
export const METRO_KEY_FILENAMES = [
|
|
51
|
+
...LOCKFILE_NAMES,
|
|
52
|
+
...METRO_CONFIG_NAMES,
|
|
53
|
+
...METRO_CONFIG_DIR_NAMES,
|
|
54
|
+
...BABEL_CONFIG_NAMES,
|
|
55
|
+
];
|
|
56
|
+
/**
|
|
57
|
+
* Directories to examine: projectRoot and every ancestor directory up to and
|
|
58
|
+
* including repoRoot. Defensive: if projectRoot isn't inside repoRoot (or
|
|
59
|
+
* repoRoot can't be related to it), fall back to examining projectRoot alone
|
|
60
|
+
* rather than guessing.
|
|
61
|
+
*/
|
|
62
|
+
const collectAncestorDirectories = (projectRoot, repoRoot) => {
|
|
63
|
+
const resolvedProjectRoot = path.resolve(projectRoot);
|
|
64
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
65
|
+
if (resolvedProjectRoot === resolvedRepoRoot) {
|
|
66
|
+
return [resolvedProjectRoot];
|
|
67
|
+
}
|
|
68
|
+
const relative = path.relative(resolvedRepoRoot, resolvedProjectRoot);
|
|
69
|
+
const isProjectRootInsideRepoRoot = relative !== '' &&
|
|
70
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
71
|
+
relative !== '..' &&
|
|
72
|
+
!path.isAbsolute(relative);
|
|
73
|
+
if (!isProjectRootInsideRepoRoot) {
|
|
74
|
+
return [resolvedProjectRoot];
|
|
75
|
+
}
|
|
76
|
+
const directories = [];
|
|
77
|
+
let current = resolvedProjectRoot;
|
|
78
|
+
for (;;) {
|
|
79
|
+
directories.push(current);
|
|
80
|
+
if (current === resolvedRepoRoot) {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
const parent = path.dirname(current);
|
|
84
|
+
if (parent === current) {
|
|
85
|
+
// Reached the filesystem root without hitting repoRoot; defensive
|
|
86
|
+
// guard against an infinite loop.
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
current = parent;
|
|
90
|
+
}
|
|
91
|
+
return directories;
|
|
92
|
+
};
|
|
93
|
+
const collectKeyFiles = (directories, repoRoot) => {
|
|
94
|
+
const matches = [];
|
|
95
|
+
for (const directory of directories) {
|
|
96
|
+
for (const candidate of METRO_KEY_FILENAMES) {
|
|
97
|
+
const fullPath = path.join(directory, candidate);
|
|
98
|
+
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
matches.push({
|
|
102
|
+
relativePath: path
|
|
103
|
+
.relative(repoRoot, fullPath)
|
|
104
|
+
.split(path.sep)
|
|
105
|
+
.join('/'),
|
|
106
|
+
content: fs.readFileSync(fullPath),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return matches;
|
|
111
|
+
};
|
|
112
|
+
const hashKeyFiles = (matches) => {
|
|
113
|
+
const hash = createHash('sha256');
|
|
114
|
+
for (const match of matches
|
|
115
|
+
.slice()
|
|
116
|
+
.sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1))) {
|
|
117
|
+
hash.update(match.relativePath);
|
|
118
|
+
hash.update('\0');
|
|
119
|
+
hash.update(match.content);
|
|
120
|
+
hash.update('\0');
|
|
121
|
+
}
|
|
122
|
+
return hash.digest('hex');
|
|
123
|
+
};
|
|
124
|
+
export const computeMetroStaticInputs = (options) => {
|
|
125
|
+
try {
|
|
126
|
+
const resolvedProjectRoot = path.resolve(options.projectRoot);
|
|
127
|
+
const resolvedRepoRoot = path.resolve(options.repoRoot);
|
|
128
|
+
// Validate both roots exist so a missing directory degrades to the
|
|
129
|
+
// always-miss fallback below instead of silently hashing zero files.
|
|
130
|
+
if (!fs.statSync(resolvedProjectRoot).isDirectory()) {
|
|
131
|
+
throw new Error(`projectRoot "${resolvedProjectRoot}" is not a directory`);
|
|
132
|
+
}
|
|
133
|
+
if (!fs.statSync(resolvedRepoRoot).isDirectory()) {
|
|
134
|
+
throw new Error(`repoRoot "${resolvedRepoRoot}" is not a directory`);
|
|
135
|
+
}
|
|
136
|
+
const directories = collectAncestorDirectories(resolvedProjectRoot, resolvedRepoRoot);
|
|
137
|
+
const matches = collectKeyFiles(directories, resolvedRepoRoot);
|
|
138
|
+
return {
|
|
139
|
+
lockfileHash: hashKeyFiles(matches),
|
|
140
|
+
bundlerMetroVersion: options.bundlerMetroVersion,
|
|
141
|
+
...(options.salt ? { salt: options.salt } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
cacheLogger.warn(`Failed to compute Metro cache key inputs for "${options.projectRoot}". Falling back to an always-miss key.`, error);
|
|
146
|
+
return {
|
|
147
|
+
lockfileHash: 'unavailable',
|
|
148
|
+
bundlerMetroVersion: options.bundlerMetroVersion,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type CacheDomainId, type HarnessCachePaths } from './paths.js';
|
|
2
|
+
import { type CacheKeyInputs, type CacheSnapshot, type CacheSnapshotEntry, type DomainRestorePlan, type DomainSavePlan, type RestorePlan, type SavePlan, type SavePolicy } from './plan.js';
|
|
3
|
+
import { computeMetroStaticInputs, METRO_KEY_FILENAMES } from './domains/metro.js';
|
|
4
|
+
export { computeMetroStaticInputs, METRO_KEY_FILENAMES };
|
|
5
|
+
export type { CacheDomainId, HarnessCachePaths };
|
|
6
|
+
export type { CacheKeyInputs, CacheSnapshot, CacheSnapshotEntry, DomainRestorePlan, DomainSavePlan, RestorePlan, SavePlan, SavePolicy, };
|
|
7
|
+
export interface HarnessCache {
|
|
8
|
+
readonly paths: HarnessCachePaths;
|
|
9
|
+
isWarm(domain: CacheDomainId): boolean;
|
|
10
|
+
ensureDomainDirectories(domain: CacheDomainId): void;
|
|
11
|
+
planRestore(inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>): RestorePlan;
|
|
12
|
+
snapshot(): CacheSnapshot;
|
|
13
|
+
planSave(before: CacheSnapshot, policy: SavePolicy, inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>): SavePlan;
|
|
14
|
+
writeKeysFile(plan: RestorePlan | SavePlan): void;
|
|
15
|
+
}
|
|
16
|
+
export declare const createHarnessCache: (options: {
|
|
17
|
+
projectRoot: string;
|
|
18
|
+
}) => HarnessCache;
|
|
19
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAKL,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,UAAU,EAChB,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAEnF,OAAO,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,CAAC;AAEzD,YAAY,EAAE,aAAa,EAAE,iBAAiB,EAAE,CAAC;AACjD,YAAY,EACV,cAAc,EACd,aAAa,EACb,kBAAkB,EAClB,iBAAiB,EACjB,cAAc,EACd,WAAW,EACX,QAAQ,EACR,UAAU,GACX,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAAC;IAClC,MAAM,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC;IACvC,uBAAuB,CAAC,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;IACrD,WAAW,CACT,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,GACrD,WAAW,CAAC;IACf,QAAQ,IAAI,aAAa,CAAC;IAC1B,QAAQ,CACN,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,UAAU,EAClB,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,GACrD,QAAQ,CAAC;IACZ,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,QAAQ,GAAG,IAAI,CAAC;CACnD;AAID,eAAO,MAAM,kBAAkB,GAAI,SAAS;IAC1C,WAAW,EAAE,MAAM,CAAC;CACrB,KAAG,YAwBH,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { logger } from '@react-native-harness/tools';
|
|
3
|
+
import { createHarnessCachePaths, getDomainDirectories, } from './paths.js';
|
|
4
|
+
import { planRestore, planSave, snapshot, writeKeysFile, } from './plan.js';
|
|
5
|
+
import { isDomainWarm } from './warmth.js';
|
|
6
|
+
import { computeMetroStaticInputs, METRO_KEY_FILENAMES } from './domains/metro.js';
|
|
7
|
+
export { computeMetroStaticInputs, METRO_KEY_FILENAMES };
|
|
8
|
+
const cacheLogger = logger.child('cache');
|
|
9
|
+
export const createHarnessCache = (options) => {
|
|
10
|
+
const paths = createHarnessCachePaths(options.projectRoot);
|
|
11
|
+
return {
|
|
12
|
+
paths,
|
|
13
|
+
isWarm: (domain) => isDomainWarm(domain, paths),
|
|
14
|
+
ensureDomainDirectories: (domain) => {
|
|
15
|
+
for (const directory of getDomainDirectories(domain, paths)) {
|
|
16
|
+
try {
|
|
17
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
cacheLogger.warn(`Failed to create cache directory "${directory}". Continuing with a cold cache.`, error);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
planRestore: (inputs) => planRestore(paths, inputs),
|
|
25
|
+
snapshot: () => snapshot(paths),
|
|
26
|
+
planSave: (before, policy, inputs) => planSave(paths, before, policy, inputs),
|
|
27
|
+
writeKeysFile: (plan) => writeKeysFile(paths, plan),
|
|
28
|
+
};
|
|
29
|
+
};
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type CacheDomainId = 'metro';
|
|
2
|
+
export interface HarnessCachePaths {
|
|
3
|
+
readonly root: string;
|
|
4
|
+
readonly metroTransform: string;
|
|
5
|
+
readonly metroFileMap: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const createHarnessCachePaths: (projectRoot: string) => HarnessCachePaths;
|
|
8
|
+
/**
|
|
9
|
+
* Directories that make up a cache domain. `metro` covers both the transform
|
|
10
|
+
* cache and the file-map (haste) cache so `ensureDomainDirectories` prepares
|
|
11
|
+
* both ahead of a run.
|
|
12
|
+
*/
|
|
13
|
+
export declare const getDomainDirectories: (domain: CacheDomainId, paths: HarnessCachePaths) => readonly string[];
|
|
14
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC;AAEpC,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED,eAAO,MAAM,uBAAuB,GAClC,aAAa,MAAM,KAClB,iBAQF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAC/B,QAAQ,aAAa,EACrB,OAAO,iBAAiB,KACvB,SAAS,MAAM,EAOjB,CAAC"}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const HARNESS_DIRNAME = '.harness';
|
|
3
|
+
const CACHE_DIRNAME = 'cache';
|
|
4
|
+
const METRO_TRANSFORM_DIRNAME = 'metro';
|
|
5
|
+
const METRO_FILE_MAP_DIRNAME = 'metro-file-map';
|
|
6
|
+
export const createHarnessCachePaths = (projectRoot) => {
|
|
7
|
+
const root = path.join(projectRoot, HARNESS_DIRNAME, CACHE_DIRNAME);
|
|
8
|
+
return {
|
|
9
|
+
root,
|
|
10
|
+
metroTransform: path.join(root, METRO_TRANSFORM_DIRNAME),
|
|
11
|
+
metroFileMap: path.join(root, METRO_FILE_MAP_DIRNAME),
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Directories that make up a cache domain. `metro` covers both the transform
|
|
16
|
+
* cache and the file-map (haste) cache so `ensureDomainDirectories` prepares
|
|
17
|
+
* both ahead of a run.
|
|
18
|
+
*/
|
|
19
|
+
export const getDomainDirectories = (domain, paths) => {
|
|
20
|
+
switch (domain) {
|
|
21
|
+
case 'metro':
|
|
22
|
+
return [paths.metroTransform, paths.metroFileMap];
|
|
23
|
+
default:
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
};
|
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { CacheDomainId, HarnessCachePaths } from './paths.js';
|
|
2
|
+
export interface CacheKeyInputs {
|
|
3
|
+
os: string;
|
|
4
|
+
staticInputs: Record<string, string>;
|
|
5
|
+
}
|
|
6
|
+
export interface DomainRestorePlan {
|
|
7
|
+
paths: string[];
|
|
8
|
+
restoreKey: string;
|
|
9
|
+
restorePrefixes: string[];
|
|
10
|
+
accretive: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface RestorePlan {
|
|
13
|
+
version: 1;
|
|
14
|
+
domains: Partial<Record<CacheDomainId, DomainRestorePlan>>;
|
|
15
|
+
}
|
|
16
|
+
export interface CacheSnapshotEntry {
|
|
17
|
+
path: string;
|
|
18
|
+
sizeBytes: number;
|
|
19
|
+
mtimeMs: number;
|
|
20
|
+
}
|
|
21
|
+
export type CacheSnapshot = Partial<Record<CacheDomainId, CacheSnapshotEntry[]>>;
|
|
22
|
+
export interface SavePolicy {
|
|
23
|
+
mode: 'default-branch' | 'always' | 'never';
|
|
24
|
+
isDefaultBranch: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface DomainSavePlan {
|
|
27
|
+
shouldSave: boolean;
|
|
28
|
+
saveKey: string;
|
|
29
|
+
delta: {
|
|
30
|
+
addedEntries: number;
|
|
31
|
+
removedEntries: number;
|
|
32
|
+
addedBytes: number;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface SavePlan {
|
|
36
|
+
version: 1;
|
|
37
|
+
domains: Partial<Record<CacheDomainId, DomainSavePlan>>;
|
|
38
|
+
}
|
|
39
|
+
export declare const planRestore: (paths: HarnessCachePaths, inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>) => RestorePlan;
|
|
40
|
+
/**
|
|
41
|
+
* Synchronous snapshot of on-disk cache contents, keyed by domain. Never
|
|
42
|
+
* throws: a domain whose directories cannot be read is reported as empty.
|
|
43
|
+
*/
|
|
44
|
+
export declare const snapshot: (paths: HarnessCachePaths) => CacheSnapshot;
|
|
45
|
+
export declare const planSave: (paths: HarnessCachePaths, before: CacheSnapshot, policy: SavePolicy, inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>) => SavePlan;
|
|
46
|
+
/**
|
|
47
|
+
* Written to `<cache-root>/keys.json`, outside any domain's cached
|
|
48
|
+
* subdirectories, so it is never itself part of what gets restored/saved.
|
|
49
|
+
* It is a secondary/debug artifact; the actual data flow between CI steps
|
|
50
|
+
* goes through GITHUB_OUTPUT, not by re-reading this file. Cache I/O must
|
|
51
|
+
* never fail a run, so write failures are logged and swallowed.
|
|
52
|
+
*/
|
|
53
|
+
export declare const writeKeysFile: (paths: HarnessCachePaths, plan: RestorePlan | SavePlan) => void;
|
|
54
|
+
//# sourceMappingURL=plan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan.d.ts","sourceRoot":"","sources":["../src/plan.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAOnE,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,CAAC,CAAC;IACX,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,iBAAiB,CAAC,CAAC,CAAC;CAC5D;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,kBAAkB,EAAE,CAAC,CAAC,CAAC;AAEjF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,gBAAgB,GAAG,QAAQ,GAAG,OAAO,CAAC;IAC5C,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE;QACL,YAAY,EAAE,MAAM,CAAC;QACrB,cAAc,EAAE,MAAM,CAAC;QACvB,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC;CACH;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,CAAC,CAAC;IACX,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,CAAC;CACzD;AA+ED,eAAO,MAAM,WAAW,GACtB,OAAO,iBAAiB,EACxB,QAAQ,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,KACrD,WAqBF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,OAAO,iBAAiB,KAAG,aAoBnD,CAAC;AAgDF,eAAO,MAAM,QAAQ,GACnB,OAAO,iBAAiB,EACxB,QAAQ,aAAa,EACrB,QAAQ,UAAU,EAClB,QAAQ,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,KACrD,QA0BF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,GACxB,OAAO,iBAAiB,EACxB,MAAM,WAAW,GAAG,QAAQ,KAC3B,IASF,CAAC"}
|
package/dist/plan.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
const cacheLogger = logger.child('cache');
|
|
7
|
+
const KEYS_FILENAME = 'keys.json';
|
|
8
|
+
const HASH_LENGTH = 16;
|
|
9
|
+
const ALL_DOMAINS = ['metro'];
|
|
10
|
+
/**
|
|
11
|
+
* Accretive domains only ever grow (transform/file-map caches keyed by
|
|
12
|
+
* content hash) so a save is always safe to key off the static hash plus a
|
|
13
|
+
* content hash, and prior saves can be found via a prefix match on the
|
|
14
|
+
* static hash. Non-accretive domains would need an exact key match instead.
|
|
15
|
+
* A domain not yet handled here defaults to non-accretive (exact-match),
|
|
16
|
+
* matching the safe-fallback style of `getDomainDirectories`/
|
|
17
|
+
* `getWarmthDirectory`: prefix-matching into an unknown domain's saves would
|
|
18
|
+
* not be a neutral no-op, so the conservative default is to require an exact
|
|
19
|
+
* key match rather than silently opt a future domain into accretive lookup.
|
|
20
|
+
*/
|
|
21
|
+
const isAccretiveDomain = (domain) => {
|
|
22
|
+
switch (domain) {
|
|
23
|
+
case 'metro':
|
|
24
|
+
return true;
|
|
25
|
+
default:
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const hashSortedEntries = (entries) => createHash('sha256')
|
|
30
|
+
.update(entries
|
|
31
|
+
.slice()
|
|
32
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
33
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
34
|
+
.join('\n'))
|
|
35
|
+
.digest('hex')
|
|
36
|
+
.slice(0, HASH_LENGTH);
|
|
37
|
+
const buildStaticHashKey = (domain, inputs) => {
|
|
38
|
+
const hash = hashSortedEntries(Object.entries(inputs.staticInputs));
|
|
39
|
+
return `harness-${domain}-${inputs.os}-${hash}`;
|
|
40
|
+
};
|
|
41
|
+
const buildContentHash = (entries) => hashSortedEntries(entries.map((entry) => [entry.path, String(entry.sizeBytes)]));
|
|
42
|
+
const walkDirectory = (root, directory, entries) => {
|
|
43
|
+
let dirents;
|
|
44
|
+
try {
|
|
45
|
+
dirents = fs.readdirSync(directory, { withFileTypes: true });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Missing/unreadable directory is treated as empty rather than an error.
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const dirent of dirents) {
|
|
52
|
+
const fullPath = path.join(directory, dirent.name);
|
|
53
|
+
if (dirent.isDirectory()) {
|
|
54
|
+
walkDirectory(root, fullPath, entries);
|
|
55
|
+
}
|
|
56
|
+
else if (dirent.isFile()) {
|
|
57
|
+
const stat = fs.statSync(fullPath);
|
|
58
|
+
entries.push({
|
|
59
|
+
path: path.relative(root, fullPath),
|
|
60
|
+
sizeBytes: stat.size,
|
|
61
|
+
mtimeMs: stat.mtimeMs,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
export const planRestore = (paths, inputs) => {
|
|
67
|
+
const domains = {};
|
|
68
|
+
for (const domain of ALL_DOMAINS) {
|
|
69
|
+
const domainInputs = inputs[domain];
|
|
70
|
+
if (!domainInputs) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const restoreKey = buildStaticHashKey(domain, domainInputs);
|
|
74
|
+
const accretive = isAccretiveDomain(domain);
|
|
75
|
+
domains[domain] = {
|
|
76
|
+
paths: [...getDomainDirectories(domain, paths)],
|
|
77
|
+
restoreKey,
|
|
78
|
+
restorePrefixes: accretive ? [`${restoreKey}-`] : [],
|
|
79
|
+
accretive,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { version: 1, domains };
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Synchronous snapshot of on-disk cache contents, keyed by domain. Never
|
|
86
|
+
* throws: a domain whose directories cannot be read is reported as empty.
|
|
87
|
+
*/
|
|
88
|
+
export const snapshot = (paths) => {
|
|
89
|
+
const result = {};
|
|
90
|
+
for (const domain of ALL_DOMAINS) {
|
|
91
|
+
try {
|
|
92
|
+
const entries = [];
|
|
93
|
+
for (const directory of getDomainDirectories(domain, paths)) {
|
|
94
|
+
walkDirectory(paths.root, directory, entries);
|
|
95
|
+
}
|
|
96
|
+
result[domain] = entries;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
cacheLogger.warn(`Failed to snapshot cache domain "${domain}". Treating as empty.`, error);
|
|
100
|
+
result[domain] = [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
};
|
|
105
|
+
const diffSnapshots = (before, after) => {
|
|
106
|
+
const beforeByPath = new Map(before.map((entry) => [entry.path, entry]));
|
|
107
|
+
const afterByPath = new Map(after.map((entry) => [entry.path, entry]));
|
|
108
|
+
let addedEntries = 0;
|
|
109
|
+
let removedEntries = 0;
|
|
110
|
+
let addedBytes = 0;
|
|
111
|
+
for (const [entryPath, afterEntry] of afterByPath) {
|
|
112
|
+
const beforeEntry = beforeByPath.get(entryPath);
|
|
113
|
+
if (!beforeEntry || beforeEntry.sizeBytes !== afterEntry.sizeBytes) {
|
|
114
|
+
addedEntries += 1;
|
|
115
|
+
addedBytes += afterEntry.sizeBytes;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const entryPath of beforeByPath.keys()) {
|
|
119
|
+
if (!afterByPath.has(entryPath)) {
|
|
120
|
+
removedEntries += 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { addedEntries, removedEntries, addedBytes };
|
|
124
|
+
};
|
|
125
|
+
const shouldSaveForPolicy = (policy, delta) => {
|
|
126
|
+
if (delta.addedEntries + delta.removedEntries === 0) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
switch (policy.mode) {
|
|
130
|
+
case 'always':
|
|
131
|
+
return true;
|
|
132
|
+
case 'default-branch':
|
|
133
|
+
return policy.isDefaultBranch;
|
|
134
|
+
case 'never':
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
export const planSave = (paths, before, policy, inputs) => {
|
|
139
|
+
const after = snapshot(paths);
|
|
140
|
+
const domains = {};
|
|
141
|
+
for (const domain of ALL_DOMAINS) {
|
|
142
|
+
const domainInputs = inputs[domain];
|
|
143
|
+
if (!domainInputs) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const beforeEntries = before[domain] ?? [];
|
|
147
|
+
const afterEntries = after[domain] ?? [];
|
|
148
|
+
const delta = diffSnapshots(beforeEntries, afterEntries);
|
|
149
|
+
const staticHashKey = buildStaticHashKey(domain, domainInputs);
|
|
150
|
+
const accretive = isAccretiveDomain(domain);
|
|
151
|
+
domains[domain] = {
|
|
152
|
+
shouldSave: shouldSaveForPolicy(policy, delta),
|
|
153
|
+
saveKey: accretive
|
|
154
|
+
? `${staticHashKey}-${buildContentHash(afterEntries)}`
|
|
155
|
+
: staticHashKey,
|
|
156
|
+
delta,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { version: 1, domains };
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Written to `<cache-root>/keys.json`, outside any domain's cached
|
|
163
|
+
* subdirectories, so it is never itself part of what gets restored/saved.
|
|
164
|
+
* It is a secondary/debug artifact; the actual data flow between CI steps
|
|
165
|
+
* goes through GITHUB_OUTPUT, not by re-reading this file. Cache I/O must
|
|
166
|
+
* never fail a run, so write failures are logged and swallowed.
|
|
167
|
+
*/
|
|
168
|
+
export const writeKeysFile = (paths, plan) => {
|
|
169
|
+
const filePath = path.join(paths.root, KEYS_FILENAME);
|
|
170
|
+
try {
|
|
171
|
+
fs.mkdirSync(paths.root, { recursive: true });
|
|
172
|
+
fs.writeFileSync(filePath, JSON.stringify(plan, null, 2));
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
cacheLogger.warn(`Failed to write cache keys file "${filePath}".`, error);
|
|
176
|
+
}
|
|
177
|
+
};
|