@rn-iso/metro 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 +71 -0
  3. package/index.js +273 -0
  4. package/package.json +17 -0
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 ADDED
@@ -0,0 +1,71 @@
1
+ # @rn-iso/metro
2
+
3
+ The two things [`rn-iso`](https://www.npmjs.com/package/rn-iso) wires into
4
+ Metro: one transform cache shared by every worktree on the machine, and a
5
+ reporter that writes the dev server's events as NDJSON.
6
+
7
+ ```bash
8
+ npm i -D @rn-iso/metro
9
+ ```
10
+
11
+ ## The shared transform cache
12
+
13
+ Metro's default cache lives under the project, so a second worktree starts cold
14
+ and re-transforms the whole module graph — thousands of modules, every time.
15
+ Pointing every checkout at one store means only the first one pays.
16
+
17
+ ```js
18
+ // metro.config.js
19
+ const { sharedCacheStores } = require('@rn-iso/metro');
20
+
21
+ const config = getDefaultConfig(__dirname);
22
+ config.cacheStores = sharedCacheStores('myapp');
23
+ module.exports = config;
24
+ ```
25
+
26
+ The `FileStore` itself is six lines; what this packages is the housekeeping.
27
+ **Metro's cache has no eviction logic whatsoever**, so left alone it grows until
28
+ the disk does. Registering it with
29
+ [`rn-iso`](https://www.npmjs.com/package/rn-iso) makes it visible:
30
+
31
+ ```bash
32
+ npx rn-iso gc # what it has grown to (reported on every run)
33
+ npx rn-iso gc --delete --older-than 30 # drop entries unused for 30 days
34
+ ```
35
+
36
+ Entries are trimmed individually — one file per cache key — so trimming costs
37
+ only the entries nothing has touched, not the whole cache.
38
+
39
+ rn-iso is an optional peer. Without it the cache works exactly the same; it is
40
+ just invisible to housekeeping.
41
+
42
+ `RN_ISO_METRO_CACHE` overrides the location.
43
+
44
+ ## The NDJSON log reporter
45
+
46
+ `ndjsonReporter({ dir })` is a Metro `Reporter` that writes every event the dev
47
+ server emits as one JSON object per line: bundler events and transform failures
48
+ into `<dir>/metro.ndjson`, forwarded in-app console logs and redboxes into
49
+ `<dir>/client.ndjson`.
50
+
51
+ ```js
52
+ const { ndjsonReporter } = require('@rn-iso/metro');
53
+
54
+ config.reporter = ndjsonReporter({ dir: '.rn-iso/logs' });
55
+ await Metro.runServer(config, { host, port });
56
+ ```
57
+
58
+ **It only survives when you host Metro yourself.** Both the Expo CLI and the
59
+ React Native CLI overwrite `config.reporter` after loading `metro.config.js`, so
60
+ a reporter set there is discarded without a word. Setting it on a config you
61
+ pass to `Metro.runServer` is the path that works, and it is how `rn-iso start`
62
+ captures a bare React Native project's logs.
63
+
64
+ Each record is `{ ts, src, level, msg }` plus, when they apply, `event` (the
65
+ Metro event name), `stack` (passed through as Metro gave it) and `marker: true`
66
+ (written on a finished bundle build, which is what `rn-iso logs --errors` counts
67
+ errors from). `dir` defaults to `.rn-iso/logs` under the working directory.
68
+
69
+ A logging failure is never a server failure: an unwritable directory or an event
70
+ shape from a Metro version this package has never seen is counted on
71
+ `reporter.drops` and swallowed, not thrown into Metro's build pipeline.
package/index.js ADDED
@@ -0,0 +1,273 @@
1
+ // The two things rn-iso wires into Metro: a transform cache shared by every
2
+ // worktree on the machine, and a reporter that writes the dev server's events
3
+ // as NDJSON.
4
+ //
5
+ // Metro's default cache lives under the project, so a second worktree starts
6
+ // cold and re-transforms the whole module graph -- thousands of modules, every
7
+ // time. Pointing every checkout at one store means only the first one pays.
8
+ //
9
+ // const { sharedCacheStores } = require('@rn-iso/metro');
10
+ // config.cacheStores = sharedCacheStores('myapp');
11
+ //
12
+ // The thin part is the FileStore. The part worth packaging is telling rn-iso the
13
+ // cache exists, so `gc` can report and trim it -- Metro's FileStore has
14
+ // no eviction logic whatsoever, so without that it grows until the disk does.
15
+ //
16
+ // The reporter is the other half, and it only works when Metro is hosted
17
+ // programmatically: both the Expo CLI and the React Native CLI overwrite
18
+ // config.reporter after loading metro.config.js, so a reporter set there is
19
+ // discarded. rn-iso's supervisor hosts Metro itself and passes this one in.
20
+ //
21
+ // const { ndjsonReporter } = require('@rn-iso/metro');
22
+ // config.reporter = ndjsonReporter({ dir: '<root>/.rn-iso/logs' });
23
+
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+
28
+ // THIS RESOLUTION EXISTS THREE TIMES: here, in
29
+ // packages/expo-build-cache/index.js, and in rn-iso's own src/paths.js
30
+ // (sharedMetroCache / sharedBuildCache). This package cannot import that module
31
+ // -- it has to work on a machine with no rn-iso installed at all -- so the
32
+ // duplication is deliberate, the same way buildCacheKey is duplicated between
33
+ // the build-cache implementations. Change one and you must change all three:
34
+ // when they drift, one entry point writes a cache the other will never read,
35
+ // and neither of them says so. rn-iso's test/cache-packages.test.js asserts all
36
+ // three agree.
37
+ //
38
+ // RN_ISO_METRO_CACHE comes first because it did before the layout existed, and
39
+ // quietly ignoring an override someone already set reads as an empty cache
40
+ // rather than as an error. It names one directory, so it wins for a named cache
41
+ // too -- otherwise half the stores on a machine would move and half would not.
42
+ function configDir() {
43
+ return process.env.RN_ISO_HOME || path.join(os.homedir(), '.rn-iso');
44
+ }
45
+
46
+ // Anything that is not a plain path segment is replaced, and leading dots go, so
47
+ // a scoped package name cannot climb out of the cache root.
48
+ function cacheNameSegment(name) {
49
+ return String(name).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^\.+/, '') || 'app';
50
+ }
51
+
52
+ function cacheRoot(name) {
53
+ if (process.env.RN_ISO_METRO_CACHE) return process.env.RN_ISO_METRO_CACHE;
54
+ const root = path.join(configDir(), 'metro-cache');
55
+ return name === undefined || name === null || name === '' ? root : path.join(root, cacheNameSegment(name));
56
+ }
57
+
58
+ // Registering makes this cache visible to `rn-iso gc`'s report, which is the
59
+ // only thing that will ever trim it -- Metro's FileStore has no eviction of its
60
+ // own.
61
+ //
62
+ // The manifest is written directly rather than through rn-iso's own module, for
63
+ // two reasons that both made the import silently do nothing:
64
+ // - the documented way to use the CLI is `npx rn-iso`, so it is usually not a
65
+ // dependency of the project and the specifier does not resolve at all
66
+ // - rn-iso is an ES module, so `require` of it throws ERR_REQUIRE_ESM on Node
67
+ // before 20.19
68
+ // A dynamic import fixes the second and not the first.
69
+ function registerCache({ dir, name, prune, note, entriesDepth }) {
70
+ try {
71
+ const home = configDir();
72
+ const file = path.join(home, 'caches.json');
73
+ let manifest = { version: 1, caches: [] };
74
+ try {
75
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
76
+ if (Array.isArray(parsed?.caches)) manifest = { version: 1, caches: parsed.caches };
77
+ } catch {
78
+ // No manifest yet, or an unreadable one: start clean rather than fail.
79
+ }
80
+ // Keyed on the directory so repeated calls update rather than accumulate --
81
+ // these run on every build.
82
+ const others = manifest.caches.filter(c => c.dir !== dir);
83
+ const record = { dir, name, prune, note, registeredBy: process.cwd() };
84
+ // Only written when the caller sets it: an absent depth means the entries
85
+ // are the directory's immediate children, which is the common case.
86
+ if (entriesDepth) record.entriesDepth = entriesDepth;
87
+ others.push(record);
88
+ fs.mkdirSync(home, { recursive: true });
89
+ fs.writeFileSync(file, JSON.stringify({ version: 1, caches: others }, null, 2));
90
+ } catch {
91
+ // A cache that cannot announce itself still works; it is just invisible.
92
+ }
93
+ }
94
+
95
+ function registerOnce(dir) {
96
+ registerCache({
97
+ dir,
98
+ name: 'Metro transform cache',
99
+ // One file per cache key, so entries nothing has touched can go
100
+ // individually rather than emptying the whole store. FileStore shards one
101
+ // level above them, so the entries are two deep.
102
+ prune: 'entries',
103
+ entriesDepth: 2,
104
+ note: 'shared Metro transforms; no eviction of its own',
105
+ });
106
+ }
107
+
108
+ // `name` only distinguishes one app's cache from another's on the same machine:
109
+ // it is a subdirectory of the shared root, not a directory of its own. Metro
110
+ // keys entries by content, so sharing one store between unrelated projects would
111
+ // be correct but pointlessly large.
112
+ function sharedCacheStores(name = 'app', { FileStore } = {}) {
113
+ const Store = FileStore || require('metro-cache').FileStore;
114
+ const root = cacheRoot(name);
115
+ registerOnce(root);
116
+ return [new Store({ root })];
117
+ }
118
+
119
+ // --- the NDJSON reporter ------------------------------------------------
120
+ //
121
+ // One JSON object per line, per rn-iso's log record contract:
122
+ //
123
+ // { ts, src: 'metro'|'client', level: 'debug'|'info'|'warn'|'error'|'fatal',
124
+ // msg, event?, stack?, marker? }
125
+ //
126
+ // Two files, because the two sources answer different questions: metro.ndjson
127
+ // is the bundler (did it build, what failed to transform) and client.ndjson is
128
+ // the app (what the running code logged and threw). Metro forwards the latter
129
+ // through the same reporter, so splitting here is what keeps `logs --source
130
+ // client` from being a grep over bundler chatter.
131
+ //
132
+ // The rules this file lives by: a logging failure must never become a dev
133
+ // server failure. Metro calls update() from inside its own build pipeline, so a
134
+ // throw here -- an event shape from a Metro version this package never saw, an
135
+ // unwritable log directory -- would take the server down with it. Every path
136
+ // swallows and counts instead.
137
+
138
+ const NDJSON_LEVELS = new Set(['debug', 'info', 'warn', 'error', 'fatal']);
139
+
140
+ // Metro speaks the console's vocabulary on client logs (`log`, `trace`,
141
+ // `group`) and its own on server logs. Anything unrecognized falls back to the
142
+ // caller's default rather than inventing a level.
143
+ function ndjsonLevel(level, fallback) {
144
+ const value = String(level === undefined || level === null ? '' : level).toLowerCase();
145
+ if (NDJSON_LEVELS.has(value)) return value;
146
+ switch (value) {
147
+ case 'log':
148
+ case 'dir':
149
+ case 'table':
150
+ case 'group':
151
+ case 'groupcollapsed':
152
+ case 'groupend':
153
+ return 'info';
154
+ case 'trace':
155
+ return 'debug';
156
+ case 'warning':
157
+ return 'warn';
158
+ default:
159
+ return fallback;
160
+ }
161
+ }
162
+
163
+ // Client logs arrive as the console's argument list, so they are joined the way
164
+ // a console would print them. A value that cannot be stringified (a circular
165
+ // object, a proxy that throws) still has to produce something.
166
+ function formatValue(value) {
167
+ if (typeof value === 'string') return value;
168
+ if (value instanceof Error) return value.message || String(value);
169
+ try {
170
+ const json = JSON.stringify(value);
171
+ if (json !== undefined) return json;
172
+ } catch {
173
+ // Fall through to String(), which handles circular structures.
174
+ }
175
+ try {
176
+ return String(value);
177
+ } catch {
178
+ return '[unprintable]';
179
+ }
180
+ }
181
+
182
+ function formatData(data) {
183
+ if (data === undefined || data === null) return '';
184
+ if (Array.isArray(data)) return data.map(formatValue).join(' ');
185
+ return formatValue(data);
186
+ }
187
+
188
+ // Metro wraps its failures differently depending on where they came from: a
189
+ // resolution failure is an Error, a transformer failure can be a plain object
190
+ // carrying only a message.
191
+ function errorMessage(error) {
192
+ if (error === undefined || error === null) return 'unknown error';
193
+ if (typeof error === 'string') return error;
194
+ if (typeof error.message === 'string' && error.message) return error.message;
195
+ return formatValue(error);
196
+ }
197
+
198
+ function ndjsonReporter({ dir } = {}) {
199
+ const logDir = dir || path.join(process.cwd(), '.rn-iso', 'logs');
200
+ let ensured = false;
201
+ let drops = 0;
202
+
203
+ // Lazily: constructing a reporter must not create directories for a server
204
+ // that may never start, and the log directory is workspace-local, so it may
205
+ // not exist yet at all.
206
+ function write(file, record) {
207
+ try {
208
+ if (!ensured) {
209
+ fs.mkdirSync(logDir, { recursive: true });
210
+ ensured = true;
211
+ }
212
+ fs.appendFileSync(path.join(logDir, file), JSON.stringify(record) + '\n');
213
+ } catch {
214
+ // An unwritable log directory is a housekeeping problem, not a build one.
215
+ // The count is what makes it visible instead of silent.
216
+ drops += 1;
217
+ ensured = false;
218
+ }
219
+ }
220
+
221
+ function update(event) {
222
+ try {
223
+ const type = event && typeof event.type === 'string' ? event.type : '';
224
+ const record = { ts: Date.now(), src: 'metro', level: 'debug', msg: '' };
225
+ if (type) record.event = type;
226
+
227
+ if (type === 'client_log') {
228
+ record.src = 'client';
229
+ record.level = ndjsonLevel(event.level, 'info');
230
+ record.msg = formatData(event.data);
231
+ // Passed through as-is: symbolication happens on the reading side, and
232
+ // a stack this reporter could not parse is still better than no stack.
233
+ if (event.stack) record.stack = event.stack;
234
+ write('client.ndjson', record);
235
+ return;
236
+ }
237
+
238
+ if (type === 'bundling_error' || type === 'transformer_error') {
239
+ record.level = 'error';
240
+ record.msg = errorMessage(event.error);
241
+ } else if (type === 'bundle_build_done') {
242
+ record.level = 'info';
243
+ record.msg = event.buildID ? `bundle build done (${event.buildID})` : 'bundle build done';
244
+ // The marker resets the window `rn-iso logs --errors` reports over: a
245
+ // successful build is the point past which older errors are history.
246
+ record.marker = true;
247
+ } else if (type === 'unstable_server_log') {
248
+ record.level = ndjsonLevel(event.level, 'info');
249
+ record.msg = formatData(event.data);
250
+ } else {
251
+ // Everything else is kept at debug rather than dropped: the event name
252
+ // is often the only evidence of what the server was doing before it
253
+ // failed, and debug costs nothing to a default query.
254
+ record.msg = formatData(event && event.data) || type || 'metro event';
255
+ }
256
+
257
+ write('metro.ndjson', record);
258
+ } catch {
259
+ // The event shape came from a package this one does not version.
260
+ drops += 1;
261
+ }
262
+ }
263
+
264
+ return {
265
+ dir: logDir,
266
+ update,
267
+ get drops() {
268
+ return drops;
269
+ },
270
+ };
271
+ }
272
+
273
+ module.exports = { sharedCacheStores, cacheRoot, ndjsonReporter };
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@rn-iso/metro",
3
+ "version": "1.0.0",
4
+ "description": "Metro integration for rn-iso: one transform cache shared by every worktree, plus an NDJSON reporter for structured logs.",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "README.md"
9
+ ],
10
+ "license": "MIT",
11
+ "scripts": {
12
+ "test": "node --test test/*.test.js"
13
+ },
14
+ "peerDependencies": {
15
+ "metro-cache": "*"
16
+ }
17
+ }