@alivelabs/expo-orchestrator-react-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # @alivelabs/expo-orchestrator-react-client
2
+
3
+ React client for the Expo CI Orchestrator. Renders a live build session in the browser: streaming logs, live iOS simulator video, session status, and interactive controls (tap, swipe, type, keypress).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @alivelabs/expo-orchestrator-react-client
9
+ # peer deps
10
+ npm install react react-dom
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```tsx
16
+ import { SessionViewer } from "@alivelabs/expo-orchestrator-react-client";
17
+
18
+ function App() {
19
+ return (
20
+ <SessionViewer
21
+ sessionId="abc-123"
22
+ apiToken="your-api-token"
23
+ baseUrl="https://your-orchestrator.example.com"
24
+ />
25
+ );
26
+ }
27
+ ```
28
+
29
+ ## Hook usage
30
+
31
+ ```tsx
32
+ import { useExpoCiSession } from "@alivelabs/expo-orchestrator-react-client";
33
+
34
+ function BuildPage() {
35
+ const { status, logs, frame, connected, error, sendInput, reconnect } = useExpoCiSession({
36
+ sessionId: "abc-123",
37
+ apiToken: "your-api-token",
38
+ baseUrl: "https://your-orchestrator.example.com",
39
+ autoConnect: true, // default
40
+ maxLogs: 2000, // default
41
+ });
42
+
43
+ return (
44
+ <div>
45
+ <p>Status: {status} — {connected ? "live" : "disconnected"}</p>
46
+ {frame && <img src={frame} alt="simulator" />}
47
+ <button onClick={() => sendInput({ type: "keypress", key: "home" })}>Home</button>
48
+ {!connected && <button onClick={reconnect}>Reconnect</button>}
49
+ </div>
50
+ );
51
+ }
52
+ ```
53
+
54
+ ## Exports reference
55
+
56
+ | Export | Kind | Description |
57
+ |---|---|---|
58
+ | `SessionViewer` | Component | All-in-one: status badge, simulator screen, log console, interaction controls |
59
+ | `SimulatorScreen` | Component | Renders the current frame; click = tap, click-drag = swipe |
60
+ | `LogConsole` | Component | Scrollable, color-coded, auto-scrolling log viewer |
61
+ | `StatusBadge` | Component | Colored pill showing session status |
62
+ | `useExpoCiSession` | Hook | React hook wrapping `ExpoCiClient` with state management |
63
+ | `ExpoCiClient` | Class | Framework-agnostic client: REST + WebSocket with auto-reconnect |
64
+
65
+ ### `<SessionViewer>` props
66
+
67
+ | Prop | Type | Required | Default | Description |
68
+ |---|---|---|---|---|
69
+ | `sessionId` | `string` | yes | — | The session to display |
70
+ | `apiToken` | `string` | yes | — | Bearer token for the API |
71
+ | `baseUrl` | `string` | no | `http://localhost:3000` | Orchestrator base URL |
72
+ | `className` | `string` | no | — | CSS class applied to the root element |
73
+
74
+ ### `<SimulatorScreen>` props
75
+
76
+ | Prop | Type | Required | Description |
77
+ |---|---|---|---|
78
+ | `frame` | `string \| null` | yes | Current frame as a `data:image/jpeg;base64,...` string |
79
+ | `onTap` | `(input: { type: "tap"; x: number; y: number }) => void` | no | Called on click |
80
+ | `onSwipe` | `(input: { type: "swipe"; fromX: number; fromY: number; toX: number; toY: number }) => void` | no | Called on drag |
81
+
82
+ ### `<LogConsole>` props
83
+
84
+ | Prop | Type | Required | Default | Description |
85
+ |---|---|---|---|---|
86
+ | `logs` | `LogEntry[]` | yes | — | Array of log entries |
87
+ | `maxHeight` | `string \| number` | no | `400` | CSS max-height of the scroll area |
88
+
89
+ ### `<StatusBadge>` props
90
+
91
+ | Prop | Type | Required | Description |
92
+ |---|---|---|---|
93
+ | `status` | `SessionStatus \| null` | yes | Session status to display |
94
+
95
+ ### `useExpoCiSession` options
96
+
97
+ | Option | Type | Default | Description |
98
+ |---|---|---|---|
99
+ | `sessionId` | `string` | — | Required |
100
+ | `apiToken` | `string` | — | Required |
101
+ | `baseUrl` | `string` | `http://localhost:3000` | Orchestrator base URL |
102
+ | `autoConnect` | `boolean` | `true` | Open WebSocket immediately on mount |
103
+ | `maxLogs` | `number` | `2000` | Max log lines retained in state |
104
+
105
+ ### `ExpoCiClient` API
106
+
107
+ ```ts
108
+ const client = new ExpoCiClient({ sessionId, apiToken, baseUrl });
109
+
110
+ client.getSession() // GET /api/sessions/:id → SessionDetail
111
+ client.getLogs() // GET /api/sessions/:id/logs → SessionLogs
112
+ client.sendInput(input) // POST /api/sessions/:id/simulator/input
113
+ client.getScreenshotObjectUrl() // GET /api/sessions/:id/screenshot → object URL (fetch + blob)
114
+
115
+ client.connect() // Open WebSocket (auto-reconnect with exponential backoff)
116
+ client.disconnect() // Close WebSocket and remove all listeners
117
+
118
+ // Typed event emitter
119
+ const off = client.on("video-frame", (msg) => console.log(msg.data));
120
+ off(); // unsubscribe
121
+ ```
122
+
123
+ Events: `open`, `close`, `connected`, `log`, `video-frame`, `status`, `error`.
@@ -0,0 +1,37 @@
1
+ import type { ClientEventMap, SessionDetail, SessionLogs, SimulatorInput, SimulatorInputResponse } from "./types.js";
2
+ type Listener<T> = T extends undefined ? () => void : (data: T) => void;
3
+ type EventMap = Record<string, any>;
4
+ declare class TypedEventEmitter<Events extends EventMap> {
5
+ private readonly listeners;
6
+ on<K extends keyof Events & string>(event: K, listener: Listener<Events[K]>): () => void;
7
+ off<K extends keyof Events & string>(event: K, listener: Listener<Events[K]>): void;
8
+ emit<K extends keyof Events & string>(event: K, ...args: Events[K] extends undefined ? [] : [Events[K]]): void;
9
+ removeAllListeners(): void;
10
+ }
11
+ export interface ExpoCiClientOptions {
12
+ baseUrl?: string;
13
+ sessionId: string;
14
+ apiToken: string;
15
+ }
16
+ export declare class ExpoCiClient extends TypedEventEmitter<ClientEventMap> {
17
+ private readonly baseUrl;
18
+ readonly sessionId: string;
19
+ private readonly apiToken;
20
+ private ws;
21
+ private reconnectTimer;
22
+ private attemptCount;
23
+ private destroyed;
24
+ constructor({ baseUrl, sessionId, apiToken }: ExpoCiClientOptions);
25
+ private restFetch;
26
+ getSession(): Promise<SessionDetail>;
27
+ getLogs(): Promise<SessionLogs>;
28
+ sendInput(input: SimulatorInput): Promise<SimulatorInputResponse>;
29
+ getScreenshotObjectUrl(): Promise<string>;
30
+ private get wsUrl();
31
+ connect(): void;
32
+ private openSocket;
33
+ private scheduleReconnect;
34
+ disconnect(): void;
35
+ }
36
+ export {};
37
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,aAAa,EACb,WAAW,EACX,cAAc,EACd,sBAAsB,EAEvB,MAAM,YAAY,CAAC;AAIpB,KAAK,QAAQ,CAAC,CAAC,IAAI,CAAC,SAAS,SAAS,GAAG,MAAM,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;AAGxE,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAEpC,cAAM,iBAAiB,CAAC,MAAM,SAAS,QAAQ;IAC7C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAwD;IAElF,EAAE,CAAC,CAAC,SAAS,MAAM,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI;IASxF,GAAG,CAAC,CAAC,SAAS,MAAM,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI;IAInF,IAAI,CAAC,CAAC,SAAS,MAAM,MAAM,GAAG,MAAM,EAClC,KAAK,EAAE,CAAC,EACR,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,SAAS,SAAS,GAAG,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GACtD,IAAI;IAQP,kBAAkB,IAAI,IAAI;CAG3B;AAUD,MAAM,WAAW,mBAAmB;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,YAAa,SAAQ,iBAAiB,CAAC,cAAc,CAAC;IACjE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAElC,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,SAAS,CAAS;gBAEd,EAAE,OAAiC,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,mBAAmB;YAU7E,SAAS;IAgBvB,UAAU,IAAI,OAAO,CAAC,aAAa,CAAC;IAIpC,OAAO,IAAI,OAAO,CAAC,WAAW,CAAC;IAI/B,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAU3D,sBAAsB,IAAI,OAAO,CAAC,MAAM,CAAC;IAc/C,OAAO,KAAK,KAAK,GAKhB;IAED,OAAO,IAAI,IAAI;IAMf,OAAO,CAAC,UAAU;IAyDlB,OAAO,CAAC,iBAAiB;IASzB,UAAU,IAAI,IAAI;CAYnB"}
package/dist/client.js ADDED
@@ -0,0 +1,172 @@
1
+ class TypedEventEmitter {
2
+ constructor() {
3
+ this.listeners = new Map();
4
+ }
5
+ on(event, listener) {
6
+ if (!this.listeners.has(event)) {
7
+ this.listeners.set(event, new Set());
8
+ }
9
+ // biome-ignore lint/style/noNonNullAssertion: just set above
10
+ this.listeners.get(event).add(listener);
11
+ return () => this.off(event, listener);
12
+ }
13
+ off(event, listener) {
14
+ this.listeners.get(event)?.delete(listener);
15
+ }
16
+ emit(event, ...args) {
17
+ const set = this.listeners.get(event);
18
+ if (!set)
19
+ return;
20
+ for (const listener of set) {
21
+ listener(...args);
22
+ }
23
+ }
24
+ removeAllListeners() {
25
+ this.listeners.clear();
26
+ }
27
+ }
28
+ // ── Reconnect config ──────────────────────────────────────────────────────────
29
+ const INITIAL_DELAY_MS = 500;
30
+ const MAX_DELAY_MS = 30000;
31
+ const MAX_ATTEMPTS = 10;
32
+ export class ExpoCiClient extends TypedEventEmitter {
33
+ constructor({ baseUrl = "http://localhost:3000", sessionId, apiToken }) {
34
+ super();
35
+ this.ws = null;
36
+ this.reconnectTimer = null;
37
+ this.attemptCount = 0;
38
+ this.destroyed = false;
39
+ // Normalize: strip trailing slash
40
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
41
+ this.sessionId = sessionId;
42
+ this.apiToken = apiToken;
43
+ }
44
+ // ── REST helpers ────────────────────────────────────────────────────────────
45
+ async restFetch(path, init) {
46
+ const url = `${this.baseUrl}${path}`;
47
+ const res = await fetch(url, {
48
+ ...init,
49
+ headers: {
50
+ Authorization: `Bearer ${this.apiToken}`,
51
+ "Content-Type": "application/json",
52
+ ...(init?.headers ?? {}),
53
+ },
54
+ });
55
+ if (!res.ok) {
56
+ throw new Error(`HTTP ${res.status} ${res.statusText} — ${url}`);
57
+ }
58
+ return res.json();
59
+ }
60
+ getSession() {
61
+ return this.restFetch(`/api/sessions/${this.sessionId}`);
62
+ }
63
+ getLogs() {
64
+ return this.restFetch(`/api/sessions/${this.sessionId}/logs`);
65
+ }
66
+ sendInput(input) {
67
+ return this.restFetch(`/api/sessions/${this.sessionId}/simulator/input`, {
68
+ method: "POST",
69
+ body: JSON.stringify(input),
70
+ });
71
+ }
72
+ async getScreenshotObjectUrl() {
73
+ const url = `${this.baseUrl}/api/sessions/${this.sessionId}/screenshot`;
74
+ const res = await fetch(url, {
75
+ headers: { Authorization: `Bearer ${this.apiToken}` },
76
+ });
77
+ if (!res.ok) {
78
+ throw new Error(`HTTP ${res.status} ${res.statusText} — ${url}`);
79
+ }
80
+ const blob = await res.blob();
81
+ return URL.createObjectURL(blob);
82
+ }
83
+ // ── WebSocket ───────────────────────────────────────────────────────────────
84
+ get wsUrl() {
85
+ // Derive ws:// / wss:// from the baseUrl scheme
86
+ const httpUrl = this.baseUrl;
87
+ const wsBase = httpUrl.replace(/^https/, "wss").replace(/^http/, "ws");
88
+ return `${wsBase}/ws/sessions/${this.sessionId}?token=${encodeURIComponent(this.apiToken)}`;
89
+ }
90
+ connect() {
91
+ if (this.destroyed)
92
+ return;
93
+ this.attemptCount = 0;
94
+ this.openSocket();
95
+ }
96
+ openSocket() {
97
+ if (this.destroyed)
98
+ return;
99
+ const socket = new WebSocket(this.wsUrl);
100
+ this.ws = socket;
101
+ socket.addEventListener("open", () => {
102
+ if (this.destroyed) {
103
+ socket.close();
104
+ return;
105
+ }
106
+ this.attemptCount = 0;
107
+ this.emit("open");
108
+ });
109
+ socket.addEventListener("message", (event) => {
110
+ if (this.destroyed)
111
+ return;
112
+ let msg;
113
+ try {
114
+ msg = JSON.parse(event.data);
115
+ }
116
+ catch {
117
+ return;
118
+ }
119
+ switch (msg.type) {
120
+ case "connected":
121
+ this.emit("connected", msg);
122
+ break;
123
+ case "log":
124
+ this.emit("log", msg);
125
+ break;
126
+ case "video-frame":
127
+ this.emit("video-frame", msg);
128
+ break;
129
+ case "status":
130
+ this.emit("status", msg);
131
+ break;
132
+ case "error":
133
+ this.emit("error", msg);
134
+ break;
135
+ }
136
+ });
137
+ socket.addEventListener("close", () => {
138
+ if (this.destroyed) {
139
+ this.emit("close");
140
+ return;
141
+ }
142
+ this.emit("close");
143
+ this.scheduleReconnect();
144
+ });
145
+ socket.addEventListener("error", () => {
146
+ // The "close" event always follows an error event on a WebSocket,
147
+ // so we let "close" drive reconnection.
148
+ });
149
+ }
150
+ scheduleReconnect() {
151
+ if (this.destroyed || this.attemptCount >= MAX_ATTEMPTS)
152
+ return;
153
+ this.attemptCount++;
154
+ const delay = Math.min(INITIAL_DELAY_MS * 2 ** (this.attemptCount - 1), MAX_DELAY_MS);
155
+ this.reconnectTimer = setTimeout(() => {
156
+ if (!this.destroyed)
157
+ this.openSocket();
158
+ }, delay);
159
+ }
160
+ disconnect() {
161
+ this.destroyed = true;
162
+ if (this.reconnectTimer !== null) {
163
+ clearTimeout(this.reconnectTimer);
164
+ this.reconnectTimer = null;
165
+ }
166
+ if (this.ws) {
167
+ this.ws.close();
168
+ this.ws = null;
169
+ }
170
+ this.removeAllListeners();
171
+ }
172
+ }
@@ -0,0 +1,8 @@
1
+ import type { LogEntry } from "../types.js";
2
+ interface LogConsoleProps {
3
+ logs: LogEntry[];
4
+ maxHeight?: string | number;
5
+ }
6
+ export declare function LogConsole({ logs, maxHeight }: LogConsoleProps): import("react/jsx-runtime").JSX.Element;
7
+ export {};
8
+ //# sourceMappingURL=LogConsole.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LogConsole.d.ts","sourceRoot":"","sources":["../../src/components/LogConsole.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAY,MAAM,aAAa,CAAC;AAEtD,UAAU,eAAe;IACvB,IAAI,EAAE,QAAQ,EAAE,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC7B;AAuDD,wBAAgB,UAAU,CAAC,EAAE,IAAI,EAAE,SAAe,EAAE,EAAE,eAAe,2CA4CpE"}
@@ -0,0 +1,72 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ const LEVEL_COLORS = {
4
+ stdout: "#d1fae5",
5
+ stderr: "#fecaca",
6
+ system: "#bfdbfe",
7
+ };
8
+ const LEVEL_LABEL_COLORS = {
9
+ stdout: "#6ee7b7",
10
+ stderr: "#f87171",
11
+ system: "#93c5fd",
12
+ };
13
+ const containerStyle = (maxHeight) => ({
14
+ backgroundColor: "#0d1117",
15
+ borderRadius: "6px",
16
+ overflow: "hidden",
17
+ display: "flex",
18
+ flexDirection: "column",
19
+ fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
20
+ fontSize: "12px",
21
+ lineHeight: "1.6",
22
+ border: "1px solid #21262d",
23
+ maxHeight,
24
+ });
25
+ const headerStyle = {
26
+ padding: "6px 12px",
27
+ backgroundColor: "#161b22",
28
+ color: "#8b949e",
29
+ fontSize: "11px",
30
+ fontWeight: 600,
31
+ letterSpacing: "0.05em",
32
+ textTransform: "uppercase",
33
+ borderBottom: "1px solid #21262d",
34
+ flexShrink: 0,
35
+ fontFamily: "system-ui, sans-serif",
36
+ };
37
+ const scrollAreaStyle = {
38
+ overflowY: "auto",
39
+ flex: 1,
40
+ padding: "8px 0",
41
+ };
42
+ const rowStyle = {
43
+ display: "flex",
44
+ alignItems: "flex-start",
45
+ gap: "8px",
46
+ padding: "1px 12px",
47
+ whiteSpace: "pre-wrap",
48
+ wordBreak: "break-all",
49
+ };
50
+ export function LogConsole({ logs, maxHeight = 400 }) {
51
+ const bottomRef = useRef(null);
52
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scroll to bottom whenever log count changes; bottomRef is a ref (stable, not reactive)
53
+ useEffect(() => {
54
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
55
+ }, [logs.length]);
56
+ return (_jsxs("div", { style: containerStyle(maxHeight), children: [_jsxs("div", { style: headerStyle, children: ["Build Logs \u2014 ", logs.length, " lines"] }), _jsxs("div", { style: scrollAreaStyle, children: [logs.length === 0 && (_jsx("div", { style: { ...rowStyle, color: "#6e7681", fontStyle: "italic" }, children: "Waiting for logs\u2026" })), logs.map((entry, idx) => (_jsxs("div", { style: { ...rowStyle, color: LEVEL_COLORS[entry.level] }, children: [_jsx("span", { style: {
57
+ color: LEVEL_LABEL_COLORS[entry.level],
58
+ flexShrink: 0,
59
+ minWidth: "44px",
60
+ fontWeight: 600,
61
+ fontSize: "11px",
62
+ }, children: entry.level }), _jsx("span", { style: { color: "#6e7681", flexShrink: 0, userSelect: "none" }, children: formatTime(entry.timestamp) }), _jsx("span", { style: { flex: 1 }, children: entry.message })] }, `${entry.timestamp}-${idx}`))), _jsx("div", { ref: bottomRef })] })] }));
63
+ }
64
+ function formatTime(iso) {
65
+ try {
66
+ const d = new Date(iso);
67
+ return d.toISOString().slice(11, 23); // HH:MM:SS.mmm
68
+ }
69
+ catch {
70
+ return iso;
71
+ }
72
+ }
@@ -0,0 +1,8 @@
1
+ export interface SessionViewerProps {
2
+ sessionId: string;
3
+ apiToken: string;
4
+ baseUrl?: string;
5
+ className?: string;
6
+ }
7
+ export declare function SessionViewer({ sessionId, apiToken, baseUrl, className }: SessionViewerProps): import("react/jsx-runtime").JSX.Element;
8
+ //# sourceMappingURL=SessionViewer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SessionViewer.d.ts","sourceRoot":"","sources":["../../src/components/SessionViewer.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAyHD,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE,kBAAkB,2CAgJ5F"}
@@ -0,0 +1,168 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useState } from "react";
3
+ import { useExpoCiSession } from "../useExpoCiSession.js";
4
+ import { LogConsole } from "./LogConsole.js";
5
+ import { SimulatorScreen } from "./SimulatorScreen.js";
6
+ import { StatusBadge } from "./StatusBadge.js";
7
+ const rootStyle = {
8
+ display: "flex",
9
+ flexDirection: "column",
10
+ gap: "16px",
11
+ fontFamily: "system-ui, -apple-system, sans-serif",
12
+ color: "#e6edf3",
13
+ backgroundColor: "#0d1117",
14
+ padding: "20px",
15
+ borderRadius: "10px",
16
+ minWidth: "360px",
17
+ };
18
+ const headerStyle = {
19
+ display: "flex",
20
+ alignItems: "center",
21
+ justifyContent: "space-between",
22
+ gap: "12px",
23
+ flexWrap: "wrap",
24
+ };
25
+ const titleStyle = {
26
+ fontSize: "15px",
27
+ fontWeight: 600,
28
+ color: "#e6edf3",
29
+ margin: 0,
30
+ };
31
+ const bodyStyle = {
32
+ display: "flex",
33
+ gap: "20px",
34
+ flexWrap: "wrap",
35
+ alignItems: "flex-start",
36
+ };
37
+ const simulatorColStyle = {
38
+ flexShrink: 0,
39
+ };
40
+ const rightColStyle = {
41
+ flex: 1,
42
+ display: "flex",
43
+ flexDirection: "column",
44
+ gap: "16px",
45
+ minWidth: "260px",
46
+ };
47
+ const controlsStyle = {
48
+ display: "flex",
49
+ flexDirection: "column",
50
+ gap: "10px",
51
+ padding: "14px",
52
+ backgroundColor: "#161b22",
53
+ borderRadius: "6px",
54
+ border: "1px solid #21262d",
55
+ };
56
+ const inputRowStyle = {
57
+ display: "flex",
58
+ gap: "8px",
59
+ };
60
+ const textInputStyle = {
61
+ flex: 1,
62
+ padding: "6px 10px",
63
+ backgroundColor: "#0d1117",
64
+ border: "1px solid #30363d",
65
+ borderRadius: "6px",
66
+ color: "#e6edf3",
67
+ fontSize: "13px",
68
+ outline: "none",
69
+ };
70
+ const btnStyle = (variant) => {
71
+ const bg = {
72
+ primary: "#1f6feb",
73
+ secondary: "#21262d",
74
+ danger: "#b91c1c",
75
+ };
76
+ return {
77
+ padding: "6px 12px",
78
+ backgroundColor: bg[variant],
79
+ color: "#e6edf3",
80
+ border: "1px solid rgba(255,255,255,0.1)",
81
+ borderRadius: "6px",
82
+ cursor: "pointer",
83
+ fontSize: "12px",
84
+ fontWeight: 500,
85
+ fontFamily: "system-ui, sans-serif",
86
+ whiteSpace: "nowrap",
87
+ };
88
+ };
89
+ const keypressRowStyle = {
90
+ display: "flex",
91
+ gap: "8px",
92
+ flexWrap: "wrap",
93
+ };
94
+ const errorBannerStyle = {
95
+ padding: "8px 12px",
96
+ backgroundColor: "#450a0a",
97
+ border: "1px solid #7f1d1d",
98
+ borderRadius: "6px",
99
+ color: "#fca5a5",
100
+ fontSize: "13px",
101
+ };
102
+ /**
103
+ * Preset hardware-button / keystroke shortcuts shown in the controls row.
104
+ * Each entry is a ready-to-send baguette payload — the orchestrator forwards
105
+ * it untouched, so the labels and shapes are decoupled from the server.
106
+ */
107
+ const PRESETS = [
108
+ { label: "Home", input: { type: "button", button: "home" } },
109
+ { label: "App Switcher", input: { type: "button", button: "app-switcher" } },
110
+ { label: "Esc", input: { type: "key", code: "Escape" } },
111
+ { label: "Enter", input: { type: "key", code: "Enter" } },
112
+ ];
113
+ export function SessionViewer({ sessionId, apiToken, baseUrl, className }) {
114
+ const { status, logs, frame, connected, error, sendInput, reconnect } = useExpoCiSession({
115
+ baseUrl,
116
+ sessionId,
117
+ apiToken,
118
+ });
119
+ const [textValue, setTextValue] = useState("");
120
+ const [sending, setSending] = useState(false);
121
+ const handleSendText = useCallback(async (e) => {
122
+ e.preventDefault();
123
+ const trimmed = textValue.trim();
124
+ if (!trimmed)
125
+ return;
126
+ setSending(true);
127
+ try {
128
+ await sendInput({ type: "type", text: trimmed });
129
+ setTextValue("");
130
+ }
131
+ catch {
132
+ // Errors surface through WS error events; silence here
133
+ }
134
+ finally {
135
+ setSending(false);
136
+ }
137
+ }, [textValue, sendInput]);
138
+ const handlePreset = useCallback(async (input) => {
139
+ setSending(true);
140
+ try {
141
+ await sendInput(input);
142
+ }
143
+ catch {
144
+ // Same rationale as above
145
+ }
146
+ finally {
147
+ setSending(false);
148
+ }
149
+ }, [sendInput]);
150
+ const handleTap = useCallback((input) => {
151
+ sendInput(input).catch(() => { });
152
+ }, [sendInput]);
153
+ const handleSwipe = useCallback((input) => {
154
+ sendInput(input).catch(() => { });
155
+ }, [sendInput]);
156
+ return (_jsxs("div", { style: rootStyle, className: className, children: [_jsxs("div", { style: headerStyle, children: [_jsxs("h2", { style: titleStyle, children: ["Session ", sessionId] }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }, children: [_jsx(StatusBadge, { status: status }), _jsx("span", { style: {
157
+ fontSize: "11px",
158
+ color: connected ? "#4ade80" : "#6b7280",
159
+ fontWeight: 500,
160
+ }, children: connected ? "● live" : "○ disconnected" }), !connected && (_jsx("button", { type: "button", style: btnStyle("secondary"), onClick: reconnect, children: "Reconnect" }))] })] }), error && _jsxs("div", { style: errorBannerStyle, children: ["\u26A0 ", error] }), _jsxs("div", { style: bodyStyle, children: [_jsx("div", { style: simulatorColStyle, children: _jsx(SimulatorScreen, { frame: frame, onTap: handleTap, onSwipe: handleSwipe }) }), _jsxs("div", { style: rightColStyle, children: [_jsxs("div", { style: controlsStyle, children: [_jsx("div", { style: {
161
+ fontSize: "11px",
162
+ fontWeight: 600,
163
+ color: "#8b949e",
164
+ marginBottom: "4px",
165
+ letterSpacing: "0.05em",
166
+ textTransform: "uppercase",
167
+ }, children: "Simulator Controls" }), _jsxs("form", { onSubmit: handleSendText, style: inputRowStyle, children: [_jsx("input", { type: "text", style: textInputStyle, placeholder: "Type text to send\u2026", value: textValue, onChange: (e) => setTextValue(e.currentTarget.value), disabled: sending }), _jsx("button", { type: "submit", style: btnStyle("primary"), disabled: sending || !textValue.trim(), children: "Send" })] }), _jsx("div", { style: keypressRowStyle, children: PRESETS.map((preset) => (_jsx("button", { type: "button", style: btnStyle("secondary"), disabled: sending, onClick: () => handlePreset(preset.input), children: preset.label }, preset.label))) })] }), _jsx(LogConsole, { logs: logs, maxHeight: 480 })] })] })] }));
168
+ }
@@ -0,0 +1,17 @@
1
+ import type { SimulatorInput } from "../types.js";
2
+ interface SimulatorScreenProps {
3
+ frame: string | null;
4
+ onTap?: (input: Extract<SimulatorInput, {
5
+ type: "tap";
6
+ }>) => void;
7
+ onSwipe?: (input: Extract<SimulatorInput, {
8
+ type: "swipe";
9
+ }>) => void;
10
+ /** Cap on the rendered simulator height; the image scales down proportionally
11
+ * to fit. Accepts any CSS length. Defaults to `80vh` so the device never
12
+ * exceeds the viewport on a tall phone like iPhone 17 Pro Max. */
13
+ maxHeight?: number | string;
14
+ }
15
+ export declare function SimulatorScreen({ frame, onTap, onSwipe, maxHeight, }: SimulatorScreenProps): import("react/jsx-runtime").JSX.Element;
16
+ export {};
17
+ //# sourceMappingURL=SimulatorScreen.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SimulatorScreen.d.ts","sourceRoot":"","sources":["../../src/components/SimulatorScreen.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,UAAU,oBAAoB;IAC5B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,cAAc,EAAE;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE,CAAC,KAAK,IAAI,CAAC;IAClE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,cAAc,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,KAAK,IAAI,CAAC;IACtE;;uEAEmE;IACnE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC7B;AAgFD,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,KAAK,EACL,OAAO,EACP,SAAkB,GACnB,EAAE,oBAAoB,2CA2GtB"}
@@ -0,0 +1,151 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useRef, useState } from "react";
3
+ const SWIPE_THRESHOLD_PX = 5;
4
+ // The outer titanium-style bezel that makes the screen read as a physical
5
+ // device. The screen (wrapper) sits inside it with its own rounded corners.
6
+ const deviceFrameStyle = {
7
+ display: "inline-block",
8
+ padding: "11px",
9
+ borderRadius: "46px",
10
+ background: "linear-gradient(150deg, #3a3a3e 0%, #1b1b1d 48%, #0a0a0c 100%)",
11
+ boxShadow: "0 24px 60px -18px rgba(0,0,0,0.75), 0 4px 14px rgba(0,0,0,0.4)," +
12
+ " inset 0 0 0 1px rgba(255,255,255,0.05), inset 0 1.5px 1px rgba(255,255,255,0.12)",
13
+ lineHeight: 0,
14
+ };
15
+ const wrapperStyle = {
16
+ position: "relative",
17
+ display: "block",
18
+ backgroundColor: "#000",
19
+ borderRadius: "36px",
20
+ overflow: "hidden",
21
+ cursor: "crosshair",
22
+ userSelect: "none",
23
+ lineHeight: 0,
24
+ boxShadow: "inset 0 0 0 2px rgba(0,0,0,0.85)",
25
+ };
26
+ const basePlaceholderStyle = {
27
+ // iPhone 17 logical resolution (402×874 pt). Using aspect-ratio keeps the
28
+ // empty state proportionally correct at any column width — no distortion —
29
+ // and matches the device the orchestrator boots by default.
30
+ aspectRatio: "402 / 874",
31
+ maxWidth: "100%",
32
+ background: "radial-gradient(120% 80% at 50% 32%, #242428 0%, #161618 55%, #0f0f12 100%)",
33
+ display: "flex",
34
+ alignItems: "center",
35
+ justifyContent: "center",
36
+ color: "#7c7c85",
37
+ fontSize: "13px",
38
+ fontFamily: "system-ui, -apple-system, sans-serif",
39
+ letterSpacing: "0.01em",
40
+ flexDirection: "column",
41
+ gap: "16px",
42
+ // The wrapper sets line-height:0 (so the <img> has no baseline gap); reset it
43
+ // here or the stacked glyph + text collapse onto each other.
44
+ lineHeight: 1.4,
45
+ };
46
+ const baseImgStyle = {
47
+ display: "block",
48
+ maxWidth: "100%",
49
+ width: "auto",
50
+ height: "auto",
51
+ pointerEvents: "none",
52
+ };
53
+ /** Convert a rendered-canvas pixel position to the image's natural pixel coords */
54
+ function toNatural(el, clientX, clientY) {
55
+ const rect = el.getBoundingClientRect();
56
+ const scaleX = el.naturalWidth / rect.width;
57
+ const scaleY = el.naturalHeight / rect.height;
58
+ return {
59
+ x: Math.round((clientX - rect.left) * scaleX),
60
+ y: Math.round((clientY - rect.top) * scaleY),
61
+ };
62
+ }
63
+ export function SimulatorScreen({ frame, onTap, onSwipe, maxHeight = "80vh", }) {
64
+ const imgRef = useRef(null);
65
+ const dragRef = useRef(null);
66
+ const imgStyle = { ...baseImgStyle, maxHeight };
67
+ // Drive the placeholder by height so its width derives from the iPhone 17
68
+ // aspect ratio (a block div would otherwise fill the column width and distort).
69
+ const placeholderStyle = { ...basePlaceholderStyle, height: maxHeight };
70
+ const handleMouseDown = useCallback((e) => {
71
+ if (!imgRef.current)
72
+ return;
73
+ const natural = toNatural(imgRef.current, e.clientX, e.clientY);
74
+ dragRef.current = {
75
+ startX: e.clientX,
76
+ startY: e.clientY,
77
+ naturalStartX: natural.x,
78
+ naturalStartY: natural.y,
79
+ };
80
+ }, []);
81
+ const [ripple, setRipple] = useState(null);
82
+ const rippleCounter = useRef(0);
83
+ const handleMouseUp = useCallback((e) => {
84
+ if (!dragRef.current || !imgRef.current)
85
+ return;
86
+ const drag = dragRef.current;
87
+ dragRef.current = null;
88
+ const dx = e.clientX - drag.startX;
89
+ const dy = e.clientY - drag.startY;
90
+ const dist = Math.sqrt(dx * dx + dy * dy);
91
+ if (dist < SWIPE_THRESHOLD_PX) {
92
+ // Tap
93
+ const natural = toNatural(imgRef.current, e.clientX, e.clientY);
94
+ onTap?.({ type: "tap", x: natural.x, y: natural.y });
95
+ // Show ripple at rendered coords
96
+ const rect = imgRef.current.getBoundingClientRect();
97
+ const id = ++rippleCounter.current;
98
+ setRipple({ x: e.clientX - rect.left, y: e.clientY - rect.top, id });
99
+ setTimeout(() => setRipple((r) => (r?.id === id ? null : r)), 500);
100
+ }
101
+ else {
102
+ // Swipe
103
+ const naturalEnd = toNatural(imgRef.current, e.clientX, e.clientY);
104
+ onSwipe?.({
105
+ type: "swipe",
106
+ startX: drag.naturalStartX,
107
+ startY: drag.naturalStartY,
108
+ endX: naturalEnd.x,
109
+ endY: naturalEnd.y,
110
+ });
111
+ }
112
+ }, [onTap, onSwipe]);
113
+ return (_jsx("div", { style: deviceFrameStyle, children: _jsxs("div", { style: wrapperStyle, role: "application", "aria-label": "iOS simulator \u2014 click to tap, drag to swipe", onMouseDown: handleMouseDown, onMouseUp: handleMouseUp, children: [frame ? (_jsx("img", { ref: imgRef, src: frame, alt: "iOS Simulator", style: imgStyle, draggable: false })) : (_jsxs("div", { style: placeholderStyle, children: [_jsx("span", { style: { fontWeight: 500 }, children: "Waiting for the simulator" }), _jsx("div", { style: { display: "flex", gap: "6px" }, children: [0, 1, 2].map((i) => (_jsx("span", { style: {
114
+ width: 6,
115
+ height: 6,
116
+ borderRadius: "50%",
117
+ backgroundColor: "currentColor",
118
+ animation: `expo-pulse 1.2s ease-in-out ${i * 0.2}s infinite`,
119
+ } }, i))) })] })), ripple && (_jsx("span", { style: {
120
+ position: "absolute",
121
+ left: ripple.x - 14,
122
+ top: ripple.y - 14,
123
+ width: 28,
124
+ height: 28,
125
+ borderRadius: "50%",
126
+ border: "2px solid rgba(255,255,255,0.7)",
127
+ backgroundColor: "rgba(255,255,255,0.15)",
128
+ pointerEvents: "none",
129
+ animation: "expo-ripple 0.5s ease-out forwards",
130
+ } }, ripple.id)), _jsx(RippleStyles, {})] }) }));
131
+ }
132
+ // Inject keyframe once
133
+ let stylesInjected = false;
134
+ function RippleStyles() {
135
+ if (typeof document === "undefined" || stylesInjected)
136
+ return null;
137
+ stylesInjected = true;
138
+ const style = document.createElement("style");
139
+ style.textContent = `
140
+ @keyframes expo-ripple {
141
+ 0% { transform: scale(0.5); opacity: 1; }
142
+ 100% { transform: scale(2.5); opacity: 0; }
143
+ }
144
+ @keyframes expo-pulse {
145
+ 0%, 100% { opacity: 0.35; transform: scale(0.94); }
146
+ 50% { opacity: 1; transform: scale(1.05); }
147
+ }
148
+ `;
149
+ document.head.appendChild(style);
150
+ return null;
151
+ }
@@ -0,0 +1,7 @@
1
+ import type { SessionStatus } from "../types.js";
2
+ interface StatusBadgeProps {
3
+ status: SessionStatus | null;
4
+ }
5
+ export declare function StatusBadge({ status }: StatusBadgeProps): import("react/jsx-runtime").JSX.Element;
6
+ export {};
7
+ //# sourceMappingURL=StatusBadge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"StatusBadge.d.ts","sourceRoot":"","sources":["../../src/components/StatusBadge.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,UAAU,gBAAgB;IACxB,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;CAC9B;AAgCD,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,EAAE,gBAAgB,2CAkBvD"}
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const STATUS_COLORS = {
3
+ created: { bg: "#6b7280", text: "#fff" },
4
+ running: { bg: "#2563eb", text: "#fff" },
5
+ completed: { bg: "#16a34a", text: "#fff" },
6
+ failed: { bg: "#dc2626", text: "#fff" },
7
+ timeout: { bg: "#d97706", text: "#fff" },
8
+ deleted: { bg: "#374151", text: "#9ca3af" },
9
+ };
10
+ const pillStyle = {
11
+ display: "inline-flex",
12
+ alignItems: "center",
13
+ gap: "6px",
14
+ padding: "3px 10px",
15
+ borderRadius: "9999px",
16
+ fontSize: "12px",
17
+ fontWeight: 600,
18
+ letterSpacing: "0.04em",
19
+ textTransform: "uppercase",
20
+ fontFamily: "system-ui, sans-serif",
21
+ };
22
+ const dotStyle = (color) => ({
23
+ width: "7px",
24
+ height: "7px",
25
+ borderRadius: "50%",
26
+ backgroundColor: color,
27
+ flexShrink: 0,
28
+ });
29
+ export function StatusBadge({ status }) {
30
+ if (!status) {
31
+ return (_jsxs("span", { style: { ...pillStyle, backgroundColor: "#1f2937", color: "#6b7280" }, children: [_jsx("span", { style: dotStyle("#6b7280") }), "unknown"] }));
32
+ }
33
+ const { bg, text } = STATUS_COLORS[status];
34
+ return (_jsxs("span", { style: { ...pillStyle, backgroundColor: bg, color: text }, children: [_jsx("span", { style: dotStyle(text === "#fff" ? "rgba(255,255,255,0.6)" : text) }), status] }));
35
+ }
@@ -0,0 +1,10 @@
1
+ export type { ExpoCiClientOptions } from "./client.js";
2
+ export { ExpoCiClient } from "./client.js";
3
+ export { LogConsole } from "./components/LogConsole.js";
4
+ export type { SessionViewerProps } from "./components/SessionViewer.js";
5
+ export { SessionViewer } from "./components/SessionViewer.js";
6
+ export { SimulatorScreen } from "./components/SimulatorScreen.js";
7
+ export { StatusBadge } from "./components/StatusBadge.js";
8
+ export type { ClientEventMap, ListSimulatorsResponse, LogEntry, LogLevel, SessionDetail, SessionLogs, SessionStatus, SimulatorButton, SimulatorDevice, SimulatorInput, SimulatorInputResponse, SimulatorKeyCode, SimulatorKeyModifier, UseExpoCiSessionOptions, UseExpoCiSessionResult, WsConnectedMessage, WsErrorMessage, WsLogMessage, WsMessage, WsStatusMessage, WsVideoFrameMessage, } from "./types.js";
9
+ export { useExpoCiSession } from "./useExpoCiSession.js";
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,YAAY,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AACxD,YAAY,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AAExE,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAE1D,YAAY,EACV,cAAc,EACd,sBAAsB,EACtB,QAAQ,EACR,QAAQ,EACR,aAAa,EACb,WAAW,EACX,aAAa,EACb,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,EACvB,sBAAsB,EACtB,kBAAkB,EAClB,cAAc,EACd,YAAY,EACZ,SAAS,EACT,eAAe,EACf,mBAAmB,GACpB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // Client
2
+ export { ExpoCiClient } from "./client.js";
3
+ export { LogConsole } from "./components/LogConsole.js";
4
+ // Components
5
+ export { SessionViewer } from "./components/SessionViewer.js";
6
+ export { SimulatorScreen } from "./components/SimulatorScreen.js";
7
+ export { StatusBadge } from "./components/StatusBadge.js";
8
+ // Hook
9
+ export { useExpoCiSession } from "./useExpoCiSession.js";
@@ -0,0 +1,75 @@
1
+ import type { Log, SessionStatus, SimulatorInput } from "@alivelabs/expo-orchestrator-schemas";
2
+ export type { ListSimulatorsResponse, Log, LogLevel, Platform, SessionDetail, SessionStatus, SimulatorButton, SimulatorDevice, SimulatorInput, SimulatorInputResponse, SimulatorKeyCode, SimulatorKeyModifier, SourceType, } from "@alivelabs/expo-orchestrator-schemas";
3
+ /** Alias kept for the public hook API. */
4
+ export type LogEntry = Log;
5
+ export interface SessionLogs {
6
+ sessionId: string;
7
+ logs: Log[];
8
+ }
9
+ export interface WsConnectedMessage {
10
+ type: "connected";
11
+ sessionId: string;
12
+ timestamp: string;
13
+ }
14
+ export interface WsLogMessage {
15
+ type: "log";
16
+ sessionId: string;
17
+ timestamp: string;
18
+ data: {
19
+ level: import("@alivelabs/expo-orchestrator-schemas").LogLevel;
20
+ message: string;
21
+ };
22
+ }
23
+ export interface WsVideoFrameMessage {
24
+ type: "video-frame";
25
+ sessionId: string;
26
+ timestamp: string;
27
+ /** base64 JPEG with NO data-URI prefix */
28
+ data: string;
29
+ }
30
+ export interface WsStatusMessage {
31
+ type: "status";
32
+ sessionId: string;
33
+ timestamp: string;
34
+ data: {
35
+ status: SessionStatus;
36
+ };
37
+ }
38
+ export interface WsErrorMessage {
39
+ type: "error";
40
+ sessionId: string;
41
+ timestamp: string;
42
+ data: {
43
+ message: string;
44
+ };
45
+ }
46
+ export type WsMessage = WsConnectedMessage | WsLogMessage | WsVideoFrameMessage | WsStatusMessage | WsErrorMessage;
47
+ export interface ClientEventMap {
48
+ open: undefined;
49
+ close: undefined;
50
+ connected: WsConnectedMessage;
51
+ log: WsLogMessage;
52
+ "video-frame": WsVideoFrameMessage;
53
+ status: WsStatusMessage;
54
+ error: WsErrorMessage;
55
+ }
56
+ export interface UseExpoCiSessionOptions {
57
+ baseUrl?: string;
58
+ sessionId: string;
59
+ apiToken: string;
60
+ /** @default true */
61
+ autoConnect?: boolean;
62
+ /** Max number of log lines to retain */
63
+ maxLogs?: number;
64
+ }
65
+ export interface UseExpoCiSessionResult {
66
+ status: SessionStatus | null;
67
+ logs: LogEntry[];
68
+ /** Current frame as a data:image/jpeg;base64,... string, or null */
69
+ frame: string | null;
70
+ connected: boolean;
71
+ error: string | null;
72
+ sendInput: (input: SimulatorInput) => Promise<import("@alivelabs/expo-orchestrator-schemas").SimulatorInputResponse>;
73
+ reconnect: () => void;
74
+ }
75
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,GAAG,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAE/F,YAAY,EACV,sBAAsB,EACtB,GAAG,EACH,QAAQ,EACR,QAAQ,EACR,aAAa,EACb,aAAa,EACb,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,gBAAgB,EAChB,oBAAoB,EACpB,UAAU,GACX,MAAM,sCAAsC,CAAC;AAE9C,0CAA0C;AAC1C,MAAM,MAAM,QAAQ,GAAG,GAAG,CAAC;AAE3B,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,GAAG,EAAE,CAAC;CACb;AAMD,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,KAAK,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE;QAAE,KAAK,EAAE,OAAO,sCAAsC,EAAE,QAAQ,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3F;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,aAAa,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,QAAQ,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE;QAAE,MAAM,EAAE,aAAa,CAAA;KAAE,CAAC;CACjC;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3B;AAED,MAAM,MAAM,SAAS,GACjB,kBAAkB,GAClB,YAAY,GACZ,mBAAmB,GACnB,eAAe,GACf,cAAc,CAAC;AAInB,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,kBAAkB,CAAC;IAC9B,GAAG,EAAE,YAAY,CAAC;IAClB,aAAa,EAAE,mBAAmB,CAAC;IACnC,MAAM,EAAE,eAAe,CAAC;IACxB,KAAK,EAAE,cAAc,CAAC;CACvB;AAID,MAAM,WAAW,uBAAuB;IACtC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB;IACpB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,wCAAwC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;IAC7B,IAAI,EAAE,QAAQ,EAAE,CAAC;IACjB,oEAAoE;IACpE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,CACT,KAAK,EAAE,cAAc,KAClB,OAAO,CAAC,OAAO,sCAAsC,EAAE,sBAAsB,CAAC,CAAC;IACpF,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB"}
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ // ── Domain & wire types ──────────────────────────────────────────────────────
2
+ // Re-exported from @alivelabs/expo-orchestrator-schemas so the client and the orchestrator share a
3
+ // single, inferred-from-Zod source of truth. The Ws* message and hook types
4
+ // below are React-client-specific and have no schema counterpart.
5
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { UseExpoCiSessionOptions, UseExpoCiSessionResult } from "./types.js";
2
+ export declare function useExpoCiSession({ baseUrl, sessionId, apiToken, autoConnect, maxLogs, }: UseExpoCiSessionOptions): UseExpoCiSessionResult;
3
+ //# sourceMappingURL=useExpoCiSession.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useExpoCiSession.d.ts","sourceRoot":"","sources":["../src/useExpoCiSession.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAKV,uBAAuB,EACvB,sBAAsB,EACvB,MAAM,YAAY,CAAC;AAIpB,wBAAgB,gBAAgB,CAAC,EAC/B,OAAO,EACP,SAAS,EACT,QAAQ,EACR,WAAkB,EAClB,OAA0B,GAC3B,EAAE,uBAAuB,GAAG,sBAAsB,CA+FlD"}
@@ -0,0 +1,84 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { ExpoCiClient } from "./client.js";
3
+ const DEFAULT_MAX_LOGS = 2000;
4
+ export function useExpoCiSession({ baseUrl, sessionId, apiToken, autoConnect = true, maxLogs = DEFAULT_MAX_LOGS, }) {
5
+ const [status, setStatus] = useState(null);
6
+ const [logs, setLogs] = useState([]);
7
+ const [frame, setFrame] = useState(null);
8
+ const [connected, setConnected] = useState(false);
9
+ const [error, setError] = useState(null);
10
+ // We keep the client in a ref so reconnect() can call client.connect()
11
+ // without causing a re-render or stale closure issues.
12
+ const clientRef = useRef(null);
13
+ // Stable reconnect callback
14
+ const reconnect = useCallback(() => {
15
+ clientRef.current?.connect();
16
+ }, []);
17
+ // Stable sendInput callback
18
+ const sendInput = useCallback((input) => {
19
+ const client = clientRef.current;
20
+ if (!client) {
21
+ return Promise.reject(new Error("Client not initialized"));
22
+ }
23
+ return client.sendInput(input);
24
+ }, []);
25
+ useEffect(() => {
26
+ const client = new ExpoCiClient({ baseUrl, sessionId, apiToken });
27
+ clientRef.current = client;
28
+ // Fetch initial session status
29
+ client
30
+ .getSession()
31
+ .then((session) => {
32
+ setStatus(session.status);
33
+ })
34
+ .catch(() => {
35
+ // Non-fatal: WS status messages will keep state up to date
36
+ });
37
+ // Fetch initial logs
38
+ client
39
+ .getLogs()
40
+ .then((sessionLogs) => {
41
+ setLogs(sessionLogs.logs.slice(-maxLogs));
42
+ })
43
+ .catch(() => {
44
+ // Non-fatal
45
+ });
46
+ const offOpen = client.on("open", () => {
47
+ setConnected(true);
48
+ setError(null);
49
+ });
50
+ const offClose = client.on("close", () => {
51
+ setConnected(false);
52
+ });
53
+ const offLog = client.on("log", (msg) => {
54
+ setLogs((prev) => {
55
+ const next = [...prev, { timestamp: msg.timestamp, ...msg.data }];
56
+ return next.length > maxLogs ? next.slice(next.length - maxLogs) : next;
57
+ });
58
+ });
59
+ const offFrame = client.on("video-frame", (msg) => {
60
+ setFrame(`data:image/jpeg;base64,${msg.data}`);
61
+ });
62
+ const offStatus = client.on("status", (msg) => {
63
+ setStatus(msg.data.status);
64
+ });
65
+ const offError = client.on("error", (msg) => {
66
+ setError(msg.data.message);
67
+ });
68
+ if (autoConnect) {
69
+ client.connect();
70
+ }
71
+ return () => {
72
+ offOpen();
73
+ offClose();
74
+ offLog();
75
+ offFrame();
76
+ offStatus();
77
+ offError();
78
+ client.disconnect();
79
+ clientRef.current = null;
80
+ };
81
+ // eslint-disable-next-line react-hooks/exhaustive-deps
82
+ }, [baseUrl, sessionId, apiToken, autoConnect, maxLogs]);
83
+ return { status, logs, frame, connected, error, sendInput, reconnect };
84
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@alivelabs/expo-orchestrator-react-client",
3
+ "version": "0.1.0",
4
+ "description": "React client for Expo CI Orchestrator — streaming logs, live simulator video, and interactive controls.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/alive-home/alive-expo-orchestrator.git",
10
+ "directory": "packages/react-client"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "main": "./dist/index.js",
16
+ "module": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "import": "./dist/index.js",
21
+ "types": "./dist/index.d.ts"
22
+ }
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc",
29
+ "prepublishOnly": "tsc",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "@alivelabs/expo-orchestrator-schemas": "^0.1.0"
34
+ },
35
+ "peerDependencies": {
36
+ "react": ">=18.0.0",
37
+ "react-dom": ">=18.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/react": "^19.2.15",
41
+ "@types/react-dom": "^19.2.3",
42
+ "react": "^19.2.6",
43
+ "react-dom": "^19.2.6",
44
+ "typescript": "^6.0.3"
45
+ }
46
+ }