From 528c11a1dc5de3bbe97f1ff218ec0662f179e9e1 Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Sun, 21 Dec 2025 11:28:16 -0500 Subject: [PATCH 1/3] made webrtc route helpers --- .../integration-suite/src/generated/app.ts | 59 --- apps/testing/webrtc-test/.gitignore | 43 ++ .../testing/webrtc-test/.vscode/settings.json | 16 + apps/testing/webrtc-test/AGENTS.md | 64 +++ apps/testing/webrtc-test/README.md | 147 ++++++ apps/testing/webrtc-test/agentuity.config.ts | 35 ++ apps/testing/webrtc-test/agentuity.json | 13 + apps/testing/webrtc-test/app.ts | 17 + apps/testing/webrtc-test/package.json | 30 ++ apps/testing/webrtc-test/src/agent/AGENTS.md | 322 +++++++++++++ .../webrtc-test/src/agent/hello/agent.ts | 15 + .../webrtc-test/src/agent/hello/index.ts | 1 + apps/testing/webrtc-test/src/api/AGENTS.md | 259 ++++++++++ apps/testing/webrtc-test/src/api/index.ts | 16 + apps/testing/webrtc-test/src/generated/app.ts | 316 ++++++++++++ apps/testing/webrtc-test/src/web/AGENTS.md | 282 +++++++++++ apps/testing/webrtc-test/src/web/App.tsx | 344 ++++++++++++++ apps/testing/webrtc-test/src/web/frontend.tsx | 29 ++ apps/testing/webrtc-test/src/web/index.html | 13 + .../webrtc-test/src/web/public/.gitkeep | 0 .../webrtc-test/src/web/public/favicon.ico | Bin 0 -> 174912 bytes apps/testing/webrtc-test/tsconfig.json | 27 ++ bun.lock | 51 +- packages/cli/src/cmd/build/ast.ts | 11 + packages/cli/src/cmd/dev/index.ts | 122 ++--- packages/frontend/src/index.ts | 7 + packages/frontend/src/webrtc-manager.ts | 448 ++++++++++++++++++ packages/react/src/index.ts | 10 + packages/react/src/webrtc.tsx | 213 +++++++++ packages/runtime/src/index.ts | 9 + packages/runtime/src/router.ts | 60 +++ packages/runtime/src/webrtc-signaling.ts | 226 +++++++++ .../runtime/test/webrtc-signaling.test.ts | 324 +++++++++++++ 33 files changed, 3385 insertions(+), 144 deletions(-) create mode 100644 apps/testing/webrtc-test/.gitignore create mode 100644 apps/testing/webrtc-test/.vscode/settings.json create mode 100644 apps/testing/webrtc-test/AGENTS.md create mode 100644 apps/testing/webrtc-test/README.md create mode 100644 apps/testing/webrtc-test/agentuity.config.ts create mode 100644 apps/testing/webrtc-test/agentuity.json create mode 100644 apps/testing/webrtc-test/app.ts create mode 100644 apps/testing/webrtc-test/package.json create mode 100644 apps/testing/webrtc-test/src/agent/AGENTS.md create mode 100644 apps/testing/webrtc-test/src/agent/hello/agent.ts create mode 100644 apps/testing/webrtc-test/src/agent/hello/index.ts create mode 100644 apps/testing/webrtc-test/src/api/AGENTS.md create mode 100644 apps/testing/webrtc-test/src/api/index.ts create mode 100644 apps/testing/webrtc-test/src/generated/app.ts create mode 100644 apps/testing/webrtc-test/src/web/AGENTS.md create mode 100644 apps/testing/webrtc-test/src/web/App.tsx create mode 100644 apps/testing/webrtc-test/src/web/frontend.tsx create mode 100644 apps/testing/webrtc-test/src/web/index.html create mode 100644 apps/testing/webrtc-test/src/web/public/.gitkeep create mode 100644 apps/testing/webrtc-test/src/web/public/favicon.ico create mode 100644 apps/testing/webrtc-test/tsconfig.json create mode 100644 packages/frontend/src/webrtc-manager.ts create mode 100644 packages/react/src/webrtc.tsx create mode 100644 packages/runtime/src/webrtc-signaling.ts create mode 100644 packages/runtime/test/webrtc-signaling.test.ts diff --git a/apps/testing/integration-suite/src/generated/app.ts b/apps/testing/integration-suite/src/generated/app.ts index 169ee9b5..1148481b 100644 --- a/apps/testing/integration-suite/src/generated/app.ts +++ b/apps/testing/integration-suite/src/generated/app.ts @@ -113,65 +113,6 @@ if (!isDevelopment()) { app.get('/_idle', idleHandler); } -// Asset proxy routes - Development mode only (proxies to Vite asset server) -if (process.env.NODE_ENV !== 'production') { - const VITE_ASSET_PORT = parseInt(process.env.VITE_PORT || '5173', 10); - - const proxyToVite = async (c: Context) => { - const viteUrl = `http://127.0.0.1:${VITE_ASSET_PORT}${c.req.path}`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout - try { - otel.logger.debug(`[Proxy] ${c.req.method} ${c.req.path} -> Vite:${VITE_ASSET_PORT}`); - const res = await fetch(viteUrl, { signal: controller.signal }); - clearTimeout(timeout); - otel.logger.debug(`[Proxy] ${c.req.path} -> ${res.status} (${res.headers.get('content-type')})`); - return new Response(res.body, { - status: res.status, - headers: res.headers, - }); - } catch (err) { - clearTimeout(timeout); - if (err instanceof Error && err.name === 'AbortError') { - otel.logger.error(`Vite proxy timeout: ${c.req.path}`); - return c.text('Vite asset server timeout', 504); - } - otel.logger.error(`Failed to proxy to Vite: ${c.req.path} - ${err instanceof Error ? err.message : String(err)}`); - return c.text('Vite asset server error', 500); - } - }; - - // Vite client scripts and HMR - app.get('/@vite/*', proxyToVite); - app.get('/@react-refresh', proxyToVite); - - // Source files for HMR - app.get('/src/web/*', proxyToVite); - app.get('/src/*', proxyToVite); // Catch-all for other source files - - // Workbench source files (in .agentuity/workbench-src/) - app.get('/.agentuity/workbench-src/*', proxyToVite); - - // Node modules (Vite transforms these) - app.get('/node_modules/*', proxyToVite); - - // Scoped packages (e.g., @agentuity/*, @types/*) - app.get('/@*', proxyToVite); - - // File system access (for Vite's @fs protocol) - app.get('/@fs/*', proxyToVite); - - // Module resolution (for Vite's @id protocol) - app.get('/@id/*', proxyToVite); - - // Any .js, .jsx, .ts, .tsx files (catch remaining modules) - app.get('/*.js', proxyToVite); - app.get('/*.jsx', proxyToVite); - app.get('/*.ts', proxyToVite); - app.get('/*.tsx', proxyToVite); - app.get('/*.css', proxyToVite); -} - // Mount API routes const { default: router_0 } = await import('../api/index.js'); app.route('/api', router_0); diff --git a/apps/testing/webrtc-test/.gitignore b/apps/testing/webrtc-test/.gitignore new file mode 100644 index 00000000..6767817a --- /dev/null +++ b/apps/testing/webrtc-test/.gitignore @@ -0,0 +1,43 @@ +# dependencies (bun install) + +node_modules + +# output + +out +dist +*.tgz + +# code coverage + +coverage +*.lcov + +# logs + +/logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]\*.json + +# dotenv environment variable files + +.env +.env.\* + +# caches + +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs + +.idea + +# Finder (MacOS) folder config + +.DS_Store + +# Agentuity build files + +.agentuity diff --git a/apps/testing/webrtc-test/.vscode/settings.json b/apps/testing/webrtc-test/.vscode/settings.json new file mode 100644 index 00000000..8b2c0232 --- /dev/null +++ b/apps/testing/webrtc-test/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "search.exclude": { + "**/.git/**": true, + "**/node_modules/**": true, + "**/bun.lock": true, + "**/.agentuity/**": true + }, + "json.schemas": [ + { + "fileMatch": [ + "agentuity.json" + ], + "url": "https://agentuity.dev/schema/cli/v1/agentuity.json" + } + ] +} \ No newline at end of file diff --git a/apps/testing/webrtc-test/AGENTS.md b/apps/testing/webrtc-test/AGENTS.md new file mode 100644 index 00000000..20cd550e --- /dev/null +++ b/apps/testing/webrtc-test/AGENTS.md @@ -0,0 +1,64 @@ +# Agent Guidelines for webrtc-test + +## Commands + +- **Build**: `bun run build` (compiles your application) +- **Dev**: `bun run dev` (starts development server) +- **Typecheck**: `bun run typecheck` (runs TypeScript type checking) +- **Deploy**: `bun run deploy` (deploys your app to the Agentuity cloud) + +## Agent-Friendly CLI + +The Agentuity CLI is designed to be agent-friendly with programmatic interfaces, structured output, and comprehensive introspection. + +Read the [AGENTS.md](./node_modules/@agentuity/cli/AGENTS.md) file in the Agentuity CLI for more information on how to work with this project. + +## Instructions + +- This project uses Bun instead of NodeJS and TypeScript for all source code +- This is an Agentuity Agent project + +## Web Frontend (src/web/) + +The `src/web/` folder contains your React frontend, which is automatically bundled by the Agentuity build system. + +**File Structure:** + +- `index.html` - Main HTML file with ` + + +``` + +## React Hooks + +### useAgent - Call Agents + +```typescript +import { useAgent } from '@agentuity/react'; + +function MyComponent() { + const { run, running, data, error } = useAgent('myAgent'); + + return ( + + ); +} +``` + +### useAgentWebsocket - WebSocket Connection + +```typescript +import { useAgentWebsocket } from '@agentuity/react'; + +function MyComponent() { + const { connected, send, data } = useAgentWebsocket('websocket'); + + return ( +
+

Status: {connected ? 'Connected' : 'Disconnected'}

+ +

Received: {data}

