@urun-sh/core
v0.6.31
Published
Core transport and channel primitives for the urun TypeScript SDK
Maintainers
Readme
@urun-sh/core
Core TypeScript proxy for apps deployed on urun from Python.
Install
npm install @urun-sh/coreQuick start
import { App } from '@urun-sh/core'
const app = App('my-app', {
baseUrl: 'https://urun.sh',
orgId: 'your-org-id',
jwt: await getCurrentUserJwt(), // advanced/manual path; @urun-sh/react can resolve WorkOS tokens for you
authProvider: 'workos',
})
const session = app.generate({ prompt: 'A sunset' })
// Consume the stream the runtime produces (direction is decided by first use). Tracks
// arrive ASYNCHRONOUSLY — subscribe with `.on('track', …)`; never read `.track` once.
const video = session.stream('video')
video.on('track', (track) => {
if (track) videoElement.srcObject = new MediaStream([track])
})
// Drive the app by writing DESIRED state into the control doc; the runtime reactively
// reconciles it (cheap config via an inline live read, structural change via `doc.bind`).
session.doc('control').set({ desired: { prompt: { text: 'A forest' } } })The names mirror the Python side 1:1:
App('my-app')mirrorsurun.App('my-app')app.generate()mirrors@app.function def generate(...)session.stream('video')mirrorsctx.stream('video', codec=...)session.doc('control')mirrorsctx.doc('control')createStore()mirrors the runtimestore = app.store
Streams are symmetric (produce-or-consume by first use)
session.stream(name) is one symmetric primitive — the first producer decides the
direction and the peer consumes (full-duplex = two named streams). On the runtime,
ctx.stream("name") is the polymorphic I/O endpoint: the pipeline HEAD when it drives and the
TAIL when it sinks. attach(track) makes the client the producer of that name (the inbound HEAD
the runtime composes with ctx.stream(name) | …); calling stream(name) without attaching
consumes the runtime's output. A second producer on the same name fails loud with
StreamDirectionConflict — never a silent merge. The runtime ctx.stream(codec=…) sink owns
media encode; the client only attaches the inbound track to a media element. Tracks arrive
asynchronously — subscribe with .on('track', …).
Muxed A/V arrives as two tracks — play them through one element
A runtime that egresses ctx.stream("av", video_codec=…, audio_codec=…) puts TWO RTP tracks on
the wire. Put both into ONE MediaStream on ONE media element so the browser's native A/V sync
machinery owns the alignment (@urun-sh/react's <Video stream="av" muted={false} /> is that
shape, prebaked):
const stream = new MediaStream()
element.srcObject = stream
session.stream('video').on('track', (t) => t && stream.addTrack(t))
session.stream('audio').on('track', (t) => t && stream.addTrack(t))Two separate elements give the browser two independently-buffered playout paths with nothing aligning them: the video track renders on arrival (a delivery gap freezes, then drops — no standing delay), while the audio track's jitter buffer conceals each gap and plays the late audio after it, so gaps ACCUMULATE into standing playout delay. RTCP sender reports are the only payback mechanism, and an SFU that re-mints them per track weakens it.
The SDK therefore also caps every inbound AUDIO receiver's jitter buffer at 300 ms
(RTCRtpReceiver.jitterBufferTarget, plus the legacy playoutDelayHint where present), which
bounds that accumulation regardless of sender-report quality. Retune it with
App(appId, { audioPlayout: { jitterBufferTargetMs: 500 } }); <= 0 sets no target at all.
Engines exposing neither property (Firefox, Safari today) are unaffected and emit an
audio-jitter-buffer-unsupported diagnostic so the gap is visible rather than silent.
The control doc is the reactive desired-state surface
The client does NOT call an imperative start/update/stop. It writes desired.* keys into a
control doc with session.doc(name).set(...). The runtime reads CHEAP config inline from the
live doc.desired.* props and uses doc.bind(construct, [live props]) (React useMemo) to
reconstruct a stage on STRUCTURAL change — the single reconcile lane, no field-mapping, no
second hand-poll (pipeline.bind / stage.bind(doc, mapping) are retired). Keep the
desired.<path> names the client writes consistent with what the runtime reads.
API
App(appId, options)— reference a deployed urun app.app.<function>(args?)— call a Python function and get a session handle.session.stream(name)— get a named symmetric stream (media or data); first use decides direction.session.stream(name).track— read the latest inbound track (consume side).session.stream(name).on('track', cb)— subscribe to inbound tracks as they arrive.session.stream(name).attach(track)— produce: send a local browser MIC/AUDIO track upstream.session.stream(name).attachVideo(track)— produce: send a local browser VIDEO track upstream (a later call swaps the source viareplaceTrack).detach()/detachVideo()stop producing.session.stream(name).emit(payload)/.messages()— the DATA lane: produce a payload on the named stream / consume it as an async iterable (subscribing is opt-in).session.stream(name).seek(seconds)— seek a persisted finite stream to seconds from start.session.stream(name).seek('live')— return a live stream to the live edge.session.doc(name).get()— read the merged document snapshot.session.doc(name).set(patch)— write desired state into a session document.session.doc(name).on('change', cb)— react to runtime-published document changes.session.request(payload)/.requestStream(payload)/.complete(payload)— correlated request/response riding a session doc (reconnect-safe);requestStreamyields incremental deltas.session.onPhase(cb)/session.whenLive()— the nine honest lifecycle phases and the "is media ready?" gate (rejects with a typedSessionFailedError).session.end()/session.disconnect()— terminal release vs. detach-and-stay-reconnectable.Session.attach(sessionId, options)— join a running session as a consume-only viewer.createStore(options)— content-addressed global store:get(key)/has(key)reads andon(name, cb)/emit(name, data)global streams. (App-scopedstore.cacheis a runtime concern; content-addressed blobs —store.model/store.path/store.repo— are not app-scoped.)
Authentication
Browser clients authenticate with an organization ID plus a user/session JWT. During urun setup, the organization is provisioned with an auth provider configuration, such as a WorkOS client ID mapped to a JWKS URL. The TypeScript client forwards orgId, jwt, and optional authProvider; urun validates the JWT server-side against the JWKS configured for that organization.
For React + WorkOS apps, prefer @urun-sh/react with authProvider="workos"; it reads the WorkOS AuthKit context and forwards refreshed access tokens for you. Use the lower-level jwt option only for advanced/non-React integrations. Do not pass server API keys or long-lived secrets to browser clients.
