Skip to content
Open
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
95 changes: 82 additions & 13 deletions packages/serve-sim/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ const STATE_DIR = join(tmpdir(), "serve-sim");
const CLIENT_DIR = resolve(import.meta.dir, "src/client");
const CLIENT_ENTRY = resolve(CLIENT_DIR, "client.tsx");

interface ServeSimState {
pid: number;
port: number;
device: string;
url: string;
streamUrl: string;
wsUrl: string;
}

// ─── Serve-sim state ───

// Cache simctl's booted-device set briefly (1.5s). dev.ts calls
Expand Down Expand Up @@ -78,7 +87,7 @@ function getBootedUdids(): Set<string> | null {
}
}

function readServeSimStates() {
function readServeSimStates(): ServeSimState[] {
let files: string[];
try {
files = readdirSync(STATE_DIR).filter(
Expand All @@ -88,7 +97,7 @@ function readServeSimStates() {
return [];
}
const booted = getBootedUdids();
const states: any[] = [];
const states: ServeSimState[] = [];
for (const f of files) {
const path = join(STATE_DIR, f);
try {
Expand Down Expand Up @@ -116,6 +125,16 @@ function readServeSimStates() {
return states;
}

function selectServeSimState(
states: ServeSimState[],
device?: string | null,
): ServeSimState | null {
if (device) {
return states.find((state) => state.device === device) ?? null;
}
return states[0] ?? null;
}

// ─── Client bundler with watch ───

let clientJs = "";
Expand Down Expand Up @@ -164,12 +183,59 @@ watch(CLIENT_DIR, { recursive: true }, (_event, filename) => {

// ─── HTML shell ───

function buildHtml(): string {
function endpoint(path: string): string {
return path;
}

function endpointForDevice(path: string, device: string): string {
return `${path}?device=${encodeURIComponent(device)}`;
}

function previewConfig(state: ServeSimState | null) {
const config: {
basePath: string;
endpoints: {
api: string;
exec: string;
logs?: string;
appState?: string;
};
state?: ServeSimState;
helper?: {
url: string;
stream: string;
ws: string;
config: string;
};
} = {
basePath: "/",
endpoints: {
api: endpoint("/api"),
exec: endpoint("/exec"),
},
};
if (state) {
config.state = state;
config.helper = {
url: state.url,
stream: state.streamUrl,
ws: state.wsUrl,
config: `${state.url}/config`,
};
config.endpoints.logs = endpointForDevice("/logs", state.device);
config.endpoints.appState = endpointForDevice("/appstate", state.device);
}
return config;
}

function inlineJson(value: unknown): string {
return JSON.stringify(value)!.replace(/</g, "\\u003c");
}

function buildHtml(device?: string | null): string {
const states = readServeSimStates();
const state = states[0] ?? null;
const configScript = state
? `<script>window.__SIM_PREVIEW__=${JSON.stringify({ ...state, logsEndpoint: "/logs" })}</script>`
: "";
const state = selectServeSimState(states, device);
const configScript = `<script>window.__SIM_PREVIEW__=${inlineJson(previewConfig(state))}</script>`;

return `<!doctype html>
<html><head>
Expand Down Expand Up @@ -221,7 +287,8 @@ Bun.serve({
// Serve-sim state API
if (url.pathname === "/api") {
const states = readServeSimStates();
return Response.json(states[0] ?? null, {
const state = selectServeSimState(states, url.searchParams.get("device"));
return Response.json(state, {
headers: { "Cache-Control": "no-store" },
});
}
Expand All @@ -248,10 +315,11 @@ Bun.serve({
// SSE logs
if (url.pathname === "/logs") {
const states = readServeSimStates();
if (states.length === 0) {
const state = selectServeSimState(states, url.searchParams.get("device"));
if (!state) {
return new Response("No serve-sim device", { status: 404 });
}
const udid = states[0].device;
const udid = state.device;
const stream = new ReadableStream({
start(controller) {
const child: ChildProcess = spawn("xcrun", [
Expand Down Expand Up @@ -294,10 +362,11 @@ Bun.serve({
// SSE foreground-app changes (filtered in the CLI; browser just listens).
if (url.pathname === "/appstate") {
const states = readServeSimStates();
if (states.length === 0) {
const state = selectServeSimState(states, url.searchParams.get("device"));
if (!state) {
return new Response("No serve-sim device", { status: 404 });
}
const udid = states[0].device;
const udid = state.device;
const stream = new ReadableStream({
start(controller) {
const child: ChildProcess = spawn("xcrun", [
Expand Down Expand Up @@ -348,7 +417,7 @@ Bun.serve({
}

// Serve the HTML page (fresh on every request — picks up state + rebuild)
return new Response(buildHtml(), {
return new Response(buildHtml(url.searchParams.get("device")), {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
Expand Down
42 changes: 41 additions & 1 deletion packages/serve-sim/src/__tests__/middleware-selection.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { selectServeSimState, type ServeSimState } from "../middleware";
import { buildSimPreviewConfig, selectServeSimState, type ServeSimState } from "../middleware";

const states: ServeSimState[] = [
{
Expand Down Expand Up @@ -33,3 +33,43 @@ describe("selectServeSimState", () => {
expect(selectServeSimState(states, "DEVICE-C")).toBeNull();
});
});

describe("buildSimPreviewConfig", () => {
test("injects middleware endpoints without helper URLs when no state is running", () => {
expect(buildSimPreviewConfig("/.sim", null)).toEqual({
basePath: "/.sim",
endpoints: {
api: "/.sim/api",
exec: "/.sim/exec",
},
});
});

test("separates middleware endpoints from helper stream/control URLs", () => {
expect(buildSimPreviewConfig("/.sim", states[1]!)).toEqual({
basePath: "/.sim",
endpoints: {
api: "/.sim/api",
exec: "/.sim/exec",
logs: "/.sim/logs?device=DEVICE-B",
appState: "/.sim/appstate?device=DEVICE-B",
},
state: states[1],
helper: {
url: "http://127.0.0.1:3101",
stream: "http://127.0.0.1:3101/stream.mjpeg",
ws: "ws://127.0.0.1:3101/ws",
config: "http://127.0.0.1:3101/config",
},
});
});

test("uses root-relative middleware endpoints for root-mounted preview", () => {
expect(buildSimPreviewConfig("", states[0]!).endpoints).toEqual({
api: "/api",
exec: "/exec",
logs: "/logs?device=DEVICE-A",
appState: "/appstate?device=DEVICE-A",
});
});
});
Loading