# @urun-sh/react

React bindings for TypeScript clients that proxy apps deployed on urun from Python.

## Install

```bash
npm install @urun-sh/react @urun-sh/core
```

## Quick start

```tsx
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 imperative `start/update/stop`. The Python 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). Streams are symmetric:
> `session.stream(name)` consumes the runtime's output, and `.attach(track)` makes the client the
> producer of that name. See the root `README.md` "How this mirrors the Python runtime DSL" and the
> `urun-sdk-canonical-patterns` skill.

## 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 by `appId`.
- `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)` (write `desired.*` 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 unless `visible`), `<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): `VideoPlayer` from `@urun-sh/react/video` — video.js is an optional peer, deliberately not in the root export.
- The `UrunAudio` / `UrunVoice` / `UrunCamera` (root) and `UrunVideo` (`/video` subpath) 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:

```tsx
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`, `UrunSessionEnded`, `UrunIdleWarning`, `UrunSessionWaking`, `UrunActivationOverlay` (+ `useSessionPhase`, `useSessionEndsAt`, `useSessionIdle`, `useSessionWake`, `useActivation`).
- Session debug workbench: `UrunStreamTail`, `UrunDocPanel`, `UrunControlSender`, `UrunEventSpine` (see `docs/session-workbench-building-blocks.md`).
- Component registry: `registerComponent()` / `ComponentRenderer` plus 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](https://github.com/pmndrs/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.

```tsx
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:

```ts
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 doc
```

Notes:

- 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 `set` for 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.

```tsx
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:

```tsx
<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`

```tsx
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:

```ts
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`.
