mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
committed by
GitHub
parent
c02cbd015f
commit
be09c1d58a
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user