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
50 changes: 50 additions & 0 deletions apps/cli/__tests__/preview-expo-web.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
expoWebDepsInstall,
expoWebInstallSpecs,
missingExpoWebPackages,
} from '../src/services/preview/expo';

function project(installed: string[], pins?: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), 'codeam-expo-web-'));
for (const name of ['expo', ...installed]) {
mkdirSync(join(dir, 'node_modules', name), { recursive: true });
writeFileSync(join(dir, 'node_modules', name, 'package.json'), '{}');
}
if (pins) writeFileSync(join(dir, 'node_modules', 'expo', 'bundledNativeModules.json'), JSON.stringify(pins));
return dir;
}

describe('the Expo WEB preview installs what Metro needs to serve a browser (owner 2026-10-31)', () => {
it('a project without the web packages gets them at the SDK’s versions, --no-save', () => {
const dir = project([], { 'react-native-web': '~0.21.0', 'react-dom': '19.1.0', '@expo/metro-runtime': '~6.1.2' });
expect(missingExpoWebPackages(dir)).toEqual(['react-native-web', 'react-dom', '@expo/metro-runtime']);
expect(expoWebDepsInstall(dir)).toEqual({
cmd: 'npm',
args: [
'install',
'--no-save',
'--legacy-peer-deps',
'--no-audit',
'--no-fund',
'react-native-web@~0.21.0',
'[email protected]',
'@expo/metro-runtime@~6.1.2',
],
});
});

it('only what is missing; an SDK without pins gives bare names', () => {
const dir = project(['react-dom']);
expect(missingExpoWebPackages(dir)).toEqual(['react-native-web', '@expo/metro-runtime']);
expect(expoWebInstallSpecs(dir, ['react-native-web'])).toEqual(['react-native-web']);
});

it('everything present: no install', () => {
const dir = project(['react-native-web', 'react-dom', '@expo/metro-runtime']);
expect(expoWebDepsInstall(dir)).toBeNull();
});
});
73 changes: 69 additions & 4 deletions apps/cli/__tests__/preview/expo-own-tunnel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ beforeEach(() => {
process.env.CODEAM_PREVIEW_INSPECTOR = '0';
vi.spyOn(process, 'cwd').mockReturnValue('/tmp/fake-expo');
vi.spyOn(preview, 'detectMissingNodeDeps').mockReturnValue(null);
// The fake project has the web packages: the bring-up installs nothing (see the web tests below).
vi.spyOn(preview, 'expoWebDepsInstall').mockReturnValue(null);
vi.spyOn(preview, 'isPortListening').mockResolvedValue(false);
vi.spyOn(preview, 'resolveCloudflared').mockResolvedValue('/fake/cloudflared');
vi.spyOn(preview, 'awaitTunnelRegistered').mockResolvedValue({
Expand All @@ -120,7 +122,7 @@ afterEach(() => {
});

describe('Expo preview over our own cloudflared tunnel', () => {
it('brings the tunnel up FIRST, spawns Expo without --tunnel + with the proxy URL, and publishes exps://', async () => {
it('brings the tunnel up FIRST, spawns Expo without --tunnel + with the proxy URL, and publishes the web URL + exps://', async () => {
const { events, emit } = collectEvents();
await runPreviewStart({
sessionId: 'sess-expo',
Expand Down Expand Up @@ -148,13 +150,20 @@ describe('Expo preview over our own cloudflared tunnel', () => {
expect(expo.env.DEBUG ?? '').not.toContain('expo:start:server:ngrok');

const ready = events.find((e) => e.type.endsWith('preview_ready'));
expect(ready?.payload).toEqual({ url: 'exps://host.example', framework: 'Expo', port: 8081 });
// The web build in the frame, Expo Go beside it (owner 2026-10-31).
expect(ready?.payload).toEqual({
url: 'https://host.example',
framework: 'Expo',
port: 8081,
expoGoUrl: 'exps://host.example',
});

// The tunnel is registered on the preview so `killPreview` tears it down.
const active = preview.activePreviews.get('sess-expo');
expect(active?.tunnel).toBe(tunnel.child);
expect(active?.devServer).toBe(expo.child);
expect(active?.url).toBe('exps://host.example');
expect(active?.url).toBe('https://host.example');
expect(active?.expoGoUrl).toBe('exps://host.example');
}, 30_000);

it('goes READY on the local manifest probe when Expo prints nothing recognisable', async () => {
Expand All @@ -176,7 +185,63 @@ describe('Expo preview over our own cloudflared tunnel', () => {
expect.objectContaining({ timeoutMs: expect.any(Number) }),
);
const ready = events.find((e) => e.type.endsWith('preview_ready'));
expect(ready?.payload.url).toBe('exps://host.example');
expect(ready?.payload.url).toBe('https://host.example');
}, 30_000);

it('without the web packages: the install runs first (--no-save) and the web URL is the preview', async () => {
devServerScript = `
process.stdout.write('Starting Metro Bundler\\n');
setInterval(() => {}, 1000);`;
vi.spyOn(preview, 'waitForExpoManifest').mockResolvedValue(true);
vi.mocked(preview.expoWebDepsInstall).mockReturnValue({
cmd: 'npm',
args: ['install', '--no-save', 'react-native-web@~0.21.0'],
});
const install = vi.spyOn(preview, 'runSetupCommand').mockResolvedValue({ status: 'ok', code: 0 });

const { events, emit } = collectEvents();
await runPreviewStart({
sessionId: 'sess-expo-web',
detection: expoDetection,
cwd: '/tmp/fake-expo',
emit: emit as never,
});

expect(install.mock.calls[0]?.slice(0, 3)).toEqual([
'npm',
['install', '--no-save', 'react-native-web@~0.21.0'],
'/tmp/fake-expo',
]);
const ready = events.find((e) => e.type.endsWith('preview_ready'));
expect(ready?.payload).toEqual({
url: 'https://host.example',
framework: 'Expo',
port: 8081,
expoGoUrl: 'exps://host.example',
});
}, 30_000);

it('without the web packages: a failed install keeps the Expo Go link alone (the QR surface, as before)', async () => {
devServerScript = `
process.stdout.write('Starting Metro Bundler\\n');
setInterval(() => {}, 1000);`;
vi.spyOn(preview, 'waitForExpoManifest').mockResolvedValue(true);
vi.mocked(preview.expoWebDepsInstall).mockReturnValue({
cmd: 'npm',
args: ['install', '--no-save', 'react-native-web'],
});
vi.spyOn(preview, 'runSetupCommand').mockResolvedValue({ status: 'failed', code: 1 });

const { events, emit } = collectEvents();
await runPreviewStart({
sessionId: 'sess-expo-noweb',
detection: expoDetection,
cwd: '/tmp/fake-expo',
emit: emit as never,
});

const ready = events.find((e) => e.type.endsWith('preview_ready'));
expect(ready?.payload).toEqual({ url: 'exps://host.example', framework: 'Expo', port: 8081 });
}, 30_000);

it('tears the tunnel down when Expo dies before it is ready', async () => {
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/start/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2215,6 +2215,7 @@ export function makePreviewReaffirm(args: {
url: active.url,
framework: active.framework,
port: active.detection.port,
...(active.expoGoUrl ? { expoGoUrl: active.expoGoUrl } : {}),
};
},
emitReady: (payload) => {
Expand Down
53 changes: 53 additions & 0 deletions apps/cli/src/services/preview/expo.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';

Expand Down Expand Up @@ -116,3 +117,55 @@ export async function waitForExpoManifest(
await new Promise((r) => setTimeout(r, interval));
}
}

/**
* The packages `expo start` needs to serve the app on the WEB: `@expo/config` turns the web
* platform on when `react-dom` resolves from the project, and Metro then answers a plain
* browser `GET /` with the web build (owner 2026-10-31: the console Preview shows the app, not
* a QR; the QR stays as "Open in Expo Go").
*/
export const EXPO_WEB_PACKAGES = ['react-native-web', 'react-dom', '@expo/metro-runtime'] as const;

export function missingExpoWebPackages(cwd: string): string[] {
return EXPO_WEB_PACKAGES.filter(
(p) => !fs.existsSync(path.join(cwd, 'node_modules', p, 'package.json')),
);
}

/**
* `pkg@version` from the installed SDK's `bundledNativeModules.json` — the versions
* `expo install` would pick, so react-dom matches the project's react. A missing or unreadable
* file (an SDK we don't know) gives bare names.
*/
export function expoWebInstallSpecs(cwd: string, missing: readonly string[]): string[] {
let pinned: Record<string, unknown> = {};
try {
const raw = JSON.parse(
fs.readFileSync(path.join(cwd, 'node_modules', 'expo', 'bundledNativeModules.json'), 'utf8'),
) as unknown;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) pinned = raw as Record<string, unknown>;
} catch {
/* no SDK pin: bare names */
}
return missing.map((p) => (typeof pinned[p] === 'string' ? `${p}@${String(pinned[p])}` : p));
}

/**
* The install that turns the web preview on, or `null` when nothing is missing. `--no-save`:
* the project's package.json and lockfile are the user's — the preview never edits them.
*/
export function expoWebDepsInstall(cwd: string): { cmd: string; args: string[] } | null {
const missing = missingExpoWebPackages(cwd);
if (missing.length === 0) return null;
return {
cmd: 'npm',
args: [
'install',
'--no-save',
'--legacy-peer-deps',
'--no-audit',
'--no-fund',
...expoWebInstallSpecs(cwd, missing),
],
};
}
2 changes: 2 additions & 0 deletions apps/cli/src/services/preview/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface ActivePreview {
* where nothing of ours fronts the port (codespace port-forwarding). */
tunnel: ChildProcess | null;
url: string;
/** Expo: the `exps://` link Expo Go opens, beside the web preview in `url`. */
expoGoUrl?: string;
framework: string;
/** The detection this preview was started from, so a restart can
* re-spawn it without re-reading `.codeam/preview.json`. */
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/services/preview/reaffirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ export interface PreviewReaffirmDeps {
* ¿Hay un preview REALMENTE sirviendo ahora mismo? Devuelve sus datos, o
* `null` si no hay ninguno o su proceso ya murió.
*/
serving: () => { url: string; framework: string; port: number } | null;
serving: () => { url: string; framework: string; port: number; expoGoUrl?: string } | null;
/** Re-emite `preview_ready`. Debe ir por el MISMO emisor serializado que las
* transiciones, para que una re-afirmación no adelante a un evento real. */
emitReady: (payload: { url: string; framework: string; port: number }) => void;
emitReady: (payload: { url: string; framework: string; port: number; expoGoUrl?: string }) => void;
/**
* Publishes `preview_stopped` once, on the FIRST beat of this process, when
* nothing is serving. The backend keeps `preview:<sessionId>` for an hour, so
Expand Down
26 changes: 25 additions & 1 deletion apps/cli/src/services/preview/start-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ export async function runPreviewStart(args: PreviewStartArgs): Promise<void> {
url: existing.url,
framework: existing.framework,
port: detection.port,
...(existing.expoGoUrl ? { expoGoUrl: existing.expoGoUrl } : {}),
});
return;
}
Expand Down Expand Up @@ -389,6 +390,21 @@ export async function runPreviewStart(args: PreviewStartArgs): Promise<void> {
async function runExpoBringUp(ctx: StageCtx): Promise<void> {
const { detection, emit, emitProgress } = ctx;

// The WEB preview (owner 2026-10-31): Metro serves the app to a browser once `react-dom`
// resolves from the project, so the web packages are installed first (`--no-save`, the
// project's package.json is the user's). Best-effort: without them the preview is the Expo Go
// QR alone, as before.
const webInstall = previewSvc.expoWebDepsInstall(ctx.cwd);
let web = webInstall === null;
if (webInstall) {
emitProgress('SETUP_RUN', `${webInstall.cmd} ${webInstall.args.join(' ')} (Expo web preview)`);
const r = await previewSvc.runSetupCommand(webInstall.cmd, webInstall.args, ctx.cwd, detection.env, {
timeoutMs: INSTALL_TIMEOUT_MS,
});
web = r.status === 'ok';
if (!web) emitProgress('SETUP_RUN', `web packages not installed (${r.status}) — Expo Go only`);
}

emitProgress('TUNNEL_STARTING', `cloudflared quick tunnel → Expo :${detection.port}`);
const pub = await bringUpPublicUrl(ctx, detection.port);
if (!pub.ok) {
Expand All @@ -404,8 +420,11 @@ async function runExpoBringUp(ctx: StageCtx): Promise<void> {
return;
}

const expoGoUrl = previewSvc.expoGoDeepLink(pub.url);
announceReady(ctx, dev, {
url: previewSvc.expoGoDeepLink(pub.url),
// The web build in the frame + Expo Go beside it; an SDK without web deps keeps the QR alone.
url: web ? pub.url : expoGoUrl,
expoGoUrl: web ? expoGoUrl : undefined,
tunnel: pub.tunnel,
// No inspector: a DOM inspector means nothing against a native app.
inspector: null,
Expand All @@ -422,6 +441,7 @@ function announceReady(ctx: StageCtx, dev: DevServerUp, tun: TunnelUp): void {
tunnel: tun.tunnel,
inspector: tun.inspector,
url: tun.url,
expoGoUrl: tun.expoGoUrl,
framework: detection.framework,
detection,
cwd: ctx.cwd,
Expand All @@ -443,6 +463,7 @@ function announceReady(ctx: StageCtx, dev: DevServerUp, tun: TunnelUp): void {
url: tun.url,
framework: detection.framework,
port: detection.port,
...(tun.expoGoUrl ? { expoGoUrl: tun.expoGoUrl } : {}),
});
}

Expand Down Expand Up @@ -696,6 +717,7 @@ async function startDevServer(
url: raceExisting.url,
framework: raceExisting.framework,
port: detection.port,
...(raceExisting.expoGoUrl ? { expoGoUrl: raceExisting.expoGoUrl } : {}),
});
return null;
}
Expand Down Expand Up @@ -918,6 +940,8 @@ async function startDevServer(
/** What stage 3 hands back for registration. */
interface TunnelUp {
url: string;
/** Expo: the `exps://` link Expo Go opens, beside the web preview in `url`. */
expoGoUrl?: string;
/** The cloudflared child fronting the dev server (or the inspector proxy). */
tunnel: ReturnType<typeof spawn>;
/**
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/types/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ export interface PreviewStatus {
/** Who started it — lets a reconnecting client keep the agent-launch UI. */
origin?: PreviewOrigin;
url?: string;
/**
* Expo only (codeam-cli ≥ 2.76.42): `url` is the WEB preview served by Metro (react-native-web)
* and this is the `exps://` link Expo Go opens for the same server. An older CLI sends the
* `exps://` link AS `url` and no `expoGoUrl` — the apps keep showing the QR alone for it.
*/
expoGoUrl?: string;
framework?: string;
detection?: PreviewDetection;
error?: { stage: PreviewErrorStage; message: string; code?: PreviewErrorCode };
Expand Down
Loading