@rn-iso/expo-build-cache 1.1.0 → 1.3.1

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.
@@ -0,0 +1,23 @@
1
+ //#region index.d.ts
2
+ interface RunOptions {
3
+ variant?: unknown;
4
+ configuration?: unknown;
5
+ buildConfiguration?: unknown;
6
+ isSimulator?: unknown;
7
+ device?: unknown;
8
+ }
9
+ declare function cacheRoot(): string;
10
+ declare function buildCacheKey(platform: string, fingerprintHash: string, runOptions?: RunOptions): string;
11
+ declare function resolveBuildCache({ platform, fingerprintHash, runOptions }: {
12
+ platform: string;
13
+ fingerprintHash: string;
14
+ runOptions?: RunOptions;
15
+ }): Promise<string | null>;
16
+ declare function uploadBuildCache({ platform, fingerprintHash, buildPath, runOptions }: {
17
+ platform: string;
18
+ fingerprintHash: string;
19
+ buildPath?: string;
20
+ runOptions?: RunOptions;
21
+ }): Promise<string | null>;
22
+ //#endregion
23
+ export { buildCacheKey, cacheRoot, resolveBuildCache, uploadBuildCache };
package/dist/index.js ADDED
@@ -0,0 +1,173 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+ //#endregion
24
+ let node_fs = require("node:fs");
25
+ node_fs = __toESM(node_fs);
26
+ let node_os = require("node:os");
27
+ node_os = __toESM(node_os);
28
+ let node_path = require("node:path");
29
+ node_path = __toESM(node_path);
30
+ let node_child_process = require("node:child_process");
31
+ //#region index.ts
32
+ function configDir() {
33
+ return process.env.RN_ISO_HOME || node_path.default.join(node_os.default.homedir(), ".rn-iso");
34
+ }
35
+ function cacheRoot() {
36
+ return process.env.RN_ISO_BUILD_CACHE || node_path.default.join(configDir(), "build-cache");
37
+ }
38
+ function entryDir(platform, key) {
39
+ return node_path.default.join(cacheRoot(), platform, key);
40
+ }
41
+ const SIMULATOR_UDID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
+ const EMULATOR_SERIAL = /^emulator-\d+$/;
43
+ function slug(value) {
44
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
45
+ }
46
+ function buildVariant(platform, options) {
47
+ const raw = platform === "android" ? options.variant : options.configuration != null ? options.configuration : options.buildConfiguration;
48
+ return (typeof raw === "string" ? slug(raw) : "") || "debug";
49
+ }
50
+ function buildTarget(options) {
51
+ if (typeof options.isSimulator === "boolean") return options.isSimulator ? "sim" : "device";
52
+ const device = options.device;
53
+ if (device === void 0 || device === null || device === false) return "sim";
54
+ if (typeof device !== "string") return "prompted";
55
+ const name = device.trim();
56
+ if (name === "" || name === "generic") return "sim";
57
+ if (SIMULATOR_UDID.test(name) || EMULATOR_SERIAL.test(name)) return "sim";
58
+ return `on-${slug(name)}`;
59
+ }
60
+ function buildCacheKey(platform, fingerprintHash, runOptions) {
61
+ const opts = runOptions && typeof runOptions === "object" ? runOptions : {};
62
+ return `${fingerprintHash}-${buildVariant(platform, opts)}-${buildTarget(opts)}`;
63
+ }
64
+ function shortKey(key, fingerprintHash) {
65
+ return `${String(fingerprintHash).slice(0, 12)}${key.slice(String(fingerprintHash).length)}`;
66
+ }
67
+ function artifactIn(dir) {
68
+ if (!node_fs.default.existsSync(dir)) return null;
69
+ let found;
70
+ try {
71
+ found = node_fs.default.readdirSync(dir).find((f) => f.endsWith(".app") || f.endsWith(".apk"));
72
+ } catch {
73
+ return null;
74
+ }
75
+ return found ? node_path.default.join(dir, found) : null;
76
+ }
77
+ function registerCache({ dir, name, prune, note, entriesDepth }) {
78
+ try {
79
+ const home = configDir();
80
+ const file = node_path.default.join(home, "caches.json");
81
+ let manifest = {
82
+ version: 1,
83
+ caches: []
84
+ };
85
+ try {
86
+ const parsed = JSON.parse(node_fs.default.readFileSync(file, "utf-8"));
87
+ if (Array.isArray(parsed?.caches)) manifest = {
88
+ version: 1,
89
+ caches: parsed.caches
90
+ };
91
+ } catch {}
92
+ const others = manifest.caches.filter((c) => c.dir !== dir);
93
+ const record = {
94
+ dir,
95
+ name,
96
+ prune,
97
+ note,
98
+ registeredBy: process.cwd()
99
+ };
100
+ if (entriesDepth) record.entriesDepth = entriesDepth;
101
+ others.push(record);
102
+ node_fs.default.mkdirSync(home, { recursive: true });
103
+ node_fs.default.writeFileSync(file, JSON.stringify({
104
+ version: 1,
105
+ caches: others
106
+ }, null, 2));
107
+ } catch {}
108
+ }
109
+ let registeredDir = null;
110
+ function registerOnce() {
111
+ const root = cacheRoot();
112
+ if (registeredDir === root) return;
113
+ registeredDir = root;
114
+ registerCache({
115
+ dir: root,
116
+ name: "Expo build cache",
117
+ prune: "entries",
118
+ entriesDepth: 2,
119
+ note: "built .app/.apk keyed on the native fingerprint"
120
+ });
121
+ }
122
+ async function resolveBuildCache({ platform, fingerprintHash, runOptions }) {
123
+ registerOnce();
124
+ const key = buildCacheKey(platform, fingerprintHash, runOptions);
125
+ const hit = artifactIn(entryDir(platform, key));
126
+ if (hit) {
127
+ console.log(`[build-cache] hit ${platform} ${shortKey(key, fingerprintHash)}`);
128
+ try {
129
+ node_fs.default.utimesSync(node_path.default.dirname(hit), /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date());
130
+ } catch {}
131
+ return hit;
132
+ }
133
+ console.log(`[build-cache] miss ${platform} ${shortKey(key, fingerprintHash)}`);
134
+ return null;
135
+ }
136
+ async function uploadBuildCache({ platform, fingerprintHash, buildPath, runOptions }) {
137
+ registerOnce();
138
+ if (!buildPath || !node_fs.default.existsSync(buildPath)) return null;
139
+ const key = buildCacheKey(platform, fingerprintHash, runOptions);
140
+ const dest = entryDir(platform, key);
141
+ if (artifactIn(dest)) return artifactIn(dest);
142
+ const staging = `${dest}.staging-${process.pid}`;
143
+ node_fs.default.rmSync(staging, {
144
+ recursive: true,
145
+ force: true
146
+ });
147
+ node_fs.default.mkdirSync(staging, { recursive: true });
148
+ (0, node_child_process.execFileSync)("cp", [
149
+ "-R",
150
+ buildPath,
151
+ node_path.default.join(staging, node_path.default.basename(buildPath))
152
+ ]);
153
+ node_fs.default.mkdirSync(node_path.default.dirname(dest), { recursive: true });
154
+ node_fs.default.rmSync(dest, {
155
+ recursive: true,
156
+ force: true
157
+ });
158
+ try {
159
+ node_fs.default.renameSync(staging, dest);
160
+ } catch {
161
+ node_fs.default.rmSync(staging, {
162
+ recursive: true,
163
+ force: true
164
+ });
165
+ }
166
+ console.log(`[build-cache] stored ${platform} ${shortKey(key, fingerprintHash)}`);
167
+ return artifactIn(dest);
168
+ }
169
+ //#endregion
170
+ exports.buildCacheKey = buildCacheKey;
171
+ exports.cacheRoot = cacheRoot;
172
+ exports.resolveBuildCache = resolveBuildCache;
173
+ exports.uploadBuildCache = uploadBuildCache;
package/package.json CHANGED
@@ -1,14 +1,27 @@
1
1
  {
2
2
  "name": "@rn-iso/expo-build-cache",
3
- "version": "1.1.0",
3
+ "version": "1.3.1",
4
4
  "description": "Local Expo build cache provider: installs a cached .app/.apk instead of compiling when no native input changed.",
5
- "main": "index.js",
5
+ "license": "MIT",
6
6
  "files": [
7
- "index.js",
8
- "README.md"
7
+ "dist",
8
+ "README.md",
9
+ "LICENSE"
9
10
  ],
10
- "license": "MIT",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "require": "./dist/index.js",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
11
20
  "scripts": {
12
- "test": "node --test test/*.test.js"
21
+ "test": "node --test test/*.test.js",
22
+ "build": "tsdown"
23
+ },
24
+ "engines": {
25
+ "node": ">=22"
13
26
  }
14
- }
27
+ }
package/index.js DELETED
@@ -1,228 +0,0 @@
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
- // 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
- }
111
-
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)}`;
117
- }
118
-
119
- // The cached artifact is the single .app / .apk inside the entry directory.
120
- function artifactIn(dir) {
121
- if (!fs.existsSync(dir)) return null;
122
- const found = fs.readdirSync(dir).find(f => f.endsWith('.app') || f.endsWith('.apk'));
123
- return found ? path.join(dir, found) : null;
124
- }
125
-
126
- // Registering makes this cache visible to `rn-iso gc`'s report, which is the
127
- // only thing that will ever trim it.
128
- //
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 }) {
138
- try {
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));
158
- } catch {
159
- // A cache that cannot announce itself still works; it is just invisible.
160
- }
161
- }
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
-
183
- // The hash the CLI passes is stable once .fingerprintignore excludes generated
184
- // files that embed absolute paths -- ios/Podfile.lock is the usual culprit, as
185
- // pod checksums can carry machine-specific paths. Use it directly rather than
186
- // recomputing: fingerprinting walks node_modules and is otherwise the single
187
- // largest cost of a cache hit.
188
- async function resolveBuildCache({ platform, fingerprintHash, runOptions }) {
189
- registerOnce();
190
- const key = buildCacheKey(platform, fingerprintHash, runOptions);
191
- const hit = artifactIn(entryDir(platform, key));
192
- if (hit) {
193
- console.log(`[build-cache] hit ${platform} ${shortKey(key, fingerprintHash)}`);
194
- // Touch on hit so age-based trimming can tell a working entry from a dead
195
- // one. Without this, the entries earning their keep look identical to the
196
- // ones nothing has used in months.
197
- fs.utimesSync(path.dirname(hit), new Date(), new Date());
198
- return hit;
199
- }
200
- console.log(`[build-cache] miss ${platform} ${shortKey(key, fingerprintHash)}`);
201
- return null;
202
- }
203
-
204
- async function uploadBuildCache({ platform, fingerprintHash, buildPath, runOptions }) {
205
- registerOnce();
206
- if (!buildPath || !fs.existsSync(buildPath)) return null;
207
-
208
- const key = buildCacheKey(platform, fingerprintHash, runOptions);
209
- const dest = entryDir(platform, key);
210
- if (artifactIn(dest)) return artifactIn(dest);
211
-
212
- // Stage in a sibling and rename into place. A copy interrupted halfway must
213
- // never be readable as a complete entry by a worktree building in parallel,
214
- // and rename is the only step that is atomic.
215
- const staging = `${dest}.staging-${process.pid}`;
216
- fs.rmSync(staging, { recursive: true, force: true });
217
- fs.mkdirSync(staging, { recursive: true });
218
- execFileSync('cp', ['-R', buildPath, path.join(staging, path.basename(buildPath))]);
219
-
220
- fs.mkdirSync(path.dirname(dest), { recursive: true });
221
- fs.rmSync(dest, { recursive: true, force: true });
222
- fs.renameSync(staging, dest);
223
-
224
- console.log(`[build-cache] stored ${platform} ${shortKey(key, fingerprintHash)}`);
225
- return artifactIn(dest);
226
- }
227
-
228
- module.exports = { resolveBuildCache, uploadBuildCache, buildCacheKey, cacheRoot };