#!/usr/bin/env node
* Long-form Hermes profiling capture. Connects to Metro's inspector, streams
* Tracing.dataCollected events during the entire capture (the listener is
* installed BEFORE Tracing.start, which is what makes this work — the old
* capture-hermes-profile.js installs the listener after Tracing.end and so
* only sees the post-end flush, limiting captures to ~2-3s).
*
* Usage:
* node perf/capture-server.js [label]
* PERF_MAP=path/to/bundle.map node perf/capture-server.js [label]
*/
const { spawnSync } = require('child_process');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const readline = require('readline');
const METRO_HOST = process.env.METRO_HOST || 'localhost';
const METRO_PORT = process.env.METRO_PORT || '8081';
const PROFILES_DIR = path.join(__dirname, 'profiles');
const MAP_PATH = process.env.PERF_MAP ? path.resolve(process.env.PERF_MAP) : null;
const LABEL = (process.argv[2] || 'capture').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80);
fs.mkdirSync(PROFILES_DIR, { recursive: true });
if (typeof WebSocket === 'undefined') {
console.error('Global WebSocket missing — run on Node 22+.');
process.exit(1);
}
function httpGetJson(url) {
return new Promise((resolve, reject) => {
http
.get(url, (res) => {
let body = '';
res.on('data', (d) => (body += d));
res.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(e);
}
});
})
.on('error', reject);
});
}
function downloadToFile(url, outPath) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(outPath);
http
.get(url, (res) => {
if (res.statusCode !== 200) {
file.destroy();
return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
}
res.pipe(file);
file.on('finish', () => file.close(() => resolve(outPath)));
file.on('error', reject);
})
.on('error', reject);
});
}
function findBundleUrlInTrace(events) {
for (const ev of events || []) {
const nodes = ev?.args?.data?.cpuProfile?.nodes;
if (!nodes) continue;
for (const n of nodes) {
const url = n?.callFrame?.url;
if (typeof url === 'string' && /\.bundle\b/.test(url)) return url;
}
}
return null;
}
function deriveMapUrl(bundleUrl) {
const m = bundleUrl.match(/^https?:\/\/[^/]+\/(.+?)\.bundle\/*[?&]?(.*)$/);
if (!m) return null;
const basePath = m[1];
const query = m[2];
return `http://${METRO_HOST}:${METRO_PORT}/${basePath}.map${query ? '?' + query : ''}`;
}
function pickTarget(targets) {
const score = (t) => {
const s = `${t.title || ''} ${t.deviceName || ''} ${t.type || ''}`.toLowerCase();
let n = 0;
if (s.includes('hermes')) n += 5;
if (s.includes('rnruntime') || s.includes('react native')) n += 3;
if (t.webSocketDebuggerUrl) n += 1;
return n;
};
return targets.filter((t) => t.webSocketDebuggerUrl).sort((a, b) => score(b) - score(a))[0];
}
function prompt(msg) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) =>
rl.question(msg, (ans) => {
rl.close();
resolve(ans);
}),
);
}
async function main() {
console.log(`Connecting to Metro at ${METRO_HOST}:${METRO_PORT} ...`);
let targets;
try {
targets = await httpGetJson(`http://${METRO_HOST}:${METRO_PORT}/json/list`);
} catch (e) {
console.error(`✗ Could not reach Metro. Is \`yarn workspace sampleapp start\` running?`);
process.exit(1);
}
if (!Array.isArray(targets) || targets.length === 0) {
console.error('✗ Metro reported no debug targets — is the app open?');
process.exit(1);
}
const target = pickTarget(targets);
if (!target) {
console.error('✗ No Hermes target with a webSocketDebuggerUrl.');
process.exit(1);
}
console.log(
` ↳ ${target.title || '(no title)'} ${target.deviceName ? `(${target.deviceName})` : ''}`,
);
const ws = new WebSocket(target.webSocketDebuggerUrl);
let msgId = 0;
const pending = new Map();
const tracingEvents = [];
const send = (method, params = {}) =>
new Promise((resolve, reject) => {
const id = ++msgId;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
ws.addEventListener('message', (evt) => {
let msg;
try {
msg = JSON.parse(evt.data);
} catch {
return;
}
if (msg.id && pending.has(msg.id)) {
const p = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) p.reject(new Error(msg.error.message));
else p.resolve(msg.result);
return;
}
if (msg.method === 'Tracing.dataCollected') {
const arr = msg.params?.value || [];
for (const ev of arr) tracingEvents.push(ev);
}
});
await new Promise((resolve, reject) => {
ws.addEventListener('open', () => resolve());
ws.addEventListener('error', (e) => reject(new Error(`WS error: ${e.message || e}`)));
});
await send('Tracing.start', {
categories: '-*,disabled-by-default-v8.cpu_profiler.hires,disabled-by-default-v8.cpu_profiler',
options: 'sampling-frequency=10000',
transferMode: 'ReportEvents',
});
console.log(`\nTracing started. Run your scenario on device.`);
await prompt(`Press Enter to STOP and save: `);
console.log('Stopping ...');
await send('Tracing.end');
await new Promise((r) => setTimeout(r, 1500));
try {
ws.close();
} catch {
}
if (tracingEvents.length === 0) {
console.error('✗ No samples collected — was the app idle the whole time?');
process.exit(1);
}
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const outPath = path.join(PROFILES_DIR, `${LABEL}-${ts}.cpuprofile`);
fs.writeFileSync(outPath, JSON.stringify(tracingEvents));
const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1);
console.log(`Saved ${sizeKb} KB → ${path.relative(process.cwd(), outPath)}\n`);
let mapPath = MAP_PATH && fs.existsSync(MAP_PATH) ? MAP_PATH : null;
let autoFetchedMap = null;
if (!mapPath && !process.env.PERF_SKIP_DESYM) {
const bundleUrl = findBundleUrlInTrace(tracingEvents);
const mapUrl = bundleUrl ? deriveMapUrl(bundleUrl) : null;
if (!mapUrl) {
console.log('No .bundle URL in profile — skipping auto-desymbolication.');
} else {
const tmpMap = path.join(os.tmpdir(), `metro-${Date.now()}.map.json`);
console.log(`Fetching source map:\n ${mapUrl}`);
try {
await downloadToFile(mapUrl, tmpMap);
mapPath = tmpMap;
autoFetchedMap = tmpMap;
} catch (e) {
console.error(`Could not fetch source map (${e.message}) — analyzing raw profile.`);
}
}
}
let analyzeTarget = outPath;
if (mapPath && fs.existsSync(mapPath)) {
console.log(`Desymbolicating with ${mapPath} ...`);
const desym = spawnSync(
process.execPath,
[path.join(__dirname, 'desymbolicate-cpuprofile.js'), outPath, mapPath],
{ stdio: 'inherit' },
);
if (desym.status === 0) {
const candidate = outPath.replace(/\.cpuprofile$/, '') + '.desymbolicated.cpuprofile';
if (fs.existsSync(candidate)) analyzeTarget = candidate;
} else {
console.error('Desymbolication failed — analyzing raw profile.');
}
if (autoFetchedMap && fs.existsSync(autoFetchedMap)) fs.unlinkSync(autoFetchedMap);
}
console.log(`\nAnalyzing ${path.basename(analyzeTarget)} ...\n`);
const analyzerArgs = [path.join(__dirname, 'analyze-cpuprofile.js'), analyzeTarget];
if (process.env.PERF_INSIDE) {
analyzerArgs.push('--inside', process.env.PERF_INSIDE);
}
const r = spawnSync(process.execPath, analyzerArgs, { stdio: 'inherit' });
process.exit(r.status ?? 0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});