Troupeby AliveDocsSign in

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

Shell
bun add --exact @alivelabs/troupe-client

Like 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:

TypeScript
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:

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

OptionDefaultWhat it does
baseUrl''The server's origin. The empty default calls the page's own origin
tokennoneA bearer token, or a function that returns the current one. The function may be async
headersnoneExtra headers on every request, such as Troupe-Project to name a cloud project
fetchfetchA replacement for fetch
discoverytrueRead runtime.version once before the first call
onErrornoneCalled 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.

TypeScript
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:

Outputcall resolves to
JSONThe parsed value
Binary, such as media.screenshot and files.get{ bytes, contentType }
An event iterator, such as logs.streamAn 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:

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

TypeScript
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:

TypeScript
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:

CaseThe error
A code this client does not knowUNKNOWN with data.originalCode and the status
An answer without a Troupe error body, such as a proxy's 502 pageUNKNOWN with an empty originalCode
No answer at allUNKNOWN 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.

TypeScript
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:

TypeScript
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:

TypeScript
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

esc

    Type to search every page, the protocol reference and the cloud API.