docs
TypeScript SDKCore Concepts

Transport

Session phases, reconnection, and consume-only viewer attach

The SDK manages the media transport for you. Invoking a function allocates a session, resolves a connect handle from the control plane, negotiates a media transport, and streams media — with regional failover and automatic reconnection. You observe it through the high-level Session phase, not a raw connection object.

Session phases

Every session exposes one of nine phases you can render directly:

idle → queued | unavailable | provisioning → connecting → live → ended | expired
                                                        ↘ error
PhaseMeaning
idleCreated, not yet admitted or connected
queuedWaiting in the admission queue — phase.queue = { position, depth }
unavailableThe control plane reports no runtime exists to serve this function (not deployed, crashed, or scaled to zero) — non-terminal; polling continues and the session recovers when a runtime appears
provisioningAdmitted; GPU compute is being allocated/warmed
connectingTransport is establishing the media session
liveFirst media track has arrived
errorAdmission or transport failed — phase.error = { reason, code?, kind? }
endedIntentionally disconnected / closed
expiredThe session reached its declared maximum length — terminal and honest, distinct from error (nothing failed; see session.endsAt)

The waiting phases (queued/unavailable/provisioningisWakingPhase()) also carry phase.runtime ({ state: 'unavailable' | 'starting' | 'busy', reason? }) and phase.wakingSince, one continuous wait timestamp across the whole wake. Subscribe with onPhase, which fires immediately with the current phase and on every transition — describeSessionPhase(p) gives honest default copy:

import { describeSessionPhase } from '@urun-sh/core'

session.onPhase((p) => {
  statusEl.textContent = describeSessionPhase(p)   // "Starting model…", queue position, …
  if (p.name === 'live') hideSpinner()
})

await session.whenLive()   // resolve at 'live'; reject with a typed SessionFailedError

Render the waiting phases as real progress (a queue position, "starting model…", an elapsed wake counter) rather than one opaque spinner. See Session lifecycle for the full honest-UX toolkit.

Reconnection

Beneath the phase sits a lower-level TransportState: connecting | connected | reconnecting | renegotiating | disconnected | failed. reconnecting is the typed signal that the SDK lost the transport and is actively re-dialing (and re-resolving the connect handle, including cross-region redirects). renegotiating is the narrower signal that the session is still admitted but its media path went dead (a recycled backend) and the SDK is re-establishing onto the rebound backend for the same session id. Treat both as recoverable — render a hint, not a dead session.

What survives a reconnect. A transport drop does not by itself tear down the session: the runtime process stays resident while the SDK re-dials, so a successful reconnect re-attaches to the same warm session — no cold start, no re-invocation. The control doc re-syncs on reconnect via CRDT merge, so session state (prompt, pose, settings) is preserved rather than reset, and an in-flight session.request(...) survives because it rides the doc. If reconnection does not succeed within the session's disconnect grace window, the session is reaped and its replica freed; a later invocation then opens a fresh session (and may cold-start). State you need to outlive that window should be written to Store, not held only in the control doc.

Transport negotiation

The SDK dials QUIC/WebTransport first and falls back to WebRTC — negotiation self-gates on browser capability and server support, so most apps never think about it. session.mediaTransport reports which path carries media: 'webtransport' once QUIC won the dial, 'webrtc' after the floor or a failover, null while undecided or where negotiation never ran (Node, older browsers). AppOptions.allowWebTransport: false forces the WebRTC path.

On the WebRTC path, uRun handles restrictive networks for you: ICE servers arrive with the connect handle (always STUN; a TLS-over-TCP/443 relay only as a last resort on DPI/UDP-blocking networks). You never configure ICE servers yourself; relayed paths add a little latency, and live is your signal that media is flowing regardless of which path won.

Streams

Inbound and outbound media flow through named streams on the session:

const video = session.stream('video')

// Inbound: the latest track produced by the function
video.on('track', (track) => {
  if (track) videoEl.srcObject = new MediaStream([track])
})

// Outbound: publish a local track upstream — attach() for mic audio,
// attachVideo() for camera video (a later call swaps the source via replaceTrack)
const cam = await navigator.mediaDevices.getUserMedia({ video: true })
await video.attachVideo(cam.getVideoTracks()[0])
// later: await video.detachVideo()   (audio: attach() / detach())

// Seek a persisted finite stream, or return to the live edge:
await video.seek(12.5)
await video.seek('live')

Streams are recorded by default (the DVR), so seek(t) addresses the retained recording window; seek('live') snaps back to the live edge. Manage retention (retain/pin/permanent/delete) through session.recordings — see Session lifecycle.

Stream names are the contract with the Python side: session.stream("video")ctx.stream("video").

Viewer attach (consume-only)

To join an already-running session as a watcher — without taking the publisher slot or allocating a GPU — use Session.attach:

import { Session } from '@urun-sh/core'

const viewer = await Session.attach(sessionId, {
  baseUrl: 'https://urun.sh',
  orgId: 'your-org-id',
  jwt: await getCurrentUserJwt(),
})

const video = viewer.stream('video')
video.on('track', (track) => { if (track) el.srcObject = new MediaStream([track]) })

A viewer watches the live producer. It never allocates a GPU, never takes the publisher slot, and never tears down the session when it disconnects. Cross-org or unknown session ids are rejected.

Related:

On this page