@farmslot/adapter-rn 0.15.0 → 0.17.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/CHANGELOG.md +14 -0
- package/README.md +48 -10
- package/bridge-runtime/bridge-core.cjs +1481 -0
- package/bridge-runtime/console-forwarder.cjs +485 -0
- package/bridge-runtime/lib/bridge-errors.cjs +108 -0
- package/bridge-runtime/lib/cdp-eval.cjs +118 -0
- package/bridge-runtime/lib/config.cjs +68 -0
- package/bridge-runtime/lib/console-format.cjs +54 -0
- package/bridge-runtime/lib/devtools-proxy.cjs +169 -0
- package/bridge-runtime/lib/issue-capture.cjs +86 -0
- package/bridge-runtime/lib/match-bridge-target.cjs +89 -0
- package/bridge-runtime/lib/target-discovery.cjs +436 -0
- package/bridge-runtime/lib/ws-client.cjs +119 -0
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/devices.d.ts +16 -0
- package/dist/devices.d.ts.map +1 -0
- package/dist/devices.js +131 -0
- package/dist/devices.js.map +1 -0
- package/dist/doctor.js +1 -1
- package/dist/doctor.js.map +1 -1
- package/dist/fingerprint-baseline.d.ts +8 -0
- package/dist/fingerprint-baseline.d.ts.map +1 -0
- package/dist/fingerprint-baseline.js +68 -0
- package/dist/fingerprint-baseline.js.map +1 -0
- package/dist/frame-metrics.d.ts +26 -0
- package/dist/frame-metrics.d.ts.map +1 -0
- package/dist/frame-metrics.js +134 -0
- package/dist/frame-metrics.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/metro-env.d.ts +11 -0
- package/dist/metro-env.d.ts.map +1 -0
- package/dist/metro-env.js +29 -0
- package/dist/metro-env.js.map +1 -0
- package/dist/source-freshness.d.ts +11 -0
- package/dist/source-freshness.d.ts.map +1 -0
- package/dist/source-freshness.js +98 -0
- package/dist/source-freshness.js.map +1 -0
- package/dist/tool-paths.d.ts +13 -0
- package/dist/tool-paths.d.ts.map +1 -0
- package/dist/tool-paths.js +125 -0
- package/dist/tool-paths.js.map +1 -0
- package/dist/video-recorder.d.ts +7 -0
- package/dist/video-recorder.d.ts.map +1 -0
- package/dist/video-recorder.js +250 -0
- package/dist/video-recorder.js.map +1 -0
- package/metro/coalesce-metro-log.cjs +28 -0
- package/metro/launch-metro.cjs +84 -0
- package/metro/metro-config.cjs +92 -0
- package/metro/metro-log-generation.cjs +116 -0
- package/package.json +27 -7
|
@@ -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
|
+
};
|