@farmslot/adapter-rn 0.15.0 → 0.16.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 (53) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +15 -10
  3. package/bridge-runtime/console-forwarder.cjs +485 -0
  4. package/bridge-runtime/lib/bridge-errors.cjs +108 -0
  5. package/bridge-runtime/lib/cdp-eval.cjs +118 -0
  6. package/bridge-runtime/lib/config.cjs +68 -0
  7. package/bridge-runtime/lib/console-format.cjs +54 -0
  8. package/bridge-runtime/lib/devtools-proxy.cjs +169 -0
  9. package/bridge-runtime/lib/issue-capture.cjs +86 -0
  10. package/bridge-runtime/lib/match-bridge-target.cjs +89 -0
  11. package/bridge-runtime/lib/target-discovery.cjs +436 -0
  12. package/bridge-runtime/lib/ws-client.cjs +119 -0
  13. package/dist/cli.js +1 -1
  14. package/dist/cli.js.map +1 -1
  15. package/dist/devices.d.ts +16 -0
  16. package/dist/devices.d.ts.map +1 -0
  17. package/dist/devices.js +131 -0
  18. package/dist/devices.js.map +1 -0
  19. package/dist/doctor.js +1 -1
  20. package/dist/doctor.js.map +1 -1
  21. package/dist/fingerprint-baseline.d.ts +8 -0
  22. package/dist/fingerprint-baseline.d.ts.map +1 -0
  23. package/dist/fingerprint-baseline.js +68 -0
  24. package/dist/fingerprint-baseline.js.map +1 -0
  25. package/dist/frame-metrics.d.ts +26 -0
  26. package/dist/frame-metrics.d.ts.map +1 -0
  27. package/dist/frame-metrics.js +134 -0
  28. package/dist/frame-metrics.js.map +1 -0
  29. package/dist/index.d.ts +7 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +6 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/metro-env.d.ts +11 -0
  34. package/dist/metro-env.d.ts.map +1 -0
  35. package/dist/metro-env.js +29 -0
  36. package/dist/metro-env.js.map +1 -0
  37. package/dist/source-freshness.d.ts +11 -0
  38. package/dist/source-freshness.d.ts.map +1 -0
  39. package/dist/source-freshness.js +98 -0
  40. package/dist/source-freshness.js.map +1 -0
  41. package/dist/tool-paths.d.ts +13 -0
  42. package/dist/tool-paths.d.ts.map +1 -0
  43. package/dist/tool-paths.js +125 -0
  44. package/dist/tool-paths.js.map +1 -0
  45. package/dist/video-recorder.d.ts +7 -0
  46. package/dist/video-recorder.d.ts.map +1 -0
  47. package/dist/video-recorder.js +250 -0
  48. package/dist/video-recorder.js.map +1 -0
  49. package/metro/coalesce-metro-log.cjs +28 -0
  50. package/metro/launch-metro.cjs +84 -0
  51. package/metro/metro-config.cjs +92 -0
  52. package/metro/metro-log-generation.cjs +116 -0
  53. package/package.json +26 -7
