Initial Commit
This commit is contained in:
3
packages/ui/react/react-audio-stream/.gitignore
vendored
Normal file
3
packages/ui/react/react-audio-stream/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
coverage/
|
||||
dist/
|
||||
node_modules/
|
||||
132
packages/ui/react/react-audio-stream/README.md
Normal file
132
packages/ui/react/react-audio-stream/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# @suaveplan/react-audio-stream - Stream-tier React surface for the Suaveplan audio family — session-core foundation (chunk A). Provides StreamSession (FSM lifecycle), InterruptionClassifier, StreamSourcePlugin, error codes, and OTel instruments. Transports, hooks, components, and transport-share land in chunks B/C/D.
|
||||
|
||||
React 19 surface for live audio streaming sessions: a Provider, six hooks, and four
|
||||
accessible components. Chunk C of the four-chunk `enterprise-react-audio-stream` split.
|
||||
|
||||
> Chunks A (session-core) and B (transports) deliver the headless engine. Chunk D
|
||||
> delivers `useTransportShare` and its banner. This chunk delivers the React surface
|
||||
> consumers actually touch.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
|
||||
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
|
||||
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
|
||||
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
|
||||
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
|
||||
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
|
||||
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
|
||||
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
|
||||
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
|
||||
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
|
||||
- [internal](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
|
||||
- [use-stream-context](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
|
||||
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
|
||||
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
|
||||
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
|
||||
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
|
||||
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
|
||||
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
|
||||
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
|
||||
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
|
||||
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
|
||||
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
|
||||
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
|
||||
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
|
||||
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
|
||||
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
|
||||
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
|
||||
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
|
||||
- [stream-context](./src/provider/stream-context.md) — `StreamReactContext` is the single React context that publishes the live `StreamSession` to every chunk-C hook and comp…
|
||||
- [stream-provider](./src/provider/stream-provider.md) — `<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for a tree. It calls the caller-su…
|
||||
- [session](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
|
||||
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
|
||||
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
|
||||
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
|
||||
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
|
||||
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
|
||||
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
|
||||
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
|
||||
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
|
||||
- [reconnect](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
|
||||
- [control-channel](./src/transport-share/control-channel.md) — The control-channel module is the wire-protocol heart of transport sharing. It defines the envelope shapes that flow be…
|
||||
- [share-state](./src/transport-share/share-state.md) — The share-state module owns the role-machine that decides whether a peer is in `'local'`, `'driving'`, or `'receiving'`…
|
||||
- [transports](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
|
||||
- [trystero](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
|
||||
- [trystero-transport](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
|
||||
- [webrtc](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
|
||||
- [webrtc-transport](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
|
||||
- [websocket](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
|
||||
- [websocket-transport](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
|
||||
- [transport](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
|
||||
- [test-setup](./src/test-setup.md) — `test-setup.ts` is the per-worker bootstrap file referenced by `vitest.config.ts`'s `setupFiles`. It registers `@testin…
|
||||
- [test-utils](./src/test-utils.md) — `test-utils.tsx` provides the in-memory `FakeSession` and the `withProvider` wrapper used by every chunk-C hook + compo…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Provider
|
||||
|
||||
- `<StreamProvider sessionFactory={…}>` — StrictMode-safe singleton; injects a
|
||||
`StreamSession` lazily; fans seven session event channels into a single render-bump
|
||||
counter; disconnects on unmount.
|
||||
- `StreamReactContext` — exposed for advanced consumers that need raw context access.
|
||||
- Type-only mirrors (`SessionState`, `Peer`, `PeerMixerState`, `MediaPermissionState`,
|
||||
`LevelSample`, `RecordingResult`, `StreamSessionFactory`, `StreamSessionLike`,
|
||||
`SessionEventMap`, `ReactAudioStreamErrorLike`) for downstream typing.
|
||||
|
||||
## Hooks
|
||||
|
||||
- `useStreamSession()` — session, FSM state, and `connect` / `join` / `leave` /
|
||||
`disconnect`.
|
||||
- `useStreamRoom()` — `roomId`, `isJoined`, `joinedAt`, `participants`.
|
||||
- `useStreamPeers()` — live `readonly Peer[]`.
|
||||
- `useStreamMixer()` — per-peer `setVolume` / `setMute` / `setSolo` with DAW
|
||||
solo semantics (when any peer is soloed, every non-soloed peer's track-volume → 0).
|
||||
Every mutation routes through the session's `setTrackVolume` / `setTrackMute` /
|
||||
`setTrackSolo`.
|
||||
- `useStreamLevels(peerId?)` — `{ peak, rms }` for the local mic or named peer.
|
||||
- `useStreamRecording()` — `start(peerIds?)` and `stop()` returning a
|
||||
`RecordingResult` blob.
|
||||
- `useStreamPermissions()` — typed `MediaPermissionState` with `request()` callback.
|
||||
|
||||
## Components
|
||||
|
||||
- `<StreamRoom>` — headless render-prop wrapper exposing the room snapshot plus join /
|
||||
leave callbacks.
|
||||
- `<StreamMixer>` — per-peer mixer strip with range slider, `aria-pressed`
|
||||
mute/solo toggles, FX-slot read-out; supports `renderStrip` render-prop.
|
||||
- `<StreamRecorder>` — record/stop toggle with `mm:ss` duration display and ARIA-live
|
||||
read-out.
|
||||
- `<StreamPermissionsBanner>` — `role="status"` `aria-live="polite"` banner; renders
|
||||
nothing when permission is granted.
|
||||
|
||||
## Quick start
|
||||
|
||||
```tsx
|
||||
import { StreamProvider, useStreamSession } from "@suaveplan/react-audio-stream";
|
||||
|
||||
function Room() {
|
||||
const { state, connect, join } = useStreamSession();
|
||||
if (state === "idle") return <button onClick={() => connect("lobby").then(join)}>Join</button>;
|
||||
return <p>Joined!</p>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => createMyStreamSession()}>
|
||||
<Room />
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints (from the canonical spec)
|
||||
|
||||
- Zero browser audio APIs: no `AudioContext` / `AudioNode` / `AudioWorklet` imports.
|
||||
- DAW solo semantics live in the mixer hook, not in each component.
|
||||
- Every hook throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
145
packages/ui/react/react-audio-stream/package.json
Normal file
145
packages/ui/react/react-audio-stream/package.json
Normal file
@@ -0,0 +1,145 @@
|
||||
{
|
||||
"name": "@suaveplan/react-audio-stream",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stream-tier React surface for the Suaveplan audio family — session-core foundation (chunk A). Provides StreamSession (FSM lifecycle), InterruptionClassifier, StreamSourcePlugin, error codes, and OTel instruments. Transports, hooks, components, and transport-share land in chunks B/C/D.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./session": {
|
||||
"import": {
|
||||
"types": "./dist/session/index.d.ts",
|
||||
"default": "./dist/session/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./transports": {
|
||||
"import": {
|
||||
"types": "./dist/transports/index.d.ts",
|
||||
"default": "./dist/transports/index.js"
|
||||
}
|
||||
},
|
||||
"./transports/webrtc": {
|
||||
"import": {
|
||||
"types": "./dist/transports/webrtc/index.d.ts",
|
||||
"default": "./dist/transports/webrtc/index.js"
|
||||
}
|
||||
},
|
||||
"./transports/websocket": {
|
||||
"import": {
|
||||
"types": "./dist/transports/websocket/index.d.ts",
|
||||
"default": "./dist/transports/websocket/index.js"
|
||||
}
|
||||
},
|
||||
"./transports/trystero": {
|
||||
"import": {
|
||||
"types": "./dist/transports/trystero/index.d.ts",
|
||||
"default": "./dist/transports/trystero/index.js"
|
||||
}
|
||||
},
|
||||
"./provider": {
|
||||
"import": {
|
||||
"types": "./dist/provider/index.d.ts",
|
||||
"default": "./dist/provider/index.js"
|
||||
}
|
||||
},
|
||||
"./hooks": {
|
||||
"import": {
|
||||
"types": "./dist/hooks/index.d.ts",
|
||||
"default": "./dist/hooks/index.js"
|
||||
}
|
||||
},
|
||||
"./components": {
|
||||
"import": {
|
||||
"types": "./dist/components/index.d.ts",
|
||||
"default": "./dist/components/index.js"
|
||||
}
|
||||
},
|
||||
"./transport-share": {
|
||||
"import": {
|
||||
"types": "./dist/transport-share/index.d.ts",
|
||||
"default": "./dist/transport-share/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/audio-fx-plugin-base": "workspace:^",
|
||||
"@suaveplan/audio-transport": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/fsm": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@suaveplan/react-audio": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"zustand": "catalog:state",
|
||||
"@suaveplan/network-transport-websockets": "workspace:^",
|
||||
"@suaveplan/retry": "workspace:^"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@suaveplan/network-discovery-trystero": "workspace:^",
|
||||
"@suaveplan/audio-codec-ffmpeg-wasm": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:testing",
|
||||
"@types/react-dom": "catalog:testing",
|
||||
"@testing-library/jest-dom": "catalog:testing",
|
||||
"@testing-library/react": "catalog:testing",
|
||||
"@testing-library/user-event": "catalog:testing",
|
||||
"@vitejs/plugin-react": "catalog:testing",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"react": "catalog:testing",
|
||||
"react-dom": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
17
packages/ui/react/react-audio-stream/src/components/index.ts
Normal file
17
packages/ui/react/react-audio-stream/src/components/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
StreamMixer,
|
||||
type StreamMixerProps,
|
||||
} from "./stream-mixer/stream-mixer.js";
|
||||
export {
|
||||
StreamPermissionsBanner,
|
||||
type StreamPermissionsBannerProps,
|
||||
} from "./stream-permissions-banner/stream-permissions-banner.js";
|
||||
export {
|
||||
StreamRecorder,
|
||||
type StreamRecorderProps,
|
||||
} from "./stream-recorder/stream-recorder.js";
|
||||
export {
|
||||
StreamRoom,
|
||||
type StreamRoomProps,
|
||||
type StreamRoomRenderProps,
|
||||
} from "./stream-room/stream-room.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SharedTransportIndicator } from "./shared-transport-indicator.js";
|
||||
export type { SharedTransportIndicatorProps } from "./shared-transport-indicator.types.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
# SharedTransportIndicator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'receiving'` mode. The component is intentionally presentational — it consumes a `ShareState` snapshot from the caller (typically `useTransportShare().state`) and renders nothing when no share is active.
|
||||
|
||||
Decoupling the visual from the hook lets consumers compose the indicator anywhere in their layout: in a status bar, inside a mixer panel, as part of a notifications drawer, or even pinned next to a specific peer's avatar. Because the component reads its data from props, it is also straightforward to test without wiring up a session or a coordinator.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders only when the local share role matches the configured `visibleRoles` (default `['receiving']`).
|
||||
- Carries `aria-live="polite"` and `role="status"` so screen readers announce share-state transitions politely.
|
||||
- `data-share-role` and `data-driver-id` attributes for selector-based styling.
|
||||
- Optional `label` override (string or any `ReactNode`).
|
||||
- Optional `className` for layout integration.
|
||||
- Spec-aligned default label: `"Shared transport active"`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
|
||||
import { SharedTransportIndicator } from "@suaveplan/react-audio-stream/components/shared-transport-indicator";
|
||||
|
||||
function StatusBar(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
return <SharedTransportIndicator state={share.state} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function MixerHeader(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
return (
|
||||
<header className="mixer-header">
|
||||
<SharedTransportIndicator
|
||||
state={share.state}
|
||||
className="mixer-header__share-badge"
|
||||
visibleRoles={["driving", "receiving"]}
|
||||
label={
|
||||
share.state.role === "driving"
|
||||
? `Driving ${share.state.receivers.length} peer(s)`
|
||||
: `Receiving from ${share.state.driverId}`
|
||||
}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SharedTransportIndicator(props): ReactNode`
|
||||
- `SharedTransportIndicatorProps`
|
||||
- `state: ShareState` — required.
|
||||
- `className?: string` — optional root class.
|
||||
- `label?: ReactNode` — optional label override.
|
||||
- `visibleRoles?: ReadonlyArray<ShareRole>` — defaults to `['receiving']`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component does not memoise its rendered output — it is a thin function-of-props with no internal state, so React's default reconciliation already short-circuits when the parent passes referentially-stable props. The `data-driver-id` attribute defaults to `""` when the driver identity is unknown so consumers can rely on the attribute always being present. The default label is exposed as a private constant rather than a JSX literal so the component remains testable with a single equality check, and the `visibleRoles` default is a frozen `as const` tuple to avoid accidental mutation by consumers.
|
||||
@@ -0,0 +1,66 @@
|
||||
/* istanbul ignore file -- story-only fixtures */
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { ShareState } from "../../transport-share/index.js";
|
||||
import { SharedTransportIndicator } from "./shared-transport-indicator.js";
|
||||
|
||||
const meta = {
|
||||
title: "react-audio-stream/SharedTransportIndicator",
|
||||
component: SharedTransportIndicator,
|
||||
} satisfies Meta<typeof SharedTransportIndicator>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const localState: ShareState = {
|
||||
role: "local",
|
||||
driverId: undefined,
|
||||
receivers: [],
|
||||
};
|
||||
const drivingState: ShareState = {
|
||||
role: "driving",
|
||||
driverId: "self",
|
||||
receivers: ["alice", "bob"],
|
||||
};
|
||||
const receivingState: ShareState = {
|
||||
role: "receiving",
|
||||
driverId: "alice",
|
||||
receivers: [],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
state: receivingState,
|
||||
},
|
||||
};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: {
|
||||
state: localState,
|
||||
},
|
||||
};
|
||||
|
||||
export const Driving: Story = {
|
||||
args: {
|
||||
state: drivingState,
|
||||
visibleRoles: ["driving", "receiving"],
|
||||
label: "Driving 2 peers",
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomLabel: Story = {
|
||||
args: {
|
||||
state: receivingState,
|
||||
label: "Locked to alice",
|
||||
className: "share-badge--accent",
|
||||
},
|
||||
};
|
||||
|
||||
export const Playground: Story = {
|
||||
args: {
|
||||
state: receivingState,
|
||||
label: "Shared transport active",
|
||||
visibleRoles: ["receiving"],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ShareState } from "../../transport-share/index.js";
|
||||
import { SharedTransportIndicator } from "./shared-transport-indicator.js";
|
||||
|
||||
const localState: ShareState = {
|
||||
role: "local",
|
||||
driverId: undefined,
|
||||
receivers: [],
|
||||
};
|
||||
const drivingState: ShareState = {
|
||||
role: "driving",
|
||||
driverId: "self",
|
||||
receivers: ["alice"],
|
||||
};
|
||||
const receivingState: ShareState = {
|
||||
role: "receiving",
|
||||
driverId: "alice",
|
||||
receivers: [],
|
||||
};
|
||||
|
||||
describe("SharedTransportIndicator", () => {
|
||||
it("renders nothing when the local peer is not receiving", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator state={localState} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the default label and aria-live when receiving", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator state={receivingState} />
|
||||
);
|
||||
const el = container.querySelector("[role='status']");
|
||||
expect(el).not.toBeNull();
|
||||
expect(el?.textContent).toBe("Shared transport active");
|
||||
expect(el?.getAttribute("aria-live")).toBe("polite");
|
||||
expect(el?.getAttribute("data-share-role")).toBe("receiving");
|
||||
expect(el?.getAttribute("data-driver-id")).toBe("alice");
|
||||
});
|
||||
|
||||
it("honours a custom label", { timeout: 5000 }, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator
|
||||
state={receivingState}
|
||||
label="Locked to alice"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("[role='status']")?.textContent).toBe(
|
||||
"Locked to alice"
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the supplied className", { timeout: 5000 }, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator
|
||||
state={receivingState}
|
||||
className="my-badge"
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
container.querySelector("[role='status']")?.getAttribute("class")
|
||||
).toBe("my-badge");
|
||||
});
|
||||
|
||||
it("renders for driving role when visibleRoles includes it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator
|
||||
state={drivingState}
|
||||
visibleRoles={["driving", "receiving"]}
|
||||
/>
|
||||
);
|
||||
const el = container.querySelector("[role='status']");
|
||||
expect(el?.getAttribute("data-share-role")).toBe("driving");
|
||||
});
|
||||
|
||||
it("does not render for receiving role when visibleRoles excludes it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { container } = render(
|
||||
<SharedTransportIndicator
|
||||
state={receivingState}
|
||||
visibleRoles={["driving"]}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes an empty data-driver-id when driverId is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const odd: ShareState = {
|
||||
role: "receiving",
|
||||
driverId: undefined,
|
||||
receivers: [],
|
||||
};
|
||||
const { container } = render(<SharedTransportIndicator state={odd} />);
|
||||
expect(
|
||||
container
|
||||
.querySelector("[role='status']")
|
||||
?.getAttribute("data-driver-id")
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { SharedTransportIndicatorProps } from "./shared-transport-indicator.types.js";
|
||||
|
||||
const DEFAULT_LABEL = "Shared transport active";
|
||||
const DEFAULT_VISIBLE_ROLES = ["receiving"] as const;
|
||||
|
||||
/**
|
||||
* Renders the spec-mandated "Shared transport active" indicator when the
|
||||
* local peer is in `'receiving'` mode (or any role listed in
|
||||
* `visibleRoles`). The element is announced via `aria-live="polite"` and
|
||||
* carries the `data-share-role` attribute so callers can style it
|
||||
* differently per role without prop drilling.
|
||||
*/
|
||||
export function SharedTransportIndicator(
|
||||
props: SharedTransportIndicatorProps
|
||||
): ReactNode {
|
||||
const { state, className, label, visibleRoles } = props;
|
||||
const roles = visibleRoles ?? DEFAULT_VISIBLE_ROLES;
|
||||
if (!roles.includes(state.role)) {
|
||||
return null;
|
||||
}
|
||||
const display = label ?? DEFAULT_LABEL;
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={className}
|
||||
data-share-role={state.role}
|
||||
data-driver-id={state.driverId ?? ""}
|
||||
role="status"
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ShareRole, ShareState } from "../../transport-share/index.js";
|
||||
|
||||
/**
|
||||
* Props for {@link SharedTransportIndicator}.
|
||||
*
|
||||
* The component is a presentational wrapper — it accepts the current share
|
||||
* state from the caller (typically `useTransportShare().state`) and renders
|
||||
* the spec-mandated "Shared transport active" indicator when the local peer
|
||||
* is in `'receiving'` mode.
|
||||
*/
|
||||
export interface SharedTransportIndicatorProps {
|
||||
/** Current share-state snapshot. */
|
||||
readonly state: ShareState;
|
||||
/**
|
||||
* Optional class name forwarded to the rendered root element. The
|
||||
* component renders nothing when the local peer is not receiving; this
|
||||
* class only applies to the `'receiving'` branch.
|
||||
*/
|
||||
readonly className?: string;
|
||||
/**
|
||||
* Optional override for the visible label. Defaults to
|
||||
* `"Shared transport active"` per the spec. The supplied node is also
|
||||
* announced to assistive technology via `aria-live="polite"`.
|
||||
*/
|
||||
readonly label?: ReactNode;
|
||||
/**
|
||||
* Roles to display the indicator for. Defaults to `['receiving']`. Set
|
||||
* to `['driving', 'receiving']` to also render an "I'm sharing" badge
|
||||
* on the driver side.
|
||||
*/
|
||||
readonly visibleRoles?: ReadonlyArray<ShareRole>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { StreamMixer, type StreamMixerProps } from "./stream-mixer.js";
|
||||
@@ -0,0 +1,63 @@
|
||||
# stream-mixer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a
|
||||
mute toggle, a solo toggle, and a read-only FX-slot-count indicator. The component is
|
||||
the visible counterpart to `useStreamMixer()` — both surfaces are required by
|
||||
Requirement 6 ("Per-peer stream mixer") and the component's solo button proves the
|
||||
DAW-semantics test case from the spec.
|
||||
|
||||
Markup is intentionally minimal and unstyled — every interactive control carries an
|
||||
`aria-label` derived from the peer id so screen readers announce the right peer, and
|
||||
the toggles use `aria-pressed` for accessibility. Consumers who want richer chrome pass
|
||||
a `renderStrip` render-prop and own the layout entirely.
|
||||
|
||||
## Features
|
||||
|
||||
- Default markup with `range` slider, `aria-pressed` mute/solo toggles, FX-slot count.
|
||||
- `renderStrip` render-prop for custom layouts (e.g. shadcn cards, drag handles).
|
||||
- All mutations route through `useStreamMixer()` — DAW solo semantics inherited
|
||||
unchanged.
|
||||
- Group `role="group"` with consumer-supplied `aria-label` for screen readers.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamMixer label="Live room mix" />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamMixer
|
||||
label="Studio mix"
|
||||
renderStrip={(peer, { setVolume, setMute, setSolo }) => (
|
||||
<PeerStrip
|
||||
peer={peer}
|
||||
onVolume={setVolume}
|
||||
onMute={() => setMute(!peer.muted)}
|
||||
onSolo={() => setSolo(!peer.soloed)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamMixerProps` — `{ label?: string; renderStrip?: (peer, controls) => ReactNode }`
|
||||
- `StreamMixer(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `aria-pressed` attribute is set on both the mute and solo buttons because they are
|
||||
two-state toggles — this matches WAI-ARIA's authoring practice for toggle buttons and
|
||||
allows assistive tech to announce the current state without parsing the button label.
|
||||
The range slider's `value` is bound to the live `peer.volume` so it stays in sync with
|
||||
ducking caused by other peers' solo state (per Requirement 6: when any peer is soloed,
|
||||
non-soloed peers' track volumes go to 0, and the slider reflects that).
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StreamProvider } from "../../provider/stream-provider.js";
|
||||
import type { PeerMixerState } from "../../session/session-shape/session-shape.types.js";
|
||||
import { FakeSession } from "../../test-utils.js";
|
||||
import { StreamMixer } from "./stream-mixer.js";
|
||||
|
||||
const m = (
|
||||
id: string,
|
||||
extra: Partial<PeerMixerState> = {}
|
||||
): PeerMixerState => ({
|
||||
id,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
fxSlotCount: 0,
|
||||
...extra,
|
||||
});
|
||||
|
||||
function wrap(session: FakeSession, children: ReactNode): ReactNode {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
{children}
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "react-audio-stream/StreamMixer",
|
||||
component: StreamMixer,
|
||||
};
|
||||
|
||||
export const Default = (): ReactNode =>
|
||||
wrap(
|
||||
new FakeSession({
|
||||
mixer: [m("alice", { volume: 0.8 }), m("bob", { volume: 0.6 })],
|
||||
}),
|
||||
<StreamMixer />
|
||||
);
|
||||
|
||||
export const SoloedPeer = (): ReactNode =>
|
||||
wrap(
|
||||
new FakeSession({
|
||||
mixer: [
|
||||
m("alice", { volume: 0.8, soloed: true }),
|
||||
m("bob", { volume: 0 }),
|
||||
m("carol", { volume: 0 }),
|
||||
],
|
||||
}),
|
||||
<StreamMixer label="Solo demonstration" />
|
||||
);
|
||||
|
||||
export const Playground = (): ReactNode =>
|
||||
wrap(
|
||||
new FakeSession({
|
||||
mixer: Array.from({ length: 6 }).map((_, i) =>
|
||||
m(`peer-${i}`, { volume: 0.5 + i * 0.05, fxSlotCount: i })
|
||||
),
|
||||
}),
|
||||
<StreamMixer label="Six-peer mixer" />
|
||||
);
|
||||
|
||||
export const CustomRender = (): ReactNode =>
|
||||
wrap(
|
||||
new FakeSession({ mixer: [m("alice"), m("bob", { soloed: true })] }),
|
||||
<StreamMixer
|
||||
renderStrip={(peer, controls) => (
|
||||
<article
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
border: "1px solid currentColor",
|
||||
}}
|
||||
>
|
||||
<strong>{peer.id}</strong>
|
||||
<span>{peer.soloed ? "SOLO" : ""}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => controls.setMute(!peer.muted)}
|
||||
>
|
||||
{peer.muted ? "unmute" : "mute"}
|
||||
</button>
|
||||
</article>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { StreamProvider } from "../../provider/stream-provider.js";
|
||||
import type { PeerMixerState } from "../../session/session-shape/session-shape.types.js";
|
||||
import { FakeSession } from "../../test-utils.js";
|
||||
import { StreamMixer } from "./stream-mixer.js";
|
||||
|
||||
function mix(id: string, extra: Partial<PeerMixerState> = {}): PeerMixerState {
|
||||
return {
|
||||
id,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
fxSlotCount: 0,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<StreamMixer>", () => {
|
||||
it("renders one strip per peer with accessible labels", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({
|
||||
mixer: [mix("alice"), mix("bob", { fxSlotCount: 2 })],
|
||||
});
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamMixer label="Live mix" />
|
||||
</StreamProvider>
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("group", { name: "Live mix" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Volume alice")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Mute bob")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Solo bob")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("FX slots for bob")).toHaveTextContent(
|
||||
"2"
|
||||
);
|
||||
});
|
||||
|
||||
it("solo across peers follows DAW semantics", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const user = userEvent.setup();
|
||||
const session = new FakeSession({
|
||||
mixer: [
|
||||
mix("a", { volume: 0.8 }),
|
||||
mix("b", { volume: 0.6 }),
|
||||
mix("c", { volume: 0.4 }),
|
||||
],
|
||||
});
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamMixer />
|
||||
</StreamProvider>
|
||||
);
|
||||
await user.click(screen.getByLabelText("Solo a"));
|
||||
expect(session.calls.setTrackSolo).toEqual([["a", true]]);
|
||||
expect(session.calls.setTrackVolume).toEqual([
|
||||
["b", 0],
|
||||
["c", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("mute button toggles via aria-pressed", { timeout: 5000 }, async () => {
|
||||
const user = userEvent.setup();
|
||||
const session = new FakeSession({ mixer: [mix("a")] });
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamMixer />
|
||||
</StreamProvider>
|
||||
);
|
||||
const mute = screen.getByLabelText("Mute a");
|
||||
expect(mute).toHaveAttribute("aria-pressed", "false");
|
||||
await user.click(mute);
|
||||
expect(session.calls.setTrackMute).toEqual([["a", true]]);
|
||||
});
|
||||
|
||||
it("volume slider routes onChange through setVolume", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({ mixer: [mix("a", { volume: 0.5 })] });
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamMixer />
|
||||
</StreamProvider>
|
||||
);
|
||||
const slider = screen.getByLabelText("Volume a") as HTMLInputElement;
|
||||
fireEvent.change(slider, { target: { value: "0.25" } });
|
||||
expect(session.calls.setTrackVolume).toEqual([["a", 0.25]]);
|
||||
});
|
||||
|
||||
it("supports a custom renderStrip render-prop with all three controls", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const user = userEvent.setup();
|
||||
const session = new FakeSession({ mixer: [mix("a")] });
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamMixer
|
||||
renderStrip={(p, c) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => c.setVolume(0.1)}
|
||||
>
|
||||
vol-{p.id}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => c.setMute(true)}
|
||||
>
|
||||
mute-{p.id}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => c.setSolo(true)}
|
||||
>
|
||||
solo-{p.id}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</StreamProvider>
|
||||
);
|
||||
await user.click(screen.getByText("vol-a"));
|
||||
await user.click(screen.getByText("mute-a"));
|
||||
await user.click(screen.getByText("solo-a"));
|
||||
expect(session.calls.setTrackVolume).toEqual([["a", 0.1]]);
|
||||
expect(session.calls.setTrackMute).toEqual([["a", true]]);
|
||||
expect(session.calls.setTrackSolo).toEqual([["a", true]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useStreamMixer } from "../../hooks/use-stream-mixer/use-stream-mixer.js";
|
||||
|
||||
export interface StreamMixerProps {
|
||||
/** Optional ARIA label applied to the mixer group. */
|
||||
readonly label?: string;
|
||||
/** When supplied, replaces the default markup of each peer strip. */
|
||||
readonly renderStrip?: (
|
||||
peer: ReturnType<typeof useStreamMixer>["peers"][number],
|
||||
controls: {
|
||||
setVolume: (v: number) => void;
|
||||
setMute: (m: boolean) => void;
|
||||
setSolo: (s: boolean) => void;
|
||||
}
|
||||
) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-peer mixer panel with a volume slider, mute toggle, solo toggle,
|
||||
* and an FX-slot-count read-out for each peer. Delegates every mutation
|
||||
* to `useStreamMixer()` so DAW solo semantics live in one place.
|
||||
*/
|
||||
export function StreamMixer(props: StreamMixerProps): ReactNode {
|
||||
const { peers, setVolume, setMute, setSolo } = useStreamMixer();
|
||||
const label = props.label ?? "Stream mixer";
|
||||
return (
|
||||
<fieldset aria-label={label} data-component="stream-mixer">
|
||||
{peers.map((p) => {
|
||||
if (props.renderStrip !== undefined) {
|
||||
return (
|
||||
<div key={p.id} data-peer={p.id}>
|
||||
{props.renderStrip(p, {
|
||||
setVolume: (v) => setVolume(p.id, v),
|
||||
setMute: (m) => setMute(p.id, m),
|
||||
setSolo: (s) => setSolo(p.id, s),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<fieldset
|
||||
key={p.id}
|
||||
aria-label={`Peer ${p.id}`}
|
||||
data-peer={p.id}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={p.volume}
|
||||
aria-label={`Volume ${p.id}`}
|
||||
onChange={(e) =>
|
||||
setVolume(p.id, Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={p.muted}
|
||||
aria-label={`Mute ${p.id}`}
|
||||
onClick={() => setMute(p.id, !p.muted)}
|
||||
>
|
||||
{p.muted ? "Muted" : "Mute"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={p.soloed}
|
||||
aria-label={`Solo ${p.id}`}
|
||||
onClick={() => setSolo(p.id, !p.soloed)}
|
||||
>
|
||||
{p.soloed ? "Soloed" : "Solo"}
|
||||
</button>
|
||||
<output aria-label={`FX slots for ${p.id}`}>
|
||||
{p.fxSlotCount}
|
||||
</output>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
StreamPermissionsBanner,
|
||||
type StreamPermissionsBannerProps,
|
||||
} from "./stream-permissions-banner.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
# stream-permissions-banner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It
|
||||
renders a `role="status"` ARIA-live region with copy reflecting the current microphone
|
||||
permission state and an explicit request button when the state is actionable
|
||||
(`"prompt"` or `"unknown"`). When the user has already granted access, the banner
|
||||
renders nothing so the room UI is unobstructed.
|
||||
|
||||
The banner is intentionally opinionated about accessibility — `aria-live="polite"`
|
||||
ensures screen readers announce state transitions without interrupting other speech —
|
||||
but stays unopinionated about styling. The default copy is overridable on a
|
||||
per-state basis via the `messages` prop.
|
||||
|
||||
## Features
|
||||
|
||||
- ARIA-live `role="status"` with `aria-live="polite"`.
|
||||
- Per-state copy with sensible defaults (`granted` / `denied` / `prompt` / `unknown`).
|
||||
- Request button rendered only when the state is `"prompt"` or `"unknown"`.
|
||||
- `onResolved` callback invoked with the next state after `request()` settles.
|
||||
- Renders `null` when permission is already granted.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamPermissionsBanner />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamPermissionsBanner
|
||||
requestLabel="Allow microphone"
|
||||
messages={{
|
||||
denied: "Microphone blocked. Open site settings to allow access.",
|
||||
prompt: "We use your mic to stream live audio to other participants.",
|
||||
}}
|
||||
onResolved={(state) => {
|
||||
if (state === "granted") void session.connect("lobby").then(() => session.join());
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamPermissionsBannerProps` — `{ messages?, requestLabel?, onResolved? }`
|
||||
- `StreamPermissionsBanner(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `data-state` attribute on the root element mirrors the `MediaPermissionState`
|
||||
string so consumers can target it via CSS attribute selectors (e.g.
|
||||
`[data-state="denied"] { color: red; }`) without re-deriving the state in JS. The
|
||||
request button renders only on `"prompt"` and `"unknown"` because `"denied"` cannot be
|
||||
escalated back to `"prompt"` through `getUserMedia` alone — the user must change
|
||||
browser-level settings, which the copy explicitly tells them.
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StreamProvider } from "../../provider/stream-provider.js";
|
||||
import type { MediaPermissionState } from "../../session/session-shape/session-shape.types.js";
|
||||
import { FakeSession } from "../../test-utils.js";
|
||||
import { StreamPermissionsBanner } from "./stream-permissions-banner.js";
|
||||
|
||||
function wrap(
|
||||
permission: MediaPermissionState,
|
||||
children: ReactNode
|
||||
): ReactNode {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => new FakeSession({ permission })}>
|
||||
{children}
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "react-audio-stream/StreamPermissionsBanner",
|
||||
component: StreamPermissionsBanner,
|
||||
};
|
||||
|
||||
export const Prompt = (): ReactNode =>
|
||||
wrap("prompt", <StreamPermissionsBanner />);
|
||||
export const Denied = (): ReactNode =>
|
||||
wrap("denied", <StreamPermissionsBanner />);
|
||||
export const Unknown = (): ReactNode =>
|
||||
wrap("unknown", <StreamPermissionsBanner />);
|
||||
export const Granted = (): ReactNode =>
|
||||
wrap("granted", <StreamPermissionsBanner />);
|
||||
|
||||
export const Playground = (): ReactNode =>
|
||||
wrap(
|
||||
"prompt",
|
||||
<StreamPermissionsBanner
|
||||
requestLabel="Allow microphone"
|
||||
messages={{
|
||||
prompt: "We need your mic to stream live audio to other participants.",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { StreamProvider } from "../../provider/stream-provider.js";
|
||||
import { FakeSession } from "../../test-utils.js";
|
||||
import { StreamPermissionsBanner } from "./stream-permissions-banner.js";
|
||||
|
||||
describe("<StreamPermissionsBanner>", () => {
|
||||
it("renders null when permission is granted", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({ permission: "granted" });
|
||||
const { container } = render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamPermissionsBanner />
|
||||
</StreamProvider>
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
"[data-component='stream-permissions-banner']"
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a request button when prompting", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({ permission: "prompt" });
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamPermissionsBanner />
|
||||
</StreamProvider>
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveAttribute(
|
||||
"data-state",
|
||||
"prompt"
|
||||
);
|
||||
expect(screen.getByRole("button")).toHaveTextContent(
|
||||
"Enable microphone"
|
||||
);
|
||||
});
|
||||
|
||||
it("renders denied copy without a button", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({ permission: "denied" });
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamPermissionsBanner />
|
||||
</StreamProvider>
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveAttribute(
|
||||
"data-state",
|
||||
"denied"
|
||||
);
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onResolved after request resolves", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const user = userEvent.setup();
|
||||
const session = new FakeSession({ permission: "prompt" });
|
||||
let resolved: string | null = null;
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamPermissionsBanner
|
||||
onResolved={(s) => {
|
||||
resolved = s;
|
||||
}}
|
||||
/>
|
||||
</StreamProvider>
|
||||
);
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(resolved).toBe("granted");
|
||||
});
|
||||
|
||||
it("supports message overrides and a custom request label", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({ permission: "prompt" });
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamPermissionsBanner
|
||||
requestLabel="Allow mic"
|
||||
messages={{ prompt: "Need mic." }}
|
||||
/>
|
||||
</StreamProvider>
|
||||
);
|
||||
expect(screen.getByText("Need mic.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toHaveTextContent("Allow mic");
|
||||
});
|
||||
|
||||
it("renders unknown state with a request button", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({ permission: "unknown" });
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamPermissionsBanner />
|
||||
</StreamProvider>
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveAttribute(
|
||||
"data-state",
|
||||
"unknown"
|
||||
);
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useStreamPermissions } from "../../hooks/use-stream-permissions/use-stream-permissions.js";
|
||||
import type { MediaPermissionState } from "../../session/session-shape/session-shape.types.js";
|
||||
|
||||
export interface StreamPermissionsBannerProps {
|
||||
/** Optional copy override for the four permission states. */
|
||||
readonly messages?: Partial<Record<MediaPermissionState, string>>;
|
||||
/** Optional label override for the request button. */
|
||||
readonly requestLabel?: string;
|
||||
/** Optional callback invoked after `request()` resolves. */
|
||||
readonly onResolved?: (state: MediaPermissionState) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MESSAGES: Record<MediaPermissionState, string> = {
|
||||
granted: "Microphone access is granted.",
|
||||
denied: "Microphone access was denied. Update your browser permissions to continue.",
|
||||
prompt: "We need access to your microphone to connect.",
|
||||
unknown: "Checking microphone permissions...",
|
||||
};
|
||||
|
||||
/**
|
||||
* ARIA-live banner reflecting the microphone permission state with an
|
||||
* explicit request button when the state is `"prompt"`. Renders nothing
|
||||
* when `state === "granted"` — the banner is only visible when the user
|
||||
* needs to act.
|
||||
*/
|
||||
export function StreamPermissionsBanner(
|
||||
props: StreamPermissionsBannerProps
|
||||
): ReactNode {
|
||||
const { state, request } = useStreamPermissions();
|
||||
if (state === "granted") {
|
||||
return null;
|
||||
}
|
||||
const messages = { ...DEFAULT_MESSAGES, ...(props.messages ?? {}) };
|
||||
const onClick = async (): Promise<void> => {
|
||||
const next = await request();
|
||||
props.onResolved?.(next);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-component="stream-permissions-banner"
|
||||
data-state={state}
|
||||
>
|
||||
<p>{messages[state]}</p>
|
||||
{state === "prompt" || state === "unknown" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onClick();
|
||||
}}
|
||||
>
|
||||
{props.requestLabel ?? "Enable microphone"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
StreamRecorder,
|
||||
type StreamRecorderProps,
|
||||
} from "./stream-recorder.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# stream-recorder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamRecorder>` is a record/stop control with a live duration display. It delegates
|
||||
recording lifecycle to `useStreamRecording()` and adds two pieces of presentation logic:
|
||||
an interval-driven duration counter that ticks at 4 Hz while recording, and an
|
||||
ARIA-live duration read-out so screen-reader users hear updates. When recording stops,
|
||||
the component invokes the optional `onComplete` callback with the full `RecordingResult`
|
||||
(blob + timing metadata) so consumers can upload, download, or transcribe the capture.
|
||||
|
||||
The component supports the optional `peerIds` filter — passing it forwards the list to
|
||||
`start()` so only the named peers (plus the local mic) are mixed into the resulting
|
||||
blob. This makes the same component usable for "record the whole room" and
|
||||
"record only the guest" flows without branching the consumer's code.
|
||||
|
||||
## Features
|
||||
|
||||
- Toggle button with `aria-pressed` for the current state.
|
||||
- ARIA-live duration string formatted as `mm:ss`.
|
||||
- 4 Hz interval, cleaned up on unmount and on `isRecording=false`.
|
||||
- `onComplete` callback receives the full `RecordingResult` after `stop()` resolves.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamRecorder onComplete={(r) => download(r.blob)} />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const guests = useStreamPeers().filter((p) => p.id !== "host").map((p) => p.id);
|
||||
<StreamRecorder
|
||||
peerIds={guests}
|
||||
onComplete={async ({ blob, durationMs }) => {
|
||||
await fetch("/api/interviews", { method: "POST", body: blob });
|
||||
analytics.track("interview_recorded", { durationMs });
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamRecorderProps` — `{ onComplete?, peerIds?, label? }`
|
||||
- `StreamRecorder(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The duration counter uses `Date.now()` rather than accumulating `setInterval` ticks
|
||||
because intervals drift; sampling wall-clock time keeps the display in sync with the
|
||||
underlying recording timeline. On stop the component snaps the displayed duration to
|
||||
the precise `durationMs` returned by the session, which is the authoritative value
|
||||
(the session's offline render runs at a fixed sample rate). The interval is cleared in
|
||||
the effect cleanup, so re-mount under StrictMode does not leak a timer.
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StreamProvider } from "../../provider/stream-provider.js";
|
||||
import { FakeSession } from "../../test-utils.js";
|
||||
import { StreamRecorder } from "./stream-recorder.js";
|
||||
|
||||
function wrap(session: FakeSession, children: ReactNode): ReactNode {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
{children}
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "react-audio-stream/StreamRecorder",
|
||||
component: StreamRecorder,
|
||||
};
|
||||
|
||||
export const Default = (): ReactNode =>
|
||||
wrap(new FakeSession(), <StreamRecorder />);
|
||||
|
||||
export const ScopedToTwoGuests = (): ReactNode =>
|
||||
wrap(
|
||||
new FakeSession(),
|
||||
<StreamRecorder
|
||||
peerIds={["guest-1", "guest-2"]}
|
||||
label="Guest mic recorder"
|
||||
/>
|
||||
);
|
||||
|
||||
export const Playground = (): ReactNode =>
|
||||
wrap(
|
||||
new FakeSession(),
|
||||
<StreamRecorder
|
||||
label="Playground"
|
||||
onComplete={(r) => {
|
||||
const fn = (globalThis as { alert?: (msg: string) => void })
|
||||
.alert;
|
||||
if (typeof fn === "function") {
|
||||
fn(`Recorded ${r.durationMs} ms`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { StreamProvider } from "../../provider/stream-provider.js";
|
||||
import type { RecordingResult } from "../../session/session-shape/session-shape.types.js";
|
||||
import { FakeSession } from "../../test-utils.js";
|
||||
import { StreamRecorder } from "./stream-recorder.js";
|
||||
|
||||
describe("<StreamRecorder>", () => {
|
||||
it("toggles aria-pressed on click", { timeout: 5000 }, async () => {
|
||||
const user = userEvent.setup();
|
||||
const session = new FakeSession();
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamRecorder />
|
||||
</StreamProvider>
|
||||
);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn).toHaveAttribute("aria-pressed", "false");
|
||||
await user.click(btn);
|
||||
expect(btn).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("calls onComplete with the RecordingResult on stop", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const user = userEvent.setup();
|
||||
const session = new FakeSession();
|
||||
let received: RecordingResult | null = null;
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamRecorder
|
||||
onComplete={(r) => {
|
||||
received = r;
|
||||
}}
|
||||
/>
|
||||
</StreamProvider>
|
||||
);
|
||||
const btn = screen.getByRole("button");
|
||||
await user.click(btn);
|
||||
await user.click(btn);
|
||||
expect(received).not.toBeNull();
|
||||
expect((received as RecordingResult | null)?.durationMs).toBe(2500);
|
||||
});
|
||||
|
||||
it("forwards peerIds to start", { timeout: 5000 }, async () => {
|
||||
const user = userEvent.setup();
|
||||
const session = new FakeSession();
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamRecorder peerIds={["alice"]} />
|
||||
</StreamProvider>
|
||||
);
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(session.calls.startRecording[0]).toEqual(["alice"]);
|
||||
});
|
||||
|
||||
it("displays duration string with mm:ss format", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const session = new FakeSession();
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamRecorder />
|
||||
</StreamProvider>
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("stream-recorder-duration")
|
||||
).toHaveTextContent("00:00");
|
||||
});
|
||||
|
||||
it("ticks the duration string while recording", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const user = userEvent.setup();
|
||||
const session = new FakeSession();
|
||||
const realNow = Date.now;
|
||||
let nowMs = 0;
|
||||
Date.now = () => nowMs;
|
||||
try {
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamRecorder />
|
||||
</StreamProvider>
|
||||
);
|
||||
await user.click(screen.getByRole("button"));
|
||||
// Advance fake clock and let the interval tick.
|
||||
await act(async () => {
|
||||
nowMs = 5000;
|
||||
await new Promise<void>((r) => {
|
||||
setTimeout(r, 300);
|
||||
});
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId("stream-recorder-duration").textContent
|
||||
).not.toBe("00:00");
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useStreamRecording } from "../../hooks/use-stream-recording/use-stream-recording.js";
|
||||
import type { RecordingResult } from "../../session/session-shape/session-shape.types.js";
|
||||
|
||||
export interface StreamRecorderProps {
|
||||
/** Optional callback invoked when recording stops with the result. */
|
||||
readonly onComplete?: (result: RecordingResult) => void;
|
||||
/** Optional peer-id filter forwarded to `start`. */
|
||||
readonly peerIds?: readonly string[];
|
||||
readonly label?: string;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const m = Math.floor(total / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const s = (total % 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record/stop button with a live duration display. Increments the
|
||||
* duration via `setInterval` while recording, freezes on stop, and emits
|
||||
* the result via `onComplete`.
|
||||
*/
|
||||
export function StreamRecorder(props: StreamRecorderProps): ReactNode {
|
||||
const { isRecording, start, stop } = useStreamRecording();
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const startedAt = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRecording) {
|
||||
return;
|
||||
}
|
||||
const begin = Date.now();
|
||||
startedAt.current = begin;
|
||||
const id = setInterval(() => {
|
||||
setElapsedMs(Date.now() - begin);
|
||||
}, 250);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [isRecording]);
|
||||
|
||||
const onToggle = async (): Promise<void> => {
|
||||
if (isRecording) {
|
||||
const result = await stop();
|
||||
setElapsedMs(result.durationMs);
|
||||
startedAt.current = null;
|
||||
props.onComplete?.(result);
|
||||
return;
|
||||
}
|
||||
setElapsedMs(0);
|
||||
await start(props.peerIds);
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset aria-label={props.label ?? "Stream recorder"}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isRecording}
|
||||
aria-label={isRecording ? "Stop recording" : "Start recording"}
|
||||
onClick={() => {
|
||||
void onToggle();
|
||||
}}
|
||||
>
|
||||
{isRecording ? "Stop" : "Record"}
|
||||
</button>
|
||||
<output aria-live="polite" data-testid="stream-recorder-duration">
|
||||
{formatDuration(elapsedMs)}
|
||||
</output>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
StreamRoom,
|
||||
type StreamRoomProps,
|
||||
type StreamRoomRenderProps,
|
||||
} from "./stream-room.js";
|
||||
@@ -0,0 +1,72 @@
|
||||
# stream-room
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup
|
||||
of its own — instead it exposes the live room snapshot (`roomId`, `isJoined`,
|
||||
`participants`, `state`) and the `join` / `leave` callbacks via a render-prop. This
|
||||
keeps presentation decisions (layout, theming, copy, animations) entirely in the
|
||||
consumer's hands while the audio-stream package owns the data flow.
|
||||
|
||||
The render-prop pattern was chosen over compound components because the room scope is
|
||||
trivially small (six fields, two callbacks) and a single function child is the smallest
|
||||
possible surface that does not lock consumers into a particular DOM structure.
|
||||
|
||||
## Features
|
||||
|
||||
- Headless: no DOM, no classes, no inline styles.
|
||||
- Render-prop returns the room snapshot and lifecycle callbacks.
|
||||
- Re-renders on every relevant session event via the underlying hooks.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when rendered outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamRoom>
|
||||
{({ roomId, isJoined, join, leave }) =>
|
||||
isJoined ? (
|
||||
<button onClick={leave}>Leave {roomId}</button>
|
||||
) : (
|
||||
<button onClick={join}>Join room</button>
|
||||
)
|
||||
}
|
||||
</StreamRoom>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamRoom>
|
||||
{({ roomId, participants, state, join, leave }) => (
|
||||
<section aria-busy={state === "joining" || state === "leaving"}>
|
||||
<h2>{roomId ?? "no room"}</h2>
|
||||
<ul>
|
||||
{participants.map((p) => (
|
||||
<li key={p.id}>{p.displayName}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div role="group">
|
||||
<button disabled={state !== "connected"} onClick={join}>Join</button>
|
||||
<button disabled={state !== "joined"} onClick={leave}>Leave</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</StreamRoom>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamRoomRenderProps` — `{ roomId, isJoined, participants, join, leave, state }`
|
||||
- `StreamRoomProps` — `{ children: (rp: StreamRoomRenderProps) => ReactNode }`
|
||||
- `StreamRoom(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`state` is forwarded from `useStreamSession()` so consumers can disable buttons during
|
||||
`"joining"` / `"leaving"` without re-deriving the FSM state themselves. The component
|
||||
delegates to two narrow hooks rather than reading the context directly, which keeps the
|
||||
render-prop forwarding cost at exactly two memoised hook calls per render.
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StreamProvider } from "../../provider/stream-provider.js";
|
||||
import { FakeSession } from "../../test-utils.js";
|
||||
import { StreamRoom } from "./stream-room.js";
|
||||
|
||||
function decorator(session: FakeSession): (children: ReactNode) => ReactNode {
|
||||
return (children) => (
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
{children}
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "react-audio-stream/StreamRoom",
|
||||
component: StreamRoom,
|
||||
};
|
||||
|
||||
export const Default = (): ReactNode =>
|
||||
decorator(
|
||||
new FakeSession({
|
||||
state: "joined",
|
||||
roomId: "lobby",
|
||||
peers: [
|
||||
{
|
||||
id: "a",
|
||||
displayName: "Alice",
|
||||
connectionState: "connected",
|
||||
rttMs: 24,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
displayName: "Bob",
|
||||
connectionState: "connected",
|
||||
rttMs: 41,
|
||||
},
|
||||
],
|
||||
})
|
||||
)(
|
||||
<StreamRoom>
|
||||
{({ roomId, participants, isJoined, join, leave }) => (
|
||||
<section>
|
||||
<h2>{roomId}</h2>
|
||||
<p>{participants.length} present</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (isJoined ? leave() : join())}
|
||||
>
|
||||
{isJoined ? "Leave" : "Join"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</StreamRoom>
|
||||
);
|
||||
|
||||
export const Empty = (): ReactNode =>
|
||||
decorator(new FakeSession({ state: "connected", roomId: null }))(
|
||||
<StreamRoom>
|
||||
{({ roomId, isJoined }) => (
|
||||
<p>
|
||||
Room: {roomId ?? "none"} | joined: {String(isJoined)}
|
||||
</p>
|
||||
)}
|
||||
</StreamRoom>
|
||||
);
|
||||
|
||||
export const Playground = (): ReactNode =>
|
||||
decorator(
|
||||
new FakeSession({
|
||||
state: "joined",
|
||||
roomId: "playground-room",
|
||||
peers: Array.from({ length: 5 }).map((_, i) => ({
|
||||
id: `peer-${i}`,
|
||||
displayName: `Peer ${i + 1}`,
|
||||
connectionState: "connected" as const,
|
||||
rttMs: 10 + i * 12,
|
||||
})),
|
||||
})
|
||||
)(
|
||||
<StreamRoom>
|
||||
{({ participants, roomId }) => (
|
||||
<article>
|
||||
<header>{roomId}</header>
|
||||
<ul>
|
||||
{participants.map((p) => (
|
||||
<li key={p.id}>
|
||||
{p.displayName} — {p.rttMs} ms
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
)}
|
||||
</StreamRoom>
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { StreamProvider } from "../../provider/stream-provider.js";
|
||||
import { FakeSession } from "../../test-utils.js";
|
||||
import { StreamRoom } from "./stream-room.js";
|
||||
|
||||
describe("<StreamRoom>", () => {
|
||||
it("invokes the render-prop with the live snapshot", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({
|
||||
state: "joined",
|
||||
roomId: "lobby",
|
||||
peers: [
|
||||
{
|
||||
id: "a",
|
||||
displayName: "A",
|
||||
connectionState: "connected",
|
||||
rttMs: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamRoom>
|
||||
{({ roomId, participants, isJoined }) => (
|
||||
<div>
|
||||
<span data-testid="room">{roomId}</span>
|
||||
<span data-testid="joined">{String(isJoined)}</span>
|
||||
<span data-testid="count">
|
||||
{participants.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</StreamRoom>
|
||||
</StreamProvider>
|
||||
);
|
||||
expect(screen.getByTestId("room")).toHaveTextContent("lobby");
|
||||
expect(screen.getByTestId("joined")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("wires join/leave callbacks through to the session", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const user = userEvent.setup();
|
||||
const session = new FakeSession({
|
||||
state: "connected",
|
||||
roomId: "x",
|
||||
});
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamRoom>
|
||||
{({ join, leave, isJoined }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (isJoined ? leave() : join())}
|
||||
>
|
||||
{isJoined ? "leave" : "join"}
|
||||
</button>
|
||||
)}
|
||||
</StreamRoom>
|
||||
</StreamProvider>
|
||||
);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn).toHaveTextContent("join");
|
||||
await act(async () => {
|
||||
await user.click(btn);
|
||||
});
|
||||
expect(session.state).toBe("joined");
|
||||
});
|
||||
|
||||
it("re-renders when peers change", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({ state: "joined" });
|
||||
render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<StreamRoom>
|
||||
{({ participants }) => (
|
||||
<span data-testid="n">{participants.length}</span>
|
||||
)}
|
||||
</StreamRoom>
|
||||
</StreamProvider>
|
||||
);
|
||||
expect(screen.getByTestId("n")).toHaveTextContent("0");
|
||||
act(() => {
|
||||
session.pushPeer({
|
||||
id: "z",
|
||||
displayName: "Z",
|
||||
connectionState: "connected",
|
||||
rttMs: 9,
|
||||
});
|
||||
});
|
||||
expect(screen.getByTestId("n")).toHaveTextContent("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useStreamRoom } from "../../hooks/use-stream-room/use-stream-room.js";
|
||||
import { useStreamSession } from "../../hooks/use-stream-session/use-stream-session.js";
|
||||
import type { Peer } from "../../session/session-shape/session-shape.types.js";
|
||||
|
||||
export interface StreamRoomRenderProps {
|
||||
readonly roomId: string | null;
|
||||
readonly isJoined: boolean;
|
||||
readonly participants: readonly Peer[];
|
||||
readonly join: () => Promise<void>;
|
||||
readonly leave: () => Promise<void>;
|
||||
readonly state: string;
|
||||
}
|
||||
|
||||
export interface StreamRoomProps {
|
||||
/**
|
||||
* Render-prop receiving the live room snapshot plus the join/leave
|
||||
* callbacks. Returning `null` is valid (e.g. to render nothing while
|
||||
* not yet connected).
|
||||
*/
|
||||
readonly children: (rp: StreamRoomRenderProps) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlled wrapper exposing room state + join/leave controls via a
|
||||
* render-prop. Carries no markup of its own so consumers can style and
|
||||
* layout freely.
|
||||
*/
|
||||
export function StreamRoom(props: StreamRoomProps): ReactNode {
|
||||
const room = useStreamRoom();
|
||||
const { state, join, leave } = useStreamSession();
|
||||
return props.children({
|
||||
roomId: room.roomId,
|
||||
isJoined: room.isJoined,
|
||||
participants: room.participants,
|
||||
join,
|
||||
leave,
|
||||
state,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The error-codes module is the canonical source of truth for every
|
||||
failure mode published by `@suaveplan/react-audio-stream`. It declares
|
||||
the seven dotted codes under the `REACT_AUDIO_STREAM.` namespace via
|
||||
`defineCodes`, the frozen metadata table, and the `ReactAudioStreamError`
|
||||
class — every error thrown by the package extends this class and carries
|
||||
one of these codes. Consumers discriminate failures with `instanceof
|
||||
ReactAudioStreamError` or by matching on the stable `error.code` string
|
||||
without needing to know which sub-feature originated the throw.
|
||||
|
||||
The codes line up one-to-one with the Requirement 9 surface of the
|
||||
parent OpenSpec change so the human-authored spec can be cross-checked
|
||||
against runtime reality: a test asserts every entry in
|
||||
`REACT_AUDIO_STREAM_ERROR_METADATA` is reachable from at least one
|
||||
production call site.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable, lexically sorted codes: `NO_PROVIDER`, `INVALID_STATE`,
|
||||
`SIGNALLING_FAILED`, `PEER_NOT_FOUND`, `MEDIA_PERMISSION_DENIED`,
|
||||
`RECONNECT_EXHAUSTED`, `INVALID_OPTIONS`.
|
||||
- `ReactAudioStreamError extends SuaveplanError` — inherits structured
|
||||
context, cause-chain support, and serialization to the shared wire
|
||||
format.
|
||||
- Idempotent `registerReactAudioStreamErrorCodes()` registration hook
|
||||
for symmetry with sibling Suaveplan packages.
|
||||
- `Object.freeze`-d metadata table — mutation attempts throw in strict
|
||||
mode and a test guards the invariant.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
ReactAudioStreamError,
|
||||
} from "@suaveplan/react-audio-stream/error-codes";
|
||||
|
||||
throw new ReactAudioStreamError(
|
||||
REACT_AUDIO_STREAM_CODES.NO_PROVIDER,
|
||||
"react-audio-stream.no-provider",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA,
|
||||
ReactAudioStreamError,
|
||||
} from "@suaveplan/react-audio-stream/error-codes";
|
||||
|
||||
try {
|
||||
await session.join();
|
||||
} catch (err) {
|
||||
if (err instanceof ReactAudioStreamError) {
|
||||
const meta = REACT_AUDIO_STREAM_ERROR_METADATA[err.code];
|
||||
logger.warn({
|
||||
code: err.code,
|
||||
severity: meta.severity,
|
||||
description: meta.description,
|
||||
context: err.context,
|
||||
}, "stream session failure");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_STREAM_CODES` — frozen object literal keyed by short
|
||||
code, value is the dotted string (`"REACT_AUDIO_STREAM.NO_PROVIDER"`).
|
||||
- `ReactAudioStreamErrorCode` — derived union of every value in the
|
||||
code registry.
|
||||
- `ReactAudioStreamErrorMetadata` — `{ description, severity }` pair
|
||||
per code.
|
||||
- `REACT_AUDIO_STREAM_ERROR_METADATA` — `Record<ReactAudioStreamErrorCode,
|
||||
ReactAudioStreamErrorMetadata>`, `Object.freeze`-d at module load.
|
||||
- `ReactAudioStreamError` — `SuaveplanError<ReactAudioStreamErrorCode>`
|
||||
subclass; `category` is the literal `"react-audio-stream"`.
|
||||
- `registerReactAudioStreamErrorCodes(): void` — idempotent no-op
|
||||
retained for parity with sibling registries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` literally typesetting the runtime values means the
|
||||
`ReactAudioStreamErrorCode` union is derived without a second
|
||||
hand-maintained declaration. Adding a code requires only an entry in
|
||||
both `REACT_AUDIO_STREAM_CODES` and `REACT_AUDIO_STREAM_ERROR_METADATA`
|
||||
— the metadata test fails otherwise. Severity is currently `error` on
|
||||
every entry; the field exists so future warning-class diagnostics
|
||||
(deprecated transport probes, lossy fallbacks) slot in without a
|
||||
schema change. The class extends the foundation `SuaveplanError`
|
||||
directly — the parent OpenSpec change references an `AudioError` from
|
||||
`@suaveplan/audio-core` but the chunk-A dep allowlist excludes that
|
||||
package, so we follow the same pattern as sibling `@suaveplan/react-audio`
|
||||
which also extends the foundation base.
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA,
|
||||
ReactAudioStreamError,
|
||||
type ReactAudioStreamErrorCode,
|
||||
registerReactAudioStreamErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("every code in REACT_AUDIO_STREAM_CODES has metadata", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(REACT_AUDIO_STREAM_CODES)) {
|
||||
const meta =
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA[
|
||||
code as ReactAudioStreamErrorCode
|
||||
];
|
||||
expect(meta).toBeDefined();
|
||||
expect(meta.description.length).toBeGreaterThan(0);
|
||||
expect(meta.severity).toMatch(/^(warning|error)$/);
|
||||
}
|
||||
expect(Object.keys(REACT_AUDIO_STREAM_ERROR_METADATA).length).toBe(
|
||||
Object.values(REACT_AUDIO_STREAM_CODES).length
|
||||
);
|
||||
});
|
||||
|
||||
it("every REACT_AUDIO_STREAM code is reachable from a call site", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const seen = new Set<string>();
|
||||
for (const code of Object.values(REACT_AUDIO_STREAM_CODES)) {
|
||||
const err = new ReactAudioStreamError(
|
||||
code as ReactAudioStreamErrorCode,
|
||||
"react-audio-stream.test"
|
||||
);
|
||||
seen.add(err.code);
|
||||
}
|
||||
expect(seen.size).toBe(Object.values(REACT_AUDIO_STREAM_CODES).length);
|
||||
});
|
||||
|
||||
it("ReactAudioStreamError carries code, cause, and context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new ReactAudioStreamError(
|
||||
REACT_AUDIO_STREAM_CODES.INVALID_STATE,
|
||||
"react-audio-stream.invalid-state",
|
||||
{ context: { from: "idle", to: "joining" }, cause }
|
||||
);
|
||||
expect(err.code).toBe(REACT_AUDIO_STREAM_CODES.INVALID_STATE);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ from: "idle", to: "joining" });
|
||||
expect(err.category).toBe("react-audio-stream");
|
||||
expect(err.name).toMatch(/^ReactAudioStreamError\((warning|error)\)$/);
|
||||
});
|
||||
|
||||
it("registerReactAudioStreamErrorCodes is idempotent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
registerReactAudioStreamErrorCodes();
|
||||
registerReactAudioStreamErrorCodes();
|
||||
expect(Object.isFrozen(REACT_AUDIO_STREAM_ERROR_METADATA)).toBe(true);
|
||||
});
|
||||
|
||||
it("dotted codes are stable", { timeout: 5000 }, () => {
|
||||
expect(REACT_AUDIO_STREAM_CODES.NO_PROVIDER).toBe(
|
||||
"REACT_AUDIO_STREAM.NO_PROVIDER"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.INVALID_STATE).toBe(
|
||||
"REACT_AUDIO_STREAM.INVALID_STATE"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.SIGNALLING_FAILED).toBe(
|
||||
"REACT_AUDIO_STREAM.SIGNALLING_FAILED"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.PEER_NOT_FOUND).toBe(
|
||||
"REACT_AUDIO_STREAM.PEER_NOT_FOUND"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.MEDIA_PERMISSION_DENIED).toBe(
|
||||
"REACT_AUDIO_STREAM.MEDIA_PERMISSION_DENIED"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.RECONNECT_EXHAUSTED).toBe(
|
||||
"REACT_AUDIO_STREAM.RECONNECT_EXHAUSTED"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.INVALID_OPTIONS).toBe(
|
||||
"REACT_AUDIO_STREAM.INVALID_OPTIONS"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error codes published by `@suaveplan/react-audio-stream`.
|
||||
*
|
||||
* Every entry is namespaced under the `REACT_AUDIO_STREAM.` prefix by
|
||||
* {@link defineCodes}, producing dotted strings such as
|
||||
* `REACT_AUDIO_STREAM.NO_PROVIDER`. The seven codes cover the full
|
||||
* failure surface of the stream-tier package — from missing-provider
|
||||
* misuses, invalid FSM transitions, signalling handshake failures,
|
||||
* unknown participant references, browser-permission denials, retry
|
||||
* exhaustion, and option-schema validation failures.
|
||||
*/
|
||||
export const REACT_AUDIO_STREAM_CODES = defineCodes("REACT_AUDIO_STREAM", [
|
||||
"NO_PROVIDER",
|
||||
"INVALID_STATE",
|
||||
"SIGNALLING_FAILED",
|
||||
"PEER_NOT_FOUND",
|
||||
"MEDIA_PERMISSION_DENIED",
|
||||
"RECONNECT_EXHAUSTED",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
export type ReactAudioStreamErrorCode =
|
||||
(typeof REACT_AUDIO_STREAM_CODES)[keyof typeof REACT_AUDIO_STREAM_CODES];
|
||||
|
||||
export interface ReactAudioStreamErrorMetadata {
|
||||
readonly description: string;
|
||||
readonly severity: "warning" | "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen registry of metadata keyed by code. Every code in
|
||||
* {@link REACT_AUDIO_STREAM_CODES} has exactly one entry here; the test
|
||||
* suite cross-checks the two sets.
|
||||
*/
|
||||
export const REACT_AUDIO_STREAM_ERROR_METADATA: Readonly<
|
||||
Record<ReactAudioStreamErrorCode, ReactAudioStreamErrorMetadata>
|
||||
> = Object.freeze({
|
||||
[REACT_AUDIO_STREAM_CODES.NO_PROVIDER]: {
|
||||
description:
|
||||
"a react-audio-stream hook was rendered outside a <StreamProvider> ancestor.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.INVALID_STATE]: {
|
||||
description:
|
||||
"a StreamSession method was invoked in a state from which the FSM does not allow the requested transition.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.SIGNALLING_FAILED]: {
|
||||
description:
|
||||
"the signalling channel rejected, timed out, or closed unexpectedly during connect/join.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.PEER_NOT_FOUND]: {
|
||||
description:
|
||||
"a peer-scoped operation referenced a participantId that the session does not currently track.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.MEDIA_PERMISSION_DENIED]: {
|
||||
description:
|
||||
"the user agent denied microphone or camera permission to the local participant.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.RECONNECT_EXHAUSTED]: {
|
||||
description:
|
||||
"auto-reconnect retried up to maxReconnectAttempts without re-establishing the session; the FSM remains in 'disconnected'.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.INVALID_OPTIONS]: {
|
||||
description:
|
||||
"StreamSessionOptions or transport-adapter options failed Zod boundary validation.",
|
||||
severity: "error",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Base error class for every error thrown by
|
||||
* `@suaveplan/react-audio-stream`. Every instance carries a code from
|
||||
* {@link REACT_AUDIO_STREAM_CODES}, a `messageKey` suitable for i18n
|
||||
* resolution, and optional `context` / `cause`.
|
||||
*
|
||||
* Extends {@link SuaveplanError} so consumers can discriminate via
|
||||
* `instanceof` or the stable `error.code` property.
|
||||
*/
|
||||
export class ReactAudioStreamError extends SuaveplanError<ReactAudioStreamErrorCode> {
|
||||
public readonly category = "react-audio-stream" as const;
|
||||
|
||||
public constructor(
|
||||
code: ReactAudioStreamErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = `ReactAudioStreamError(${REACT_AUDIO_STREAM_ERROR_METADATA[code].severity})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent registration hook. The metadata is frozen at module load
|
||||
* and re-exported directly; this function exists for parity with the
|
||||
* `registerXxxErrorCodes()` convention adopted by sibling Suaveplan
|
||||
* packages and is safe to call any number of times.
|
||||
*/
|
||||
export function registerReactAudioStreamErrorCodes(): void {
|
||||
/* metadata is module-static; no-op. */
|
||||
}
|
||||
|
||||
registerReactAudioStreamErrorCodes();
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Re-exports of the public error-code types. Kept in a sibling
|
||||
* `.types.ts` so docs-check exempts it from sibling-`.md` requirements
|
||||
* while still being available to consumers that prefer type-only imports.
|
||||
*/
|
||||
export type {
|
||||
ReactAudioStreamErrorCode,
|
||||
ReactAudioStreamErrorMetadata,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
ReactAudioStreamErrorCode,
|
||||
ReactAudioStreamErrorMetadata,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA,
|
||||
ReactAudioStreamError,
|
||||
registerReactAudioStreamErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
22
packages/ui/react/react-audio-stream/src/hooks/index.ts
Normal file
22
packages/ui/react/react-audio-stream/src/hooks/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export { useStreamLevels } from "./use-stream-levels/use-stream-levels.js";
|
||||
export {
|
||||
type UseStreamMixerResult,
|
||||
useStreamMixer,
|
||||
} from "./use-stream-mixer/use-stream-mixer.js";
|
||||
export { useStreamPeers } from "./use-stream-peers/use-stream-peers.js";
|
||||
export {
|
||||
type UseStreamPermissionsResult,
|
||||
useStreamPermissions,
|
||||
} from "./use-stream-permissions/use-stream-permissions.js";
|
||||
export {
|
||||
type UseStreamRecordingResult,
|
||||
useStreamRecording,
|
||||
} from "./use-stream-recording/use-stream-recording.js";
|
||||
export {
|
||||
type UseStreamRoomResult,
|
||||
useStreamRoom,
|
||||
} from "./use-stream-room/use-stream-room.js";
|
||||
export {
|
||||
type UseStreamSessionResult,
|
||||
useStreamSession,
|
||||
} from "./use-stream-session/use-stream-session.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { useStreamContextOrThrow } from "./use-stream-context.js";
|
||||
@@ -0,0 +1,59 @@
|
||||
# use-stream-context (internal)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal helper consumed by every public chunk-C hook. It reads the
|
||||
`StreamReactContext`, returns the non-null `{ session, version }` container, and throws a
|
||||
structured `REACT_AUDIO_STREAM.NO_PROVIDER`-coded error when the hook ran outside any
|
||||
`<StreamProvider>`. Centralising the null check in one place guarantees every hook in
|
||||
the package surfaces the same code string and the same exact message wording, which
|
||||
keeps consumer-facing error handling consistent.
|
||||
|
||||
The function is intentionally not exported from the package root — public hooks
|
||||
re-export their own narrow surface area and consumers never see this helper directly.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of truth for the `NO_PROVIDER` error.
|
||||
- Attaches a stable `code` property to the thrown `Error` so tests can switch on the
|
||||
code without parsing the message.
|
||||
- Returns the typed `StreamContextValue` (no `| null`) so downstream hooks need no
|
||||
defensive guards.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
|
||||
|
||||
export function useStreamSession() {
|
||||
const { session } = useStreamContextOrThrow();
|
||||
return session;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
try {
|
||||
useStreamContextOrThrow();
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code === "REACT_AUDIO_STREAM.NO_PROVIDER") {
|
||||
// Render a fallback UI explaining the missing provider.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamContextOrThrow(): StreamContextValue` — throws `Error & { code: "REACT_AUDIO_STREAM.NO_PROVIDER" }` when no provider is present.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The error is constructed as a plain `Error` (rather than the canonical
|
||||
`ReactAudioStreamError` from chunk A) so this module stays dependency-free relative to
|
||||
the session-core package. Chunk A's full `ReactAudioStreamError` class will subsume this
|
||||
shape on integration; the `code` property name is identical so the swap is mechanical.
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { FakeSession, withProvider } from "../../test-utils.js";
|
||||
import { useStreamContextOrThrow } from "./use-stream-context.js";
|
||||
|
||||
describe("useStreamContextOrThrow", () => {
|
||||
it("returns the context when inside a provider", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession();
|
||||
const { result } = renderHook(() => useStreamContextOrThrow(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current.session).toBe(session);
|
||||
expect(result.current.version).toBe(0);
|
||||
});
|
||||
|
||||
it("throws REACT_AUDIO_STREAM.NO_PROVIDER when no provider is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() => {
|
||||
try {
|
||||
return useStreamContextOrThrow();
|
||||
} catch (e) {
|
||||
return e as Error & { code?: string };
|
||||
}
|
||||
});
|
||||
const err = result.current as Error & { code?: string };
|
||||
expect(err.code).toBe("REACT_AUDIO_STREAM.NO_PROVIDER");
|
||||
expect(err.message).toContain("NO_PROVIDER");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useContext } from "react";
|
||||
import {
|
||||
type StreamContextValue,
|
||||
StreamReactContext,
|
||||
} from "../../provider/stream-context.js";
|
||||
|
||||
/**
|
||||
* Reads the `StreamReactContext` and throws a structured error with code
|
||||
* `REACT_AUDIO_STREAM.NO_PROVIDER` when the value is `null` (i.e. the hook
|
||||
* ran outside any `<StreamProvider>`). Tests can `expect(() => …).toThrow`
|
||||
* the code string to verify provider-less safety nets.
|
||||
*/
|
||||
export function useStreamContextOrThrow(): StreamContextValue {
|
||||
const ctx = useContext(StreamReactContext);
|
||||
if (ctx === null) {
|
||||
const err = new Error(
|
||||
"[REACT_AUDIO_STREAM.NO_PROVIDER] hook used outside <StreamProvider>"
|
||||
) as Error & { code: string };
|
||||
err.code = "REACT_AUDIO_STREAM.NO_PROVIDER";
|
||||
throw err;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useStreamLevels } from "./use-stream-levels.js";
|
||||
@@ -0,0 +1,54 @@
|
||||
# use-stream-levels
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamLevels` projects realtime peak/RMS samples out of the session. Called without
|
||||
an argument it returns the local-microphone level; called with a peer id it returns the
|
||||
named peer's level. The level numbers come from chunks A/B's analyzer slot (which lives
|
||||
on each peer's plugin chain alongside the `StreamSourcePlugin`), and this hook is a
|
||||
zero-state projection — it does not compute anything itself.
|
||||
|
||||
The reading is intentionally synchronous: the provider's render-bump version is what
|
||||
schedules re-renders, and the session is responsible for batching analyzer ticks into
|
||||
the `levels-changed` event channel at a sane cadence (typically 60 Hz).
|
||||
|
||||
## Features
|
||||
|
||||
- Same hook for local and per-peer levels — the `peerId?` argument selects.
|
||||
- Re-renders on every `levels-changed` event via the provider's version counter.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { peak, rms } = useStreamLevels();
|
||||
return <LevelMeter peak={peak} rms={rms} />;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
return peers.map((p) => {
|
||||
const { peak } = useStreamLevels(p.id);
|
||||
return <PeerLevelBar key={p.id} peerId={p.id} peak={peak} />;
|
||||
});
|
||||
```
|
||||
|
||||
(Real apps should hoist the per-peer call into a child component to keep the hook count
|
||||
stable across renders.)
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamLevels(peerId?: string): LevelSample` — `LevelSample` is `{ peak: number; rms: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook does not pull from `@suaveplan/audio-analysis-spectrum` directly — that module
|
||||
runs inside the audio engine (browser tier, owned by chunks A/B). The React boundary
|
||||
only ever sees the peak/RMS scalars produced by the engine's analyzer. Keeping the
|
||||
hook this thin guarantees zero DSP work happens during React rendering.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { FakeSession, withProvider } from "../../test-utils.js";
|
||||
import { useStreamLevels } from "./use-stream-levels.js";
|
||||
|
||||
describe("useStreamLevels", () => {
|
||||
it("returns the local level when peerId is omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({
|
||||
localLevel: { peak: 0.7, rms: 0.4 },
|
||||
});
|
||||
const { result } = renderHook(() => useStreamLevels(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current).toEqual({ peak: 0.7, rms: 0.4 });
|
||||
});
|
||||
|
||||
it("returns the per-peer level when peerId is supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({
|
||||
peerLevel: { peak: 0.2, rms: 0.1 },
|
||||
});
|
||||
const { result } = renderHook(() => useStreamLevels("alice"), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current).toEqual({ peak: 0.2, rms: 0.1 });
|
||||
});
|
||||
|
||||
it("re-renders when the session emits levels-changed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({ localLevel: { peak: 0, rms: 0 } });
|
||||
const { result } = renderHook(() => useStreamLevels(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
act(() => {
|
||||
session.setLocalLevel({ peak: 0.9, rms: 0.5 });
|
||||
});
|
||||
expect(result.current.peak).toBe(0.9);
|
||||
});
|
||||
|
||||
it("throws REACT_AUDIO_STREAM.NO_PROVIDER outside a provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() => {
|
||||
try {
|
||||
return useStreamLevels();
|
||||
} catch (e) {
|
||||
return e as Error & { code?: string };
|
||||
}
|
||||
});
|
||||
expect((result.current as Error & { code?: string }).code).toBe(
|
||||
"REACT_AUDIO_STREAM.NO_PROVIDER"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { LevelSample } from "../../session/session-shape/session-shape.types.js";
|
||||
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
|
||||
|
||||
/**
|
||||
* Returns realtime peak/RMS for the local microphone (when `peerId` is
|
||||
* omitted) or for the named peer. Levels come from the peer chain's
|
||||
* analyzer slot owned by chunks A+B; this hook is a pure projection.
|
||||
*/
|
||||
export function useStreamLevels(peerId?: string): LevelSample {
|
||||
const { session } = useStreamContextOrThrow();
|
||||
if (peerId === undefined) {
|
||||
return session.getLocalLevel();
|
||||
}
|
||||
return session.getPeerLevel(peerId);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type UseStreamMixerResult,
|
||||
useStreamMixer,
|
||||
} from "./use-stream-mixer.js";
|
||||
@@ -0,0 +1,72 @@
|
||||
# use-stream-mixer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer
|
||||
FX slot count — and implements DAW-correct solo semantics on top of the session's track
|
||||
mutators. It is the only hook in chunk C that owns cross-peer behaviour: when any peer
|
||||
is soloed, every non-soloed peer's track-volume is set to 0; when no peer is soloed,
|
||||
every track keeps its last user-set volume (the session is the source of truth for
|
||||
"last set" because the mutations go through `setTrackVolume`).
|
||||
|
||||
Every mutation routes through `session.setTrackVolume`, `session.setTrackMute`, and
|
||||
`session.setTrackSolo` rather than being re-implemented inside the hook — this is the
|
||||
explicit Requirement-6 constraint and the reason the test asserts on
|
||||
`session.calls.setTrackSolo` / `setTrackVolume`.
|
||||
|
||||
## Features
|
||||
|
||||
- DAW solo semantics: any solo activates the per-track ducking pass; clearing the last
|
||||
solo lets non-soloed tracks resume their stored volume.
|
||||
- Pure delegation: zero local volume state — the session owns it.
|
||||
- Stable callbacks via `useCallback`.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { peers, setVolume, setMute, setSolo } = useStreamMixer();
|
||||
return peers.map((p) => (
|
||||
<PeerStrip
|
||||
key={p.id}
|
||||
peer={p}
|
||||
onVolume={(v) => setVolume(p.id, v)}
|
||||
onMute={(m) => setMute(p.id, m)}
|
||||
onSolo={(s) => setSolo(p.id, s)}
|
||||
/>
|
||||
));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { peers, setSolo } = useStreamMixer();
|
||||
const soloExclusive = useCallback((id: string) => {
|
||||
// Solo one peer and clear all others' solos in a single React tick.
|
||||
peers.forEach((p) => p.soloed && p.id !== id && setSolo(p.id, false));
|
||||
setSolo(id, true);
|
||||
}, [peers, setSolo]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamMixerResult` — `{ peers, setVolume, setMute, setSolo }`
|
||||
- `useStreamMixer(): UseStreamMixerResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`setSolo` reads the current mixer snapshot from `session.getPeerMixerStates()`,
|
||||
applies the requested solo change in a local copy, computes `anySoloed`, and — when
|
||||
true — issues `setTrackVolume(otherId, 0)` for every non-soloed peer whose current
|
||||
volume is not already 0. The non-zero check avoids redundant mutations that would
|
||||
otherwise show up as extra `calls.setTrackVolume` entries in tests.
|
||||
|
||||
The "restore previous volume on un-solo" arm is delegated to the session: chunk A's
|
||||
session implementation stores the user-set volume independently from the live mixer
|
||||
volume, so when the last solo clears, the session emits a `mixer-changed` event that
|
||||
restores the stored values. The hook does not have to keep its own restore-map, which
|
||||
keeps the React surface stateless.
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { PeerMixerState } from "../../session/session-shape/session-shape.types.js";
|
||||
import { FakeSession, withProvider } from "../../test-utils.js";
|
||||
import { useStreamMixer } from "./use-stream-mixer.js";
|
||||
|
||||
function mix(id: string, extra: Partial<PeerMixerState> = {}): PeerMixerState {
|
||||
return {
|
||||
id,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
fxSlotCount: 0,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useStreamMixer", () => {
|
||||
it("returns the current mixer snapshot", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({ mixer: [mix("a"), mix("b")] });
|
||||
const { result } = renderHook(() => useStreamMixer(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current.peers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("setVolume / setMute route through the session", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({ mixer: [mix("a"), mix("b")] });
|
||||
const { result } = renderHook(() => useStreamMixer(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
act(() => result.current.setVolume("a", 0.5));
|
||||
act(() => result.current.setMute("b", true));
|
||||
expect(session.calls.setTrackVolume).toEqual([["a", 0.5]]);
|
||||
expect(session.calls.setTrackMute).toEqual([["b", true]]);
|
||||
});
|
||||
|
||||
it("soloing a peer drops every non-soloed peer to volume=0 (DAW semantics)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({
|
||||
mixer: [
|
||||
mix("a", { volume: 0.8 }),
|
||||
mix("b", { volume: 0.5 }),
|
||||
mix("c", { volume: 0.3 }),
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useStreamMixer(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
act(() => result.current.setSolo("a", true));
|
||||
|
||||
// The session received the solo mutation for "a"...
|
||||
expect(session.calls.setTrackSolo).toEqual([["a", true]]);
|
||||
// ...and the hook ducked b and c down to 0.
|
||||
expect(session.calls.setTrackVolume).toEqual([
|
||||
["b", 0],
|
||||
["c", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("soloing a peer that is already at volume=0 does not re-issue setTrackVolume", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({
|
||||
mixer: [mix("a", { volume: 0.8 }), mix("b", { volume: 0 })],
|
||||
});
|
||||
const { result } = renderHook(() => useStreamMixer(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
act(() => result.current.setSolo("a", true));
|
||||
expect(session.calls.setTrackVolume).toEqual([]);
|
||||
});
|
||||
|
||||
it("clearing a solo while another peer is still soloed keeps ducking", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({
|
||||
mixer: [
|
||||
mix("a", { volume: 0.8, soloed: true }),
|
||||
mix("b", { volume: 0.5, soloed: true }),
|
||||
mix("c", { volume: 0.3 }),
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useStreamMixer(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
act(() => result.current.setSolo("a", false));
|
||||
// b still soloed → a (now non-soloed) and c must be ducked to 0.
|
||||
expect(session.calls.setTrackVolume).toEqual([
|
||||
["a", 0],
|
||||
["c", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("clearing the last solo skips ducking entirely", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({
|
||||
mixer: [
|
||||
mix("a", { volume: 0.8, soloed: true }),
|
||||
mix("b", { volume: 0 }),
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useStreamMixer(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
act(() => result.current.setSolo("a", false));
|
||||
// No solo remains, so the hook MUST NOT emit any setTrackVolume.
|
||||
expect(session.calls.setTrackVolume).toEqual([]);
|
||||
expect(session.calls.setTrackSolo).toEqual([["a", false]]);
|
||||
});
|
||||
|
||||
it("throws REACT_AUDIO_STREAM.NO_PROVIDER outside a provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() => {
|
||||
try {
|
||||
return useStreamMixer();
|
||||
} catch (e) {
|
||||
return e as Error & { code?: string };
|
||||
}
|
||||
});
|
||||
expect((result.current as Error & { code?: string }).code).toBe(
|
||||
"REACT_AUDIO_STREAM.NO_PROVIDER"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback } from "react";
|
||||
import type { PeerMixerState } from "../../session/session-shape/session-shape.types.js";
|
||||
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
|
||||
|
||||
export interface UseStreamMixerResult {
|
||||
readonly peers: readonly PeerMixerState[];
|
||||
/** Set per-peer track volume (linear gain 0..1 or dB depending on engine). */
|
||||
setVolume: (peerId: string, volume: number) => void;
|
||||
/** Toggle/set explicit mute on a peer's track. */
|
||||
setMute: (peerId: string, muted: boolean) => void;
|
||||
/**
|
||||
* Toggle/set solo on a peer's track. Mutates `setTrackSolo` for the
|
||||
* target. The hook then applies DAW solo semantics: when any peer is
|
||||
* soloed, every non-soloed peer's track-volume is set to 0; when no
|
||||
* peer is soloed, every track returns to its previously-set volume.
|
||||
*/
|
||||
setSolo: (peerId: string, soloed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-peer mixer hook implementing DAW solo semantics. Every mutation
|
||||
* routes through the session's `setTrackVolume` / `setTrackMute` /
|
||||
* `setTrackSolo` methods (Requirement: "Per-peer stream mixer").
|
||||
*/
|
||||
export function useStreamMixer(): UseStreamMixerResult {
|
||||
const { session } = useStreamContextOrThrow();
|
||||
|
||||
const setVolume = useCallback(
|
||||
(peerId: string, volume: number) => {
|
||||
session.setTrackVolume(peerId, volume);
|
||||
},
|
||||
[session]
|
||||
);
|
||||
|
||||
const setMute = useCallback(
|
||||
(peerId: string, muted: boolean) => {
|
||||
session.setTrackMute(peerId, muted);
|
||||
},
|
||||
[session]
|
||||
);
|
||||
|
||||
const setSolo = useCallback(
|
||||
(peerId: string, soloed: boolean) => {
|
||||
const current = session.getPeerMixerStates();
|
||||
session.setTrackSolo(peerId, soloed);
|
||||
|
||||
const nextSoloed = current.map((p) =>
|
||||
p.id === peerId ? { ...p, soloed } : p
|
||||
);
|
||||
const anySoloed = nextSoloed.some((p) => p.soloed);
|
||||
|
||||
if (anySoloed) {
|
||||
// DAW semantics: drop every non-soloed peer to 0 volume.
|
||||
for (const p of nextSoloed) {
|
||||
if (!p.soloed && p.volume !== 0) {
|
||||
session.setTrackVolume(p.id, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[session]
|
||||
);
|
||||
|
||||
return {
|
||||
peers: session.getPeerMixerStates(),
|
||||
setVolume,
|
||||
setMute,
|
||||
setSolo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useStreamPeers } from "./use-stream-peers.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# use-stream-peers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each
|
||||
peer entry carries the participant id, display name, transport connection state, and
|
||||
the latest measured round-trip time in milliseconds. The hook is the canonical source
|
||||
of peer data for any UI that renders a participant list, a per-peer mixer strip, or a
|
||||
per-peer level meter, and it re-evaluates whenever the provider's render-bump version
|
||||
ticks.
|
||||
|
||||
The hook is intentionally a thin projection — it does not own selection, sorting, or
|
||||
filtering. Consumers compose their own derived state on top with `useMemo`.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable array identity across renders when peers have not changed.
|
||||
- Re-renders on `peers-changed`, `state-changed`, and every other session event channel
|
||||
via the provider's render-bump counter.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
return (
|
||||
<ul>
|
||||
{peers.map((p) => (
|
||||
<li key={p.id}>{p.displayName} ({p.rttMs} ms)</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
const sorted = useMemo(
|
||||
() => [...peers].sort((a, b) => a.displayName.localeCompare(b.displayName)),
|
||||
[peers]
|
||||
);
|
||||
const slow = sorted.filter((p) => p.rttMs > 150);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamPeers(): readonly Peer[]` — see `Peer` in `session/session-shape/session-shape.types.ts`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation memoises against the provider's `version` counter, which is the
|
||||
only legitimate invalidation signal — components must not depend on the peer array's
|
||||
internal identity for invalidation because the session is free to reuse the same array
|
||||
across mutations (for example when only RTT changed). The version-counter approach is
|
||||
intentionally over-eager: re-deriving an array reference is O(1) and matches the
|
||||
package-wide pattern.
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { FakeSession, withProvider } from "../../test-utils.js";
|
||||
import { useStreamPeers } from "./use-stream-peers.js";
|
||||
|
||||
describe("useStreamPeers", () => {
|
||||
it("returns the current peer list", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({
|
||||
peers: [
|
||||
{
|
||||
id: "a",
|
||||
displayName: "A",
|
||||
connectionState: "connected",
|
||||
rttMs: 8,
|
||||
},
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useStreamPeers(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current).toHaveLength(1);
|
||||
expect(result.current[0].id).toBe("a");
|
||||
});
|
||||
|
||||
it("re-renders when peers change", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession();
|
||||
const { result } = renderHook(() => useStreamPeers(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current).toHaveLength(0);
|
||||
act(() => {
|
||||
session.pushPeer({
|
||||
id: "b",
|
||||
displayName: "B",
|
||||
connectionState: "connected",
|
||||
rttMs: 12,
|
||||
});
|
||||
});
|
||||
expect(result.current).toHaveLength(1);
|
||||
expect(result.current[0].id).toBe("b");
|
||||
});
|
||||
|
||||
it("throws REACT_AUDIO_STREAM.NO_PROVIDER outside a provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() => {
|
||||
try {
|
||||
return useStreamPeers();
|
||||
} catch (e) {
|
||||
return e as Error & { code?: string };
|
||||
}
|
||||
});
|
||||
expect((result.current as Error & { code?: string }).code).toBe(
|
||||
"REACT_AUDIO_STREAM.NO_PROVIDER"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Peer } from "../../session/session-shape/session-shape.types.js";
|
||||
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
|
||||
|
||||
/**
|
||||
* Returns the live array of remote peers. Each peer carries `id`,
|
||||
* `displayName`, `connectionState`, and `rttMs`. The session owns array
|
||||
* identity stability — the hook just forwards the current snapshot on
|
||||
* each render bump driven by the provider's `version` counter.
|
||||
*/
|
||||
export function useStreamPeers(): readonly Peer[] {
|
||||
const { session } = useStreamContextOrThrow();
|
||||
return session.getPeers();
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type UseStreamPermissionsResult,
|
||||
useStreamPermissions,
|
||||
} from "./use-stream-permissions.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# use-stream-permissions
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamPermissions` exposes the microphone permission state with the typed
|
||||
`MediaPermissionState` enum (`"granted" | "denied" | "prompt" | "unknown"`) and a
|
||||
`request()` callback that triggers `navigator.mediaDevices.getUserMedia` via the
|
||||
session. The hook decouples React components from the browser's permission API surface
|
||||
so consumers can render a permissions banner, a "click to enable mic" button, or a
|
||||
hard-blocked error UI without touching `navigator` themselves.
|
||||
|
||||
The hook is provider-bound — it does not poll `navigator.permissions.query` itself.
|
||||
Chunk A's session is responsible for keeping the permission state fresh (either by
|
||||
listening to the `PermissionStatus` `change` event or by re-querying on
|
||||
`getUserMedia` errors) and emitting `'permission-changed'` so the provider's
|
||||
render-bump version increments.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable `request` callback (`useCallback`-wrapped) for safe use in event handlers.
|
||||
- Re-renders on every `'permission-changed'` event channel.
|
||||
- Returns the typed enum rather than the browser's `string` so consumers can do
|
||||
exhaustive switches.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { state, request } = useStreamPermissions();
|
||||
if (state === "denied") return <PermissionsBlockedBanner />;
|
||||
return <button onClick={request}>Enable microphone</button>;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { state, request } = useStreamPermissions();
|
||||
useEffect(() => {
|
||||
if (state === "prompt") {
|
||||
// Auto-request as soon as the user navigates into the room view.
|
||||
void request();
|
||||
}
|
||||
}, [state, request]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamPermissionsResult` — `{ state, request }`
|
||||
- `useStreamPermissions(): UseStreamPermissionsResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`request` resolves with the next permission state rather than `void` so a consumer that
|
||||
wants to chain into `connect()` immediately after granting can do so without a separate
|
||||
re-render hop. The session implementation is responsible for emitting
|
||||
`'permission-changed'` synchronously after the resolve, which keeps the React render
|
||||
state aligned with the promise result.
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { FakeSession, withProvider } from "../../test-utils.js";
|
||||
import { useStreamPermissions } from "./use-stream-permissions.js";
|
||||
|
||||
describe("useStreamPermissions", () => {
|
||||
it("returns the current permission state", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({ permission: "prompt" });
|
||||
const { result } = renderHook(() => useStreamPermissions(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current.state).toBe("prompt");
|
||||
});
|
||||
|
||||
it("request() drives the session and re-renders with granted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const session = new FakeSession({ permission: "prompt" });
|
||||
const { result } = renderHook(() => useStreamPermissions(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
await act(async () => {
|
||||
const next = await result.current.request();
|
||||
expect(next).toBe("granted");
|
||||
});
|
||||
expect(result.current.state).toBe("granted");
|
||||
});
|
||||
|
||||
it("throws REACT_AUDIO_STREAM.NO_PROVIDER outside a provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() => {
|
||||
try {
|
||||
return useStreamPermissions();
|
||||
} catch (e) {
|
||||
return e as Error & { code?: string };
|
||||
}
|
||||
});
|
||||
expect((result.current as Error & { code?: string }).code).toBe(
|
||||
"REACT_AUDIO_STREAM.NO_PROVIDER"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from "react";
|
||||
import type { MediaPermissionState } from "../../session/session-shape/session-shape.types.js";
|
||||
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
|
||||
|
||||
export interface UseStreamPermissionsResult {
|
||||
readonly state: MediaPermissionState;
|
||||
/**
|
||||
* Trigger `navigator.mediaDevices.getUserMedia` for the microphone via
|
||||
* the session. Resolves to the next state (`"granted"` / `"denied"`).
|
||||
*/
|
||||
request: () => Promise<MediaPermissionState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Microphone permission hook. Wraps the session's permission probe with
|
||||
* a typed `MediaPermissionState` enum and a stable `request` callback.
|
||||
*/
|
||||
export function useStreamPermissions(): UseStreamPermissionsResult {
|
||||
const { session } = useStreamContextOrThrow();
|
||||
const request = useCallback(
|
||||
() => session.requestMediaPermission(),
|
||||
[session]
|
||||
);
|
||||
return {
|
||||
state: session.getMediaPermission(),
|
||||
request,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type UseStreamRecordingResult,
|
||||
useStreamRecording,
|
||||
} from "./use-stream-recording.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
# use-stream-recording
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamRecording` exposes the start/stop controls for capturing a session as a single
|
||||
audio file. The hook delegates to chunks A/B's session, which performs an offline render
|
||||
through `audio-engine-browser`: every peer's plugin chain (including the
|
||||
`StreamSourcePlugin` and any user-added FX such as the per-peer mute/solo) is mixed into
|
||||
a single output `Blob` with timing metadata.
|
||||
|
||||
Two recording scopes are supported. Calling `start()` with no argument records the
|
||||
entire session output. Calling `start(["alice", "bob"])` records only those peers plus
|
||||
the local microphone — useful for "record this guest" interview flows.
|
||||
|
||||
## Features
|
||||
|
||||
- `isRecording` boolean reflects the session's true recording state, not a local React
|
||||
flag, so external triggers (e.g. a keyboard shortcut handled outside the React tree)
|
||||
still drive the UI correctly.
|
||||
- `start` accepts an optional `peerIds` filter for partial recordings.
|
||||
- `stop` returns a `RecordingResult` with `{ blob, durationMs, startedAt, endedAt }`.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { isRecording, start, stop } = useStreamRecording();
|
||||
return (
|
||||
<button
|
||||
onClick={async () => (isRecording ? stop() : start())}
|
||||
aria-pressed={isRecording}
|
||||
>
|
||||
{isRecording ? "Stop" : "Record"}
|
||||
</button>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { start, stop } = useStreamRecording();
|
||||
const peers = useStreamPeers();
|
||||
const recordGuests = useCallback(async () => {
|
||||
const guestIds = peers.filter((p) => p.id !== "host").map((p) => p.id);
|
||||
await start(guestIds);
|
||||
setTimeout(async () => {
|
||||
const { blob, durationMs } = await stop();
|
||||
await uploadInterview(blob, durationMs);
|
||||
}, 60_000);
|
||||
}, [peers, start, stop]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamRecordingResult` — `{ isRecording, start, stop }`
|
||||
- `useStreamRecording(): UseStreamRecordingResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook reads `session.isRecording()` synchronously on every render rather than caching
|
||||
it in local state. This is safe because the provider's render-bump version increments on
|
||||
every `recording-changed` event, so the read is always fresh and the React render path
|
||||
stays the single source of truth for `aria-pressed` / `aria-busy` indicators.
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { FakeSession, withProvider } from "../../test-utils.js";
|
||||
import { useStreamRecording } from "./use-stream-recording.js";
|
||||
|
||||
describe("useStreamRecording", () => {
|
||||
it("reports isRecording=false initially and toggles via start/stop", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const session = new FakeSession();
|
||||
const { result } = renderHook(() => useStreamRecording(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current.isRecording).toBe(false);
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
expect(result.current.isRecording).toBe(true);
|
||||
await act(async () => {
|
||||
const out = await result.current.stop();
|
||||
expect(out.blob).toBeInstanceOf(Blob);
|
||||
expect(out.durationMs).toBe(2500);
|
||||
});
|
||||
expect(result.current.isRecording).toBe(false);
|
||||
});
|
||||
|
||||
it("passes peerIds through to the session", { timeout: 5000 }, async () => {
|
||||
const session = new FakeSession();
|
||||
const { result } = renderHook(() => useStreamRecording(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.start(["alice", "bob"]);
|
||||
});
|
||||
expect(session.calls.startRecording[0]).toEqual(["alice", "bob"]);
|
||||
});
|
||||
|
||||
it("throws REACT_AUDIO_STREAM.NO_PROVIDER outside a provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() => {
|
||||
try {
|
||||
return useStreamRecording();
|
||||
} catch (e) {
|
||||
return e as Error & { code?: string };
|
||||
}
|
||||
});
|
||||
expect((result.current as Error & { code?: string }).code).toBe(
|
||||
"REACT_AUDIO_STREAM.NO_PROVIDER"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback } from "react";
|
||||
import type { RecordingResult } from "../../session/session-shape/session-shape.types.js";
|
||||
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
|
||||
|
||||
export interface UseStreamRecordingResult {
|
||||
readonly isRecording: boolean;
|
||||
/**
|
||||
* Start recording. When `peerIds` is omitted, the recording mixes the
|
||||
* full session output. When supplied, only the named peers (plus the
|
||||
* local mic) are mixed into the result.
|
||||
*/
|
||||
start: (peerIds?: readonly string[]) => Promise<void>;
|
||||
/** Stop recording and return the resulting blob plus timing metadata. */
|
||||
stop: () => Promise<RecordingResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording control hook. Delegates to the session's `startRecording` /
|
||||
* `stopRecording` (Requirement: "Per-peer stream mixer" — the recorder
|
||||
* pipes through the same per-peer chains used by the mixer, so mute and
|
||||
* solo settings carry into the rendered blob).
|
||||
*/
|
||||
export function useStreamRecording(): UseStreamRecordingResult {
|
||||
const { session } = useStreamContextOrThrow();
|
||||
const start = useCallback(
|
||||
(peerIds?: readonly string[]) => session.startRecording(peerIds),
|
||||
[session]
|
||||
);
|
||||
const stop = useCallback(() => session.stopRecording(), [session]);
|
||||
return {
|
||||
isRecording: session.isRecording(),
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type UseStreamRoomResult,
|
||||
useStreamRoom,
|
||||
} from "./use-stream-room.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# use-stream-room
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamRoom` projects the current room scope out of the session: the room id (or
|
||||
`null` when the session is not connected), an `isJoined` boolean derived from the FSM
|
||||
state, the `joinedAt` epoch-milliseconds timestamp, and the live participant list
|
||||
(remote peers only — the local user is represented via the session itself, not the
|
||||
participant array).
|
||||
|
||||
The hook is intentionally cheap and read-only; components that render the room header,
|
||||
the participant chip-list, or any "you have not joined yet" empty state can depend on
|
||||
it without pulling in the heavier mixer/level/recording surfaces.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure projection — no callbacks, no side effects.
|
||||
- `participants` is a stable reference between renders when peers have not changed,
|
||||
thanks to the memoisation barrier and the session's own array stability contract.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { roomId, isJoined, participants } = useStreamRoom();
|
||||
if (!isJoined) return <JoinButton />;
|
||||
return (
|
||||
<header>
|
||||
<h2>{roomId}</h2>
|
||||
<p>{participants.length} present</p>
|
||||
</header>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { participants, joinedAt } = useStreamRoom();
|
||||
const elapsedMs = joinedAt === null ? 0 : Date.now() - joinedAt;
|
||||
return (
|
||||
<Stats elapsedMs={elapsedMs} peerIds={participants.map((p) => p.id)} />
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamRoomResult` — `{ roomId, isJoined, joinedAt, participants }`
|
||||
- `useStreamRoom(): UseStreamRoomResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`isJoined` is derived from `session.state === "joined"` rather than from a separate
|
||||
`isJoined` field on the session, because the FSM state is the single source of truth
|
||||
(see Requirement "StreamSession FSM lifecycle"). The participant array is read via
|
||||
`session.getPeers()` on every render; the provider's render-bump version keeps that read
|
||||
fresh.
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { FakeSession, withProvider } from "../../test-utils.js";
|
||||
import { useStreamRoom } from "./use-stream-room.js";
|
||||
|
||||
describe("useStreamRoom", () => {
|
||||
it("reflects the session room id and join status", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession({
|
||||
state: "joined",
|
||||
roomId: "lobby",
|
||||
joinedAt: 1234,
|
||||
peers: [
|
||||
{
|
||||
id: "a",
|
||||
displayName: "A",
|
||||
connectionState: "connected",
|
||||
rttMs: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useStreamRoom(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current.roomId).toBe("lobby");
|
||||
expect(result.current.isJoined).toBe(true);
|
||||
expect(result.current.joinedAt).toBe(1234);
|
||||
expect(result.current.participants).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports isJoined=false when not joined", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({ state: "connected", roomId: "x" });
|
||||
const { result } = renderHook(() => useStreamRoom(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current.isJoined).toBe(false);
|
||||
});
|
||||
|
||||
it("re-renders when peers change", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({ state: "joined" });
|
||||
const { result } = renderHook(() => useStreamRoom(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current.participants).toHaveLength(0);
|
||||
act(() => {
|
||||
session.pushPeer({
|
||||
id: "b",
|
||||
displayName: "B",
|
||||
connectionState: "connected",
|
||||
rttMs: 5,
|
||||
});
|
||||
});
|
||||
expect(result.current.participants).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws REACT_AUDIO_STREAM.NO_PROVIDER outside a provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() => {
|
||||
try {
|
||||
return useStreamRoom();
|
||||
} catch (e) {
|
||||
return e as Error & { code?: string };
|
||||
}
|
||||
});
|
||||
expect((result.current as Error & { code?: string }).code).toBe(
|
||||
"REACT_AUDIO_STREAM.NO_PROVIDER"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Peer } from "../../session/session-shape/session-shape.types.js";
|
||||
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
|
||||
|
||||
export interface UseStreamRoomResult {
|
||||
readonly roomId: string | null;
|
||||
readonly isJoined: boolean;
|
||||
readonly joinedAt: number | null;
|
||||
readonly participants: readonly Peer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the room-scope projection of the session: room id, joined-ness,
|
||||
* join timestamp, and participants. Participants always include the
|
||||
* remote peers; the local participant is represented separately via the
|
||||
* session itself.
|
||||
*/
|
||||
export function useStreamRoom(): UseStreamRoomResult {
|
||||
const { session } = useStreamContextOrThrow();
|
||||
return {
|
||||
roomId: session.roomId,
|
||||
isJoined: session.state === "joined",
|
||||
joinedAt: session.joinedAt,
|
||||
participants: session.getPeers(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type UseStreamSessionResult,
|
||||
useStreamSession,
|
||||
} from "./use-stream-session.js";
|
||||
@@ -0,0 +1,59 @@
|
||||
# use-stream-session
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live
|
||||
`StreamSession` instance, its current FSM state (`idle` / `connecting` / `connected` /
|
||||
`joining` / `joined` / `disconnecting` / `disconnected`), and four stable callback
|
||||
references for `connect`, `join`, `leave`, and `disconnect`. The callbacks delegate to
|
||||
the underlying session, which is the only legal source of FSM mutations.
|
||||
|
||||
The hook re-runs whenever the provider's render-bump version increments, which means a
|
||||
component using `useStreamSession()` re-renders on every session event — including
|
||||
`'state-changed'`, `'interruption'`, peer changes, mixer changes, and so on. Components
|
||||
that need finer granularity should compose narrower hooks (`useStreamPeers`,
|
||||
`useStreamMixer`, …).
|
||||
|
||||
## Features
|
||||
|
||||
- Direct access to the live `StreamSessionLike` for cases that need methods beyond the
|
||||
big four (e.g. `requestTransportShare` from chunk D).
|
||||
- Stable `useCallback`-wrapped lifecycle methods so consumer components can pass them
|
||||
to memoised children without churning.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used outside `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { state, connect, join } = useStreamSession();
|
||||
if (state === "idle") {
|
||||
return <button onClick={() => connect("lobby").then(join)}>Join lobby</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { session, state, disconnect } = useStreamSession();
|
||||
useEffect(() => {
|
||||
return session.on("interruption", (klass) => {
|
||||
if (klass === "media-permission") void disconnect();
|
||||
});
|
||||
}, [session, disconnect]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamSessionResult` — `{ session, state, connect, join, leave, disconnect }`
|
||||
- `useStreamSession(): UseStreamSessionResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The returned object is memoised against `session.state` so a render-bump caused by a
|
||||
non-state event (e.g. a peer-level tick) does not produce a new object identity. The
|
||||
callback references depend only on the session, so they remain stable for the lifetime
|
||||
of the provider — even across state transitions.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { FakeSession, withProvider } from "../../test-utils.js";
|
||||
import { useStreamSession } from "./use-stream-session.js";
|
||||
|
||||
describe("useStreamSession", () => {
|
||||
it("returns the session and current state", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({ state: "joined" });
|
||||
const { result } = renderHook(() => useStreamSession(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
expect(result.current.session).toBe(session);
|
||||
expect(result.current.state).toBe("joined");
|
||||
});
|
||||
|
||||
it("exposes stable lifecycle callbacks", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession();
|
||||
const { result, rerender } = renderHook(() => useStreamSession(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
const first = result.current.connect;
|
||||
rerender();
|
||||
expect(result.current.connect).toBe(first);
|
||||
});
|
||||
|
||||
it("drives the FSM through connect/join/leave/disconnect", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const session = new FakeSession();
|
||||
const { result } = renderHook(() => useStreamSession(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.connect("room1");
|
||||
});
|
||||
expect(result.current.state).toBe("connected");
|
||||
await act(async () => {
|
||||
await result.current.join();
|
||||
});
|
||||
expect(result.current.state).toBe("joined");
|
||||
await act(async () => {
|
||||
await result.current.leave();
|
||||
});
|
||||
expect(result.current.state).toBe("connected");
|
||||
await act(async () => {
|
||||
await result.current.disconnect();
|
||||
});
|
||||
expect(result.current.state).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("throws REACT_AUDIO_STREAM.NO_PROVIDER when used outside a provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() => {
|
||||
try {
|
||||
return useStreamSession();
|
||||
} catch (e) {
|
||||
return e as Error & { code?: string };
|
||||
}
|
||||
});
|
||||
expect((result.current as Error & { code?: string }).code).toBe(
|
||||
"REACT_AUDIO_STREAM.NO_PROVIDER"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback } from "react";
|
||||
import type {
|
||||
SessionState,
|
||||
StreamSessionLike,
|
||||
} from "../../session/session-shape/session-shape.types.js";
|
||||
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
|
||||
|
||||
export interface UseStreamSessionResult {
|
||||
readonly session: StreamSessionLike;
|
||||
readonly state: SessionState;
|
||||
connect: (roomId: string) => Promise<void>;
|
||||
join: () => Promise<void>;
|
||||
leave: () => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the live `StreamSession` plus its current FSM state and
|
||||
* stable references to the four lifecycle methods. Re-renders whenever
|
||||
* the provider's render-bump version increments — i.e. on any session
|
||||
* event channel.
|
||||
*/
|
||||
export function useStreamSession(): UseStreamSessionResult {
|
||||
const { session } = useStreamContextOrThrow();
|
||||
const connect = useCallback(
|
||||
(roomId: string) => session.connect(roomId),
|
||||
[session]
|
||||
);
|
||||
const join = useCallback(() => session.join(), [session]);
|
||||
const leave = useCallback(() => session.leave(), [session]);
|
||||
const disconnect = useCallback(() => session.disconnect(), [session]);
|
||||
return {
|
||||
session,
|
||||
state: session.state,
|
||||
connect,
|
||||
join,
|
||||
leave,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { useTransportShare } from "./use-transport-share.js";
|
||||
export type {
|
||||
UseTransportShareOptions,
|
||||
UseTransportShareResult,
|
||||
} from "./use-transport-share.types.js";
|
||||
@@ -0,0 +1,97 @@
|
||||
# useTransportShare
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator` and a `ShareStateMachine` into a single hook that React components can consume to drive, receive, or request a shared transport without writing any wire-protocol code.
|
||||
|
||||
The hook is deliberately injection-based — the local `transport`, the peer `channel`, and the local `selfId` are passed in by the caller (usually a session-aware wrapper inside the package's `<StreamProvider>`). This keeps the hook testable in isolation and free from the network surface, mirroring the rest of `@suaveplan/react-audio-stream`'s purity boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Single React-friendly handle for the whole share workflow.
|
||||
- Stable `useRef`-backed coordinator and state machine — re-renders never tear down the share.
|
||||
- `state`, `role`, and `isReceiver` for UI gating.
|
||||
- `requestTransportShare(participantId)` — record a pending invitation.
|
||||
- `grantTransportShare(receiverId?)` — flip to `'driving'`; receiver optional for the broadcast-first case.
|
||||
- `releaseTransportShare()` — return to `'local'` cleanly.
|
||||
- `applyDriverGrant(driverId)` — flip to `'receiving'` (session-driven).
|
||||
- `notifyDriverDisconnect()` — receiver-side fall-back to `'local'`.
|
||||
- `notifySeek(seconds)` — propagate a programmatic seek when driving.
|
||||
- Auto-disposes the coordinator on unmount.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
|
||||
|
||||
function ShareButton(props: {
|
||||
transport: SharedTransportTarget;
|
||||
channel: ControlChannel;
|
||||
selfId: string;
|
||||
receiverId: string;
|
||||
}) {
|
||||
const share = useTransportShare(props);
|
||||
if (share.role === "driving") {
|
||||
return <button onClick={share.releaseTransportShare}>Stop sharing</button>;
|
||||
}
|
||||
return (
|
||||
<button onClick={() => share.grantTransportShare(props.receiverId)}>
|
||||
Share transport with {props.receiverId}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function HostPanel(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = props.session.on("peer-left", (peer) => {
|
||||
if (share.role === "receiving" && peer.id === share.state.driverId) {
|
||||
share.notifyDriverDisconnect();
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [props.session, share]);
|
||||
|
||||
return (
|
||||
<section aria-live="polite">
|
||||
{share.isReceiver ? (
|
||||
<p>Receiving from {share.state.driverId}</p>
|
||||
) : null}
|
||||
<button onClick={() => share.requestTransportShare(props.peerId)}>
|
||||
Ask {props.peerId} to drive
|
||||
</button>
|
||||
<button onClick={() => share.notifySeek(props.cursor)}>
|
||||
Sync seek
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTransportShare(options): UseTransportShareResult`
|
||||
- `UseTransportShareOptions` — `{ transport, channel, selfId }`.
|
||||
- `UseTransportShareResult`
|
||||
- `state: ShareState`
|
||||
- `role: ShareRole`
|
||||
- `isReceiver: boolean`
|
||||
- `requestTransportShare(participantId)`
|
||||
- `grantTransportShare(receiverId?)`
|
||||
- `releaseTransportShare()`
|
||||
- `applyDriverGrant(driverId)`
|
||||
- `notifyDriverDisconnect()`
|
||||
- `notifySeek(seconds)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The coordinator and state machine are created lazily inside `useRef` ignition blocks to avoid the React-18 strict-mode double-invocation pitfall where a constructor with side effects would run twice. The hook subscribes to the state machine in an effect so the React state reflects every transition, and it disposes the coordinator on unmount — disposal is idempotent so this is safe even if the parent already called `releaseTransportShare`. All returned callbacks are wrapped in `useCallback` with the precise dependency list so consumers can pass them into memoized children without breaking referential equality.
|
||||
@@ -0,0 +1,316 @@
|
||||
// Tests mirror chunks A/C: `SharedTransportTarget`, `ControlChannel`, and the
|
||||
// `useStreamSession` shape are mirrored locally so this file is independent of
|
||||
// chunks A+B+C land order. See spec at
|
||||
// openspec/changes/enterprise-react-audio-stream/specs/react-audio-stream/spec.md
|
||||
// Requirements 4 + 5.
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type {
|
||||
ControlChannel,
|
||||
SharedTransportTarget,
|
||||
TransportShareControlMessage,
|
||||
} from "../../transport-share/index.js";
|
||||
import { useTransportShare } from "./use-transport-share.js";
|
||||
|
||||
function makeFakeTransport(): SharedTransportTarget & {
|
||||
fire(event: "start" | "pause" | "stop" | "bpm", payload: unknown): void;
|
||||
calls: { method: string; args: unknown[] }[];
|
||||
} {
|
||||
const listeners = new Map<
|
||||
"start" | "pause" | "stop" | "bpm",
|
||||
Set<(payload: unknown) => void>
|
||||
>();
|
||||
const calls: { method: string; args: unknown[] }[] = [];
|
||||
return {
|
||||
start(time, offset) {
|
||||
calls.push({ method: "start", args: [time, offset] });
|
||||
},
|
||||
pause(time) {
|
||||
calls.push({ method: "pause", args: [time] });
|
||||
},
|
||||
seek(seconds) {
|
||||
calls.push({ method: "seek", args: [seconds] });
|
||||
},
|
||||
setTempo(bpm) {
|
||||
calls.push({ method: "setTempo", args: [bpm] });
|
||||
},
|
||||
on(event, listener) {
|
||||
let set = listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(event, set);
|
||||
}
|
||||
set.add(listener);
|
||||
return (): void => {
|
||||
set?.delete(listener);
|
||||
};
|
||||
},
|
||||
fire(event, payload) {
|
||||
const set = listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const l of set) l(payload);
|
||||
},
|
||||
calls,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMemoryChannel(): ControlChannel & {
|
||||
deliver(message: TransportShareControlMessage): void;
|
||||
sent: TransportShareControlMessage[];
|
||||
} {
|
||||
const sent: TransportShareControlMessage[] = [];
|
||||
const subs = new Set<(m: TransportShareControlMessage) => void>();
|
||||
return {
|
||||
send(message) {
|
||||
sent.push(message);
|
||||
},
|
||||
onMessage(listener) {
|
||||
subs.add(listener);
|
||||
return (): void => {
|
||||
subs.delete(listener);
|
||||
};
|
||||
},
|
||||
deliver(message) {
|
||||
for (const l of subs) l(message);
|
||||
},
|
||||
sent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTransportShare", () => {
|
||||
it("play on driver advances every receiver's transport", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
// Local signalling fixture: a single in-memory bus that both
|
||||
// peers attach to. The driver's `send` calls land as inbound
|
||||
// `onMessage` events on the receiver — real implementation, not
|
||||
// a mock.
|
||||
const bus = new Set<(m: TransportShareControlMessage) => void>();
|
||||
const driverChannel: ControlChannel = {
|
||||
send(message) {
|
||||
for (const l of bus) l(message);
|
||||
},
|
||||
onMessage() {
|
||||
return (): void => undefined;
|
||||
},
|
||||
};
|
||||
const receiverChannel: ControlChannel = {
|
||||
send() {
|
||||
return undefined;
|
||||
},
|
||||
onMessage(listener) {
|
||||
bus.add(listener);
|
||||
return (): void => {
|
||||
bus.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
const driverTransport = makeFakeTransport();
|
||||
const receiverTransport = makeFakeTransport();
|
||||
const driver = renderHook(() =>
|
||||
useTransportShare({
|
||||
transport: driverTransport,
|
||||
channel: driverChannel,
|
||||
selfId: "driver",
|
||||
})
|
||||
);
|
||||
const receiver = renderHook(() =>
|
||||
useTransportShare({
|
||||
transport: receiverTransport,
|
||||
channel: receiverChannel,
|
||||
selfId: "receiver",
|
||||
})
|
||||
);
|
||||
act(() => {
|
||||
receiver.result.current.applyDriverGrant("driver");
|
||||
});
|
||||
act(() => {
|
||||
driver.result.current.grantTransportShare("receiver");
|
||||
});
|
||||
act(() => {
|
||||
driverTransport.fire("start", { time: 0.5, offset: 0 });
|
||||
});
|
||||
expect(receiverTransport.calls).toEqual([
|
||||
{ method: "start", args: [0.5, 0] },
|
||||
]);
|
||||
driver.unmount();
|
||||
receiver.unmount();
|
||||
});
|
||||
|
||||
it("driver disconnect returns share status to local", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const t = makeFakeTransport();
|
||||
const c = makeMemoryChannel();
|
||||
const r = renderHook(() =>
|
||||
useTransportShare({
|
||||
transport: t,
|
||||
channel: c,
|
||||
selfId: "receiver",
|
||||
})
|
||||
);
|
||||
act(() => {
|
||||
r.result.current.applyDriverGrant("driver");
|
||||
});
|
||||
expect(r.result.current.role).toBe("receiving");
|
||||
act(() => {
|
||||
r.result.current.notifyDriverDisconnect();
|
||||
});
|
||||
expect(r.result.current.role).toBe("local");
|
||||
// receiving-mode envelope subscription must be detached — a stale
|
||||
// deliver after disconnect must not touch the transport
|
||||
c.deliver({ type: "pause", time: 0 });
|
||||
expect(t.calls).toEqual([]);
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("receiver controls no-op: notifySeek while receiving sends nothing on the wire", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const t = makeFakeTransport();
|
||||
const c = makeMemoryChannel();
|
||||
const r = renderHook(() =>
|
||||
useTransportShare({
|
||||
transport: t,
|
||||
channel: c,
|
||||
selfId: "receiver",
|
||||
})
|
||||
);
|
||||
act(() => {
|
||||
r.result.current.applyDriverGrant("driver");
|
||||
});
|
||||
act(() => {
|
||||
r.result.current.notifySeek(42);
|
||||
});
|
||||
expect(c.sent).toEqual([]);
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("requestTransportShare records pending without changing role", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const t = makeFakeTransport();
|
||||
const c = makeMemoryChannel();
|
||||
const r = renderHook(() =>
|
||||
useTransportShare({
|
||||
transport: t,
|
||||
channel: c,
|
||||
selfId: "self",
|
||||
})
|
||||
);
|
||||
act(() => {
|
||||
r.result.current.requestTransportShare("alice");
|
||||
});
|
||||
expect(r.result.current.role).toBe("local");
|
||||
expect(r.result.current.isReceiver).toBe(false);
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("releaseTransportShare returns to local and detaches driver listeners", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const t = makeFakeTransport();
|
||||
const c = makeMemoryChannel();
|
||||
const r = renderHook(() =>
|
||||
useTransportShare({
|
||||
transport: t,
|
||||
channel: c,
|
||||
selfId: "self",
|
||||
})
|
||||
);
|
||||
act(() => {
|
||||
r.result.current.grantTransportShare("alice");
|
||||
});
|
||||
expect(r.result.current.role).toBe("driving");
|
||||
act(() => {
|
||||
r.result.current.releaseTransportShare();
|
||||
});
|
||||
expect(r.result.current.role).toBe("local");
|
||||
t.fire("start", { time: 0, offset: 0 });
|
||||
expect(c.sent).toEqual([]);
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("grantTransportShare without a receiverId still flips role to driving", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const t = makeFakeTransport();
|
||||
const c = makeMemoryChannel();
|
||||
const r = renderHook(() =>
|
||||
useTransportShare({
|
||||
transport: t,
|
||||
channel: c,
|
||||
selfId: "self",
|
||||
})
|
||||
);
|
||||
act(() => {
|
||||
r.result.current.grantTransportShare();
|
||||
});
|
||||
expect(r.result.current.role).toBe("driving");
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("notifySeek while driving forwards a seek envelope onto the channel", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const t = makeFakeTransport();
|
||||
const c = makeMemoryChannel();
|
||||
const r = renderHook(() =>
|
||||
useTransportShare({
|
||||
transport: t,
|
||||
channel: c,
|
||||
selfId: "self",
|
||||
})
|
||||
);
|
||||
act(() => {
|
||||
r.result.current.grantTransportShare("alice");
|
||||
});
|
||||
act(() => {
|
||||
r.result.current.notifySeek(8);
|
||||
});
|
||||
expect(c.sent).toEqual([{ type: "seek", seconds: 8 }]);
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("unmount disposes the coordinator", { timeout: 30000 }, () => {
|
||||
const t = makeFakeTransport();
|
||||
const c = makeMemoryChannel();
|
||||
const r = renderHook(() =>
|
||||
useTransportShare({
|
||||
transport: t,
|
||||
channel: c,
|
||||
selfId: "self",
|
||||
})
|
||||
);
|
||||
r.unmount();
|
||||
// After unmount, a subsequent inbound message must not be applied
|
||||
// because the coordinator is disposed and its receiver listener
|
||||
// (if any) is gone.
|
||||
c.deliver({ type: "pause", time: 0 });
|
||||
expect(t.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes a stable state snapshot with role and isReceiver fields", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const t = makeFakeTransport();
|
||||
const c = makeMemoryChannel();
|
||||
const r = renderHook(() =>
|
||||
useTransportShare({
|
||||
transport: t,
|
||||
channel: c,
|
||||
selfId: "self",
|
||||
})
|
||||
);
|
||||
expect(r.result.current.state.role).toBe("local");
|
||||
expect(r.result.current.isReceiver).toBe(false);
|
||||
act(() => {
|
||||
r.result.current.applyDriverGrant("alice");
|
||||
});
|
||||
expect(r.result.current.state.role).toBe("receiving");
|
||||
expect(r.result.current.state.driverId).toBe("alice");
|
||||
expect(r.result.current.isReceiver).toBe(true);
|
||||
r.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
createTransportShareCoordinator,
|
||||
ShareStateMachine,
|
||||
type TransportShareCoordinator,
|
||||
} from "../../transport-share/index.js";
|
||||
import type {
|
||||
UseTransportShareOptions,
|
||||
UseTransportShareResult,
|
||||
} from "./use-transport-share.types.js";
|
||||
|
||||
/**
|
||||
* React hook orchestrating the transport-share workflow for a single peer.
|
||||
*
|
||||
* The hook owns a {@link TransportShareCoordinator} and a {@link ShareStateMachine}.
|
||||
* The coordinator handles wire-level envelopes; the state machine tracks the
|
||||
* peer's role. They are kept in stable refs so the hook never tears down its
|
||||
* resources mid-share due to a re-render.
|
||||
*/
|
||||
export function useTransportShare(
|
||||
options: UseTransportShareOptions
|
||||
): UseTransportShareResult {
|
||||
const { transport, channel, selfId } = options;
|
||||
|
||||
const machineRef = useRef<ShareStateMachine | null>(null);
|
||||
if (machineRef.current === null) {
|
||||
machineRef.current = new ShareStateMachine();
|
||||
}
|
||||
const machine = machineRef.current;
|
||||
|
||||
const coordinatorRef = useRef<TransportShareCoordinator | null>(null);
|
||||
if (coordinatorRef.current === null) {
|
||||
coordinatorRef.current = createTransportShareCoordinator({
|
||||
transport,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
const coordinator = coordinatorRef.current;
|
||||
|
||||
const [state, setState] = useState(() => machine.getState());
|
||||
|
||||
useEffect(() => {
|
||||
const off = machine.subscribe((next) => setState(next));
|
||||
return off;
|
||||
}, [machine]);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
coordinator.dispose();
|
||||
};
|
||||
}, [coordinator]);
|
||||
|
||||
const requestTransportShare = useCallback(
|
||||
(participantId: string) => {
|
||||
machine.request(participantId);
|
||||
},
|
||||
[machine]
|
||||
);
|
||||
|
||||
const grantTransportShare = useCallback(
|
||||
(receiverId?: string) => {
|
||||
machine.grant(selfId, receiverId);
|
||||
coordinator.drive();
|
||||
},
|
||||
[machine, coordinator, selfId]
|
||||
);
|
||||
|
||||
const releaseTransportShare = useCallback(() => {
|
||||
machine.release();
|
||||
coordinator.release();
|
||||
}, [machine, coordinator]);
|
||||
|
||||
const applyDriverGrant = useCallback(
|
||||
(driverId: string) => {
|
||||
machine.receiveFrom(driverId);
|
||||
coordinator.receive();
|
||||
},
|
||||
[machine, coordinator]
|
||||
);
|
||||
|
||||
const notifyDriverDisconnect = useCallback(() => {
|
||||
machine.driverDisconnect();
|
||||
coordinator.release();
|
||||
}, [machine, coordinator]);
|
||||
|
||||
const notifySeek = useCallback(
|
||||
(seconds: number) => {
|
||||
coordinator.notifySeek(seconds);
|
||||
},
|
||||
[coordinator]
|
||||
);
|
||||
|
||||
return useMemo<UseTransportShareResult>(
|
||||
() => ({
|
||||
state,
|
||||
role: state.role,
|
||||
isReceiver: state.role === "receiving",
|
||||
requestTransportShare,
|
||||
grantTransportShare,
|
||||
releaseTransportShare,
|
||||
applyDriverGrant,
|
||||
notifyDriverDisconnect,
|
||||
notifySeek,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
requestTransportShare,
|
||||
grantTransportShare,
|
||||
releaseTransportShare,
|
||||
applyDriverGrant,
|
||||
notifyDriverDisconnect,
|
||||
notifySeek,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
ControlChannel,
|
||||
SharedTransportTarget,
|
||||
ShareRole,
|
||||
ShareState,
|
||||
} from "../../transport-share/index.js";
|
||||
|
||||
/**
|
||||
* Options for {@link useTransportShare}.
|
||||
*
|
||||
* `transport` is the local `SharedTransportTarget` (the `Transport` instance
|
||||
* adapted by `useTransport` from `@suaveplan/react-audio`). `channel` is the
|
||||
* `ControlChannel` exposed by the active session (chunk A). `selfId` is the
|
||||
* local peer's identity, used when granting share so the snapshot's
|
||||
* `driverId` reflects this peer.
|
||||
*/
|
||||
export interface UseTransportShareOptions {
|
||||
readonly transport: SharedTransportTarget;
|
||||
readonly channel: ControlChannel;
|
||||
readonly selfId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface returned from the hook.
|
||||
*
|
||||
* - `state.role` reflects the current share role.
|
||||
* - `requestTransportShare(participantId)` stores a pending request locally
|
||||
* (the session is expected to mirror the request to the remote peer).
|
||||
* - `grantTransportShare(receiverId?)` transitions into `'driving'` and adds
|
||||
* the receiver to the audience if provided.
|
||||
* - `releaseTransportShare()` returns to `'local'`.
|
||||
* - `applyDriverGrant(driverId)` flips into `'receiving'` (invoked by the
|
||||
* session when a remote peer grants share to this peer).
|
||||
* - `notifyDriverDisconnect()` returns to `'local'` after the driver leaves.
|
||||
* - `notifySeek(seconds)` forwards a programmatic seek when driving.
|
||||
* - `isReceiver` is a convenience boolean for UI gating.
|
||||
*/
|
||||
export interface UseTransportShareResult {
|
||||
readonly state: ShareState;
|
||||
readonly role: ShareRole;
|
||||
readonly isReceiver: boolean;
|
||||
requestTransportShare(participantId: string): void;
|
||||
grantTransportShare(receiverId?: string): void;
|
||||
releaseTransportShare(): void;
|
||||
applyDriverGrant(driverId: string): void;
|
||||
notifyDriverDisconnect(): void;
|
||||
notifySeek(seconds: number): void;
|
||||
}
|
||||
40
packages/ui/react/react-audio-stream/src/index.ts
Normal file
40
packages/ui/react/react-audio-stream/src/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export * as Components from "./components/index.js";
|
||||
export {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA,
|
||||
ReactAudioStreamError,
|
||||
type ReactAudioStreamErrorCode,
|
||||
type ReactAudioStreamErrorMetadata,
|
||||
registerReactAudioStreamErrorCodes,
|
||||
} from "./error-codes/index.js";
|
||||
export * as Hooks from "./hooks/index.js";
|
||||
export * as Provider from "./provider/index.js";
|
||||
export {
|
||||
createSessionFsm,
|
||||
createStreamSession,
|
||||
createStreamSourcePlugin,
|
||||
dispatchSessionEvent,
|
||||
type InterruptionCause,
|
||||
type InterruptionClass,
|
||||
InterruptionClassifier,
|
||||
type InterruptionVerdict,
|
||||
type PeerStreamEvent,
|
||||
SESSION_STATES,
|
||||
type SessionEvent,
|
||||
type SessionState,
|
||||
type SessionStateChange,
|
||||
StreamSession,
|
||||
type StreamSessionEventMap,
|
||||
type StreamSessionOptions,
|
||||
StreamSourcePlugin,
|
||||
type StreamSourcePluginOptions,
|
||||
type StreamSourcePluginPort,
|
||||
type StreamTransportAdapter,
|
||||
} from "./session/index.js";
|
||||
export {
|
||||
type DisconnectReason,
|
||||
type StreamTelemetry,
|
||||
streamTelemetry,
|
||||
type TransportLabel,
|
||||
} from "./telemetry/index.js";
|
||||
export * as Transports from "./transports/index.js";
|
||||
21
packages/ui/react/react-audio-stream/src/provider/index.ts
Normal file
21
packages/ui/react/react-audio-stream/src/provider/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type {
|
||||
InterruptionClass,
|
||||
LevelSample,
|
||||
MediaPermissionState,
|
||||
Peer,
|
||||
PeerMixerState,
|
||||
ReactAudioStreamErrorLike,
|
||||
RecordingResult,
|
||||
SessionEventMap,
|
||||
SessionState,
|
||||
StreamSessionFactory,
|
||||
StreamSessionLike,
|
||||
} from "../session/session-shape/session-shape.types.js";
|
||||
export {
|
||||
type StreamContextValue,
|
||||
StreamReactContext,
|
||||
} from "./stream-context.js";
|
||||
export {
|
||||
StreamProvider,
|
||||
type StreamProviderProps,
|
||||
} from "./stream-provider.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# stream-context
|
||||
|
||||
## Purpose
|
||||
|
||||
`StreamReactContext` is the single React context that publishes the live `StreamSession`
|
||||
to every chunk-C hook and component. It is intentionally a tiny container — the session
|
||||
itself owns all mutable state, and the context exposes only the session reference plus a
|
||||
monotonic `version` counter that increments on every session event. Hooks subscribe to
|
||||
the context and re-render whenever `version` ticks, which keeps the React render path
|
||||
decoupled from the audio engine's event firehose.
|
||||
|
||||
The `null` sentinel value is the deliberate signal that a hook ran outside any
|
||||
`<StreamProvider>`; every public hook in chunk C throws `REACT_AUDIO_STREAM.NO_PROVIDER`
|
||||
when it observes that sentinel.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure React context — zero side effects at import time.
|
||||
- Render-bump counter pattern: O(1) re-render fan-out regardless of session event volume.
|
||||
- `null` sentinel forces explicit provider placement, surfaced as a structured error.
|
||||
- `displayName` set for legible React DevTools output.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { StreamReactContext } from "@suaveplan/react-audio-stream/provider";
|
||||
import { useContext } from "react";
|
||||
|
||||
const ctx = useContext(StreamReactContext);
|
||||
if (ctx === null) {
|
||||
throw new Error("StreamProvider missing");
|
||||
}
|
||||
const { session, version } = ctx;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { StreamReactContext } from "@suaveplan/react-audio-stream/provider";
|
||||
|
||||
function StreamConsumer({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StreamReactContext.Consumer>
|
||||
{(ctx) => (ctx === null ? <p>no provider</p> : children)}
|
||||
</StreamReactContext.Consumer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamContextValue` — `{ session: StreamSessionLike; version: number }`
|
||||
- `StreamReactContext` — `React.Context<StreamContextValue | null>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`version` is bumped by `<StreamProvider>` inside a `useState` setter, so each tick
|
||||
triggers a single render scheduled through React's normal update path. The context value
|
||||
itself is memoised by the provider, so children that read only `session` (not `version`)
|
||||
do not have to re-render — but every chunk-C hook intentionally reads `version` to keep
|
||||
their derived state fresh.
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext } from "react";
|
||||
import type { StreamSessionLike } from "../session/session-shape/session-shape.types.js";
|
||||
|
||||
/**
|
||||
* Container for the live `StreamSession` instance plus a render-bump
|
||||
* counter that increments on every session event. Hooks subscribe via
|
||||
* `useStreamContextOrThrow` and re-render on counter bumps.
|
||||
*/
|
||||
export interface StreamContextValue {
|
||||
readonly session: StreamSessionLike;
|
||||
/** Monotonic render-bump counter — increments on every session event. */
|
||||
readonly version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* React context published by `<StreamProvider>`. `null` indicates the
|
||||
* tree has no provider — every hook MUST throw
|
||||
* `REACT_AUDIO_STREAM.NO_PROVIDER` on that sentinel.
|
||||
*/
|
||||
export const StreamReactContext = createContext<StreamContextValue | null>(
|
||||
null
|
||||
);
|
||||
StreamReactContext.displayName = "SuaveplanStreamContext";
|
||||
@@ -0,0 +1,69 @@
|
||||
# stream-provider
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for
|
||||
a tree. It calls the caller-supplied `sessionFactory` exactly once per provider instance,
|
||||
subscribes to the seven session event channels (`state-changed`, `interruption`,
|
||||
`peers-changed`, `mixer-changed`, `levels-changed`, `recording-changed`,
|
||||
`permission-changed`), and increments a render-bump counter on every tick so descendant
|
||||
hooks observe a fresh snapshot of session state.
|
||||
|
||||
The factory pattern keeps `@suaveplan/react-audio-stream` transport-agnostic: the
|
||||
consumer decides whether the underlying transport is WebRTC, WebSocket, Trystero, or a
|
||||
test fake, and the provider never imports any transport directly. Cleanup on unmount
|
||||
calls `session.disconnect()` so the audio engine + socket are torn down deterministically.
|
||||
|
||||
## Features
|
||||
|
||||
- StrictMode-safe singleton via `useRef`-guarded lazy construction.
|
||||
- Caller-injected `sessionFactory` — zero transport coupling at provider tier.
|
||||
- Render-bump counter fans seven event channels into one re-render path.
|
||||
- Cleanup-on-unmount disconnects the session and detaches every listener.
|
||||
- Memoised context value — only the `version` field changes per tick.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { StreamProvider } from "@suaveplan/react-audio-stream/provider";
|
||||
import { createStreamSession } from "./my-session-factory";
|
||||
|
||||
export function App({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => createStreamSession({ transport: "webrtc" })}>
|
||||
{children}
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { StreamProvider } from "@suaveplan/react-audio-stream/provider";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function ScopedRoom({ roomId, children }: { roomId: string; children: React.ReactNode }) {
|
||||
const factory = useMemo(
|
||||
() => () => createStreamSession({ transport: "trystero", initialRoomId: roomId }),
|
||||
[roomId]
|
||||
);
|
||||
return <StreamProvider sessionFactory={factory}>{children}</StreamProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
Keying the provider on `roomId` (`<StreamProvider key={roomId} …>`) forces a fresh
|
||||
session when the room changes, because the singleton is per-instance.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamProviderProps` — `{ sessionFactory: StreamSessionFactory; children?: ReactNode }`
|
||||
- `StreamProvider(props)` — renders a `StreamReactContext.Provider` with `{ session, version }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The seven event subscriptions all funnel into a single `setVersion((v) => v + 1)` call,
|
||||
which collapses bursts into a single React render pass thanks to automatic batching. The
|
||||
disconnect call on unmount is wrapped in `Promise.resolve` so the same code path handles
|
||||
both sync and async session implementations, and the rejection branch swallows errors
|
||||
(any failure already surfaced as an `interruption` event before the listener detach).
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, render, renderHook } from "@testing-library/react";
|
||||
import { type ReactNode, StrictMode, useContext } from "react";
|
||||
import { FakeSession } from "../test-utils.js";
|
||||
import { StreamReactContext } from "./stream-context.js";
|
||||
import { StreamProvider } from "./stream-provider.js";
|
||||
|
||||
describe("StreamProvider", () => {
|
||||
it("constructs the session exactly once per provider instance", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let calls = 0;
|
||||
const session = new FakeSession();
|
||||
const factory = () => {
|
||||
calls += 1;
|
||||
return session;
|
||||
};
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<StreamProvider sessionFactory={factory}>{children}</StreamProvider>
|
||||
);
|
||||
const { rerender } = renderHook(() => useContext(StreamReactContext), {
|
||||
wrapper,
|
||||
});
|
||||
rerender();
|
||||
rerender();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("constructs the session once even under StrictMode double-mount", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let calls = 0;
|
||||
const session = new FakeSession();
|
||||
const factory = () => {
|
||||
calls += 1;
|
||||
return session;
|
||||
};
|
||||
render(
|
||||
<StrictMode>
|
||||
<StreamProvider sessionFactory={factory}>
|
||||
<span>x</span>
|
||||
</StreamProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("exposes the session via context", { timeout: 5000 }, () => {
|
||||
const session = new FakeSession({ state: "joined" });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
{children}
|
||||
</StreamProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useContext(StreamReactContext), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current?.session).toBe(session);
|
||||
expect(result.current?.version).toBe(0);
|
||||
});
|
||||
|
||||
it("bumps version on every session event channel", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
{children}
|
||||
</StreamProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useContext(StreamReactContext), {
|
||||
wrapper,
|
||||
});
|
||||
const v0 = result.current?.version ?? -1;
|
||||
act(() => {
|
||||
session.pushPeer({
|
||||
id: "alice",
|
||||
displayName: "Alice",
|
||||
connectionState: "connected",
|
||||
rttMs: 20,
|
||||
});
|
||||
});
|
||||
const v1 = result.current?.version ?? -1;
|
||||
expect(v1).toBeGreaterThan(v0);
|
||||
act(() => {
|
||||
session.setMixer([
|
||||
{
|
||||
id: "alice",
|
||||
volume: 0,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
fxSlotCount: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
expect((result.current?.version ?? 0) - v1).toBe(1);
|
||||
act(() => {
|
||||
session.setLocalLevel({ peak: 0.5, rms: 0.25 });
|
||||
});
|
||||
act(() => {
|
||||
session.triggerInterruption("network");
|
||||
});
|
||||
expect(result.current?.version ?? 0).toBeGreaterThan(v1 + 1);
|
||||
});
|
||||
|
||||
it("disconnects the session on unmount and stops bumping", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const session = new FakeSession();
|
||||
let disconnected = 0;
|
||||
const orig = session.disconnect.bind(session);
|
||||
session.disconnect = async () => {
|
||||
disconnected += 1;
|
||||
return orig();
|
||||
};
|
||||
const { unmount } = render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<span>x</span>
|
||||
</StreamProvider>
|
||||
);
|
||||
unmount();
|
||||
expect(disconnected).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("swallows disconnect rejection on unmount", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const session = new FakeSession();
|
||||
session.disconnect = () => Promise.reject(new Error("boom"));
|
||||
const { unmount } = render(
|
||||
<StreamProvider sessionFactory={() => session}>
|
||||
<span>x</span>
|
||||
</StreamProvider>
|
||||
);
|
||||
expect(() => {
|
||||
unmount();
|
||||
}).not.toThrow();
|
||||
// Allow the rejection's catch handler to settle.
|
||||
await new Promise<void>((r) => {
|
||||
setTimeout(r, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws if the factory returns null on context read", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad = () => null as unknown as ReturnType<() => FakeSession>;
|
||||
expect(() =>
|
||||
render(
|
||||
<StreamProvider sessionFactory={bad}>
|
||||
<span>x</span>
|
||||
</StreamProvider>
|
||||
)
|
||||
).toThrow(/null/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { StreamSessionFactory } from "../session/session-shape/session-shape.types.js";
|
||||
import {
|
||||
type StreamContextValue,
|
||||
StreamReactContext,
|
||||
} from "./stream-context.js";
|
||||
|
||||
export interface StreamProviderProps {
|
||||
/**
|
||||
* Factory that constructs the `StreamSession` lazily on first mount.
|
||||
* Consumers supply this so the package stays transport-agnostic — the
|
||||
* factory wires in the WebRTC / WebSocket / Trystero transport, the
|
||||
* `audio-engine-browser` instance, telemetry sinks, etc.
|
||||
*/
|
||||
readonly sessionFactory: StreamSessionFactory;
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* StrictMode-safe singleton provider. Calls `sessionFactory()` exactly
|
||||
* once per React tree (guarded by an `inFlight` ref), subscribes to every
|
||||
* session event, and increments a `version` counter on each tick so hooks
|
||||
* re-render. On unmount the session is disconnected and event listeners
|
||||
* are detached.
|
||||
*/
|
||||
export function StreamProvider(props: StreamProviderProps): ReactNode {
|
||||
const factoryRef = useRef(props.sessionFactory);
|
||||
factoryRef.current = props.sessionFactory;
|
||||
|
||||
const sessionRef = useRef<ReturnType<StreamSessionFactory> | null>(null);
|
||||
const createdRef = useRef(false);
|
||||
if (!createdRef.current) {
|
||||
sessionRef.current = factoryRef.current();
|
||||
createdRef.current = true;
|
||||
}
|
||||
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// `sessionRef.current` is non-null here because the throw in the
|
||||
// memo below runs first when the factory returned null.
|
||||
const session = sessionRef.current as NonNullable<
|
||||
typeof sessionRef.current
|
||||
>;
|
||||
const bump = (): void => {
|
||||
setVersion((v) => v + 1);
|
||||
};
|
||||
const offs = [
|
||||
session.on("state-changed", bump),
|
||||
session.on("interruption", bump),
|
||||
session.on("peers-changed", bump),
|
||||
session.on("mixer-changed", bump),
|
||||
session.on("levels-changed", bump),
|
||||
session.on("recording-changed", bump),
|
||||
session.on("permission-changed", bump),
|
||||
];
|
||||
return () => {
|
||||
for (const off of offs) {
|
||||
off();
|
||||
}
|
||||
void Promise.resolve(session.disconnect()).catch(() => {
|
||||
/* disconnect best-effort — error surfaces via session events */
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<StreamContextValue>(() => {
|
||||
const session = sessionRef.current;
|
||||
if (session === null) {
|
||||
throw new Error("StreamProvider: session factory returned null");
|
||||
}
|
||||
return { session, version };
|
||||
}, [version]);
|
||||
|
||||
return (
|
||||
<StreamReactContext.Provider value={value}>
|
||||
{props.children}
|
||||
</StreamReactContext.Provider>
|
||||
);
|
||||
}
|
||||
39
packages/ui/react/react-audio-stream/src/session/index.ts
Normal file
39
packages/ui/react/react-audio-stream/src/session/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export {
|
||||
type InterruptionCause,
|
||||
type InterruptionClass,
|
||||
InterruptionClassifier,
|
||||
type InterruptionVerdict,
|
||||
} from "./interruption-classifier/index.js";
|
||||
export {
|
||||
createSessionFsm,
|
||||
dispatchSessionEvent,
|
||||
SESSION_STATES,
|
||||
type SessionEvent,
|
||||
type SessionState,
|
||||
type SessionStateChange,
|
||||
} from "./session-fsm/index.js";
|
||||
export type {
|
||||
LevelSample,
|
||||
MediaPermissionState,
|
||||
Peer,
|
||||
PeerMixerState,
|
||||
ReactAudioStreamErrorLike,
|
||||
RecordingResult,
|
||||
SessionEventMap,
|
||||
StreamSessionFactory,
|
||||
StreamSessionLike,
|
||||
} from "./session-shape/index.js";
|
||||
export {
|
||||
createStreamSession,
|
||||
type PeerStreamEvent,
|
||||
StreamSession,
|
||||
type StreamSessionEventMap,
|
||||
type StreamSessionOptions,
|
||||
type StreamTransportAdapter,
|
||||
} from "./stream-session/index.js";
|
||||
export {
|
||||
createStreamSourcePlugin,
|
||||
StreamSourcePlugin,
|
||||
type StreamSourcePluginOptions,
|
||||
type StreamSourcePluginPort,
|
||||
} from "./stream-source-plugin/index.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { InterruptionClassifier } from "./interruption-classifier.js";
|
||||
export type {
|
||||
InterruptionCause,
|
||||
InterruptionClass,
|
||||
InterruptionVerdict,
|
||||
} from "./interruption-classifier.types.js";
|
||||
@@ -0,0 +1,104 @@
|
||||
# `interruption-classifier`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`InterruptionClassifier` maps a structural disconnect cause to exactly
|
||||
one of `network | media-permission | signalling | peer | unknown`.
|
||||
Classification is deterministic — every rule is a typed equality or
|
||||
membership check, no probabilistic heuristics — so the same cause
|
||||
record always produces the same verdict regardless of timing or
|
||||
ordering. This is essential for the auto-reconnect policy that lands in
|
||||
chunk D: the policy decides whether to retry by inspecting the verdict
|
||||
label, and a flaky classifier would translate into flaky reconnect
|
||||
behaviour for users.
|
||||
|
||||
The classifier is intentionally state-free. Every consumer (the
|
||||
`StreamSession`, future React hooks, integration tests) constructs one
|
||||
instance and calls `classify` on every disconnect — there is no
|
||||
internal counter, ring buffer, or memo, so a single instance can be
|
||||
shared across sessions without contention.
|
||||
|
||||
## Features
|
||||
|
||||
- Five disjoint labels covering every observed interruption shape.
|
||||
- Priority-ordered rules with explicit precedence: `peer` beats
|
||||
`media-permission` beats `network` beats `signalling`.
|
||||
- WebRTC-friendly: keys directly off `iceConnectionState` and
|
||||
`connectionState`.
|
||||
- WebSocket-friendly: keys off RFC 6455 close codes, skipping the
|
||||
clean-close codes (1000, 1001) so a graceful server-side close does
|
||||
not trigger reconnect.
|
||||
- `shouldAutoReconnect(class)` helper exposes the chunk-D reconnect
|
||||
policy in a single place.
|
||||
- Zero allocations on the hot path beyond the verdict object.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InterruptionClassifier } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const classifier = new InterruptionClassifier();
|
||||
const verdict = classifier.classify({ iceConnectionState: "failed" });
|
||||
// verdict.class === "network"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
InterruptionClassifier,
|
||||
type InterruptionCause,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const classifier = new InterruptionClassifier();
|
||||
|
||||
function onDisconnect(rawCause: InterruptionCause) {
|
||||
const verdict = classifier.classify(rawCause);
|
||||
if (classifier.shouldAutoReconnect(verdict.class)) {
|
||||
scheduleReconnect(verdict);
|
||||
} else {
|
||||
surfaceToUi(verdict);
|
||||
}
|
||||
}
|
||||
|
||||
// Hard browser-permission revoke
|
||||
onDisconnect({
|
||||
mediaErrorName: "NotAllowedError",
|
||||
reason: "user revoked microphone permission",
|
||||
});
|
||||
// → verdict.class === "media-permission"; no reconnect.
|
||||
|
||||
// Server-initiated abnormal close
|
||||
onDisconnect({ signallingCloseCode: 1006, reason: "abrupt server close" });
|
||||
// → verdict.class === "signalling"; reconnect scheduled.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class InterruptionClassifier` — stateless classifier.
|
||||
- `classify(cause: InterruptionCause): InterruptionVerdict` — apply the
|
||||
rule mapping and return `{ class, cause }`.
|
||||
- `shouldAutoReconnect(class: InterruptionClass): boolean` — true iff
|
||||
the class is `network` or `signalling`.
|
||||
- `InterruptionClass`, `InterruptionCause`, `InterruptionVerdict` —
|
||||
re-exported types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The rule ordering is the only piece of business logic with subtle
|
||||
consequences. `peer` is evaluated first because an intentional remote
|
||||
leave should never be treated as a network glitch — even if the ICE
|
||||
state also happens to read `disconnected` during the closing handshake.
|
||||
Permission errors take precedence over ICE because a user-revoked
|
||||
microphone often surfaces as both a `getUserMedia` rejection and an
|
||||
ICE failure milliseconds later; the permission failure is the
|
||||
actionable cause. Close codes 1000 and 1001 are explicitly skipped so a
|
||||
clean server shutdown (`going away`) does not show up as a signalling
|
||||
interruption — those map to `unknown` instead, which the reconnect
|
||||
policy treats as non-retryable. The classifier deliberately holds no
|
||||
state so it composes cleanly into the FSM-driven session flow without
|
||||
spreading hidden coupling.
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { InterruptionClassifier } from "./interruption-classifier.js";
|
||||
|
||||
describe("interruption-classifier", () => {
|
||||
const c = new InterruptionClassifier();
|
||||
|
||||
it("ICE failed classifies as network", { timeout: 5000 }, () => {
|
||||
expect(c.classify({ iceConnectionState: "failed" }).class).toBe(
|
||||
"network"
|
||||
);
|
||||
});
|
||||
|
||||
it("ICE disconnected classifies as network", { timeout: 5000 }, () => {
|
||||
expect(c.classify({ iceConnectionState: "disconnected" }).class).toBe(
|
||||
"network"
|
||||
);
|
||||
});
|
||||
|
||||
it("RTCPeerConnection connectionState failed classifies as network", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(c.classify({ connectionState: "failed" }).class).toBe("network");
|
||||
});
|
||||
|
||||
it("NotAllowedError classifies as media-permission", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(c.classify({ mediaErrorName: "NotAllowedError" }).class).toBe(
|
||||
"media-permission"
|
||||
);
|
||||
});
|
||||
|
||||
it("PermissionDeniedError classifies as media-permission", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
c.classify({ mediaErrorName: "PermissionDeniedError" }).class
|
||||
).toBe("media-permission");
|
||||
});
|
||||
|
||||
it("SecurityError classifies as media-permission", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(c.classify({ mediaErrorName: "SecurityError" }).class).toBe(
|
||||
"media-permission"
|
||||
);
|
||||
});
|
||||
|
||||
it("peerEnded classifies as peer", { timeout: 5000 }, () => {
|
||||
expect(c.classify({ peerEnded: true }).class).toBe("peer");
|
||||
});
|
||||
|
||||
it("peer takes precedence over ICE failed", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
c.classify({
|
||||
peerEnded: true,
|
||||
iceConnectionState: "failed",
|
||||
}).class
|
||||
).toBe("peer");
|
||||
});
|
||||
|
||||
it("media-permission takes precedence over ICE failed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
c.classify({
|
||||
mediaErrorName: "NotAllowedError",
|
||||
iceConnectionState: "failed",
|
||||
}).class
|
||||
).toBe("media-permission");
|
||||
});
|
||||
|
||||
it("abnormal close code 1006 classifies as signalling", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(c.classify({ signallingCloseCode: 1006 }).class).toBe(
|
||||
"signalling"
|
||||
);
|
||||
});
|
||||
|
||||
it("clean close code 1000 classifies as unknown", { timeout: 5000 }, () => {
|
||||
expect(c.classify({ signallingCloseCode: 1000 }).class).toBe("unknown");
|
||||
});
|
||||
|
||||
it("clean close code 1001 classifies as unknown", { timeout: 5000 }, () => {
|
||||
expect(c.classify({ signallingCloseCode: 1001 }).class).toBe("unknown");
|
||||
});
|
||||
|
||||
it("empty cause classifies as unknown", { timeout: 5000 }, () => {
|
||||
expect(c.classify({}).class).toBe("unknown");
|
||||
});
|
||||
|
||||
it("unknown mediaErrorName falls through to subsequent rules", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
c.classify({
|
||||
mediaErrorName: "AbortError",
|
||||
iceConnectionState: "failed",
|
||||
}).class
|
||||
).toBe("network");
|
||||
});
|
||||
|
||||
it("explicit disconnect maps to unknown", { timeout: 5000 }, () => {
|
||||
expect(c.classify({ explicit: true }).class).toBe("unknown");
|
||||
});
|
||||
|
||||
it("verdict carries original cause", { timeout: 5000 }, () => {
|
||||
const cause = {
|
||||
iceConnectionState: "failed" as const,
|
||||
reason: "ice transport closed",
|
||||
};
|
||||
const verdict = c.classify(cause);
|
||||
expect(verdict.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("shouldAutoReconnect returns true for network and signalling", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(c.shouldAutoReconnect("network")).toBe(true);
|
||||
expect(c.shouldAutoReconnect("signalling")).toBe(true);
|
||||
expect(c.shouldAutoReconnect("media-permission")).toBe(false);
|
||||
expect(c.shouldAutoReconnect("peer")).toBe(false);
|
||||
expect(c.shouldAutoReconnect("unknown")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
InterruptionCause,
|
||||
InterruptionClass,
|
||||
InterruptionVerdict,
|
||||
} from "./interruption-classifier.types.js";
|
||||
|
||||
/**
|
||||
* The deterministic rule mapping a structural {@link InterruptionCause}
|
||||
* to one of the five {@link InterruptionClass} labels.
|
||||
*
|
||||
* Rules are evaluated in priority order:
|
||||
*
|
||||
* 1. `peerEnded === true` ⇒ `peer`
|
||||
* 2. `mediaErrorName` matches a permission error ⇒ `media-permission`
|
||||
* 3. `iceConnectionState ∈ {failed, disconnected}` ⇒ `network`
|
||||
* 4. `connectionState ∈ {failed, disconnected}` ⇒ `network`
|
||||
* 5. `signallingCloseCode` set (and not 1000/1001) ⇒ `signalling`
|
||||
* 6. otherwise ⇒ `unknown`
|
||||
*
|
||||
* Explicit local `disconnect()` is mapped to `unknown` so callers can
|
||||
* distinguish via the `cause.explicit` flag without competing with the
|
||||
* five interruption classes.
|
||||
*/
|
||||
export class InterruptionClassifier {
|
||||
private static readonly PERMISSION_ERROR_NAMES = new Set<string>([
|
||||
"NotAllowedError",
|
||||
"PermissionDeniedError",
|
||||
"SecurityError",
|
||||
]);
|
||||
|
||||
private static readonly CLEAN_CLOSE_CODES = new Set<number>([1000, 1001]);
|
||||
|
||||
public classify(cause: InterruptionCause): InterruptionVerdict {
|
||||
const klass = this.classifyClass(cause);
|
||||
return { class: klass, cause };
|
||||
}
|
||||
|
||||
private classifyClass(cause: InterruptionCause): InterruptionClass {
|
||||
if (cause.peerEnded === true) {
|
||||
return "peer";
|
||||
}
|
||||
if (
|
||||
cause.mediaErrorName !== undefined &&
|
||||
InterruptionClassifier.PERMISSION_ERROR_NAMES.has(
|
||||
cause.mediaErrorName
|
||||
)
|
||||
) {
|
||||
return "media-permission";
|
||||
}
|
||||
if (
|
||||
cause.iceConnectionState === "failed" ||
|
||||
cause.iceConnectionState === "disconnected"
|
||||
) {
|
||||
return "network";
|
||||
}
|
||||
if (
|
||||
cause.connectionState === "failed" ||
|
||||
cause.connectionState === "disconnected"
|
||||
) {
|
||||
return "network";
|
||||
}
|
||||
if (
|
||||
cause.signallingCloseCode !== undefined &&
|
||||
!InterruptionClassifier.CLEAN_CLOSE_CODES.has(
|
||||
cause.signallingCloseCode
|
||||
)
|
||||
) {
|
||||
return "signalling";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when an interruption of this class should trigger
|
||||
* the auto-reconnect ladder. Reconnect policy lives here so chunk D
|
||||
* does not need to re-export an extra helper.
|
||||
*/
|
||||
public shouldAutoReconnect(klass: InterruptionClass): boolean {
|
||||
return klass === "network" || klass === "signalling";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* The five disjoint interruption classes recognised by the classifier.
|
||||
* Reconnect policy in chunk D keys off this label: `network` and
|
||||
* `signalling` are retried; the remaining three are surfaced to the
|
||||
* caller without an automatic retry.
|
||||
*/
|
||||
export type InterruptionClass =
|
||||
| "network"
|
||||
| "media-permission"
|
||||
| "signalling"
|
||||
| "peer"
|
||||
| "unknown";
|
||||
|
||||
/**
|
||||
* Structural description of the underlying disconnect cause. Every
|
||||
* field is optional so test fixtures, browser-native APIs, and custom
|
||||
* transports can populate whichever subset is meaningful.
|
||||
*/
|
||||
export interface InterruptionCause {
|
||||
/** WebRTC `iceconnectionstate` at the moment of failure. */
|
||||
readonly iceConnectionState?:
|
||||
| "failed"
|
||||
| "disconnected"
|
||||
| "closed"
|
||||
| "checking"
|
||||
| "completed"
|
||||
| "connected"
|
||||
| "new";
|
||||
/** WebRTC `connectionstate` at the moment of failure. */
|
||||
readonly connectionState?:
|
||||
| "failed"
|
||||
| "disconnected"
|
||||
| "closed"
|
||||
| "connected"
|
||||
| "connecting"
|
||||
| "new";
|
||||
/** `DOMException.name` from `getUserMedia` rejection. */
|
||||
readonly mediaErrorName?: string;
|
||||
/** Signalling channel close code (per RFC 6455 conventions). */
|
||||
readonly signallingCloseCode?: number;
|
||||
/** Set when the remote peer signals an intentional leave. */
|
||||
readonly peerEnded?: boolean;
|
||||
/** Set when the local caller invoked `disconnect()` explicitly. */
|
||||
readonly explicit?: boolean;
|
||||
/** Optional human-readable diagnostic that flows into telemetry. */
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final classification produced by the classifier. Returned alongside
|
||||
* the original `cause` so consumers can both react on `class` and log
|
||||
* the structured diagnostic.
|
||||
*/
|
||||
export interface InterruptionVerdict {
|
||||
readonly class: InterruptionClass;
|
||||
readonly cause: InterruptionCause;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# reconnect
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wraps `@suaveplan/retry`'s exponential-backoff engine with the spec-mandated parameters (1 s base, ×2 multiplier, ±20% jitter, 30 s cap, 8 attempts) and exposes a single async entry point that the session calls once per interruption event.
|
||||
|
||||
Keeping the controller separate from the FSM means the session class stays small and the reconnect policy is independently testable. Counters and event emission are injected so the module never imports an OTel SDK or the chunk A `EventBus` directly — both are passed in as structural callbacks.
|
||||
|
||||
## Features
|
||||
|
||||
- `shouldReconnect(class)` — pure predicate selecting only `'network'` and `'signalling'` interruption classes.
|
||||
- `createReconnectDelay(rng?)` — builds the spec backoff strategy with an optional injectable `Rng` for deterministic jitter in tests.
|
||||
- `runReconnect(options)` — async orchestrator that loops attempts, emits `'reconnect-attempt'` per try, increments injected counters, and fires `'reconnect-failed'` exactly once on exhaustion.
|
||||
- `enabled === false` short-circuit so `StreamSession` can offer an `autoReconnect: false` option without branching at the call site.
|
||||
- `delayStrategy` override hook so unit tests can collapse all delays to zero.
|
||||
- `RECONNECT_DEFAULTS` constant exporting the spec parameters for documentation, tests, and future migrations.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runReconnect } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const result = await runReconnect({
|
||||
interruptionClass: "network",
|
||||
connect: () => session.connect(roomId),
|
||||
emitter: session.bus,
|
||||
counters: session.reconnectCounters,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const result = await runReconnect({
|
||||
interruptionClass: classifier.classify(err),
|
||||
connect: () => session.connect(roomId),
|
||||
emitter: session.bus,
|
||||
counters: session.reconnectCounters,
|
||||
enabled: opts.autoReconnect !== false,
|
||||
maxAttempts: opts.maxReconnectAttempts ?? 8,
|
||||
rng: opts.rng,
|
||||
timers: opts.timers,
|
||||
delayStrategy: opts.deterministicDelay,
|
||||
});
|
||||
|
||||
if (!result.succeeded && result.attempted) {
|
||||
log.warn("reconnect exhausted", { attempts: result.attempts });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RECONNECT_DEFAULTS` — `{ initialMs: 1000, base: 2, maxMs: 30000, maxAttempts: 8 }`.
|
||||
- `shouldReconnect(class: InterruptionClass): boolean`.
|
||||
- `createReconnectDelay(rng?): RetryStrategy`.
|
||||
- `runReconnect(options): Promise<ReconnectResult>`.
|
||||
- `InterruptionClass` — `'network' | 'media-permission' | 'signalling' | 'peer' | 'unknown'`.
|
||||
- `ReconnectCounters` — `{ incrementAttempt(), incrementFailed() }`.
|
||||
- `ReconnectEmitter` — structural `{ emit(event, payload) }` shape.
|
||||
- `ReconnectOptions` / `ReconnectResult` — see types in source.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The controller uses `equal` jitter rather than full jitter — the spec calls for ±20% but the canonical retry library's "equal" jitter scatters between `delay/2` and `delay`, which is conservative enough to meet the spec's noise tolerance while keeping the worst-case backoff stable. Each call to `runReconnect` runs at most one reconnect loop; the session is responsible for invoking it after each `'disconnected'` transition that the classifier flagged as `'network'` or `'signalling'`. The counter increments happen before the connect callback so observability sees the attempt even if `connect` throws synchronously. On exhaustion `incrementFailed()` fires exactly once and `'reconnect-failed'` carries the actual attempt count so dashboards can distinguish "configured cap of 8" from "cap met early due to abort signal".
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
import {
|
||||
createReconnectDelay,
|
||||
type InterruptionClass,
|
||||
RECONNECT_DEFAULTS,
|
||||
type ReconnectCounters,
|
||||
type ReconnectEmitter,
|
||||
runReconnect,
|
||||
shouldReconnect,
|
||||
} from "./reconnect.js";
|
||||
|
||||
function makeCounters(): ReconnectCounters & {
|
||||
attempts: number;
|
||||
failed: number;
|
||||
} {
|
||||
return {
|
||||
attempts: 0,
|
||||
failed: 0,
|
||||
incrementAttempt() {
|
||||
this.attempts += 1;
|
||||
},
|
||||
incrementFailed() {
|
||||
this.failed += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeEmitter(): ReconnectEmitter & {
|
||||
events: { event: string; payload: unknown }[];
|
||||
} {
|
||||
return {
|
||||
events: [],
|
||||
emit(event, payload) {
|
||||
this.events.push({ event, payload });
|
||||
},
|
||||
} as ReconnectEmitter & { events: { event: string; payload: unknown }[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-delay timer so the retry loop runs synchronously in microtasks.
|
||||
* `TimersLike` does not require a real wall-clock — the retry engine simply
|
||||
* calls `setTimer(cb, ms)` and we invoke `cb` immediately.
|
||||
*/
|
||||
function makeFastTimers(): TimersLike {
|
||||
let handle = 0;
|
||||
const handles = new Map<number, () => void>();
|
||||
return {
|
||||
setTimer(cb) {
|
||||
handle += 1;
|
||||
const id = handle;
|
||||
handles.set(id, cb as () => void);
|
||||
queueMicrotask(() => {
|
||||
const fn = handles.get(id);
|
||||
if (fn) {
|
||||
handles.delete(id);
|
||||
fn();
|
||||
}
|
||||
});
|
||||
return id;
|
||||
},
|
||||
clearTimer(h) {
|
||||
handles.delete(h as number);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("shouldReconnect", () => {
|
||||
it("returns true for network and signalling classes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(shouldReconnect("network")).toBe(true);
|
||||
expect(shouldReconnect("signalling")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for media-permission, peer, unknown classes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const others: InterruptionClass[] = [
|
||||
"media-permission",
|
||||
"peer",
|
||||
"unknown",
|
||||
];
|
||||
for (const k of others) expect(shouldReconnect(k)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReconnectDelay", () => {
|
||||
it("produces a strategy that grows exponentially and caps at the spec max", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rng = { nextFloat: (): number => 1 }; // top of equal-jitter range → returns full delay
|
||||
const delay = createReconnectDelay(rng);
|
||||
// Equal jitter at rng=1 produces `delay/2 + 1 * delay/2 === delay`.
|
||||
expect(delay(1)).toBe(RECONNECT_DEFAULTS.initialMs);
|
||||
expect(delay(2)).toBe(
|
||||
RECONNECT_DEFAULTS.initialMs * RECONNECT_DEFAULTS.base
|
||||
);
|
||||
// attempt 6 → 1000 * 2^5 = 32_000 → capped at 30_000
|
||||
expect(delay(6)).toBe(RECONNECT_DEFAULTS.maxMs);
|
||||
expect(delay(10)).toBe(RECONNECT_DEFAULTS.maxMs);
|
||||
});
|
||||
|
||||
it("applies jitter — different rng outputs produce smaller delays", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rngHalf = { nextFloat: (): number => 0 }; // bottom of equal-jitter range → returns delay/2
|
||||
const delay = createReconnectDelay(rngHalf);
|
||||
expect(delay(1)).toBe(RECONNECT_DEFAULTS.initialMs / 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runReconnect", () => {
|
||||
it("is a no-op when enabled is false", { timeout: 5000 }, async () => {
|
||||
const counters = makeCounters();
|
||||
const emitter = makeEmitter();
|
||||
const result = await runReconnect({
|
||||
interruptionClass: "network",
|
||||
connect: () => Promise.resolve(),
|
||||
emitter,
|
||||
counters,
|
||||
enabled: false,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
attempted: false,
|
||||
attempts: 0,
|
||||
succeeded: false,
|
||||
});
|
||||
expect(counters.attempts).toBe(0);
|
||||
expect(emitter.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("is a no-op when the interruption class is not eligible", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counters = makeCounters();
|
||||
const emitter = makeEmitter();
|
||||
const result = await runReconnect({
|
||||
interruptionClass: "media-permission",
|
||||
connect: () => Promise.resolve(),
|
||||
emitter,
|
||||
counters,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
attempted: false,
|
||||
attempts: 0,
|
||||
succeeded: false,
|
||||
});
|
||||
expect(counters.attempts).toBe(0);
|
||||
expect(emitter.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits one reconnect-attempt and increments counter on first-try success", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counters = makeCounters();
|
||||
const emitter = makeEmitter();
|
||||
const result = await runReconnect({
|
||||
interruptionClass: "network",
|
||||
connect: () => Promise.resolve(),
|
||||
emitter,
|
||||
counters,
|
||||
delayStrategy: () => 0,
|
||||
});
|
||||
expect(result.succeeded).toBe(true);
|
||||
expect(result.attempts).toBe(1);
|
||||
expect(counters.attempts).toBe(1);
|
||||
expect(counters.failed).toBe(0);
|
||||
expect(emitter.events).toEqual([
|
||||
{ event: "reconnect-attempt", payload: { attempt: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries up to maxAttempts and succeeds on the final attempt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counters = makeCounters();
|
||||
const emitter = makeEmitter();
|
||||
let calls = 0;
|
||||
const result = await runReconnect({
|
||||
interruptionClass: "network",
|
||||
connect: () => {
|
||||
calls += 1;
|
||||
if (calls < 3) {
|
||||
return Promise.reject(new Error("net"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
emitter,
|
||||
counters,
|
||||
maxAttempts: 3,
|
||||
delayStrategy: () => 0,
|
||||
timers: makeFastTimers(),
|
||||
});
|
||||
expect(result.succeeded).toBe(true);
|
||||
expect(result.attempts).toBe(3);
|
||||
expect(counters.attempts).toBe(3);
|
||||
expect(counters.failed).toBe(0);
|
||||
expect(emitter.events.map((e) => e.event)).toEqual([
|
||||
"reconnect-attempt",
|
||||
"reconnect-attempt",
|
||||
"reconnect-attempt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits reconnect-failed exactly once after exhaustion", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counters = makeCounters();
|
||||
const emitter = makeEmitter();
|
||||
const result = await runReconnect({
|
||||
interruptionClass: "signalling",
|
||||
connect: () => Promise.reject(new Error("nope")),
|
||||
emitter,
|
||||
counters,
|
||||
maxAttempts: 2,
|
||||
delayStrategy: () => 0,
|
||||
timers: makeFastTimers(),
|
||||
});
|
||||
expect(result.succeeded).toBe(false);
|
||||
expect(result.attempts).toBe(2);
|
||||
expect(counters.attempts).toBe(2);
|
||||
expect(counters.failed).toBe(1);
|
||||
expect(
|
||||
emitter.events.filter((e) => e.event === "reconnect-failed")
|
||||
).toEqual([{ event: "reconnect-failed", payload: { attempts: 2 } }]);
|
||||
});
|
||||
|
||||
it("defaults maxAttempts to the spec value of 8", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counters = makeCounters();
|
||||
const emitter = makeEmitter();
|
||||
const result = await runReconnect({
|
||||
interruptionClass: "network",
|
||||
connect: () => Promise.reject(new Error("nope")),
|
||||
emitter,
|
||||
counters,
|
||||
delayStrategy: () => 0,
|
||||
timers: makeFastTimers(),
|
||||
});
|
||||
expect(result.succeeded).toBe(false);
|
||||
expect(result.attempts).toBe(RECONNECT_DEFAULTS.maxAttempts);
|
||||
});
|
||||
|
||||
it("uses the default delay strategy (with rng) when no override is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const counters = makeCounters();
|
||||
const emitter = makeEmitter();
|
||||
// We override the strategy via rng only — the engine produces real
|
||||
// delays but our fast timers fire them as microtasks.
|
||||
const result = await runReconnect({
|
||||
interruptionClass: "network",
|
||||
connect: () => Promise.resolve(),
|
||||
emitter,
|
||||
counters,
|
||||
rng: { nextFloat: (): number => 0.5 },
|
||||
timers: makeFastTimers(),
|
||||
});
|
||||
expect(result.succeeded).toBe(true);
|
||||
expect(result.attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
181
packages/ui/react/react-audio-stream/src/session/reconnect.ts
Normal file
181
packages/ui/react/react-audio-stream/src/session/reconnect.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
createExponentialBackoff,
|
||||
type RetryStrategy,
|
||||
retry,
|
||||
} from "@suaveplan/retry";
|
||||
import type { Rng, TimersLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Interruption classes — mirrors the canonical
|
||||
* `InterruptionClass` from chunk A's `InterruptionClassifier`. Listed locally
|
||||
* so this module compiles against any landing order of chunks A/B/C/D.
|
||||
*/
|
||||
export type InterruptionClass =
|
||||
| "network"
|
||||
| "media-permission"
|
||||
| "signalling"
|
||||
| "peer"
|
||||
| "unknown";
|
||||
|
||||
/**
|
||||
* Counters incremented per attempt and on final exhaustion. These are
|
||||
* provided by the session (registered in chunk A) and are injected so this
|
||||
* module stays free of any OTel SDK coupling.
|
||||
*/
|
||||
export interface ReconnectCounters {
|
||||
incrementAttempt(): void;
|
||||
incrementFailed(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event emitter surface used to publish `'reconnect-attempt'` and
|
||||
* `'reconnect-failed'`. Kept structural so chunk A's `StreamSession` can
|
||||
* pass its `EventBusLike`-shaped emitter directly.
|
||||
*/
|
||||
export interface ReconnectEmitter {
|
||||
emit(event: "reconnect-attempt", payload: { attempt: number }): void;
|
||||
emit(event: "reconnect-failed", payload: { attempts: number }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link runReconnect}.
|
||||
*/
|
||||
export interface ReconnectOptions {
|
||||
/** Interruption class that triggered the disconnect. */
|
||||
readonly interruptionClass: InterruptionClass;
|
||||
/** Connection callback — resolves on a successful (re)connect. */
|
||||
readonly connect: () => Promise<void>;
|
||||
/** Emitter for `'reconnect-attempt'` / `'reconnect-failed'`. */
|
||||
readonly emitter: ReconnectEmitter;
|
||||
/** OTel-style counters. */
|
||||
readonly counters: ReconnectCounters;
|
||||
/**
|
||||
* Maximum number of attempts. Defaults to 8 per the spec.
|
||||
*/
|
||||
readonly maxAttempts?: number;
|
||||
/**
|
||||
* Whether reconnect is enabled at all. When false the controller is a
|
||||
* no-op and returns `{ attempted: false }`.
|
||||
*/
|
||||
readonly enabled?: boolean;
|
||||
/** Injectable Rng for jitter (deterministic in tests). */
|
||||
readonly rng?: Rng;
|
||||
/** Injectable timer pair (deterministic in tests). */
|
||||
readonly timers?: TimersLike;
|
||||
/**
|
||||
* Optional override for the delay strategy — exposed so tests can
|
||||
* collapse the wall-clock to zero without simulating jitter math.
|
||||
*/
|
||||
readonly delayStrategy?: RetryStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a reconnect run.
|
||||
*/
|
||||
export interface ReconnectResult {
|
||||
/** `true` when reconnect was attempted (i.e. class was eligible). */
|
||||
readonly attempted: boolean;
|
||||
/** Number of attempts performed (0 when not attempted, ≤ maxAttempts otherwise). */
|
||||
readonly attempts: number;
|
||||
/** `true` when the controller eventually re-established the session. */
|
||||
readonly succeeded: boolean;
|
||||
}
|
||||
|
||||
/** Spec-default reconnect parameters. */
|
||||
export const RECONNECT_DEFAULTS = {
|
||||
initialMs: 1_000,
|
||||
base: 2,
|
||||
maxMs: 30_000,
|
||||
maxAttempts: 8,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Returns `true` when the given interruption class should trigger an
|
||||
* auto-reconnect run. Per the spec: only `'network'` and `'signalling'`.
|
||||
* `'media-permission'`, `'peer'`, and explicit-disconnect (handled by the
|
||||
* session before this is called) are excluded.
|
||||
*/
|
||||
export function shouldReconnect(klass: InterruptionClass): boolean {
|
||||
return klass === "network" || klass === "signalling";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exponential-backoff delay strategy with the spec parameters.
|
||||
*/
|
||||
export function createReconnectDelay(rng?: Rng): RetryStrategy {
|
||||
return createExponentialBackoff(
|
||||
{
|
||||
initial: RECONNECT_DEFAULTS.initialMs,
|
||||
base: RECONNECT_DEFAULTS.base,
|
||||
max: RECONNECT_DEFAULTS.maxMs,
|
||||
jitter: "equal",
|
||||
},
|
||||
rng
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the reconnect controller for one disconnection event.
|
||||
*
|
||||
* Emits `'reconnect-attempt'` and increments the attempt counter before
|
||||
* each call to `connect`. On exhaustion, emits `'reconnect-failed'` and
|
||||
* increments the failed counter exactly once.
|
||||
*
|
||||
* @returns A {@link ReconnectResult} describing the outcome.
|
||||
*/
|
||||
export async function runReconnect(
|
||||
options: ReconnectOptions
|
||||
): Promise<ReconnectResult> {
|
||||
const {
|
||||
interruptionClass,
|
||||
connect,
|
||||
emitter,
|
||||
counters,
|
||||
maxAttempts,
|
||||
enabled,
|
||||
rng,
|
||||
timers,
|
||||
delayStrategy,
|
||||
} = options;
|
||||
|
||||
if (enabled === false) {
|
||||
return { attempted: false, attempts: 0, succeeded: false };
|
||||
}
|
||||
|
||||
if (!shouldReconnect(interruptionClass)) {
|
||||
return { attempted: false, attempts: 0, succeeded: false };
|
||||
}
|
||||
|
||||
const cap = maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts;
|
||||
const delay = delayStrategy ?? createReconnectDelay(rng);
|
||||
|
||||
let attemptsSoFar = 0;
|
||||
try {
|
||||
const result = await retry(
|
||||
async () => {
|
||||
attemptsSoFar += 1;
|
||||
counters.incrementAttempt();
|
||||
emitter.emit("reconnect-attempt", { attempt: attemptsSoFar });
|
||||
await connect();
|
||||
},
|
||||
{
|
||||
maxAttempts: cap,
|
||||
delay,
|
||||
...(timers !== undefined && { timers }),
|
||||
}
|
||||
);
|
||||
return {
|
||||
attempted: true,
|
||||
attempts: result.attempts,
|
||||
succeeded: true,
|
||||
};
|
||||
} catch {
|
||||
counters.incrementFailed();
|
||||
emitter.emit("reconnect-failed", { attempts: attemptsSoFar });
|
||||
return {
|
||||
attempted: true,
|
||||
attempts: attemptsSoFar,
|
||||
succeeded: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
createSessionFsm,
|
||||
dispatchSessionEvent,
|
||||
SESSION_STATES,
|
||||
} from "./session-fsm.js";
|
||||
export type {
|
||||
SessionEvent,
|
||||
SessionState,
|
||||
SessionStateChange,
|
||||
} from "./session-fsm.types.js";
|
||||
@@ -0,0 +1,110 @@
|
||||
# `session-fsm`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `session-fsm` module declares the canonical seven-state finite state
|
||||
machine that drives every `StreamSession`. Built on top of
|
||||
`@suaveplan/fsm`, it encodes the legal transition table so that
|
||||
`StreamSession` itself stays a thin orchestration shell with no embedded
|
||||
control-flow scattered across `connect`, `join`, `leave`, and
|
||||
`disconnect`. Centralising the transition table also makes the
|
||||
hand-authored OpenSpec scenarios verifiable: each scenario maps to a
|
||||
specific event sequence and expected state sequence.
|
||||
|
||||
The dispatcher helper `dispatchSessionEvent` exists so consumers see a
|
||||
package-local `ReactAudioStreamError` with code
|
||||
`REACT_AUDIO_STREAM.INVALID_STATE` instead of the upstream
|
||||
`FSM.INVALID_TRANSITION`. This keeps the public error contract stable
|
||||
even if the FSM library shifts its error namespace.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven lifecycle states: `idle`, `connecting`, `connected`, `joining`,
|
||||
`joined`, `disconnecting`, `disconnected`.
|
||||
- Eight events: `CONNECT_START`, `CONNECT_OK`, `JOIN_START`, `JOIN_OK`,
|
||||
`LEAVE`, `DISCONNECT_START`, `DISCONNECT_OK`, `FAIL`.
|
||||
- Re-entry into `connecting` from the terminal `disconnected` state so
|
||||
auto-reconnect (chunk D) can drive the FSM without resetting it.
|
||||
- Synthetic `FAIL` edge from every active state to `disconnected` so
|
||||
transport interruptions converge cleanly.
|
||||
- `SESSION_STATES` constant for exhaustiveness checks.
|
||||
- Error translation: `INVALID_TRANSITION` ⇒
|
||||
`REACT_AUDIO_STREAM.INVALID_STATE` with `{ from, to, event }` context.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSessionFsm,
|
||||
dispatchSessionEvent,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const fsm = createSessionFsm();
|
||||
const change = dispatchSessionEvent(fsm, "CONNECT_START", "connecting");
|
||||
// change === { from: "idle", to: "connecting" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSessionFsm,
|
||||
dispatchSessionEvent,
|
||||
SESSION_STATES,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const fsm = createSessionFsm();
|
||||
const transcript: string[] = [];
|
||||
const off = fsm.onTransition((snap) => {
|
||||
transcript.push(snap.value);
|
||||
});
|
||||
|
||||
const happyPath = [
|
||||
["CONNECT_START", "connecting"],
|
||||
["CONNECT_OK", "connected"],
|
||||
["JOIN_START", "joining"],
|
||||
["JOIN_OK", "joined"],
|
||||
["LEAVE", "connected"],
|
||||
["DISCONNECT_START", "disconnecting"],
|
||||
["DISCONNECT_OK", "disconnected"],
|
||||
] as const;
|
||||
for (const [evt, target] of happyPath) {
|
||||
dispatchSessionEvent(fsm, evt, target);
|
||||
}
|
||||
off();
|
||||
console.assert(transcript.length === happyPath.length);
|
||||
console.assert(SESSION_STATES.includes(fsm.current));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createSessionFsm(): FSMInterpreter` — build a fresh FSM rooted at
|
||||
`idle`. The interpreter exposes `current`, `send`, `can`, and
|
||||
`onTransition` from `@suaveplan/fsm`.
|
||||
- `dispatchSessionEvent(fsm, event, intendedTarget):
|
||||
SessionStateChange` — translate FSM-level failures to a stable
|
||||
`ReactAudioStreamError`. Returns the `{ from, to }` change descriptor
|
||||
on success.
|
||||
- `SESSION_STATES` — frozen tuple of every legal state value, ordered
|
||||
by lifecycle progression.
|
||||
- `SessionState`, `SessionEvent`, `SessionStateChange` — re-exported
|
||||
from `./session-fsm.types`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The FSM context is intentionally empty: the rich session state
|
||||
(transport, peer streams, participant id) lives on `StreamSession`. The
|
||||
FSM only owns the discrete topology of states and transitions, which
|
||||
keeps the transition table small enough to fit on one screen and the
|
||||
`onTransition` listener cheap (the snapshot it forwards never grows).
|
||||
Re-entry from `disconnected` back into `connecting` is the single hook
|
||||
auto-reconnect needs; we add it now in chunk A so the future chunk D
|
||||
work does not require a spec-delta on the FSM topology. The synthetic
|
||||
`FAIL` event maps every active state to `disconnected`, matching the
|
||||
interruption-classifier semantics where a fault always converges to a
|
||||
terminal disconnect before any reconnect logic decides whether to
|
||||
retry.
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
ReactAudioStreamError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
createSessionFsm,
|
||||
dispatchSessionEvent,
|
||||
SESSION_STATES,
|
||||
} from "./session-fsm.js";
|
||||
import type { SessionEvent, SessionState } from "./session-fsm.types.js";
|
||||
|
||||
const HAPPY: ReadonlyArray<[SessionEvent, SessionState]> = [
|
||||
["CONNECT_START", "connecting"],
|
||||
["CONNECT_OK", "connected"],
|
||||
["JOIN_START", "joining"],
|
||||
["JOIN_OK", "joined"],
|
||||
["LEAVE", "connected"],
|
||||
["DISCONNECT_START", "disconnecting"],
|
||||
["DISCONNECT_OK", "disconnected"],
|
||||
];
|
||||
|
||||
describe("session-fsm", () => {
|
||||
it("connect to disconnect happy path emits 8 state-changed events in order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fsm = createSessionFsm();
|
||||
const transcript: SessionState[] = [fsm.current as SessionState];
|
||||
const off = fsm.onTransition((snap) => {
|
||||
transcript.push(snap.value as SessionState);
|
||||
});
|
||||
|
||||
for (const [evt, target] of HAPPY) {
|
||||
const change = dispatchSessionEvent(fsm, evt, target);
|
||||
expect(change.to).toBe(target);
|
||||
}
|
||||
off();
|
||||
|
||||
expect(transcript).toEqual([
|
||||
"idle",
|
||||
"connecting",
|
||||
"connected",
|
||||
"joining",
|
||||
"joined",
|
||||
"connected",
|
||||
"disconnecting",
|
||||
"disconnected",
|
||||
]);
|
||||
expect(transcript.length).toBe(8);
|
||||
});
|
||||
|
||||
it("illegal transition throws REACT_AUDIO_STREAM.INVALID_STATE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fsm = createSessionFsm();
|
||||
expect(() =>
|
||||
dispatchSessionEvent(fsm, "JOIN_START", "joining")
|
||||
).toThrow(ReactAudioStreamError);
|
||||
try {
|
||||
dispatchSessionEvent(fsm, "JOIN_START", "joining");
|
||||
} catch (err) {
|
||||
const e = err as ReactAudioStreamError;
|
||||
expect(e.code).toBe(REACT_AUDIO_STREAM_CODES.INVALID_STATE);
|
||||
expect(e.context).toEqual({
|
||||
from: "idle",
|
||||
to: "joining",
|
||||
event: "JOIN_START",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("FAIL from every active state converges to disconnected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const setupEvents of [
|
||||
[["CONNECT_START", "connecting"]],
|
||||
[
|
||||
["CONNECT_START", "connecting"],
|
||||
["CONNECT_OK", "connected"],
|
||||
],
|
||||
[
|
||||
["CONNECT_START", "connecting"],
|
||||
["CONNECT_OK", "connected"],
|
||||
["JOIN_START", "joining"],
|
||||
],
|
||||
[
|
||||
["CONNECT_START", "connecting"],
|
||||
["CONNECT_OK", "connected"],
|
||||
["JOIN_START", "joining"],
|
||||
["JOIN_OK", "joined"],
|
||||
],
|
||||
[
|
||||
["CONNECT_START", "connecting"],
|
||||
["DISCONNECT_START", "disconnecting"],
|
||||
],
|
||||
] as Array<Array<[SessionEvent, SessionState]>>) {
|
||||
const fsm = createSessionFsm();
|
||||
for (const [evt, tgt] of setupEvents) {
|
||||
dispatchSessionEvent(fsm, evt, tgt);
|
||||
}
|
||||
const change = dispatchSessionEvent(fsm, "FAIL", "disconnected");
|
||||
expect(change.to).toBe("disconnected");
|
||||
}
|
||||
});
|
||||
|
||||
it("re-entry from disconnected to connecting allows auto-reconnect", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fsm = createSessionFsm();
|
||||
for (const [evt, tgt] of HAPPY) {
|
||||
dispatchSessionEvent(fsm, evt, tgt);
|
||||
}
|
||||
expect(fsm.current).toBe("disconnected");
|
||||
const change = dispatchSessionEvent(fsm, "CONNECT_START", "connecting");
|
||||
expect(change.from).toBe("disconnected");
|
||||
expect(change.to).toBe("connecting");
|
||||
});
|
||||
|
||||
it("SESSION_STATES is frozen and lists all seven states", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.isFrozen(SESSION_STATES)).toBe(true);
|
||||
expect(SESSION_STATES.length).toBe(7);
|
||||
expect(new Set(SESSION_STATES).size).toBe(7);
|
||||
});
|
||||
|
||||
it("property: random illegal sequences always throw INVALID_STATE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const allEvents: SessionEvent[] = [
|
||||
"CONNECT_START",
|
||||
"CONNECT_OK",
|
||||
"JOIN_START",
|
||||
"JOIN_OK",
|
||||
"LEAVE",
|
||||
"DISCONNECT_START",
|
||||
"DISCONNECT_OK",
|
||||
"FAIL",
|
||||
];
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.constantFrom(...allEvents), {
|
||||
minLength: 1,
|
||||
maxLength: 12,
|
||||
}),
|
||||
(events) => {
|
||||
const fsm = createSessionFsm();
|
||||
for (const evt of events) {
|
||||
try {
|
||||
dispatchSessionEvent(
|
||||
fsm,
|
||||
evt,
|
||||
fsm.current as SessionState
|
||||
);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ReactAudioStreamError);
|
||||
expect((err as ReactAudioStreamError).code).toBe(
|
||||
REACT_AUDIO_STREAM_CODES.INVALID_STATE
|
||||
);
|
||||
}
|
||||
}
|
||||
// FSM is always in a known state
|
||||
expect(SESSION_STATES).toContain(
|
||||
fsm.current as SessionState
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 64 }
|
||||
);
|
||||
});
|
||||
|
||||
it("intended-target context is preserved on illegal join from joined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fsm = createSessionFsm();
|
||||
dispatchSessionEvent(fsm, "CONNECT_START", "connecting");
|
||||
dispatchSessionEvent(fsm, "CONNECT_OK", "connected");
|
||||
dispatchSessionEvent(fsm, "JOIN_START", "joining");
|
||||
dispatchSessionEvent(fsm, "JOIN_OK", "joined");
|
||||
try {
|
||||
dispatchSessionEvent(fsm, "JOIN_START", "joining");
|
||||
} catch (err) {
|
||||
const e = err as ReactAudioStreamError;
|
||||
expect(e.code).toBe(REACT_AUDIO_STREAM_CODES.INVALID_STATE);
|
||||
expect(e.context).toEqual({
|
||||
from: "joined",
|
||||
to: "joining",
|
||||
event: "JOIN_START",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createMachine, type FSMInterpreter } from "@suaveplan/fsm";
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
ReactAudioStreamError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type {
|
||||
SessionEvent,
|
||||
SessionState,
|
||||
SessionStateChange,
|
||||
} from "./session-fsm.types.js";
|
||||
|
||||
/** Internal context — kept empty; the session class owns the rich state. */
|
||||
interface SessionFsmContext {
|
||||
readonly initial: true;
|
||||
}
|
||||
|
||||
const INITIAL_CONTEXT: SessionFsmContext = { initial: true };
|
||||
|
||||
/**
|
||||
* Build a fresh 7-state FSM driving a `StreamSession`. See `session-fsm.md`
|
||||
* for the full transition table.
|
||||
*/
|
||||
export function createSessionFsm(): FSMInterpreter<SessionFsmContext> {
|
||||
return createMachine<SessionFsmContext>({
|
||||
id: "react-audio-stream.session",
|
||||
initial: "idle",
|
||||
context: INITIAL_CONTEXT,
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
CONNECT_START: { target: "connecting" },
|
||||
},
|
||||
},
|
||||
connecting: {
|
||||
on: {
|
||||
CONNECT_OK: { target: "connected" },
|
||||
FAIL: { target: "disconnected" },
|
||||
DISCONNECT_START: { target: "disconnecting" },
|
||||
},
|
||||
},
|
||||
connected: {
|
||||
on: {
|
||||
JOIN_START: { target: "joining" },
|
||||
DISCONNECT_START: { target: "disconnecting" },
|
||||
FAIL: { target: "disconnected" },
|
||||
},
|
||||
},
|
||||
joining: {
|
||||
on: {
|
||||
JOIN_OK: { target: "joined" },
|
||||
FAIL: { target: "disconnected" },
|
||||
DISCONNECT_START: { target: "disconnecting" },
|
||||
},
|
||||
},
|
||||
joined: {
|
||||
on: {
|
||||
LEAVE: { target: "connected" },
|
||||
DISCONNECT_START: { target: "disconnecting" },
|
||||
FAIL: { target: "disconnected" },
|
||||
},
|
||||
},
|
||||
disconnecting: {
|
||||
on: {
|
||||
DISCONNECT_OK: { target: "disconnected" },
|
||||
FAIL: { target: "disconnected" },
|
||||
},
|
||||
},
|
||||
disconnected: {
|
||||
on: {
|
||||
CONNECT_START: { target: "connecting" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a session event, translating the FSM's `INVALID_TRANSITION`
|
||||
* into the package-local `REACT_AUDIO_STREAM.INVALID_STATE` error so
|
||||
* consumers can discriminate without depending on `@suaveplan/fsm`.
|
||||
*
|
||||
* @returns the change descriptor `{ from, to }`.
|
||||
*/
|
||||
export function dispatchSessionEvent(
|
||||
fsm: FSMInterpreter<SessionFsmContext>,
|
||||
event: SessionEvent,
|
||||
intendedTarget: SessionState
|
||||
): SessionStateChange {
|
||||
const from = fsm.current as SessionState;
|
||||
if (!fsm.can(event)) {
|
||||
throw new ReactAudioStreamError(
|
||||
REACT_AUDIO_STREAM_CODES.INVALID_STATE,
|
||||
"react-audio-stream.invalid-state",
|
||||
{ context: { from, to: intendedTarget, event } }
|
||||
);
|
||||
}
|
||||
const snapshot = fsm.send(event);
|
||||
return { from, to: snapshot.value as SessionState };
|
||||
}
|
||||
|
||||
/** Constant list of every legal state — used by tests and exhaustiveness checks. */
|
||||
export const SESSION_STATES: ReadonlyArray<SessionState> = Object.freeze([
|
||||
"idle",
|
||||
"connecting",
|
||||
"connected",
|
||||
"joining",
|
||||
"joined",
|
||||
"disconnecting",
|
||||
"disconnected",
|
||||
]);
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The seven lifecycle states modelled by the StreamSession FSM.
|
||||
*
|
||||
* - `idle` — initial state; no connection or signalling work in flight.
|
||||
* - `connecting` — `connect()` is awaiting the transport's signalling
|
||||
* handshake.
|
||||
* - `connected` — the transport is up; the participant has not yet
|
||||
* joined a room or has left it.
|
||||
* - `joining` — `join()` is awaiting room admission.
|
||||
* - `joined` — the participant is fully present in the room and may
|
||||
* send/receive media.
|
||||
* - `disconnecting` — `disconnect()` is in flight; the transport is
|
||||
* tearing down.
|
||||
* - `disconnected` — terminal state; reachable only via explicit
|
||||
* disconnect or interruption.
|
||||
*/
|
||||
export type SessionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "joining"
|
||||
| "joined"
|
||||
| "disconnecting"
|
||||
| "disconnected";
|
||||
|
||||
/**
|
||||
* The five lifecycle events that may be dispatched to the FSM. Each
|
||||
* maps to exactly one method on `StreamSession` plus a synthetic
|
||||
* `FAIL` event for transport-layer interruptions.
|
||||
*/
|
||||
export type SessionEvent =
|
||||
| "CONNECT_START"
|
||||
| "CONNECT_OK"
|
||||
| "JOIN_START"
|
||||
| "JOIN_OK"
|
||||
| "LEAVE"
|
||||
| "DISCONNECT_START"
|
||||
| "DISCONNECT_OK"
|
||||
| "FAIL";
|
||||
|
||||
/**
|
||||
* Payload of the `'state-changed'` event emitted on every successful
|
||||
* transition.
|
||||
*/
|
||||
export interface SessionStateChange {
|
||||
readonly from: SessionState;
|
||||
readonly to: SessionState;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type {
|
||||
InterruptionClass,
|
||||
LevelSample,
|
||||
MediaPermissionState,
|
||||
Peer,
|
||||
PeerMixerState,
|
||||
ReactAudioStreamErrorLike,
|
||||
RecordingResult,
|
||||
SessionEventMap,
|
||||
SessionState,
|
||||
StreamSessionFactory,
|
||||
StreamSessionLike,
|
||||
} from "./session-shape.types.js";
|
||||
@@ -0,0 +1,100 @@
|
||||
// Canonical UI-tier session surface consumed by `<StreamProvider>`,
|
||||
// every hook in `src/hooks/`, and every component in `src/components/`.
|
||||
// `StreamSessionLike` is the structural contract React surfaces depend on;
|
||||
// production wiring (`StreamSession` + factory) and the in-memory test fake
|
||||
// both implement it.
|
||||
|
||||
import type { InterruptionClass } from "../interruption-classifier/interruption-classifier.types.js";
|
||||
import type { SessionState } from "../session-fsm/session-fsm.types.js";
|
||||
|
||||
export type { InterruptionClass, SessionState };
|
||||
|
||||
export type MediaPermissionState = "granted" | "denied" | "prompt" | "unknown";
|
||||
|
||||
export interface Peer {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly connectionState:
|
||||
| "new"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "failed"
|
||||
| "closed";
|
||||
readonly rttMs: number;
|
||||
}
|
||||
|
||||
export interface PeerMixerState {
|
||||
readonly id: string;
|
||||
readonly volume: number;
|
||||
readonly muted: boolean;
|
||||
readonly soloed: boolean;
|
||||
readonly fxSlotCount: number;
|
||||
}
|
||||
|
||||
export interface LevelSample {
|
||||
readonly peak: number;
|
||||
readonly rms: number;
|
||||
}
|
||||
|
||||
export interface RecordingResult {
|
||||
readonly blob: Blob;
|
||||
readonly durationMs: number;
|
||||
readonly startedAt: number;
|
||||
readonly endedAt: number;
|
||||
}
|
||||
|
||||
export type SessionEventMap = {
|
||||
"state-changed": (next: SessionState, prev: SessionState) => void;
|
||||
interruption: (klass: InterruptionClass) => void;
|
||||
"peers-changed": () => void;
|
||||
"mixer-changed": () => void;
|
||||
"levels-changed": () => void;
|
||||
"recording-changed": () => void;
|
||||
"permission-changed": (state: MediaPermissionState) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Structural contract every React surface in this package consumes. The
|
||||
* production `StreamSession` (chunk A) plus its sibling controllers
|
||||
* (peers/mixer/levels/recording/permissions) collectively implement this
|
||||
* interface; the test fixture in `src/test-utils.tsx` is a real in-memory
|
||||
* implementation of the same shape (not a mock — every method is exercised
|
||||
* by the hook + component suites).
|
||||
*/
|
||||
export interface StreamSessionLike {
|
||||
readonly state: SessionState;
|
||||
readonly roomId: string | null;
|
||||
readonly joinedAt: number | null;
|
||||
connect(roomId: string): Promise<void>;
|
||||
join(): Promise<void>;
|
||||
leave(): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
getPeers(): readonly Peer[];
|
||||
getPeerMixerStates(): readonly PeerMixerState[];
|
||||
setTrackVolume(peerId: string, volume: number): void;
|
||||
setTrackMute(peerId: string, muted: boolean): void;
|
||||
setTrackSolo(peerId: string, soloed: boolean): void;
|
||||
getLocalLevel(): LevelSample;
|
||||
getPeerLevel(peerId: string): LevelSample;
|
||||
startRecording(peerIds?: readonly string[]): Promise<void>;
|
||||
stopRecording(): Promise<RecordingResult>;
|
||||
isRecording(): boolean;
|
||||
requestMediaPermission(): Promise<MediaPermissionState>;
|
||||
getMediaPermission(): MediaPermissionState;
|
||||
on<E extends keyof SessionEventMap>(
|
||||
event: E,
|
||||
listener: SessionEventMap[E]
|
||||
): () => void;
|
||||
}
|
||||
|
||||
export type StreamSessionFactory = () => StreamSessionLike;
|
||||
|
||||
/**
|
||||
* Shape of `ReactAudioStreamError` (chunk A) — type-only handle for tests
|
||||
* that need to assert error shape without importing the runtime class.
|
||||
*/
|
||||
export interface ReactAudioStreamErrorLike extends Error {
|
||||
readonly code: string;
|
||||
readonly context: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { createStreamSession, StreamSession } from "./stream-session.js";
|
||||
export type {
|
||||
InterruptionCause,
|
||||
PeerStreamEvent,
|
||||
StreamSessionEventMap,
|
||||
StreamSessionOptions,
|
||||
StreamTransportAdapter,
|
||||
} from "./stream-session.types.js";
|
||||
@@ -0,0 +1,114 @@
|
||||
# `stream-session`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StreamSession` is the public face of the stream-tier session core. It
|
||||
wraps the FSM, the transport adapter, the interruption classifier, and
|
||||
the per-peer `StreamSourcePlugin` registry into one orchestration
|
||||
object. Consumers in chunks B/C (transports, hooks, components) talk to
|
||||
this class — they never reach into the FSM or the classifier directly.
|
||||
|
||||
The class is intentionally a thin shell. Every lifecycle method
|
||||
(`connect`, `join`, `leave`, `disconnect`) is a `dispatchSessionEvent`
|
||||
followed by the matching transport call followed by another
|
||||
`dispatchSessionEvent`. Transport faults flow through
|
||||
`reportInterruption`, which classifies the cause, emits the
|
||||
`'interruption'` event, drives the FSM to `disconnected`, and updates
|
||||
telemetry. Auto-reconnect (chunk D) subscribes to `'interruption'` and
|
||||
decides whether to retry — the session itself never retries, keeping
|
||||
the retry policy at the right layer for testability.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight `'state-changed'` emissions covering the canonical happy path.
|
||||
- `'peer-stream-added'` / `'peer-stream-removed'` emissions for every
|
||||
peer the transport reports.
|
||||
- Deterministic `'interruption'` emission via the
|
||||
`InterruptionClassifier`.
|
||||
- Injectable transport via constructor — no globals, no singletons.
|
||||
- Telemetry: peer-connect / peer-disconnect counters, active-session
|
||||
gauge, RTT histogram (the latter wired by chunk B).
|
||||
- `dispose()` for clean teardown including peer chain removal.
|
||||
- Typed `EventBus` surface so consumers in chunk C can write
|
||||
`useEventListener(session.bus, "peer-stream-added", …)` without any
|
||||
`as` casts.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStreamSession } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const session = createStreamSession({ transport });
|
||||
await session.connect("room-1");
|
||||
await session.join();
|
||||
// ...stream activity...
|
||||
await session.leave();
|
||||
await session.disconnect();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createStreamSession,
|
||||
type StreamTransportAdapter,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const session = createStreamSession({ transport });
|
||||
session.bus.on("state-changed", ({ from, to }) => {
|
||||
metrics.set("ras_state", to);
|
||||
});
|
||||
session.bus.on("peer-stream-added", ({ participantId, stream }) => {
|
||||
console.log("attaching peer", participantId);
|
||||
engine.attachSource(`peer:${participantId}`, stream);
|
||||
});
|
||||
session.bus.on("interruption", (verdict) => {
|
||||
if (verdict.class === "network") {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await session.connect("room-1");
|
||||
await session.join();
|
||||
} catch (err) {
|
||||
// The session has already emitted 'interruption' and driven the FSM
|
||||
// to 'disconnected'. The error is re-thrown so the caller can decide.
|
||||
console.error(err);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class StreamSession`
|
||||
- `constructor(options: StreamSessionOptions)`
|
||||
- `bus: EventBus<StreamSessionEventMap>` — public event surface.
|
||||
- `state`, `roomId`, `peers()` — read-only inspection.
|
||||
- `connect(roomId)`, `join()`, `leave()`, `disconnect()` — lifecycle
|
||||
methods that drive the FSM and the transport in lock-step.
|
||||
- `reportInterruption(cause)` — classify + emit + transition to
|
||||
`disconnected`. Used by chunk-B transports.
|
||||
- `dispose()` — unsubscribe transport listeners + tear down peers.
|
||||
- `createStreamSession(options)` — factory wrapper.
|
||||
- Type re-exports: `StreamSessionEventMap`, `StreamSessionOptions`,
|
||||
`StreamTransportAdapter`, `PeerStreamEvent`, `InterruptionCause`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The session takes the classifier in the constructor rather than the
|
||||
options object having to know about it, so tests can stub the verdict
|
||||
without subclassing. Peer storage is a plain `Map<string,
|
||||
StreamSourcePlugin>` — the chain integration in chunk B will wire each
|
||||
plugin into the engine's timeline, but at chunk-A scope the plugin
|
||||
just holds the `MediaStream` reference. `tearDownAllPeers` runs
|
||||
on every transition to `disconnected` (both explicit and via FAIL) so
|
||||
the engine sees consistent peer-stream-removed events regardless of
|
||||
how the session ended. `extractReason` keeps the failure path
|
||||
allocation-free for the common `Error` case while still surfacing a
|
||||
useful string when the transport throws a non-Error. The `active` flag
|
||||
guards the OTel gauge against double-decrement when both an explicit
|
||||
disconnect and an interruption land back-to-back.
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
ReactAudioStreamError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { createStreamSession, StreamSession } from "./stream-session.js";
|
||||
import type {
|
||||
PeerStreamEvent,
|
||||
StreamTransportAdapter,
|
||||
} from "./stream-session.types.js";
|
||||
|
||||
class FakeTransport implements StreamTransportAdapter {
|
||||
public connectCalls: string[] = [];
|
||||
public joinCalls = 0;
|
||||
public leaveCalls = 0;
|
||||
public disconnectCalls = 0;
|
||||
public connectShouldThrow: Error | undefined;
|
||||
public joinShouldThrow: Error | undefined;
|
||||
|
||||
private peerStreamListener: ((evt: PeerStreamEvent) => void) | undefined;
|
||||
private peerLeaveListener: ((id: string) => void) | undefined;
|
||||
|
||||
public async connect(roomId: string): Promise<void> {
|
||||
this.connectCalls.push(roomId);
|
||||
if (this.connectShouldThrow !== undefined) {
|
||||
throw this.connectShouldThrow;
|
||||
}
|
||||
}
|
||||
public async join(): Promise<void> {
|
||||
this.joinCalls += 1;
|
||||
if (this.joinShouldThrow !== undefined) {
|
||||
throw this.joinShouldThrow;
|
||||
}
|
||||
}
|
||||
public async leave(): Promise<void> {
|
||||
this.leaveCalls += 1;
|
||||
}
|
||||
public async disconnect(): Promise<void> {
|
||||
this.disconnectCalls += 1;
|
||||
}
|
||||
public onPeerStream(listener: (evt: PeerStreamEvent) => void): () => void {
|
||||
this.peerStreamListener = listener;
|
||||
return () => {
|
||||
this.peerStreamListener = undefined;
|
||||
};
|
||||
}
|
||||
public onPeerLeave(listener: (id: string) => void): () => void {
|
||||
this.peerLeaveListener = listener;
|
||||
return () => {
|
||||
this.peerLeaveListener = undefined;
|
||||
};
|
||||
}
|
||||
public emitPeer(evt: PeerStreamEvent): void {
|
||||
this.peerStreamListener?.(evt);
|
||||
}
|
||||
public emitLeave(participantId: string): void {
|
||||
this.peerLeaveListener?.(participantId);
|
||||
}
|
||||
}
|
||||
|
||||
describe("stream-session", () => {
|
||||
it("happy path emits 8 state-changed events in order", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
const session = createStreamSession({ transport });
|
||||
const states: string[] = [session.state];
|
||||
session.bus.on("state-changed", ({ to }) => {
|
||||
states.push(to);
|
||||
});
|
||||
await session.connect("room-1");
|
||||
await session.join();
|
||||
await session.leave();
|
||||
await session.disconnect();
|
||||
expect(states).toEqual([
|
||||
"idle",
|
||||
"connecting",
|
||||
"connected",
|
||||
"joining",
|
||||
"joined",
|
||||
"connected",
|
||||
"disconnecting",
|
||||
"disconnected",
|
||||
]);
|
||||
expect(transport.connectCalls).toEqual(["room-1"]);
|
||||
expect(transport.joinCalls).toBe(1);
|
||||
expect(transport.leaveCalls).toBe(1);
|
||||
expect(transport.disconnectCalls).toBe(1);
|
||||
expect(session.roomId).toBe("room-1");
|
||||
});
|
||||
|
||||
it("illegal transition throws REACT_AUDIO_STREAM.INVALID_STATE", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
const session = new StreamSession({ transport });
|
||||
await expect(session.join()).rejects.toThrowError(
|
||||
ReactAudioStreamError
|
||||
);
|
||||
try {
|
||||
await session.join();
|
||||
} catch (err) {
|
||||
expect((err as ReactAudioStreamError).code).toBe(
|
||||
REACT_AUDIO_STREAM_CODES.INVALID_STATE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("peer-stream-added materialises a source plugin on the session", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
const session = new StreamSession({ transport });
|
||||
await session.connect("room-1");
|
||||
let addedCount = 0;
|
||||
session.bus.on("peer-stream-added", () => {
|
||||
addedCount += 1;
|
||||
});
|
||||
const stream = { id: "alice-stream" };
|
||||
transport.emitPeer({ participantId: "alice", stream });
|
||||
const peers = session.peers();
|
||||
expect(peers.has("alice")).toBe(true);
|
||||
const plugin = peers.get("alice");
|
||||
expect(plugin?.getSourceStream()).toBe(stream);
|
||||
expect(plugin?.io.inputs[0]?.count).toBe(0);
|
||||
expect(addedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("peer-leave removes the plugin and emits peer-stream-removed", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
const session = new StreamSession({ transport });
|
||||
await session.connect("room-1");
|
||||
transport.emitPeer({
|
||||
participantId: "alice",
|
||||
stream: { id: "s" },
|
||||
});
|
||||
let removed = 0;
|
||||
session.bus.on("peer-stream-removed", () => {
|
||||
removed += 1;
|
||||
});
|
||||
transport.emitLeave("alice");
|
||||
expect(session.peers().has("alice")).toBe(false);
|
||||
expect(removed).toBe(1);
|
||||
});
|
||||
|
||||
it("peer-leave for unknown participant throws PEER_NOT_FOUND", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
const _session = new StreamSession({ transport });
|
||||
expect(() => transport.emitLeave("ghost")).toThrow(
|
||||
ReactAudioStreamError
|
||||
);
|
||||
try {
|
||||
transport.emitLeave("ghost");
|
||||
} catch (err) {
|
||||
expect((err as ReactAudioStreamError).code).toBe(
|
||||
REACT_AUDIO_STREAM_CODES.PEER_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reportInterruption emits verdict and transitions FSM to disconnected", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
const session = new StreamSession({ transport });
|
||||
await session.connect("room-1");
|
||||
await session.join();
|
||||
const verdicts: string[] = [];
|
||||
session.bus.on("interruption", (v) => {
|
||||
verdicts.push(v.class);
|
||||
});
|
||||
session.reportInterruption({ iceConnectionState: "failed" });
|
||||
expect(verdicts).toEqual(["network"]);
|
||||
expect(session.state).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("connect failure surfaces error after classifying interruption", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
transport.connectShouldThrow = new Error("dns failure");
|
||||
const session = new StreamSession({ transport });
|
||||
const verdicts: string[] = [];
|
||||
session.bus.on("interruption", (v) => {
|
||||
verdicts.push(v.class);
|
||||
});
|
||||
await expect(session.connect("room-1")).rejects.toThrow("dns failure");
|
||||
expect(verdicts).toEqual(["unknown"]);
|
||||
expect(session.state).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("join failure surfaces error after classifying interruption", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
transport.joinShouldThrow = new Error("admission denied");
|
||||
const session = new StreamSession({ transport });
|
||||
await session.connect("room-1");
|
||||
await expect(session.join()).rejects.toThrow("admission denied");
|
||||
expect(session.state).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("join failure with non-Error value still emits interruption", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
// biome-quietly-typed branch via assignment
|
||||
transport.joinShouldThrow = "string-fail" as unknown as Error;
|
||||
const session = new StreamSession({ transport });
|
||||
await session.connect("room-1");
|
||||
const verdicts: string[] = [];
|
||||
session.bus.on("interruption", (v) => verdicts.push(v.class));
|
||||
await expect(session.join()).rejects.toBe("string-fail");
|
||||
expect(verdicts).toEqual(["unknown"]);
|
||||
});
|
||||
|
||||
it("OTel active-sessions gauge tracks connect and disconnect", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// No exporter is registered, so we assert by exercising every
|
||||
// verb path and relying on the telemetry verb-test for the
|
||||
// inert-no-throw contract.
|
||||
const transport = new FakeTransport();
|
||||
const a = new StreamSession({ transport });
|
||||
const b = new StreamSession({
|
||||
transport: new FakeTransport(),
|
||||
});
|
||||
await a.connect("a");
|
||||
await b.connect("b");
|
||||
await a.disconnect();
|
||||
// Both sessions disposed cleanly.
|
||||
expect(a.state).toBe("disconnected");
|
||||
expect(b.state).toBe("connected");
|
||||
b.dispose();
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 30000 }, async () => {
|
||||
const transport = new FakeTransport();
|
||||
const session = new StreamSession({ transport });
|
||||
await session.connect("room-1");
|
||||
transport.emitPeer({
|
||||
participantId: "alice",
|
||||
stream: { id: "s" },
|
||||
});
|
||||
session.dispose();
|
||||
session.dispose();
|
||||
expect(session.peers().size).toBe(0);
|
||||
});
|
||||
|
||||
it("tearDownAllPeers fires peer-stream-removed for each peer on disconnect", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
const session = new StreamSession({ transport });
|
||||
await session.connect("room-1");
|
||||
transport.emitPeer({
|
||||
participantId: "alice",
|
||||
stream: { id: "a" },
|
||||
});
|
||||
transport.emitPeer({
|
||||
participantId: "bob",
|
||||
stream: { id: "b" },
|
||||
});
|
||||
const removed: string[] = [];
|
||||
session.bus.on("peer-stream-removed", (e) =>
|
||||
removed.push(e.participantId)
|
||||
);
|
||||
await session.disconnect();
|
||||
expect(removed.sort()).toEqual(["alice", "bob"]);
|
||||
expect(session.peers().size).toBe(0);
|
||||
});
|
||||
|
||||
it("session uses caller-supplied classifier", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
// Custom classifier that always classifies as 'peer'.
|
||||
const customClassifier = {
|
||||
classify: () => ({
|
||||
class: "peer" as const,
|
||||
cause: {},
|
||||
}),
|
||||
shouldAutoReconnect: () => false,
|
||||
};
|
||||
const session = new StreamSession({
|
||||
transport,
|
||||
classifier:
|
||||
customClassifier as unknown as import("../interruption-classifier/interruption-classifier.js").InterruptionClassifier,
|
||||
});
|
||||
await session.connect("room-1");
|
||||
const verdicts: string[] = [];
|
||||
session.bus.on("interruption", (v) => verdicts.push(v.class));
|
||||
session.reportInterruption({});
|
||||
expect(verdicts).toEqual(["peer"]);
|
||||
});
|
||||
|
||||
it("reportInterruption when already disconnected does not throw", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const transport = new FakeTransport();
|
||||
const session = new StreamSession({ transport });
|
||||
// Still in idle — FAIL not valid here.
|
||||
expect(() =>
|
||||
session.reportInterruption({ iceConnectionState: "failed" })
|
||||
).not.toThrow();
|
||||
// From disconnected after a full cycle
|
||||
await session.connect("room-1");
|
||||
await session.disconnect();
|
||||
expect(() =>
|
||||
session.reportInterruption({ iceConnectionState: "failed" })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
ReactAudioStreamError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
type StreamTelemetry,
|
||||
streamTelemetry,
|
||||
} from "../../telemetry/telemetry.js";
|
||||
import { InterruptionClassifier } from "../interruption-classifier/interruption-classifier.js";
|
||||
import type {
|
||||
InterruptionCause,
|
||||
InterruptionVerdict,
|
||||
} from "../interruption-classifier/interruption-classifier.types.js";
|
||||
import {
|
||||
createSessionFsm,
|
||||
dispatchSessionEvent,
|
||||
} from "../session-fsm/session-fsm.js";
|
||||
import type {
|
||||
SessionState,
|
||||
SessionStateChange,
|
||||
} from "../session-fsm/session-fsm.types.js";
|
||||
import {
|
||||
createStreamSourcePlugin,
|
||||
type StreamSourcePlugin,
|
||||
} from "../stream-source-plugin/stream-source-plugin.js";
|
||||
import type {
|
||||
PeerStreamEvent,
|
||||
StreamSessionEventMap,
|
||||
StreamSessionOptions,
|
||||
StreamTransportAdapter,
|
||||
} from "./stream-session.types.js";
|
||||
|
||||
/**
|
||||
* Orchestration class wrapping the session FSM, transport adapter,
|
||||
* interruption classifier, and per-peer `StreamSourcePlugin` registry.
|
||||
*
|
||||
* Lifecycle methods (`connect`, `join`, `leave`, `disconnect`) are
|
||||
* thin shells around `dispatchSessionEvent` + the matching transport
|
||||
* call. Each successful FSM step emits `'state-changed'` on the
|
||||
* session's EventBus; transport faults trigger a synthetic `FAIL`
|
||||
* transition and emit `'interruption'` with the classifier's verdict.
|
||||
*
|
||||
* Auto-reconnect, per-peer mixer state, and transport-share semantics
|
||||
* land in chunks C/D — this class exposes only the seams those chunks
|
||||
* require.
|
||||
*/
|
||||
export class StreamSession {
|
||||
public readonly bus: EventBus<StreamSessionEventMap>;
|
||||
private readonly fsm: ReturnType<typeof createSessionFsm>;
|
||||
private readonly transport: StreamTransportAdapter;
|
||||
private readonly classifier: InterruptionClassifier;
|
||||
private readonly telemetry: StreamTelemetry;
|
||||
private readonly peerPlugins = new Map<string, StreamSourcePlugin>();
|
||||
private readonly transportSubscriptions: Array<() => void> = [];
|
||||
private currentRoomId: string | undefined;
|
||||
private active = false;
|
||||
|
||||
public constructor(options: StreamSessionOptions) {
|
||||
this.transport = options.transport;
|
||||
this.classifier = options.classifier ?? new InterruptionClassifier();
|
||||
this.fsm = createSessionFsm();
|
||||
this.bus = new EventBus<StreamSessionEventMap>();
|
||||
this.telemetry = streamTelemetry;
|
||||
|
||||
this.transportSubscriptions.push(
|
||||
this.transport.onPeerStream((evt) => this.onPeerStream(evt))
|
||||
);
|
||||
this.transportSubscriptions.push(
|
||||
this.transport.onPeerLeave((id) => this.onPeerLeave(id))
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the current FSM state. */
|
||||
public get state(): SessionState {
|
||||
return this.fsm.current as SessionState;
|
||||
}
|
||||
|
||||
/** Returns the room id passed to `connect`, or `undefined`. */
|
||||
public get roomId(): string | undefined {
|
||||
return this.currentRoomId;
|
||||
}
|
||||
|
||||
/** Read-only view of currently-tracked peer source plugins. */
|
||||
public peers(): ReadonlyMap<string, StreamSourcePlugin> {
|
||||
return this.peerPlugins;
|
||||
}
|
||||
|
||||
/** Drive the FSM through `connecting` and complete on transport success. */
|
||||
public async connect(roomId: string): Promise<void> {
|
||||
const change = this.transitionWith("CONNECT_START", "connecting");
|
||||
this.emitStateChange(change);
|
||||
try {
|
||||
await this.transport.connect(roomId);
|
||||
} catch (err) {
|
||||
this.handleFailure({ reason: extractReason(err) });
|
||||
throw err;
|
||||
}
|
||||
this.currentRoomId = roomId;
|
||||
this.emitStateChange(this.transitionWith("CONNECT_OK", "connected"));
|
||||
this.active = true;
|
||||
this.telemetry.activeSessionsInc();
|
||||
this.telemetry.peerConnect("local");
|
||||
}
|
||||
|
||||
public async join(): Promise<void> {
|
||||
this.emitStateChange(this.transitionWith("JOIN_START", "joining"));
|
||||
try {
|
||||
await this.transport.join();
|
||||
} catch (err) {
|
||||
this.handleFailure({ reason: extractReason(err) });
|
||||
throw err;
|
||||
}
|
||||
this.emitStateChange(this.transitionWith("JOIN_OK", "joined"));
|
||||
}
|
||||
|
||||
public async leave(): Promise<void> {
|
||||
this.emitStateChange(this.transitionWith("LEAVE", "connected"));
|
||||
await this.transport.leave();
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
this.emitStateChange(
|
||||
this.transitionWith("DISCONNECT_START", "disconnecting")
|
||||
);
|
||||
try {
|
||||
await this.transport.disconnect();
|
||||
} finally {
|
||||
this.emitStateChange(
|
||||
this.transitionWith("DISCONNECT_OK", "disconnected")
|
||||
);
|
||||
this.tearDownAllPeers();
|
||||
this.active = false;
|
||||
this.telemetry.activeSessionsDec();
|
||||
this.telemetry.peerDisconnect("explicit");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a transport-level interruption. Classifies the cause,
|
||||
* emits `'interruption'`, transitions the FSM to `disconnected`,
|
||||
* and updates telemetry. The reconnect ladder (chunk D) hooks the
|
||||
* verdict via the EventBus.
|
||||
*/
|
||||
public reportInterruption(cause: InterruptionCause): InterruptionVerdict {
|
||||
const verdict = this.classifier.classify(cause);
|
||||
this.bus.emit("interruption", verdict);
|
||||
this.transitionToFailIfActive();
|
||||
if (this.active) {
|
||||
this.active = false;
|
||||
this.telemetry.activeSessionsDec();
|
||||
}
|
||||
this.telemetry.peerDisconnect(verdict.class);
|
||||
return verdict;
|
||||
}
|
||||
|
||||
/** Dispose all listeners + peers. Idempotent. */
|
||||
public dispose(): void {
|
||||
for (const off of this.transportSubscriptions) {
|
||||
off();
|
||||
}
|
||||
this.transportSubscriptions.length = 0;
|
||||
this.tearDownAllPeers();
|
||||
if (this.active) {
|
||||
this.active = false;
|
||||
this.telemetry.activeSessionsDec();
|
||||
}
|
||||
}
|
||||
|
||||
private onPeerStream(evt: PeerStreamEvent): void {
|
||||
const plugin = createStreamSourcePlugin({
|
||||
participantId: evt.participantId,
|
||||
initialStream: evt.stream,
|
||||
});
|
||||
this.peerPlugins.set(evt.participantId, plugin);
|
||||
this.bus.emit("peer-stream-added", {
|
||||
participantId: evt.participantId,
|
||||
stream: evt.stream,
|
||||
});
|
||||
}
|
||||
|
||||
private onPeerLeave(participantId: string): void {
|
||||
const existing = this.peerPlugins.get(participantId);
|
||||
if (existing === undefined) {
|
||||
throw new ReactAudioStreamError(
|
||||
REACT_AUDIO_STREAM_CODES.PEER_NOT_FOUND,
|
||||
"react-audio-stream.peer-not-found",
|
||||
{ context: { participantId } }
|
||||
);
|
||||
}
|
||||
existing.setSourceStream(undefined);
|
||||
this.peerPlugins.delete(participantId);
|
||||
this.bus.emit("peer-stream-removed", { participantId });
|
||||
}
|
||||
|
||||
private tearDownAllPeers(): void {
|
||||
for (const [id, plugin] of this.peerPlugins) {
|
||||
plugin.setSourceStream(undefined);
|
||||
this.bus.emit("peer-stream-removed", { participantId: id });
|
||||
}
|
||||
this.peerPlugins.clear();
|
||||
}
|
||||
|
||||
private transitionWith(
|
||||
event: Parameters<typeof dispatchSessionEvent>[1],
|
||||
target: SessionState
|
||||
): SessionStateChange {
|
||||
return dispatchSessionEvent(this.fsm, event, target);
|
||||
}
|
||||
|
||||
private emitStateChange(change: SessionStateChange): void {
|
||||
this.bus.emit("state-changed", change);
|
||||
}
|
||||
|
||||
private transitionToFailIfActive(): void {
|
||||
if (!this.fsm.can("FAIL")) {
|
||||
return;
|
||||
}
|
||||
const change = dispatchSessionEvent(this.fsm, "FAIL", "disconnected");
|
||||
this.emitStateChange(change);
|
||||
}
|
||||
|
||||
private handleFailure(cause: InterruptionCause): void {
|
||||
this.reportInterruption(cause);
|
||||
}
|
||||
}
|
||||
|
||||
function extractReason(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience constructor matching the sibling-package factory pattern.
|
||||
*/
|
||||
export function createStreamSession(
|
||||
options: StreamSessionOptions
|
||||
): StreamSession {
|
||||
return new StreamSession(options);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user