From d5385d2fbb09c4a2f05942b142d5c6988ad0ddff Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Sun, 17 May 2026 16:34:40 -0700 Subject: [PATCH] [cli] Add /_expo/open middleware for programmatic deep link resolution (#45804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why The dev server resolves deep links for `expo start` interactively via the terminal (`i`/`a` keystrokes) and the `/_expo/link` disambiguation page used by QR codes. There is no programmatic surface for external tooling (preview systems, IDE integrations, distributed dev environments) to ask the running dev server "what URL should I use to open this project on iOS/Android/web?" without scraping the disambiguation HTML. This adds a documented `/_expo/open` endpoint that returns structured info about how to open the project, mirroring the CLI's existing resolution behavior. # How - New `OpenMiddleware` registered at `/_expo/open` supporting `GET` (discovery / per-platform info) and `POST` (action — actually opens the project). - New `createOpenMiddlewareOptions` factory that wires the middleware to the same runtime resolution and platform-launch logic used by the keyboard shortcuts. - Per-platform responses include `runtime` (`expo` | `custom` | `web`, omitted when disambiguation is required), `url`, and `appId` (bundle identifier / package name) so callers can check install state before deep linking. - `runtime` request parameter accepts `default` (mirrors `i`/`a`), `expo` / `custom` (force a direct link), and `unknown` (force the disambiguation page). - Response cache disabled; no host machine info leaked in responses. # Test Plan - Unit tests for the middleware and option factory covering discovery, per-platform GET, POST actions, runtime disambiguation, missing scheme, missing app ID, and the `unknown` runtime override (~736 lines of new tests). - `yarn lint`, `yarn tsc --noEmit`, and `yarn prepare` all clean in `packages/@expo/cli`. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- docs/pages/more/expo-cli.mdx | 82 ++++ packages/@expo/cli/CHANGELOG.md | 2 + .../@expo/cli/src/start/server/UrlCreator.ts | 5 + .../server/metro/MetroBundlerDevServer.ts | 47 ++ .../start/server/middleware/OpenMiddleware.ts | 256 ++++++++++ .../middleware/RuntimeRedirectMiddleware.ts | 4 +- .../__tests__/OpenMiddleware-test.ts | 437 ++++++++++++++++++ .../middleware/__tests__/openHandlers-test.ts | 355 ++++++++++++++ .../start/server/middleware/openHandlers.ts | 149 ++++++ .../@expo/cli/src/utils/__tests__/net-test.ts | 6 + packages/@expo/cli/src/utils/net.ts | 8 +- 11 files changed, 1349 insertions(+), 2 deletions(-) create mode 100644 packages/@expo/cli/src/start/server/middleware/OpenMiddleware.ts create mode 100644 packages/@expo/cli/src/start/server/middleware/__tests__/OpenMiddleware-test.ts create mode 100644 packages/@expo/cli/src/start/server/middleware/__tests__/openHandlers-test.ts create mode 100644 packages/@expo/cli/src/start/server/middleware/openHandlers.ts diff --git a/docs/pages/more/expo-cli.mdx b/docs/pages/more/expo-cli.mdx index d289686362fba3..b9a0a69fba8425 100644 --- a/docs/pages/more/expo-cli.mdx +++ b/docs/pages/more/expo-cli.mdx @@ -154,6 +154,88 @@ When you start the development server in a project for the first time, a **.expo Both of these files have information that is specific to your local computer. This is the reason why **.expo** directory is included in the **.gitignore** file, by default, when a new project is created. It is not meant to be shared with other developers. +### Open endpoint + +The dev server exposes `/_expo/open` so external tools, such as cloud agents, remote preview services, CI scripts, can introspect the deep links the CLI would use, and optionally trigger the same action as pressing I / A / W in the **Terminal UI**. It supplements the legacy `/_expo/link` endpoint, which returns a `307` redirect to a deep link scheme that non-mobile clients can't follow. + +| Method | Effect | +| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | Dry run: returns the deep link as JSON. Safe to call across tunnels. | +| `POST` | Opens the project locally — equivalent to pressing I / A / W in the **Terminal UI**. Restricted to same-origin requests. | + +#### Query params + +- `platform` (or `expo-platform` header): `ios`, `android`, or `web`. Omit on `GET` for a discovery response that lists every platform. +- `runtime`: choose how the URL is resolved. + - `default` (omitted): mirrors what pressing I / A does. It picks the dev client when the server was started with `--dev-client`, falls back to a disambiguation page when the project has both Expo Go and a development build, otherwise opens Expo Go. + - `expo`: force the Expo Go deep link (`exp://…`). + - `custom`: force the development-build deep link (`://expo-development-client/?url=…`). + - `unknown`: force the disambiguation `/_expo/loading` page, letting the device decide between Expo Go and the dev build. + +The endpoint reflects mid-run state changes — pressing S to toggle between Expo Go and the dev client, or installing `expo-dev-client` while the server is running, both show up on the next request. + +#### GET response + +For a specific platform: + +```json +{ + "runtime": "expo", + "url": "exp://192.168.1.71:8081", + "scheme": "myapp", + "availableRuntimes": ["expo", "custom"], + "appId": "com.example.app" +} +``` + +- `runtime`: the resolved runtime (`expo`, `custom`, or `web`). Omitted when `url` is the disambiguation page; the device determines the eventual runtime. +- `url`: the deep link (or disambiguation page URL for `runtime: 'unknown'` and the `default`-with-both-runtimes case). Routes through the ngrok host when `--tunnel` is active. +- `scheme`: the project's URL scheme used for development-build deep links, or `null` when none is configured. +- `availableRuntimes`: `['expo']`, `['custom']`, or `['expo', 'custom']`. When `.length > 1`, the caller should either pick a runtime explicitly or let the device disambiguate. +- `appId`: the iOS bundle identifier or Android package name resolved from the project config (or native files). `null` for web, or when the project hasn't set `ios.bundleIdentifier` / `android.package`. Useful for verifying a build is installed on a remote device before opening the deep link. + +Without `platform`, the response is keyed by platform for discovery: + +```json +{ + "scheme": "myapp", + "availableRuntimes": ["expo", "custom"], + "platforms": { + "ios": { + "url": "http://192.168.1.71:8081/_expo/loading?platform=ios", + "appId": "com.example.app" + }, + "android": { + "url": "http://192.168.1.71:8081/_expo/loading?platform=android", + "appId": "com.example.app" + }, + "web": { "runtime": "web", "url": "http://192.168.1.71:8081", "appId": null } + } +} +``` + +#### POST behavior + +`POST /_expo/open?platform=ios` opens the project locally on the requested platform (`ios` → iOS Simulator, `android` → Android emulator, `web` → desktop browser). Responses: + +- `200`: `{ "platform", "runtime", "url" }` describing what was opened. +- `403`: cross-origin POST. The body's `error` explains the host mismatch and points at `GET /_expo/open` as the safe alternative. +- `501`: host can't launch the requested platform (for example, `platform=ios` on Linux/Windows). The response carries a `details` field explaining why and suggesting the GET-then-launch-remotely workflow. +- `500`: `openPlatformAsync` threw. The body forwards the underlying error code and message. + +#### Examples + +```sh +# Get the deep link for iOS (works over a tunnel, no Expo Go install required). +curl http://localhost:8081/_expo/open?platform=ios + +# Force the disambiguation page so a device or external picker chooses. +curl 'http://localhost:8081/_expo/open?platform=android&runtime=unknown' + +# Trigger an iOS Simulator launch (only works on the dev server's host). +curl -X POST http://localhost:8081/_expo/open?platform=ios +``` + ## Building A React Native app consists of two parts: a native runtime ([compiling](#compiling)), and static files like JavaScript bundles and assets ([exporting](#exporting)). Expo CLI provides commands for performing both tasks. diff --git a/packages/@expo/cli/CHANGELOG.md b/packages/@expo/cli/CHANGELOG.md index 0a80b005141dd3..db163bf588e1db 100644 --- a/packages/@expo/cli/CHANGELOG.md +++ b/packages/@expo/cli/CHANGELOG.md @@ -6,6 +6,8 @@ ### 🎉 New features +- Add `/_expo/open` middleware for programmatically resolving deep links and disambiguation pages for the running dev server. ([#45804](https://github.com/expo/expo/pull/45804) by [@EvanBacon](https://github.com/EvanBacon)) + ### 🐛 Bug fixes ### 💡 Others diff --git a/packages/@expo/cli/src/start/server/UrlCreator.ts b/packages/@expo/cli/src/start/server/UrlCreator.ts index a296a8cfefe911..b1552cdb547adb 100644 --- a/packages/@expo/cli/src/start/server/UrlCreator.ts +++ b/packages/@expo/cli/src/start/server/UrlCreator.ts @@ -100,6 +100,11 @@ export class UrlCreator { return this.gatewayInfo.address; } + /** URL scheme configured for development-build deep links (e.g. `myapp`). `null` when unset. */ + public getScheme(): string | null { + return this.defaults?.scheme ?? null; + } + /** Get the URL components from the Ngrok server URL. */ private getTunnelUrlComponents(options: Pick): UrlComponents | null { const tunnelUrl = this.bundlerInfo.getTunnelUrl?.(); diff --git a/packages/@expo/cli/src/start/server/metro/MetroBundlerDevServer.ts b/packages/@expo/cli/src/start/server/metro/MetroBundlerDevServer.ts index 4ae0d1a381481d..f3b3a8ea3707ff 100644 --- a/packages/@expo/cli/src/start/server/metro/MetroBundlerDevServer.ts +++ b/packages/@expo/cli/src/start/server/metro/MetroBundlerDevServer.ts @@ -68,6 +68,8 @@ import { CommandError } from '../../../utils/errors'; import { toPosixPath } from '../../../utils/filePath'; import { getEnvFiles, reloadEnvFiles } from '../../../utils/nodeEnv'; import { getFreePortAsync } from '../../../utils/port'; +import { AndroidAppIdResolver } from '../../platforms/android/AndroidAppIdResolver'; +import { AppleAppIdResolver } from '../../platforms/ios/AppleAppIdResolver'; import type { BundlerStartOptions, DevServerInstance } from '../BundlerDevServer'; import { BundlerDevServer } from '../BundlerDevServer'; import { evalMetroAndWrapFunctions, evalMetroNoHandling } from '../getStaticRenderFunctions'; @@ -83,6 +85,7 @@ import { createDomComponentsMiddleware } from '../middleware/DomComponentsMiddle import { FaviconMiddleware } from '../middleware/FaviconMiddleware'; import { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware'; import { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware'; +import { OpenHostSupportEntry, OpenMiddleware, OpenPlatform } from '../middleware/OpenMiddleware'; import { RuntimeRedirectMiddleware } from '../middleware/RuntimeRedirectMiddleware'; import { ServeStaticMiddleware } from '../middleware/ServeStaticMiddleware'; import type { ExpoMetroOptions } from '../middleware/metroOptions'; @@ -95,6 +98,7 @@ import { getMetroDirectBundleOptions, } from '../middleware/metroOptions'; import { prependMiddleware } from '../middleware/mutations'; +import { createInfoHandler, createOpen } from '../middleware/openHandlers'; import type { ServerNext, ServerRequest, ServerResponse } from '../middleware/server.types'; import { startTypescriptTypeGenerationAsync } from '../type-generation/startTypescriptTypeGeneration'; @@ -1378,6 +1382,49 @@ export class MetroBundlerDevServer extends BundlerDevServer { }); middleware.use(deepLinkMiddleware.getHandler()); + const getHostSupport = (platform: OpenPlatform): OpenHostSupportEntry => { + if (platform === 'ios' && process.platform !== 'darwin') { + return { + canOpen: false, + reason: `iOS simulators require macOS with Xcode installed; this dev server is running on ${process.platform}.`, + }; + } + return { canOpen: true }; + }; + + // Read all dev-server state live — pressing `s` in the terminal toggles `isDevClient` + // and the scheme, and `expo-dev-client` can be installed mid-run (re-resolved by + // isRedirectPageEnabled on every call). + const openMiddleware = new OpenMiddleware(this.projectRoot, { + serverBaseUrl, + getHostSupport, + getInfo: createInfoHandler({ + urlCreator: this.getUrlCreator(), + getIsDevClient: () => this.isDevClient, + getIsRedirectPageEnabled: () => this.isRedirectPageEnabled(), + getAppId: async (platform) => { + if (platform === 'web') return null; + const resolver = + platform === 'ios' + ? new AppleAppIdResolver(this.projectRoot) + : new AndroidAppIdResolver(this.projectRoot); + try { + return await resolver.getAppIdAsync(); + } catch { + // Surfacing the error would block the dry-run; consumers can detect the missing id + // from `appId: null` and prompt the user to configure ios.bundleIdentifier / + // android.package. + return null; + } + }, + }), + open: createOpen({ + getIsDevClient: () => this.isDevClient, + openPlatformAsync: (target, resolver) => this.openPlatformAsync(target, resolver), + }), + }); + middleware.use(openMiddleware.getHandler()); + const domComponentRenderer = createDomComponentsMiddleware( { projectRoot: this.projectRoot }, instanceMetroOptions diff --git a/packages/@expo/cli/src/start/server/middleware/OpenMiddleware.ts b/packages/@expo/cli/src/start/server/middleware/OpenMiddleware.ts new file mode 100644 index 00000000000000..55e64e65fb6083 --- /dev/null +++ b/packages/@expo/cli/src/start/server/middleware/OpenMiddleware.ts @@ -0,0 +1,256 @@ +import { disableResponseCache, ExpoMiddleware } from './ExpoMiddleware'; +import { parsePlatformHeader } from './resolvePlatform'; +import type { ServerRequest, ServerResponse } from './server.types'; +import { isLocalSocket, isMatchingOrigin } from '../../../utils/net'; + +export const OpenEndpoint = '/_expo/open'; + +/** + * Resolved runtime returned by the server. Omitted when the URL is a disambiguation page (the + * actual runtime — Expo Go vs. dev build — isn't decided until the device picks). Matches what + * the CLI does for `i`/`a` when the project supports both Expo Go and a development build. + */ +export type OpenRuntime = 'expo' | 'custom' | 'web'; + +/** + * Runtime that a caller can request. + * - `default` mirrors the CLI's `i`/`a` resolution. + * - `expo` / `custom` force a direct deep link, bypassing disambiguation. + * - `unknown` forces the disambiguation/interstitial page even when the CLI would resolve + * directly. Useful when the caller wants the device (rather than the dev server) to decide. + */ +export type OpenRequestedRuntime = 'expo' | 'custom' | 'unknown' | 'default'; + +/** Subset of {@link OpenRuntime} that represents native deep-link choices (not how to deliver them). */ +export type OpenNativeRuntime = 'expo' | 'custom'; + +/** Platform supported by the open endpoint. */ +export type OpenPlatform = 'ios' | 'android' | 'web'; + +/** Whether the dev server's host machine can launch the project on a given platform. */ +export interface OpenHostSupportEntry { + /** `true` when the host machine is expected to be able to open the platform locally. */ + canOpen: boolean; + /** Human-readable explanation when `canOpen` is `false`. */ + reason?: string; +} + +/** Per-platform open info — present whether the caller asked for one platform or for discovery. */ +export interface OpenPlatformInfo { + /** + * Concrete runtime that the URL targets. `undefined` when the URL is a disambiguation page — + * the device decides between Expo Go and the dev build after the user picks. + */ + runtime?: OpenRuntime; + /** Deep link (native), disambiguation HTML page (when `runtime` is omitted), or dev server URL (web). `null` when no URL scheme is configured for `runtime: 'custom'`. */ + url: string | null; + /** + * Native application identifier (iOS bundle identifier / Android package name) used to detect + * whether the project is already installed on a target device. `null` when the platform has no + * concept of an app ID (web), when the project's config is missing the identifier, or when + * resolution failed. Useful for distributed preview systems that need to confirm a build is + * present before opening a deep link. + */ + appId: string | null; +} + +/** Project-level metadata returned on every GET. */ +interface OpenProjectMeta { + /** URL scheme used for development build deep links (e.g. `myapp`). `null` when none is configured. */ + scheme: string | null; + /** + * Native runtimes the project can target. `['expo']` for Expo Go only, `['custom']` for a dev + * client only, `['expo', 'custom']` when both are configured — in that case the caller should + * either pick one explicitly or rely on `runtime: 'default'` (which mirrors the CLI's + * disambiguation behavior). + */ + availableRuntimes: OpenNativeRuntime[]; +} + +/** GET `/_expo/open?platform=…` — focused per-platform response. */ +export interface OpenSinglePlatformResult extends OpenProjectMeta, OpenPlatformInfo {} + +/** GET `/_expo/open` — discovery response with all platforms. */ +export interface OpenDiscoveryResult extends OpenProjectMeta { + platforms: Record; +} + +export type OpenInfoResult = OpenSinglePlatformResult | OpenDiscoveryResult; + +/** Result of a POST to `/_expo/open`. */ +export interface OpenActionResult { + platform: OpenPlatform; + runtime: OpenRuntime; + /** Deep link that was opened on the local device. */ + url: string; +} + +export interface OpenMiddlewareOptions { + /** + * Dev server base URL (e.g. `http://localhost:8081`). Used for the POST same-origin CSRF + * check — requests whose `Origin` header doesn't match this URL's host are rejected. + */ + serverBaseUrl: string; + /** Compute the dry-run information for the requested platform + runtime. */ + getInfo: (props: { + platform: OpenPlatform | null; + runtime: OpenRequestedRuntime; + }) => Promise; + /** Open the project locally on the requested platform — equivalent to pressing `i` / `a` in the terminal UI. */ + open: (props: { platform: OpenPlatform }) => Promise; + /** Whether the host can launch a given platform. */ + getHostSupport: (platform: OpenPlatform) => OpenHostSupportEntry; +} + +export class OpenMiddleware extends ExpoMiddleware { + constructor( + projectRoot: string, + protected options: OpenMiddlewareOptions + ) { + super(projectRoot, [OpenEndpoint]); + } + + async handleRequestAsync(req: ServerRequest, res: ServerResponse): Promise { + disableResponseCache(res); + res.setHeader('Content-Type', 'application/json'); + + const method = (req.method ?? 'GET').toUpperCase(); + const searchParams = new URL(req.url ?? '', 'http://localhost').searchParams; + const platformParam = parsePlatformHeader(req); + const platform = normalizePlatform(platformParam); + const runtimeParam = searchParams.get('runtime') ?? undefined; + const normalizedRuntime = runtimeParam ? normalizeRequestedRuntime(runtimeParam) : 'default'; + + if (platformParam && !platform) { + sendError(res, 400, { + code: 'INVALID_PLATFORM', + error: `Unsupported "platform" value "${platformParam}". Must be "ios", "android", or "web".`, + }); + return; + } + + if (runtimeParam && !normalizedRuntime) { + sendError(res, 400, { + code: 'INVALID_RUNTIME', + error: `Unsupported "runtime" value "${runtimeParam}". Must be "default", "expo", "custom", or "unknown".`, + }); + return; + } + const runtime: OpenRequestedRuntime = normalizedRuntime ?? 'default'; + + if (method === 'POST') { + const sameDeviceError = assertSameDevice(req); + if (sameDeviceError) { + sendError(res, 403, sameDeviceError); + return; + } + + const sameOriginError = assertSameOrigin(req, this.options.serverBaseUrl); + if (sameOriginError) { + sendError(res, 403, sameOriginError); + return; + } + + if (!platform) { + sendError(res, 400, { + code: 'MISSING_PLATFORM', + error: `POST /_expo/open requires a platform. Pass it as the "platform" query param or "expo-platform" header. Must be "ios", "android", or "web".`, + }); + return; + } + + const support = this.options.getHostSupport(platform); + if (!support.canOpen) { + sendError(res, 501, { + code: 'HOST_CANNOT_OPEN_PLATFORM', + platform, + error: `Cannot open the project on ${platform} from this dev server host.`, + details: + (support.reason ? support.reason + ' ' : '') + + `Use GET /_expo/open?platform=${platform} to retrieve the deep link, then launch it from a host that supports ${platform} or hand it to a remote preview service.`, + }); + return; + } + + try { + const result = await this.options.open({ platform }); + res.statusCode = 200; + res.end(JSON.stringify(result)); + } catch (error: any) { + sendError(res, 500, { + code: typeof error?.code === 'string' ? error.code : 'OPEN_FAILED', + platform, + error: `Failed to open the project on ${platform}.`, + details: + (typeof error?.message === 'string' ? error.message : String(error)) + + ` Check the dev server logs for more detail, or use GET /_expo/open?platform=${platform} to launch the deep link from another environment.`, + }); + } + return; + } + + if (method !== 'GET' && method !== 'HEAD') { + res.setHeader('Allow', 'GET, HEAD, POST'); + sendError(res, 405, { + code: 'METHOD_NOT_ALLOWED', + error: `Method "${method}" not allowed. Use GET to inspect, POST to open.`, + }); + return; + } + + const info = await this.options.getInfo({ platform, runtime }); + res.statusCode = 200; + res.end(JSON.stringify(info)); + } +} + +function normalizePlatform(p: string | null): OpenPlatform | null { + return p === 'ios' || p === 'android' || p === 'web' ? p : null; +} + +function normalizeRequestedRuntime(r: string | undefined): OpenRequestedRuntime | null { + return r === 'default' || r === 'expo' || r === 'custom' || r === 'unknown' ? r : null; +} + +interface ErrorBody { + code: string; + error: string; + platform?: OpenPlatform; + details?: string; +} + +function sendError(res: ServerResponse, statusCode: number, body: ErrorBody) { + res.statusCode = statusCode; + res.end(JSON.stringify(body)); +} + +function assertSameDevice(req: ServerRequest): ErrorBody | null { + if (isLocalSocket(req.socket)) { + return null; + } + return { + code: 'REMOTE_DEVICE_FORBIDDEN', + error: 'POST /_expo/open is restricted to same-device requests.', + details: + `The dev server only opens the project for clients connected over the loopback interface ` + + `so a device on the LAN (or a tunnel client) can't launch the app on the developer's machine. ` + + `Issue the POST from the dev server's host, or use GET /_expo/open to retrieve the deep link and open it from the remote device.`, + }; +} + +function assertSameOrigin(req: ServerRequest, serverBaseUrl: string): ErrorBody | null { + if (isMatchingOrigin(req, serverBaseUrl)) { + return null; + } + const origin = firstHeader(req.headers?.origin) ?? 'unknown'; + return { + code: 'CROSS_ORIGIN_FORBIDDEN', + error: 'POST /_expo/open is restricted to same-origin requests.', + details: `Request origin "${origin}" does not match the dev server "${serverBaseUrl}". This protects the dev server from cross-origin scripts that might try to launch the app without the developer's consent. Issue POST requests from the dev server's origin (or from a non-browser client), or use GET /_expo/open to retrieve the deep link and open it yourself.`, + }; +} + +function firstHeader(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) return value[0]; + return value ?? undefined; +} diff --git a/packages/@expo/cli/src/start/server/middleware/RuntimeRedirectMiddleware.ts b/packages/@expo/cli/src/start/server/middleware/RuntimeRedirectMiddleware.ts index 13c53f7146850b..1a947a1e1d6f9a 100644 --- a/packages/@expo/cli/src/start/server/middleware/RuntimeRedirectMiddleware.ts +++ b/packages/@expo/cli/src/start/server/middleware/RuntimeRedirectMiddleware.ts @@ -15,6 +15,8 @@ const debug = require('debug')( 'expo:start:server:middleware:runtimeRedirect' ) as typeof console.log; +export const LinkEndpoint = '/_expo/link'; + /** Runtime to target: expo = Expo Go, custom = Dev Client. */ type RuntimeTarget = 'expo' | 'custom'; @@ -31,7 +33,7 @@ export class RuntimeRedirectMiddleware extends ExpoMiddleware { getLocation: (props: { runtime: RuntimeTarget }) => string | null | undefined; } ) { - super(projectRoot, ['/_expo/link']); + super(projectRoot, [LinkEndpoint]); } async handleRequestAsync(req: ServerRequest, res: ServerResponse): Promise { diff --git a/packages/@expo/cli/src/start/server/middleware/__tests__/OpenMiddleware-test.ts b/packages/@expo/cli/src/start/server/middleware/__tests__/OpenMiddleware-test.ts new file mode 100644 index 00000000000000..cd6f424c87977d --- /dev/null +++ b/packages/@expo/cli/src/start/server/middleware/__tests__/OpenMiddleware-test.ts @@ -0,0 +1,437 @@ +import { + OpenActionResult, + OpenHostSupportEntry, + OpenInfoResult, + OpenMiddleware, + OpenMiddlewareOptions, + OpenPlatformInfo, + OpenRequestedRuntime, + OpenSinglePlatformResult, +} from '../OpenMiddleware'; +import type { ServerRequest, ServerResponse } from '../server.types'; + +const localSocket = { + localAddress: '127.0.0.1', + remoteAddress: '127.0.0.1', + remoteFamily: 'IPv4', +} as unknown as ServerRequest['socket']; + +const remoteSocket = { + localAddress: '192.168.1.10', + remoteAddress: '192.168.1.42', + remoteFamily: 'IPv4', +} as unknown as ServerRequest['socket']; + +const asReq = (req: Partial) => ({ socket: localSocket, ...req }) as ServerRequest; + +const fullSupport: OpenHostSupportEntry = { canOpen: true }; +const iosBlocked: OpenHostSupportEntry = { + canOpen: false, + reason: 'iOS simulators require macOS with Xcode installed; this dev server is running on linux.', +}; + +function createMockResponse() { + return { + setHeader: jest.fn(), + end: jest.fn(), + statusCode: 0, + } as unknown as ServerResponse; +} + +function singleResult(overrides: Partial = {}): OpenSinglePlatformResult { + return { + runtime: 'expo', + url: 'exp://127.0.0.1:8081', + scheme: 'myapp', + availableRuntimes: ['expo', 'custom'], + appId: 'com.example.app', + ...overrides, + }; +} + +type GetInfoMock = jest.Mock< + Promise, + [{ platform: any; runtime: OpenRequestedRuntime }] +>; + +function createMiddleware(overrides: Partial = {}): { + middleware: OpenMiddleware; + getInfo: GetInfoMock; + open: jest.Mock, [{ platform: any }]>; + getHostSupport: jest.Mock; +} { + const getInfo = + (overrides.getInfo as GetInfoMock) ?? + (jest.fn(async ({ runtime }) => + singleResult({ + runtime: runtime === 'default' ? 'expo' : runtime, + }) + ) as GetInfoMock); + const open = + (overrides.open as jest.Mock, [{ platform: any }]>) ?? + jest.fn(async ({ platform }) => ({ platform, runtime: 'expo' as const, url: 'exp://opened' })); + const getHostSupport = + (overrides.getHostSupport as jest.Mock) ?? + jest.fn(() => fullSupport); + const middleware = new OpenMiddleware('/', { + serverBaseUrl: overrides.serverBaseUrl ?? 'http://localhost:8081', + getInfo, + open, + getHostSupport, + }); + return { middleware, getInfo, open, getHostSupport }; +} + +describe('shouldHandleRequest', () => { + const { middleware } = createMiddleware(); + + it('matches /_expo/open', () => { + expect(middleware.shouldHandleRequest(asReq({ url: 'http://localhost:8081/_expo/open' }))).toBe( + true + ); + expect( + middleware.shouldHandleRequest( + asReq({ url: 'http://localhost:8081/_expo/open?platform=ios' }) + ) + ).toBe(true); + }); + + it('rejects other paths', () => { + for (const url of [ + 'http://localhost:8081', + 'http://localhost:8081/', + 'http://localhost:8081/_expo/link', + ]) { + expect(middleware.shouldHandleRequest(asReq({ url }))).toBe(false); + } + }); +}); + +describe('GET /_expo/open with platform', () => { + it('returns focused single-platform info', async () => { + const { middleware, getInfo } = createMiddleware(); + const res = createMockResponse(); + + await middleware.handleRequestAsync( + asReq({ url: 'http://localhost:8081/_expo/open?platform=ios', method: 'GET', headers: {} }), + res + ); + + expect(getInfo).toHaveBeenCalledWith({ platform: 'ios', runtime: 'default' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse((res.end as jest.Mock).mock.calls[0][0]); + expect(body).toEqual({ + runtime: 'expo', + url: 'exp://127.0.0.1:8081', + scheme: 'myapp', + availableRuntimes: ['expo', 'custom'], + appId: 'com.example.app', + }); + }); + + it('runtime defaults to "default" when omitted', async () => { + const { middleware, getInfo } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ url: 'http://localhost:8081/_expo/open?platform=ios', method: 'GET', headers: {} }), + res + ); + expect(getInfo).toHaveBeenCalledWith({ platform: 'ios', runtime: 'default' }); + }); + + it('forwards an explicit runtime', async () => { + const { middleware, getInfo } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ + url: 'http://localhost:8081/_expo/open?platform=ios&runtime=custom', + method: 'GET', + headers: {}, + }), + res + ); + expect(getInfo).toHaveBeenCalledWith({ platform: 'ios', runtime: 'custom' }); + }); + + it('omits runtime when the URL is the disambiguation page', async () => { + const disambiguation: OpenPlatformInfo = { + url: 'http://127.0.0.1:8081/_expo/loading?platform=ios', + appId: 'com.example.app', + }; + const { middleware } = createMiddleware({ + getInfo: jest.fn(async () => ({ + scheme: 'myapp', + availableRuntimes: ['expo', 'custom'], + ...disambiguation, + })), + }); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ url: 'http://localhost:8081/_expo/open?platform=ios', method: 'GET', headers: {} }), + res + ); + const body = JSON.parse((res.end as jest.Mock).mock.calls[0][0]); + expect(body.runtime).toBeUndefined(); + expect(Object.hasOwn(body, 'runtime')).toBe(false); + expect(body.url).toMatch(/_expo\/loading/); + expect(body.availableRuntimes).toEqual(['expo', 'custom']); + }); + + it('400s on unsupported runtime', async () => { + const { middleware } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ + url: 'http://localhost:8081/_expo/open?platform=ios&runtime=bogus', + method: 'GET', + headers: {}, + }), + res + ); + expect(res.statusCode).toBe(400); + const body = JSON.parse((res.end as jest.Mock).mock.calls[0][0]); + expect(body.error).toMatch(/Must be "default", "expo", "custom", or "unknown"/); + }); + + it('400s on unsupported platform', async () => { + const { middleware } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ url: 'http://localhost:8081/_expo/open?platform=tv', method: 'GET', headers: {} }), + res + ); + expect(res.statusCode).toBe(400); + }); +}); + +describe('GET /_expo/open without platform (discovery)', () => { + it('returns a per-platform map plus project metadata', async () => { + const { middleware, getInfo } = createMiddleware({ + getInfo: jest.fn(async () => ({ + scheme: 'myapp', + availableRuntimes: ['expo', 'custom'], + platforms: { + // ios & android omit `runtime` because the URL is the disambiguation page + ios: { + url: 'http://127.0.0.1:8081/_expo/loading?platform=ios', + appId: 'com.example.ios', + }, + android: { + url: 'http://127.0.0.1:8081/_expo/loading?platform=android', + appId: 'com.example.android', + }, + web: { runtime: 'web', url: 'http://127.0.0.1:8081', appId: null }, + }, + })), + }); + const res = createMockResponse(); + + await middleware.handleRequestAsync( + asReq({ url: 'http://localhost:8081/_expo/open', method: 'GET', headers: {} }), + res + ); + + expect(getInfo).toHaveBeenCalledWith({ platform: null, runtime: 'default' }); + const body = JSON.parse((res.end as jest.Mock).mock.calls[0][0]); + expect(body.scheme).toBe('myapp'); + expect(body.availableRuntimes).toEqual(['expo', 'custom']); + expect(Object.keys(body.platforms)).toEqual(['ios', 'android', 'web']); + expect(body.platforms.ios.runtime).toBeUndefined(); + expect(body.platforms.web.runtime).toBe('web'); + }); +}); + +describe('POST /_expo/open', () => { + it('calls open() and returns the action result', async () => { + const { middleware, open } = createMiddleware(); + const res = createMockResponse(); + + await middleware.handleRequestAsync( + asReq({ + url: 'http://localhost:8081/_expo/open?platform=ios', + method: 'POST', + headers: { host: 'localhost:8081' }, + }), + res + ); + + expect(open).toHaveBeenCalledWith({ platform: 'ios' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse((res.end as jest.Mock).mock.calls[0][0]); + expect(body).toEqual({ platform: 'ios', runtime: 'expo', url: 'exp://opened' }); + }); + + it('400s when no platform is provided', async () => { + const { middleware, open } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ url: 'http://localhost:8081/_expo/open', method: 'POST', headers: {} }), + res + ); + expect(open).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(400); + }); +}); + +describe('POST same-device enforcement', () => { + it('403s POST from a non-loopback socket', async () => { + const { middleware, open } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ + url: 'http://localhost:8081/_expo/open?platform=ios', + method: 'POST', + headers: { host: 'localhost:8081' }, + socket: remoteSocket, + }), + res + ); + expect(open).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + const body = JSON.parse((res.end as jest.Mock).mock.calls[0][0]); + expect(body.code).toBe('REMOTE_DEVICE_FORBIDDEN'); + }); + + it('allows POST from an IPv6 loopback socket', async () => { + const { middleware, open } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ + url: 'http://localhost:8081/_expo/open?platform=ios', + method: 'POST', + headers: { host: 'localhost:8081' }, + socket: { + localAddress: '::1', + remoteAddress: '::1', + remoteFamily: 'IPv6', + } as unknown as ServerRequest['socket'], + }), + res + ); + expect(open).toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + }); + + it('does not enforce same-device on GET (LAN devices need to fetch deep links)', async () => { + const { middleware, getInfo } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ + url: 'http://localhost:8081/_expo/open?platform=ios', + method: 'GET', + headers: { host: '192.168.1.10:8081' }, + socket: remoteSocket, + }), + res + ); + expect(getInfo).toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + }); +}); + +describe('POST same-origin enforcement', () => { + it('allows POST when no Origin header is set', async () => { + const { middleware, open } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ + url: 'http://localhost:8081/_expo/open?platform=ios', + method: 'POST', + headers: { host: 'localhost:8081' }, + }), + res + ); + expect(open).toHaveBeenCalled(); + }); + + it('403s POST from a different origin', async () => { + const { middleware, open } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ + url: 'http://localhost:8081/_expo/open?platform=ios', + method: 'POST', + headers: { host: 'localhost:8081', origin: 'https://malicious.example.com' }, + }), + res + ); + expect(open).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + const body = JSON.parse((res.end as jest.Mock).mock.calls[0][0]); + expect(body.code).toBe('CROSS_ORIGIN_FORBIDDEN'); + }); + + it('does not enforce same-origin on GET (tunnels)', async () => { + const { middleware, getInfo } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ + url: 'http://localhost:8081/_expo/open?platform=ios', + method: 'GET', + headers: { host: 'localhost:8081', origin: 'https://my-tunnel.example.com' }, + }), + res + ); + expect(getInfo).toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + }); +}); + +describe('host platform support', () => { + it('501s POST when the host cannot open the platform', async () => { + const { middleware, open } = createMiddleware({ + getHostSupport: jest.fn((p) => (p === 'ios' ? iosBlocked : fullSupport)), + }); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ + url: 'http://localhost:8081/_expo/open?platform=ios', + method: 'POST', + headers: { host: 'localhost:8081' }, + }), + res + ); + expect(open).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(501); + const body = JSON.parse((res.end as jest.Mock).mock.calls[0][0]); + expect(body.code).toBe('HOST_CANNOT_OPEN_PLATFORM'); + expect(body.details).toMatch(/linux/); + expect(body.details).toMatch(/remote preview service/); + }); + + it('500s POST when open() throws, carrying the underlying code', async () => { + const { middleware } = createMiddleware({ + open: jest.fn(async () => { + const err: any = new Error('xcrun simctl is not available'); + err.code = 'SIMCTL'; + throw err; + }), + }); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ + url: 'http://localhost:8081/_expo/open?platform=android', + method: 'POST', + headers: { host: 'localhost:8081' }, + }), + res + ); + expect(res.statusCode).toBe(500); + const body = JSON.parse((res.end as jest.Mock).mock.calls[0][0]); + expect(body.code).toBe('SIMCTL'); + expect(body.details).toMatch(/xcrun simctl/); + }); +}); + +describe('unsupported methods', () => { + it('405s on PUT', async () => { + const { middleware } = createMiddleware(); + const res = createMockResponse(); + await middleware.handleRequestAsync( + asReq({ url: 'http://localhost:8081/_expo/open?platform=ios', method: 'PUT', headers: {} }), + res + ); + expect(res.statusCode).toBe(405); + expect(res.setHeader).toHaveBeenCalledWith('Allow', 'GET, HEAD, POST'); + }); +}); diff --git a/packages/@expo/cli/src/start/server/middleware/__tests__/openHandlers-test.ts b/packages/@expo/cli/src/start/server/middleware/__tests__/openHandlers-test.ts new file mode 100644 index 00000000000000..85f6b0883269e3 --- /dev/null +++ b/packages/@expo/cli/src/start/server/middleware/__tests__/openHandlers-test.ts @@ -0,0 +1,355 @@ +import { UrlCreator } from '../../UrlCreator'; +import { OpenDiscoveryResult, OpenSinglePlatformResult } from '../OpenMiddleware'; +import { createOpen, resolveOpenInfo } from '../openHandlers'; + +jest.mock('../../../../log'); + +const TUNNEL_URL = 'https://abc.ngrok-free.app'; +const LAN_ADDR = '192.168.7.42'; + +beforeEach(() => { + delete process.env.EXPO_PACKAGER_PROXY_URL; + delete process.env.REACT_NATIVE_PACKAGER_HOSTNAME; +}); + +function lanCreator(scheme: string | null = 'myapp') { + return new UrlCreator( + { scheme: scheme ?? undefined }, + { port: 8081, getTunnelUrl: () => null }, + { address: LAN_ADDR } + ); +} + +function tunnelCreator(scheme: string | null = 'myapp') { + return new UrlCreator( + { scheme: scheme ?? undefined, hostType: 'tunnel' }, + { port: 8081, getTunnelUrl: () => TUNNEL_URL }, + { address: LAN_ADDR } + ); +} + +const noAppId = async () => null; +const sampleAppIds = async (platform: 'ios' | 'android' | 'web') => { + if (platform === 'ios') return 'com.example.app.ios'; + if (platform === 'android') return 'com.example.app.android'; + return null; +}; + +describe('resolveOpenInfo — LAN (no tunnel)', () => { + const deps = { + urlCreator: lanCreator(), + getIsDevClient: () => false, + getIsRedirectPageEnabled: () => false, + getAppId: noAppId, + }; + + it('returns the LAN expo go deep link for ios', async () => { + const info = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + deps + )) as OpenSinglePlatformResult; + expect(info.runtime).toBe('expo'); + expect(info.url).toBe(`exp://${LAN_ADDR}:8081`); + expect(info.availableRuntimes).toEqual(['expo']); + }); + + it('returns the LAN host for web', async () => { + const info = (await resolveOpenInfo( + { platform: 'web', runtime: 'default' }, + deps + )) as OpenSinglePlatformResult; + expect(info.runtime).toBe('web'); + expect(info.url).toBe(`http://${LAN_ADDR}:8081`); + }); +}); + +describe('resolveOpenInfo — tunnel', () => { + const baseDeps = { + urlCreator: tunnelCreator('myapp'), + getIsDevClient: () => false, + getIsRedirectPageEnabled: () => false, + getAppId: noAppId, + }; + + it('routes expo go URLs through the tunnel host', async () => { + const info = (await resolveOpenInfo( + { platform: 'ios', runtime: 'expo' }, + baseDeps + )) as OpenSinglePlatformResult; + expect(info.url).toBe('exp://abc.ngrok-free.app'); + }); + + it('routes dev client URLs through the tunnel host (with https inner URL)', async () => { + const info = (await resolveOpenInfo( + { platform: 'android', runtime: 'custom' }, + baseDeps + )) as OpenSinglePlatformResult; + expect(info.url).toBe('myapp://expo-development-client/?url=https%3A%2F%2Fabc.ngrok-free.app'); + }); + + it('routes the disambiguation URL through the tunnel host and omits the runtime field', async () => { + const info = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + { ...baseDeps, getIsRedirectPageEnabled: () => true } + )) as OpenSinglePlatformResult; + expect(info.runtime).toBeUndefined(); + expect(info.url).toBe('http://abc.ngrok-free.app/_expo/loading?platform=ios'); + expect(info.availableRuntimes).toEqual(['expo', 'custom']); + }); + + it('returns the tunnel URL for web (regression: previously hardcoded localhost)', async () => { + const info = (await resolveOpenInfo( + { platform: 'web', runtime: 'default' }, + baseDeps + )) as OpenSinglePlatformResult; + expect(info.url).toBe('http://abc.ngrok-free.app'); + expect(info.url).not.toMatch(/localhost|127\.0\.0\.1/); + }); + + it('returns tunnel URLs for every platform in discovery mode', async () => { + const info = (await resolveOpenInfo( + { platform: null, runtime: 'default' }, + { ...baseDeps, getIsRedirectPageEnabled: () => true } + )) as OpenDiscoveryResult; + expect(info.platforms.ios.url).toBe('http://abc.ngrok-free.app/_expo/loading?platform=ios'); + expect(info.platforms.android.url).toBe( + 'http://abc.ngrok-free.app/_expo/loading?platform=android' + ); + expect(info.platforms.web.url).toBe('http://abc.ngrok-free.app'); + expect(info.platforms.ios.url).not.toMatch(/localhost|127\.0\.0\.1/); + expect(info.platforms.web.url).not.toMatch(/localhost|127\.0\.0\.1/); + }); +}); + +describe('resolveOpenInfo — runtime: default resolution', () => { + it('--dev-client → runtime "custom" with the dev client URL', async () => { + const info = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + { + urlCreator: lanCreator('myapp'), + getIsDevClient: () => true, + getIsRedirectPageEnabled: () => false, + getAppId: noAppId, + } + )) as OpenSinglePlatformResult; + expect(info.runtime).toBe('custom'); + expect(info.url).toMatch(/^myapp:\/\/expo-development-client\//); + expect(info.availableRuntimes).toEqual(['custom']); + }); + + it('project has both → runtime omitted with the disambiguation URL', async () => { + const info = (await resolveOpenInfo( + { platform: 'android', runtime: 'default' }, + { + urlCreator: lanCreator('myapp'), + getIsDevClient: () => false, + getIsRedirectPageEnabled: () => true, + getAppId: noAppId, + } + )) as OpenSinglePlatformResult; + expect(info.runtime).toBeUndefined(); + expect(info.url).toBe(`http://${LAN_ADDR}:8081/_expo/loading?platform=android`); + }); + + it('expo go only → runtime "expo" with the exp:// URL', async () => { + const info = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + { + urlCreator: lanCreator('myapp'), + getIsDevClient: () => false, + getIsRedirectPageEnabled: () => false, + getAppId: noAppId, + } + )) as OpenSinglePlatformResult; + expect(info.runtime).toBe('expo'); + expect(info.url).toBe(`exp://${LAN_ADDR}:8081`); + }); +}); + +describe('resolveOpenInfo — runtime=unknown (explicit interstitial)', () => { + it('returns the disambiguation URL with no runtime field, even when the CLI would resolve directly', async () => { + // Expo Go-only project: default would return runtime=expo; runtime=unknown still hands back the interstitial. + const info = (await resolveOpenInfo( + { platform: 'ios', runtime: 'unknown' }, + { + urlCreator: lanCreator('myapp'), + getIsDevClient: () => false, + getIsRedirectPageEnabled: () => false, + getAppId: noAppId, + } + )) as OpenSinglePlatformResult; + expect(info.runtime).toBeUndefined(); + expect(info.url).toBe(`http://${LAN_ADDR}:8081/_expo/loading?platform=ios`); + }); +}); + +describe('resolveOpenInfo — live state', () => { + it('reflects mid-run isDevClient changes (`s` in the terminal)', async () => { + let isDevClient = false; + const deps = { + urlCreator: lanCreator('myapp'), + getIsDevClient: () => isDevClient, + getIsRedirectPageEnabled: () => false, + getAppId: noAppId, + }; + const before = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + deps + )) as OpenSinglePlatformResult; + expect(before.runtime).toBe('expo'); + expect(before.availableRuntimes).toEqual(['expo']); + + isDevClient = true; + const after = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + deps + )) as OpenSinglePlatformResult; + expect(after.runtime).toBe('custom'); + expect(after.availableRuntimes).toEqual(['custom']); + }); + + it('reflects mid-run expo-dev-client installation (isRedirectPageEnabled flips)', async () => { + let redirectEnabled = false; + const deps = { + urlCreator: lanCreator('myapp'), + getIsDevClient: () => false, + getIsRedirectPageEnabled: () => redirectEnabled, + getAppId: noAppId, + }; + const before = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + deps + )) as OpenSinglePlatformResult; + expect(before.availableRuntimes).toEqual(['expo']); + expect(before.runtime).toBe('expo'); + + redirectEnabled = true; + const after = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + deps + )) as OpenSinglePlatformResult; + expect(after.availableRuntimes).toEqual(['expo', 'custom']); + expect(after.runtime).toBeUndefined(); // falls through to the disambiguation page + expect(after.url).toMatch(/_expo\/loading/); + }); + + it('reflects mid-run scheme changes (UrlCreator.defaults is mutated by toggleRuntimeMode)', async () => { + const urlCreator = lanCreator('oldscheme'); + const deps = { + urlCreator, + getIsDevClient: () => false, + getIsRedirectPageEnabled: () => false, + getAppId: noAppId, + }; + const before = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + deps + )) as OpenSinglePlatformResult; + expect(before.scheme).toBe('oldscheme'); + + urlCreator.defaults.scheme = 'newscheme'; + const after = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + deps + )) as OpenSinglePlatformResult; + expect(after.scheme).toBe('newscheme'); + }); +}); + +describe('resolveOpenInfo — appId', () => { + const baseDeps = { + urlCreator: lanCreator('myapp'), + getIsDevClient: () => false, + getIsRedirectPageEnabled: () => false, + }; + + it('includes the resolved appId on single-platform responses', async () => { + const info = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + { ...baseDeps, getAppId: sampleAppIds } + )) as OpenSinglePlatformResult; + expect(info.appId).toBe('com.example.app.ios'); + }); + + it('per-platform appIds in discovery mode (web is always null)', async () => { + const info = (await resolveOpenInfo( + { platform: null, runtime: 'default' }, + { ...baseDeps, getAppId: sampleAppIds } + )) as OpenDiscoveryResult; + expect(info.platforms.ios.appId).toBe('com.example.app.ios'); + expect(info.platforms.android.appId).toBe('com.example.app.android'); + expect(info.platforms.web.appId).toBeNull(); + }); + + it('null when the project has no bundle identifier / package name', async () => { + const info = (await resolveOpenInfo( + { platform: 'ios', runtime: 'default' }, + { ...baseDeps, getAppId: noAppId } + )) as OpenSinglePlatformResult; + expect(info.appId).toBeNull(); + }); + + it('resolves appIds in parallel during discovery', async () => { + const order: string[] = []; + let resolveIos: () => void = () => {}; + let resolveAndroid: () => void = () => {}; + const iosStarted = new Promise((r) => (resolveIos = r)); + const androidStarted = new Promise((r) => (resolveAndroid = r)); + const getAppId = jest.fn(async (platform: 'ios' | 'android' | 'web') => { + if (platform === 'web') return null; + order.push(platform); + // Mark this platform as started, then wait for the other to start too. If resolution is + // serial this deadlocks (the second never starts because the first never resolves). + if (platform === 'ios') { + resolveIos(); + await androidStarted; + } else { + resolveAndroid(); + await iosStarted; + } + return `com.example.${platform}`; + }); + const info = (await resolveOpenInfo( + { platform: null, runtime: 'default' }, + { ...baseDeps, getAppId } + )) as OpenDiscoveryResult; + expect(getAppId).toHaveBeenCalledTimes(3); + expect(order).toEqual(['ios', 'android']); // both started before either completed + expect(info.platforms.ios.appId).toBe('com.example.ios'); + expect(info.platforms.android.appId).toBe('com.example.android'); + }); +}); + +describe('createOpen', () => { + it('dispatches platform=ios to openPlatformAsync("simulator")', async () => { + const openPlatformAsync = jest.fn(async () => ({ url: 'exp://opened-ios' })); + const open = createOpen({ getIsDevClient: () => false, openPlatformAsync }); + await expect(open({ platform: 'ios' })).resolves.toEqual({ + platform: 'ios', + runtime: 'expo', + url: 'exp://opened-ios', + }); + expect(openPlatformAsync).toHaveBeenCalledWith('simulator', { shouldPrompt: false }); + }); + + it('dispatches platform=android to openPlatformAsync("emulator")', async () => { + const openPlatformAsync = jest.fn(async () => ({ url: 'exp://opened-android' })); + const open = createOpen({ getIsDevClient: () => true, openPlatformAsync }); + const result = await open({ platform: 'android' }); + expect(openPlatformAsync).toHaveBeenCalledWith('emulator', { shouldPrompt: false }); + // isDevClient → response runtime reflects 'custom' + expect(result.runtime).toBe('custom'); + }); + + it('dispatches platform=web to openPlatformAsync("desktop")', async () => { + const openPlatformAsync = jest.fn(async () => ({ url: 'http://abc.ngrok-free.app' })); + const open = createOpen({ getIsDevClient: () => false, openPlatformAsync }); + const result = await open({ platform: 'web' }); + expect(openPlatformAsync).toHaveBeenCalledWith('desktop'); + expect(result).toEqual({ + platform: 'web', + runtime: 'web', + url: 'http://abc.ngrok-free.app', + }); + }); +}); diff --git a/packages/@expo/cli/src/start/server/middleware/openHandlers.ts b/packages/@expo/cli/src/start/server/middleware/openHandlers.ts new file mode 100644 index 00000000000000..338bc7b9031086 --- /dev/null +++ b/packages/@expo/cli/src/start/server/middleware/openHandlers.ts @@ -0,0 +1,149 @@ +import type { + OpenActionResult, + OpenInfoResult, + OpenMiddlewareOptions, + OpenNativeRuntime, + OpenPlatform, + OpenPlatformInfo, + OpenRequestedRuntime, +} from './OpenMiddleware'; +import type { UrlCreator } from '../UrlCreator'; + +interface InfoHandlerDeps { + /** Stable UrlCreator instance — its `defaults` mutate when `toggleRuntimeMode` runs, so the same instance keeps producing fresh URLs and reflects the current scheme. */ + urlCreator: UrlCreator; + /** + * Read live values every call. The dev server's runtime mode can flip mid-run via the `s` key + * in the terminal, and `expo-dev-client` can be installed while the server is running — both + * change `isDevClient` and `isRedirectPageEnabled`, and the endpoint should reflect the + * current state on every request. + */ + getIsDevClient: () => boolean; + /** Live mirror of `BundlerDevServer.isRedirectPageEnabled()`. */ + getIsRedirectPageEnabled: () => boolean; + /** + * Resolve the native application identifier for a platform (iOS bundle id / Android package + * name). Implementations should return `null` instead of throwing when the project has no + * configured identifier; the endpoint surfaces `null` so distributed preview systems can detect + * that the build can't be matched by app id and either bail out or prompt the user. + */ + getAppId: (platform: OpenPlatform) => Promise; +} + +/** + * Build the GET handler for `/_expo/open`. Resolves dry-run info for a single platform, or for + * every platform in discovery mode. Extracted so it can be exercised with a real + * {@link UrlCreator} in tests (covers tunnel routing in particular). + */ +export function createInfoHandler(deps: InfoHandlerDeps): OpenMiddlewareOptions['getInfo'] { + return ({ platform, runtime }) => resolveOpenInfo({ platform, runtime }, deps); +} + +export async function resolveOpenInfo( + { platform, runtime }: { platform: OpenPlatform | null; runtime: OpenRequestedRuntime }, + deps: InfoHandlerDeps +): Promise { + // Snapshot the live state once per request so the response is internally consistent even if a + // toggle happens between sub-resolutions. + const scheme = deps.urlCreator.getScheme(); + const isDevClient = deps.getIsDevClient(); + const isRedirectPageEnabled = deps.getIsRedirectPageEnabled(); + const availableRuntimes: OpenNativeRuntime[] = isDevClient + ? ['custom'] + : isRedirectPageEnabled + ? ['expo', 'custom'] + : ['expo']; + + if (platform) { + return { + scheme, + availableRuntimes, + ...(await resolvePlatformInfo(platform, runtime, deps, { + isDevClient, + isRedirectPageEnabled, + })), + }; + } + + const [ios, android, web] = await Promise.all([ + resolvePlatformInfo('ios', runtime, deps, { isDevClient, isRedirectPageEnabled }), + resolvePlatformInfo('android', runtime, deps, { isDevClient, isRedirectPageEnabled }), + resolvePlatformInfo('web', runtime, deps, { isDevClient, isRedirectPageEnabled }), + ]); + return { scheme, availableRuntimes, platforms: { ios, android, web } }; +} + +async function resolvePlatformInfo( + platform: OpenPlatform, + runtime: OpenRequestedRuntime, + deps: InfoHandlerDeps, + state: { isDevClient: boolean; isRedirectPageEnabled: boolean } +): Promise { + const { urlCreator, getAppId } = deps; + const { isDevClient, isRedirectPageEnabled } = state; + const appId = await getAppId(platform); + + if (platform === 'web') { + // constructUrl inherits the tunnel host from `defaults.hostType` when --tunnel is active, + // so this returns the ngrok URL instead of localhost in that case. + return { runtime: 'web', url: urlCreator.constructUrl({ scheme: 'http' }), appId }; + } + + // Caller explicitly wants the disambiguation page — useful when they want the device (not the + // dev server) to pick between Expo Go and the dev build. No `runtime` field on the response + // since the actual runtime depends on the device's choice. + if (runtime === 'unknown') { + return { url: urlCreator.constructLoadingUrl({}, platform), appId }; + } + + // `runtime: 'default'` mirrors what pressing `i` / `a` does in the terminal: + // --dev-client server → open the dev client directly. + // project has both → hand off to the disambiguation interstitial so the + // device resolves between Expo Go and the dev build. + // else → open Expo Go directly. + if (runtime === 'default') { + if (isDevClient) { + return { runtime: 'custom', url: urlCreator.constructDevClientUrl(), appId }; + } + if (isRedirectPageEnabled) { + return { url: urlCreator.constructLoadingUrl({}, platform), appId }; + } + return { runtime: 'expo', url: urlCreator.constructUrl({ scheme: 'exp' }), appId }; + } + + return { + runtime, + url: + runtime === 'custom' + ? urlCreator.constructDevClientUrl() + : urlCreator.constructUrl({ scheme: 'exp' }), + appId, + }; +} + +interface OpenHandlerDeps { + /** Live `BundlerDevServer.isDevClient` — `s` in the terminal can flip this between dispatch and response. */ + getIsDevClient: () => boolean; + /** Same shape as `BundlerDevServer.openPlatformAsync`. */ + openPlatformAsync: ( + launchTarget: 'simulator' | 'emulator' | 'desktop', + resolver?: { shouldPrompt?: boolean } + ) => Promise<{ url: string | null }>; +} + +/** Build the POST handler for `/_expo/open` — dispatches to the dev server's platform launcher. */ +export function createOpen(deps: OpenHandlerDeps): OpenMiddlewareOptions['open'] { + return async ({ platform }): Promise => { + if (platform === 'web') { + const result = await deps.openPlatformAsync('desktop'); + return { platform, runtime: 'web', url: result.url ?? '' }; + } + const launchTarget = platform === 'ios' ? 'simulator' : 'emulator'; + const result = await deps.openPlatformAsync(launchTarget, { shouldPrompt: false }); + return { + platform, + runtime: deps.getIsDevClient() ? 'custom' : 'expo', + url: result.url ?? '', + }; + }; +} diff --git a/packages/@expo/cli/src/utils/__tests__/net-test.ts b/packages/@expo/cli/src/utils/__tests__/net-test.ts index 8636600c32a650..bdbb450a0cca6a 100644 --- a/packages/@expo/cli/src/utils/__tests__/net-test.ts +++ b/packages/@expo/cli/src/utils/__tests__/net-test.ts @@ -54,4 +54,10 @@ describe(isMatchingOrigin, () => { false ); }); + + it('treats a malformed Origin header as untrusted', () => { + expect(isMatchingOrigin({ headers: { origin: 'not-a-url' } }, 'http://127.0.0.1:8181')).toBe( + false + ); + }); }); diff --git a/packages/@expo/cli/src/utils/net.ts b/packages/@expo/cli/src/utils/net.ts index 406cdd735bca3a..7cff63223f5fd6 100644 --- a/packages/@expo/cli/src/utils/net.ts +++ b/packages/@expo/cli/src/utils/net.ts @@ -32,7 +32,13 @@ export const isMatchingOrigin = ( if (!request.headers.origin) { return true; } - const actualHost = new URL(`${request.headers.origin}`).host; + let actualHost: string; + try { + actualHost = new URL(`${request.headers.origin}`).host; + } catch { + // Malformed Origin — treat as untrusted. + return false; + } const expectedHost = new URL(serverBaseUrl).host; return actualHost === expectedHost; };