package/CHANGELOG.md CHANGED
@@ -6,6 +6,15 @@ All notable changes to `@farmslot/adapter-rn` (published as `@farmslot/expo-reci
6
6
 
7
7
  - Active-development baseline; add user-facing changes here before release or package publication.
8
8
 
9
+ ## 0.16.0 - 2026-10-05
10
+
11
+ - Add the generic React Native runtime, moved from mm-harness:
12
+ - **Bridge runtime:** the Hermes CDP libraries in `bridge-runtime/lib/*.cjs` (target discovery, ws client, devtools proxy, eval, error codes, port config, console format, in-app issue buffer snippets) and `bridge-runtime/console-forwarder.cjs`.
13
+ - **Metro:** the config wrapper, detached launcher and log helpers in `metro/*.cjs`.
14
+ - **From the index:** adb/idb discovery (`resolveMobileToolPath`, overridable with `RECIPE_RN_ADB_PATH`/`RECIPE_RN_IDB_PATH`), `listConnectedDevices`, the Android and iOS-simulator video recorders, `summarizeFrames`, and Metro-env and source fingerprints with recorded baselines. The project supplies the input lists and marker paths. Hosts set `RECIPE_RN_EXPLICIT_PLATFORM` and `RECIPE_RN_METRO_*` for the bridge and the Metro wrapper.
15
+ - `--help` and `doctor` now print "Farmslot React Native adapter" (they still said "Farmslot Expo Recipe").
16
+ - Publish with protocol 0.34.0 and recipe-runner 0.24.0.
17
+
9
18
  ## 0.15.0 - 2026-10-05
10
19
 
11
20
  - **Breaking:** renamed from `@farmslot/expo-recipe`, with no alias package. The bin is now `farmslot-adapter-rn` (was `farmslot-expo-recipe`), and `init` writes `recipe:*` scripts that call it. Run provenance and the bundled recipe source report `@farmslot/adapter-rn`. `runExpoRecipeCli` and `EXPO_RECIPE_PACKAGE_VERSIONS` are now `runAdapterRnCli` and `ADAPTER_RN_PACKAGE_VERSIONS`. Expo-specific APIs (`installExpoRecipeScaffold`, `runExpoRecipeDoctor`, `runExpoRecipeDocument`, …) keep their names.
package/README.md CHANGED
@@ -6,15 +6,20 @@ Public docs: <https://farmslot.io/docs/guides/adapter-rn>
6
6
 
7
7
  ## Source layout
8
8
 
9
- | Path | Owns |
10
- | ------------------ | ------------------------------------------------------------- |
11
- | `bin/` | Published `farmslot-adapter-rn` executable shim. |
12
- | `src/cli.ts` | Init command parsing and CLI entrypoint. |
13
- | `src/scaffold.ts` | File-copy and package-script scaffolding. |
14
- | `src/doctor.ts` | Project integration checks. |
15
- | `src/runner.ts` | Expo smoke runner wiring built on `@farmslot/recipe-runner`. |
16
- | `src/redaction.ts` | Output redaction helpers for generated artifacts. |
17
- | `templates/` | Versionless project scaffold copied into consuming Expo apps. |
9
+ | Path | Owns |
10
+ | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
11
+ | `bin/` | Published `farmslot-adapter-rn` executable shim. |
12
+ | `src/cli.ts` | Init command parsing and CLI entrypoint. |
13
+ | `src/scaffold.ts` | File-copy and package-script scaffolding. |
14
+ | `src/doctor.ts` | Project integration checks. |
15
+ | `src/runner.ts` | Expo smoke runner wiring built on `@farmslot/recipe-runner`. |
16
+ | `src/redaction.ts` | Output redaction helpers for generated artifacts. |
17
+ | `templates/` | Versionless project scaffold copied into consuming Expo apps. |
18
+ | `bridge-runtime/` | Hermes CDP bridge libraries (`lib/*.cjs`) and the console forwarder; a host bridge CLI requires them. |
19
+ | `metro/` | Metro config wrapper, detached launcher and log generation/coalescing helpers. |
20
+ | `src/tool-paths.ts`, `src/devices.ts` | adb/idb discovery (`RECIPE_RN_ADB_PATH`/`RECIPE_RN_IDB_PATH`) and connected-device listing. |
21
+ | `src/video-recorder.ts`, `src/frame-metrics.ts` | Device video recorders and frame-timing summaries. |
22
+ | `src/metro-env.ts`, `src/source-freshness.ts`, `src/fingerprint-baseline.ts` | Bundle-input fingerprints with recorded baselines; the project supplies the inputs. |
18
23
 
19
24
  ## Relationship to the harness
20
25
 
@@ -22,7 +27,7 @@ Public docs: <https://farmslot.io/docs/guides/adapter-rn>
22
27
 
23
28
  - `@farmslot/protocol` owns Recipe Protocol v1 schemas, action names, and validation.
24
29
  - `@farmslot/recipe-runner` owns the generic runner, official core actions, UI actions, and CDP/React Native transports.
25
- - `@farmslot/adapter-rn` only adds Expo-friendly scaffolding: package scripts, a default recipe, optional dev-only React Native bridge/HUD files, and integration checks.
30
+ - `@farmslot/adapter-rn` adds Expo-friendly scaffolding (package scripts, a default recipe, optional dev-only React Native bridge/HUD files, integration checks) and the generic React Native runtime a harness drives: the Hermes bridge libraries, Metro helpers, device tools, recorders and freshness fingerprints. Product commands, routes and env names stay in the host harness.
26
31
 
27
32
  Do not add project-specific actions such as wallet, perps, or meetings to this package. Those belong in the app or a project-specific runner/manifest that extends the official harness actions. Generic whole-run video proof stays in the shared harness capability surface.
28
33
 
@@ -0,0 +1,485 @@
1
+ #!/usr/bin/env node
2
+ // console-forwarder — stream device console lines into metro.log.
3
+ //
4
+ // React Native (Bridgeless) gates its built-in console→Metro forwarding on
5
+ // `console._isPolyfilled` (setUpDeveloperTools.js, T214991636); with the Hermes
6
+ // native console that gate is false, so app logs (incl. DevLogger) never reach
7
+ // Metro's log. The supported contract for log consumption in modern RN is CDP —
8
+ // this process does exactly what React Native DevTools does: hold a persistent
9
+ // debugger session per device page, enable the Runtime domain once, and stream
10
+ // `Runtime.consoleAPICalled` events as they happen.
11
+ //
12
+ // Recovery: on session loss the runtime's console buffer is replayed on the
13
+ // next Runtime.enable, and a per-device cursor (last-seen timestamp + texts at
14
+ // that timestamp, persisted next to the log) dedupes it — lines emitted during
15
+ // a disconnect, an app reload, or a forwarder restart are backfilled once.
16
+ // Dedupe keys on the runtime's console timestamps (fractional-ms doubles), so
17
+ // a device clock stepping backwards can drop lines emitted below the cursor:
18
+ // replay is a recovery path, not a ledger. Target discovery is a cheap HTTP
19
+ // poll against Metro only (never the app runtime): fast while a device is
20
+ // unattached, slow when all sessions are live. Never exits on its own; idles
21
+ // while Metro is down.
22
+ //
23
+ // Usage: node console-forwarder.cjs --port <metroPort> --out <logFile>
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('node:fs');
28
+ const http = require('node:http');
29
+ const path = require('node:path');
30
+ const { rankRuntimeCandidates } = require('./lib/target-discovery.cjs');
31
+ const { formatArgs: formatConsoleArgs } = require('./lib/console-format.cjs');
32
+ const {
33
+ brokerSocketPath,
34
+ createCdpBroker,
35
+ deviceIdFromUrl,
36
+ } = require('@farmslot/recipe-runner/cdp-broker');
37
+ const { createDevtoolsProxy } = require('./lib/devtools-proxy.cjs');
38
+ const { resolvePort } = require('./lib/config.cjs');
39
+ const { createInspectorWebSocket } = require('./lib/ws-client.cjs');
40
+
41
+ const HANDSHAKE_TIMEOUT_MS = 3000;
42
+ const RUNTIME_ENABLE_TIMEOUT_MS = 60_000;
43
+
44
+ const DISCOVER_ACTIVE_MS = 1000; // a device is unattached — look for it quickly
45
+ const DISCOVER_STEADY_MS = 10000; // all known targets attached — cheap liveness tick
46
+ const FLUSH_MS = 100;
47
+ const MAX_LINE_CHARS = 4000;
48
+
49
+ function parseArgs(argv) {
50
+ const args = { port: resolvePort(), out: null };
51
+ for (let i = 2; i < argv.length; i += 1) {
52
+ if (argv[i] === '--port') args.port = argv[++i];
53
+ else if (argv[i] === '--out') args.out = argv[++i];
54
+ }
55
+ if (!args.out) {
56
+ process.stderr.write('console-forwarder: --out <logFile> is required\n');
57
+ process.exit(2);
58
+ }
59
+ return args;
60
+ }
61
+
62
+ const { port, out } = parseArgs(process.argv);
63
+
64
+ // stderr is a log file beside the console log (or a pipe). When that disk is
65
+ // full or the reader is gone, diagnostics have nowhere to go; the forwarder
66
+ // keeps running because it also hosts the CDP broker.
67
+ process.stderr.on('error', (error) => {
68
+ if (error.code !== 'ENOSPC' && error.code !== 'EPIPE') throw error;
69
+ });
70
+ const statePath = `${out}.forwarder-state.json`;
71
+
72
+ // Bridge-priority coordination: the inspector proxy reliably serves one
73
+ // debugger slot. cdp-bridge holds this lock while a command runs; we yield the
74
+ // slot immediately and re-attach after a settle window — the runtime's console
75
+ // buffer replay backfills everything missed, so no lines are lost.
76
+ const LOCK_FILE = path.join(path.dirname(out), 'cdp-bridge.lock');
77
+ const LOCK_SETTLE_MS = 1500;
78
+ // The inspector can deliver NEW_DEBUGGER_OPENED after a short bridge command
79
+ // has already removed its lock. Keep a narrow release grace so that delayed
80
+ // close event is not mistaken for a human DevTools takeover (which otherwise
81
+ // suppresses device logs for five minutes).
82
+ const BRIDGE_RELEASE_GRACE_MS = 3000;
83
+ const LOCK_STALE_MS = 30000; // unreadable lock body: crashed bridge must not block logs forever
84
+ // dev-middleware serves one debugger slot per device; when a debugger we do not
85
+ // coordinate with (React Native DevTools) takes it, re-attaching would evict
86
+ // the human back and start a mutual-eviction storm. Stand down for a long
87
+ // window instead — bridge commands still work (they carry their own lock).
88
+ const FOREIGN_DEBUGGER_BACKOFF_MS = 5 * 60 * 1000;
89
+
90
+ function bridgeLockActive() {
91
+ // The lock body is the bridge pid, so liveness is the real signal: a bridge
92
+ // command may legitimately outlive any fixed mtime window (wallet setup runs
93
+ // with CDP_TIMEOUT=120000). mtime staleness only guards an unreadable body.
94
+ let body;
95
+ try {
96
+ body = fs.readFileSync(LOCK_FILE, 'utf8');
97
+ } catch {
98
+ return false;
99
+ }
100
+ const pid = Number.parseInt(body.trim(), 10);
101
+ if (Number.isInteger(pid) && pid > 0) {
102
+ try {
103
+ process.kill(pid, 0);
104
+ return true;
105
+ } catch (error) {
106
+ // EPERM: the pid is alive but owned by another user — still a live bridge.
107
+ return error.code === 'EPERM';
108
+ }
109
+ }
110
+ try {
111
+ return Date.now() - fs.statSync(LOCK_FILE).mtimeMs < LOCK_STALE_MS;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function yieldSessions() {
118
+ for (const session of sessions.values()) {
119
+ broker?.onSessionClose(session.deviceId);
120
+ session.ws.close();
121
+ }
122
+ sessions.clear();
123
+ }
124
+
125
+ let resumeTimer = null;
126
+ let bridgeCoordinationUntil = 0;
127
+
128
+ function noteBridgeCoordination() {
129
+ bridgeCoordinationUntil = Date.now() + BRIDGE_RELEASE_GRACE_MS;
130
+ }
131
+
132
+ function bridgeCoordinationRecent() {
133
+ return Date.now() < bridgeCoordinationUntil;
134
+ }
135
+
136
+ try {
137
+ fs.watch(path.dirname(out), (_event, filename) => {
138
+ if (filename !== path.basename(LOCK_FILE)) return;
139
+ noteBridgeCoordination();
140
+ if (bridgeLockActive()) {
141
+ if (resumeTimer) {
142
+ clearTimeout(resumeTimer);
143
+ resumeTimer = null;
144
+ }
145
+ yieldSessions();
146
+ } else if (!resumeTimer) {
147
+ resumeTimer = setTimeout(() => {
148
+ resumeTimer = null;
149
+ discover();
150
+ }, LOCK_SETTLE_MS);
151
+ }
152
+ });
153
+ } catch {
154
+ // fs.watch unavailable: the stale-mtime check in discover() still guards us.
155
+ }
156
+
157
+ /**
158
+ * deviceId -> { ts, seen } dedupe cursor: highest consoleAPICalled timestamp
159
+ * already written, plus the formatted texts already written AT that timestamp.
160
+ * Runtime stamps are fractional-ms doubles, but two logs in one tick share a
161
+ * stamp — a timestamp-only cursor dropped the second on replay. A device clock
162
+ * stepping backwards can still drop lines (ts below the cursor): replay is a
163
+ * recovery path, not a ledger.
164
+ */
165
+ const lastByDevice = new Map();
166
+ let savedState = {};
167
+ try {
168
+ savedState = JSON.parse(fs.readFileSync(statePath, 'utf8'));
169
+ } catch (error) {
170
+ // First start has no state file, and a SIGKILL mid-write leaves a truncated
171
+ // one. Both mean "no cursor": replay starts from the beginning.
172
+ if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
173
+ }
174
+ for (const [device, value] of Object.entries(savedState)) {
175
+ // Numeric values are state written by a timestamp-only forwarder build.
176
+ lastByDevice.set(
177
+ device,
178
+ typeof value === 'number'
179
+ ? { ts: value, seen: new Set() }
180
+ : { ts: Number(value.ts) || 0, seen: new Set(Array.isArray(value.seen) ? value.seen : []) },
181
+ );
182
+ }
183
+
184
+ function serializeState() {
185
+ const state = {};
186
+ for (const [device, cursor] of lastByDevice) {
187
+ state[device] = { ts: cursor.ts, seen: [...cursor.seen] };
188
+ }
189
+ return JSON.stringify(state);
190
+ }
191
+
192
+ /** deviceId -> live WebSocket session state. */
193
+ const sessions = new Map();
194
+ let broker = null;
195
+ let devtoolsProxy = null;
196
+
197
+ function sendCommand(session, method, params = {}, timeoutMs = 10_000) {
198
+ return new Promise((resolve, reject) => {
199
+ if (!session.opened) {
200
+ reject(new Error('CDP session is not open'));
201
+ return;
202
+ }
203
+ const id = ++session.nextId;
204
+ const timer = setTimeout(() => {
205
+ session.pending.delete(id);
206
+ reject(new Error(`CDP command timed out: ${method}`));
207
+ }, timeoutMs);
208
+ session.pending.set(id, {
209
+ resolve: (result) => {
210
+ clearTimeout(timer);
211
+ resolve(result);
212
+ },
213
+ reject: (error) => {
214
+ clearTimeout(timer);
215
+ reject(error);
216
+ },
217
+ });
218
+ session.ws.send(JSON.stringify({ id, method, params }));
219
+ });
220
+ }
221
+
222
+ broker = createCdpBroker({
223
+ socketPath: brokerSocketPath(path.dirname(out), port),
224
+ sessions,
225
+ sendCommand,
226
+ requestDiscovery(deviceId) {
227
+ foreignDebuggerUntil.delete(deviceId);
228
+ discover();
229
+ },
230
+ onClientActivity: noteBridgeCoordination,
231
+ });
232
+ devtoolsProxy = createDevtoolsProxy({
233
+ descriptorPath: path.join(path.dirname(out), 'devtools-proxy.json'),
234
+ sessions,
235
+ sendCommand,
236
+ requestDiscovery: discover,
237
+ allowedOrigins: [`http://127.0.0.1:${port}`, `http://localhost:${port}`],
238
+ });
239
+
240
+ /** deviceId -> epoch ms until which a foreign debugger owns the slot. */
241
+ const foreignDebuggerUntil = new Map();
242
+
243
+ /** Buffered lines, flushed together so replay bursts are one write. */
244
+ let pending = [];
245
+ let flushTimer = null;
246
+
247
+ function flush() {
248
+ flushTimer = null;
249
+ if (pending.length === 0) return;
250
+ const lines = pending.join('\n');
251
+ pending = [];
252
+ fs.appendFile(out, `${lines}\n`, (error) => reportWriteFailure(out, error));
253
+ fs.writeFile(statePath, serializeState(), (error) => reportWriteFailure(statePath, error));
254
+ }
255
+
256
+ // Signal-path flush: process.exit() cancels queued async I/O, so the SIGTERM/
257
+ // SIGINT handlers must write synchronously or pending lines and the last-seen
258
+ // state are lost on every restart (a stale state file re-duplicates replay).
259
+ function flushSync() {
260
+ if (flushTimer) {
261
+ clearTimeout(flushTimer);
262
+ flushTimer = null;
263
+ }
264
+ const lines = pending.length > 0 ? `${pending.join('\n')}\n` : '';
265
+ pending = [];
266
+ if (lines) writeSyncOrReport(out, () => fs.appendFileSync(out, lines));
267
+ writeSyncOrReport(statePath, () => fs.writeFileSync(statePath, serializeState()));
268
+ }
269
+
270
+ function writeSyncOrReport(destination, write) {
271
+ try {
272
+ write();
273
+ } catch (error) {
274
+ reportWriteFailure(destination, error);
275
+ }
276
+ }
277
+
278
+ // A failed write loses that batch of console lines. This process also hosts
279
+ // the CDP broker, so it keeps running: slot teardown (runtime dir gone) needs
280
+ // no report, anything else (disk full, permissions) goes to the forwarder's
281
+ // stderr log once per destination and error code, and the next flush tries
282
+ // again.
283
+ const reportedWriteFailures = new Map();
284
+ function reportWriteFailure(destination, error) {
285
+ if (!error) {
286
+ reportedWriteFailures.delete(destination);
287
+ return;
288
+ }
289
+ if (error.code === 'ENOENT' || reportedWriteFailures.get(destination) === error.code) return;
290
+ reportedWriteFailures.set(destination, error.code);
291
+ process.stderr.write(`console-forwarder: write to ${destination} failed: ${error.message}\n`);
292
+ }
293
+
294
+ function queueLine(line) {
295
+ pending.push(line);
296
+ if (!flushTimer) flushTimer = setTimeout(flush, FLUSH_MS);
297
+ }
298
+
299
+ function levelLabel(type) {
300
+ const t = String(type || 'log').toUpperCase();
301
+ return t === 'WARNING' ? 'WARN' : t;
302
+ }
303
+
304
+ function formatArgs(args) {
305
+ return formatConsoleArgs(args, MAX_LINE_CHARS);
306
+ }
307
+
308
+ function deviceNameFromTitle(title) {
309
+ const m = /\(([^)]+)\)\s*$/.exec(title || '');
310
+ return m ? m[1] : title || 'device';
311
+ }
312
+
313
+ function connect(target) {
314
+ const deviceId = deviceIdFromUrl(target.webSocketDebuggerUrl);
315
+ const name = deviceNameFromTitle(target.title);
316
+ let ws;
317
+ try {
318
+ ws = createInspectorWebSocket(target.webSocketDebuggerUrl);
319
+ } catch {
320
+ return;
321
+ }
322
+ const session = {
323
+ deviceId,
324
+ name,
325
+ ws,
326
+ opened: false,
327
+ brokerReady: false,
328
+ nextId: 1,
329
+ pending: new Map(),
330
+ };
331
+ sessions.set(deviceId, session);
332
+ const handshakeTimer = setTimeout(() => ws.close(), HANDSHAKE_TIMEOUT_MS);
333
+ ws.addEventListener('open', async () => {
334
+ clearTimeout(handshakeTimer);
335
+ session.opened = true;
336
+ // A live inspector socket is enough for broker commands. Do not keep the
337
+ // broker unavailable while Runtime.enable replays a large console buffer or
338
+ // while __AGENTIC__ is being installed after a Hermes context handoff.
339
+ // Commands such as status already report agenticPresent=false and their
340
+ // callers retry against the same bounded action deadline.
341
+ session.brokerReady = true;
342
+ broker.onSessionOpen(deviceId);
343
+ devtoolsProxy.onSessionOpen(deviceId, session);
344
+ process.stderr.write(`console-forwarder: attached ${name}\n`);
345
+ try {
346
+ await sendCommand(session, 'Runtime.enable', {}, RUNTIME_ENABLE_TIMEOUT_MS);
347
+ const evaluation = await sendCommand(session, 'Runtime.evaluate', {
348
+ expression: "typeof globalThis.__AGENTIC__ === 'object'",
349
+ returnByValue: true,
350
+ awaitPromise: false,
351
+ });
352
+ if (evaluation?.result?.value !== true) {
353
+ process.stderr.write(`console-forwarder: ${name} is waiting for __AGENTIC__\n`);
354
+ }
355
+ } catch (error) {
356
+ process.stderr.write(
357
+ `console-forwarder: console bootstrap pending for ${name}: ${String(error?.message || error).slice(0, 256)}\n`,
358
+ );
359
+ }
360
+ });
361
+ ws.addEventListener('message', (event) => {
362
+ let msg;
363
+ try {
364
+ msg = JSON.parse(String(event.data));
365
+ } catch {
366
+ return;
367
+ }
368
+ if (msg.id && session.pending.has(msg.id)) {
369
+ const entry = session.pending.get(msg.id);
370
+ session.pending.delete(msg.id);
371
+ if (msg.error) entry.reject(new Error(JSON.stringify(msg.error)));
372
+ else entry.resolve(msg.result);
373
+ return;
374
+ }
375
+ if (msg.method) {
376
+ broker.onCdpEvent(deviceId, msg.method, msg.params || {});
377
+ devtoolsProxy.onCdpEvent(deviceId, msg.method, msg.params || {});
378
+ }
379
+ if (msg.method !== 'Runtime.consoleAPICalled') return;
380
+ const text = formatArgs(msg.params.args);
381
+ // dev-middleware emits this NOTE on every debugger attach (i.e. ours). Drop it.
382
+ if (text.includes('unsupported debugging client')) return;
383
+ const ts = msg.params.timestamp || Date.now();
384
+ const cursor = lastByDevice.get(deviceId);
385
+ if (cursor && ts < cursor.ts) return;
386
+ if (cursor && ts === cursor.ts) {
387
+ if (cursor.seen.has(text)) return;
388
+ cursor.seen.add(text);
389
+ } else {
390
+ lastByDevice.set(deviceId, { ts, seen: new Set([text]) });
391
+ }
392
+ const time = new Date(ts).toISOString().slice(11, 23);
393
+ queueLine(` ${levelLabel(msg.params.type)} ${time} [console:${name}] ${text}`);
394
+ });
395
+ const drop = () => {
396
+ clearTimeout(handshakeTimer);
397
+ session.opened = false;
398
+ session.brokerReady = false;
399
+ for (const entry of session.pending.values()) {
400
+ entry.reject(new Error('CDP session closed'));
401
+ }
402
+ session.pending.clear();
403
+ if (sessions.get(deviceId) === session) {
404
+ sessions.delete(deviceId);
405
+ broker.onSessionClose(deviceId);
406
+ process.stderr.write(`console-forwarder: detached ${name}; will re-attach\n`);
407
+ }
408
+ };
409
+ ws.addEventListener('close', (event) => {
410
+ // dev-middleware closes the previous debugger with NEW_DEBUGGER_OPENED when
411
+ // another one attaches. With no bridge lock present that debugger is a
412
+ // human's DevTools session — back off long instead of evicting them back.
413
+ const why = event && event.reason ? String(event.reason) : '';
414
+ if (why.includes('NEW_DEBUGGER_OPENED') && !bridgeLockActive() && !bridgeCoordinationRecent()) {
415
+ foreignDebuggerUntil.set(deviceId, Date.now() + FOREIGN_DEBUGGER_BACKOFF_MS);
416
+ process.stderr.write(
417
+ `console-forwarder: another debugger took ${name}; standing down for ${FOREIGN_DEBUGGER_BACKOFF_MS / 60000} min\n`,
418
+ );
419
+ }
420
+ drop();
421
+ });
422
+ ws.addEventListener('error', drop);
423
+ }
424
+
425
+ function discover() {
426
+ if (bridgeLockActive()) {
427
+ noteBridgeCoordination();
428
+ schedule(DISCOVER_ACTIVE_MS);
429
+ return;
430
+ }
431
+ http
432
+ .get({ host: '127.0.0.1', port, path: '/json/list', timeout: 3000 }, (res) => {
433
+ let body = '';
434
+ res.on('data', (c) => (body += c));
435
+ res.on('end', () => {
436
+ let targets;
437
+ try {
438
+ targets = JSON.parse(body);
439
+ } catch {
440
+ schedule(DISCOVER_ACTIVE_MS);
441
+ return;
442
+ }
443
+ // Same candidate filter + JS-runtime-first ranking the bridge uses:
444
+ // devices expose multiple pages (page 1 = native C++ runtime) and the
445
+ // first ranked page per device is its JS runtime — attaching to the
446
+ // native page would stream nothing and block the right one.
447
+ let unattached = false;
448
+ const picked = new Set();
449
+ for (const t of rankRuntimeCandidates(targets)) {
450
+ const deviceId = deviceIdFromUrl(t.webSocketDebuggerUrl);
451
+ if (picked.has(deviceId)) continue;
452
+ picked.add(deviceId);
453
+ if (sessions.has(deviceId)) continue;
454
+ if ((foreignDebuggerUntil.get(deviceId) || 0) > Date.now()) continue;
455
+ foreignDebuggerUntil.delete(deviceId);
456
+ unattached = true;
457
+ connect(t);
458
+ }
459
+ schedule(unattached ? DISCOVER_ACTIVE_MS : DISCOVER_STEADY_MS);
460
+ });
461
+ })
462
+ .on('error', () => schedule(DISCOVER_STEADY_MS));
463
+ }
464
+
465
+ let discoverTimer = null;
466
+ function schedule(ms) {
467
+ if (discoverTimer) clearTimeout(discoverTimer);
468
+ discoverTimer = setTimeout(discover, ms);
469
+ }
470
+
471
+ process.on('SIGTERM', () => {
472
+ flushSync();
473
+ devtoolsProxy.close();
474
+ broker.close();
475
+ process.exit(0);
476
+ });
477
+ process.on('SIGINT', () => {
478
+ flushSync();
479
+ devtoolsProxy.close();
480
+ broker.close();
481
+ process.exit(0);
482
+ });
483
+
484
+ process.stderr.write(`console-forwarder: streaming Metro :${port} → ${out}\n`);
485
+ discover();
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ // Typed bridge failure codes — the single source of truth for classifying a
4
+ // cdp-bridge failure. Substring needles on a critical path are brittle; the
5
+ // bridge classifies at the source and stamps a code, so callers (bridge.mjs,
6
+ // adapters.ts) branch on the code and only fall back to needles for output
7
+ // produced by an older bridge that predates the codes.
8
+ //
9
+ // NO_TARGET no debug target answered (app backgrounded / not attached
10
+ // / a device pin matched nothing).
11
+ // CDP_TIMEOUT the CDP connection or a single CDP message timed out.
12
+ // WS_CLOSED the Hermes debug socket closed mid-command (app reload).
13
+ // METRO_UNREACHABLE Metro's inspector HTTP endpoint could not be reached.
14
+ const BRIDGE_ERROR_CODES = {
15
+ NO_TARGET: 'NO_TARGET',
16
+ CDP_TIMEOUT: 'CDP_TIMEOUT',
17
+ WS_CLOSED: 'WS_CLOSED',
18
+ METRO_UNREACHABLE: 'METRO_UNREACHABLE',
19
+ };
20
+
21
+ const BRIDGE_RESULT_ERROR_CODES = {
22
+ SCROLLABLE_NOT_FOUND: 'SCROLLABLE_NOT_FOUND',
23
+ };
24
+
25
+ // Process exit code the bridge returns per failure code so a caller that only
26
+ // sees the child's exit status (no stderr) can still recover the code. Kept
27
+ // clear of exit 1 (unknown/uncoded) and 2 (usage).
28
+ const EXIT_CODE_BY_ERROR_CODE = {
29
+ NO_TARGET: 10,
30
+ CDP_TIMEOUT: 11,
31
+ WS_CLOSED: 12,
32
+ METRO_UNREACHABLE: 13,
33
+ };
34
+
35
+ const ERROR_CODE_BY_EXIT_CODE = Object.fromEntries(
36
+ Object.entries(EXIT_CODE_BY_ERROR_CODE).map(([code, exit]) => [exit, code]),
37
+ );
38
+
39
+ // Needle → code fallback for uncoded output (older bridge). Case-insensitive,
40
+ // first match wins, so order the more specific Metro/no-target needles ahead of
41
+ // the generic 'timed out'.
42
+ const NEEDLE_CODES = [
43
+ ['cannot reach metro', BRIDGE_ERROR_CODES.METRO_UNREACHABLE],
44
+ ['is metro running', BRIDGE_ERROR_CODES.METRO_UNREACHABLE],
45
+ ['timeout fetching', BRIDGE_ERROR_CODES.METRO_UNREACHABLE],
46
+ ['no responding bridge target', BRIDGE_ERROR_CODES.NO_TARGET],
47
+ ['no debug targets found', BRIDGE_ERROR_CODES.NO_TARGET],
48
+ ['no suitable debug target', BRIDGE_ERROR_CODES.NO_TARGET],
49
+ ['did not match any metro target', BRIDGE_ERROR_CODES.NO_TARGET],
50
+ ['pinned android device', BRIDGE_ERROR_CODES.NO_TARGET],
51
+ ['no react native bridge target', BRIDGE_ERROR_CODES.NO_TARGET],
52
+ ['cdp broker target unavailable', BRIDGE_ERROR_CODES.NO_TARGET],
53
+ ['cdp broker never observed the requested target', BRIDGE_ERROR_CODES.NO_TARGET],
54
+ ['websocket closed', BRIDGE_ERROR_CODES.WS_CLOSED],
55
+ ['websocket error', BRIDGE_ERROR_CODES.WS_CLOSED],
56
+ ['cdp connection timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
57
+ ['cdp message timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
58
+ ['cdp broker connection timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
59
+ ['cdp broker request timeout', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
60
+ ['evaluation timed out', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
61
+ ['timed out', BRIDGE_ERROR_CODES.CDP_TIMEOUT],
62
+ ];
63
+
64
+ function classifyBridgeErrorMessage(message) {
65
+ const text = String(message == null ? '' : message).toLowerCase();
66
+ for (const [needle, code] of NEEDLE_CODES) {
67
+ if (text.includes(needle)) return code;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function classifyBridgeResultError(command, result) {
73
+ if (command !== 'scroll-view' || result?.ok !== false) return null;
74
+ return /^No scrollable(?: found)? near testID=/u.test(String(result.error ?? ''))
75
+ ? BRIDGE_RESULT_ERROR_CODES.SCROLLABLE_NOT_FOUND
76
+ : null;
77
+ }
78
+
79
+ // Attach a code to an error at its throw site (source classification).
80
+ function coded(error, code) {
81
+ if (error && typeof error === 'object') error.code = code;
82
+ return error;
83
+ }
84
+
85
+ // The bridge prints this marker on stderr so a caller recovers the code without
86
+ // relying on the exit status alone.
87
+ const MARKER = /^ERROR\[([A-Z_]+)\]:/mu;
88
+
89
+ function formatErrorMarker(code, message) {
90
+ return `ERROR[${code}]: ${message}`;
91
+ }
92
+
93
+ function parseErrorMarker(text) {
94
+ const match = MARKER.exec(String(text == null ? '' : text));
95
+ return match ? match[1] : null;
96
+ }
97
+
98
+ module.exports = {
99
+ BRIDGE_ERROR_CODES,
100
+ BRIDGE_RESULT_ERROR_CODES,
101
+ EXIT_CODE_BY_ERROR_CODE,
102
+ ERROR_CODE_BY_EXIT_CODE,
103
+ classifyBridgeErrorMessage,
104
+ classifyBridgeResultError,
105
+ coded,
106
+ formatErrorMarker,
107
+ parseErrorMarker,
108
+ };