Skip to content

Commit 48264ca

Browse files
authored
refactor: extract companion tunnel core (#442)
1 parent adf1d06 commit 48264ca

16 files changed

Lines changed: 754 additions & 606 deletions

src/__tests__/cli-react-devtools.test.ts

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ vi.mock('../utils/exec.ts', () => ({
66
runCmdStreaming: vi.fn(),
77
}));
88

9-
vi.mock('../client-metro-companion.ts', () => ({
10-
ensureMetroCompanion: vi.fn(),
11-
stopMetroCompanion: vi.fn(),
9+
vi.mock('../client-react-devtools-companion.ts', () => ({
10+
ensureReactDevtoolsCompanion: vi.fn(),
11+
stopReactDevtoolsCompanion: vi.fn(),
1212
}));
1313

1414
import { runCmdStreaming } from '../utils/exec.ts';
15-
import { ensureMetroCompanion, stopMetroCompanion } from '../client-metro-companion.ts';
15+
import {
16+
ensureReactDevtoolsCompanion,
17+
stopReactDevtoolsCompanion,
18+
} from '../client-react-devtools-companion.ts';
1619
import {
1720
AGENT_REACT_DEVTOOLS_PACKAGE,
1821
buildReactDevtoolsNpmExecArgs,
@@ -54,13 +57,13 @@ test('react-devtools starts remote Android companion around passthrough command'
5457
stdout: '',
5558
stderr: '',
5659
});
57-
vi.mocked(ensureMetroCompanion).mockResolvedValueOnce({
60+
vi.mocked(ensureReactDevtoolsCompanion).mockResolvedValueOnce({
5861
pid: 123,
5962
spawned: true,
6063
statePath: '/tmp/state.json',
6164
logPath: '/tmp/companion.log',
6265
});
63-
vi.mocked(stopMetroCompanion).mockResolvedValueOnce({
66+
vi.mocked(stopReactDevtoolsCompanion).mockResolvedValueOnce({
6467
stopped: true,
6568
statePath: '/tmp/state.json',
6669
});
@@ -84,22 +87,17 @@ test('react-devtools starts remote Android companion around passthrough command'
8487
});
8588

8689
assert.equal(exitCode, 0);
87-
assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 1);
88-
assert.deepEqual(vi.mocked(ensureMetroCompanion).mock.calls[0]?.[0], {
90+
assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 1);
91+
assert.deepEqual(vi.mocked(ensureReactDevtoolsCompanion).mock.calls[0]?.[0], {
8992
projectRoot: '/tmp/project',
9093
stateDir: '/tmp/agent-device-state',
91-
kind: 'react-devtools',
9294
serverBaseUrl: 'https://bridge.example.test',
9395
bearerToken: 'token',
94-
localBaseUrl: 'http://127.0.0.1:8097',
9596
bridgeScope: {
9697
tenantId: 'tenant-1',
9798
runId: 'run-1',
9899
leaseId: 'lease-1',
99100
},
100-
registerPath: '/api/react-devtools/companion/register',
101-
unregisterPath: '/api/react-devtools/companion/unregister',
102-
devicePort: 8097,
103101
session: 'default',
104102
profileKey: '/tmp/remote.json',
105103
consumerKey: 'default',
@@ -108,11 +106,10 @@ test('react-devtools starts remote Android companion around passthrough command'
108106
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[0], 'npm');
109107
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.cwd, '/tmp/project');
110108
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.env, env);
111-
assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 1);
112-
assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], {
109+
assert.equal(vi.mocked(stopReactDevtoolsCompanion).mock.calls.length, 1);
110+
assert.deepEqual(vi.mocked(stopReactDevtoolsCompanion).mock.calls[0]?.[0], {
113111
projectRoot: '/tmp/project',
114112
stateDir: '/tmp/agent-device-state',
115-
kind: 'react-devtools',
116113
profileKey: '/tmp/remote.json',
117114
consumerKey: 'default',
118115
});
@@ -138,8 +135,8 @@ test('react-devtools skips companion for non-Android remote sessions', async ()
138135
},
139136
});
140137

141-
assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0);
142-
assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 0);
138+
assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 0);
139+
assert.equal(vi.mocked(stopReactDevtoolsCompanion).mock.calls.length, 0);
143140
});
144141

