npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@alivelabs/mobile-react-client

v0.6.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 or paste to type; and on iOS, live touch: a drag or a hold is a finger that moves as the pointer moves, two fingers on a touch screen are two, and a trackpad pinches and scrolls).

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.

Changed: SimulatorCanvas with onTouch makes every press a live finger

Pass onTouch (a device whose stream's hello says touch) and the canvas no longer turns a drag into an onSwipe, or a long press into an onTap with a duration: every press is a live finger from the moment it goes down until it is let go, so a tap lasts as long as it really did, a hold opens the device's own long press while it is held, and a drag keeps its speed. Two fingers on a touch screen are two. Each frame now carries t, when it was made; forward it only to a server whose hello says touchTimestamps, since an older one refuses a frame that has it. SimulationScreen does both for you. Without onTouch nothing changed: a click is a tap, a drag a swipe, a long press a tap with a duration.

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",
  // A fixed string (an access token, or a simulation's stream ticket for the three
  // stream-ticket routes), or a provider that keeps an access token issued by Alive fresh:
  // see "Signing in with Alive" below.
  token: tokenProvider,
});

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 the simulator: drivable when stream.token is a drive ticket (the reader held simulations:drive), watch-only when it is a view ticket, with no controls and a canvas that takes no input. 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).

Stream tickets (0.4). stream.token is a short-lived ticket, and the screen handles its life for you. It connects to /stream with the ticket in Sec-WebSocket-Protocol, never in a URL. It keeps the fresh ticket the server pushes over the open socket every minute. When the socket drops, it reconnects on its own with that ticket, and nothing asks the API: at once if the socket had been streaming for at least 5 s, otherwise after 250 ms, doubling to 4 s, with jitter. Only such a healthy stretch resets the backoff: a socket that opens and closes, or delivers one frame and dies, counts as a failure. A new stream for the same simulation (every re-read carries a new ticket) never tears down a socket that is working.

If the server refuses every ticket the screen holds, it calls onStreamExpired. Answer it with one read of the simulation, passing the stream that read returns, and nothing more: the screen already paces the calls, at once for the first refusal and then after about 1, 2, 4 and 8 seconds, and after five refused tickets in a row with no healthy stretch in between it stops and shows an error rather than asking again (a remount, or a stream for another simulation, starts it afresh). A host that retries on its own schedule on top of that, or loops on the callback, is how a stream server with a clock two minutes out of step once got hammered 80 times a second. A host that already re-reads on a timer (the hooks do) can leave the prop out. A stream.token that is not a stream ticket cannot be presented to the server: the screen shows an error, calls onStreamExpired once and opens no socket. This client speaks tickets only, so it does not work against a control plane older than them.

Adaptive streaming (0.5). With adaptive, fps and bitrate are the most the stream asks for, and the screen picks the setting the link can carry and changes it while streaming, on the open socket, with no reconnect.

  • What it measures. A driving socket sends a ping, and the server's pong comes back queued behind the video already sent. Its round trip, minus the quietest one seen, is the backlog between the encoder and the viewer.
  • Stepping down. A backlog that stands for a second (150 ms or more, and not draining) steps down to the highest setting that fits what got through. So does a decoder that falls four frames behind. The settings below the cap are 30, 20, 15, 10 and 5 fps, each at about 100 kbit per frame, rates that divide 60 so a 60 Hz screen keeps an even cadence. The last is 5 fps at half that, 0.25 Mbps, so a link under ~600 kbps has room to drain its backlog.
  • Stepping up. It steps back up one setting after 8 s of calm in which the link recently carried real video, and never sooner than 15 s after a step down. A step up is a probe: within 10 s of it, a smaller backlog (80 ms) sends it back to the setting it left, and the wait before the next one doubles, up to 2 minutes.
  • Change cost. Each change restarts the simulator's encoder and costs every viewer a keyframe, so a step down waits 4 s for the old backlog to drain before another.
  • Across remounts. It remembers each stream's setting for 10 minutes, so a remount does not start over at the cap.

Only a server that applies settings while streaming answers the ping: an iOS simulator does; an Android emulator, and a server older than 0.5, do not. Without an answer the socket pings once and stays at fps/bitrate. A watching socket never pings: the encoder is the driver's to set. ADAPTIVE_STEPS lists the settings below the cap.

