@rn-iso/expo-build-cache 0.14.0 → 1.0.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 +21 -0
- package/README.md +2 -2
- package/index.js +147 -30
- package/package.json +4 -9
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Janic Duplessis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -37,8 +37,8 @@ The cache registers itself with [`rn-iso`](https://www.npmjs.com/package/rn-iso)
|
|
|
37
37
|
if it is installed, so it can be reported and trimmed:
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
npx rn-iso gc
|
|
41
|
-
npx rn-iso gc --
|
|
40
|
+
npx rn-iso gc # what it has grown to (reported on every run)
|
|
41
|
+
npx rn-iso gc --delete --older-than 30 # drop entries unused for 30 days
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
rn-iso is an optional peer. Without it the cache works exactly the same; it is
|
package/index.js
CHANGED
|
@@ -25,11 +25,95 @@ const os = require('node:os');
|
|
|
25
25
|
const path = require('node:path');
|
|
26
26
|
const { execFileSync } = require('node:child_process');
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// THIS RESOLUTION EXISTS THREE TIMES: here, in packages/metro/index.js,
|
|
29
|
+
// and in rn-iso's own src/paths.js (sharedBuildCache / sharedMetroCache). This
|
|
30
|
+
// package cannot import that module -- it has to work on a machine with no
|
|
31
|
+
// rn-iso installed at all -- so the duplication is deliberate, exactly like
|
|
32
|
+
// buildCacheKey below. Change one and you must change all three: when they
|
|
33
|
+
// drift, the CLI stores a build in one directory and this provider looks for it
|
|
34
|
+
// in another, and neither of them says so. rn-iso's
|
|
35
|
+
// test/cache-packages.test.js asserts all three agree.
|
|
36
|
+
//
|
|
37
|
+
// RN_ISO_BUILD_CACHE comes first because it did before the layout existed, and
|
|
38
|
+
// quietly ignoring an override someone already set reads as an empty cache
|
|
39
|
+
// rather than as an error.
|
|
40
|
+
function configDir() {
|
|
41
|
+
return process.env.RN_ISO_HOME || path.join(os.homedir(), '.rn-iso');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A function rather than a constant: resolving it at load time froze whatever
|
|
45
|
+
// the environment was when a metro.config.js or an Expo config first required
|
|
46
|
+
// this file, which is not necessarily what it is when a build runs.
|
|
47
|
+
function cacheRoot() {
|
|
48
|
+
return process.env.RN_ISO_BUILD_CACHE || path.join(configDir(), 'build-cache');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function entryDir(platform, key) {
|
|
52
|
+
return path.join(cacheRoot(), platform, key);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A simulator udid is a canonical UUID. Apple's hardware identifiers are not:
|
|
56
|
+
// they are 40 hex characters, or the 8-digits-dash-16-hex form newer devices
|
|
57
|
+
// use.
|
|
58
|
+
const SIMULATOR_UDID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
59
|
+
// adb's name for a running emulator.
|
|
60
|
+
const EMULATOR_SERIAL = /^emulator-\d+$/;
|
|
61
|
+
|
|
62
|
+
function slug(value) {
|
|
63
|
+
return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The Xcode configuration on iOS, the gradle variant on Android. `expo run:ios`
|
|
67
|
+
// defaults to Debug and `expo run:android` to debug, so an absent value is that
|
|
68
|
+
// and not "unknown".
|
|
69
|
+
function buildVariant(platform, options) {
|
|
70
|
+
const raw = platform === 'android'
|
|
71
|
+
? options.variant
|
|
72
|
+
: (options.configuration != null ? options.configuration : options.buildConfiguration);
|
|
73
|
+
return (typeof raw === 'string' ? slug(raw) : '') || 'debug';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A binary built for real hardware cannot run on a simulator, and the reverse is
|
|
77
|
+
// equally true, so the target class is part of the key. `runOptions.device` is
|
|
78
|
+
// the only signal Expo passes, and it is ambiguous by nature:
|
|
79
|
+
// absent -- the CLI targets a simulator or emulator. The default.
|
|
80
|
+
// "generic" -- a build-only simulator build.
|
|
81
|
+
// a udid or serial -- classifiable when it has the shape of a simulator id.
|
|
82
|
+
// a bare -d flag -- the CLI prompts, and the answer can be hardware.
|
|
83
|
+
// a name -- unclassifiable.
|
|
84
|
+
// The last two get a bucket of their own rather than sharing the simulator one:
|
|
85
|
+
// a wasted rebuild is cheap, and a binary that cannot launch is not. Two
|
|
86
|
+
// workspaces naming the same device still share their entries.
|
|
87
|
+
function buildTarget(options) {
|
|
88
|
+
if (typeof options.isSimulator === 'boolean') return options.isSimulator ? 'sim' : 'device';
|
|
89
|
+
const device = options.device;
|
|
90
|
+
if (device === undefined || device === null || device === false) return 'sim';
|
|
91
|
+
if (typeof device !== 'string') return 'prompted';
|
|
92
|
+
const name = device.trim();
|
|
93
|
+
if (name === '' || name === 'generic') return 'sim';
|
|
94
|
+
if (SIMULATOR_UDID.test(name) || EMULATOR_SERIAL.test(name)) return 'sim';
|
|
95
|
+
return `on-${slug(name)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The fingerprint covers what the project IS, never how it was built. Keying on
|
|
99
|
+
// it alone means a Release build answers a Debug resolve, and a device build
|
|
100
|
+
// answers a simulator one -- both silently, both producing a binary that cannot
|
|
101
|
+
// run. Only the run-option keys named above are read, so a future Expo CLI
|
|
102
|
+
// cannot change the key by adding one.
|
|
103
|
+
//
|
|
104
|
+
// rn-iso's own `rn-iso build-cache` command computes this key the same way
|
|
105
|
+
// (src/build-cache.js), so both entry points address the same entry. Changing
|
|
106
|
+
// one without the other splits them onto separate sets of entries.
|
|
107
|
+
function buildCacheKey(platform, fingerprintHash, runOptions) {
|
|
108
|
+
const opts = runOptions && typeof runOptions === 'object' ? runOptions : {};
|
|
109
|
+
return `${fingerprintHash}-${buildVariant(platform, opts)}-${buildTarget(opts)}`;
|
|
110
|
+
}
|
|
30
111
|
|
|
31
|
-
|
|
32
|
-
|
|
112
|
+
// Log lines name the entry, so they have to carry what distinguishes it: the
|
|
113
|
+
// fingerprint abbreviates fine, the variant and target do not -- a Debug miss
|
|
114
|
+
// and a Release miss on the same commit read identically without them.
|
|
115
|
+
function shortKey(key, fingerprintHash) {
|
|
116
|
+
return `${String(fingerprintHash).slice(0, 12)}${key.slice(String(fingerprintHash).length)}`;
|
|
33
117
|
}
|
|
34
118
|
|
|
35
119
|
// The cached artifact is the single .app / .apk inside the entry directory.
|
|
@@ -39,56 +123,89 @@ function artifactIn(dir) {
|
|
|
39
123
|
return found ? path.join(dir, found) : null;
|
|
40
124
|
}
|
|
41
125
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
126
|
+
// Registering makes this cache visible to `rn-iso gc`'s report, which is the
|
|
127
|
+
// only thing that will ever trim it.
|
|
44
128
|
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
129
|
+
// The manifest is written directly rather than through rn-iso's own module, for
|
|
130
|
+
// two reasons that both made the import silently do nothing:
|
|
131
|
+
// - the documented way to use the CLI is `npx rn-iso`, so it is usually not a
|
|
132
|
+
// dependency of the project and the specifier does not resolve at all
|
|
133
|
+
// - rn-iso is an ES module, so `require` of it throws ERR_REQUIRE_ESM on Node
|
|
134
|
+
// before 20.19
|
|
135
|
+
// A dynamic import fixes the second and not the first. The format is a stable
|
|
136
|
+
// contract, so writing it is the cheaper trade.
|
|
137
|
+
function registerCache({ dir, name, prune, note, entriesDepth }) {
|
|
52
138
|
try {
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
139
|
+
const home = configDir();
|
|
140
|
+
const file = path.join(home, 'caches.json');
|
|
141
|
+
let manifest = { version: 1, caches: [] };
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
144
|
+
if (Array.isArray(parsed?.caches)) manifest = { version: 1, caches: parsed.caches };
|
|
145
|
+
} catch {
|
|
146
|
+
// No manifest yet, or an unreadable one: start clean rather than fail.
|
|
147
|
+
}
|
|
148
|
+
// Keyed on the directory so repeated calls update rather than accumulate --
|
|
149
|
+
// these run on every build.
|
|
150
|
+
const others = manifest.caches.filter(c => c.dir !== dir);
|
|
151
|
+
const record = { dir, name, prune, note, registeredBy: process.cwd() };
|
|
152
|
+
// Only written when the caller sets it: an absent depth means the entries
|
|
153
|
+
// are the directory's immediate children, which is the common case.
|
|
154
|
+
if (entriesDepth) record.entriesDepth = entriesDepth;
|
|
155
|
+
others.push(record);
|
|
156
|
+
fs.mkdirSync(home, { recursive: true });
|
|
157
|
+
fs.writeFileSync(file, JSON.stringify({ version: 1, caches: others }, null, 2));
|
|
61
158
|
} catch {
|
|
62
|
-
//
|
|
159
|
+
// A cache that cannot announce itself still works; it is just invisible.
|
|
63
160
|
}
|
|
64
161
|
}
|
|
65
162
|
|
|
163
|
+
// Keyed on the directory rather than a plain boolean, so a root that changes
|
|
164
|
+
// under a long-lived process still reaches the manifest.
|
|
165
|
+
let registeredDir = null;
|
|
166
|
+
function registerOnce() {
|
|
167
|
+
const root = cacheRoot();
|
|
168
|
+
if (registeredDir === root) return;
|
|
169
|
+
registeredDir = root;
|
|
170
|
+
registerCache({
|
|
171
|
+
dir: root,
|
|
172
|
+
name: 'Expo build cache',
|
|
173
|
+
// Every entry is an independent directory keyed by fingerprint, so old ones
|
|
174
|
+
// can be trimmed individually. They sit two levels down --
|
|
175
|
+
// <root>/<platform>/<key> -- and gc has to be told, or it treats ios/ and
|
|
176
|
+
// android/ as the entries and one removal takes a whole platform.
|
|
177
|
+
prune: 'entries',
|
|
178
|
+
entriesDepth: 2,
|
|
179
|
+
note: 'built .app/.apk keyed on the native fingerprint',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
66
183
|
// The hash the CLI passes is stable once .fingerprintignore excludes generated
|
|
67
184
|
// files that embed absolute paths -- ios/Podfile.lock is the usual culprit, as
|
|
68
185
|
// pod checksums can carry machine-specific paths. Use it directly rather than
|
|
69
186
|
// recomputing: fingerprinting walks node_modules and is otherwise the single
|
|
70
187
|
// largest cost of a cache hit.
|
|
71
|
-
async function resolveBuildCache({ platform, fingerprintHash }) {
|
|
188
|
+
async function resolveBuildCache({ platform, fingerprintHash, runOptions }) {
|
|
72
189
|
registerOnce();
|
|
73
|
-
const key = fingerprintHash;
|
|
190
|
+
const key = buildCacheKey(platform, fingerprintHash, runOptions);
|
|
74
191
|
const hit = artifactIn(entryDir(platform, key));
|
|
75
192
|
if (hit) {
|
|
76
|
-
console.log(`[build-cache] hit ${platform} ${key
|
|
193
|
+
console.log(`[build-cache] hit ${platform} ${shortKey(key, fingerprintHash)}`);
|
|
77
194
|
// Touch on hit so age-based trimming can tell a working entry from a dead
|
|
78
195
|
// one. Without this, the entries earning their keep look identical to the
|
|
79
196
|
// ones nothing has used in months.
|
|
80
197
|
fs.utimesSync(path.dirname(hit), new Date(), new Date());
|
|
81
198
|
return hit;
|
|
82
199
|
}
|
|
83
|
-
console.log(`[build-cache] miss ${platform} ${key
|
|
200
|
+
console.log(`[build-cache] miss ${platform} ${shortKey(key, fingerprintHash)}`);
|
|
84
201
|
return null;
|
|
85
202
|
}
|
|
86
203
|
|
|
87
|
-
async function uploadBuildCache({ platform, fingerprintHash, buildPath }) {
|
|
204
|
+
async function uploadBuildCache({ platform, fingerprintHash, buildPath, runOptions }) {
|
|
88
205
|
registerOnce();
|
|
89
206
|
if (!buildPath || !fs.existsSync(buildPath)) return null;
|
|
90
207
|
|
|
91
|
-
const key = fingerprintHash;
|
|
208
|
+
const key = buildCacheKey(platform, fingerprintHash, runOptions);
|
|
92
209
|
const dest = entryDir(platform, key);
|
|
93
210
|
if (artifactIn(dest)) return artifactIn(dest);
|
|
94
211
|
|
|
@@ -104,8 +221,8 @@ async function uploadBuildCache({ platform, fingerprintHash, buildPath }) {
|
|
|
104
221
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
105
222
|
fs.renameSync(staging, dest);
|
|
106
223
|
|
|
107
|
-
console.log(`[build-cache] stored ${platform} ${key
|
|
224
|
+
console.log(`[build-cache] stored ${platform} ${shortKey(key, fingerprintHash)}`);
|
|
108
225
|
return artifactIn(dest);
|
|
109
226
|
}
|
|
110
227
|
|
|
111
|
-
module.exports = { resolveBuildCache, uploadBuildCache,
|
|
228
|
+
module.exports = { resolveBuildCache, uploadBuildCache, buildCacheKey, cacheRoot };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rn-iso/expo-build-cache",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Local Expo build cache provider: installs a cached .app/.apk instead of compiling when no native input changed.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -8,12 +8,7 @@
|
|
|
8
8
|
"README.md"
|
|
9
9
|
],
|
|
10
10
|
"license": "MIT",
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
},
|
|
14
|
-
"peerDependenciesMeta": {
|
|
15
|
-
"rn-iso": {
|
|
16
|
-
"optional": true
|
|
17
|
-
}
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test test/*.test.js"
|
|
18
13
|
}
|
|
19
|
-
}
|
|
14
|
+
}
|