diff --git a/src/__tests__/contracts-schema-public.test.ts b/src/__tests__/contracts-schema-public.test.ts new file mode 100644 index 000000000..e57d9b8ee --- /dev/null +++ b/src/__tests__/contracts-schema-public.test.ts @@ -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/, + ); +}); diff --git a/src/__tests__/metro-protocol-public.test.ts b/src/__tests__/metro-protocol-public.test.ts new file mode 100644 index 000000000..88072ba6b --- /dev/null +++ b/src/__tests__/metro-protocol-public.test.ts @@ -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); +}); diff --git a/src/client-metro-companion-contract.ts b/src/client-metro-companion-contract.ts index 23ba55908..6ce88afc3 100644 --- a/src/client-metro-companion-contract.ts +++ b/src/client-metro-companion-contract.ts @@ -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; - bodyBase64?: string; - } - | { - type: 'ws-open'; - streamId: string; - path: string; - headers?: Record; - } - | { - 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; diff --git a/src/client-metro.ts b/src/client-metro.ts index 7a8b4c181..f5e5e7045 100644 --- a/src/client-metro.ts +++ b/src/client-metro.ts @@ -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; @@ -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; @@ -42,8 +43,8 @@ export type DaemonRequestMeta = { }; export type DaemonRequest = { - token: string; - session: string; + token?: string; + session?: string; command: string; positionals: string[]; flags?: Record; @@ -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 = { + jsonrpc?: string; + id?: JsonRpcId; + method?: string; + params?: TParams; +}; + +type RuntimeSchema = { + 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(parse: (input: unknown, path: string) => T): RuntimeSchema { + 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 { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + fail(path, 'Expected an object'); + } + return input as Record; +} + +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, + key: string, + path: string, +): string | undefined { + const value = record[key]; + return value === undefined ? undefined : expectString(value, `${path}.${key}`); +} + +function optionalBoolean( + record: Record, + 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, + key: string, + path: string, +): number | undefined { + const value = record[key]; + return value === undefined ? undefined : expectInteger(value, `${path}.${key}`); +} + +function optionalObject( + record: Record, + key: string, + path: string, +): Record | undefined { + const value = record[key]; + return value === undefined ? undefined : expectObject(value, `${path}.${key}`); +} + +function optionalEnum( + record: Record, + 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 { + const record = expectObject(input, path); + const result: Record = {}; + 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((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((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((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((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((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, + 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((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, + }; +}); diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 01be9be6e..8391a40aa 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -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 & { + installSource?: DaemonInstallSource; + lockPlatform?: PlatformSelector; + leaseBackend?: 'ios-simulator'; +}; + +export type DaemonRequest = Omit & { 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; - 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 & { - 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; - }; - }; +export type DaemonResponse = PublicDaemonResponse; type RecordingTelemetryBase = { tMs: number; diff --git a/src/metro.ts b/src/metro.ts index 0bd4655b5..69a98d292 100644 --- a/src/metro.ts +++ b/src/metro.ts @@ -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; -// 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; + bodyBase64?: string; +}; + +export type MetroTunnelHttpResponseMessage = { + type: 'http-response'; + requestId: string; + status: number; + headers: Record; + bodyBase64?: string; +}; + +export type MetroTunnelHttpErrorMessage = { + type: 'http-error'; + requestId: string; + message: string; +}; + +export type MetroTunnelWebSocketOpenMessage = { + type: 'ws-open'; + streamId: string; + path: string; + headers?: Record; +}; + +export type MetroTunnelWebSocketOpenResultMessage = { + type: 'ws-open-result'; + streamId: string; + success: boolean; + headers?: Record; + 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'; diff --git a/src/utils/url.ts b/src/utils/url.ts index 3edec87b7..5f9370fe1 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -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(); +} diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index dbc66a32e..183b3fb1c 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -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);