fix: include lease scope in Metro bridge calls (#408)

This commit is contained in:
Michał Pierzchała
2026-04-13 17:32:23 +02:00
committed by GitHub
parent ac01393a9e
commit e99bbb526e
17 changed files with 347 additions and 4 deletions
+16
View File
@@ -126,6 +126,9 @@ test('metro prepare forwards normalized options to client.metro.prepare', async
metroPublicBaseUrl: 'https://sandbox.example.test',
metroProxyBaseUrl: 'https://proxy.example.test',
metroBearerToken: 'secret',
tenant: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
metroPreparePort: 9090,
metroKind: 'expo',
metroRuntimeFile: './.agent-device/metro-runtime.json',
@@ -143,6 +146,11 @@ test('metro prepare forwards normalized options to client.metro.prepare', async
publicBaseUrl: 'https://sandbox.example.test',
proxyBaseUrl: 'https://proxy.example.test',
bearerToken: 'secret',
bridgeScope: {
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
},
port: 9090,
kind: 'expo',
runtimeFilePath: './.agent-device/metro-runtime.json',
@@ -366,6 +374,9 @@ test('metro prepare with --remote-config loads profile defaults', async () => {
metroProjectRoot: './apps/demo',
metroPublicBaseUrl: 'https://sandbox.example.test',
metroProxyBaseUrl: 'https://proxy.example.test',
tenant: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
metroPreparePort: 9090,
}),
);
@@ -421,6 +432,11 @@ test('metro prepare with --remote-config loads profile defaults', async () => {
publicBaseUrl: 'https://sandbox.example.test',
proxyBaseUrl: 'https://proxy.example.test',
bearerToken: undefined,
bridgeScope: {
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
},
port: 9090,
listenHost: undefined,
statusHost: undefined,
@@ -11,6 +11,12 @@ vi.mock('../client-metro-companion.ts', () => ({
import { ensureMetroCompanion } from '../client-metro-companion.ts';
import { prepareMetroRuntime } from '../client-metro.ts';
const TEST_BRIDGE_SCOPE = {
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
};
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -96,6 +102,7 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
publicBaseUrl: 'https://public.example.test',
proxyBaseUrl: 'https://proxy.example.test',
proxyBearerToken: 'shared-token',
bridgeScope: TEST_BRIDGE_SCOPE,
metroPort: 8081,
reuseExisting: true,
installDependenciesIfNeeded: false,
@@ -117,6 +124,7 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
projectRoot,
serverBaseUrl: 'https://proxy.example.test',
bearerToken: 'shared-token',
bridgeScope: TEST_BRIDGE_SCOPE,
localBaseUrl: 'http://127.0.0.1:8081',
launchUrl: undefined,
profileKey: undefined,
@@ -126,6 +134,26 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
assert.equal(fetchMock.mock.calls.length, 3);
assert.equal(fetchMock.mock.calls[1]?.[0], 'https://proxy.example.test/api/metro/bridge');
assert.equal(fetchMock.mock.calls[2]?.[0], 'https://proxy.example.test/api/metro/bridge');
assert.deepEqual(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)), {
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
ios_runtime: {
metro_bundle_url:
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
},
timeout_ms: 10000,
});
assert.deepEqual(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body)), {
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
ios_runtime: {
metro_bundle_url:
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
},
timeout_ms: 10000,
});
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
@@ -165,6 +193,7 @@ test('prepareMetroRuntime preserves the initial bridge error if companion startu
publicBaseUrl: 'https://public.example.test',
proxyBaseUrl: 'https://proxy.example.test',
proxyBearerToken: 'shared-token',
bridgeScope: TEST_BRIDGE_SCOPE,
metroPort: 8081,
reuseExisting: true,
installDependenciesIfNeeded: false,
@@ -183,6 +212,57 @@ test('prepareMetroRuntime preserves the initial bridge error if companion startu
}
});
test('prepareMetroRuntime fails fast when initial bridge failure is non-retryable', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-401-'));
const projectRoot = path.join(tempRoot, 'project');
fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({
name: 'metro-initial-bridge-non-retryable-test',
private: true,
dependencies: {
'react-native': '0.0.0-test',
},
}),
);
const fetchMock = vi.fn();
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => 'packager-status:running',
});
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
text: async () => JSON.stringify({ ok: false, error: 'invalid scope' }),
});
vi.stubGlobal('fetch', fetchMock);
try {
await assert.rejects(
() =>
prepareMetroRuntime({
projectRoot,
publicBaseUrl: 'https://public.example.test',
proxyBaseUrl: 'https://proxy.example.test',
proxyBearerToken: 'shared-token',
bridgeScope: TEST_BRIDGE_SCOPE,
metroPort: 8081,
reuseExisting: true,
installDependenciesIfNeeded: false,
probeTimeoutMs: 10,
}),
/\/api\/metro\/bridge failed \(401\)/,
);
assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0);
assert.equal(fetchMock.mock.calls.length, 2);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test('prepareMetroRuntime fails fast on non-retryable bridge errors after companion startup', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-401-'));
const projectRoot = path.join(tempRoot, 'project');
@@ -231,6 +311,7 @@ test('prepareMetroRuntime fails fast on non-retryable bridge errors after compan
publicBaseUrl: 'https://public.example.test',
proxyBaseUrl: 'https://proxy.example.test',
proxyBearerToken: 'shared-token',
bridgeScope: TEST_BRIDGE_SCOPE,
metroPort: 8081,
reuseExisting: true,
installDependenciesIfNeeded: false,
@@ -335,6 +416,7 @@ test('prepareMetroRuntime retries malformed retryable bridge responses after com
publicBaseUrl: 'https://public.example.test',
proxyBaseUrl: 'https://proxy.example.test',
proxyBearerToken: 'shared-token',
bridgeScope: TEST_BRIDGE_SCOPE,
metroPort: 8081,
reuseExisting: true,
installDependenciesIfNeeded: false,
@@ -178,6 +178,7 @@ test('metro companion worker proxies websocket frames to the local upstream serv
const upstreamMessage = createDeferred<string>();
const bridgePong = createDeferred<void>();
const bridgeSocketReady = createDeferred<NodeJS.WritableStream>();
const registrationBody = createDeferred<Record<string, unknown>>();
const bridgeOpen = createDeferred<void>();
const bridgeFrame = createDeferred<string>();
const bridgeClose = createDeferred<CloseFrame>();
@@ -233,8 +234,12 @@ test('metro companion worker proxies websocket frames to the local upstream serv
const bridgeServer = http.createServer((req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
if (req.method === 'POST' && url.pathname === '/api/metro/companion/register') {
req.resume();
const chunks: Buffer[] = [];
req.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('end', () => {
registrationBody.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
@@ -325,6 +330,9 @@ test('metro companion worker proxies websocket frames to the local upstream serv
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
},
stdio: ['ignore', 'pipe', 'pipe'],
},
@@ -349,6 +357,12 @@ test('metro companion worker proxies websocket frames to the local upstream serv
waitFor(bridgeSocketReady.promise, 5_000, 'bridge websocket connection'),
earlyExit,
]);
assert.deepEqual(await waitFor(registrationBody.promise, 5_000, 'companion registration'), {
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
local_base_url: `http://127.0.0.1:${upstreamPort}`,
});
await Promise.race([waitFor(bridgePong.promise, 5_000, 'bridge pong'), earlyExit]);
await Promise.race([waitFor(bridgeOpen.promise, 5_000, 'bridge ws-open-result'), earlyExit]);
bridgeSocket.write(
@@ -467,6 +481,9 @@ test('metro companion worker reconnects after the bridge closes immediately afte
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`,
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
},
stdio: ['ignore', 'pipe', 'pipe'],
},
@@ -571,6 +588,9 @@ test('metro companion worker exits after its state file is removed', async () =>
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`,
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath,
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -615,6 +635,9 @@ test('metro companion worker exits immediately when its state file is already mi
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: 'http://127.0.0.1:1',
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: 'http://127.0.0.1:1',
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath,
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -24,6 +24,12 @@ import {
} from '../utils/process-identity.ts';
import { ensureMetroCompanion, stopMetroCompanion } from '../client-metro-companion.ts';
const TEST_BRIDGE_SCOPE = {
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
};
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
@@ -59,6 +65,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
serverBaseUrl: 'https://bridge.example.test',
bearerToken: 'token',
localBaseUrl: 'http://127.0.0.1:8081',
bridgeScope: TEST_BRIDGE_SCOPE,
launchUrl: 'myapp://staging',
profileKey: '/tmp/staging.json',
consumerKey: 'session-a',
@@ -68,6 +75,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
serverBaseUrl: 'https://bridge.example.test',
bearerToken: 'token',
localBaseUrl: 'http://127.0.0.1:8081',
bridgeScope: TEST_BRIDGE_SCOPE,
launchUrl: 'myapp://staging',
profileKey: '/tmp/staging.json',
consumerKey: 'session-b',
@@ -77,6 +85,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
serverBaseUrl: 'https://bridge.example.test',
bearerToken: 'token',
localBaseUrl: 'http://127.0.0.1:8081',
bridgeScope: TEST_BRIDGE_SCOPE,
launchUrl: 'myapp://prod',
profileKey: '/tmp/prod.json',
consumerKey: 'session-prod',
@@ -155,6 +164,7 @@ test('launchUrl changes force a companion respawn for the same profile', async (
serverBaseUrl: 'https://bridge.example.test',
bearerToken: 'token',
localBaseUrl: 'http://127.0.0.1:8081',
bridgeScope: TEST_BRIDGE_SCOPE,
launchUrl: 'myapp://first',
profileKey: '/tmp/profile.json',
consumerKey: 'session-a',
@@ -164,6 +174,7 @@ test('launchUrl changes force a companion respawn for the same profile', async (
serverBaseUrl: 'https://bridge.example.test',
bearerToken: 'token',
localBaseUrl: 'http://127.0.0.1:8081',
bridgeScope: TEST_BRIDGE_SCOPE,
launchUrl: 'myapp://second',
profileKey: '/tmp/profile.json',
consumerKey: 'session-a',
@@ -184,3 +195,54 @@ test('launchUrl changes force a companion respawn for the same profile', async (
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
test('legacy state without bridge scope is stopped before respawn', async () => {
const projectRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'agent-device-metro-companion-legacy-'),
);
const statePath = path.join(projectRoot, '.agent-device', 'metro-companion.json');
try {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(
statePath,
`${JSON.stringify({
pid: 555,
startTime: 'start-555',
command: `${process.execPath} src/metro-companion.ts --agent-device-run-metro-companion`,
serverBaseUrl: 'https://bridge.example.test',
localBaseUrl: 'http://127.0.0.1:8081',
tokenHash: 'legacy-token-hash',
consumers: ['session-a'],
})}\n`,
);
vi.mocked(runCmdDetached).mockReturnValueOnce(666);
vi.mocked(isProcessAlive).mockReturnValue(true);
vi.mocked(readProcessStartTime).mockReturnValue('start-555');
vi.mocked(readProcessCommand).mockReturnValue(
`${process.execPath} src/metro-companion.ts --agent-device-run-metro-companion`,
);
vi.mocked(waitForProcessExit).mockResolvedValue(true);
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const spawned = await ensureMetroCompanion({
projectRoot,
serverBaseUrl: 'https://bridge.example.test',
bearerToken: 'token',
localBaseUrl: 'http://127.0.0.1:8081',
bridgeScope: TEST_BRIDGE_SCOPE,
consumerKey: 'session-a',
});
assert.equal(spawned.spawned, true);
assert.equal(spawned.pid, 666);
assert.equal(vi.mocked(runCmdDetached).mock.calls.length, 1);
assert.deepEqual(killSpy.mock.calls[0], [555, 'SIGTERM']);
const state = JSON.parse(fs.readFileSync(spawned.statePath, 'utf8')) as {
bridgeScope?: unknown;
};
assert.deepEqual(state.bridgeScope, TEST_BRIDGE_SCOPE);
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
+24
View File
@@ -50,8 +50,14 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as {
tenantId?: string;
runId?: string;
leaseId?: string;
ios_runtime?: { metro_bundle_url?: string };
};
assert.equal(body.tenantId, 'tenant-1');
assert.equal(body.runId, 'run-1');
assert.equal(body.leaseId, 'lease-1');
assert.match(body.ios_runtime?.metro_bundle_url ?? '', /index\.bundle\?platform=ios/);
res.statusCode = 200;
@@ -114,6 +120,11 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim
publicBaseUrl: `http://127.0.0.1:${metroPort}`,
proxyBaseUrl: `http://127.0.0.1:${proxyPort}`,
proxyBearerToken: TEST_TOKEN,
bridgeScope: {
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
},
metroPort,
reuseExisting: false,
installDependenciesIfNeeded: false,
@@ -172,6 +183,19 @@ test('prepareMetroRuntime rejects incomplete proxy configuration', async () => {
error.code === 'INVALID_ARGS' &&
error.message.includes('AGENT_DEVICE_PROXY_TOKEN'),
);
await assert.rejects(
() =>
prepareMetroRuntime({
publicBaseUrl: 'https://sandbox.example.test',
proxyBaseUrl: 'https://proxy.example.test',
proxyBearerToken: TEST_TOKEN,
}),
(error) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message.includes('tenantId, runId, and leaseId bridge scope'),
);
});
function writeFakeNpx(binDir: string): void {
+9
View File
@@ -25,6 +25,12 @@ import {
stopMetroTunnel,
} from '../metro.ts';
const TEST_BRIDGE_SCOPE = {
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
};
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
@@ -66,6 +72,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
publicBaseUrl: 'https://public.example.test',
proxyBaseUrl: 'https://proxy.example.test',
proxyBearerToken: 'token',
bridgeScope: TEST_BRIDGE_SCOPE,
profileKey: '/tmp/profile.remote.json',
consumerKey: 'session-a',
port: 8081,
@@ -75,6 +82,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
serverBaseUrl: 'https://proxy.example.test',
bearerToken: 'token',
localBaseUrl: 'http://127.0.0.1:8081',
bridgeScope: TEST_BRIDGE_SCOPE,
});
await stopMetroTunnel({
projectRoot: '/tmp/project',
@@ -90,6 +98,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
publicBaseUrl: 'https://public.example.test',
proxyBaseUrl: 'https://proxy.example.test',
proxyBearerToken: 'token',
bridgeScope: TEST_BRIDGE_SCOPE,
launchUrl: undefined,
companionProfileKey: '/tmp/profile.remote.json',
companionConsumerKey: 'session-a',
+5
View File
@@ -177,6 +177,11 @@ test('connect allocates a lease, prepares Metro, and writes connection state', a
const state = readRemoteConnectionState({ stateDir, session: 'adc-android' });
assert.equal(observedBackend, 'android-instance');
assert.equal(observedPrepare?.companionProfileKey, remoteConfigPath);
assert.deepEqual(observedPrepare?.bridgeScope, {
tenantId: 'acme',
runId: 'run-123',
leaseId: 'lease-1',
});
assert.equal(state?.leaseId, 'lease-1');
assert.equal(state?.remoteConfigHash, hashRemoteConfigFile(remoteConfigPath));
assert.deepEqual(state?.daemon, {
+11 -1
View File
@@ -86,7 +86,11 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) =>
allocatedForThisCommand = true;
}
const metro = await prepareConnectedMetro(flags, client, remoteConfig.resolvedPath, session);
const metro = await prepareConnectedMetro(flags, client, remoteConfig.resolvedPath, session, {
tenantId: lease.tenantId,
runId: lease.runId,
leaseId: lease.leaseId,
});
metroCleanup = metro.cleanup;
const now = new Date().toISOString();
const state: RemoteConnectionState = {
@@ -243,6 +247,11 @@ async function prepareConnectedMetro(
client: AgentDeviceClient,
remoteConfigPath: string,
session: string,
bridgeScope: {
tenantId: string;
runId: string;
leaseId: string;
},
): Promise<{
runtime?: SessionRuntimeHints;
cleanup?: NonNullable<RemoteConnectionState['metro']>;
@@ -265,6 +274,7 @@ async function prepareConnectedMetro(
publicBaseUrl: flags.metroPublicBaseUrl,
proxyBaseUrl: flags.metroProxyBaseUrl,
bearerToken: flags.metroBearerToken,
bridgeScope,
launchUrl: flags.launchUrl,
companionProfileKey: remoteConfigPath,
companionConsumerKey: session,
+8
View File
@@ -20,6 +20,14 @@ export const metroCommand: ClientCommandHandler = async ({ positionals, flags, c
publicBaseUrl: flags.metroPublicBaseUrl,
proxyBaseUrl: flags.metroProxyBaseUrl,
bearerToken: flags.metroBearerToken,
bridgeScope:
flags.tenant && flags.runId && flags.leaseId
? {
tenantId: flags.tenant,
runId: flags.runId,
leaseId: flags.leaseId,
}
: undefined,
startupTimeoutMs: flags.metroStartupTimeoutMs,
probeTimeoutMs: flags.metroProbeTimeoutMs,
reuseExisting: flags.metroNoReuseExisting ? false : undefined,
+7
View File
@@ -1,3 +1,5 @@
import type { MetroBridgeScope } from './client-metro.ts';
export const METRO_COMPANION_RUN_ARG = '--agent-device-run-metro-companion';
export const METRO_COMPANION_RECONNECT_DELAY_MS = 1_000;
export const METRO_COMPANION_LEASE_CHECK_INTERVAL_MS = 250;
@@ -8,13 +10,18 @@ export const ENV_BEARER_TOKEN = 'AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN';
export const ENV_LOCAL_BASE_URL = 'AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL';
export const ENV_LAUNCH_URL = 'AGENT_DEVICE_METRO_COMPANION_LAUNCH_URL';
export const ENV_STATE_PATH = 'AGENT_DEVICE_METRO_COMPANION_STATE_PATH';
export const ENV_SCOPE_TENANT_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID';
export const ENV_SCOPE_RUN_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID';
export const ENV_SCOPE_LEASE_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID';
export type { MetroTunnelRequestMessage as MetroCompanionRequest } from './metro.ts';
export type { MetroBridgeScope };
export type CompanionOptions = {
serverBaseUrl: string;
bearerToken: string;
localBaseUrl: string;
bridgeScope: MetroBridgeScope;
launchUrl?: string;
statePath?: string;
};
+15
View File
@@ -5,6 +5,9 @@ import {
ENV_LAUNCH_URL,
ENV_LOCAL_BASE_URL,
ENV_SERVER_BASE_URL,
ENV_SCOPE_LEASE_ID,
ENV_SCOPE_RUN_ID,
ENV_SCOPE_TENANT_ID,
ENV_STATE_PATH,
METRO_COMPANION_LEASE_CHECK_INTERVAL_MS,
METRO_COMPANION_RECONNECT_DELAY_MS,
@@ -29,6 +32,7 @@ async function registerCompanion(options: CompanionOptions): Promise<{ wsUrl: st
method: 'POST',
headers: createHeaders(options.serverBaseUrl, options.bearerToken),
body: JSON.stringify({
...options.bridgeScope,
local_base_url: normalizeBaseUrl(options.localBaseUrl),
...(options.launchUrl ? { launch_url: options.launchUrl } : {}),
}),
@@ -315,10 +319,21 @@ function readWorkerOptions(argv: string[], env: NodeJS.ProcessEnv): CompanionOpt
if (!serverBaseUrl || !bearerToken || !localBaseUrl) {
throw new Error('Metro companion worker is missing required environment configuration.');
}
const tenantId = env[ENV_SCOPE_TENANT_ID]?.trim();
const runId = env[ENV_SCOPE_RUN_ID]?.trim();
const leaseId = env[ENV_SCOPE_LEASE_ID]?.trim();
if (!tenantId || !runId || !leaseId) {
throw new Error('Metro companion worker is missing required bridge scope configuration.');
}
return {
serverBaseUrl,
bearerToken,
localBaseUrl,
bridgeScope: {
tenantId,
runId,
leaseId,
},
launchUrl: env[ENV_LAUNCH_URL]?.trim() || undefined,
statePath: env[ENV_STATE_PATH]?.trim() || undefined,
};
+34
View File
@@ -7,9 +7,13 @@ import {
ENV_LAUNCH_URL,
ENV_LOCAL_BASE_URL,
ENV_SERVER_BASE_URL,
ENV_SCOPE_LEASE_ID,
ENV_SCOPE_RUN_ID,
ENV_SCOPE_TENANT_ID,
ENV_STATE_PATH,
METRO_COMPANION_RUN_ARG,
} from './client-metro-companion-contract.ts';
import type { MetroBridgeScope } from './client-metro-companion-contract.ts';
import { normalizeBaseUrl } from './utils/url.ts';
import { runCmdDetached } from './utils/exec.ts';
import {
@@ -32,6 +36,7 @@ type CompanionState = {
serverBaseUrl: string;
localBaseUrl: string;
launchUrl?: string;
bridgeScope?: MetroBridgeScope;
tokenHash: string;
consumers: string[];
};
@@ -41,6 +46,7 @@ export type EnsureMetroCompanionOptions = {
serverBaseUrl: string;
bearerToken: string;
localBaseUrl: string;
bridgeScope: MetroBridgeScope;
launchUrl?: string;
profileKey?: string;
consumerKey?: string;
@@ -68,6 +74,27 @@ function normalizeOptionalString(input: string | undefined): string | undefined
return input?.trim() ? input.trim() : undefined;
}
function readCompanionScope(input: unknown): MetroBridgeScope | undefined {
if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined;
const record = input as Partial<MetroBridgeScope>;
if (
typeof record.tenantId !== 'string' ||
typeof record.runId !== 'string' ||
typeof record.leaseId !== 'string'
) {
return undefined;
}
return {
tenantId: record.tenantId,
runId: record.runId,
leaseId: record.leaseId,
};
}
function areCompanionScopesEqual(a: MetroBridgeScope, b: MetroBridgeScope): boolean {
return a.tenantId === b.tenantId && a.runId === b.runId && a.leaseId === b.leaseId;
}
function resolveCompanionPaths(
projectRoot: string,
profileKey?: string,
@@ -109,6 +136,7 @@ function readCompanionState(statePath: string): CompanionState | null {
launchUrl: normalizeOptionalString(
typeof parsed.launchUrl === 'string' ? parsed.launchUrl : undefined,
),
bridgeScope: readCompanionScope(parsed.bridgeScope),
tokenHash: parsed.tokenHash,
consumers,
};
@@ -178,10 +206,12 @@ function shouldReuseCompanion(
}
const command = readProcessCommand(state.pid);
if (!command || !isMetroCompanionCommand(command)) return false;
if (!state.bridgeScope) return false;
return (
state.serverBaseUrl === normalizeBaseUrl(options.serverBaseUrl) &&
state.localBaseUrl === normalizeBaseUrl(options.localBaseUrl) &&
state.launchUrl === normalizeOptionalString(options.launchUrl) &&
areCompanionScopesEqual(state.bridgeScope, options.bridgeScope) &&
state.tokenHash === hashString(options.bearerToken)
);
}
@@ -250,6 +280,9 @@ function buildCompanionEnv(
[ENV_LOCAL_BASE_URL]: normalizeBaseUrl(options.localBaseUrl),
[ENV_STATE_PATH]: resolveCompanionPaths(options.projectRoot, options.profileKey).statePath,
};
nextEnv[ENV_SCOPE_TENANT_ID] = options.bridgeScope.tenantId;
nextEnv[ENV_SCOPE_RUN_ID] = options.bridgeScope.runId;
nextEnv[ENV_SCOPE_LEASE_ID] = options.bridgeScope.leaseId;
if (options.launchUrl?.trim()) {
nextEnv[ENV_LAUNCH_URL] = options.launchUrl.trim();
} else {
@@ -297,6 +330,7 @@ function spawnCompanionProcess(
serverBaseUrl: normalizeBaseUrl(options.serverBaseUrl),
localBaseUrl: normalizeBaseUrl(options.localBaseUrl),
launchUrl: normalizeOptionalString(options.launchUrl),
bridgeScope: options.bridgeScope,
tokenHash: hashString(options.bearerToken),
consumers: [],
};
+30 -2
View File
@@ -20,6 +20,12 @@ export type MetroPrepareKind = 'auto' | 'react-native' | 'expo';
type ResolvedMetroKind = Exclude<MetroPrepareKind, 'auto'>;
type EnvSource = NodeJS.ProcessEnv | Record<string, string | undefined>;
export type MetroBridgeScope = {
tenantId: string;
runId: string;
leaseId: string;
};
type PackageJsonShape = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
@@ -43,6 +49,7 @@ export type PrepareMetroRuntimeOptions = {
publicBaseUrl?: string;
proxyBaseUrl?: string;
proxyBearerToken?: string;
bridgeScope?: MetroBridgeScope;
launchUrl?: string;
companionProfileKey?: string;
companionConsumerKey?: string;
@@ -74,6 +81,7 @@ export type PrepareMetroRuntimeResult = {
type ProxyBridgeRequestOptions = {
baseUrl: string;
bearerToken: string;
scope: MetroBridgeScope;
runtime: MetroBridgeRuntimePayload;
timeoutMs: number;
};
@@ -359,6 +367,7 @@ async function configureMetroBridge(input: ProxyBridgeRequestOptions): Promise<M
method: 'POST',
headers: createProxyHeaders(input.baseUrl, input.bearerToken),
body: JSON.stringify({
...input.scope,
ios_runtime: input.runtime,
timeout_ms: input.timeoutMs,
}),
@@ -515,6 +524,16 @@ function resolveProxySettings(
};
}
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;
}
async function waitForMetroReady(
statusUrl: string,
startupTimeoutMs: number,
@@ -538,6 +557,7 @@ async function waitForMetroReady(
async function configureMetroBridgeUntilReady(options: {
baseUrl: string;
bearerToken: string;
scope: MetroBridgeScope;
runtime: MetroBridgeRuntimePayload;
probeTimeoutMs: number;
startupTimeoutMs: number;
@@ -553,6 +573,7 @@ async function configureMetroBridgeUntilReady(options: {
const bridge = await configureMetroBridge({
baseUrl: options.baseUrl,
bearerToken: options.bearerToken,
scope: options.scope,
runtime: options.runtime,
timeoutMs: options.probeTimeoutMs,
});
@@ -625,6 +646,7 @@ export async function prepareMetroRuntime(
normalizeOptionalBaseUrl(input.proxyBaseUrl),
normalizeOptionalString(input.proxyBearerToken) ?? '',
);
const bridgeScope = proxyEnabled ? requireBridgeScope(input.bridgeScope) : null;
const dependencyInstall = installProjectDeps
? installDependenciesIfNeeded(projectRoot, env)
@@ -662,28 +684,33 @@ export async function prepareMetroRuntime(
let bridge: MetroBridgeResult | null = null;
let initialBridgeError: string | null = null;
if (proxyEnabled) {
if (bridgeScope) {
try {
bridge = await configureMetroBridge({
baseUrl: proxyBaseUrl,
bearerToken: proxyBearerToken,
scope: bridgeScope,
runtime: {
metro_bundle_url: publicIosRuntime.bundleUrl,
},
timeoutMs: probeTimeoutMs,
});
} catch (error) {
if (!isRetryableBridgeError(error)) {
throw error;
}
initialBridgeError = error instanceof Error ? error.message : String(error);
}
}
if (proxyEnabled && (!bridge || bridge.probe.reachable === false)) {
if (bridgeScope && (!bridge || bridge.probe.reachable === false)) {
let companionLogPath: string | undefined;
try {
const companion = await ensureMetroCompanion({
projectRoot,
serverBaseUrl: proxyBaseUrl,
bearerToken: proxyBearerToken,
bridgeScope,
localBaseUrl: `http://${statusHost}:${metroPort}`,
launchUrl: normalizeOptionalString(input.launchUrl),
profileKey: normalizeOptionalString(input.companionProfileKey),
@@ -705,6 +732,7 @@ export async function prepareMetroRuntime(
bridge = await configureMetroBridgeUntilReady({
baseUrl: proxyBaseUrl,
bearerToken: proxyBearerToken,
scope: bridgeScope,
runtime: {
metro_bundle_url: publicIosRuntime.bundleUrl,
},
+5
View File
@@ -267,6 +267,11 @@ export type MetroPrepareOptions = {
publicBaseUrl: string;
proxyBaseUrl?: string;
bearerToken?: string;
bridgeScope?: {
tenantId: string;
runId: string;
leaseId: string;
};
launchUrl?: string;
companionProfileKey?: string;
companionConsumerKey?: string;
+1
View File
@@ -264,6 +264,7 @@ export function createAgentDeviceClient(
publicBaseUrl: options.publicBaseUrl,
proxyBaseUrl: options.proxyBaseUrl,
proxyBearerToken: options.bearerToken,
bridgeScope: options.bridgeScope,
launchUrl: options.launchUrl,
companionProfileKey: options.companionProfileKey,
companionConsumerKey: options.companionConsumerKey,
+11
View File
@@ -149,6 +149,11 @@ export type PrepareRemoteMetroOptions = {
publicBaseUrl: string;
proxyBaseUrl?: string;
proxyBearerToken?: string;
bridgeScope?: {
tenantId: string;
runId: string;
leaseId: string;
};
launchUrl?: string;
profileKey?: string;
consumerKey?: string;
@@ -178,6 +183,11 @@ export type EnsureMetroTunnelOptions = {
serverBaseUrl: string;
bearerToken: string;
localBaseUrl: string;
bridgeScope: {
tenantId: string;
runId: string;
leaseId: string;
};
launchUrl?: string;
profileKey?: string;
consumerKey?: string;
@@ -205,6 +215,7 @@ export async function prepareRemoteMetro(
publicBaseUrl: options.publicBaseUrl,
proxyBaseUrl: options.proxyBaseUrl,
proxyBearerToken: options.proxyBearerToken,
bridgeScope: options.bridgeScope,
launchUrl: options.launchUrl,
companionProfileKey: options.profileKey,
companionConsumerKey: options.consumerKey,
@@ -369,6 +369,9 @@ test('connect prepares Metro and open reuses bridged runtime for remote daemon',
capturedBridgeRequest?.body?.ios_runtime?.metro_bundle_url,
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
);
assert.equal(capturedBridgeRequest?.body?.tenantId, 'acme');
assert.equal(capturedBridgeRequest?.body?.runId, 'run-123');
assert.equal(capturedBridgeRequest?.body?.leaseId, 'abc123abc123abc1');
assert.deepEqual(result.json?.data?.runtime, {
platform: 'android',
metroHost: '10.0.2.2',