Integrate
The client SDK
@alivelabs/troupe-client makes typed calls to a Troupe runtime or to Troupe Cloud. This page covers
creating a client, calling procedures, handling errors and reading event streams.
Install
bun add --exact @alivelabs/troupe-clientLike the rest of Troupe, the package is not on npm yet; Install has the status.
The package has two entries. @alivelabs/troupe-client holds the calls, errors, events and pairing.
@alivelabs/troupe-client/stream holds the stream client. Both depend only on
@alivelabs/troupe-protocol, so they are safe in a browser bundle.
It runs under Bun, and in a browser through a bundler. The package ships TypeScript sources, so the bundler
must compile TypeScript inside node_modules: Vite, Bun.build and esbuild do. Node is not supported.
Create a client
A client needs the server's address and a bearer token.
For a local runtime, start the daemon (bunx troupe runtime start --detach) and read both from
runtime.json:
import { homedir } from 'node:os'
import { join } from 'node:path'
import { createClient } from '@alivelabs/troupe-client'
const home = process.env.TROUPE_HOME ?? join(homedir(), '.troupe')
const { url, token } = await Bun.file(join(home, 'runtime.json')).json()
const troupe = createClient({ baseUrl: url, token })The token is the runtime's owner token. It changes each time the daemon starts.
For Troupe Cloud, use the cloud's API address and an API key:
const troupe = createClient({
baseUrl: 'https://api.troupe.ali.ve',
token: () => process.env.TROUPE_API_KEY,
})The calls are the same against either. Only the address and the token differ.
| Option | Default | What it does |
|---|---|---|
baseUrl | '' | The server's origin. The empty default calls the page's own origin |
token | none | A bearer token, or a function that returns the current one. The function may be async |
headers | none | Extra headers on every request, such as Troupe-Project to name a cloud project |
fetch | fetch | A replacement for fetch |
discovery | true | Read runtime.version once before the first call |
onError | none | Called with every TroupeClientError a call raises |
Call a procedure
call takes a procedure's id and its input. Both are typed from the contract, and so is the result.
const session = await troupe.call('sessions.create', { spec: { platform: 'ios-simulator' } })
const { sessionId } = session
const { aria } = await troupe.call('ui.snapshot', { sessionId })
const shot = await troupe.call('media.screenshot', { sessionId })
await Bun.write('screen.png', shot.bytes)A procedure answers in one of three ways:
| Output | call resolves to |
|---|---|
| JSON | The parsed value |
Binary, such as media.screenshot and files.get | { bytes, contentType } |
An event iterator, such as logs.stream | An EventStream |
Actions take a target: a locator, an element ref from the last snapshot, or a point. Build a locator with
the protocol package's Playwright-shaped builder:
import { getByRole } from '@alivelabs/troupe-protocol'
await troupe.call('input.tap', {
sessionId,
target: { locator: getByRole('button', { name: 'Sign in' }).toJSON() },
})The runtime waits for the element to be actionable before it acts, as a test's tap() does. The
reference lists every procedure with its input, output and errors.
Before it sends anything, the client checks the input against the procedure's schema. An invalid input
fails with VALIDATION and no request is made. Outputs are read leniently: unknown fields are kept and
unknown enum values pass through, so a newer server does not break an older client.
call takes a third argument with signal (an AbortSignal), callId (the id the runtime records this
call's trace events under) and requestId.
Discovery
Before the first call, the client reads runtime.version once.
It then refuses, locally and with CONTRACT_UNSUPPORTED, a server whose contract major differs from its own
and a procedure newer than the server.
const { contract } = await troupe.version()
const { platforms } = await troupe.capabilities()
const early = await troupe.supports('sessions.streamBeforeReady')capabilities() returns what each platform of this server can do. Call resetDiscovery() after the
runtime restarts.
Errors
Every failure is a TroupeClientError, except your own abort, which is thrown as it is. The error carries
the protocol's typed error: code, data, retryable and requestId, and the HTTP status.
hasCode narrows an error to one code, so its data is typed:
import { hasCode, isTroupeClientError } from '@alivelabs/troupe-client'
try {
await troupe.call('input.tap', { sessionId, target })
} catch (error) {
if (hasCode(error, 'LOCATOR_NOT_FOUND')) {
console.log(error.error.data.locator, error.error.data.timeoutMs)
} else if (hasCode(error, 'CAPACITY_UNAVAILABLE')) {
await Bun.sleep(error.error.data.retryAfterMs)
} else if (isTroupeClientError(error) && error.retryable) {
// The same request may succeed later.
} else {
throw error
}
}Errors lists every code with its data. One code exists only in the client:
| Case | The error |
|---|---|
| A code this client does not know | UNKNOWN with data.originalCode and the status |
| An answer without a Troupe error body, such as a proxy's 502 page | UNKNOWN with an empty originalCode |
| No answer at all | UNKNOWN with status 0, retryable |
Events
follow reads an event iterator and keeps it going. When the connection drops, it reconnects and resumes
after the last event it saw, waiting 250 ms at first and up to 4 seconds between attempts.
const events = troupe.follow('logs.stream', { sessionId, minLevel: 'warn' })
for await (const event of events) {
if (event.type === 'log') console.log(event.level, event.message)
}The loop ends when the server ends the stream, or when you call events.close() or leave the loop. An error
that is not retryable ends it by throwing. The event iterators are
sessions.events,
logs.stream and
runs.events.
call on the same procedures opens one stream without reconnecting. Each event has a seq; pass the last
one as since to resume yourself.
From a browser page
A page that the runtime serves, as Troupe Studio is, calls its own origin. A page on another origin must be paired with the local runtime first. A script that holds the owner token mints a one-time code for that origin:
const { code } = await troupe.call('pairings.create', { origin: 'http://localhost:5173' })The page redeems the code for a token that works only from that origin:
import { createClient, redeemPairing } from '@alivelabs/troupe-client'
const pairing = await redeemPairing(code, { baseUrl: 'http://127.0.0.1:47123' })
const troupe = createClient({ baseUrl: pairing.baseUrl, token: pairing.token })A pairing may read, acquire and drive devices and read logs. It cannot administer the runtime. Keep the
token in sessionStorage with storagePairingStore; pairFromLocation is the studio's whole flow for a
code in the URL fragment. The pairings procedures are experimental and may change.
A page that shows a Troupe Cloud device does not hold an API key. Your backend calls the cloud and hands the page a stream ticket. See Hosted device sessions.
Next
- The live stream: show and drive a device from a page
- Sessions: the calls that acquire and release a device
- Calling the API directly: the HTTP requests behind
call