@rn-iso/expo-build-cache 0.14.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 (3) hide show
  1. package/README.md +47 -0
  2. package/index.js +111 -0
  3. package/package.json +19 -0
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @rn-iso/expo-build-cache
2
+
3
+ A local Expo build cache provider. When no native input has changed, the CLI
4
+ installs a cached `.app` / `.apk` instead of compiling — which is the difference
5
+ between a JS-only change costing a simulator boot and costing a full native
6
+ build.
7
+
8
+ Local on purpose: a directory under `$HOME` shared by every worktree on the
9
+ machine. No account, no network, and a second worktree building the same commit
10
+ is a hit rather than a second five-minute build.
11
+
12
+ ```bash
13
+ npm i -D @rn-iso/expo-build-cache
14
+ ```
15
+
16
+ Point your Expo config at it. **Which key depends on the SDK, and the wrong one
17
+ is a silent no-op rather than an error:**
18
+
19
+ ```jsonc
20
+ // SDK 54+
21
+ { "expo": { "buildCacheProvider": { "plugin": "@rn-iso/expo-build-cache" } } }
22
+
23
+ // SDK 53 — reads only the experiments key and ignores the top-level one
24
+ { "expo": { "experiments": { "buildCacheProvider": { "plugin": "@rn-iso/expo-build-cache" } } } }
25
+ ```
26
+
27
+ Add a `.fingerprintignore` for anything that changes without changing the build.
28
+ `ios/Podfile.lock` is the usual culprit: pod checksums can embed absolute paths,
29
+ which makes the fingerprint differ per machine and the cache never hit.
30
+
31
+ Watch for `[build-cache] hit` or `miss` in the output. A miss means you changed
32
+ something native — or that you are the first workspace on this commit.
33
+
34
+ ### Housekeeping
35
+
36
+ The cache registers itself with [`rn-iso`](https://www.npmjs.com/package/rn-iso)
37
+ if it is installed, so it can be reported and trimmed:
38
+
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
42
+ ```
43
+
44
+ rn-iso is an optional peer. Without it the cache works exactly the same; it is
45
+ just invisible to housekeeping.
46
+
47
+ `RN_ISO_BUILD_CACHE` overrides the location.
package/index.js ADDED
@@ -0,0 +1,111 @@
1
+ // A local Expo build cache provider.
2
+ //
3
+ // Expo fingerprints the project's native inputs and hands the hash to a
4
+ // provider. If a build already exists for that hash, the provider returns it and
5
+ // the CLI installs it instead of compiling. That is the difference between a
6
+ // JS-only change costing a simulator boot and costing a full native build, and
7
+ // most changes are JS-only.
8
+ //
9
+ // Local on purpose: a directory under $HOME shared by every worktree on the
10
+ // machine. No account, no network, and a second worktree building the same
11
+ // commit is a hit rather than a second five-minute build.
12
+ //
13
+ // Wire it up in app.json. Which key depends on the SDK, and the wrong one is a
14
+ // silent no-op rather than an error:
15
+ //
16
+ // SDK 54+ { "expo": { "buildCacheProvider": { "plugin": "@rn-iso/expo-build-cache" } } }
17
+ // SDK 53 { "expo": { "experiments": { "buildCacheProvider": { ... } } } }
18
+ //
19
+ // SDK 53's CLI reads only the experiments key and ignores the top-level one
20
+ // without saying so; SDK 54+ reads the top-level key and falls back to
21
+ // experiments.
22
+
23
+ const fs = require('node:fs');
24
+ const os = require('node:os');
25
+ const path = require('node:path');
26
+ const { execFileSync } = require('node:child_process');
27
+
28
+ const CACHE_ROOT =
29
+ process.env.RN_ISO_BUILD_CACHE || path.join(os.homedir(), '.rn-iso-build-cache');
30
+
31
+ function entryDir(platform, fingerprintHash) {
32
+ return path.join(CACHE_ROOT, platform, fingerprintHash);
33
+ }
34
+
35
+ // The cached artifact is the single .app / .apk inside the entry directory.
36
+ function artifactIn(dir) {
37
+ if (!fs.existsSync(dir)) return null;
38
+ const found = fs.readdirSync(dir).find(f => f.endsWith('.app') || f.endsWith('.apk'));
39
+ return found ? path.join(dir, found) : null;
40
+ }
41
+
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.
44
+ //
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;
52
+ 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
+ });
61
+ } catch {
62
+ // rn-iso not installed, or too old to export the manifest. Nothing to do.
63
+ }
64
+ }
65
+
66
+ // The hash the CLI passes is stable once .fingerprintignore excludes generated
67
+ // files that embed absolute paths -- ios/Podfile.lock is the usual culprit, as
68
+ // pod checksums can carry machine-specific paths. Use it directly rather than
69
+ // recomputing: fingerprinting walks node_modules and is otherwise the single
70
+ // largest cost of a cache hit.
71
+ async function resolveBuildCache({ platform, fingerprintHash }) {
72
+ registerOnce();
73
+ const key = fingerprintHash;
74
+ const hit = artifactIn(entryDir(platform, key));
75
+ if (hit) {
76
+ console.log(`[build-cache] hit ${platform} ${key.slice(0, 12)}`);
77
+ // Touch on hit so age-based trimming can tell a working entry from a dead
78
+ // one. Without this, the entries earning their keep look identical to the
79
+ // ones nothing has used in months.
80
+ fs.utimesSync(path.dirname(hit), new Date(), new Date());
81
+ return hit;
82
+ }
83
+ console.log(`[build-cache] miss ${platform} ${key.slice(0, 12)}`);
84
+ return null;
85
+ }
86
+
87
+ async function uploadBuildCache({ platform, fingerprintHash, buildPath }) {
88
+ registerOnce();
89
+ if (!buildPath || !fs.existsSync(buildPath)) return null;
90
+
91
+ const key = fingerprintHash;
92
+ const dest = entryDir(platform, key);
93
+ if (artifactIn(dest)) return artifactIn(dest);
94
+
95
+ // Stage in a sibling and rename into place. A copy interrupted halfway must
96
+ // never be readable as a complete entry by a worktree building in parallel,
97
+ // and rename is the only step that is atomic.
98
+ const staging = `${dest}.staging-${process.pid}`;
99
+ fs.rmSync(staging, { recursive: true, force: true });
100
+ fs.mkdirSync(staging, { recursive: true });
101
+ execFileSync('cp', ['-R', buildPath, path.join(staging, path.basename(buildPath))]);
102
+
103
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
104
+ fs.rmSync(dest, { recursive: true, force: true });
105
+ fs.renameSync(staging, dest);
106
+
107
+ console.log(`[build-cache] stored ${platform} ${key.slice(0, 12)}`);
108
+ return artifactIn(dest);
109
+ }
110
+
111
+ module.exports = { resolveBuildCache, uploadBuildCache, CACHE_ROOT };
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@rn-iso/expo-build-cache",
3
+ "version": "0.14.0",
4
+ "description": "Local Expo build cache provider: installs a cached .app/.apk instead of compiling when no native input changed.",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "README.md"
9
+ ],
10
+ "license": "MIT",
11
+ "peerDependencies": {
12
+ "rn-iso": ">=0.14.0"
13
+ },
14
+ "peerDependenciesMeta": {
15
+ "rn-iso": {
16
+ "optional": true
17
+ }
18
+ }
19
+ }