@urun-sh/react
v0.6.31
Published
React bindings for the urun TypeScript SDK
Maintainers
Readme
@urun-sh/react
React bindings for TypeScript clients that proxy apps deployed on urun from Python.
Install
npm install @urun-sh/react @urun-sh/coreQuick start
import '@urun-sh/react/styles.css'
import { UrunProvider, useApp, Session, Camera, Video } from '@urun-sh/react'
import { UrunWorkOSProvider } from '@urun-sh/react/workos'
function Restyle() {
const app = useApp()
const session = app.generate({ prompt: 'A sunset' })
return (
<Session session={session}>
<Camera stream="cam" front /> {/* publish the webcam → the runtime's ctx.stream("cam") */}
<Video stream="video" /> {/* render the runtime's video output */}
<button onClick={() => session.doc('control').set({ desired: { prompt: { text: 'A forest' } } })}>
Restyle
</button>
</Session>
)
}
export default function App() {
return (
<UrunWorkOSProvider clientId="client_...">
<UrunProvider
baseUrl="https://urun.sh"
orgId="your-org-id"
authProvider="workos"
appId="my-app"
>
<Restyle />
</UrunProvider>
</UrunWorkOSProvider>
)
}useApp() is render-safe with zero user-side memoization: app is the same proxy across
re-renders, app.generate is the same function reference on every access, and repeated
app.generate(args) calls during React re-renders return the same session reference for the
same function and args (a new reference appears only after the session actually changed, i.e. it
was disconnected/ended and the next call dials fresh). Treat app.<function>() like a hook: call
it unconditionally during render and let it own the session lifecycle.
React Compiler note (default Next lint, react-hooks/purity): the compiler only freezes
values that flow through hook-named calls. If nothing hook-shaped touches the session, an event
handler in the same component that both writes through it (session.doc(...).set(...)) and calls
an impure function (Date.now(), Math.random()) is conservatively flagged as render-scope.
Keep it compiler-clean without memoization by reading the session through a hook you want anyway —
e.g. const phase = useSessionPhase(session) for the wake/status UI — which freezes the session
for the compiler. This contract is pinned by the fixtures in
src/__tests__/react-compiler-lint.test.ts.
Drive the app by writing desired state into the control doc (
doc('control').set({ desired: … })), not an imperativestart/update/stop. The Python runtime reads cheap config inline from the livedoc.desired.*props and usesdoc.bind(construct, [live props])(ReactuseMemo) to reconstruct a stage on structural change — the single reconcile lane (no field-mapping). Streams are symmetric:session.stream(name)consumes the runtime's output, and.attach(track)makes the client the producer of that name. See the rootREADME.md"How this mirrors the Python runtime DSL" and theurun-sdk-canonical-patternsskill.
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.
When authProvider="workos", wrap UrunProvider in an auth bridge that supplies short-lived access tokens. For pure React WorkOS apps, import UrunWorkOSProvider from @urun-sh/react/workos. For Next.js WorkOS apps, import UrunWorkOSProvider from @urun-sh/react/next-workos.
For other auth providers, use UrunAuthProvider with a getAccessToken function, or UrunJwtProvider for a static JWT in tests. urun validates JWTs server-side against the JWKS configured for the organization. Do not pass server API keys or long-lived secrets to browser clients.
API
UrunProvider— configures the app proxy (and is the auth onramp).useApp()— returns the deployed app proxy configured byappId.app.<function>(args?)— starts or reconnects a running function call and returns a render-safe session proxy.session.stream(name)— returns a render-safe named symmetric stream (first use decides direction) with.track,.attach(track),.detach(),.seek(seconds | 'live'), and lifecycle events.session.doc(name)— returns a render-safe synced document with.get(),.set(patch)(writedesired.*state), and lifecycle events.
Prebaked media components
The declarative send/show surface, keyed on the runtime's stream names. Bind one session for
a whole subtree with <Session session={session}> (every component also takes an
explicit session prop). They own capture, the persistent srcObject, iOS attributes, the
autoplay unlock, and front/back switching — hand-rolled getUserMedia / .track →
srcObject effects in app code are the anti-pattern.
- Send:
<Camera stream="cam" front|back visible />(viewless unlessvisible),<Mic stream="mic" />. - Show:
<Video stream="video" />(live inbound video),<Image stream="image" />(the platform image lane — H.264 intra frames),<Audio stream="audio" controls? />,<Voice stream="audio" />(full-duplex voice loop on one name). - VOD/recordings playback (seek/scrub):
VideoPlayerfrom@urun-sh/react/video— video.js is an optional peer, deliberately not in the root export. - The
UrunAudio/UrunVoice/UrunCamera(root) andUrunVideo(/videosubpath) aliases are deprecated and removed next minor — write the bare names.
Muxed A/V: consume an "av" parent through ONE element
A runtime that egresses ctx.stream("av", video_codec="h264", audio_codec="opus") puts two
RTP tracks on the wire. Name the parent and <Video> puts both into ONE MediaStream on
ONE element, so the browser's native A/V sync machinery owns the alignment:
const video = useRef<VideoHandle>(null)
<button onClick={() => { video.current?.unlock(); void start() }}>Start</button>
<Video ref={video} stream="av" muted={false} onUnlockChange={setSoundReady} />This is the canonical single-element muxed playout path. Consuming the same pair as two
elements (<Video stream="video"> + <Audio stream="audio">) gives the browser two
independently-buffered playout paths with nothing aligning them: audio's jitter buffer
accumulates standing delay on every delivery gap while video renders on arrival, and the
drift is bounded only by RTCP sender reports that an SFU may re-mint per track. That is
still supported — pass audioStream={false} on the video element so it never claims the
voice leg — but one element is the shape to reach for.
Since an unmuted element is the audio sink, it needs the same user gesture an <Audio>
does: call unlock() synchronously from the app's start-button handler and render a
tap-to-enable affordance from onUnlockChange(false).
The SDK also caps every inbound audio receiver's jitter buffer at 300 ms
(RTCRtpReceiver.jitterBufferTarget) so standing audio delay cannot accumulate past that
bound. Retune it per provider with
<UrunProvider audioPlayout={{ jitterBufferTargetMs: 500 }}>.
Hooks and building blocks
- Session doc/data:
useDocStore,useSessionDoc,useStreamMessages,useSessionTrack. - Continuous input (keyboard/mouse/pointer riding awareness presence):
useInputPresence(session). - Request/response over the session doc:
useRequest,useCompletion,useChat. - Session honesty:
UrunSessionStatus,UrunSessionGate,UrunSessionClock,UrunSessionPaused,UrunIdleWarning,UrunSessionWaking,UrunActivationOverlay(+useSessionPhase,useSessionEndsAt,useSessionIdle,useSessionWake,useActivation). - Session history:
useSessionDirectory()— list the owner's sessions (paused ones included),end/archive/unarchiveone. Attach stays onuseApp():app.fn(args, { sessionId })resumes a named session,app.fn(args)starts a new one. Model:urun-python/docs/design/session-semantics.md. - Session debug workbench:
UrunStreamTail,UrunDocPanel,UrunControlSender,UrunEventSpine(seedocs/session-workbench-building-blocks.md). - Component registry:
registerComponent()/ComponentRendererplus the schema-validated built-ins (ProgressCard,StatusBadge,TextStream,ImageFrame,MetricsPanel) rendered from app-defined named data streams.
Session docs as a zustand store
Session docs (prompts, desired state, status) read like a plain zustand store. The doc — a vanilla Yjs document synced by the platform — stays the single source of truth: the store is a read-projection plus write-through, never an authoritative copy. Selectors give granular re-renders; set(patch) deep-merges through the doc's field-granular CRDT write path, so concurrent writers to different subkeys both survive.
import { useDocStore } from '@urun-sh/react'
type ControlDoc = {
session?: { status?: string }
desired?: { prompt?: { text?: string } }
}
function PromptControls({ session }) {
const useControl = useDocStore<ControlDoc>(session, 'control')
// Status field read — re-renders ONLY when the selected value changes.
const status = useControl((s) => s.doc.session?.status ?? 'idle')
// Desired-state write — write-through to the doc (field-granular merge).
const set = useControl((s) => s.set)
return (
<div>
<span>{status}</span>
<button onClick={() => set({ desired: { prompt: { text: 'a sunset' } } })}>
Set prompt
</button>
</div>
)
}Outside React (or with a session handle you own), bind a store directly to a doc:
import { createDocStore } from '@urun-sh/react'
const control = createDocStore<ControlDoc>(session.doc('control'))
control.getState().doc.session?.status
control.subscribe((s) => console.log(s.doc))
control.set({ desired: { prompt: { text: 'dawn' } } })
control.unbind() // detach the projection; the session still owns the docNotes:
- Selectors that return objects/arrays should use zustand's
useShallow(zustand/react/shallow) — each doc change produces a fresh snapshot tree. - Arrays in the doc are read as plain snapshots. Do not read-modify-write an array through
setfor append-only logs; use the platform's stream/text primitives for growing data. useSessionDoc(session, key, selector?)also accepts a selector for one-off reads.
Streaming text (Text)
Point it at a named stream; it renders the text unstyled at whatever rate the
wire delivers (1k+ tok/s), because the network path touches zero React state:
chunks append to a plain buffer and a requestAnimationFrame loop drains it
into one bare DOM text node via appendData — one DOM write per frame, no
reconciliation, no parsing in the hot loop.
import { Text } from '@urun-sh/react'
<Text session={session} stream={`llm-resp:${requestId}`} onDone={setFinalText} />A reasoning pane is just a second Text on the other stream — nothing special:
<Text session={session} stream={`llm-think:${requestId}`} />Props: session, stream, onDone(text), onError(error), onMeter(meter),
smooth (typewriter drain below smoothThreshold, bypassed above it),
smoothCharsPerFrame, smoothThreshold, className, style.
Styling is the caller's — the container is an unstyled <span> and the content
is raw text. Apply markdown/code highlighting at block boundaries or on
completion, never per token.
Headless: useText / useTextMeter
const { ref, meterRef, getText } = useText({ session, stream: `llm-resp:${id}` })
const { chars, tokens, tokensPerSecond } = useTextMeter(meterRef, 250)
return <pre ref={ref} />useText holds no React state, so a component using it renders once on mount
regardless of chunk count. Counters are computed in the RAF loop and written
into meterRef in place; useTextMeter samples them on an interval so a
tok/s readout costs O(interval) renders, never O(chunks).
Styling
Import the package CSS when using built-in components:
import '@urun-sh/react/styles.css'Peer dependencies
@urun-sh/react supports React 18 and 19 and expects @urun-sh/core of the same release line. WorkOS React and Next.js integrations are optional peers used only by @urun-sh/react/workos and @urun-sh/react/next-workos; video.js is an optional peer used only by @urun-sh/react/video.
