feat: expose bridge-facing contract and metro APIs (#373)

* feat: expose bridge-facing contract and metro APIs

* chore: address review feedback on public contract exports

* refactor: reuse public daemon contracts internally

* refactor: reuse public metro types internally

* fix: widen bridge-facing public contracts
This commit is contained in:
Michał Pierzchała
2026-04-10 14:06:27 +02:00
committed by GitHub
parent c02cbd015f
commit be09c1d58a
9 changed files with 665 additions and 176 deletions
@@ -0,0 +1,88 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import {
daemonCommandRequestSchema,
daemonRuntimeSchema,
jsonRpcRequestSchema,
leaseAllocateSchema,
leaseHeartbeatSchema,
leaseReleaseSchema,
} from '../contracts.ts';
test('public contract schemas validate daemon requests and lease payloads', () => {
const runtime = daemonRuntimeSchema.parse({
platform: 'ios',
metroHost: '127.0.0.1',
metroPort: 8081,
bundleUrl: 'https://example.test/index.bundle?platform=ios',
});
const request = daemonCommandRequestSchema.parse({
command: 'open',
positionals: ['Demo'],
runtime,
meta: {
tenantId: 'acme',
runId: 'run-1',
leaseBackend: 'ios-instance',
lockPolicy: 'reject',
lockPlatform: 'ios',
},
});
const allocate = leaseAllocateSchema.parse({
tenantId: 'acme',
runId: 'run-1',
ttlMs: 60_000,
backend: 'android-instance',
});
const heartbeat = leaseHeartbeatSchema.parse({
tenantId: 'acme',
runId: 'run-1',
leaseId: 'lease-1',
ttlMs: 60_000,
});
const release = leaseReleaseSchema.parse({
tenant: 'acme',
runId: 'run-1',
leaseId: 'lease-1',
});
assert.equal(request.runtime?.platform, 'ios');
assert.equal(request.meta?.leaseBackend, 'ios-instance');
assert.equal(request.session, undefined);
assert.equal(allocate.backend, 'android-instance');
assert.equal(heartbeat.runId, 'run-1');
assert.equal(release.tenant, 'acme');
assert.equal(heartbeat.leaseId, 'lease-1');
assert.equal(release.leaseId, 'lease-1');
});
test('public contract schemas reject invalid payloads', () => {
assert.throws(
() =>
daemonCommandRequestSchema.parse({
token: 'secret',
session: 'default',
command: 'open',
positionals: [123],
}),
/positionals\[0\]/,
);
assert.throws(
() =>
jsonRpcRequestSchema.parse({
jsonrpc: '2.0',
id: {},
method: 'agent_device.command',
}),
/\.id/,
);
assert.throws(
() =>
leaseReleaseSchema.parse({
token: 'secret',
leaseId: 'lease-1',
ttlMs: 60_000,
}),
/\.ttlMs/,
);
});
@@ -0,0 +1,64 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import {
buildBundleUrl,
normalizeBaseUrl,
type MetroBridgeDescriptor,
type MetroTunnelRequestMessage,
type MetroTunnelResponseMessage,
} from '../metro.ts';
test('public metro exports expose stable bridge payload types and url helpers', () => {
const descriptor: MetroBridgeDescriptor = {
enabled: true,
base_url: 'https://bridge.example.test',
ios_runtime: {
metro_host: '127.0.0.1',
metro_port: 8081,
metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=ios',
},
android_runtime: {
metro_host: '10.0.2.2',
metro_port: 8081,
metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=android',
},
upstream: {
bundle_url: 'http://127.0.0.1:8081/index.bundle?platform=ios',
},
probe: {
reachable: true,
status_code: 200,
latency_ms: 4,
detail: 'ok',
},
};
assert.equal(descriptor.upstream.port, undefined);
assert.equal(descriptor.status_url, undefined);
assert.equal(normalizeBaseUrl('https://bridge.example.test///'), 'https://bridge.example.test');
assert.equal(
buildBundleUrl('https://bridge.example.test/', 'ios'),
'https://bridge.example.test/index.bundle?platform=ios&dev=true&minify=false',
);
});
test('public metro exports compile for representative tunnel request and response payloads', () => {
const request: MetroTunnelRequestMessage = {
type: 'ws-frame',
streamId: 'stream-1',
dataBase64: 'aGVsbG8=',
binary: false,
};
const response: MetroTunnelResponseMessage = {
type: 'http-response',
requestId: 'req-1',
status: 200,
headers: { 'content-type': 'application/json' },
};
const messages = [request, response];
assert.equal(request.type, 'ws-frame');
assert.equal(response.type, 'http-response');
assert.equal(messages.length, 2);
});
+1 -28
View File
@@ -7,34 +7,7 @@ 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 type MetroCompanionRequest =
| { type: 'ping'; timestamp: number }
| {
type: 'http-request';
requestId: string;
method: string;
path: string;
headers?: Record<string, string>;
bodyBase64?: string;
}
| {
type: 'ws-open';
streamId: string;
path: string;
headers?: Record<string, string>;
}
| {
type: 'ws-frame';
streamId: string;
dataBase64: string;
binary: boolean;
}
| {
type: 'ws-close';
streamId: string;
code?: number;
reason?: string;
};
export type { MetroTunnelRequestMessage as MetroCompanionRequest } from './metro.ts';
export type CompanionOptions = {
serverBaseUrl: string;
+18 -78
View File
@@ -1,10 +1,16 @@
import fs from 'node:fs';
import path from 'node:path';
import { ensureMetroCompanion } from './client-metro-companion.ts';
import { normalizeBaseUrl } from './utils/url.ts';
import type {
MetroBridgeDescriptor,
MetroBridgeResult,
MetroBridgeRuntimePayload,
MetroRuntimeHints,
} from './metro.ts';
import { AppError } from './utils/errors.ts';
import { runCmdSync, runCmdDetached } from './utils/exec.ts';
import { resolveUserPath } from './utils/path-resolution.ts';
import { buildBundleUrl, normalizeBaseUrl } from './utils/url.ts';
export type MetroPrepareKind = 'auto' | 'react-native' | 'expo';
type ResolvedMetroKind = Exclude<MetroPrepareKind, 'auto'>;
@@ -24,64 +30,6 @@ type MetroProcessResult = {
pid: number;
};
type MetroRuntimeHintsPayload = {
metro_host?: string;
metro_port?: number;
metro_bundle_url?: string;
launch_url?: string;
};
type MetroBridgeResponsePayload = {
enabled: boolean;
base_url: string;
status_url: string;
bundle_url: string;
ios_runtime: MetroRuntimeHintsPayload;
android_runtime: MetroRuntimeHintsPayload;
upstream: {
bundle_url: string;
host: string;
port: number;
status_url: string;
};
probe: {
reachable: boolean;
status_code: number;
latency_ms: number;
detail: string;
};
};
// Keep this internal shape aligned with the public copies in src/metro.ts and src/contracts.ts.
export type MetroRuntimeHints = {
platform?: 'ios' | 'android';
metroHost?: string;
metroPort?: number;
bundleUrl?: string;
launchUrl?: string;
};
export type MetroBridgeResult = {
enabled: boolean;
baseUrl: string;
statusUrl: string;
bundleUrl: string;
iosRuntime: MetroRuntimeHints;
androidRuntime: MetroRuntimeHints;
upstream: {
bundleUrl: string;
host: string;
port: number;
statusUrl: string;
};
probe: {
reachable: boolean;
statusCode: number;
latencyMs: number;
detail: string;
};
};
export type PrepareMetroRuntimeOptions = {
projectRoot?: string;
kind?: MetroPrepareKind;
@@ -122,7 +70,7 @@ export type PrepareMetroRuntimeResult = {
type ProxyBridgeRequestOptions = {
baseUrl: string;
bearerToken: string;
runtime: MetroRuntimeHintsPayload;
runtime: MetroBridgeRuntimePayload;
timeoutMs: number;
};
@@ -142,14 +90,6 @@ function resolvePath(inputPath: string, env: EnvSource, cwd: string): string {
return resolveUserPath(inputPath, { env, cwd });
}
function buildBundleUrl(baseUrl: string, platform: 'ios' | 'android'): string {
const url = new URL(`${normalizeBaseUrl(baseUrl)}/index.bundle`);
url.searchParams.set('platform', platform);
url.searchParams.set('dev', 'true');
url.searchParams.set('minify', 'false');
return url.toString();
}
function fileExists(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.F_OK);
@@ -237,7 +177,7 @@ export function buildMetroRuntimeHints(
}
function normalizeProxyRuntimeHints(
value: MetroRuntimeHintsPayload | undefined,
value: MetroBridgeRuntimePayload | undefined,
platform: 'ios' | 'android',
): MetroRuntimeHints {
return {
@@ -424,23 +364,23 @@ async function configureMetroBridge(input: ProxyBridgeRequestOptions): Promise<M
}
return normalizeBridgeResponse(
(responsePayload.data ?? responsePayload) as MetroBridgeResponsePayload,
(responsePayload.data ?? responsePayload) as MetroBridgeDescriptor,
);
}
function normalizeBridgeResponse(response: MetroBridgeResponsePayload): MetroBridgeResult {
function normalizeBridgeResponse(response: MetroBridgeDescriptor): MetroBridgeResult {
return {
enabled: response.enabled,
baseUrl: response.base_url,
statusUrl: response.status_url,
bundleUrl: response.bundle_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,
statusUrl: response.upstream.status_url,
bundleUrl: response.upstream.bundle_url ?? '',
host: response.upstream.host ?? '',
port: response.upstream.port ?? 0,
statusUrl: response.upstream.status_url ?? '',
},
probe: {
reachable: response.probe.reachable,
@@ -530,7 +470,7 @@ async function waitForMetroReady(
async function configureMetroBridgeUntilReady(options: {
baseUrl: string;
bearerToken: string;
runtime: MetroRuntimeHintsPayload;
runtime: MetroBridgeRuntimePayload;
probeTimeoutMs: number;
startupTimeoutMs: number;
initialBridgeError?: string | null;
+341 -3
View File
@@ -20,6 +20,7 @@ export type DaemonInstallSource =
};
export type DaemonLockPolicy = 'reject' | 'strip';
export type LeaseBackend = 'ios-simulator' | 'ios-instance' | 'android-instance';
export type DaemonRequestMeta = {
requestId?: string;
@@ -29,7 +30,7 @@ export type DaemonRequestMeta = {
runId?: string;
leaseId?: string;
leaseTtlMs?: number;
leaseBackend?: 'ios-simulator';
leaseBackend?: LeaseBackend;
sessionIsolation?: 'none' | 'tenant';
uploadedArtifactId?: string;
clientArtifactPaths?: Record<string, string>;
@@ -42,8 +43,8 @@ export type DaemonRequestMeta = {
};
export type DaemonRequest = {
token: string;
session: string;
token?: string;
session?: string;
command: string;
positionals: string[];
flags?: Record<string, unknown>;
@@ -81,3 +82,340 @@ export type DaemonResponse =
ok: false;
error: DaemonError;
};
export type LeaseAllocatePayload = {
token?: string;
session?: string;
tenantId?: string;
tenant?: string;
runId?: string;
ttlMs?: number;
backend?: LeaseBackend;
};
export type LeaseHeartbeatPayload = {
token?: string;
session?: string;
tenantId?: string;
tenant?: string;
runId?: string;
leaseId?: string;
ttlMs?: number;
};
export type LeaseReleasePayload = {
token?: string;
session?: string;
tenantId?: string;
tenant?: string;
runId?: string;
leaseId?: string;
};
export type JsonRpcId = string | number | null;
export type JsonRpcRequestEnvelope<TParams = unknown> = {
jsonrpc?: string;
id?: JsonRpcId;
method?: string;
params?: TParams;
};
type RuntimeSchema<T> = {
parse(input: unknown): T;
};
// Keep the public contracts entrypoint dependency-free. These schemas exist so bridge/cloud
// consumers can validate shared payloads without pulling in an additional runtime library.
function schema<T>(parse: (input: unknown, path: string) => T): RuntimeSchema<T> {
return {
parse(input: unknown): T {
return parse(input, '$');
},
};
}
function fail(path: string, message: string): never {
throw new Error(`${path}: ${message}`);
}
function expectObject(input: unknown, path: string): Record<string, unknown> {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
fail(path, 'Expected an object');
}
return input as Record<string, unknown>;
}
function expectString(input: unknown, path: string): string {
if (typeof input !== 'string') {
fail(path, 'Expected a string');
}
return input;
}
function expectInteger(input: unknown, path: string): number {
if (!Number.isInteger(input)) {
fail(path, 'Expected an integer');
}
return input as number;
}
function expectArray(input: unknown, path: string): unknown[] {
if (!Array.isArray(input)) {
fail(path, 'Expected an array');
}
return input;
}
function optionalString(
record: Record<string, unknown>,
key: string,
path: string,
): string | undefined {
const value = record[key];
return value === undefined ? undefined : expectString(value, `${path}.${key}`);
}
function optionalBoolean(
record: Record<string, unknown>,
key: string,
path: string,
): boolean | undefined {
const value = record[key];
if (value === undefined) return undefined;
if (typeof value !== 'boolean') {
fail(`${path}.${key}`, 'Expected a boolean');
}
return value;
}
function optionalInteger(
record: Record<string, unknown>,
key: string,
path: string,
): number | undefined {
const value = record[key];
return value === undefined ? undefined : expectInteger(value, `${path}.${key}`);
}
function optionalObject(
record: Record<string, unknown>,
key: string,
path: string,
): Record<string, unknown> | undefined {
const value = record[key];
return value === undefined ? undefined : expectObject(value, `${path}.${key}`);
}
function optionalEnum<T extends string>(
record: Record<string, unknown>,
key: string,
allowed: readonly T[],
path: string,
): T | undefined {
const value = record[key];
if (value === undefined) return undefined;
if (typeof value !== 'string' || !allowed.includes(value as T)) {
fail(`${path}.${key}`, `Expected one of: ${allowed.join(', ')}`);
}
return value as T;
}
function expectStringRecord(input: unknown, path: string): Record<string, string> {
const record = expectObject(input, path);
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(record)) {
result[key] = expectString(value, `${path}.${key}`);
}
return result;
}
function parseDaemonInstallSource(input: unknown, path: string): DaemonInstallSource {
const record = expectObject(input, path);
const kind = expectString(record.kind, `${path}.kind`);
if (kind === 'url') {
const url = expectString(record.url, `${path}.url`);
const headers =
record.headers === undefined
? undefined
: expectStringRecord(record.headers, `${path}.headers`);
return headers ? { kind, url, headers } : { kind, url };
}
if (kind === 'path') {
return {
kind,
path: expectString(record.path, `${path}.path`),
};
}
fail(`${path}.kind`, 'Expected "url" or "path"');
}
export const daemonRuntimeSchema = schema<SessionRuntimeHints>((input, path) => {
const record = expectObject(input, path);
return {
platform: optionalEnum(record, 'platform', ['ios', 'android'] as const, path),
metroHost: optionalString(record, 'metroHost', path),
metroPort: optionalInteger(record, 'metroPort', path),
bundleUrl: optionalString(record, 'bundleUrl', path),
launchUrl: optionalString(record, 'launchUrl', path),
};
});
export const daemonCommandRequestSchema = schema<DaemonRequest>((input, path) => {
const record = expectObject(input, path);
const rawPositionals = expectArray(record.positionals, `${path}.positionals`);
const meta = optionalObject(record, 'meta', path);
return {
token: optionalString(record, 'token', path),
session: optionalString(record, 'session', path),
command: expectString(record.command, `${path}.command`),
positionals: rawPositionals.map((value, index) =>
expectString(value, `${path}.positionals[${String(index)}]`),
),
flags: optionalObject(record, 'flags', path),
runtime: record.runtime === undefined ? undefined : daemonRuntimeSchema.parse(record.runtime),
meta:
meta === undefined
? undefined
: {
requestId: optionalString(meta, 'requestId', `${path}.meta`),
debug: optionalBoolean(meta, 'debug', `${path}.meta`),
cwd: optionalString(meta, 'cwd', `${path}.meta`),
tenantId: optionalString(meta, 'tenantId', `${path}.meta`),
runId: optionalString(meta, 'runId', `${path}.meta`),
leaseId: optionalString(meta, 'leaseId', `${path}.meta`),
leaseTtlMs: optionalInteger(meta, 'leaseTtlMs', `${path}.meta`),
leaseBackend: optionalEnum(
meta,
'leaseBackend',
['ios-simulator', 'ios-instance', 'android-instance'] as const,
`${path}.meta`,
),
sessionIsolation: optionalEnum(
meta,
'sessionIsolation',
['none', 'tenant'] as const,
`${path}.meta`,
),
uploadedArtifactId: optionalString(meta, 'uploadedArtifactId', `${path}.meta`),
clientArtifactPaths:
meta.clientArtifactPaths === undefined
? undefined
: expectStringRecord(meta.clientArtifactPaths, `${path}.meta.clientArtifactPaths`),
installSource:
meta.installSource === undefined
? undefined
: parseDaemonInstallSource(meta.installSource, `${path}.meta.installSource`),
retainMaterializedPaths: optionalBoolean(
meta,
'retainMaterializedPaths',
`${path}.meta`,
),
materializedPathRetentionMs: optionalInteger(
meta,
'materializedPathRetentionMs',
`${path}.meta`,
),
materializationId: optionalString(meta, 'materializationId', `${path}.meta`),
lockPolicy: optionalEnum(
meta,
'lockPolicy',
['reject', 'strip'] as const,
`${path}.meta`,
),
lockPlatform: optionalEnum(
meta,
'lockPlatform',
['ios', 'macos', 'android', 'linux', 'apple'] as const,
`${path}.meta`,
),
},
};
});
function parseLeaseCommon(
input: unknown,
path: string,
): {
token?: string;
session?: string;
leaseId?: string;
ttlMs?: number;
} {
const record = expectObject(input, path);
return {
token: optionalString(record, 'token', path),
session: optionalString(record, 'session', path),
leaseId: optionalString(record, 'leaseId', path),
ttlMs: optionalInteger(record, 'ttlMs', path),
};
}
export const leaseAllocateSchema = schema<LeaseAllocatePayload>((input, path) => {
const record = expectObject(input, path);
return {
token: optionalString(record, 'token', path),
session: optionalString(record, 'session', path),
tenantId: optionalString(record, 'tenantId', path),
tenant: optionalString(record, 'tenant', path),
runId: optionalString(record, 'runId', path),
ttlMs: optionalInteger(record, 'ttlMs', path),
backend: optionalEnum(
record,
'backend',
['ios-simulator', 'ios-instance', 'android-instance'] as const,
path,
),
};
});
export const leaseHeartbeatSchema = schema<LeaseHeartbeatPayload>((input, path) => {
const parsed = parseLeaseCommon(input, path);
const record = expectObject(input, path);
return {
token: parsed.token,
session: parsed.session,
tenantId: optionalString(record, 'tenantId', path),
tenant: optionalString(record, 'tenant', path),
runId: optionalString(record, 'runId', path),
leaseId: parsed.leaseId,
ttlMs: parsed.ttlMs,
};
});
export const leaseReleaseSchema = schema<LeaseReleasePayload>((input, path) => {
const record = expectObject(input, path);
if (record.ttlMs !== undefined) {
fail(`${path}.ttlMs`, 'Unexpected field');
}
return {
token: optionalString(record, 'token', path),
session: optionalString(record, 'session', path),
tenantId: optionalString(record, 'tenantId', path),
tenant: optionalString(record, 'tenant', path),
runId: optionalString(record, 'runId', path),
leaseId: optionalString(record, 'leaseId', path),
};
});
function parseJsonRpcId(
record: Record<string, unknown>,
path: string,
): string | number | null | undefined {
const value = record.id;
if (value === undefined || value === null) return value as null | undefined;
if (typeof value !== 'string' && typeof value !== 'number') {
fail(`${path}.id`, 'Expected a string, number, or null');
}
return value;
}
export const jsonRpcRequestSchema = schema<JsonRpcRequestEnvelope>((input, path) => {
const record = expectObject(input, path);
return {
jsonrpc: optionalString(record, 'jsonrpc', path),
id: parseJsonRpcId(record, path),
method: optionalString(record, 'method', path),
params: record.params,
};
});
+21 -57
View File
@@ -1,3 +1,12 @@
import type {
DaemonArtifact as PublicDaemonArtifact,
DaemonRequest as PublicDaemonRequest,
DaemonRequestMeta as PublicDaemonRequestMeta,
DaemonResponse as PublicDaemonResponse,
DaemonResponseData as PublicDaemonResponseData,
SessionRuntimeHints as PublicSessionRuntimeHints,
} from '../contracts.ts';
export type { DaemonLockPolicy } from '../contracts.ts';
import type { MaterializeInstallSource } from '../platforms/install-source.ts';
import type { CommandFlags } from '../core/dispatch.ts';
import type { SessionSurface } from '../core/session-surface.ts';
@@ -8,54 +17,21 @@ import type { AndroidSnapshotFreshness } from './android-snapshot-freshness.ts';
import type { AppLogState } from './app-log-process.ts';
export type DaemonInstallSource = MaterializeInstallSource;
export type DaemonLockPolicy = 'reject' | 'strip';
export type SessionRuntimeHints = PublicSessionRuntimeHints;
export type DaemonArtifact = PublicDaemonArtifact;
export type DaemonResponseData = PublicDaemonResponseData;
export type DaemonRequest = {
type DaemonRequestMeta = Omit<PublicDaemonRequestMeta, 'installSource' | 'lockPlatform'> & {
installSource?: DaemonInstallSource;
lockPlatform?: PlatformSelector;
leaseBackend?: 'ios-simulator';
};
export type DaemonRequest = Omit<PublicDaemonRequest, 'token' | 'session' | 'flags' | 'meta'> & {
token: string;
session: string;
command: string;
positionals: string[];
flags?: CommandFlags;
runtime?: SessionRuntimeHints;
meta?: {
requestId?: string;
debug?: boolean;
cwd?: string;
tenantId?: string;
runId?: string;
leaseId?: string;
leaseTtlMs?: number;
leaseBackend?: 'ios-simulator';
sessionIsolation?: 'none' | 'tenant';
uploadedArtifactId?: string;
clientArtifactPaths?: Record<string, string>;
installSource?: DaemonInstallSource;
retainMaterializedPaths?: boolean;
materializedPathRetentionMs?: number;
materializationId?: string;
lockPolicy?: DaemonLockPolicy;
lockPlatform?: PlatformSelector;
};
};
export type SessionRuntimeHints = {
platform?: 'ios' | 'android';
metroHost?: string;
metroPort?: number;
bundleUrl?: string;
launchUrl?: string;
};
export type DaemonArtifact = {
field: string;
artifactId?: string;
fileName?: string;
localPath?: string;
path?: string;
};
export type DaemonResponseData = Record<string, unknown> & {
artifacts?: DaemonArtifact[];
meta?: DaemonRequestMeta;
};
export type ReplaySuiteTestSkipReason = 'skipped-by-filter';
@@ -113,19 +89,7 @@ export type ReplaySuiteResult = {
tests: ReplaySuiteTestResult[];
};
export type DaemonResponse =
| { ok: true; data?: DaemonResponseData }
| {
ok: false;
error: {
code: string;
message: string;
hint?: string;
diagnosticId?: string;
logPath?: string;
details?: Record<string, unknown>;
};
};
export type DaemonResponse = PublicDaemonResponse;
type RecordingTelemetryBase = {
tMs: number;
+110 -9
View File
@@ -1,17 +1,11 @@
import type { SessionRuntimeHints } from './contracts.ts';
import { buildMetroRuntimeHints, prepareMetroRuntime } from './client-metro.ts';
import { ensureMetroCompanion, stopMetroCompanion } from './client-metro-companion.ts';
export { buildBundleUrl, normalizeBaseUrl } from './utils/url.ts';
type EnvSource = NodeJS.ProcessEnv | Record<string, string | undefined>;
// Keep this public shape aligned with SessionRuntimeHints in src/contracts.ts and the
// internal MetroRuntimeHints in src/client-metro.ts.
export type MetroRuntimeHints = {
platform?: 'ios' | 'android';
metroHost?: string;
metroPort?: number;
bundleUrl?: string;
launchUrl?: string;
};
export type MetroRuntimeHints = SessionRuntimeHints;
export type MetroBridgeResult = {
enabled: boolean;
@@ -34,6 +28,113 @@ export type MetroBridgeResult = {
};
};
export type MetroBridgeRuntimePayload = {
metro_host?: string;
metro_port?: number;
metro_bundle_url?: string;
launch_url?: string;
};
export type MetroBridgeDescriptor = {
enabled: boolean;
base_url: string;
status_url?: string;
bundle_url?: string;
ios_runtime: MetroBridgeRuntimePayload;
android_runtime: MetroBridgeRuntimePayload;
upstream: {
bundle_url?: string;
host?: string;
port?: number;
status_url?: string;
};
probe: {
reachable: boolean;
status_code: number;
latency_ms: number;
detail: string;
};
};
export type MetroTunnelPingMessage = {
type: 'ping';
timestamp: number;
};
export type MetroTunnelPongMessage = {
type: 'pong';
timestamp: number;
};
export type MetroTunnelHttpRequestMessage = {
type: 'http-request';
requestId: string;
method: string;
path: string;
headers?: Record<string, string>;
bodyBase64?: string;
};
export type MetroTunnelHttpResponseMessage = {
type: 'http-response';
requestId: string;
status: number;
headers: Record<string, string>;
bodyBase64?: string;
};
export type MetroTunnelHttpErrorMessage = {
type: 'http-error';
requestId: string;
message: string;
};
export type MetroTunnelWebSocketOpenMessage = {
type: 'ws-open';
streamId: string;
path: string;
headers?: Record<string, string>;
};
export type MetroTunnelWebSocketOpenResultMessage = {
type: 'ws-open-result';
streamId: string;
success: boolean;
headers?: Record<string, string>;
error?: string;
};
export type MetroTunnelWebSocketFrameMessage = {
type: 'ws-frame';
streamId: string;
dataBase64: string;
binary: boolean;
};
export type MetroTunnelWebSocketCloseMessage = {
type: 'ws-close';
streamId: string;
code?: number;
reason?: string;
};
export type MetroTunnelRequestMessage =
| MetroTunnelPingMessage
| MetroTunnelHttpRequestMessage
| MetroTunnelWebSocketOpenMessage
| MetroTunnelWebSocketFrameMessage
| MetroTunnelWebSocketCloseMessage;
export type MetroTunnelResponseMessage =
| MetroTunnelPongMessage
| MetroTunnelHttpResponseMessage
| MetroTunnelHttpErrorMessage
| MetroTunnelWebSocketOpenResultMessage
| MetroTunnelWebSocketFrameMessage
| MetroTunnelWebSocketCloseMessage;
export type MetroTunnelMessage = MetroTunnelRequestMessage | MetroTunnelResponseMessage;
export type PrepareRemoteMetroOptions = {
projectRoot: string;
kind: 'auto' | 'react-native' | 'expo';
+8
View File
@@ -5,3 +5,11 @@ export function normalizeBaseUrl(input: string): string {
}
return end === input.length ? input : input.slice(0, end);
}
export function buildBundleUrl(baseUrl: string, platform: 'ios' | 'android'): string {
const url = new URL(`${normalizeBaseUrl(baseUrl)}/index.bundle`);
url.searchParams.set('platform', platform);
url.searchParams.set('dev', 'true');
url.searchParams.set('minify', 'false');
return url.toString();
}
@@ -277,11 +277,18 @@ test('installed package exposes Node APIs and packaged metro companion entrypoin
['--input-type=module', '-e'],
`
import 'agent-device/contracts';
import { buildIosRuntimeHints } from 'agent-device/metro';
import { daemonCommandRequestSchema } from 'agent-device/contracts';
import { buildBundleUrl, buildIosRuntimeHints, normalizeBaseUrl } from 'agent-device/metro';
import { resolveRemoteConfigProfile } from 'agent-device/remote-config';
const loaded = resolveRemoteConfigProfile({ configPath: ${JSON.stringify(remoteConfigPath)}, cwd: process.cwd() });
console.log(JSON.stringify({
bundleUrl: buildIosRuntimeHints('https://public.example.test').bundleUrl,
normalizedBaseUrl: normalizeBaseUrl('https://public.example.test///'),
protocolBundleUrl: buildBundleUrl('https://public.example.test', 'android'),
parsedCommand: daemonCommandRequestSchema.parse({
command: 'session_list',
positionals: []
}).command,
resolvedPath: loaded.resolvedPath,
metroProjectRoot: loaded.profile.metroProjectRoot
}));
@@ -291,6 +298,12 @@ test('installed package exposes Node APIs and packaged metro companion entrypoin
imports.bundleUrl,
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
);
assert.equal(imports.normalizedBaseUrl, 'https://public.example.test');
assert.equal(
imports.protocolBundleUrl,
'https://public.example.test/index.bundle?platform=android&dev=true&minify=false',
);
assert.equal(imports.parsedCommand, 'session_list');
assert.equal(imports.resolvedPath, remoteConfigPath);
assert.equal(imports.metroProjectRoot, projectRoot);