Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@expo/agent-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

### 🐛 Bug fixes

- The entry-bundle check that runs before `runtime:tree`, `runtime:tap`, `runtime:type`, `runtime:reload` and `smoke` sends its HEAD to the dev server it was given, never to the tunnel host the manifest names. Through the Expo tunnel that HEAD closed every websocket on the tunnel, the app's debugger socket too, so on a dev server started with `--tunnel` (every EAS Simulator session) every runtime read answered "No target found". ([#197](https://github.com/expo/expo-agent-cli/pull/197) by [@vonovak](https://github.com/vonovak))
- `dev --detach --wait-ready` rejects another project's Metro and follows its own child's port changes within the original timeout. The same readiness check applies when reusing a running server.
- Discovery skips a dev server whose `/status` names another project root, on the scanned ports and on the port `start.log` names, and reports the foreign servers in `status` when nothing else answered. A `/status` that does not answer within 3 s is not accepted. The dev-server lock never publishes a port that serves another project, named on the command line or not. ([#132](https://github.com/expo/expo-agent-cli/pull/132) by [@vonovak](https://github.com/vonovak))
- `status --explain`, the `--eas` device commands, and a failed `eas` step of `dev` no longer quote the first line of the EAS CLI's "project not configured" message with the `eas init` forms cut off. They say the project is not linked to an EAS project and name `npx --yes eas-cli@latest init --account <account> --non-interactive`, with the account filled in when EAS listed one — the answer `deploy` already gave. An unlinked project is its own needs-human scenario, `eas-project-unlinked` (`EAS_PROJECT_NOT_LINKED`), where the generic `eas-prompt` row used to answer for it and tell the caller to answer a question in a terminal. ([#38](https://github.com/expo/expo-agent-cli/pull/38) by [@kudo](https://github.com/kudo))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,10 @@ describeLive('live-cloud', gate)('live-cloud: an EAS Simulator session, on expo-
'dev-server-bundle',
]).toContain(report.verifiedBy);
expect(report.bundle.ok).toBe(true);
expect(report.bundle.url).toContain(publicHost);
// The check stays on the local dev server and never touches the tunnel (llp/0010 §Other gates).
// The public origin is proven by the `<origin>/status` curl above and, in expo-go mode, by
// `navigate`; in dev-build mode the curl is the only proof.
expect(report.bundle.url).toContain(`http://127.0.0.1:${port}/`);