+
+ ); +} +``` + +### useAgentEventStream - Server-Sent Events + +```typescript +import { useAgentEventStream } from '@agentuity/react'; + +function MyComponent() { + const { connected, data, error } = useAgentEventStream('sse'); + + return ( +
+

Connected: {connected ? 'Yes' : 'No'}

+ {error &&

Error: {error.message}

} +

Data: {data}

+
+ ); +} +``` + +## Complete Example + +```typescript +import { AgentuityProvider, useAgent, useAgentWebsocket } from '@agentuity/react'; +import { useEffect, useState } from 'react'; + +export function App() { + const [count, setCount] = useState(0); + const { run, data: agentResult } = useAgent('simple'); + const { connected, send, data: wsMessage } = useAgentWebsocket('websocket'); + + useEffect(() => { + // Send WebSocket message every second + const interval = setInterval(() => { + send(`Message at ${new Date().toISOString()}`); + }, 1000); + return () => clearInterval(interval); + }, [send]); + + return ( +
+ +

My Agentuity App

+ +
+

Count: {count}

+ +
+ +
+ +

{agentResult}

+
+ +
+ WebSocket: + {connected ? JSON.stringify(wsMessage) : 'Not connected'} +
+
+
+ ); +} +``` + +## Static Assets + +Place static files in the **public/** folder: + +``` +src/web/public/ +├── logo.svg +├── styles.css +└── script.js +``` + +Reference them in your HTML or components: + +```html + + + +``` + +```typescript +// In React components +Logo +``` + +## Styling + +### Inline Styles + +```typescript +
+ Styled content +
+``` + +### CSS Files + +Create `public/styles.css`: + +```css +body { + background-color: #09090b; + color: #fff; + font-family: sans-serif; +} +``` + +Import in `index.html`: + +```html + +``` + +### Style Tag in Component + +```typescript +
+ + +
+``` + +## Best Practices + +- Wrap your app with **AgentuityProvider** for hooks to work +- Use **useAgent** for one-off agent calls +- Use **useAgentWebsocket** for bidirectional real-time communication +- Use **useAgentEventStream** for server-to-client streaming +- Place reusable components in separate files +- Keep static assets in the **public/** folder +- Use TypeScript for type safety +- Handle loading and error states in UI + +## Rules + +- **App.tsx** must export a function named `App` +- **frontend.tsx** must render the `App` component to `#root` +- **index.html** must have a `
` +- All agents are accessible via `useAgent('agentName')` +- The web app is served at `/` by default +- Static files in `public/` are served at `/public/*` +- Module script tag: `` diff --git a/apps/testing/webrtc-test/src/web/App.tsx b/apps/testing/webrtc-test/src/web/App.tsx new file mode 100644 index 00000000..973a3a90 --- /dev/null +++ b/apps/testing/webrtc-test/src/web/App.tsx @@ -0,0 +1,344 @@ +import { useWebRTCCall } from '@agentuity/react'; +import { useState, useEffect } from 'react'; + +export function App() { + const [roomId, setRoomId] = useState('test-room'); + const [joined, setJoined] = useState(false); + + const { + localVideoRef, + remoteVideoRef, + status, + error, + peerId, + remotePeerId, + isAudioMuted, + isVideoMuted, + connect, + hangup, + muteAudio, + muteVideo, + } = useWebRTCCall({ + roomId, + signalUrl: '/api/call/signal', + autoConnect: false, + }); + + // Auto-attach streams to video elements when refs are ready + useEffect(() => { + if (localVideoRef.current) { + localVideoRef.current.muted = true; + localVideoRef.current.playsInline = true; + } + if (remoteVideoRef.current) { + remoteVideoRef.current.playsInline = true; + } + }, [localVideoRef, remoteVideoRef]); + + const handleJoin = () => { + setJoined(true); + connect(); + }; + + const handleLeave = () => { + hangup(); + setJoined(false); + }; + + return ( +
+
+

WebRTC Video Call Demo

+

Powered by Agentuity

+
+ + {!joined ? ( +
+

Join a Room

+
+ + setRoomId(e.target.value)} + placeholder="Enter room ID" + /> +
+ +

Open this page in two browser tabs to test

+
+ ) : ( +
+
+ {status} + {peerId && You: {peerId}} + {remotePeerId && Remote: {remotePeerId}} +
+ + {error &&
Error: {error.message}
} + +
+
+
+
+
+
+ +
+ + + +
+
+ )} + + +
+ ); +} diff --git a/apps/testing/webrtc-test/src/web/frontend.tsx b/apps/testing/webrtc-test/src/web/frontend.tsx new file mode 100644 index 00000000..96996781 --- /dev/null +++ b/apps/testing/webrtc-test/src/web/frontend.tsx @@ -0,0 +1,29 @@ +/** + * This file is the entry point for the React app, it sets up the root + * element and renders the App component to the DOM. + * + * It is included in `src/index.html`. + */ + +import React, { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AgentuityProvider } from '@agentuity/react'; +import { App } from './App'; + +const elem = document.getElementById('root')!; +const app = ( + + + + + +); + +if (import.meta.hot) { + // With hot module reloading, `import.meta.hot.data` is persisted. + const root = (import.meta.hot.data.root ??= createRoot(elem)); + root.render(app); +} else { + // The hot module reloading API is not available in production. + createRoot(elem).render(app); +} diff --git a/apps/testing/webrtc-test/src/web/index.html b/apps/testing/webrtc-test/src/web/index.html new file mode 100644 index 00000000..781191e6 --- /dev/null +++ b/apps/testing/webrtc-test/src/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Agentuity + Bun + React + + + +
+ + diff --git a/apps/testing/webrtc-test/src/web/public/.gitkeep b/apps/testing/webrtc-test/src/web/public/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/testing/webrtc-test/src/web/public/favicon.ico b/apps/testing/webrtc-test/src/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..21f46e6f552734670df178e96f66592ad1741b37 GIT binary patch literal 174912 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 | undefined; + let previousMetadata: + | Awaited> + | undefined; let devmode: DevmodeResponse | undefined; let gravityBin: string | undefined; @@ -358,67 +360,67 @@ export const command = createCommand({ await tui.spinner({ message: 'Building dev bundle', callback: async () => { - const { generateEntryFile } = await import('../build/entry-generator'); - await generateEntryFile({ - rootDir, - projectId: project?.projectId ?? '', - deploymentId, - logger, - mode: 'dev', - }); - - // Bundle the app with LLM patches (dev mode = no minification) - const { installExternalsAndBuild } = await import('../build/vite/server-bundler'); - await installExternalsAndBuild({ - rootDir, - dev: true, // DevMode: no minification, inline sourcemaps - logger, - }); - - // Generate metadata file (needed for eval ID lookup at runtime) - const { discoverAgents } = await import('../build/vite/agent-discovery'); - const { discoverRoutes } = await import('../build/vite/route-discovery'); - const { generateMetadata, writeMetadataFile } = await import( - '../build/vite/metadata-generator' - ); - - const srcDir = join(rootDir, 'src'); - const agents = await discoverAgents( - srcDir, - project?.projectId ?? '', - deploymentId, - logger - ); - const { routes } = await discoverRoutes( - srcDir, - project?.projectId ?? '', - deploymentId, - logger - ); - - const metadata = await generateMetadata({ - rootDir, - projectId: project?.projectId ?? '', - orgId: project?.orgId ?? '', - deploymentId, - agents, - routes, - dev: true, - logger, - }); - - writeMetadataFile(rootDir, metadata, true, logger); + const { generateEntryFile } = await import('../build/entry-generator'); + await generateEntryFile({ + rootDir, + projectId: project?.projectId ?? '', + deploymentId, + logger, + mode: 'dev', + }); + + // Bundle the app with LLM patches (dev mode = no minification) + const { installExternalsAndBuild } = await import('../build/vite/server-bundler'); + await installExternalsAndBuild({ + rootDir, + dev: true, // DevMode: no minification, inline sourcemaps + logger, + }); + + // Generate metadata file (needed for eval ID lookup at runtime) + const { discoverAgents } = await import('../build/vite/agent-discovery'); + const { discoverRoutes } = await import('../build/vite/route-discovery'); + const { generateMetadata, writeMetadataFile } = await import( + '../build/vite/metadata-generator' + ); - // Sync metadata with backend (creates agents and evals in the database) - if (syncService && project?.projectId) { - await syncService.sync( - metadata, - previousMetadata, - project.projectId, - deploymentId + const srcDir = join(rootDir, 'src'); + const agents = await discoverAgents( + srcDir, + project?.projectId ?? '', + deploymentId, + logger ); - previousMetadata = metadata; - } + const { routes } = await discoverRoutes( + srcDir, + project?.projectId ?? '', + deploymentId, + logger + ); + + const metadata = await generateMetadata({ + rootDir, + projectId: project?.projectId ?? '', + orgId: project?.orgId ?? '', + deploymentId, + agents, + routes, + dev: true, + logger, + }); + + writeMetadataFile(rootDir, metadata, true, logger); + + // Sync metadata with backend (creates agents and evals in the database) + if (syncService && project?.projectId) { + await syncService.sync( + metadata, + previousMetadata, + project.projectId, + deploymentId + ); + previousMetadata = metadata; + } }, clearOnSuccess: true, }); diff --git a/packages/frontend/src/index.ts b/packages/frontend/src/index.ts index 48178564..7a64a187 100644 --- a/packages/frontend/src/index.ts +++ b/packages/frontend/src/index.ts @@ -18,6 +18,13 @@ export { type EventStreamManagerOptions, type EventStreamManagerState, } from './eventstream-manager'; +export { + WebRTCManager, + type WebRTCStatus, + type WebRTCCallbacks, + type WebRTCManagerOptions, + type WebRTCManagerState, +} from './webrtc-manager'; // Export client implementation (local to this package) export { createClient } from './client/index'; diff --git a/packages/frontend/src/webrtc-manager.ts b/packages/frontend/src/webrtc-manager.ts new file mode 100644 index 00000000..e5f28ddf --- /dev/null +++ b/packages/frontend/src/webrtc-manager.ts @@ -0,0 +1,448 @@ +/** + * WebRTC connection status + */ +export type WebRTCStatus = 'disconnected' | 'connecting' | 'signaling' | 'connected'; + +/** + * Signaling message types (must match server protocol) + */ +type SignalMsg = + | { t: 'join'; roomId: string } + | { t: 'joined'; peerId: string; roomId: string; peers: string[] } + | { t: 'peer-joined'; peerId: string } + | { t: 'peer-left'; peerId: string } + | { t: 'sdp'; from: string; to?: string; description: RTCSessionDescriptionInit } + | { t: 'ice'; from: string; to?: string; candidate: RTCIceCandidateInit } + | { t: 'error'; message: string }; + +/** + * Callbacks for WebRTC manager state changes + */ +export interface WebRTCCallbacks { + onLocalStream?: (stream: MediaStream) => void; + onRemoteStream?: (stream: MediaStream) => void; + onStatusChange?: (status: WebRTCStatus) => void; + onError?: (error: Error) => void; + onPeerJoined?: (peerId: string) => void; + onPeerLeft?: (peerId: string) => void; +} + +/** + * Options for WebRTCManager + */ +export interface WebRTCManagerOptions { + /** WebSocket signaling URL */ + signalUrl: string; + /** Room ID to join */ + roomId: string; + /** Whether this peer is "polite" in perfect negotiation (default: true) */ + polite?: boolean; + /** ICE servers configuration */ + iceServers?: RTCIceServer[]; + /** Media constraints for getUserMedia */ + media?: MediaStreamConstraints; + /** Callbacks for state changes */ + callbacks?: WebRTCCallbacks; +} + +/** + * WebRTC manager state + */ +export interface WebRTCManagerState { + status: WebRTCStatus; + peerId: string | null; + remotePeerId: string | null; + isAudioMuted: boolean; + isVideoMuted: boolean; +} + +/** + * Default ICE servers (public STUN servers) + */ +const DEFAULT_ICE_SERVERS: RTCIceServer[] = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, +]; + +/** + * Framework-agnostic WebRTC connection manager with signaling, + * perfect negotiation, and media stream handling. + */ +export class WebRTCManager { + private ws: WebSocket | null = null; + private pc: RTCPeerConnection | null = null; + private localStream: MediaStream | null = null; + private remoteStream: MediaStream | null = null; + + private peerId: string | null = null; + private remotePeerId: string | null = null; + private status: WebRTCStatus = 'disconnected'; + private isAudioMuted = false; + private isVideoMuted = false; + + // Perfect negotiation state + private makingOffer = false; + private ignoreOffer = false; + private polite: boolean; + + // ICE candidate buffering - buffer until remote description is set + private pendingCandidates: RTCIceCandidateInit[] = []; + private hasRemoteDescription = false; + + private options: WebRTCManagerOptions; + private callbacks: WebRTCCallbacks; + + constructor(options: WebRTCManagerOptions) { + this.options = options; + this.polite = options.polite ?? true; + this.callbacks = options.callbacks ?? {}; + } + + /** + * Get current manager state + */ + getState(): WebRTCManagerState { + return { + status: this.status, + peerId: this.peerId, + remotePeerId: this.remotePeerId, + isAudioMuted: this.isAudioMuted, + isVideoMuted: this.isVideoMuted, + }; + } + + /** + * Get local media stream + */ + getLocalStream(): MediaStream | null { + return this.localStream; + } + + /** + * Get remote media stream + */ + getRemoteStream(): MediaStream | null { + return this.remoteStream; + } + + private setStatus(status: WebRTCStatus): void { + this.status = status; + this.callbacks.onStatusChange?.(status); + } + + private send(msg: SignalMsg): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } + + /** + * Connect to the signaling server and start the call + */ + async connect(): Promise { + if (this.status !== 'disconnected') return; + + this.setStatus('connecting'); + + try { + // Get local media + const mediaConstraints = this.options.media ?? { video: true, audio: true }; + this.localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints); + this.callbacks.onLocalStream?.(this.localStream); + + // Connect to signaling server + this.ws = new WebSocket(this.options.signalUrl); + + this.ws.onopen = () => { + this.setStatus('signaling'); + this.send({ t: 'join', roomId: this.options.roomId }); + }; + + this.ws.onmessage = (event) => { + const msg = JSON.parse(event.data) as SignalMsg; + this.handleSignalingMessage(msg); + }; + + this.ws.onerror = () => { + this.callbacks.onError?.(new Error('WebSocket connection error')); + }; + + this.ws.onclose = () => { + if (this.status !== 'disconnected') { + this.setStatus('disconnected'); + } + }; + } catch (err) { + this.setStatus('disconnected'); + this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err))); + } + } + + private async handleSignalingMessage(msg: SignalMsg): Promise { + switch (msg.t) { + case 'joined': + this.peerId = msg.peerId; + // If there's already a peer in the room, we're the offerer (impolite) + if (msg.peers.length > 0) { + this.remotePeerId = msg.peers[0]; + // Late joiner is impolite (makes the offer, wins collisions) + this.polite = this.options.polite ?? false; + await this.createPeerConnection(); + await this.createOffer(); + } else { + // First peer is polite (waits for offers, yields on collision) + this.polite = this.options.polite ?? true; + } + break; + + case 'peer-joined': + this.remotePeerId = msg.peerId; + this.callbacks.onPeerJoined?.(msg.peerId); + // New peer joined, wait for their offer (they initiate) + await this.createPeerConnection(); + break; + + case 'peer-left': + this.callbacks.onPeerLeft?.(msg.peerId); + if (msg.peerId === this.remotePeerId) { + this.remotePeerId = null; + this.closePeerConnection(); + this.setStatus('signaling'); + } + break; + + case 'sdp': + await this.handleRemoteSDP(msg.description); + break; + + case 'ice': + await this.handleRemoteICE(msg.candidate); + break; + + case 'error': + this.callbacks.onError?.(new Error(msg.message)); + break; + } + } + + private async createPeerConnection(): Promise { + if (this.pc) return; + + const iceServers = this.options.iceServers ?? DEFAULT_ICE_SERVERS; + this.pc = new RTCPeerConnection({ iceServers }); + + // Add local tracks + if (this.localStream) { + for (const track of this.localStream.getTracks()) { + this.pc.addTrack(track, this.localStream); + } + } + + // Handle remote tracks + this.pc.ontrack = (event) => { + // Use the stream from the event if available (preferred - already has track) + // Otherwise create a new stream with the track + if (event.streams?.[0]) { + if (this.remoteStream !== event.streams[0]) { + this.remoteStream = event.streams[0]; + this.callbacks.onRemoteStream?.(this.remoteStream); + } + } else { + // Fallback: create stream with track already included + if (!this.remoteStream) { + this.remoteStream = new MediaStream([event.track]); + this.callbacks.onRemoteStream?.(this.remoteStream); + } else { + this.remoteStream.addTrack(event.track); + // Re-trigger callback so video element updates + this.callbacks.onRemoteStream?.(this.remoteStream); + } + } + + if (this.status !== 'connected') { + this.setStatus('connected'); + } + }; + + // Handle ICE candidates + this.pc.onicecandidate = (event) => { + if (event.candidate) { + this.send({ + t: 'ice', + from: this.peerId!, + to: this.remotePeerId ?? undefined, + candidate: event.candidate.toJSON(), + }); + } + }; + + // Perfect negotiation: handle negotiation needed + this.pc.onnegotiationneeded = async () => { + try { + this.makingOffer = true; + await this.pc!.setLocalDescription(); + this.send({ + t: 'sdp', + from: this.peerId!, + to: this.remotePeerId ?? undefined, + description: this.pc!.localDescription!, + }); + } catch (err) { + this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err))); + } finally { + this.makingOffer = false; + } + }; + + this.pc.oniceconnectionstatechange = () => { + if (this.pc?.iceConnectionState === 'disconnected') { + this.setStatus('signaling'); + } else if (this.pc?.iceConnectionState === 'connected') { + this.setStatus('connected'); + } + }; + } + + private async createOffer(): Promise { + if (!this.pc) return; + + try { + this.makingOffer = true; + const offer = await this.pc.createOffer(); + await this.pc.setLocalDescription(offer); + + this.send({ + t: 'sdp', + from: this.peerId!, + to: this.remotePeerId ?? undefined, + description: this.pc.localDescription!, + }); + } finally { + this.makingOffer = false; + } + } + + private async handleRemoteSDP(description: RTCSessionDescriptionInit): Promise { + if (!this.pc) { + await this.createPeerConnection(); + } + + const pc = this.pc!; + const isOffer = description.type === 'offer'; + + // Perfect negotiation: collision detection + const offerCollision = isOffer && (this.makingOffer || pc.signalingState !== 'stable'); + + this.ignoreOffer = !this.polite && offerCollision; + if (this.ignoreOffer) return; + + await pc.setRemoteDescription(description); + this.hasRemoteDescription = true; + + // Flush buffered ICE candidates now that remote description is set + for (const candidate of this.pendingCandidates) { + try { + await pc.addIceCandidate(candidate); + } catch (err) { + // Ignore errors for candidates that arrived during collision + if (!this.ignoreOffer) { + console.warn('Failed to add buffered ICE candidate:', err); + } + } + } + this.pendingCandidates = []; + + if (isOffer) { + await pc.setLocalDescription(); + this.send({ + t: 'sdp', + from: this.peerId!, + to: this.remotePeerId ?? undefined, + description: pc.localDescription!, + }); + } + } + + private async handleRemoteICE(candidate: RTCIceCandidateInit): Promise { + // Buffer candidates until peer connection AND remote description are ready + if (!this.pc || !this.hasRemoteDescription) { + this.pendingCandidates.push(candidate); + return; + } + + try { + await this.pc.addIceCandidate(candidate); + } catch (err) { + if (!this.ignoreOffer) { + // Log but don't propagate - some ICE failures are normal + console.warn('Failed to add ICE candidate:', err); + } + } + } + + private closePeerConnection(): void { + if (this.pc) { + this.pc.close(); + this.pc = null; + } + this.remoteStream = null; + this.pendingCandidates = []; + this.makingOffer = false; + this.ignoreOffer = false; + this.hasRemoteDescription = false; + } + + /** + * End the call and disconnect + */ + hangup(): void { + this.closePeerConnection(); + + if (this.localStream) { + for (const track of this.localStream.getTracks()) { + track.stop(); + } + this.localStream = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.peerId = null; + this.remotePeerId = null; + this.setStatus('disconnected'); + } + + /** + * Mute or unmute audio + */ + muteAudio(muted: boolean): void { + if (this.localStream) { + for (const track of this.localStream.getAudioTracks()) { + track.enabled = !muted; + } + } + this.isAudioMuted = muted; + } + + /** + * Mute or unmute video + */ + muteVideo(muted: boolean): void { + if (this.localStream) { + for (const track of this.localStream.getVideoTracks()) { + track.enabled = !muted; + } + } + this.isVideoMuted = muted; + } + + /** + * Clean up all resources + */ + dispose(): void { + this.hangup(); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 28b26ba5..e6c2babc 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -30,6 +30,12 @@ export { type SSERouteOutput, type EventStreamOptions, } from './eventstream'; +export { + useWebRTCCall, + type UseWebRTCCallOptions, + type UseWebRTCCallResult, + type WebRTCStatus, +} from './webrtc'; export { useAPI, type RouteKey, @@ -65,6 +71,10 @@ export { type EventStreamCallbacks, type EventStreamManagerOptions, type EventStreamManagerState, + WebRTCManager, + type WebRTCCallbacks, + type WebRTCManagerOptions, + type WebRTCManagerState, // Client type exports (createClient is exported from ./client.ts) type Client, type ClientOptions, diff --git a/packages/react/src/webrtc.tsx b/packages/react/src/webrtc.tsx new file mode 100644 index 00000000..5cec7c5f --- /dev/null +++ b/packages/react/src/webrtc.tsx @@ -0,0 +1,213 @@ +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { + WebRTCManager, + buildUrl, + type WebRTCStatus, + type WebRTCManagerOptions, +} from '@agentuity/frontend'; +import { AgentuityContext } from './context'; + +export type { WebRTCStatus }; + +/** + * Options for useWebRTCCall hook + */ +export interface UseWebRTCCallOptions { + /** Room ID to join */ + roomId: string; + /** WebSocket signaling URL (e.g., '/call/signal' or full URL) */ + signalUrl: string; + /** Whether this peer is "polite" in perfect negotiation (default: true for first joiner) */ + polite?: boolean; + /** ICE servers configuration */ + iceServers?: RTCIceServer[]; + /** Media constraints for getUserMedia */ + media?: MediaStreamConstraints; + /** Whether to auto-connect on mount (default: true) */ + autoConnect?: boolean; +} + +/** + * Return type for useWebRTCCall hook + */ +export interface UseWebRTCCallResult { + /** Ref to attach to local video element */ + localVideoRef: React.RefObject; + /** Ref to attach to remote video element */ + remoteVideoRef: React.RefObject; + /** Current connection status */ + status: WebRTCStatus; + /** Current error if any */ + error: Error | null; + /** Local peer ID assigned by server */ + peerId: string | null; + /** Remote peer ID */ + remotePeerId: string | null; + /** Whether audio is muted */ + isAudioMuted: boolean; + /** Whether video is muted */ + isVideoMuted: boolean; + /** Manually start the connection (if autoConnect is false) */ + connect: () => void; + /** End the call */ + hangup: () => void; + /** Mute or unmute audio */ + muteAudio: (muted: boolean) => void; + /** Mute or unmute video */ + muteVideo: (muted: boolean) => void; +} + +/** + * React hook for WebRTC peer-to-peer audio/video calls. + * + * Handles WebRTC signaling, media capture, and peer connection management. + * + * @example + * ```tsx + * function VideoCall({ roomId }: { roomId: string }) { + * const { + * localVideoRef, + * remoteVideoRef, + * status, + * hangup, + * muteAudio, + * isAudioMuted, + * } = useWebRTCCall({ + * roomId, + * signalUrl: '/call/signal', + * }); + * + * return ( + *
+ *
+ * ); + * } + * ``` + */ +export function useWebRTCCall(options: UseWebRTCCallOptions): UseWebRTCCallResult { + const context = useContext(AgentuityContext); + + const managerRef = useRef(null); + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + + const [status, setStatus] = useState('disconnected'); + const [error, setError] = useState(null); + const [peerId, setPeerId] = useState(null); + const [remotePeerId, setRemotePeerId] = useState(null); + const [isAudioMuted, setIsAudioMuted] = useState(false); + const [isVideoMuted, setIsVideoMuted] = useState(false); + + // Build full signaling URL + const signalUrl = useMemo(() => { + // If it's already a full URL, use as-is + if (options.signalUrl.startsWith('ws://') || options.signalUrl.startsWith('wss://')) { + return options.signalUrl; + } + + // Build from context base URL + const base = context?.baseUrl ?? window.location.origin; + const wsBase = base.replace(/^http(s?):/, 'ws$1:'); + return buildUrl(wsBase, options.signalUrl); + }, [context?.baseUrl, options.signalUrl]); + + // Create manager options - use refs to avoid recreating manager on state changes + const managerOptions = useMemo((): WebRTCManagerOptions => { + return { + signalUrl, + roomId: options.roomId, + polite: options.polite, + iceServers: options.iceServers, + media: options.media, + callbacks: { + onLocalStream: (stream) => { + if (localVideoRef.current) { + localVideoRef.current.srcObject = stream; + } + }, + onRemoteStream: (stream) => { + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = stream; + } + }, + onStatusChange: (newStatus) => { + setStatus(newStatus); + if (managerRef.current) { + const state = managerRef.current.getState(); + setPeerId(state.peerId); + setRemotePeerId(state.remotePeerId); + } + }, + onError: (err) => { + setError(err); + }, + onPeerJoined: (id) => { + setRemotePeerId(id); + }, + onPeerLeft: (id) => { + setRemotePeerId((current) => current === id ? null : current); + }, + }, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signalUrl, options.roomId, options.polite, options.iceServers, options.media]); + + // Initialize manager + useEffect(() => { + const manager = new WebRTCManager(managerOptions); + managerRef.current = manager; + + // Auto-connect if enabled (default: true) + if (options.autoConnect !== false) { + manager.connect(); + } + + return () => { + manager.dispose(); + managerRef.current = null; + }; + }, [managerOptions, options.autoConnect]); + + const connect = useCallback(() => { + managerRef.current?.connect(); + }, []); + + const hangup = useCallback(() => { + managerRef.current?.hangup(); + setStatus('disconnected'); + setPeerId(null); + setRemotePeerId(null); + }, []); + + const muteAudio = useCallback((muted: boolean) => { + managerRef.current?.muteAudio(muted); + setIsAudioMuted(muted); + }, []); + + const muteVideo = useCallback((muted: boolean) => { + managerRef.current?.muteVideo(muted); + setIsVideoMuted(muted); + }, []); + + return { + localVideoRef, + remoteVideoRef, + status, + error, + peerId, + remotePeerId, + isAudioMuted, + isVideoMuted, + connect, + hangup, + muteAudio, + muteVideo, + }; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 14a4737f..bbeea41b 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -59,6 +59,15 @@ export { registerDevModeRoutes } from './devmode'; // router.ts exports export { type HonoEnv, type WebSocketConnection, createRouter } from './router'; +// webrtc-signaling.ts exports +export { + type SignalMsg, + type SDPDescription, + type ICECandidate, + type WebRTCOptions, + WebRTCRoomManager, +} from './webrtc-signaling'; + // eval.ts exports export { type EvalContext, diff --git a/packages/runtime/src/router.ts b/packages/runtime/src/router.ts index 011cf539..af1e3b92 100644 --- a/packages/runtime/src/router.ts +++ b/packages/runtime/src/router.ts @@ -14,6 +14,7 @@ import { hash, returnResponse } from './_util'; import type { Env } from './app'; import { getAgentAsyncLocalStorage } from './_context'; import { parseEmail, type Email } from './io/email'; +import { WebRTCRoomManager, type WebRTCOptions } from './webrtc-signaling'; // Re-export both Env types export type { Env }; @@ -274,6 +275,28 @@ declare module 'hono' { middleware: MiddlewareHandler, handler: (c: Context) => (stream: any) => void ): this; + + /** + * Create a WebRTC signaling endpoint for peer-to-peer audio/video communication. + * + * Registers a WebSocket signaling route at `${path}/signal` that handles: + * - Room membership and peer discovery + * - SDP offer/answer relay + * - ICE candidate relay + * + * @param path - The base route path (e.g., '/call') + * @param options - Optional configuration for the WebRTC endpoint + * + * @example + * ```typescript + * // Create a WebRTC signaling endpoint at /call/signal + * router.webrtc('/call'); + * + * // With options + * router.webrtc('/call', { maxPeers: 2 }); + * ``` + */ + webrtc(path: string, options?: WebRTCOptions): this; } } @@ -284,6 +307,7 @@ declare module 'hono' { * - **stream()** - Stream responses with ReadableStream * - **websocket()** - WebSocket connections * - **sse()** - Server-Sent Events + * - **webrtc()** - WebRTC signaling for peer-to-peer communication * - **email()** - Email handler routing * - **sms()** - SMS handler routing * - **cron()** - Scheduled task routing @@ -715,5 +739,41 @@ export const createRouter = (): } }; + _router.webrtc = (path: string, options?: WebRTCOptions) => { + const roomManager = new WebRTCRoomManager(options); + const signalPath = `${path}/signal`; + + // Use the existing websocket implementation for the signaling route + const wrapper = upgradeWebSocket((_c: Context) => { + let currentWs: WebSocketConnection | undefined; + + return { + onOpen: (_event: any, ws: any) => { + currentWs = { + onOpen: () => {}, + onMessage: () => {}, + onClose: () => {}, + send: (data) => ws.send(data), + }; + }, + onMessage: (event: any, _ws: any) => { + if (currentWs) { + roomManager.handleMessage(currentWs, String(event.data)); + } + }, + onClose: (_event: any, _ws: any) => { + if (currentWs) { + roomManager.handleDisconnect(currentWs); + } + }, + }; + }); + + const wsMiddleware: MiddlewareHandler = (c, next) => + (wrapper as unknown as MiddlewareHandler)(c, next); + + return router.get(signalPath, wsMiddleware); + }; + return router; }; diff --git a/packages/runtime/src/webrtc-signaling.ts b/packages/runtime/src/webrtc-signaling.ts new file mode 100644 index 00000000..b4b708f5 --- /dev/null +++ b/packages/runtime/src/webrtc-signaling.ts @@ -0,0 +1,226 @@ +import type { WebSocketConnection } from './router'; + +// WebRTC types for signaling (not using DOM types since this runs on server) +export interface SDPDescription { + type: 'offer' | 'answer' | 'pranswer' | 'rollback'; + sdp?: string; +} + +export interface ICECandidate { + candidate?: string; + sdpMid?: string | null; + sdpMLineIndex?: number | null; + usernameFragment?: string | null; +} + +// Signaling message protocol +export type SignalMsg = + | { t: 'join'; roomId: string } + | { t: 'joined'; peerId: string; roomId: string; peers: string[] } + | { t: 'peer-joined'; peerId: string } + | { t: 'peer-left'; peerId: string } + | { t: 'sdp'; from: string; to?: string; description: SDPDescription } + | { t: 'ice'; from: string; to?: string; candidate: ICECandidate } + | { t: 'error'; message: string }; + +export interface WebRTCOptions { + /** Maximum number of peers per room (default: 2) */ + maxPeers?: number; +} + +interface PeerConnection { + ws: WebSocketConnection; + roomId: string; +} + +/** + * In-memory room manager for WebRTC signaling. + * Tracks rooms and their connected peers. + */ +export class WebRTCRoomManager { + // roomId -> Map + private rooms = new Map>(); + // ws -> peerId (reverse lookup for cleanup) + private wsToPeer = new Map(); + private maxPeers: number; + private peerIdCounter = 0; + + constructor(options?: WebRTCOptions) { + this.maxPeers = options?.maxPeers ?? 2; + } + + private generatePeerId(): string { + return `peer-${Date.now()}-${++this.peerIdCounter}`; + } + + private send(ws: WebSocketConnection, msg: SignalMsg): void { + ws.send(JSON.stringify(msg)); + } + + private broadcast(roomId: string, msg: SignalMsg, excludePeerId?: string): void { + const room = this.rooms.get(roomId); + if (!room) return; + + for (const [peerId, peer] of room) { + if (peerId !== excludePeerId) { + this.send(peer.ws, msg); + } + } + } + + /** + * Handle a peer joining a room + */ + handleJoin(ws: WebSocketConnection, roomId: string): void { + let room = this.rooms.get(roomId); + + // Create room if it doesn't exist + if (!room) { + room = new Map(); + this.rooms.set(roomId, room); + } + + // Check room capacity + if (room.size >= this.maxPeers) { + this.send(ws, { t: 'error', message: `Room is full (max ${this.maxPeers} peers)` }); + return; + } + + const peerId = this.generatePeerId(); + const existingPeers = Array.from(room.keys()); + + // Add peer to room + room.set(peerId, { ws, roomId }); + this.wsToPeer.set(ws, { peerId, roomId }); + + // Send joined confirmation with list of existing peers + this.send(ws, { t: 'joined', peerId, roomId, peers: existingPeers }); + + // Notify existing peers about new peer + this.broadcast(roomId, { t: 'peer-joined', peerId }, peerId); + } + + /** + * Handle a peer disconnecting + */ + handleDisconnect(ws: WebSocketConnection): void { + const peerInfo = this.wsToPeer.get(ws); + if (!peerInfo) return; + + const { peerId, roomId } = peerInfo; + const room = this.rooms.get(roomId); + + if (room) { + room.delete(peerId); + + // Notify remaining peers + this.broadcast(roomId, { t: 'peer-left', peerId }); + + // Clean up empty room + if (room.size === 0) { + this.rooms.delete(roomId); + } + } + + this.wsToPeer.delete(ws); + } + + /** + * Relay SDP message to target peer(s) + */ + handleSDP(ws: WebSocketConnection, to: string | undefined, description: SDPDescription): void { + const peerInfo = this.wsToPeer.get(ws); + if (!peerInfo) { + this.send(ws, { t: 'error', message: 'Not in a room' }); + return; + } + + const { peerId, roomId } = peerInfo; + const room = this.rooms.get(roomId); + if (!room) return; + + // Server injects 'from' to prevent spoofing + const msg: SignalMsg = { t: 'sdp', from: peerId, description }; + + if (to) { + // Send to specific peer + const targetPeer = room.get(to); + if (targetPeer) { + this.send(targetPeer.ws, msg); + } + } else { + // Broadcast to all peers in room + this.broadcast(roomId, msg, peerId); + } + } + + /** + * Relay ICE candidate to target peer(s) + */ + handleICE(ws: WebSocketConnection, to: string | undefined, candidate: ICECandidate): void { + const peerInfo = this.wsToPeer.get(ws); + if (!peerInfo) { + this.send(ws, { t: 'error', message: 'Not in a room' }); + return; + } + + const { peerId, roomId } = peerInfo; + const room = this.rooms.get(roomId); + if (!room) return; + + // Server injects 'from' to prevent spoofing + const msg: SignalMsg = { t: 'ice', from: peerId, candidate }; + + if (to) { + // Send to specific peer + const targetPeer = room.get(to); + if (targetPeer) { + this.send(targetPeer.ws, msg); + } + } else { + // Broadcast to all peers in room + this.broadcast(roomId, msg, peerId); + } + } + + /** + * Handle incoming signaling message + */ + handleMessage(ws: WebSocketConnection, data: string): void { + let msg: SignalMsg; + try { + msg = JSON.parse(data); + } catch { + this.send(ws, { t: 'error', message: 'Invalid JSON' }); + return; + } + + switch (msg.t) { + case 'join': + this.handleJoin(ws, msg.roomId); + break; + case 'sdp': + this.handleSDP(ws, msg.to, msg.description); + break; + case 'ice': + this.handleICE(ws, msg.to, msg.candidate); + break; + default: + this.send(ws, { + t: 'error', + message: `Unknown message type: ${(msg as { t: string }).t}`, + }); + } + } + + /** + * Get room stats for debugging + */ + getRoomStats(): { roomCount: number; totalPeers: number } { + let totalPeers = 0; + for (const room of this.rooms.values()) { + totalPeers += room.size; + } + return { roomCount: this.rooms.size, totalPeers }; + } +} diff --git a/packages/runtime/test/webrtc-signaling.test.ts b/packages/runtime/test/webrtc-signaling.test.ts new file mode 100644 index 00000000..2c96bf45 --- /dev/null +++ b/packages/runtime/test/webrtc-signaling.test.ts @@ -0,0 +1,324 @@ +import { describe, test, expect, beforeEach } from 'bun:test'; +import { + WebRTCRoomManager, + type SignalMsg, + type SDPDescription, + type ICECandidate, +} from '../src/webrtc-signaling'; +import type { WebSocketConnection } from '../src/router'; + +// Mock WebSocket connection +function createMockWs(): WebSocketConnection & { messages: string[] } { + const messages: string[] = []; + return { + messages, + onOpen: () => {}, + onMessage: () => {}, + onClose: () => {}, + send: (data: string | ArrayBuffer | Uint8Array) => { + messages.push(typeof data === 'string' ? data : data.toString()); + }, + }; +} + +function parseMessage(ws: { messages: string[] }, index = -1): SignalMsg { + const idx = index < 0 ? ws.messages.length + index : index; + return JSON.parse(ws.messages[idx]); +} + +describe('WebRTCRoomManager', () => { + let roomManager: WebRTCRoomManager; + + beforeEach(() => { + roomManager = new WebRTCRoomManager({ maxPeers: 2 }); + }); + + describe('handleJoin', () => { + test('should assign peerId and send joined message', () => { + const ws = createMockWs(); + roomManager.handleJoin(ws, 'room-1'); + + expect(ws.messages.length).toBe(1); + const msg = parseMessage(ws); + expect(msg.t).toBe('joined'); + if (msg.t === 'joined') { + expect(msg.peerId).toMatch(/^peer-/); + expect(msg.roomId).toBe('room-1'); + expect(msg.peers).toEqual([]); + } + }); + + test('should include existing peers in joined message', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + + roomManager.handleJoin(ws1, 'room-1'); + const msg1 = parseMessage(ws1); + const peer1Id = msg1.t === 'joined' ? msg1.peerId : ''; + + roomManager.handleJoin(ws2, 'room-1'); + const msg2 = parseMessage(ws2); + + expect(msg2.t).toBe('joined'); + if (msg2.t === 'joined') { + expect(msg2.peers).toContain(peer1Id); + } + }); + + test('should notify existing peers when new peer joins', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + + roomManager.handleJoin(ws1, 'room-1'); + roomManager.handleJoin(ws2, 'room-1'); + + // ws1 should receive peer-joined notification + expect(ws1.messages.length).toBe(2); + const notification = parseMessage(ws1); + expect(notification.t).toBe('peer-joined'); + }); + + test('should reject peer when room is full', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + const ws3 = createMockWs(); + + roomManager.handleJoin(ws1, 'room-1'); + roomManager.handleJoin(ws2, 'room-1'); + roomManager.handleJoin(ws3, 'room-1'); + + const msg = parseMessage(ws3); + expect(msg.t).toBe('error'); + if (msg.t === 'error') { + expect(msg.message).toContain('full'); + } + }); + + test('should allow joining different rooms', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + const ws3 = createMockWs(); + + roomManager.handleJoin(ws1, 'room-1'); + roomManager.handleJoin(ws2, 'room-1'); + roomManager.handleJoin(ws3, 'room-2'); + + // ws3 should successfully join room-2 + const msg = parseMessage(ws3); + expect(msg.t).toBe('joined'); + }); + }); + + describe('handleDisconnect', () => { + test('should remove peer from room and notify others', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + + roomManager.handleJoin(ws1, 'room-1'); + const msg1 = parseMessage(ws1); + const peer1Id = msg1.t === 'joined' ? msg1.peerId : ''; + + roomManager.handleJoin(ws2, 'room-1'); + ws1.messages.length = 0; // Clear in-place + + roomManager.handleDisconnect(ws1); + + // ws2 should receive peer-left notification + const notification = parseMessage(ws2); + expect(notification.t).toBe('peer-left'); + if (notification.t === 'peer-left') { + expect(notification.peerId).toBe(peer1Id); + } + }); + + test('should allow new peer after disconnect', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + const ws3 = createMockWs(); + + roomManager.handleJoin(ws1, 'room-1'); + roomManager.handleJoin(ws2, 'room-1'); + roomManager.handleDisconnect(ws1); + roomManager.handleJoin(ws3, 'room-1'); + + // ws3 should successfully join + const msg = parseMessage(ws3); + expect(msg.t).toBe('joined'); + }); + + test('should clean up empty rooms', () => { + const ws1 = createMockWs(); + roomManager.handleJoin(ws1, 'room-1'); + roomManager.handleDisconnect(ws1); + + const stats = roomManager.getRoomStats(); + expect(stats.roomCount).toBe(0); + expect(stats.totalPeers).toBe(0); + }); + }); + + describe('handleSDP', () => { + test('should relay SDP to target peer', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + + roomManager.handleJoin(ws1, 'room-1'); + roomManager.handleJoin(ws2, 'room-1'); + + const msg2 = parseMessage(ws2); + const peer2Id = msg2.t === 'joined' ? msg2.peerId : ''; + + ws2.messages.length = 0; // Clear in-place + + const sdp: SDPDescription = { type: 'offer', sdp: 'test-sdp' }; + roomManager.handleSDP(ws1, peer2Id, sdp); + + const relayed = parseMessage(ws2); + expect(relayed.t).toBe('sdp'); + if (relayed.t === 'sdp') { + expect(relayed.description).toEqual(sdp); + expect(relayed.from).toMatch(/^peer-/); // Server-injected from + } + }); + + test('should broadcast SDP to all peers if no target', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + + roomManager.handleJoin(ws1, 'room-1'); + roomManager.handleJoin(ws2, 'room-1'); + + ws2.messages.length = 0; // Clear in-place + + const sdp: SDPDescription = { type: 'offer', sdp: 'test-sdp' }; + roomManager.handleSDP(ws1, undefined, sdp); + + const relayed = parseMessage(ws2); + expect(relayed.t).toBe('sdp'); + }); + + test('should return error if not in a room', () => { + const ws = createMockWs(); + const sdp: SDPDescription = { type: 'offer', sdp: 'test-sdp' }; + roomManager.handleSDP(ws, undefined, sdp); + + const msg = parseMessage(ws); + expect(msg.t).toBe('error'); + }); + }); + + describe('handleICE', () => { + test('should relay ICE candidate to target peer', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + + roomManager.handleJoin(ws1, 'room-1'); + roomManager.handleJoin(ws2, 'room-1'); + + const msg2 = parseMessage(ws2); + const peer2Id = msg2.t === 'joined' ? msg2.peerId : ''; + + ws2.messages.length = 0; // Clear in-place + + const candidate: ICECandidate = { candidate: 'test-candidate', sdpMid: '0' }; + roomManager.handleICE(ws1, peer2Id, candidate); + + const relayed = parseMessage(ws2); + expect(relayed.t).toBe('ice'); + if (relayed.t === 'ice') { + expect(relayed.candidate).toEqual(candidate); + expect(relayed.from).toMatch(/^peer-/); + } + }); + }); + + describe('handleMessage', () => { + test('should parse and route join messages', () => { + const ws = createMockWs(); + roomManager.handleMessage(ws, JSON.stringify({ t: 'join', roomId: 'room-1' })); + + const msg = parseMessage(ws); + expect(msg.t).toBe('joined'); + }); + + test('should parse and route sdp messages', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + + roomManager.handleJoin(ws1, 'room-1'); + roomManager.handleJoin(ws2, 'room-1'); + + ws2.messages.length = 0; // Clear in-place + + const sdpMsg = { + t: 'sdp', + from: 'ignored', // Server should override this + description: { type: 'offer', sdp: 'test' }, + }; + roomManager.handleMessage(ws1, JSON.stringify(sdpMsg)); + + const relayed = parseMessage(ws2); + expect(relayed.t).toBe('sdp'); + }); + + test('should return error for invalid JSON', () => { + const ws = createMockWs(); + roomManager.handleMessage(ws, 'not-json'); + + const msg = parseMessage(ws); + expect(msg.t).toBe('error'); + if (msg.t === 'error') { + expect(msg.message).toContain('Invalid JSON'); + } + }); + + test('should return error for unknown message type', () => { + const ws = createMockWs(); + roomManager.handleMessage(ws, JSON.stringify({ t: 'unknown' })); + + const msg = parseMessage(ws); + expect(msg.t).toBe('error'); + if (msg.t === 'error') { + expect(msg.message).toContain('Unknown message type'); + } + }); + }); + + describe('getRoomStats', () => { + test('should return correct room and peer counts', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + const ws3 = createMockWs(); + + roomManager.handleJoin(ws1, 'room-1'); + roomManager.handleJoin(ws2, 'room-1'); + roomManager.handleJoin(ws3, 'room-2'); + + const stats = roomManager.getRoomStats(); + expect(stats.roomCount).toBe(2); + expect(stats.totalPeers).toBe(3); + }); + }); + + describe('maxPeers configuration', () => { + test('should respect custom maxPeers limit', () => { + const manager = new WebRTCRoomManager({ maxPeers: 3 }); + const ws1 = createMockWs(); + const ws2 = createMockWs(); + const ws3 = createMockWs(); + const ws4 = createMockWs(); + + manager.handleJoin(ws1, 'room-1'); + manager.handleJoin(ws2, 'room-1'); + manager.handleJoin(ws3, 'room-1'); + manager.handleJoin(ws4, 'room-1'); + + // ws4 should be rejected + const msg = parseMessage(ws4); + expect(msg.t).toBe('error'); + + const stats = manager.getRoomStats(); + expect(stats.totalPeers).toBe(3); + }); + }); +}); From 26f854e0ae348406917e33cd4fd380fc025d49c0 Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Sun, 21 Dec 2025 11:34:34 -0500 Subject: [PATCH 2/3] fix: remove undefined eslint rule reference --- packages/react/src/webrtc.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/webrtc.tsx b/packages/react/src/webrtc.tsx index 5cec7c5f..52195824 100644 --- a/packages/react/src/webrtc.tsx +++ b/packages/react/src/webrtc.tsx @@ -156,7 +156,7 @@ export function useWebRTCCall(options: UseWebRTCCallOptions): UseWebRTCCallResul }, }, }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line }, [signalUrl, options.roomId, options.polite, options.iceServers, options.media]); // Initialize manager From 719fa438b86793bf45acaa54c0f069e30dd57458 Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Sun, 21 Dec 2025 14:35:13 -0500 Subject: [PATCH 3/3] feat(webrtc): add state machine and SDK callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add shared WebRTC types to @agentuity/core (SignalMessage, SDPDescription, ICECandidate, WebRTCConnectionState, WebRTCDisconnectReason, WebRTCSignalingCallbacks) - Refactor frontend WebRTCManager with explicit state machine (idle → connecting → signaling → negotiating → connected) - Add WebRTCClientCallbacks interface for SDK users to hook into all lifecycle events - Add WebRTCSignalingCallbacks to backend for room/peer event monitoring - Update React hook to expose new state and forward user callbacks - Add 7 new tests for backend callback functionality BREAKING CHANGE: WebRTCManagerState now has 'state' property (WebRTCConnectionState). 'status' is deprecated but still available for backwards compatibility. Amp-Thread-ID: https://ampcode.com/threads/T-019b4261-8e29-754e-a659-41f5d637485c Co-authored-by: Amp --- packages/core/src/index.ts | 11 + packages/core/src/webrtc.ts | 132 ++++++ packages/frontend/src/index.ts | 4 + packages/frontend/src/webrtc-manager.ts | 271 +++++++++++-- packages/react/src/index.ts | 3 + packages/react/src/webrtc.tsx | 381 ++++++++++-------- packages/runtime/src/index.ts | 2 + packages/runtime/src/webrtc-signaling.ts | 103 +++-- .../runtime/test/webrtc-signaling.test.ts | 113 ++++++ 9 files changed, 787 insertions(+), 233 deletions(-) create mode 100644 packages/core/src/webrtc.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0e5f40d5..58c7d4ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -91,4 +91,15 @@ export { type WorkbenchConfig, } from './workbench-config'; +// webrtc.ts exports +export type { + SDPDescription, + ICECandidate, + SignalMessage, + SignalMsg, + WebRTCConnectionState, + WebRTCDisconnectReason, + WebRTCSignalingCallbacks, +} from './webrtc'; + // Client code moved to @agentuity/frontend for better bundler compatibility diff --git a/packages/core/src/webrtc.ts b/packages/core/src/webrtc.ts new file mode 100644 index 00000000..7cca51a1 --- /dev/null +++ b/packages/core/src/webrtc.ts @@ -0,0 +1,132 @@ +/** + * WebRTC signaling types shared between server and client. + */ + +// ============================================================================= +// Signaling Protocol Types +// ============================================================================= + +/** + * SDP (Session Description Protocol) description for WebRTC negotiation. + */ +export interface SDPDescription { + type: 'offer' | 'answer' | 'pranswer' | 'rollback'; + sdp?: string; +} + +/** + * ICE (Interactive Connectivity Establishment) candidate for NAT traversal. + */ +export interface ICECandidate { + candidate?: string; + sdpMid?: string | null; + sdpMLineIndex?: number | null; + usernameFragment?: string | null; +} + +/** + * Signaling message protocol for WebRTC peer communication. + * + * Message types: + * - `join`: Client requests to join a room + * - `joined`: Server confirms join with peer ID and existing peers + * - `peer-joined`: Server notifies when another peer joins the room + * - `peer-left`: Server notifies when a peer leaves the room + * - `sdp`: SDP offer/answer exchange between peers + * - `ice`: ICE candidate exchange between peers + * - `error`: Error message from server + */ +export type SignalMessage = + | { t: 'join'; roomId: string } + | { t: 'joined'; peerId: string; roomId: string; peers: string[] } + | { t: 'peer-joined'; peerId: string } + | { t: 'peer-left'; peerId: string } + | { t: 'sdp'; from: string; to?: string; description: SDPDescription } + | { t: 'ice'; from: string; to?: string; candidate: ICECandidate } + | { t: 'error'; message: string }; + +/** + * @deprecated Use `SignalMessage` instead. Alias for backwards compatibility. + */ +export type SignalMsg = SignalMessage; + +// ============================================================================= +// Frontend State Machine Types +// ============================================================================= + +/** + * WebRTC connection states for the frontend state machine. + * + * State transitions: + * - idle → connecting: connect() called + * - connecting → signaling: WebSocket opened, joined room + * - connecting → idle: error or cancel + * - signaling → negotiating: peer joined, SDP exchange started + * - signaling → idle: hangup or WebSocket closed + * - negotiating → connected: ICE complete, media flowing + * - negotiating → signaling: peer left during negotiation + * - negotiating → idle: error or hangup + * - connected → negotiating: renegotiation needed + * - connected → signaling: peer left + * - connected → idle: hangup or WebSocket closed + */ +export type WebRTCConnectionState = 'idle' | 'connecting' | 'signaling' | 'negotiating' | 'connected'; + +/** + * Reasons for disconnection. + */ +export type WebRTCDisconnectReason = 'hangup' | 'error' | 'peer-left' | 'timeout'; + +// ============================================================================= +// Backend Signaling Callbacks +// ============================================================================= + +/** + * Callbacks for WebRTC signaling server events. + * All callbacks are optional - only subscribe to events you care about. + */ +export interface WebRTCSignalingCallbacks { + /** + * Called when a new room is created. + * @param roomId - The room ID + */ + onRoomCreated?: (roomId: string) => void; + + /** + * Called when a room is destroyed (last peer left). + * @param roomId - The room ID + */ + onRoomDestroyed?: (roomId: string) => void; + + /** + * Called when a peer joins a room. + * @param peerId - The peer's ID + * @param roomId - The room ID + */ + onPeerJoin?: (peerId: string, roomId: string) => void; + + /** + * Called when a peer leaves a room. + * @param peerId - The peer's ID + * @param roomId - The room ID + * @param reason - Why the peer left + */ + onPeerLeave?: (peerId: string, roomId: string, reason: 'disconnect' | 'kicked') => void; + + /** + * Called when a signaling message is relayed. + * @param type - Message type ('sdp' or 'ice') + * @param from - Sender peer ID + * @param to - Target peer ID (undefined for broadcast) + * @param roomId - The room ID + */ + onMessage?: (type: 'sdp' | 'ice', from: string, to: string | undefined, roomId: string) => void; + + /** + * Called when an error occurs. + * @param error - The error that occurred + * @param peerId - The peer ID if applicable + * @param roomId - The room ID if applicable + */ + onError?: (error: Error, peerId?: string, roomId?: string) => void; +} diff --git a/packages/frontend/src/index.ts b/packages/frontend/src/index.ts index 7a64a187..178713e7 100644 --- a/packages/frontend/src/index.ts +++ b/packages/frontend/src/index.ts @@ -24,8 +24,12 @@ export { type WebRTCCallbacks, type WebRTCManagerOptions, type WebRTCManagerState, + type WebRTCClientCallbacks, } from './webrtc-manager'; +// Re-export core WebRTC types for convenience +export type { WebRTCConnectionState, WebRTCDisconnectReason } from '@agentuity/core'; + // Export client implementation (local to this package) export { createClient } from './client/index'; export type { diff --git a/packages/frontend/src/webrtc-manager.ts b/packages/frontend/src/webrtc-manager.ts index e5f28ddf..388156fa 100644 --- a/packages/frontend/src/webrtc-manager.ts +++ b/packages/frontend/src/webrtc-manager.ts @@ -1,22 +1,107 @@ +import type { + SignalMessage, + WebRTCConnectionState, + WebRTCDisconnectReason, +} from '@agentuity/core'; + /** - * WebRTC connection status + * Callbacks for WebRTC client state changes and events. + * All callbacks are optional - only subscribe to events you care about. */ -export type WebRTCStatus = 'disconnected' | 'connecting' | 'signaling' | 'connected'; +export interface WebRTCClientCallbacks { + /** + * Called on every state transition. + * @param from - Previous state + * @param to - New state + * @param reason - Optional reason for the transition + */ + onStateChange?: (from: WebRTCConnectionState, to: WebRTCConnectionState, reason?: string) => void; + + /** + * Called when connection is fully established. + */ + onConnect?: () => void; + + /** + * Called when disconnected from the call. + * @param reason - Why the disconnection happened + */ + onDisconnect?: (reason: WebRTCDisconnectReason) => void; + + /** + * Called when local media stream is acquired. + * @param stream - The local MediaStream + */ + onLocalStream?: (stream: MediaStream) => void; + + /** + * Called when remote media stream is received. + * @param stream - The remote MediaStream + */ + onRemoteStream?: (stream: MediaStream) => void; + + /** + * Called when a new track is added to a stream. + * @param track - The added track + * @param stream - The stream containing the track + */ + onTrackAdded?: (track: MediaStreamTrack, stream: MediaStream) => void; + + /** + * Called when a track is removed from a stream. + * @param track - The removed track + */ + onTrackRemoved?: (track: MediaStreamTrack) => void; + + /** + * Called when a peer joins the room. + * @param peerId - The peer's ID + */ + onPeerJoined?: (peerId: string) => void; + + /** + * Called when a peer leaves the room. + * @param peerId - The peer's ID + */ + onPeerLeft?: (peerId: string) => void; + + /** + * Called when SDP/ICE negotiation starts. + */ + onNegotiationStart?: () => void; + + /** + * Called when SDP/ICE negotiation completes successfully. + */ + onNegotiationComplete?: () => void; + + /** + * Called for each ICE candidate generated. + * @param candidate - The ICE candidate + */ + onIceCandidate?: (candidate: RTCIceCandidateInit) => void; + + /** + * Called when ICE connection state changes. + * @param state - The new ICE connection state + */ + onIceStateChange?: (state: string) => void; + + /** + * Called when an error occurs. + * @param error - The error that occurred + * @param state - The state when the error occurred + */ + onError?: (error: Error, state: WebRTCConnectionState) => void; +} /** - * Signaling message types (must match server protocol) + * @deprecated Use `WebRTCConnectionState` from @agentuity/core instead. */ -type SignalMsg = - | { t: 'join'; roomId: string } - | { t: 'joined'; peerId: string; roomId: string; peers: string[] } - | { t: 'peer-joined'; peerId: string } - | { t: 'peer-left'; peerId: string } - | { t: 'sdp'; from: string; to?: string; description: RTCSessionDescriptionInit } - | { t: 'ice'; from: string; to?: string; candidate: RTCIceCandidateInit } - | { t: 'error'; message: string }; +export type WebRTCStatus = 'disconnected' | 'connecting' | 'signaling' | 'connected'; /** - * Callbacks for WebRTC manager state changes + * @deprecated Use `WebRTCClientCallbacks` from @agentuity/core instead. */ export interface WebRTCCallbacks { onLocalStream?: (stream: MediaStream) => void; @@ -41,19 +126,24 @@ export interface WebRTCManagerOptions { iceServers?: RTCIceServer[]; /** Media constraints for getUserMedia */ media?: MediaStreamConstraints; - /** Callbacks for state changes */ - callbacks?: WebRTCCallbacks; + /** + * Callbacks for state changes and events. + * Supports both legacy WebRTCCallbacks and new WebRTCClientCallbacks. + */ + callbacks?: WebRTCClientCallbacks; } /** * WebRTC manager state */ export interface WebRTCManagerState { - status: WebRTCStatus; + state: WebRTCConnectionState; peerId: string | null; remotePeerId: string | null; isAudioMuted: boolean; isVideoMuted: boolean; + /** @deprecated Use `state` instead */ + status: WebRTCStatus; } /** @@ -64,9 +154,45 @@ const DEFAULT_ICE_SERVERS: RTCIceServer[] = [ { urls: 'stun:stun1.l.google.com:19302' }, ]; +/** + * Map new state to legacy status for backwards compatibility + */ +function stateToStatus(state: WebRTCConnectionState): WebRTCStatus { + if (state === 'idle') return 'disconnected'; + if (state === 'negotiating') return 'connecting'; + return state as WebRTCStatus; +} + /** * Framework-agnostic WebRTC connection manager with signaling, * perfect negotiation, and media stream handling. + * + * Uses an explicit state machine for connection lifecycle: + * - idle: No resources allocated, ready to connect + * - connecting: Acquiring media + opening WebSocket + * - signaling: In room, waiting for peer + * - negotiating: SDP/ICE exchange in progress + * - connected: Media flowing + * + * @example + * ```ts + * const manager = new WebRTCManager({ + * signalUrl: 'wss://example.com/call/signal', + * roomId: 'my-room', + * callbacks: { + * onStateChange: (from, to, reason) => { + * console.log(`State: ${from} → ${to}`, reason); + * }, + * onConnect: () => console.log('Connected!'), + * onDisconnect: (reason) => console.log('Disconnected:', reason), + * onLocalStream: (stream) => { localVideo.srcObject = stream; }, + * onRemoteStream: (stream) => { remoteVideo.srcObject = stream; }, + * onError: (error, state) => console.error(`Error in ${state}:`, error), + * }, + * }); + * + * await manager.connect(); + * ``` */ export class WebRTCManager { private ws: WebSocket | null = null; @@ -76,10 +202,12 @@ export class WebRTCManager { private peerId: string | null = null; private remotePeerId: string | null = null; - private status: WebRTCStatus = 'disconnected'; private isAudioMuted = false; private isVideoMuted = false; + // State machine + private _state: WebRTCConnectionState = 'idle'; + // Perfect negotiation state private makingOffer = false; private ignoreOffer = false; @@ -90,7 +218,7 @@ export class WebRTCManager { private hasRemoteDescription = false; private options: WebRTCManagerOptions; - private callbacks: WebRTCCallbacks; + private callbacks: WebRTCClientCallbacks; constructor(options: WebRTCManagerOptions) { this.options = options; @@ -98,12 +226,20 @@ export class WebRTCManager { this.callbacks = options.callbacks ?? {}; } + /** + * Current connection state + */ + get state(): WebRTCConnectionState { + return this._state; + } + /** * Get current manager state */ getState(): WebRTCManagerState { return { - status: this.status, + state: this._state, + status: stateToStatus(this._state), peerId: this.peerId, remotePeerId: this.remotePeerId, isAudioMuted: this.isAudioMuted, @@ -125,12 +261,42 @@ export class WebRTCManager { return this.remoteStream; } - private setStatus(status: WebRTCStatus): void { - this.status = status; - this.callbacks.onStatusChange?.(status); + /** + * Transition to a new state with callback notifications + */ + private setState(newState: WebRTCConnectionState, reason?: string): void { + const prevState = this._state; + if (prevState === newState) return; + + this._state = newState; + + // Fire state change callback + this.callbacks.onStateChange?.(prevState, newState, reason); + + // Fire connect/disconnect callbacks + if (newState === 'connected' && prevState !== 'connected') { + this.callbacks.onConnect?.(); + this.callbacks.onNegotiationComplete?.(); + } + + if (newState === 'idle' && prevState !== 'idle') { + const disconnectReason = this.mapToDisconnectReason(reason); + this.callbacks.onDisconnect?.(disconnectReason); + } + + if (newState === 'negotiating' && prevState !== 'negotiating') { + this.callbacks.onNegotiationStart?.(); + } + } + + private mapToDisconnectReason(reason?: string): WebRTCDisconnectReason { + if (reason === 'hangup') return 'hangup'; + if (reason === 'peer-left') return 'peer-left'; + if (reason === 'timeout') return 'timeout'; + return 'error'; } - private send(msg: SignalMsg): void { + private send(msg: SignalMessage): void { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(msg)); } @@ -140,9 +306,9 @@ export class WebRTCManager { * Connect to the signaling server and start the call */ async connect(): Promise { - if (this.status !== 'disconnected') return; + if (this._state !== 'idle') return; - this.setStatus('connecting'); + this.setState('connecting', 'connect() called'); try { // Get local media @@ -154,31 +320,33 @@ export class WebRTCManager { this.ws = new WebSocket(this.options.signalUrl); this.ws.onopen = () => { - this.setStatus('signaling'); + this.setState('signaling', 'WebSocket opened'); this.send({ t: 'join', roomId: this.options.roomId }); }; this.ws.onmessage = (event) => { - const msg = JSON.parse(event.data) as SignalMsg; + const msg = JSON.parse(event.data) as SignalMessage; this.handleSignalingMessage(msg); }; this.ws.onerror = () => { - this.callbacks.onError?.(new Error('WebSocket connection error')); + const error = new Error('WebSocket connection error'); + this.callbacks.onError?.(error, this._state); }; this.ws.onclose = () => { - if (this.status !== 'disconnected') { - this.setStatus('disconnected'); + if (this._state !== 'idle') { + this.setState('idle', 'WebSocket closed'); } }; } catch (err) { - this.setStatus('disconnected'); - this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err))); + const error = err instanceof Error ? err : new Error(String(err)); + this.callbacks.onError?.(error, this._state); + this.setState('idle', 'error'); } } - private async handleSignalingMessage(msg: SignalMsg): Promise { + private async handleSignalingMessage(msg: SignalMessage): Promise { switch (msg.t) { case 'joined': this.peerId = msg.peerId; @@ -188,6 +356,7 @@ export class WebRTCManager { // Late joiner is impolite (makes the offer, wins collisions) this.polite = this.options.polite ?? false; await this.createPeerConnection(); + this.setState('negotiating', 'creating offer'); await this.createOffer(); } else { // First peer is polite (waits for offers, yields on collision) @@ -207,11 +376,14 @@ export class WebRTCManager { if (msg.peerId === this.remotePeerId) { this.remotePeerId = null; this.closePeerConnection(); - this.setStatus('signaling'); + this.setState('signaling', 'peer-left'); } break; case 'sdp': + if (this._state === 'signaling') { + this.setState('negotiating', 'received SDP'); + } await this.handleRemoteSDP(msg.description); break; @@ -220,7 +392,8 @@ export class WebRTCManager { break; case 'error': - this.callbacks.onError?.(new Error(msg.message)); + const error = new Error(msg.message); + this.callbacks.onError?.(error, this._state); break; } } @@ -235,6 +408,7 @@ export class WebRTCManager { if (this.localStream) { for (const track of this.localStream.getTracks()) { this.pc.addTrack(track, this.localStream); + this.callbacks.onTrackAdded?.(track, this.localStream); } } @@ -259,14 +433,17 @@ export class WebRTCManager { } } - if (this.status !== 'connected') { - this.setStatus('connected'); + this.callbacks.onTrackAdded?.(event.track, this.remoteStream!); + + if (this._state !== 'connected') { + this.setState('connected', 'track received'); } }; // Handle ICE candidates this.pc.onicecandidate = (event) => { if (event.candidate) { + this.callbacks.onIceCandidate?.(event.candidate.toJSON()); this.send({ t: 'ice', from: this.peerId!, @@ -288,17 +465,26 @@ export class WebRTCManager { description: this.pc!.localDescription!, }); } catch (err) { - this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err))); + const error = err instanceof Error ? err : new Error(String(err)); + this.callbacks.onError?.(error, this._state); } finally { this.makingOffer = false; } }; this.pc.oniceconnectionstatechange = () => { - if (this.pc?.iceConnectionState === 'disconnected') { - this.setStatus('signaling'); - } else if (this.pc?.iceConnectionState === 'connected') { - this.setStatus('connected'); + const iceState = this.pc?.iceConnectionState; + if (iceState) { + this.callbacks.onIceStateChange?.(iceState); + } + + if (iceState === 'disconnected') { + this.setState('signaling', 'ICE disconnected'); + } else if (iceState === 'connected') { + this.setState('connected', 'ICE connected'); + } else if (iceState === 'failed') { + const error = new Error('ICE connection failed'); + this.callbacks.onError?.(error, this._state); } }; } @@ -401,6 +587,7 @@ export class WebRTCManager { if (this.localStream) { for (const track of this.localStream.getTracks()) { track.stop(); + this.callbacks.onTrackRemoved?.(track); } this.localStream = null; } @@ -412,7 +599,7 @@ export class WebRTCManager { this.peerId = null; this.remotePeerId = null; - this.setStatus('disconnected'); + this.setState('idle', 'hangup'); } /** diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e6c2babc..b683c1a5 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -35,6 +35,8 @@ export { type UseWebRTCCallOptions, type UseWebRTCCallResult, type WebRTCStatus, + type WebRTCConnectionState, + type WebRTCClientCallbacks, } from './webrtc'; export { useAPI, @@ -75,6 +77,7 @@ export { type WebRTCCallbacks, type WebRTCManagerOptions, type WebRTCManagerState, + type WebRTCDisconnectReason, // Client type exports (createClient is exported from ./client.ts) type Client, type ClientOptions, diff --git a/packages/react/src/webrtc.tsx b/packages/react/src/webrtc.tsx index 52195824..dc5d80b2 100644 --- a/packages/react/src/webrtc.tsx +++ b/packages/react/src/webrtc.tsx @@ -1,60 +1,80 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { - WebRTCManager, - buildUrl, - type WebRTCStatus, - type WebRTCManagerOptions, + WebRTCManager, + buildUrl, + type WebRTCStatus, + type WebRTCManagerOptions, + type WebRTCConnectionState, + type WebRTCClientCallbacks, } from '@agentuity/frontend'; + +export type { WebRTCClientCallbacks }; import { AgentuityContext } from './context'; -export type { WebRTCStatus }; +export type { WebRTCStatus, WebRTCConnectionState }; /** * Options for useWebRTCCall hook */ export interface UseWebRTCCallOptions { - /** Room ID to join */ - roomId: string; - /** WebSocket signaling URL (e.g., '/call/signal' or full URL) */ - signalUrl: string; - /** Whether this peer is "polite" in perfect negotiation (default: true for first joiner) */ - polite?: boolean; - /** ICE servers configuration */ - iceServers?: RTCIceServer[]; - /** Media constraints for getUserMedia */ - media?: MediaStreamConstraints; - /** Whether to auto-connect on mount (default: true) */ - autoConnect?: boolean; + /** Room ID to join */ + roomId: string; + /** WebSocket signaling URL (e.g., '/call/signal' or full URL) */ + signalUrl: string; + /** Whether this peer is "polite" in perfect negotiation (default: true for first joiner) */ + polite?: boolean; + /** ICE servers configuration */ + iceServers?: RTCIceServer[]; + /** Media constraints for getUserMedia */ + media?: MediaStreamConstraints; + /** Whether to auto-connect on mount (default: true) */ + autoConnect?: boolean; + /** + * Optional callbacks for WebRTC events. + * These are called in addition to the hook's internal state management. + */ + callbacks?: Partial; } /** * Return type for useWebRTCCall hook */ export interface UseWebRTCCallResult { - /** Ref to attach to local video element */ - localVideoRef: React.RefObject; - /** Ref to attach to remote video element */ - remoteVideoRef: React.RefObject; - /** Current connection status */ - status: WebRTCStatus; - /** Current error if any */ - error: Error | null; - /** Local peer ID assigned by server */ - peerId: string | null; - /** Remote peer ID */ - remotePeerId: string | null; - /** Whether audio is muted */ - isAudioMuted: boolean; - /** Whether video is muted */ - isVideoMuted: boolean; - /** Manually start the connection (if autoConnect is false) */ - connect: () => void; - /** End the call */ - hangup: () => void; - /** Mute or unmute audio */ - muteAudio: (muted: boolean) => void; - /** Mute or unmute video */ - muteVideo: (muted: boolean) => void; + /** Ref to attach to local video element */ + localVideoRef: React.RefObject; + /** Ref to attach to remote video element */ + remoteVideoRef: React.RefObject; + /** Current connection state (new state machine) */ + state: WebRTCConnectionState; + /** @deprecated Use `state` instead. Current connection status */ + status: WebRTCStatus; + /** Current error if any */ + error: Error | null; + /** Local peer ID assigned by server */ + peerId: string | null; + /** Remote peer ID */ + remotePeerId: string | null; + /** Whether audio is muted */ + isAudioMuted: boolean; + /** Whether video is muted */ + isVideoMuted: boolean; + /** Manually start the connection (if autoConnect is false) */ + connect: () => void; + /** End the call */ + hangup: () => void; + /** Mute or unmute audio */ + muteAudio: (muted: boolean) => void; + /** Mute or unmute video */ + muteVideo: (muted: boolean) => void; +} + +/** + * Map new state to legacy status for backwards compatibility + */ +function stateToStatus(state: WebRTCConnectionState): WebRTCStatus { + if (state === 'idle') return 'disconnected'; + if (state === 'negotiating') return 'connecting'; + return state as WebRTCStatus; } /** @@ -68,20 +88,27 @@ export interface UseWebRTCCallResult { * const { * localVideoRef, * remoteVideoRef, - * status, + * state, * hangup, * muteAudio, * isAudioMuted, * } = useWebRTCCall({ * roomId, * signalUrl: '/call/signal', + * callbacks: { + * onStateChange: (from, to, reason) => { + * console.log(`State: ${from} → ${to}`, reason); + * }, + * onConnect: () => console.log('Connected!'), + * onDisconnect: (reason) => console.log('Disconnected:', reason), + * }, * }); * * return ( *
*