When the picture or the input stops working. The screen recovers, or says why, rather than sitting on a frozen frame:

  • A decoder that fails is rebuilt. WebCodecs closes a decoder that errors (a GPU reset, or Chrome reclaiming a background tab's codec), and the server never resends a description a socket already has, so the screen keeps the description and rebuilds the decoder from it, then asks for a keyframe. One that keeps failing before it lasts 5 s is rebuilt at most three times (at once, after 1 s, after 4 s); after that it asks the device for nothing and tries again only with a keyframe the stream sends anyway, at most every 5 s. While the page is hidden nothing is rebuilt: the rebuild, and its keyframe, wait for the page to be shown. SimulatorDecoder does all of this itself; onKeyframeNeeded and onRecovered are how a host that drives its own canvas wires it up.
  • A socket that goes silent is replaced. A server that names a heartbeatMs in its hello pushes something at least that often, so after 2.5 heartbeats of silence (150 s by default) the screen gives the socket up and reconnects. A watcher sends nothing, so before this nothing ever noticed a path that died under it.
  • Input the device did not take is said over the picture, for 5 s, without covering it: a Maestro flow driving the device, a view-only viewer, input the device cannot take. It is the server's input_refused (StreamConnectionEvents.inputRefused), shown through InputNotice and useInputNotice, which a host drawing its own screen can use the same way. The screen's own notes about typing and pasting (below) are said the same way.

Typing and pasting. With the screen focused, keys go to the device and the page keeps none of them, Tab and Escape included: Shift+Escape is the way out. The device takes printable US-ASCII text only. Typography with a plain form is typed in it (’ and ‘ as ', “ and ” as ", hyphens, dashes and the minus sign as -, … as ..., a no-break space as a space, and a zero-width space or byte order mark as nothing), which covers what iOS's Smart Punctuation, macOS text and web pages put in; anything else (é, an emoji) sends nothing, and the screen says so over the picture (InputNotice) rather than dropping it without a word.

  • Paste. Cmd+V (Ctrl+V elsewhere) types this computer's clipboard text, read from the paste event, so no clipboard permission is asked for. Line breaks go as Enter and tabs as Tab. Up to 1,000 characters (on iOS a character takes 8 ms to type, a shifted one 16, and the device takes no other input meanwhile); longer text, or text with a character the device cannot take, is not pasted at all. When the browser brings no text (an image, or a browser that fires no paste on the screen), the device's own paste is sent instead (below).
  • The device's own clipboard, on iOS. Cmd+Shift+V (Ctrl+Shift+V elsewhere) sends the device ⌘V, which pastes what was last copied inside the app. Always ⌘V, whatever was pressed here: iOS pastes on ⌘V only. Android has no such paste, since its input path takes no Cmd or Ctrl key, so the screen's description leaves it out for platform="android".
  • Input methods. Keys an IME is composing with are not sent; the text it commits is typed when it is US-ASCII.
  • A phone's keyboard. On a touch device, a Keyboard button (in the controls panel, and in the "compact" toolbar) raises the phone's own keyboard. What is typed, autocorrected or pasted there reaches the device as the keys that make the same edit, with the same 1,000-character cap on one edit. A one-line input drops a paste's line breaks, so a multi-line paste arrives as one line there; Cmd+V on the screen keeps them. Tapping another text field (the type-to-send field, a chat box of the host's, one inside a frame or a web component) hands it the keyboard. chrome={false} has no button.
  • Across a reconnect. A tap, key or paste given while the socket is reconnecting is held, at most 32 lines for at most 1.5 s, and sent in order once it is back; anything older, or crowded out by newer input, is dropped, a paste whole, and the note says which. Trackpad touch frames are never held, and never sent before the server has taken the socket. Text in the type-to-send field stays there if it could not be sent.

Pass platform (the artifact's) so a screen reader names the device; without it the screen is a "Device screen".

The connection policy itself (StreamConnection, with streamIdentity and the StreamConnectionEnv / StreamSocket types) has no React in it, so a host that draws its own canvas can use it directly. Give it each fresh stream with offer(); the outcome says whether it was taken (accepted), changed nothing (unchanged), belongs to another simulation or place (other-stream, which needs its own connection) or came after stop() or the give-up (stopped). Send taps, keys and text with sendInput(lines), whose answer (InputDelivery) is sent, queued while the socket is away, or refused; inputDropped(lines, reason) reports held input that never went (stale, full or watching). send(line) is for live touch frames, which are never held and go only on a socket the server has taken.

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 hello frame 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 OrchestratorClient instance, 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 caller

StreamStatus, 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.

Signing in with Alive (OAuthTokenProvider)

Alive Mobile holds no accounts. Alive does, and it is the OAuth 2.1 authorization server the API trusts: every token the API accepts is a JWT Alive issued for it by name (RFC 8707 resource). A browser app signs in there with authorization code + PKCE and hands the client a provider that keeps the access token fresh:

import {
  beginSignIn, completeSignIn, OAuthTokenProvider, OrchestratorClient, type SignInConfig,
} from "@alivelabs/mobile-react-client";

const config: SignInConfig = {
  issuer: "https://app.alive.site",                        // exactly as Alive's metadata prints it
  discoveryUrl: "https://app.alive.site/api/oauth/.well-known/oauth-authorization-server",
  clientId: "alive-mobile-viewer",                         // Alive's client for this app
  redirectUri: `${location.origin}/callback`,              // registered on that client, exactly
  resource: "https://api.example.dev",                     // the API's resource identifier
  scopes: ["builds:read", "simulations:read", "admin"],
};

location.assign(await beginSignIn(config));                // on the sign-in button
await completeSignIn(config, location.search);             // on /callback

const token = new OAuthTokenProvider({ config, onExpired: () => showSignIn() });
const client = new OrchestratorClient({ baseUrl: config.resource, token });
await client.me();                                         // who the API takes you to be

Every token names one Alive workspace, picked at Alive's sign-in, and the API shows that workspace's builds, simulations and Maestro runs and nothing else; me() says which (kind: "workspace", with its tenant). A platform admin who picks "All workspaces" in an admin-capable client gets kind: "admin" instead. A token that names no workspace and is neither is refused on every route with 403 WORKSPACE_REQUIRED: sign in again and pick one.

  • Discovery is checked. discoverAuthorizationServer reads the metadata once and refuses a document that names another issuer (RFC 8414 §3.3), an endpoint that is not https (loopback excepted, for development), or a server without S256.
  • The callback checks state first. completeSignIn refuses a callback this tab did not start before it believes or shows anything the callback says (a forged error_description included), and such a callback leaves the tab's pending sign-in in place, so it cannot cancel a real one on its way back.
  • Tokens are bound to their configuration, and to one origin. They are stored with the issuer, client and resource they were issued for, and a pair for anything else is ignored rather than sent. Every request names where the token is going (TokenRequest.destination; the REST client and the event stream always pass it), and OAuthTokenProvider refuses, with TokenDestinationError, to hand a token to any origin but its resource's.
  • A failed refresh is a sign-out only when Alive refused the refresh token: invalid_grant, or a 401 from its token endpoint (isRefreshRejection). Then the refresh token is revoked, the stored pair cleared, onExpired fires, and the call rejects with SessionExpiredError. A network error, a timeout or a 5xx keeps the pair and rejects with that error, so the next call can succeed. Any other 4xx from Alive (invalid_scope, unauthorized_client, invalid_target, …) is isTerminalOAuthError: the pair is kept, and asking again gets the same answer, so a UI shows it rather than retrying on a timer.
  • endSession(config) is the one way to end a session on purpose: under the refresh lock, it reads the stored pair, revokes its refresh token at Alive, then clears it. The lock is what stops a refresh in another tab from storing a new pair just after the clear.
  • One refresh per origin, not per tab. The pair lives in localStorage, which every tab shares, and Alive revokes a family whose refresh token is redeemed twice. The provider refreshes under a Web Lock (navigator.locks) and re-reads storage inside it: a tab that waited uses the pair another tab just stored. Where the Web Locks API is missing (an insecure context), refreshes are de-duplicated within the tab only.
  • revokeToken(config, refreshToken) revokes at Alive's revocation endpoint, best effort.

The client retries a request once on a 401 with a forced refresh; a 403 is never retried, since a fresh token carries the same scopes. Both, once final, also go to onAuthFailure (an OrchestratorClient option), from REST calls and the event stream alike, before the call rejects: a 403 can mean one missing scope or a session that is not good for this client at all, and only an app-wide identity check tells the two apart. The viewer uses it for exactly that.

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 | | --alive-notice-surface · --alive-notice-fg · --alive-notice-border | InputNotice, the note over the picture. Dark with light text by default on every theme, and none of the tokens above reaches it: it sits on the video, like the bezel, so only these three change it |

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 for a mouse, a pen or a touch screen (click = tap, drag = swipe; with onTouch, every press, two fingers and a trackpad's pinch and scroll are live touch; keys and pastes through onKeyInput / onKeyInputs, with onInputNotice for what could not be sent); used by SimulationScreen. | | KeyboardCapture | Component | The hidden input behind the phone-keyboard button: open it, from the tap itself, and what the phone's keyboard types arrives as type and key inputs through onInputs (answer false for a batch the stream refused). onClose is called when focus goes to another text field; change resetKey when input already handed over was dropped. With useCoarsePointer() to tell a touch device. | | SimulatorDecoder | Class | WebCodecs H.264/AVCC decoder painting to a <canvas> (advanced). Rebuilds itself after a decoder error; wire onKeyframeNeeded to StreamConnection.requestKeyframe. | | InputNotice / useInputNotice | Component / Hook | A polite status over the picture, and the state behind it: show(text) for 5 s. What SimulationScreen says a refused input with. | | 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. | | platform | Platform | no | n/a | The device's platform (the artifact's), for the screen's accessible name. | | onStreamExpired | () => void | no | n/a | Called when the server has refused every stream ticket the screen holds, and once, with no socket opened, when stream.token is not a stream ticket at all. Answer with ONE read of the simulation and pass the stream it returns (or unmount if it has ended); never retry on a schedule of your own. The screen paces the calls — at once, then about 1, 2, 4 and 8 seconds — and after five refusals with no healthy stretch of video in between it stops and shows an error. Leave it out and the screen waits for a fresher stream to arrive on its own, which is enough for a host that re-reads on a timer (the hooks do). | | fps | number | no | 10 | Target frame rate; with adaptive, the most the stream asks for. | | bitrate | number | no | 4_000_000 | Target bitrate in bits/sec; with adaptive, the most the stream asks for. | | adaptive | boolean | no | false | Let the stream choose its frame rate and bitrate, at most fps and bitrate, from the backlog it measures. See "Adaptive streaming (0.5)". | | 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 });   // token: string | TokenProvider

client.me();                                           // → Principal: kind, issuer, clientId, subject, scopes, tenant (a workspace's)

// 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.