@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.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/index.js +273 -0
- 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
|
+
}
|