@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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -2
  3. package/index.js +147 -30
  4. 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 --caches # what it has grown to
41
- npx rn-iso gc --caches --delete --older-than 30 # drop entries unused for 30 days
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
- const CACHE_ROOT =
29
- process.env.RN_ISO_BUILD_CACHE || path.join(os.homedir(), '.rn-iso-build-cache');
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
- function entryDir(platform, fingerprintHash) {
32
- return path.join(CACHE_ROOT, platform, fingerprintHash);
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
- // Tell rn-iso this cache exists, so `gc --caches` can report and trim it. Every
43
- // entry is an independent directory keyed by fingerprint, hence prune: entries.
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
- // Best effort by design: this package is useful without rn-iso installed, and a
46
- // missing peer must never break a build. Registration is idempotent, so calling
47
- // it on every resolve is fine.
48
- let registered = false;
49
- function registerOnce() {
50
- if (registered) return;
51
- registered = true;
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
- // eslint-disable-next-line global-require
54
- const { register } = require('rn-iso/cache-manifest');
55
- register({
56
- dir: CACHE_ROOT,
57
- name: 'Expo build cache',
58
- prune: 'entries',
59
- note: 'built .app/.apk keyed on the native fingerprint',
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
- // rn-iso not installed, or too old to export the manifest. Nothing to do.
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.slice(0, 12)}`);
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.slice(0, 12)}`);
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.slice(0, 12)}`);
224
+ console.log(`[build-cache] stored ${platform} ${shortKey(key, fingerprintHash)}`);
108
225
  return artifactIn(dest);
109
226
  }
110
227
 
111
- module.exports = { resolveBuildCache, uploadBuildCache, CACHE_ROOT };
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.14.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
- "peerDependencies": {
12
- "rn-iso": ">=0.14.0"
13
- },
14
- "peerDependenciesMeta": {
15
- "rn-iso": {
16
- "optional": true
17
- }
11
+ "scripts": {
12
+ "test": "node --test test/*.test.js"
18
13
  }
19
- }
14
+ }