diff --git a/.changeset/expo-device-hub-website-flags.md b/.changeset/expo-device-hub-website-flags.md new file mode 100644 index 0000000000..3b2b5ad122 --- /dev/null +++ b/.changeset/expo-device-hub-website-flags.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': minor +--- + +Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. diff --git a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts index 7eb2d7c5ee..b2da2b4df4 100644 --- a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts @@ -2,7 +2,9 @@ import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job'; import type { bunyan } from '@expo/logger'; import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps'; import * as ngrok from '@ngrok/ngrok'; -import { access, mkdir, readdir, rm, writeFile } from 'node:fs/promises'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { access, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import type { CustomBuildContext } from '../../../customBuildContext'; @@ -17,7 +19,7 @@ import { ensureMacosPreviewEncoderInstalledAsync, startDeviceRunSessionPreview, } from '../deviceRunSessionPreview'; -import { startDeviceSessionHostAsync } from '../deviceSessionHost'; +import { EXPO_DEVICE_HUB_STATE_DIR, startDeviceSessionHostAsync } from '../deviceSessionHost'; import { ensureFfmpegInstalledOnceAsync, fetchWebPreviewTurnArgsAsync, @@ -74,8 +76,27 @@ const logger = { const stopServer = jest.fn(); const closeTunnel = jest.fn(); const directories: string[] = []; +const hubStateFiles: string[] = []; const baseDomain = 'preview.example.test'; +// expo-device-hub records its session token in `server-.json` before it answers /readyz, +// in the state directory it resolves from its env. +function writeHubState({ + args, + env: hubEnv, +}: { + args: readonly string[]; + env: BuildStepEnv; +}): void { + const port = args[args.indexOf('--port') + 1]; + const stateDir = + hubEnv.EXPO_DEVICE_HUB_STATE_DIR || path.join(hubEnv.TMPDIR ?? os.tmpdir(), 'expo-device-hub'); + const file = path.join(stateDir, `server-${port}.json`); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(file, JSON.stringify({ pid: 1, port: Number(port), token: 'hub-preview-token' })); + hubStateFiles.push(file); +} + async function startHostAsync(separateLogPhase = true) { return await startDeviceSessionHostAsync(ctx, { runtimePlatform: BuildRuntimePlatform.LINUX, @@ -103,6 +124,9 @@ beforeEach(() => { if (flag >= 0) { directories.push(options.args[flag + 1]); } + if (options.args.includes('--platform')) { + writeHubState(options); + } return { pid: undefined, getOutput: () => '', @@ -126,7 +150,9 @@ beforeEach(() => { afterEach(async () => { jest.useRealTimers(); await Promise.all( - directories.splice(0).map(directory => rm(directory, { recursive: true, force: true })) + [...directories.splice(0), ...hubStateFiles.splice(0)].map(entry => + rm(entry, { recursive: true, force: true }) + ) ); }); @@ -243,6 +269,7 @@ it('drains an in-flight tunnel when finishing and rejects new opens', async () = it('keeps cleanup and upload best-effort when tunnel close and finalization fail', async () => { jest.mocked(spawnDetached).mockImplementationOnce(options => { directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]); + writeHubState(options); return { pid: undefined, getOutput: () => '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255', @@ -284,6 +311,7 @@ it('keeps cleanup and upload best-effort when tunnel close and finalization fail it('logs the reason and skips upload and output dump when the Hub never recorded', async () => { jest.mocked(spawnDetached).mockImplementationOnce(options => { directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]); + writeHubState(options); return { pid: undefined, getOutput: () => '[serve-emu] Android recording skipped', @@ -474,6 +502,55 @@ it('closes a listener that arrives after finish has already timed out waiting fo expect(closeTunnel).toHaveBeenCalledTimes(1); }); +// The same session token and website origins as the serve-sim host on iOS. +it('starts expo-device-hub with a session token and the website origins', async () => { + const host = await startHostAsync(); + const { args } = jest.mocked(spawnDetached).mock.calls[0][0]; + expect(args).toContain('--require-token'); + expect(args.join(' ')).toContain( + '--cors-origin https://expo.dev --frame-ancestor https://expo.dev' + ); + const preview = await host.openPreviewAsync({ baseDomain }); + expect(preview.previewToken).toBe('hub-preview-token'); + await host.finishAsync(); +}); + +it.each([ + ['its own state directory', 'EXPO_DEVICE_HUB_STATE_DIR'], + ['its own TMPDIR', 'TMPDIR'], +])('reads the Hub token when the step env sets %s', async (_description, variable) => { + const override = await mkdtemp(path.join(os.tmpdir(), 'hub-state-override-')); + directories.push(override); + const host = await startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.LINUX, + env: { ...env, [variable]: override }, + logger, + separateLogPhase: true, + timeoutMs: 10_000, + }); + const options = jest.mocked(spawnDetached).mock.calls[0][0]; + expect(options.env.EXPO_DEVICE_HUB_STATE_DIR).toBe(EXPO_DEVICE_HUB_STATE_DIR); + expect((await host.openPreviewAsync({ baseDomain })).previewToken).toBe('hub-preview-token'); + await host.finishAsync(); +}); + +it('stops when expo-device-hub records no session token', async () => { + jest.mocked(spawnDetached).mockImplementationOnce(options => { + const flag = options.args.indexOf('--android-recording-directory'); + directories.push(options.args[flag + 1]); + return { + pid: undefined, + getOutput: () => '', + getExitError: () => undefined, + stopAsync: stopServer, + }; + }); + await expect(startHostAsync()).rejects.toThrow( + 'expo-device-hub became ready but wrote no session token' + ); + expect(stopServer).toHaveBeenCalledTimes(1); +}); + it('leaves iOS recording to its existing build steps', async () => { const host = await startDeviceSessionHostAsync(ctx, { runtimePlatform: BuildRuntimePlatform.DARWIN, @@ -500,13 +577,21 @@ const turnArgs = [ 'turn-secret', ]; -it('passes the TURN credential and the Android control token to the host as secrets', async () => { +it('passes the TURN credential and the Android control token to the host as secrets, and adds the session token once the Hub is ready', async () => { jest.mocked(fetchWebPreviewTurnArgsAsync).mockResolvedValueOnce(turnArgs); + const spawn = jest.mocked(spawnDetached).getMockImplementation()!; + let secretsAtSpawn: string[] | undefined; + jest.mocked(spawnDetached).mockImplementationOnce(options => { + secretsAtSpawn = [...(options.secrets ?? [])]; + return spawn(options); + }); const host = await startHostAsync(); const options = jest.mocked(spawnDetached).mock.calls[0][0]; const controlToken = options.env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN; expect(controlToken).toMatch(/^[a-f0-9]{64}$/); - expect(options.secrets).toEqual(['turn-secret', controlToken]); + expect(secretsAtSpawn).toEqual(['turn-secret', controlToken]); + // Host output is redacted against this array, so the token must land in the one passed at spawn. + expect(options.secrets).toEqual(['turn-secret', controlToken, 'hub-preview-token']); await host.finishAsync(); }); @@ -642,6 +727,7 @@ it.each([ let exitError: Error | undefined; jest.mocked(spawnDetached).mockImplementationOnce(options => { directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]); + writeHubState(options); return { pid: undefined, getOutput: () => '', diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index 4c81dcc763..f0d833a5c8 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -4,6 +4,7 @@ import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from import spawn from '@expo/turtle-spawn'; import * as ngrok from '@ngrok/ngrok'; import { EventEmitter } from 'node:events'; +import { mkdirSync, writeFileSync } from 'node:fs'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -38,13 +39,14 @@ import { } from '../remoteDeviceRunSession'; import { + EXPO_DEVICE_HUB_STATE_DIR, createExpoDeviceHubArgs, createServeSimArgs, simulatorPreviewPageUrl, startDeviceSessionHostAsync, waitForWebPreviewReadyAsync, websiteOrigin, - websiteOriginServeSimArgs, + websiteOriginArgs, } from '../deviceSessionHost'; import { parseNetworkCaptureFieldsInput, parseNetworkCaptureInputs } from '../networkCaptureFields'; @@ -561,6 +563,7 @@ describe(createExpoDeviceHubArgs, () => { '4321', '--host', '127.0.0.1', + '--require-token', '--platform', 'android', '--transport', @@ -582,6 +585,23 @@ describe(createExpoDeviceHubArgs, () => { ]); }); + // The same website flags as serve-sim, after the TURN args. + it('appends the website args after the TURN args when provided', () => { + const args = createExpoDeviceHubArgs({ + port: 4321, + turnArgs: ['--turn-url', 'turns:turn.example.test:443'], + websiteArgs: ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'], + }); + expect(args.slice(-6)).toEqual([ + '--turn-url', + 'turns:turn.example.test:443', + '--cors-origin', + 'https://expo.dev', + '--frame-ancestor', + 'https://expo.dev', + ]); + }); + it('pins the requested package version', () => { expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe( 'expo-device-hub@0.7.0' @@ -589,9 +609,9 @@ describe(createExpoDeviceHubArgs, () => { }); }); -describe(websiteOriginServeSimArgs, () => { +describe(websiteOriginArgs, () => { it('names only the production website by default', () => { - expect(websiteOriginServeSimArgs({} as BuildStepEnv)).toEqual([ + expect(websiteOriginArgs({} as BuildStepEnv)).toEqual([ '--cors-origin', 'https://expo.dev', '--frame-ancestor', @@ -600,7 +620,7 @@ describe(websiteOriginServeSimArgs, () => { }); it('names staging, its deploy previews and local website subdomains on staging', () => { - const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv); + const args = websiteOriginArgs({ EXPO_STAGING: '1' } as BuildStepEnv); expect(args).toEqual([ '--cors-origin', 'https://staging.expo.dev', @@ -623,14 +643,14 @@ describe(websiteOriginServeSimArgs, () => { it('names only https origins', () => { for (const env of [{}, { EXPO_STAGING: '1' }, { EXPO_LOCAL: '1' }]) { - const args = websiteOriginServeSimArgs(env as BuildStepEnv); + const args = websiteOriginArgs(env as BuildStepEnv); expect(args.filter(value => value.startsWith('http://'))).toEqual([]); } }); it('names local website subdomains without the deploy-preview wildcard on local', () => { for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) { - expect(websiteOriginServeSimArgs(env as BuildStepEnv)).toEqual([ + expect(websiteOriginArgs(env as BuildStepEnv)).toEqual([ '--cors-origin', 'https://expo.test', '--frame-ancestor', @@ -831,6 +851,7 @@ describe(startDeviceSessionHostAsync, () => { DEVICE_RUN_SESSION_ID: 'drs-id', NGROK_AUTHTOKEN: 'ngrok-token', } as unknown as BuildStepEnv; + const hubStateFiles: string[] = []; beforeEach(() => { jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset(); @@ -852,7 +873,17 @@ describe(startDeviceSessionHostAsync, () => { }), }, }); - jest.mocked(spawn).mockReturnValue(spawnPromise as never); + // expo-device-hub records its session token in `server-.json` before it is ready. + jest.mocked(spawn).mockImplementation(((_command: string, args: string[] = []) => { + if (args.includes('--platform')) { + const port = args[args.indexOf('--port') + 1]; + const file = path.join(EXPO_DEVICE_HUB_STATE_DIR, `server-${port}.json`); + mkdirSync(EXPO_DEVICE_HUB_STATE_DIR, { recursive: true }); + writeFileSync(file, JSON.stringify({ pid: 1, port: Number(port), token: 'hub-token' })); + hubStateFiles.push(file); + } + return spawnPromise; + }) as never); jest.mocked(turtleFetch).mockImplementation(async url => { if (url.endsWith('/turn-ice-servers')) { @@ -876,8 +907,9 @@ describe(startDeviceSessionHostAsync, () => { }); }); - afterEach(() => { + afterEach(async () => { jest.restoreAllMocks(); + await Promise.all(hubStateFiles.splice(0).map(file => fs.rm(file, { force: true }))); }); it('gives serve-sim its recording grace period on shutdown', async () => { @@ -947,7 +979,13 @@ describe(startDeviceSessionHostAsync, () => { const recordingDirectory = args[args.indexOf('--android-recording-directory') + 1]; expect(args).toEqual([ '--yes', - ...createExpoDeviceHubArgs({ port, turnArgs, packageVersion, recordingDirectory }), + ...createExpoDeviceHubArgs({ + port, + turnArgs, + websiteArgs, + packageVersion, + recordingDirectory, + }), ]); expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port })); expect(preview.apiUrl).toBe('https://android-preview.example.test'); @@ -1026,8 +1064,8 @@ describe(startDeviceSessionHostAsync, () => { ).rejects.toThrow(/wrote no session token/); }); - // expo-device-hub mints no token, so the Android preview must not require one. - it('starts for Linux without a token, and does not gate expo-device-hub', async () => { + // expo-device-hub records its token in a state file, as serve-sim does. + it('carries the expo-device-hub session token for Linux', async () => { jest.mocked(readServeSimServersAsync).mockResolvedValue([]); jest.mocked(ngrok.forward).mockResolvedValue({ url: () => 'https://android-preview.example.test', @@ -1042,9 +1080,10 @@ describe(startDeviceSessionHostAsync, () => { }); const preview = await host.openPreviewAsync({ baseDomain }); - expect(preview.previewToken).toBeUndefined(); - const [, args] = jest.mocked(spawn).mock.calls[0]; - expect(args).not.toContain('--require-token'); + expect(preview.previewToken).toBe('hub-token'); + const [, args] = jest.mocked(spawn).mock.calls.find(([command]) => command === 'npx')!; + expect(args).toContain('--require-token'); + await host.finishAsync(); }); it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => { diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 60a0d1155c..bfff1fa886 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -57,6 +57,8 @@ const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub'; const EXPO_DEVICE_HUB_MAX_DIMENSION = '960'; const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000'; const EXPO_DEVICE_HUB_VIDEO_FPS = '60'; +// expo-device-hub's record of each running Hub, like serve-sim's: `server-.json`. +export const EXPO_DEVICE_HUB_STATE_DIR = path.join(os.tmpdir(), 'expo-device-hub'); // On SIGTERM the Hub finalizes the recording itself, with this deadline before it force-exits, // so a stop request that failed here still gets one more chance to write the MP4. const EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS = 60_000; @@ -81,7 +83,8 @@ export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): // Local website hosts are shared by local and staging simulator sessions. const WEBSITE_DEV_ORIGINS = ['https://expo.test', 'https://*.expo.test']; -export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] { +// serve-sim and expo-device-hub take the same website origin flags and values. +export function websiteOriginArgs(env: BuildStepEnv): string[] { const origins = new Set([websiteOrigin(env)]); if (!env.EXPO_LOCAL && env.EXPO_STAGING) { origins.add('https://*.expo.dev'); @@ -161,11 +164,13 @@ export function createServeSimArgs({ export function createExpoDeviceHubArgs({ port, turnArgs = [], + websiteArgs = [], packageVersion, recordingDirectory, }: { port: number; turnArgs?: string[]; + websiteArgs?: string[]; packageVersion?: string; recordingDirectory?: string; }): string[] { @@ -175,6 +180,7 @@ export function createExpoDeviceHubArgs({ String(port), '--host', WEB_PREVIEW_HOST, + '--require-token', '--platform', 'android', '--transport', @@ -193,6 +199,7 @@ export function createExpoDeviceHubArgs({ '--hide-boot-device', ...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []), ...turnArgs, + ...websiteArgs, ]; } @@ -242,6 +249,20 @@ export async function waitForWebPreviewReadyAsync({ ); } +export async function readExpoDeviceHubPreviewTokenAsync( + port: number, + stateDir: string = EXPO_DEVICE_HUB_STATE_DIR +): Promise { + try { + const state = JSON.parse( + await fs.promises.readFile(path.join(stateDir, `server-${port}.json`), 'utf-8') + ) as { token?: unknown }; + return typeof state.token === 'string' ? state.token : undefined; + } catch { + return undefined; + } +} + export async function readServeSimPreviewTokenAsync( udid: string, stateDir: string = SERVE_SIM_STATE_DIR @@ -369,12 +390,13 @@ async function startDeviceSessionHostInternalAsync( turnArgs, packageVersion, recordingDirectory: recording?.directory, + websiteArgs: websiteOriginArgs(env), }) : createServeSimArgs({ port, turnArgs, packageVersion, - websiteArgs: websiteOriginServeSimArgs(env), + websiteArgs: websiteOriginArgs(env), shareUrl: previewPageUrl, launchAppIdentifier, launchArgs, @@ -404,6 +426,9 @@ async function startDeviceSessionHostInternalAsync( ...env, EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory, ...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}), + // The Hub would resolve its state directory from the step's env, which may set its own + // TMPDIR or state directory, so it writes its token where the host reads it below. + ...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}), }, // Only an Android host records through expo-device-hub; serve-sim records on iOS. stopGracePeriodMs: recording @@ -514,17 +539,20 @@ async function startDeviceSessionHostInternalAsync( timeoutMs, }); hostReady = true; + // Both servers record their session token in a state file once they are ready. + previewToken = isAndroid + ? await readExpoDeviceHubPreviewTokenAsync(port, EXPO_DEVICE_HUB_STATE_DIR) + : await readServeSimPreviewTokenAsync(device); + if (!previewToken) { + throw new SystemError( + `${serverName} became ready but wrote no session token for device ${device}. The ` + + 'preview is on a public tunnel and would be reachable without one, so the session ' + + 'cannot continue. This usually means the state file was not written as expected; ' + + 'retry the session, and report it if it repeats.' + ); + } + secrets.push(previewToken); if (!isAndroid) { - previewToken = await readServeSimPreviewTokenAsync(device); - if (!previewToken) { - throw new SystemError( - `serve-sim became ready but wrote no session token for device ${device}. The preview is ` + - 'on a public tunnel and would be reachable without one, so the session cannot continue. ' + - 'This usually means the state file was not written as expected; retry the session, and ' + - 'report it if it repeats.' - ); - } - secrets.push(previewToken); IosSimulatorRecordingUtils.useServeSimPackage(packageSpec); } // Android installed FFmpeg before launching the host. The optional thumbnail must not delay