@alivelabs/mobile-react-client
v0.3.0
Published
React client for Alive Mobile: REST client, polling hooks, streaming logs, and a live, drivable simulator or emulator.
Readme
@alivelabs/mobile-react-client
React client for Alive Mobile's two-stage build/simulation API. Watch a build to
completion, then render the live simulation: streaming logs, the live iOS simulator
(H.264 decoded with WebCodecs onto a <canvas>), status, and interaction
(click to tap, drag to swipe, type to type).
No orchestration lives here: this package talks to the API and renders. Status, logs and capacity arrive over the API's event stream rather than a poll; see Live updates.
Breaking change: Run is now Simulation
The second stage was called a run; it is now a simulation: booting a built
artifact on a native iOS simulator. Everywhere else in the system a run is an
execution inside a Tart VM, which is exactly what this stage is not. Every export
below is a mechanical rename with no behaviour change, except SimulatorScreen,
which is now SimulatorCanvas.
| Was | Now |
|---|---|
| Run | Simulation |
| RunList | SimulationList |
| RunStatus | SimulationStatus |
| RunStream | SimulationStream |
| CreateRunBody | CreateSimulationBody |
| ListRunsQuery | ListSimulationsQuery |
| useRun | useSimulation; its result field run is now simulation |
| useRuns | useSimulations; its result field runs is now simulations |
| UseRunResult | UseSimulationResult |
| UseRunsResult | UseSimulationsResult |
| RunScreen / RunScreenProps | SimulationScreen / SimulationScreenProps |
| SimulatorScreen / SimulatorScreenProps | SimulatorCanvas / SimulatorCanvasProps |
| client.createRun | client.createSimulation |
| client.getRun | client.getSimulation |
| client.listRuns | client.listSimulations |
| client.getRunLogs | client.getSimulationLogs |
| client.cancelRun | client.cancelSimulation |
| client.setRunOrientation | client.setSimulationOrientation |
The wire follows: the API is now /api/simulations/…, the id field is
simulationId (prefix sim_), and the error codes are SIMULATION_NOT_FOUND /
SIMULATION_NOT_STREAMING. Status values are unchanged: queued,
dispatched, booting, streaming, ended, failed, canceled. There are no
deprecated aliases; the old names are gone.
Install
bun add @alivelabs/mobile-react-client # peers: react, react-dom (>=18)Quick start
import {
OrchestratorClient, SimulationScreen, LogConsole, StatusBadge, useBuild, useSimulation,
} from "@alivelabs/mobile-react-client";
const client = new OrchestratorClient({
baseUrl: "https://your-orchestrator.example.com",
token: import.meta.env.VITE_API_TOKEN,
});
function Pipeline({ buildId, simulationId }: { buildId: string; simulationId: string | null }) {
const { build, logs: buildLogs } = useBuild(client, buildId);
const { simulation, logs: simulationLogs, stream } = useSimulation(client, simulationId);
return (
<>
<StatusBadge status={build?.status ?? null} />
<LogConsole logs={buildLogs} />
{simulation?.status === "streaming" && stream && (
<SimulationScreen
stream={stream}
onRotate={(orientation) =>
client.setSimulationOrientation(simulation.simulationId, orientation)
}
/>
)}
<LogConsole logs={simulationLogs} />
</>
);
}SimulationScreen owns the WebSocket to the simulation's stream, the WebCodecs
decode, and the input wiring. Give it stream (from useSimulation().stream or
Simulation.stream) and it renders a drivable simulator. Create the
build/simulation with the client (createBuild / createSimulation) and pass
the ids in; the hooks keep the rest current. Rotation is the one control it cannot do
alone: it travels REST rather than the stream socket, so pass onRotate (omit it
and the rotate buttons are hidden).
WebCodecs needs a modern browser (Chrome/Edge, Safari 16.4+, recent Firefox) and a
secure context (HTTPS or localhost). For full control, drive your own canvas with
SimulatorCanvas + SimulatorDecoder.
Live updates
The hooks read the API's SSE event stream (GET /api/events) instead of polling it
once a second. No hook signature changed: useBuild(client, id) still returns
{ build, logs, error } and still keeps itself current. What changed is the cost.
Over one 133-second build, the old client would have issued 266 requests (status
and logs at 1 Hz); the new one held a single connection carrying 3 job events and
24 log frames (4,987 log lines), plus 8 safety-net reads.
Three things matter if you build on this:
- Polling is the fallback, not the design. A client that cannot hold a stream falls back to 2 s for a job page and 5 s for a list. The log cursor is shared between both paths, so a stream that drops mid-build carries on from where it stopped instead of rewinding to line one, and recovering to the stream resumes from the same number.
- A resync runs even while the stream is healthy, about every 30 s, at the
cadence the server's
helloframe asks for. It is the backstop for an event that was never emitted, which is the one failure this design can have, and it is why a hook cannot sit permanently on a stale status. - Hooks that need no log subscription share one connection per client. An
overview with three lists and worker health holds one socket rather than four,
which matters because browsers cap HTTP/1.1 at six per origin. The sharing key is
the
OrchestratorClientinstance, so memoise it rather than constructing one per render.
client.openEvents(handlers) is that stream unwrapped, for code that is not a hook:
const handle = client.openEvents({
logOwner: "build", // with logId: subscribe to one job's logs
logId: buildId,
onJob: (e) => { if (e.kind === "build") setBuild(e.entity); }, // whole entity
onLog: (e) => append(e.logs), // { logs, nextSince, ownerType, ownerId }
onWorkers: (list) => setWorkers(list.workers),
onStatus: (s) => setStatus(s), // "connecting" | "open" | "degraded"
onFatal: (err) => setError(err), // refused outright (401/403/404)
});
handle.close(); // or its retry loop outlives the callerStreamStatus, EventStreamHandlers and ClientEventStreamOptions are exported
for it. Job and worker events reach every stream, so filter onJob by id. A
dropped connection is retried with jittered backoff and is not an error; onFatal
fires only for a response the server refused, which is a configuration problem
rather than a blip.
Theming
The components style themselves with inline CSSProperties and ship no stylesheet, so there is
nothing to import and nothing to override with a selector. They are still themeable: every colour
is written var(--alive-x, <default>), where the default is the dark value they have always used.
Define nothing and nothing changes. Define these on :root (or on any ancestor of the
components) and they follow, including under prefers-color-scheme since a custom property can
be redefined in a media query where an inline style cannot.
| Variable | What it colours |
|---|---|
| --alive-bg · --alive-surface | the console body, the controls panel |
| --alive-border · --alive-border-strong · --alive-border-focus | panel edges, the text input, the focus ring |
| --alive-fg · --alive-fg-muted · --alive-fg-dim | body text, headings, timestamps |
| --alive-accent · --alive-ok | the primary button, the "live" dot |
| --alive-danger-surface · --alive-danger-line · --alive-danger-fg | the error banner and the rotate error |
| --alive-log-{stdout,stderr,system} and each -tag | a log line's message, and its brighter level tag |
| --alive-pill-idle-bg · --alive-pill-idle-fg | StatusBadge in its two colourless states |
Two things are deliberately not themeable. SimulatorCanvas draws a phone: its bezel gradient
and the black behind the video stay dark, because a phone is a dark object on any desk and a white
one reads as a rendering fault rather than as a light theme. And the nine saturated status pills
keep their own colours, since white on a solid red or green is legible against either background.
Exports
| Export | Kind | Description |
|---|---|---|
| OrchestratorClient | Class | REST client for the API (below). |
| OrchestratorError | Class | Thrown by every client method on a non-2xx: status, code, message, details. |
| useBuild | Hook | (client, buildId \| null) → { build, logs, error }; a build + its logs, live, until terminal. |
| useSimulation | Hook | (client, simulationId \| null) → { simulation, logs, stream, error }; a simulation + its logs, live, surfacing stream once streaming. |
| useBuilds | Hook | (client, query?) → { builds, nextCursor, error }; a page of builds, kept current, forever. |
| useSimulations | Hook | (client, query?) → { simulations, nextCursor, error }. |
| useGhaJobs | Hook | (client, query?) → { jobs, nextCursor, error }. |
| useWorkers | Hook | (client) → { workers, error }; worker health and slot usage. |
| SimulationScreen | Component | All-in-one live simulation: connects to stream, decodes video, renders a drivable canvas. |
| SimulatorCanvas | Component | Lower-level canvas renderer (click = tap, drag = swipe); used by SimulationScreen. |
| SimulatorDecoder | Class | WebCodecs H.264/AVCC decoder painting to a <canvas> (advanced). |
| LogConsole | Component | Scrollable, color-coded, auto-scrolling log viewer. |
| StatusBadge | Component | Colored pill for any PipelineStatus: build, simulation, or GitHub Actions job. |
The list hooks never stop: a list has no terminal state, since new jobs keep
arriving. A matching job event invalidates the page and schedules a coalesced
refetch, so a burst of jobs changing at once is one request rather than one each,
and an idle system makes none. A failed read sets error and keeps the last good
page on screen.
<SimulationScreen> props (SimulationScreenProps)
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| stream | SimulationStream | yes | n/a | The simulation's direct-stream descriptor (Simulation.stream / useSimulation().stream). |
| onRotate | (o: Orientation) => void \| Promise<void> | no | n/a | Rotate the device; omit and the rotate controls are not rendered. |
| orientation | Orientation | no | n/a | The server's truth for where the device is turned; without it a rotated device paints sideways on reload. |
| fps | number | no | 10 | Target frame rate. |
| bitrate | number | no | 4_000_000 | Target bitrate in bits/sec. |
| maxHeight | number \| string | no | "80vh" | Cap on the rendered screen height. Ignored by chrome="compact", which sizes itself. |
| layout | "stacked" \| "split" | no | "stacked" | Controls under the screen, or beside it. Ignored by chrome="compact". |
| chrome | boolean \| "compact" | no | true | true: full controls panel. false: bare stream. "compact": fill a fixed-height container with the picture plus a one-row toolbar (status, Home, App Switcher, rotate arrows, type-to-send) and a "More" popover holding the preset groups and orientation grid. The picture is sized from the container and the stream's live aspect ratio — nothing scrolls, nothing is clipped. Give the container a definite height. |
| className | string | no | n/a | CSS class on the root element. |
<LogConsole> props (LogConsoleProps)
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| logs | Log[] | yes | n/a | Log entries ({ level, message, timestamp }). |
| title | string | no | "Logs" | Heading text. |
| maxHeight | string \| number | no | 400 | Max-height of the scroll area. |
<StatusBadge> props (StatusBadgeProps)
| Prop | Type | Required | Description |
|---|---|---|---|
| status | PipelineStatus \| null | yes | Build, simulation, or GHA job status to display. |
OrchestratorClient
const client = new OrchestratorClient({ baseUrl, token });
// Build stage
client.createBuild({ source: { type: "git", repoUrl, branch }, platform, appRoot, env });
client.uploadBuildSource(buildId, gzipBytes); // for { type: "tarball" } sources
client.getBuild(buildId); // → Build
client.listBuilds({ limit, cursor, status }); // → BuildList { items, nextCursor }
client.getBuildLogs(buildId, since); // → LogPage { logs, nextSince }
client.cancelBuild(buildId);
// Simulation stage
client.createSimulation({ buildId /* or artifactId */, simulatorName, osVersion, devServerUrl });
client.getSimulation(simulationId); // → Simulation (with `stream` once streaming)
client.listSimulations({ limit, cursor, status }); // → SimulationList
client.getSimulationLogs(simulationId, since); // → LogPage
client.cancelSimulation(simulationId);
// Driving a simulation over REST (a streaming viewer should use its own socket instead)
client.setSimulationOrientation(simulationId, "landscape-left");
client.sendInput(simulationId, { type: "tap", x, y, width, height });
client.screenshot(simulationId, { scale, quality }); // → Blob
client.describeUi(simulationId, { x, y }); // → { tree }
// Overview
client.listGhaJobs({ limit, cursor, status }); // → GhaJobList
client.getWorkers(); // → { workers }
client.getDeviceCatalog(); // → DeviceCatalog: what the simulator host can boot
// Live events (see above): returns a handle you must close
client.openEvents({ logOwner, logId, since, onJob, onLog, onWorkers, onStatus, onFatal });Pass getBuildLogs/getSimulationLogs the previous response's nextSince to fetch
only newer lines, and carry it forward even when a page came back empty. Dropping
it and the next read rewinds to the start of the log. The id on a log event is
the same cursor, so the two ways of reading logs interoperate exactly.
Every method throws OrchestratorError on failure, carrying the API's own
code so a caller can branch on it instead of matching an English sentence.
The wire types (Build, Simulation, SimulationStream, Log, DeviceInput,
Orientation, …) are re-exported from @alivelabs/mobile-schemas.