// The two lists, reported side by side because they can disagree here (llp/0005 §What proves a reload
// question). `commandSocketClients` has to be a number rather than null: "nobody asked" and "nobody
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,9 @@ describe('@expo/agent-cli runtime:reload --eas', () => {
// Watched and not seen: this project has a captured dev server log, and the stub never
// writes a `Bundled` line into it.
expect(report.bundlesAfterReload.observed).toBe(false);
// The manifest names a tunnel host; the bundle check asks this stub, so it compiles here.
expect(report.bundle).toMatchObject({ checked: true, ok: true });
expect(new URL(report.bundle.url).origin).toBe(new URL(stub.url).origin);
expect(result.exitCode).toBe(22);

const execs = easInvocations(projectRoot).filter((argv) => argv[0] === 'simulator:exec');
Expand Down
2 changes: 1 addition & 1 deletion packages/@expo/agent-cli/llp/0010-agent-conventions.rfc.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ A command that reports on the app does not gate on what it reported. `runtime:er

`runtime:stop` and `dev:stop`: already in the asked-for state is success, exit 0. `wasRunning` and `reason: "not-running"` keep the fact itself readable. A device tool that refused is 1. A foreign dev server is 20.

The entry-bundle check (`src/runtime/bundleCheck.ts`) is what `smoke`, `runtime:reload`, and `dev --detach --wait-ready` call. A broken bundle is 20. A cold first build that does not finish inside `--timeout` is 22. `unknown` is not `broken`. `bundle.checked` is exactly `bundle.ok != null`. The `dev:wait` command itself is deferred ([[0017-deferred-commands]]).
The entry-bundle check (`src/runtime/bundleCheck.ts`) is what `smoke`, `runtime:reload`, `runtime:tree`, `runtime:tap` and `runtime:type` call. A broken bundle is 20. A cold first build that does not finish inside `--timeout` is 22. `unknown` is not `broken`. `bundle.checked` is exactly `bundle.ok != null`. The check sends its HEAD to the dev server it was given, the one of [[0005-runtime-loop-tools]] §Where a device reaches the dev server, with the path and query the manifest or the web page names and never their host. `bundle.url` is that URL. The reason: a `--tunnel` manifest names the tunnel host, and a HEAD of the bundle through the Expo tunnel closes every websocket on that tunnel, the app's debugger socket too [observed — 2026-10-27, `expo start --tunnel`, `@expo/ws-tunnel` 2.0.1]. A GET that is cancelled after its headers is the same abort, so it is not an alternative. Tests: `cloud-simulator-test` "restarts the app and then deep-links the route, and never closes it"; `bundleCheck-test` "should send the HEAD to the dev server given a launch asset on the tunnel host" and "should send the HEAD to the dev server when the page names another host"; `live-cloud-test` "runtime:reload --eas reloads the app and proves it, climbing to the rung that works" (`bundle.url` is on the local port). The `dev:wait` command itself is deferred ([[0017-deferred-commands]]).

## Needs-human protocol

Expand Down
4 changes: 3 additions & 1 deletion packages/@expo/agent-cli/llp/0022-live-tier.plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,9 @@ The dev server's own tunnel is the public origin — v2. `expo start --tunnel` v
the same flag uses `@expo/ws-tunnel` on the Expo account and works [verified live —
2026-09-05]. So the suite starts `dev --tunnel`, reads the advertised origin back through
the CLI, and curls `<origin>/status` before it starts anything that bills — the tunnel is
tested surface now, not harness plumbing. `AGENT_CLI_LIVE_PUBLIC_ORIGIN` keeps the proxy
tested surface now, not harness plumbing. The entry-bundle check is not part of that surface:
it stays on the local dev server ([[0010-agent-conventions]] §Other gates), and the suite
asserts that. `AGENT_CLI_LIVE_PUBLIC_ORIGIN` keeps the proxy
path (`EXPO_PACKAGER_PROXY_URL`) as the hatch, because the v2 flag is `UNSTABLE`-prefixed.

A cloud reload takes whichever rung works, and which one that is turns out to be the
Expand Down
45 changes: 35 additions & 10 deletions packages/@expo/agent-cli/src/runtime/__tests__/bundleCheck-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,22 @@ describe(checkEntryBundleAsync, () => {
expect(calls[1]).toMatchObject({ method: 'HEAD', url: WEB_BUNDLE_URL });
});

// A page that names a host is sent to the dev server too, the way a tunnel manifest is.
it(`should send the HEAD to the dev server when the page names another host`, async () => {
const elsewhere = WEB_PAGE.replace('src="/', 'src="https://c-abc.on.expo.app/');
mockFetch({
'http://127.0.0.1:8123/': { text: elsewhere },
[`HEAD ${WEB_BUNDLE_URL}`]: { status: 200 },
});

const result = await checkEntryBundleAsync(devServerUrl, {
platform: 'web',
timeoutMs: 5000,
});

expect(result).toMatchObject({ outcome: 'ok', url: WEB_BUNDLE_URL });
});

it(`should report the same TransformError a native check reports`, async () => {
mockFetch({
'http://127.0.0.1:8123/': { text: WEB_PAGE },
Expand Down Expand Up @@ -530,20 +546,29 @@ describe(checkEntryBundleAsync, () => {
expect(result.reason).toContain('50ms');
});

// The dev server answers with a path-relative URL when the request carried a `Forwarded` header.
it(`should resolve a relative launch asset against the dev server`, async () => {
const calls = mockFetch({
'http://127.0.0.1:8123/': {
json: { launchAsset: { url: 'index.bundle?platform=ios&dev=true' } },
},
'HEAD http://127.0.0.1:8123/index.bundle?platform=ios&dev=true': { status: 200 },
// The dev server answers with a path-relative URL when the request carried a `Forwarded` header,
// and a --tunnel dev server names its tunnel host. The HEAD goes to the dev server either way.
it.each([
[
'a relative launch asset',
'index.bundle?platform=ios&dev=true',
'http://127.0.0.1:8123/index.bundle?platform=ios&dev=true',
],
[
'a launch asset on the tunnel host',
'https://c-abc.on.expo.app/index.bundle?platform=ios&dev=true',
'http://127.0.0.1:8123/index.bundle?platform=ios&dev=true',
],
])(`should send the HEAD to the dev server given %s`, async (_name, launchAsset, expectedUrl) => {
// Any other URL throws in mockFetch, so `ok` is the proof that the HEAD went to the expected one.
mockFetch({
'http://127.0.0.1:8123/': { json: { launchAsset: { url: launchAsset } } },
[`HEAD ${expectedUrl}`]: { status: 200 },
});

const result = await checkEntryBundleAsync(devServerUrl, { platform: 'ios', timeoutMs: 5000 });

expect(result.outcome).toBe('ok');
expect(result.url).toBe('http://127.0.0.1:8123/index.bundle?platform=ios&dev=true');
expect(calls[1]!.url).toBe('http://127.0.0.1:8123/index.bundle?platform=ios&dev=true');
expect(result).toMatchObject({ outcome: 'ok', url: expectedUrl });
});

it(`should treat a not-modified answer as a bundle that compiles`, async () => {
Expand Down
45 changes: 34 additions & 11 deletions packages/@expo/agent-cli/src/runtime/bundleCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@
// the *source* of the URL differs by platform and everything after it does not. Asking for JSON
// and reading the HTML answer as a parse failure is what left `--platform web` reporting
// `checked: true, ok: null` with a `SyntaxError` for a reason [observed — friction run 2].
// 2. A **HEAD** of that exact URL. HEAD builds the bundle and reports the real status without
// sending the 8 MB body [observed — Metro treats it as a normal request and only annotates it
// as a prefetch]; the body is only fetched when the status says something went wrong. The URL
// is used byte for byte, because every query parameter is part of Metro's graph id and changing
// one would compile a second graph rather than read the one the app uses.
// 2. A **HEAD** of the path and query of that URL, on the dev server it was given. HEAD builds
// the bundle and reports the real status without sending the 8 MB body [observed — Metro
// treats it as a normal request and only annotates it as a prefetch]; the body is only fetched
// when the status says something went wrong. The path and query are used byte for byte,
// because every query parameter is part of Metro's graph id and changing one would compile a
// second graph rather than read the one the app uses. The host the manifest or the page names
// is never used: behind `--tunnel` it is the tunnel host, and a HEAD of the bundle through the
// Expo tunnel closes every websocket on that tunnel [observed — 2026-10-27].

import { stripVTControlCharacters } from 'util';

Expand Down Expand Up @@ -213,23 +216,24 @@ export async function checkEntryBundleAsync(
if (entry.url == null) {
return finish({ outcome: 'unknown', url: null, error: null, reason: entry.reason });
}
const url = toDevServerUrl(entry.url, origin);

// HEAD, so a bundle that compiles costs a status line instead of megabytes of JavaScript.
const head = await fetch(entry.url, {
const head = await fetch(url, {
method: 'HEAD',
signal: controller.signal,
headers: { connection: 'close' },
});
if (head.ok || head.status === 304) {
return finish({ outcome: 'ok', url: entry.url, error: null });
return finish({ outcome: 'ok', url, error: null });
}

// Only now is the body worth having, and it is the small one: Metro answers a failed build
// with a JSON object, not with a bundle.
const body = await fetch(entry.url, { signal: controller.signal });
const body = await fetch(url, { signal: controller.signal });
return finish({
outcome: 'broken',
url: entry.url,
url,
error: await readBundleErrorAsync(body),
});
} catch (error: unknown) {
Expand All @@ -252,6 +256,21 @@ export async function checkEntryBundleAsync(
}
}

/**
* The same path and query, on the dev server the check was given.
*
* The host a manifest or a page names is never used, a different port on the same host included:
* Metro keys the bundle on the path and query, never on the host. Why the tunnel host in particular
* must not be used is in the header comment, item 2.
*/
function toDevServerUrl(bundleUrl: string, origin: string): string {
const named = new URL(bundleUrl);
const url = new URL(`${origin}/`);
url.pathname = named.pathname;
url.search = named.search;
return url.toString();
}

/** Where the entry bundle URL of one platform comes from, or why it could not be read. */
interface EntryBundleUrl {
url: string | null;
Expand All @@ -271,7 +290,7 @@ interface EntryBundleUrl {
* The entry bundle URL this dev server would hand the app, for one platform.
*
* Two documents answer this and the platform decides which: native reads the manifest, web reads
* the page. Both end in the same place — one URL, used byte for byte.
* the page. Both end in the same place — one URL.
*/
function readEntryBundleUrlAsync(
origin: string,
Expand Down Expand Up @@ -592,7 +611,11 @@ export interface BundleCheckJson {
ok: boolean | null;
/** Platform the bundle was built for, or null when nothing was built. */
platform: BundleCheckPlatform | null;
/** Entry bundle URL that was fetched, resolved from the dev server's own manifest. */
/**
* Entry bundle URL that was fetched: the path and query the dev server's own manifest (or web
* page) names, on the dev server the check was given. Behind a tunnel it is the local URL, not
* the public one the manifest names.
*/
url: string | null;
/** What the bundler reported. Present exactly when `ok` is false. */
error: BundleCheckError | null;
Expand Down