145142
test('react-devtools fails clearly when remote Android bridge scope is incomplete', async () => {
@@ -161,5 +158,5 @@ test('react-devtools fails clearly when remote Android bridge scope is incomplet
161158
);
162159

163160
assert.equal(vi.mocked(runCmdStreaming).mock.calls.length, 0);
164-
assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0);
161+
assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 0);
165162
});

src/__tests__/client-metro-companion-worker.test.ts renamed to src/__tests__/client-companion-tunnel-worker.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import path from 'node:path';
88
import type { Duplex } from 'node:stream';
99
import { setTimeout as delay } from 'node:timers/promises';
1010
import { afterEach, test } from 'vitest';
11-
import { buildCompanionPayload } from '../client-metro-companion-worker.ts';
11+
import { buildCompanionPayload } from '../client-companion-tunnel-worker.ts';
1212

1313
type Deferred<T> = {
1414
promise: Promise<T>;
@@ -358,6 +358,7 @@ test('metro companion worker proxies websocket frames to the local upstream serv
358358
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
359359
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
360360
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
361+
AGENT_DEVICE_METRO_COMPANION_REGISTER_PATH: '/api/metro/companion/register',
361362
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
362363
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
363364
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
@@ -509,6 +510,7 @@ test('metro companion worker reconnects after the bridge closes immediately afte
509510
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
510511
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
511512
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`,
513+
AGENT_DEVICE_METRO_COMPANION_REGISTER_PATH: '/api/metro/companion/register',
512514
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
513515
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
514516
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
@@ -616,6 +618,7 @@ test('metro companion worker exits after its state file is removed', async () =>
616618
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
617619
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
618620
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`,
621+
AGENT_DEVICE_METRO_COMPANION_REGISTER_PATH: '/api/metro/companion/register',
619622
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
620623
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
621624
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
@@ -663,6 +666,7 @@ test('metro companion worker exits immediately when its state file is already mi
663666
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: 'http://127.0.0.1:1',
664667
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
665668
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: 'http://127.0.0.1:1',
669+
AGENT_DEVICE_METRO_COMPANION_REGISTER_PATH: '/api/metro/companion/register',
666670
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
667671
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
668672
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',

src/__tests__/client-metro-companion.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
waitForProcessExit,
2424
} from '../utils/process-identity.ts';
2525
import { ensureMetroCompanion, stopMetroCompanion } from '../client-metro-companion.ts';
26+
import { ensureReactDevtoolsCompanion } from '../client-react-devtools-companion.ts';
2627

2728
const TEST_BRIDGE_SCOPE = {
2829
tenantId: 'tenant-1',
@@ -46,6 +47,12 @@ function assertCompanionSpawnTarget(): void {
4647
assert.equal(firstCall[1].at(-1), '--agent-device-run-metro-companion');
4748
}
4849

50+
function assertCompanionRunArg(callIndex: number, runArg: string): void {
51+
const call = vi.mocked(runCmdDetached).mock.calls[callIndex];
52+
assert.ok(call, `expected runCmdDetached call ${callIndex}`);
53+
assert.equal(call[1].at(-1), runArg);
54+
}
55+
4956
test('companion ownership is profile-scoped and consumer-counted', async () => {
5057
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-state-'));
5158
try {
@@ -196,6 +203,54 @@ test('launchUrl changes force a companion respawn for the same profile', async (
196203
}
197204
});
198205

206+
test('metro and React DevTools companions use distinct profile state paths', async () => {
207+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-companion-paths-'));
208+
try {
209+
vi.mocked(runCmdDetached).mockReturnValueOnce(777).mockReturnValueOnce(888);
210+
vi.mocked(readProcessStartTime).mockImplementation((pid) =>
211+
pid === 777 ? 'start-777' : 'start-888',
212+
);
213+
vi.mocked(readProcessCommand).mockImplementation((pid) =>
214+
pid === 888
215+
? `${process.execPath} src/metro-companion.ts --agent-device-run-react-devtools-companion`
216+
: `${process.execPath} src/metro-companion.ts --agent-device-run-metro-companion`,
217+
);
218+
219+
const profileKey = '/tmp/shared-remote.json';
220+
const metro = await ensureMetroCompanion({
221+
projectRoot,
222+
serverBaseUrl: 'https://bridge.example.test',
223+
bearerToken: 'token',
224+
localBaseUrl: 'http://127.0.0.1:8081',
225+
bridgeScope: TEST_BRIDGE_SCOPE,
226+
launchUrl: 'myapp://open',
227+
profileKey,
228+
consumerKey: 'session-a',
229+
});
230+
const reactDevtools = await ensureReactDevtoolsCompanion({
231+
projectRoot,
232+
serverBaseUrl: 'https://bridge.example.test',
233+
bearerToken: 'token',
234+
bridgeScope: TEST_BRIDGE_SCOPE,
235+
session: 'session-a',
236+
profileKey,
237+
consumerKey: 'session-a',
238+
});
239+
240+
assert.notEqual(metro.statePath, reactDevtools.statePath);
241+
assert.notEqual(metro.logPath, reactDevtools.logPath);
242+
assert.match(metro.statePath, /metro-companion[/\\]metro-companion-[a-f0-9]+\.json$/);
243+
assert.match(
244+
reactDevtools.statePath,
245+
/react-devtools-companion[/\\]react-devtools-companion-[a-f0-9]+\.json$/,
246+
);
247+
assertCompanionRunArg(0, '--agent-device-run-metro-companion');
248+
assertCompanionRunArg(1, '--agent-device-run-react-devtools-companion');
249+
} finally {
250+
fs.rmSync(projectRoot, { recursive: true, force: true });
251+
}
252+
});
253+
199254
test('legacy state without bridge scope is stopped before respawn', async () => {
200255
const projectRoot = fs.mkdtempSync(
201256
path.join(os.tmpdir(), 'agent-device-metro-companion-legacy-'),

src/__tests__/remote-connection.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ vi.mock('../client-metro-companion.ts', () => ({
88
stopMetroCompanion: vi.fn(),
99
}));
1010

11+
vi.mock('../client-react-devtools-companion.ts', () => ({
12+
stopReactDevtoolsCompanion: vi.fn(),
13+
}));
14+
1115
import {
1216
connectCommand,
1317
connectionCommand,

src/cli/commands/connection-runtime.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { resolveDaemonPaths } from '../../daemon/config.ts';
2-
import { stopMetroCompanion } from '../../client-metro-companion.ts';
2+
import { stopReactDevtoolsCompanion } from '../../client-react-devtools-companion.ts';
33
import { stopMetroTunnel } from '../../metro.ts';
44
import { resolveRemoteConfigProfile } from '../../remote-config.ts';
5-
import type { MetroBridgeScope } from '../../client-metro-companion-contract.ts';
5+
import type { CompanionTunnelScope as MetroBridgeScope } from '../../client-companion-tunnel-contract.ts';
66
import {
77
buildRemoteConnectionDaemonState,
88
hashRemoteConfigFile,
@@ -242,10 +242,9 @@ export async function stopReactDevtoolsCleanup(options: {
242242
state: Pick<RemoteConnectionState, 'remoteConfigPath' | 'session'>;
243243
}): Promise<void> {
244244
try {
245-
await stopMetroCompanion({
245+
await stopReactDevtoolsCompanion({
246246
projectRoot: process.cwd(),
247247
stateDir: options.stateDir,
248-
kind: 'react-devtools',
249248
profileKey: options.state.remoteConfigPath,
250249
consumerKey: options.state.session,
251250
});

src/cli/commands/react-devtools.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import { runCmdStreaming } from '../../utils/exec.ts';
2-
import { ensureMetroCompanion, stopMetroCompanion } from '../../client-metro-companion.ts';
2+
import {
3+
ensureReactDevtoolsCompanion,
4+
stopReactDevtoolsCompanion,
5+
} from '../../client-react-devtools-companion.ts';
36
import { AppError } from '../../utils/errors.ts';
47
import type { CliFlags } from '../../utils/command-schema.ts';
58

69
export const AGENT_REACT_DEVTOOLS_VERSION = '0.4.0';
710
export const AGENT_REACT_DEVTOOLS_PACKAGE = `agent-react-devtools@${AGENT_REACT_DEVTOOLS_VERSION}`;
811
const AGENT_REACT_DEVTOOLS_BIN = 'agent-react-devtools';
9-
const REACT_DEVTOOLS_LOCAL_BASE_URL = 'http://127.0.0.1:8097';
10-
const REACT_DEVTOOLS_DEVICE_PORT = 8097;
11-
const REACT_DEVTOOLS_REGISTER_PATH = '/api/react-devtools/companion/register';
12-
const REACT_DEVTOOLS_UNREGISTER_PATH = '/api/react-devtools/companion/unregister';
1312

1413
type ReactDevtoolsCommandOptions = {
1514
flags?: Pick<
@@ -101,21 +100,16 @@ async function withRemoteAndroidDevtoolsCompanion<T>(
101100
const session = options.session ?? flags?.session ?? 'default';
102101
const profileKey =
103102
flags?.remoteConfig ?? `${bridgeConfig.tenantId}:${bridgeConfig.runId}:${bridgeConfig.leaseId}`;
104-
await ensureMetroCompanion({
103+
await ensureReactDevtoolsCompanion({
105104
projectRoot: options.cwd ?? process.cwd(),
106105
stateDir,
107-
kind: 'react-devtools',
108106
serverBaseUrl: bridgeConfig.serverBaseUrl,
109107
bearerToken: bridgeConfig.bearerToken,
110-
localBaseUrl: REACT_DEVTOOLS_LOCAL_BASE_URL,
111108
bridgeScope: {
112109
tenantId: bridgeConfig.tenantId,
113110
runId: bridgeConfig.runId,
114111
leaseId: bridgeConfig.leaseId,
115112
},
116-
registerPath: REACT_DEVTOOLS_REGISTER_PATH,
117-
unregisterPath: REACT_DEVTOOLS_UNREGISTER_PATH,
118-
devicePort: REACT_DEVTOOLS_DEVICE_PORT,
119113
session,
120114
profileKey,
121115
consumerKey: session,
@@ -124,10 +118,9 @@ async function withRemoteAndroidDevtoolsCompanion<T>(
124118
try {
125119
return await action();
126120
} finally {
127-
await stopMetroCompanion({
121+
await stopReactDevtoolsCompanion({
128122
projectRoot: options.cwd ?? process.cwd(),
129123
stateDir,
130-
kind: 'react-devtools',
131124
profileKey,
132125
consumerKey: session,
133126
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export const METRO_COMPANION_RUN_ARG = '--agent-device-run-metro-companion';
2+
export const REACT_DEVTOOLS_COMPANION_RUN_ARG = '--agent-device-run-react-devtools-companion';
3+
export const COMPANION_TUNNEL_RECONNECT_DELAY_MS = 1_000;
4+
export const COMPANION_TUNNEL_LEASE_CHECK_INTERVAL_MS = 250;
5+
export const WS_READY_STATE_OPEN = 1;
6+
7+
export const ENV_COMPANION_TUNNEL_SERVER_BASE_URL = 'AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL';
8+
export const ENV_COMPANION_TUNNEL_BEARER_TOKEN = 'AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN';
9+
export const ENV_COMPANION_TUNNEL_LOCAL_BASE_URL = 'AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL';
10+
export const ENV_COMPANION_TUNNEL_LAUNCH_URL = 'AGENT_DEVICE_METRO_COMPANION_LAUNCH_URL';
11+
export const ENV_COMPANION_TUNNEL_STATE_PATH = 'AGENT_DEVICE_METRO_COMPANION_STATE_PATH';
12+
export const ENV_COMPANION_TUNNEL_SCOPE_TENANT_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID';
13+
export const ENV_COMPANION_TUNNEL_SCOPE_RUN_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID';
14+
export const ENV_COMPANION_TUNNEL_SCOPE_LEASE_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID';
15+
export const ENV_COMPANION_TUNNEL_REGISTER_PATH = 'AGENT_DEVICE_METRO_COMPANION_REGISTER_PATH';
16+
export const ENV_COMPANION_TUNNEL_UNREGISTER_PATH = 'AGENT_DEVICE_METRO_COMPANION_UNREGISTER_PATH';
17+
export const ENV_COMPANION_TUNNEL_DEVICE_PORT = 'AGENT_DEVICE_METRO_COMPANION_DEVICE_PORT';
18+
export const ENV_COMPANION_TUNNEL_SESSION = 'AGENT_DEVICE_METRO_COMPANION_SESSION';
19+
20+
export type CompanionTunnelScope = {
21+
tenantId: string;
22+
runId: string;
23+
leaseId: string;
24+
};
25+
26+
export type CompanionTunnelWorkerOptions = {
27+
serverBaseUrl: string;
28+
bearerToken: string;
29+
localBaseUrl: string;
30+
bridgeScope: CompanionTunnelScope;
31+
registerPath: string;
32+
launchUrl?: string;
33+
statePath?: string;
34+
unregisterPath?: string;
35+
devicePort?: number;
36+
session?: string;
37+
};

0 commit comments

Comments
 (0)