Files
callstack__agent-device/src/client-metro.ts
T
2026-05-19 17:11:31 +02:00

996 lines
29 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import { sleep } from './utils/timeouts.ts';
import { ensureMetroCompanion } from './client-metro-companion.ts';
import type { MetroBridgeScope } from './client-companion-tunnel-contract.ts';
import type {
MetroBridgeDescriptor,
MetroBridgeResult,
MetroBridgeRuntimePayload,
MetroRuntimeHints,
} from './metro-types.ts';
import { AppError } from './utils/errors.ts';
import { runCmdSync, runCmdDetached } from './utils/exec.ts';
import { resolveUserPath } from './utils/path-resolution.ts';
import { waitForProcessExit } from './utils/process-identity.ts';
import { buildBundleUrl, normalizeBaseUrl } from './utils/url.ts';
import {
resolveRuntimeTransportHints,
type ResolvedRuntimeTransport,
} from './utils/runtime-transport.ts';
const DEFAULT_METRO_HOST = 'localhost';
const DEFAULT_METRO_PORT = 8081;
const METRO_TERM_TIMEOUT_MS = 1_000;
const METRO_KILL_TIMEOUT_MS = 1_000;
export type MetroPrepareKind = 'auto' | 'react-native' | 'expo';
type ResolvedMetroKind = Exclude<MetroPrepareKind, 'auto'>;
type EnvSource = NodeJS.ProcessEnv | Record<string, string | undefined>;
export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts';
type PackageJsonShape = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
type PackageManagerConfig = {
command: string;
installArgs: string[];
};
type MetroProcessResult = {
pid: number;
};
type ResolvedMetroPrepareSettings = {
env: EnvSource;
projectRoot: string;
kind: ResolvedMetroKind;
metroPort: number;
listenHost: string;
statusHost: string;
publicBaseUrl: string;
proxyBaseUrl: string;
proxyBearerToken: string;
bridgeScope: MetroBridgeScope | null;
startupTimeoutMs: number;
probeTimeoutMs: number;
reuseExisting: boolean;
installProjectDeps: boolean;
runtimeFilePath: string | null;
logPath: string;
};
type MetroProcessState = {
started: boolean;
reused: boolean;
pid: number;
statusUrl: string;
};
export type PrepareMetroRuntimeOptions = {
projectRoot?: string;
kind?: MetroPrepareKind;
metroPort?: number | string;
listenHost?: string;
statusHost?: string;
publicBaseUrl?: string;
proxyBaseUrl?: string;
proxyBearerToken?: string;
bridgeScope?: MetroBridgeScope;
launchUrl?: string;
companionProfileKey?: string;
companionConsumerKey?: string;
startupTimeoutMs?: number | string;
probeTimeoutMs?: number | string;
reuseExisting?: boolean;
installDependenciesIfNeeded?: boolean;
runtimeFilePath?: string;
logPath?: string;
env?: EnvSource;
};
export type PrepareMetroRuntimeResult = {
projectRoot: string;
kind: ResolvedMetroKind;
dependenciesInstalled: boolean;
packageManager: string | null;
started: boolean;
reused: boolean;
pid: number;
logPath: string;
statusUrl: string;
runtimeFilePath: string | null;
iosRuntime: MetroRuntimeHints;
androidRuntime: MetroRuntimeHints;
bridge: MetroBridgeResult | null;
};
export type ReloadMetroOptions = {
metroHost?: string;
metroPort?: number | string;
bundleUrl?: string;
runtime?: MetroRuntimeHints;
timeoutMs?: number | string;
};
export type ReloadMetroResult = {
reloaded: true;
reloadUrl: string;
status: number;
body: string;
};
type ProxyBridgeRequestOptions = {
baseUrl: string;
bearerToken: string;
scope: MetroBridgeScope;
runtime?: MetroBridgeRuntimePayload;
timeoutMs: number;
};
type MetroBridgeRequestError = Error & {
retryable?: boolean;
};
function normalizeOptionalBaseUrl(input: unknown): string {
return typeof input === 'string' && input.trim() ? normalizeBaseUrl(input.trim()) : '';
}
function normalizeOptionalString(input: unknown): string | undefined {
return typeof input === 'string' && input.trim() ? input.trim() : undefined;
}
function resolvePath(inputPath: string, env: EnvSource, cwd: string): string {
return resolveUserPath(inputPath, { env, cwd });
}
function fileExists(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
function directoryExists(dirPath: string): boolean {
try {
return fs.statSync(dirPath).isDirectory();
} catch {
return false;
}
}
function readPackageJson(projectRoot: string): PackageJsonShape {
const packageJsonPath = path.join(projectRoot, 'package.json');
if (!fileExists(packageJsonPath)) {
throw new AppError('INVALID_ARGS', `package.json not found at ${packageJsonPath}`);
}
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJsonShape;
}
function detectPackageManager(projectRoot: string): PackageManagerConfig {
if (fileExists(path.join(projectRoot, 'pnpm-lock.yaml'))) {
return { command: 'pnpm', installArgs: ['install'] };
}
if (fileExists(path.join(projectRoot, 'yarn.lock'))) {
return { command: 'yarn', installArgs: ['install'] };
}
return { command: 'npm', installArgs: ['install'] };
}
function detectMetroKind(projectRoot: string, requestedKind: MetroPrepareKind): ResolvedMetroKind {
if (requestedKind !== 'auto') {
return requestedKind;
}
const packageJson = readPackageJson(projectRoot);
const dependencies = {
...(packageJson.dependencies ?? {}),
...(packageJson.devDependencies ?? {}),
};
return typeof dependencies.expo === 'string' ? 'expo' : 'react-native';
}
function parseTimeout(
value: number | string | undefined,
fallback: number,
minimum: number,
): number {
if (value === undefined || value === null || value === '') {
return fallback;
}
const parsed = Number.parseInt(String(value), 10);
if (!Number.isInteger(parsed)) {
return fallback;
}
return Math.max(parsed, minimum);
}
function parsePort(value: number | string | undefined, fallback: number): number {
if (value === undefined || value === null || value === '') {
return fallback;
}
const parsed = Number.parseInt(String(value), 10);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new AppError('INVALID_ARGS', `Invalid Metro port: ${String(value)}. Use 1-65535.`);
}
return parsed;
}
export function buildMetroRuntimeHints(
baseUrl: string,
platform: 'ios' | 'android',
): MetroRuntimeHints {
return {
platform,
bundleUrl: buildBundleUrl(baseUrl, platform),
};
}
function normalizeProxyRuntimeHints(
value: MetroBridgeRuntimePayload | undefined,
platform: 'ios' | 'android',
): MetroRuntimeHints {
return {
platform,
metroHost: normalizeOptionalString(value?.metro_host),
metroPort: value?.metro_port,
bundleUrl: normalizeOptionalString(value?.metro_bundle_url),
launchUrl: normalizeOptionalString(value?.launch_url),
};
}
function installDependenciesIfNeeded(
projectRoot: string,
env: EnvSource,
): { installed: boolean; packageManager?: string } {
if (directoryExists(path.join(projectRoot, 'node_modules'))) {
return { installed: false };
}
const packageManager = detectPackageManager(projectRoot);
runCmdSync(packageManager.command, packageManager.installArgs, {
cwd: projectRoot,
env: env as NodeJS.ProcessEnv,
});
return { installed: true, packageManager: packageManager.command };
}
async function wait(ms: number): Promise<void> {
await sleep(ms);
}
async function fetchText(
url: string,
timeoutMs: number,
extraHeaders: Record<string, string> = {},
): Promise<{ ok: boolean; status: number; body: string }> {
try {
const response = await fetch(url, {
headers: extraHeaders,
signal: AbortSignal.timeout(timeoutMs),
});
return {
ok: response.ok,
status: response.status,
body: await response.text(),
};
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
throw new Error(`Timed out fetching ${url} after ${timeoutMs}ms`);
}
throw error;
}
}
async function isMetroReady(statusUrl: string, timeoutMs: number): Promise<boolean> {
try {
const response = await fetchText(statusUrl, timeoutMs);
return response.ok && response.body.includes('packager-status:running');
} catch {
return false;
}
}
function buildReloadUrl(transport: ResolvedRuntimeTransport, pathName: string): string {
const url = new URL(`${transport.scheme}://localhost`);
url.hostname = transport.host;
url.port = String(transport.port);
url.pathname = pathName;
return url.toString();
}
function resolveMetroReloadPath(bundleUrl: string | undefined): string {
const value = normalizeOptionalString(bundleUrl);
if (!value) return '/reload';
const url = new URL(value);
const bundlePath = url.pathname.replace(/\/+$/, '');
if (!bundlePath.endsWith('/index.bundle')) return '/reload';
return `${bundlePath.slice(0, -'/index.bundle'.length)}/reload`;
}
function resolveReloadMetroHost(
input: ReloadMetroOptions,
hasExplicitBundleUrl: boolean,
hasBundleUrl: boolean,
): string | undefined {
return (
normalizeOptionalString(input.metroHost) ??
(hasExplicitBundleUrl ? undefined : normalizeOptionalString(input.runtime?.metroHost)) ??
(hasBundleUrl ? undefined : DEFAULT_METRO_HOST)
);
}
function resolveReloadMetroPort(
input: ReloadMetroOptions,
hasExplicitBundleUrl: boolean,
hasBundleUrl: boolean,
): number | undefined {
if (input.metroPort !== undefined) {
return parsePort(input.metroPort, DEFAULT_METRO_PORT);
}
if (hasExplicitBundleUrl) {
return undefined;
}
return input.runtime?.metroPort ?? (hasBundleUrl ? undefined : DEFAULT_METRO_PORT);
}
function resolveMetroReloadUrl(input: ReloadMetroOptions): string {
const explicitBundleUrl = normalizeOptionalString(input.bundleUrl);
const bundleUrl = explicitBundleUrl ?? input.runtime?.bundleUrl;
const hasExplicitBundleUrl = Boolean(explicitBundleUrl);
const hasBundleUrl = Boolean(normalizeOptionalString(bundleUrl));
const transport = resolveRuntimeTransportHints({
metroHost: resolveReloadMetroHost(input, hasExplicitBundleUrl, hasBundleUrl),
metroPort: resolveReloadMetroPort(input, hasExplicitBundleUrl, hasBundleUrl),
bundleUrl,
});
if (!transport) {
throw new AppError('INVALID_ARGS', 'Unable to resolve Metro host and port for reload.');
}
return buildReloadUrl(transport, resolveMetroReloadPath(bundleUrl));
}
function buildMetroCommand(
kind: ResolvedMetroKind,
port: number,
listenHost: string,
): PackageManagerConfig {
if (kind === 'expo') {
return {
command: 'npx',
installArgs: ['expo', 'start', '--host', 'lan', '--port', String(port)],
};
}
return {
command: 'npx',
installArgs: ['react-native', 'start', '--host', listenHost, '--port', String(port)],
};
}
function startMetroProcess(
projectRoot: string,
kind: ResolvedMetroKind,
port: number,
listenHost: string,
logPath: string,
env: EnvSource,
): MetroProcessResult {
const metro = buildMetroCommand(kind, port, listenHost);
fs.mkdirSync(path.dirname(logPath), { recursive: true });
const logFd = fs.openSync(logPath, 'a');
let pid = 0;
try {
pid = runCmdDetached(metro.command, metro.installArgs, {
cwd: projectRoot,
env: env as NodeJS.ProcessEnv,
stdio: ['ignore', logFd, logFd],
});
} finally {
fs.closeSync(logFd);
}
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error('Failed to start Metro. Expected a detached child PID.');
}
return {
pid,
};
}
async function stopSpawnedMetroProcess(pid: number): Promise<void> {
if (!Number.isInteger(pid) || pid <= 0) return;
try {
process.kill(pid, 'SIGTERM');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ESRCH' || code === 'EPERM') return;
throw error;
}
if (await waitForProcessExit(pid, METRO_TERM_TIMEOUT_MS)) return;
try {
process.kill(pid, 'SIGKILL');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ESRCH' || code === 'EPERM') return;
throw error;
}
await waitForProcessExit(pid, METRO_KILL_TIMEOUT_MS);
}
function createProxyHeaders(baseUrl: string, bearerToken: string): Record<string, string> {
return {
Authorization: `Bearer ${bearerToken}`,
'Content-Type': 'application/json',
...(baseUrl.includes('ngrok') ? { 'ngrok-skip-browser-warning': '1' } : {}),
};
}
function createMetroBridgeRequestError(
message: string,
retryable: boolean,
): MetroBridgeRequestError {
const error = new Error(message) as MetroBridgeRequestError;
error.retryable = retryable;
return error;
}
function isRetryableBridgeHttpFailure(statusCode: number, responsePayload: unknown): boolean {
if (statusCode >= 500 || statusCode === 408 || statusCode === 425 || statusCode === 429) {
return true;
}
const responseText = JSON.stringify(responsePayload);
if (responseText.includes('Metro companion is not connected')) {
return true;
}
return false;
}
function isRetryableBridgeError(error: unknown): boolean {
return Boolean(
error &&
typeof error === 'object' &&
'retryable' in error &&
(error as MetroBridgeRequestError).retryable === true,
);
}
async function configureMetroBridge(input: ProxyBridgeRequestOptions): Promise<MetroBridgeResult> {
let response: Response;
try {
response = await fetch(`${input.baseUrl}/api/metro/bridge`, {
method: 'POST',
headers: createProxyHeaders(input.baseUrl, input.bearerToken),
body: JSON.stringify({
...input.scope,
...(input.runtime ? { ios_runtime: input.runtime } : {}),
timeout_ms: input.timeoutMs,
}),
signal: AbortSignal.timeout(input.timeoutMs),
});
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
throw createMetroBridgeRequestError(
`/api/metro/bridge timed out after ${input.timeoutMs}ms calling ${input.baseUrl}/api/metro/bridge`,
true,
);
}
throw createMetroBridgeRequestError(
error instanceof Error ? error.message : String(error),
true,
);
}
const responseText = await response.text();
const responsePayload = parseMetroBridgeResponsePayload(
responseText,
response.status,
input.baseUrl,
);
if (!response.ok) {
throw createMetroBridgeRequestError(
`/api/metro/bridge failed (${response.status}): ${JSON.stringify(responsePayload)}`,
isRetryableBridgeHttpFailure(response.status, responsePayload),
);
}
return normalizeMetroBridgeResponsePayload(responsePayload);
}
function parseMetroBridgeResponsePayload(
responseText: string,
statusCode: number,
baseUrl: string,
): Record<string, unknown> {
if (!responseText) return {};
try {
const parsed = JSON.parse(responseText) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Expected a JSON object');
}
return parsed as Record<string, unknown>;
} catch (error) {
const snippet = responseText.slice(0, 200);
const detail = error instanceof Error ? error.message : String(error);
throw createMetroBridgeRequestError(
`/api/metro/bridge returned invalid JSON (${statusCode}) from ${baseUrl}: ${detail}. body=${JSON.stringify(snippet)}`,
isRetryableBridgeHttpFailure(statusCode, responseText),
);
}
}
function normalizeBridgeResponse(response: MetroBridgeDescriptor): MetroBridgeResult {
return {
enabled: response.enabled,
baseUrl: response.base_url,
statusUrl: response.status_url ?? '',
bundleUrl: response.bundle_url ?? '',
iosRuntime: normalizeProxyRuntimeHints(response.ios_runtime, 'ios'),
androidRuntime: normalizeProxyRuntimeHints(response.android_runtime, 'android'),
upstream: {
bundleUrl: response.upstream.bundle_url ?? '',
host: response.upstream.host ?? '',
port: response.upstream.port ?? 0,
statusUrl: response.upstream.status_url ?? '',
},
probe: {
reachable: response.probe.reachable,
statusCode: response.probe.status_code,
latencyMs: response.probe.latency_ms,
detail: response.probe.detail,
},
};
}
function normalizeMetroBridgeResponsePayload(
responsePayload: Record<string, unknown>,
): MetroBridgeResult {
const descriptor = responsePayload.data ?? responsePayload;
if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) {
throw createMetroBridgeRequestError(
'/api/metro/bridge returned malformed descriptor: Expected a JSON object.',
false,
);
}
try {
return normalizeBridgeResponse(descriptor as MetroBridgeDescriptor);
} catch (error) {
throw createMetroBridgeRequestError(
`/api/metro/bridge returned malformed descriptor: ${error instanceof Error ? error.message : String(error)}`,
false,
);
}
}
function describeBridgeFailure(
baseUrl: string,
bridgeError: string | null,
bridge: MetroBridgeResult | null,
initialBridgeError?: string | null,
companionLogPath?: string,
): string {
const parts = [
`Metro bridge is required for this run but could not be configured via ${baseUrl}/api/metro/bridge.`,
];
if (bridgeError) {
parts.push(`bridgeError=${bridgeError}`);
}
if (bridge?.probe.reachable === false) {
parts.push(
`bridgeProbe=${bridge.probe.detail || `unreachable (status ${bridge.probe.statusCode || 0})`}`,
);
}
if (initialBridgeError && initialBridgeError !== bridgeError) {
parts.push(`initialBridgeError=${initialBridgeError}`);
}
if (companionLogPath) {
parts.push(`metroCompanionLog=${companionLogPath}`);
}
return parts.join(' ');
}
function requireBridgeRuntimeDescriptor(baseUrl: string, bridge: MetroBridgeResult | null): void {
if (!bridge?.iosRuntime.bundleUrl) {
throw new Error(
describeBridgeFailure(
baseUrl,
'bridge descriptor is missing ios_runtime.metro_bundle_url',
bridge,
),
);
}
}
function resolveProxySettings(
proxyBaseUrl: string,
proxyBearerToken: string,
env: EnvSource,
): {
proxyEnabled: boolean;
proxyBaseUrl: string;
proxyBearerToken: string;
} {
const proxySpecificBearerToken =
proxyBearerToken || normalizeOptionalString(env.AGENT_DEVICE_METRO_BEARER_TOKEN) || '';
const resolvedProxyBearerToken =
proxySpecificBearerToken ||
(proxyBaseUrl ? normalizeOptionalString(env.AGENT_DEVICE_DAEMON_AUTH_TOKEN) || '' : '');
if (proxyBaseUrl && !resolvedProxyBearerToken) {
throw new AppError(
'INVALID_ARGS',
'metro prepare requires proxy auth when --proxy-base-url is provided. Pass --bearer-token or set AGENT_DEVICE_METRO_BEARER_TOKEN or AGENT_DEVICE_DAEMON_AUTH_TOKEN.',
);
}
if (!proxyBaseUrl && proxySpecificBearerToken) {
throw new AppError(
'INVALID_ARGS',
'metro prepare requires --proxy-base-url when proxy auth is provided.',
);
}
return {
proxyEnabled: Boolean(proxyBaseUrl && resolvedProxyBearerToken),
proxyBaseUrl,
proxyBearerToken: resolvedProxyBearerToken,
};
}
function requireBridgeScope(scope: MetroBridgeScope | undefined): MetroBridgeScope {
if (!scope?.tenantId || !scope.runId || !scope.leaseId) {
throw new AppError(
'INVALID_ARGS',
'metro prepare with proxy requires tenantId, runId, and leaseId bridge scope.',
);
}
return scope;
}
function requireMetroBaseUrl(publicBaseUrl: string, proxyBaseUrl: string): void {
if (publicBaseUrl || proxyBaseUrl) {
return;
}
throw new AppError(
'INVALID_ARGS',
'metro prepare requires --public-base-url <url> or --proxy-base-url <url>.',
);
}
function resolveMetroRuntimeFilePath(
input: PrepareMetroRuntimeOptions,
env: EnvSource,
cwd: string,
): string | null {
return input.runtimeFilePath ? resolvePath(input.runtimeFilePath, env, cwd) : null;
}
function resolveMetroLogPath(
input: PrepareMetroRuntimeOptions,
env: EnvSource,
cwd: string,
projectRoot: string,
): string {
return resolvePath(
input.logPath ?? path.join(projectRoot, '.agent-device', 'metro.log'),
env,
cwd,
);
}
function resolveMetroPrepareSettings(
input: PrepareMetroRuntimeOptions,
): ResolvedMetroPrepareSettings {
const env = input.env ?? process.env;
const cwd = process.cwd();
const projectRoot = resolvePath(input.projectRoot ?? cwd, env, cwd);
const publicBaseUrl = normalizeOptionalBaseUrl(input.publicBaseUrl);
const proxyBaseUrlInput = normalizeOptionalBaseUrl(input.proxyBaseUrl);
requireMetroBaseUrl(publicBaseUrl, proxyBaseUrlInput);
const { proxyEnabled, proxyBaseUrl, proxyBearerToken } = resolveProxySettings(
proxyBaseUrlInput,
normalizeOptionalString(input.proxyBearerToken) ?? '',
env,
);
return {
env,
projectRoot,
kind: detectMetroKind(projectRoot, input.kind ?? 'auto'),
metroPort: parsePort(input.metroPort ?? 8081, 8081),
listenHost: normalizeOptionalString(input.listenHost) ?? '0.0.0.0',
statusHost: normalizeOptionalString(input.statusHost) ?? '127.0.0.1',
publicBaseUrl,
proxyBaseUrl,
proxyBearerToken,
bridgeScope: proxyEnabled ? requireBridgeScope(input.bridgeScope) : null,
startupTimeoutMs: parseTimeout(input.startupTimeoutMs, 180_000, 30_000),
probeTimeoutMs: parseTimeout(input.probeTimeoutMs, 10_000, 1_000),
reuseExisting: input.reuseExisting ?? true,
installProjectDeps: input.installDependenciesIfNeeded ?? true,
runtimeFilePath: resolveMetroRuntimeFilePath(input, env, cwd),
logPath: resolveMetroLogPath(input, env, cwd, projectRoot),
};
}
async function waitForMetroReady(
statusUrl: string,
startupTimeoutMs: number,
probeTimeoutMs: number,
): Promise<boolean> {
const deadline = Date.now() + startupTimeoutMs;
while (Date.now() < deadline) {
const remainingMs = deadline - Date.now();
const requestTimeoutMs = Math.min(probeTimeoutMs, Math.max(remainingMs, 1));
if (await isMetroReady(statusUrl, requestTimeoutMs)) {
return true;
}
const sleepMs = Math.min(500, Math.max(deadline - Date.now(), 0));
if (sleepMs > 0) {
await wait(sleepMs);
}
}
return false;
}
async function configureMetroBridgeUntilReady(options: {
baseUrl: string;
bearerToken: string;
scope: MetroBridgeScope;
runtime?: MetroBridgeRuntimePayload;
probeTimeoutMs: number;
startupTimeoutMs: number;
initialBridgeError?: string | null;
companionLogPath?: string;
}): Promise<MetroBridgeResult> {
const deadline = Date.now() + options.startupTimeoutMs;
let lastBridge: MetroBridgeResult | null = null;
let lastBridgeError: string | null = null;
while (Date.now() < deadline) {
try {
const bridge = await configureMetroBridge({
baseUrl: options.baseUrl,
bearerToken: options.bearerToken,
scope: options.scope,
runtime: options.runtime,
timeoutMs: options.probeTimeoutMs,
});
if (bridge.probe.reachable !== false) {
return bridge;
}
lastBridge = bridge;
lastBridgeError = null;
} catch (error) {
lastBridgeError = error instanceof Error ? error.message : String(error);
if (!isRetryableBridgeError(error)) {
throw new Error(
describeBridgeFailure(
options.baseUrl,
lastBridgeError,
lastBridge,
options.initialBridgeError,
options.companionLogPath,
),
);
}
}
const sleepMs = Math.min(1_000, Math.max(deadline - Date.now(), 0));
if (sleepMs > 0) {
await wait(sleepMs);
}
}
throw new Error(
describeBridgeFailure(
options.baseUrl,
lastBridgeError,
lastBridge,
options.initialBridgeError,
options.companionLogPath,
),
);
}
async function ensureMetroProcessReady(
settings: ResolvedMetroPrepareSettings,
): Promise<MetroProcessState> {
const statusUrl = `http://${settings.statusHost}:${settings.metroPort}/status`;
if (settings.reuseExisting && (await isMetroReady(statusUrl, settings.probeTimeoutMs))) {
return { started: false, reused: true, pid: 0, statusUrl };
}
const startedProcess = startMetroProcess(
settings.projectRoot,
settings.kind,
settings.metroPort,
settings.listenHost,
settings.logPath,
settings.env,
);
if (await waitForMetroReady(statusUrl, settings.startupTimeoutMs, settings.probeTimeoutMs)) {
return { started: true, reused: false, pid: startedProcess.pid, statusUrl };
}
await stopSpawnedMetroProcess(startedProcess.pid).catch(() => {});
throw new Error(
`Metro did not become ready at ${statusUrl} within ${settings.startupTimeoutMs}ms. Check ${settings.logPath}.`,
);
}
async function configureProxyBridgeForRuntime(
input: PrepareMetroRuntimeOptions,
settings: ResolvedMetroPrepareSettings,
): Promise<MetroBridgeResult | null> {
const bridgeScope = settings.bridgeScope;
if (!bridgeScope) {
return null;
}
let bridge: MetroBridgeResult | null = null;
let initialBridgeError: string | null = null;
try {
bridge = await configureMetroBridge({
baseUrl: settings.proxyBaseUrl,
bearerToken: settings.proxyBearerToken,
scope: bridgeScope,
timeoutMs: settings.probeTimeoutMs,
});
} catch (error) {
if (!isRetryableBridgeError(error)) {
throw error;
}
initialBridgeError = error instanceof Error ? error.message : String(error);
}
if (!bridge || bridge.probe.reachable === false) {
bridge = await configureProxyBridgeViaCompanion(
input,
settings,
bridgeScope,
bridge,
initialBridgeError,
);
}
requireBridgeRuntimeDescriptor(settings.proxyBaseUrl, bridge);
return bridge;
}
async function configureProxyBridgeViaCompanion(
input: PrepareMetroRuntimeOptions,
settings: ResolvedMetroPrepareSettings,
bridgeScope: MetroBridgeScope,
bridge: MetroBridgeResult | null,
initialBridgeError: string | null,
): Promise<MetroBridgeResult> {
let companionLogPath: string | undefined;
try {
const companion = await ensureMetroCompanion({
projectRoot: settings.projectRoot,
serverBaseUrl: settings.proxyBaseUrl,
bearerToken: settings.proxyBearerToken,
bridgeScope,
localBaseUrl: `http://${settings.statusHost}:${settings.metroPort}`,
launchUrl: normalizeOptionalString(input.launchUrl),
profileKey: normalizeOptionalString(input.companionProfileKey),
consumerKey: normalizeOptionalString(input.companionConsumerKey),
env: settings.env as NodeJS.ProcessEnv,
});
companionLogPath = companion.logPath;
} catch (error) {
throw new Error(
describeBridgeFailure(
settings.proxyBaseUrl,
error instanceof Error ? error.message : String(error),
bridge,
initialBridgeError,
),
);
}
try {
return await configureMetroBridgeUntilReady({
baseUrl: settings.proxyBaseUrl,
bearerToken: settings.proxyBearerToken,
scope: bridgeScope,
probeTimeoutMs: settings.probeTimeoutMs,
startupTimeoutMs: settings.startupTimeoutMs,
initialBridgeError,
companionLogPath,
});
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
}
function buildBaseRuntimeHints(publicBaseUrl: string): {
baseIosRuntime: MetroRuntimeHints;
baseAndroidRuntime: MetroRuntimeHints;
} {
return {
baseIosRuntime: publicBaseUrl
? buildMetroRuntimeHints(publicBaseUrl, 'ios')
: { platform: 'ios' as const },
baseAndroidRuntime: publicBaseUrl
? buildMetroRuntimeHints(publicBaseUrl, 'android')
: { platform: 'android' as const },
};
}
function writeMetroRuntimeFile(
runtimeFilePath: string | null,
result: PrepareMetroRuntimeResult,
): void {
if (!runtimeFilePath) {
return;
}
fs.mkdirSync(path.dirname(runtimeFilePath), { recursive: true });
fs.writeFileSync(runtimeFilePath, JSON.stringify(result, null, 2));
}
export async function prepareMetroRuntime(
input: PrepareMetroRuntimeOptions = {},
): Promise<PrepareMetroRuntimeResult> {
const settings = resolveMetroPrepareSettings(input);
const dependencyInstall = settings.installProjectDeps
? installDependenciesIfNeeded(settings.projectRoot, settings.env)
: { installed: false as const };
const processState = await ensureMetroProcessReady(settings);
const { baseIosRuntime, baseAndroidRuntime } = buildBaseRuntimeHints(settings.publicBaseUrl);
const bridge = await configureProxyBridgeForRuntime(input, settings);
const iosRuntime = bridge?.iosRuntime ?? baseIosRuntime;
const androidRuntime = bridge?.androidRuntime ?? baseAndroidRuntime;
const result: PrepareMetroRuntimeResult = {
projectRoot: settings.projectRoot,
kind: settings.kind,
dependenciesInstalled: dependencyInstall.installed,
packageManager: dependencyInstall.packageManager ?? null,
started: processState.started,
reused: processState.reused,
pid: processState.pid,
logPath: settings.logPath,
statusUrl: processState.statusUrl,
runtimeFilePath: settings.runtimeFilePath,
iosRuntime,
androidRuntime,
bridge,
};
writeMetroRuntimeFile(settings.runtimeFilePath, result);
return result;
}
export async function reloadMetro(input: ReloadMetroOptions = {}): Promise<ReloadMetroResult> {
const timeoutMs = parseTimeout(input.timeoutMs, 10_000, 1_000);
const reloadUrl = resolveMetroReloadUrl(input);
const response = await fetchText(reloadUrl, timeoutMs);
if (!response.ok) {
throw new AppError('COMMAND_FAILED', `Metro reload failed (${response.status}).`, {
reloadUrl,
status: response.status,
body: response.body,
hint: 'Verify Metro is running and the target React Native app is connected to this Metro instance.',
});
}
return {
reloaded: true,
reloadUrl,
status: response.status,
body: response.body,
};
}