diff --git a/.github/workflows/package-smoke-test.yaml b/.github/workflows/package-smoke-test.yaml index 0ff2ed4da..f88812aa0 100644 --- a/.github/workflows/package-smoke-test.yaml +++ b/.github/workflows/package-smoke-test.yaml @@ -326,7 +326,7 @@ jobs: playwright-e2e-test: runs-on: blacksmith-4vcpu-ubuntu-2204 - timeout-minutes: 10 + timeout-minutes: 15 name: Playwright E2E Smoke Test steps: - uses: actions/checkout@v4 @@ -346,8 +346,11 @@ jobs: - name: Install SDK in e2e-web run: bash scripts/install-sdk-tarballs.sh apps/testing/e2e-web + # No explicit build step needed: playwright.config.ts webServer starts + # "bun run dev" which compiles on-the-fly via Vite. A production build + # would be redundant since e2e tests run against the dev server. - name: Run Playwright E2E tests - run: bash scripts/test-e2e.sh + run: bun run test:e2e # TODO: Re-enable after artifact storage quota resets (disabled 2026-02-01) # - name: Upload Playwright report diff --git a/AGENTS.md b/AGENTS.md index 01cb463ac..6dfff7079 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,17 +14,23 @@ Bun workspaces monorepo with packages in `packages/`: -| Package | Runtime | Description | -| ------------ | -------- | -------------------------------------------- | -| `core` | Node/Bun | Shared types and utilities (publish first) | -| `schema` | Any | Schema validation (StandardSchema v1) | -| `server` | Node/Bun | Runtime-agnostic server utilities | -| `runtime` | Bun | Hono-based server runtime with OpenTelemetry | -| `react` | Browser | React hooks for agents | -| `frontend` | Browser | Framework-agnostic web utilities | -| `auth` | Both | Authentication providers (Clerk, etc.) | -| `cli` | Bun | CLI framework with commander.js | -| `test-utils` | Test | Private test helpers (never published) | +| Package | Runtime | Description | +| ------------ | -------- | ----------------------------------------------------- | +| `core` | Node/Bun | Shared types and utilities (publish first) | +| `schema` | Any | Schema validation (StandardSchema v1) | +| `server` | Node/Bun | Runtime-agnostic server utilities | +| `runtime` | Bun | Hono-based server runtime with WebRTC signaling | +| `react` | Browser | React hooks for agents and WebRTC | +| `frontend` | Browser | Framework-agnostic web utilities with WebRTC manager | +| `auth` | Both | Authentication providers (Clerk, etc.) | +| `cli` | Bun | CLI framework with commander.js | +| `postgres` | Node/Bun | Resilient PostgreSQL client with auto-reconnection | +| `drizzle` | Node/Bun | Drizzle ORM integration with resilient connections | +| `evals` | Any | Reusable evaluation presets | +| `opencode` | Bun | Opencoder agent plugins for Agentuity | +| `workbench` | Browser | Workbench UI component for agent testing | +| `vscode` | Node | VS Code extension for Agentuity | +| `test-utils` | Test | Private test helpers (never published) | ## Code Style diff --git a/README.md b/README.md index 112b2f145..28b6360a1 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,11 @@ The structure of this mono repository: - `packages/core` - Shared utilities used by most packages - `packages/drizzle` - Drizzle ORM integration with resilient PostgreSQL connections - `packages/evals` - Reusable Evaluation Presets -- `packages/frontend` - Reusable code for web frontends +- `packages/frontend` - Reusable code for web frontends including WebRTC peer connections - `packages/opencode` - Opencoder agent plugins for Agentuity - `packages/postgres` - Resilient PostgreSQL client with automatic reconnection -- `packages/react` - React package for the Browser -- `packages/runtime` - Server-side package for the Agent runtime +- `packages/react` - React package for the Browser including WebRTC hooks +- `packages/runtime` - Server-side package for the Agent runtime with WebRTC signaling - `packages/schema` - Schema validation library similar to zod and arktype - `packages/server` - Runtime-agnostic server-side SDK (Node.js & Bun) - `packages/test-utils` - Internal test utilities that can be used by packages diff --git a/apps/testing/e2e-web/.vscode/settings.json b/apps/testing/e2e-web/.vscode/settings.json new file mode 100644 index 000000000..8b2c0232a --- /dev/null +++ b/apps/testing/e2e-web/.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/e2e-web/src/api/index.ts b/apps/testing/e2e-web/src/api/index.ts index 2123f318b..2445262d8 100644 --- a/apps/testing/e2e-web/src/api/index.ts +++ b/apps/testing/e2e-web/src/api/index.ts @@ -1,8 +1,11 @@ -import { createRouter } from '@agentuity/runtime'; +import { createRouter, webrtc } from '@agentuity/runtime'; import hello from '../agent/hello/agent'; const api = createRouter(); +// WebRTC signaling endpoint for E2E tests +api.get('/webrtc/signal', webrtc({ maxPeers: 10 })); + api.post('/hello', hello.validator(), async (c) => { const data = c.req.valid('json'); const result = await hello.run(data); diff --git a/apps/testing/e2e-web/src/generated/routes.ts b/apps/testing/e2e-web/src/generated/routes.ts index f9a4ff36c..9bab2a9ab 100644 --- a/apps/testing/e2e-web/src/generated/routes.ts +++ b/apps/testing/e2e-web/src/generated/routes.ts @@ -119,6 +119,12 @@ declare module '@agentuity/frontend' { stream: false; params: { userId: string }; }; + 'GET /api/webrtc/signal': { + inputSchema: never; + outputSchema: never; + stream: false; + params: never; + }; } /** @@ -200,6 +206,14 @@ declare module '@agentuity/frontend' { get: { input: never; output: never; type: 'api'; params: { userId: string }; paramsTuple: [string] }; }; }; + webrtc: { + signal: { + /** + * Route: GET /api/webrtc/signal + */ + get: { input: never; output: never; type: 'api'; params: never; paramsTuple: [] }; + }; + }; } } @@ -231,6 +245,12 @@ declare module '@agentuity/react' { stream: false; params: { userId: string }; }; + 'GET /api/webrtc/signal': { + inputSchema: never; + outputSchema: never; + stream: false; + params: never; + }; } export interface WebSocketRouteRegistry { '/api/echo': { @@ -293,6 +313,14 @@ declare module '@agentuity/react' { get: { input: never; output: never; type: 'api'; params: { userId: string }; paramsTuple: [string] }; }; }; + webrtc: { + signal: { + /** + * Route: GET /api/webrtc/signal + */ + get: { input: never; output: never; type: 'api'; params: never; paramsTuple: [] }; + }; + }; } } @@ -352,6 +380,14 @@ const _rpcRouteMetadata = { ] } } + }, + "webrtc": { + "signal": { + "get": { + "type": "api", + "path": "/api/webrtc/signal" + } + } } } as const; diff --git a/apps/testing/e2e-web/src/web/App.tsx b/apps/testing/e2e-web/src/web/App.tsx index 43430b727..43674a249 100644 --- a/apps/testing/e2e-web/src/web/App.tsx +++ b/apps/testing/e2e-web/src/web/App.tsx @@ -5,6 +5,7 @@ import { RpcPage } from './RpcPage'; import { RpcTestPage } from './RpcTestPage'; import { PathParamsPage } from './PathParamsPage'; import { AnalyticsTestPage } from './AnalyticsTestPage'; +import { WebRTCTestPage } from './WebRTCTestPage'; const WORKBENCH_PATH = process.env.AGENTUITY_PUBLIC_WORKBENCH_PATH; @@ -32,6 +33,10 @@ export function App() { return ; } + if (path === '/webrtc') { + return ; + } + const [name, setName] = useState('World'); const { data: greeting, invoke, isLoading: running } = useAPI('POST /api/hello'); diff --git a/apps/testing/e2e-web/src/web/WebRTCTestPage.tsx b/apps/testing/e2e-web/src/web/WebRTCTestPage.tsx new file mode 100644 index 000000000..48055c4da --- /dev/null +++ b/apps/testing/e2e-web/src/web/WebRTCTestPage.tsx @@ -0,0 +1,862 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { WebRTCManager, type WebRTCConnectionState } from '@agentuity/frontend'; +import type { RecordingHandle, RecordingState } from '@agentuity/core'; + +interface Message { + from: 'local' | 'remote'; + peerId?: string; + data: string; + timestamp: number; +} + +interface CursorPosition { + peerId: string; + x: number; + y: number; + color: string; +} + +const CURSOR_COLORS = [ + '#e91e63', + '#9c27b0', + '#673ab7', + '#3f51b5', + '#2196f3', + '#00bcd4', + '#009688', + '#4caf50', + '#ff9800', + '#ff5722', +]; + +export function WebRTCTestPage() { + const [roomId, setRoomId] = useState('e2e-test-room'); + const [state, setState] = useState('idle'); + const [peerId, setPeerId] = useState(null); + const [remotePeerIds, setRemotePeerIds] = useState([]); + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(''); + const [error, setError] = useState(null); + const [dataChannelOpen, setDataChannelOpen] = useState(false); + const [isScreenSharing, setIsScreenSharing] = useState(false); + const [recordingState, setRecordingState] = useState('inactive'); + const [recordingSize, setRecordingSize] = useState(null); + const [recordingMimeType, setRecordingMimeType] = useState(null); + + // Media options + const [enableVideo, setEnableVideo] = useState(false); + const [enableAudio, setEnableAudio] = useState(false); + const [isAudioMuted, setIsAudioMuted] = useState(false); + const [isVideoMuted, setIsVideoMuted] = useState(false); + const [autoReconnect, setAutoReconnect] = useState(true); + const [maxReconnectAttempts, setMaxReconnectAttempts] = useState(5); + const [reconnectAttempt, setReconnectAttempt] = useState(null); + const [reconnectStatus, setReconnectStatus] = useState<'idle' | 'reconnecting' | 'reconnected' | 'failed'>( + 'idle' + ); + + // Cursor tracking + const [remoteCursors, setRemoteCursors] = useState>(new Map()); + const [cursorChannelOpen, setCursorChannelOpen] = useState(false); + const peerColorsRef = useRef>(new Map()); + + // Store remote streams so we can apply them when video elements mount + const [remoteStreams, setRemoteStreams] = useState>(new Map()); + + const managerRef = useRef(null); + const localVideoRef = useRef(null); + const canvasRef = useRef(null); + const recordingHandleRef = useRef(null); + const checkPeerIdRef = useRef | null>(null); + + const connect = useCallback(() => { + // Clear any existing peer ID check interval + if (checkPeerIdRef.current) { + clearInterval(checkPeerIdRef.current); + checkPeerIdRef.current = null; + } + if (managerRef.current) { + managerRef.current.dispose(); + } + setError(null); + setReconnectStatus('idle'); + setReconnectAttempt(null); + + const signalUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/webrtc/signal`; + + const mediaEnabled = enableVideo || enableAudio; + const mediaConstraints = mediaEnabled + ? { video: enableVideo, audio: enableAudio } + : false; + + const manager = new WebRTCManager({ + signalUrl, + roomId, + media: mediaConstraints, + autoReconnect, + maxReconnectAttempts, + dataChannels: [ + { label: 'chat', ordered: true }, + { label: 'cursors', ordered: false, maxRetransmits: 0 }, + ], + callbacks: { + onLocalStream: (stream) => { + console.log('[WebRTC] Local stream received'); + if (localVideoRef.current) { + localVideoRef.current.srcObject = stream; + } + }, + onRemoteStream: (remotePeerId, stream) => { + console.log('[WebRTC] Remote stream received from:', remotePeerId); + setRemoteStreams((prev) => { + const next = new Map(prev); + next.set(remotePeerId, stream); + return next; + }); + }, + onStateChange: (from, to, reason) => { + console.log(`[WebRTC] State: ${from} → ${to}`, reason); + setState(to); + }, + onConnect: () => { + console.log('[WebRTC] Connected!'); + }, + onDisconnect: (reason) => { + console.log('[WebRTC] Disconnected:', reason); + setDataChannelOpen(false); + setRemotePeerIds([]); + setIsScreenSharing(false); + }, + onPeerJoined: (id) => { + console.log('[WebRTC] Peer joined:', id); + setRemotePeerIds((prev) => (prev.includes(id) ? prev : [...prev, id])); + }, + onPeerLeft: (id) => { + console.log('[WebRTC] Peer left:', id); + setRemotePeerIds((prev) => prev.filter((p) => p !== id)); + setRemoteStreams((prev) => { + const next = new Map(prev); + next.delete(id); + return next; + }); + }, + onDataChannelOpen: (remotePeerId, label) => { + console.log('[WebRTC] Data channel opened:', label, 'with peer:', remotePeerId); + if (label === 'chat') { + setDataChannelOpen(true); + } + if (label === 'cursors') { + setCursorChannelOpen(true); + // Assign color to peer if not already assigned + if (!peerColorsRef.current.has(remotePeerId)) { + const colorIndex = peerColorsRef.current.size % CURSOR_COLORS.length; + const color = CURSOR_COLORS[colorIndex] ?? '#e91e63'; + peerColorsRef.current.set(remotePeerId, color); + } + } + }, + onDataChannelClose: (remotePeerId, label) => { + console.log('[WebRTC] Data channel closed:', label, 'with peer:', remotePeerId); + if (label === 'chat') { + const manager = managerRef.current; + if (manager) { + const labels = manager.getDataChannelLabels(); + if (!labels.includes('chat')) { + setDataChannelOpen(false); + } + } + } + if (label === 'cursors') { + // Remove cursor when peer's channel closes + setRemoteCursors((prev) => { + const next = new Map(prev); + next.delete(remotePeerId); + return next; + }); + } + }, + onDataChannelMessage: (remotePeerId, label, data) => { + if (label === 'chat') { + console.log('[WebRTC] Chat message from:', remotePeerId, data); + setMessages((prev) => [ + ...prev, + { + from: 'remote', + peerId: remotePeerId, + data: typeof data === 'string' ? data : JSON.stringify(data), + timestamp: Date.now(), + }, + ]); + } + if (label === 'cursors' && typeof data === 'object' && data !== null) { + const cursorData = data as { x: number; y: number }; + const color = peerColorsRef.current.get(remotePeerId) || '#999'; + setRemoteCursors((prev) => { + const next = new Map(prev); + next.set(remotePeerId, { + peerId: remotePeerId, + x: cursorData.x, + y: cursorData.y, + color, + }); + return next; + }); + } + }, + onDataChannelError: (remotePeerId, label, err) => { + console.error('[WebRTC] Data channel error:', label, err, 'peer:', remotePeerId); + setError(`Data channel error: ${err.message}`); + }, + onError: (err) => { + console.error('[WebRTC] Error:', err); + setError(err.message); + }, + onScreenShareStart: () => { + setIsScreenSharing(true); + }, + onScreenShareStop: () => { + setIsScreenSharing(false); + }, + onReconnecting: (attempt: number) => { + setReconnectAttempt(attempt); + setReconnectStatus('reconnecting'); + }, + onReconnected: () => { + setReconnectStatus('reconnected'); + }, + onReconnectFailed: () => { + setReconnectStatus('failed'); + }, + }, + }); + + managerRef.current = manager; + manager.connect(); + + checkPeerIdRef.current = setInterval(() => { + const managerState = manager.getState(); + if (managerState.peerId) { + setPeerId(managerState.peerId); + if (checkPeerIdRef.current) { + clearInterval(checkPeerIdRef.current); + checkPeerIdRef.current = null; + } + } + }, 100); + }, [roomId, enableVideo, enableAudio, autoReconnect, maxReconnectAttempts]); + + const toggleAudioMute = useCallback(() => { + if (managerRef.current) { + const newMuted = !isAudioMuted; + managerRef.current.muteAudio(newMuted); + setIsAudioMuted(newMuted); + } + }, [isAudioMuted]); + + const toggleVideoMute = useCallback(() => { + if (managerRef.current) { + const newMuted = !isVideoMuted; + managerRef.current.muteVideo(newMuted); + setIsVideoMuted(newMuted); + } + }, [isVideoMuted]); + + const startScreenShare = useCallback(async () => { + if (!managerRef.current) return; + try { + await managerRef.current.startScreenShare(); + managerRef.current.sendJSON('chat', { type: 'screen-share', active: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + } + }, []); + + const stopScreenShare = useCallback(async () => { + if (!managerRef.current) return; + try { + await managerRef.current.stopScreenShare(); + managerRef.current.sendJSON('chat', { type: 'screen-share', active: false }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + } + }, []); + + const startRecording = useCallback(() => { + if (!managerRef.current) return; + const handle = managerRef.current.startRecording('local'); + if (!handle) { + setError('Recording failed'); + return; + } + recordingHandleRef.current = handle; + setRecordingState(handle.state); + setRecordingSize(null); + setRecordingMimeType(null); + }, []); + + const stopRecording = useCallback(async () => { + const handle = recordingHandleRef.current; + if (!handle) return; + try { + const blob = await handle.stop(); + setRecordingState('inactive'); + setRecordingSize(blob.size); + setRecordingMimeType(blob.type); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('Recording stop failed:', message); + setError(message); + setRecordingState('inactive'); + setRecordingSize(null); + setRecordingMimeType(null); + } finally { + recordingHandleRef.current = null; + } + }, []); + + const forceWebSocketClose = useCallback(() => { + const manager = managerRef.current as unknown as { ws?: WebSocket } | null; + manager?.ws?.close(); + }, []); + + // Test utility: Set an invalid signal URL to force reconnection failures + const setInvalidSignalUrl = useCallback(() => { + const manager = managerRef.current as unknown as { options?: { signalUrl?: string } } | null; + if (manager?.options) { + manager.options.signalUrl = 'ws://invalid-host-that-will-not-resolve.local:12345/signal'; + } + }, []); + + // Handle canvas mouse movement + const handleCanvasMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!managerRef.current || !cursorChannelOpen) return; + + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; + const y = ((e.clientY - rect.top) / rect.height) * 100; + + managerRef.current.sendJSON('cursors', { x, y }); + }, + [cursorChannelOpen] + ); + + // Draw cursors on canvas + const drawCanvas = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Clear canvas + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw grid + ctx.strokeStyle = '#333'; + ctx.lineWidth = 1; + for (let i = 0; i <= 10; i++) { + const x = (canvas.width / 10) * i; + const y = (canvas.height / 10) * i; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, canvas.height); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(canvas.width, y); + ctx.stroke(); + } + + // Draw title + ctx.fillStyle = '#666'; + ctx.font = '14px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText('Move your cursor here to share position', canvas.width / 2, 20); + + // Draw remote cursors + remoteCursors.forEach((cursor) => { + const x = (cursor.x / 100) * canvas.width; + const y = (cursor.y / 100) * canvas.height; + + // Draw cursor pointer + ctx.fillStyle = cursor.color; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + 12, y + 10); + ctx.lineTo(x + 4, y + 10); + ctx.lineTo(x + 4, y + 18); + ctx.lineTo(x, y + 14); + ctx.closePath(); + ctx.fill(); + + // Draw peer label + ctx.fillStyle = cursor.color; + ctx.font = 'bold 10px system-ui'; + ctx.textAlign = 'left'; + const label = cursor.peerId.slice(5, 15); + ctx.fillText(label, x + 14, y + 14); + }); + }, [remoteCursors]); + + useEffect(() => { + drawCanvas(); + }, [drawCanvas]); + + const disconnect = useCallback(() => { + // Clear peer ID check interval + if (checkPeerIdRef.current) { + clearInterval(checkPeerIdRef.current); + checkPeerIdRef.current = null; + } + if (managerRef.current) { + managerRef.current.dispose(); + managerRef.current = null; + } + setState('idle'); + setPeerId(null); + setRemotePeerIds([]); + setRemoteStreams(new Map()); + setDataChannelOpen(false); + setCursorChannelOpen(false); + setRemoteCursors(new Map()); + setIsScreenSharing(false); + setRecordingState('inactive'); + setRecordingSize(null); + setRecordingMimeType(null); + recordingHandleRef.current = null; + setReconnectStatus('idle'); + setReconnectAttempt(null); + }, []); + + const sendMessage = useCallback(() => { + if (!managerRef.current || !inputMessage.trim()) return; + + const success = managerRef.current.sendString('chat', inputMessage); + if (success) { + setMessages((prev) => [ + ...prev, + { from: 'local', data: inputMessage, timestamp: Date.now() }, + ]); + setInputMessage(''); + } + }, [inputMessage]); + + const sendJSON = useCallback(() => { + if (!managerRef.current) return; + + const data = { type: 'ping', timestamp: Date.now() }; + const success = managerRef.current.sendJSON('chat', data); + if (success) { + setMessages((prev) => [ + ...prev, + { from: 'local', data: JSON.stringify(data), timestamp: Date.now() }, + ]); + } + }, []); + + useEffect(() => { + return () => { + // Clean up peer ID check interval + if (checkPeerIdRef.current) { + clearInterval(checkPeerIdRef.current); + checkPeerIdRef.current = null; + } + if (managerRef.current) { + managerRef.current.dispose(); + } + }; + }, []); + + return ( +
+

WebRTC Data Channel Test

+ +
+ +
+ + {state !== 'idle' && ( +
+ + +
+ )} + +
+ + +
+ +
+ + +
+ +
+ {state === 'idle' ? ( + + ) : ( + + )} +
+ +
+
+ State: {state} +
+
+ My Peer ID: {peerId || 'N/A'} +
+
+ Remote Peers:{' '} + {remotePeerIds.length > 0 ? remotePeerIds.join(', ') : 'Waiting...'} +
+
+ Data Channel: {dataChannelOpen ? 'Open' : 'Closed'} +
+
+ Screen Share: {isScreenSharing ? 'On' : 'Off'} +
+
+ Reconnect: {reconnectStatus} +
+ {reconnectAttempt !== null && ( +
+ Reconnect Attempt: {reconnectAttempt} +
+ )} + {error && ( +
+ Error: {error} +
+ )} +
+ + {/* Cursor Tracking Canvas */} + {state !== 'idle' && ( +
+

Cursor Tracking

+

+ Move your mouse over the canvas to share your cursor position with peers. + {remoteCursors.size > 0 && ` (${remoteCursors.size} remote cursor${remoteCursors.size > 1 ? 's' : ''})`} +

+ +
+ )} + + {/* Video Section */} + {(enableVideo || enableAudio) && state !== 'idle' && ( +
+

Media

+
+ {/* Local Video */} +
+
You
+ +
+ {enableAudio && ( + + )} + {enableVideo && ( + + )} +
+
+ + +
+
+ + +
+
+
+ Recording: {recordingState} +
+
+ Recording Size:{' '} + {recordingSize !== null ? `${recordingSize} bytes` : 'N/A'} +
+
+ Recording MIME:{' '} + {recordingMimeType ?? 'N/A'} +
+
+
+ + {/* Remote Videos */} + {remotePeerIds.map((remotePeerId) => ( +
+
+ {remotePeerId.slice(0, 15)}... +
+ +
+ ))} +
+
+ )} + + {dataChannelOpen && ( +
+
+ setInputMessage(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && sendMessage()} + placeholder="Type a message..." + data-testid="message-input" + style={{ flex: 1, padding: '0.5rem' }} + /> + + +
+
+ )} + +
+

Messages

+ {messages.length === 0 ? ( +

No messages yet

+ ) : ( + messages.map((msg) => ( +
+ {msg.from === 'local' ? 'You' : `Remote (${msg.peerId})`}:{' '} + {msg.data} +
+ )) + )} +
+
+ ); +} diff --git a/apps/testing/webrtc-test/.agents/agentuity/sdk/agent/AGENTS.md b/apps/testing/webrtc-test/.agents/agentuity/sdk/agent/AGENTS.md new file mode 100644 index 000000000..3c5330d3c --- /dev/null +++ b/apps/testing/webrtc-test/.agents/agentuity/sdk/agent/AGENTS.md @@ -0,0 +1,308 @@ +# Agents Folder Guide + +This folder contains AI agents for your Agentuity application. Each agent is organized in its own subdirectory. + +## Generated Types + +The `src/generated/` folder contains auto-generated TypeScript files: + +- `registry.ts` - Agent registry with strongly-typed agent definitions and schema types +- `routes.ts` - Route registry for API, WebSocket, and SSE endpoints +- `app.ts` - Application entry point (regenerated on every build) + +**Important:** Never edit files in `src/generated/` - they are overwritten on every build. + +Import generated types in your agents: + +```typescript +import type { HelloInput, HelloOutput } from '../generated/registry'; +``` + +## Directory Structure + +Each agent folder must contain: + +- **agent.ts** (required) - Agent definition with schema and handler + +Example structure: + +``` +src/agent/ +├── hello/ +│ └── agent.ts +├── process-data/ +│ └── agent.ts +└── (generated files in src/generated/) +``` + +**Note:** HTTP routes are defined separately in `src/api/` - see the API folder guide for details. + +## Creating an Agent + +### Basic Agent (agent.ts) + +```typescript +import { createAgent } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; + +const agent = createAgent('my-agent', { + description: 'What this agent does', + schema: { + input: s.object({ + name: s.string(), + age: s.number(), + }), + output: s.string(), + }, + handler: async (ctx, input) => { + // Access context: ctx.app, ctx.config, ctx.logger, ctx.kv, ctx.vector, ctx.stream + return `Hello, ${input.name}! You are ${input.age} years old.`; + }, +}); + +export default agent; +``` + +### Agent with Lifecycle (setup/shutdown) + +```typescript +import { createAgent } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; + +const agent = createAgent('lifecycle-agent', { + description: 'Agent with setup and shutdown', + schema: { + input: s.object({ message: s.string() }), + output: s.object({ result: s.string() }), + }, + setup: async (app) => { + // Initialize resources (runs once on startup) + // app contains: appName, version, startedAt, config + return { + agentId: `agent-${Math.random().toString(36).substr(2, 9)}`, + connectionPool: ['conn-1', 'conn-2'], + }; + }, + handler: async (ctx, input) => { + // Access setup config via ctx.config (fully typed) + ctx.logger.info('Agent ID:', ctx.config.agentId); + ctx.logger.info('Connections:', ctx.config.connectionPool); + return { result: `Processed: ${input.message}` }; + }, + shutdown: async (app, config) => { + // Cleanup resources (runs on shutdown) + console.log('Shutting down agent:', config.agentId); + }, +}); + +export default agent; +``` + +### Agent with Event Listeners + +```typescript +import { createAgent } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; + +const agent = createAgent('event-agent', { + schema: { + input: s.object({ data: s.string() }), + output: s.string(), + }, + handler: async (ctx, input) => { + return `Processed: ${input.data}`; + }, +}); + +agent.addEventListener('started', (eventName, agent, ctx) => { + ctx.logger.info('Agent started'); +}); + +agent.addEventListener('completed', (eventName, agent, ctx) => { + ctx.logger.info('Agent completed'); +}); + +agent.addEventListener('errored', (eventName, agent, ctx, error) => { + ctx.logger.error('Agent errored:', error); +}); + +export default agent; +``` + +## Agent Context (ctx) + +The handler receives a context object with: + +- **ctx.app** - Application state (appName, version, startedAt, config from createApp) +- **ctx.config** - Agent-specific config (from setup return value, fully typed) +- **ctx.logger** - Structured logger (info, warn, error, debug, trace) +- **ctx.tracer** - OpenTelemetry tracer for custom spans +- **ctx.sessionId** - Unique session identifier +- **ctx.kv** - Key-value storage +- **ctx.vector** - Vector storage for embeddings +- **ctx.stream** - Stream storage for real-time data +- **ctx.state** - In-memory request-scoped state (Map) +- **ctx.thread** - Thread information for multi-turn conversations +- **ctx.session** - Session information +- **ctx.waitUntil** - Schedule background tasks + +## Examples + +### Using Key-Value Storage + +```typescript +handler: async (ctx, input) => { + await ctx.kv.set('user:123', { name: 'Alice', age: 30 }); + const user = await ctx.kv.get('user:123'); + await ctx.kv.delete('user:123'); + const keys = await ctx.kv.list('user:*'); + return user; +}; +``` + +### Using Vector Storage + +```typescript +handler: async (ctx, input) => { + await ctx.vector.upsert('docs', [ + { id: '1', values: [0.1, 0.2, 0.3], metadata: { text: 'Hello' } }, + ]); + const results = await ctx.vector.query('docs', [0.1, 0.2, 0.3], { topK: 5 }); + return results; +}; +``` + +### Using Streams + +```typescript +handler: async (ctx, input) => { + const stream = await ctx.stream.create('agent-logs'); + await ctx.stream.write(stream.id, 'Processing step 1'); + await ctx.stream.write(stream.id, 'Processing step 2'); + return { streamId: stream.id }; +}; +``` + +### Background Tasks with waitUntil + +```typescript +handler: async (ctx, input) => { + // Schedule background work that continues after response + ctx.waitUntil(async () => { + await ctx.kv.set('processed', Date.now()); + ctx.logger.info('Background task complete'); + }); + + return { status: 'processing' }; +}; +``` + +### Calling Another Agent + +```typescript +// Import the agent directly +import otherAgent from '../other-agent/agent'; + +handler: async (ctx, input) => { + const result = await otherAgent.run({ data: input.value }); + return `Other agent returned: ${result}`; +}; +``` + +## Subagents (Nested Agents) + +Agents can have subagents organized one level deep. This is useful for grouping related functionality. + +### Directory Structure for Subagents + +``` +src/agent/ +└── team/ # Parent agent + ├── agent.ts # Parent agent + ├── members/ # Subagent + │ └── agent.ts + └── tasks/ # Subagent + └── agent.ts +``` + +### Parent Agent + +```typescript +import { createAgent } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; + +const agent = createAgent('team', { + description: 'Team Manager', + schema: { + input: s.object({ action: s.union([s.literal('info'), s.literal('count')]) }), + output: s.object({ + message: s.string(), + timestamp: s.string(), + }), + }, + handler: async (ctx, { action }) => { + return { + message: 'Team parent agent - manages members and tasks', + timestamp: new Date().toISOString(), + }; + }, +}); + +export default agent; +``` + +### Subagent (Accessing Parent) + +```typescript +import { createAgent } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; +import parentAgent from '../agent'; + +const agent = createAgent('team.members', { + description: 'Members Subagent', + schema: { + input: s.object({ + action: s.union([s.literal('list'), s.literal('add'), s.literal('remove')]), + name: s.optional(s.string()), + }), + output: s.object({ + members: s.array(s.string()), + parentInfo: s.optional(s.string()), + }), + }, + handler: async (ctx, { action, name }) => { + // Call parent agent directly + const parentResult = await parentAgent.run({ action: 'info' }); + const parentInfo = `Parent says: ${parentResult.message}`; + + let members = ['Alice', 'Bob']; + if (action === 'add' && name) { + members.push(name); + } + + return { members, parentInfo }; + }, +}); + +export default agent; +``` + +### Key Points About Subagents + +- **One level deep**: Only one level of nesting is supported (no nested subagents) +- **Access parent**: Import and call parent agents directly +- **Agent names**: Subagents have dotted names like `"team.members"` +- **Shared context**: Subagents share the same app context (kv, logger, etc.) + +## Rules + +- Each agent folder name becomes the agent's route name (e.g., `hello/` → `/agent/hello`) +- **agent.ts** must export default the agent instance +- The first argument to `createAgent()` is the agent name (must match folder structure) +- Input/output schemas are enforced with @agentuity/schema validation +- Setup return value type automatically flows to ctx.config (fully typed) +- Use ctx.logger for logging, not console.log +- Import agents directly to call them (recommended approach) +- Subagents are one level deep only (team/members/, not team/members/subagent/) + + diff --git a/apps/testing/webrtc-test/.agents/agentuity/sdk/api/AGENTS.md b/apps/testing/webrtc-test/.agents/agentuity/sdk/api/AGENTS.md new file mode 100644 index 000000000..e6c32b3fb --- /dev/null +++ b/apps/testing/webrtc-test/.agents/agentuity/sdk/api/AGENTS.md @@ -0,0 +1,367 @@ +# APIs Folder Guide + +This folder contains REST API routes for your Agentuity application. Each API is organized in its own subdirectory. + +## Generated Types + +The `src/generated/` folder contains auto-generated TypeScript files: + +- `routes.ts` - Route registry with strongly-typed route definitions and schema types +- `registry.ts` - Agent registry (for calling agents from routes) +- `app.ts` - Application entry point (regenerated on every build) + +**Important:** Never edit files in `src/generated/` - they are overwritten on every build. + +Import generated types in your routes: + +```typescript +import type { POST_Api_UsersInput, POST_Api_UsersOutput } from '../generated/routes'; +``` + +## Directory Structure + +Each API folder must contain: + +- **route.ts** (required) - HTTP route definitions using Hono router + +Example structure: + +``` +src/api/ +├── index.ts (optional, mounted at /api) +├── status/ +│ └── route.ts (mounted at /api/status) +├── users/ +│ └── route.ts (mounted at /api/users) +├── agent-call/ + └── route.ts (mounted at /api/agent-call) +``` + +## Creating an API + +### Basic API (route.ts) + +```typescript +import { createRouter } from '@agentuity/runtime'; + +const router = createRouter(); + +// GET /api/status +router.get('/', (c) => { + return c.json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: '1.0.0', + }); +}); + +// POST /api/status +router.post('/', async (c) => { + const body = await c.req.json(); + return c.json({ received: body }); +}); + +export default router; +``` + +### API with Request Validation + +```typescript +import { createRouter } from '@agentuity/runtime'; +import { s } from '@agentuity/schema'; +import { validator } from 'hono/validator'; + +const router = createRouter(); + +const createUserSchema = s.object({ + name: s.string(), + email: s.string(), + age: s.number(), +}); + +router.post( + '/', + validator('json', (value, c) => { + const result = createUserSchema['~standard'].validate(value); + if (result.issues) { + return c.json({ error: 'Validation failed', issues: result.issues }, 400); + } + return result.value; + }), + async (c) => { + const data = c.req.valid('json'); + // data is fully typed: { name: string, email: string, age: number } + return c.json({ + success: true, + user: data, + }); + } +); + +export default router; +``` + +### API Calling Agents + +APIs can call agents directly by importing them: + +```typescript +import { createRouter } from '@agentuity/runtime'; +import helloAgent from '@agent/hello'; + +const router = createRouter(); + +router.get('/', async (c) => { + // Call an agent directly + const result = await helloAgent.run({ name: 'API Caller', age: 42 }); + + return c.json({ + success: true, + agentResult: result, + }); +}); + +router.post('/with-input', async (c) => { + const body = await c.req.json(); + const { name, age } = body; + + // Call agent with dynamic input + const result = await helloAgent.run({ name, age }); + + return c.json({ + success: true, + agentResult: result, + }); +}); + +export default router; +``` + +### API with Agent Validation + +Use `agent.validator()` for automatic input validation from agent schemas: + +```typescript +import { createRouter } from '@agentuity/runtime'; +import myAgent from '@agent/my-agent'; + +const router = createRouter(); + +// POST with automatic validation using agent's input schema +router.post('/', myAgent.validator(), async (c) => { + const data = c.req.valid('json'); // Fully typed from agent schema! + const result = await myAgent.run(data); + return c.json({ success: true, result }); +}); + +export default router; +``` + +### API with Logging + +```typescript +import { createRouter } from '@agentuity/runtime'; + +const router = createRouter(); + +router.get('/log-test', (c) => { + c.var.logger.info('Info message'); + c.var.logger.error('Error message'); + c.var.logger.warn('Warning message'); + c.var.logger.debug('Debug message'); + c.var.logger.trace('Trace message'); + + return c.text('Check logs'); +}); + +export default router; +``` + +## Route Context (c) + +The route handler receives a Hono context object with: + +- **c.req** - Request object (c.req.json(), c.req.param(), c.req.query(), etc.) +- **c.json()** - Return JSON response +- **c.text()** - Return text response +- **c.html()** - Return HTML response +- **c.redirect()** - Redirect to URL +- **c.var.logger** - Structured logger (info, warn, error, debug, trace) +- **c.var.kv** - Key-value storage +- **c.var.vector** - Vector storage +- **c.var.stream** - Stream management +- **Import agents directly** - Import and call agents directly (recommended) + +## HTTP Methods + +```typescript +const router = createRouter(); + +router.get('/path', (c) => { + /* ... */ +}); +router.post('/path', (c) => { + /* ... */ +}); +router.put('/path', (c) => { + /* ... */ +}); +router.patch('/path', (c) => { + /* ... */ +}); +router.delete('/path', (c) => { + /* ... */ +}); +router.options('/path', (c) => { + /* ... */ +}); +``` + +## Path Parameters + +```typescript +// GET /api/users/:id +router.get('/:id', (c) => { + const id = c.req.param('id'); + return c.json({ userId: id }); +}); + +// GET /api/posts/:postId/comments/:commentId +router.get('/:postId/comments/:commentId', (c) => { + const postId = c.req.param('postId'); + const commentId = c.req.param('commentId'); + return c.json({ postId, commentId }); +}); +``` + +## Query Parameters + +```typescript +// GET /api/search?q=hello&limit=10 +router.get('/search', (c) => { + const query = c.req.query('q'); + const limit = c.req.query('limit') || '20'; + return c.json({ query, limit: parseInt(limit) }); +}); +``` + +## Request Body + +```typescript +// JSON body +router.post('/', async (c) => { + const body = await c.req.json(); + return c.json({ received: body }); +}); + +// Form data +router.post('/upload', async (c) => { + const formData = await c.req.formData(); + const file = formData.get('file'); + return c.json({ fileName: file?.name }); +}); +``` + +## Error Handling + +```typescript +import myAgent from '@agent/my-agent'; + +router.get('/', async (c) => { + try { + const result = await myAgent.run({ data: 'test' }); + return c.json({ success: true, result }); + } catch (error) { + c.var.logger.error('Agent call failed:', error); + return c.json( + { + success: false, + error: error instanceof Error ? error.message : String(error), + }, + 500 + ); + } +}); +``` + +## Response Types + +```typescript +// JSON response +return c.json({ data: 'value' }); + +// Text response +return c.text('Hello World'); + +// HTML response +return c.html('

Hello

'); + +// Custom status code +return c.json({ error: 'Not found' }, 404); + +// Redirect +return c.redirect('/new-path'); + +// Headers +return c.json({ data: 'value' }, 200, { + 'X-Custom-Header': 'value', +}); +``` + +## Streaming Routes + +```typescript +import { createRouter, stream, sse, websocket } from '@agentuity/runtime'; + +const router = createRouter(); + +// Stream response (use with POST) +router.post( + '/events', + stream((c) => { + return new ReadableStream({ + start(controller) { + controller.enqueue('event 1\n'); + controller.enqueue('event 2\n'); + controller.close(); + }, + }); + }) +); + +// Server-Sent Events (use with GET) +router.get( + '/notifications', + sse((c, stream) => { + stream.writeSSE({ data: 'Hello', event: 'message' }); + stream.writeSSE({ data: 'World', event: 'message' }); + }) +); + +// WebSocket (use with GET) +router.get( + '/ws', + websocket((c, ws) => { + ws.onOpen(() => { + ws.send('Connected!'); + }); + ws.onMessage((event) => { + ws.send(`Echo: ${event.data}`); + }); + }) +); + +export default router; +``` + +## Rules + +- Each API folder name becomes the route name (e.g., `status/` → `/api/status`) +- **route.ts** must export default the router instance +- Use c.var.logger for logging, not console.log +- Import agents directly to call them (e.g., `import agent from '@agent/name'`) +- Validation should use @agentuity/schema or agent.validator() for type safety +- Return appropriate HTTP status codes +- APIs run at `/api/{folderName}` by default + + diff --git a/apps/testing/webrtc-test/.agents/agentuity/sdk/web/AGENTS.md b/apps/testing/webrtc-test/.agents/agentuity/sdk/web/AGENTS.md new file mode 100644 index 000000000..2a6eb0da5 --- /dev/null +++ b/apps/testing/webrtc-test/.agents/agentuity/sdk/web/AGENTS.md @@ -0,0 +1,511 @@ +# Web Folder Guide + +This folder contains your React-based web application that communicates with your Agentuity agents. + +## Generated Types + +The `src/generated/` folder contains auto-generated TypeScript files: + +- `routes.ts` - Route registry with type-safe API, WebSocket, and SSE route definitions +- `registry.ts` - Agent registry with input/output types + +**Important:** Never edit files in `src/generated/` - they are overwritten on every build. + +Import generated types in your components: + +```typescript +// Routes are typed automatically via module augmentation +import { useAPI } from '@agentuity/react'; + +// The route 'GET /api/users' is fully typed +const { data } = useAPI('GET /api/users'); +``` + +## Directory Structure + +Required files: + +- **App.tsx** (required) - Main React application component +- **frontend.tsx** (required) - Frontend entry point with client-side rendering +- **index.html** (required) - HTML template +- **public/** (optional) - Static assets (images, CSS, JS files) + +Example structure: + +``` +src/web/ +├── App.tsx +├── frontend.tsx +├── index.html +└── public/ + ├── styles.css + ├── logo.svg + └── script.js +``` + +## Creating the Web App + +### App.tsx - Main Component + +```typescript +import { AgentuityProvider, useAPI } from '@agentuity/react'; +import { useState } from 'react'; + +function HelloForm() { + const [name, setName] = useState('World'); + const { invoke, isLoading, data: greeting } = useAPI('POST /api/hello'); + + return ( +
+ setName(e.target.value)} + disabled={isLoading} + /> + + + +
{greeting ?? 'Waiting for response'}
+
+ ); +} + +export function App() { + return ( + +
+

Welcome to Agentuity

+ +
+
+ ); +} +``` + +### frontend.tsx - Entry Point + +```typescript +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +const root = document.getElementById('root'); +if (!root) throw new Error('Root element not found'); + +createRoot(root).render(); +``` + +### index.html - HTML Template + +```html + + + + + + My Agentuity App + + +
+ + + +``` + +## React Hooks + +All hooks from `@agentuity/react` must be used within an `AgentuityProvider`. **Always use these hooks instead of raw `fetch()` calls** - they provide type safety, automatic error handling, and integration with the Agentuity platform. + +### useAPI - Type-Safe API Calls + +The primary hook for making HTTP requests. **Use this instead of `fetch()`.** + +```typescript +import { useAPI } from '@agentuity/react'; + +function MyComponent() { + // GET requests auto-execute and return refetch + const { data, isLoading, error, refetch } = useAPI('GET /api/users'); + + // POST/PUT/DELETE return invoke for manual execution + const { invoke, data: result, isLoading: saving } = useAPI('POST /api/users'); + + const handleCreate = async () => { + // Input is fully typed from route schema! + await invoke({ name: 'Alice', email: 'alice@example.com' }); + }; + + return ( +
+ + {result &&

Created: {result.name}

} +
+ ); +} +``` + +**useAPI Return Values:** + +| Property | Type | Description | +| ------------ | ------------------------ | ----------------------------------------- | +| `data` | `T \| undefined` | Response data (typed from route schema) | +| `error` | `Error \| null` | Error if request failed | +| `isLoading` | `boolean` | True during initial load | +| `isFetching` | `boolean` | True during any fetch (including refetch) | +| `isSuccess` | `boolean` | True if last request succeeded | +| `isError` | `boolean` | True if last request failed | +| `invoke` | `(input?) => Promise` | Manual trigger (POST/PUT/DELETE) | +| `refetch` | `() => Promise` | Refetch data (GET) | +| `reset` | `() => void` | Reset state to initial | + +### useAPI Options + +```typescript +// GET with query parameters and caching +const { data } = useAPI({ + route: 'GET /api/search', + query: { q: 'react', limit: '10' }, + staleTime: 5000, // Cache for 5 seconds + refetchInterval: 10000, // Auto-refetch every 10 seconds + enabled: true, // Set to false to disable auto-fetch +}); + +// POST with callbacks +const { invoke } = useAPI({ + route: 'POST /api/users', + onSuccess: (data) => console.log('Created:', data), + onError: (error) => console.error('Failed:', error), +}); + +// Streaming responses with onChunk +const { invoke } = useAPI({ + route: 'POST /api/stream', + onChunk: (chunk) => console.log('Received chunk:', chunk), + delimiter: '\n', // Split stream by newlines (default) +}); + +// Custom headers +const { data } = useAPI({ + route: 'GET /api/protected', + headers: { 'X-Custom-Header': 'value' }, +}); +``` + +### useWebsocket - WebSocket Connection + +For bidirectional real-time communication. Automatically handles reconnection. + +```typescript +import { useWebsocket } from '@agentuity/react'; + +function ChatComponent() { + const { isConnected, data, send, messages, clearMessages, error, reset } = useWebsocket('/api/chat'); + + return ( +
+

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

+ +
+ {messages.map((msg, i) => ( +

{JSON.stringify(msg)}

+ ))} +
+ +
+ ); +} +``` + +**useWebsocket Return Values:** + +| Property | Type | Description | +| --------------- | ---------------- | ---------------------------------------- | +| `isConnected` | `boolean` | True when WebSocket is connected | +| `data` | `T \| undefined` | Most recent message received | +| `messages` | `T[]` | Array of all received messages | +| `send` | `(data) => void` | Send a message (typed from route schema) | +| `clearMessages` | `() => void` | Clear the messages array | +| `close` | `() => void` | Close the connection | +| `error` | `Error \| null` | Error if connection failed | +| `isError` | `boolean` | True if there's an error | +| `reset` | `() => void` | Reset state and reconnect | +| `readyState` | `number` | WebSocket ready state | + +### useEventStream - Server-Sent Events + +For server-to-client streaming (one-way). Use when server pushes updates to client. + +```typescript +import { useEventStream } from '@agentuity/react'; + +function NotificationsComponent() { + const { isConnected, data, error, close, reset } = useEventStream('/api/notifications'); + + return ( +
+

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

+ {error &&

Error: {error.message}

} +

Latest: {JSON.stringify(data)}

+ +
+ ); +} +``` + +**useEventStream Return Values:** + +| Property | Type | Description | +| ------------- | ---------------- | ---------------------------------- | +| `isConnected` | `boolean` | True when EventSource is connected | +| `data` | `T \| undefined` | Most recent event data | +| `error` | `Error \| null` | Error if connection failed | +| `isError` | `boolean` | True if there's an error | +| `close` | `() => void` | Close the connection | +| `reset` | `() => void` | Reset state and reconnect | +| `readyState` | `number` | EventSource ready state | + +### useAgentuity - Access Context + +Access the Agentuity context for base URL and configuration. + +```typescript +import { useAgentuity } from '@agentuity/react'; + +function MyComponent() { + const { baseUrl } = useAgentuity(); + + return

API Base: {baseUrl}

; +} +``` + +### useAuth - Authentication State + +Access and manage authentication state. + +```typescript +import { useAuth } from '@agentuity/react'; + +function AuthStatus() { + const { isAuthenticated, authHeader, setAuthHeader, authLoading } = useAuth(); + + const handleLogin = async (token: string) => { + setAuthHeader?.(`Bearer ${token}`); + }; + + const handleLogout = () => { + setAuthHeader?.(null); + }; + + if (authLoading) return

Loading...

; + + return ( +
+ {isAuthenticated ? ( + + ) : ( + + )} +
+ ); +} +``` + +**useAuth Return Values:** + +| Property | Type | Description | +| ----------------- | ------------------- | ------------------------------------------- | +| `isAuthenticated` | `boolean` | True if user has auth token and not loading | +| `authHeader` | `string \| null` | Current auth header (e.g., "Bearer ...") | +| `setAuthHeader` | `(token) => void` | Set auth header (null to clear) | +| `authLoading` | `boolean` | True during auth state changes | +| `setAuthLoading` | `(loading) => void` | Set auth loading state | + +## Complete Example + +```typescript +import { AgentuityProvider, useAPI, useWebsocket } from '@agentuity/react'; +import { useEffect, useState } from 'react'; + +function Dashboard() { + const [count, setCount] = useState(0); + const { invoke, data: agentResult } = useAPI('POST /api/process'); + const { isConnected, send, data: wsMessage } = useWebsocket('/api/live'); + + useEffect(() => { + if (isConnected) { + const interval = setInterval(() => { + send({ ping: Date.now() }); + }, 1000); + return () => clearInterval(interval); + } + }, [isConnected, send]); + + return ( +
+

My Agentuity App

+ +
+

Count: {count}

+ +
+ +
+ +

{JSON.stringify(agentResult)}

+
+ +
+ WebSocket: + {isConnected ? JSON.stringify(wsMessage) : 'Not connected'} +
+
+ ); +} + +export function App() { + return ( + + + + ); +} +``` + +## 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 +
+ + +
+``` + +## RPC-Style API Client + +For non-React contexts (like utility functions or event handlers), use `createClient`: + +```typescript +import { createClient } from '@agentuity/react'; + +// Create a typed client (uses global baseUrl and auth from AgentuityProvider) +const api = createClient(); + +// Type-safe RPC-style calls - routes become nested objects +// Route 'GET /api/users' becomes api.users.get() +// Route 'POST /api/users' becomes api.users.post() +// Route 'GET /api/users/:id' becomes api.users.id.get({ id: '123' }) + +async function fetchData() { + const users = await api.users.get(); + const newUser = await api.users.post({ name: 'Alice', email: 'alice@example.com' }); + const user = await api.users.id.get({ id: '123' }); + return { users, newUser, user }; +} +``` + +**When to use `createClient` vs `useAPI`:** + +| Use Case | Recommendation | +| ------------------------- | -------------- | +| React component rendering | `useAPI` hook | +| Event handlers | Either works | +| Utility functions | `createClient` | +| Non-React code | `createClient` | +| Need loading/error state | `useAPI` hook | +| Need caching/refetch | `useAPI` hook | + +## Best Practices + +- Wrap your app with **AgentuityProvider** for hooks to work +- **Always use `useAPI` instead of `fetch()`** for type safety and error handling +- Use **useAPI** for type-safe HTTP requests (GET, POST, PUT, DELETE) +- Use **useWebsocket** for bidirectional real-time communication +- Use **useEventStream** for server-to-client streaming +- Use **useAuth** for authentication state management +- Handle loading and error states in UI +- Place reusable components in separate files +- Keep static assets in the **public/** folder + +## Rules + +- **App.tsx** must export a function named `App` +- **frontend.tsx** must render the `App` component to `#root` +- **index.html** must have a `
` +- Routes are typed via module augmentation from `src/generated/routes.ts` +- The web app is served at `/` by default +- Static files in `public/` are served at `/public/*` +- Module script tag: `` +- **Never use raw `fetch()` calls** - always use `useAPI` or `createClient` + + diff --git a/apps/testing/webrtc-test/.gitignore b/apps/testing/webrtc-test/.gitignore new file mode 100644 index 000000000..0b627cce3 --- /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.*.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 000000000..13d25f367 --- /dev/null +++ b/apps/testing/webrtc-test/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "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" + } + ] +} diff --git a/apps/testing/webrtc-test/AGENTS.md b/apps/testing/webrtc-test/AGENTS.md new file mode 100644 index 000000000..20cd550e1 --- /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 ``; + // Session script sets cookies and window.__AGENTUITY_SESSION__ (dynamic, not cached) + const sessionScript = ''; + + // In production, the beacon is already in HTML as a CDN asset (data-agentuity-beacon marker) + // Inject config/session BEFORE the beacon marker so config exists when beacon runs + const beaconMarker = ' + + +``` + +## 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'; + +function AppContent() { + 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'} +
+
+ ); +} + +export function App() { + return ( + + + + ); +} +``` + +## 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 000000000..973a3a906 --- /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 000000000..969967816 --- /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 000000000..781191e6d --- /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 000000000..e69de29bb 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 000000000..21f46e6f5 Binary files /dev/null and b/apps/testing/webrtc-test/src/web/public/favicon.ico differ diff --git a/apps/testing/webrtc-test/tsconfig.json b/apps/testing/webrtc-test/tsconfig.json new file mode 100644 index 000000000..9b379e0f6 --- /dev/null +++ b/apps/testing/webrtc-test/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "paths": { + "@agent/*": ["./src/agent/*"], + "@api/*": ["./src/api/*"] + } + }, + "include": ["src/**/*", "app.ts"] +} diff --git a/bun.lock b/bun.lock index 7ba5c5c15..4829b250a 100644 --- a/bun.lock +++ b/bun.lock @@ -306,6 +306,30 @@ "vite": "^7.2.7", }, }, + "apps/testing/webrtc-test": { + "name": "webrtc-test", + "version": "0.0.1", + "dependencies": { + "@agentuity/core": "workspace:*", + "@agentuity/frontend": "workspace:*", + "@agentuity/react": "workspace:*", + "@agentuity/runtime": "workspace:*", + "@agentuity/schema": "workspace:*", + "@agentuity/workbench": "workspace:*", + "hono": "^4.11.3", + "react": "^19.2.0", + "react-dom": "^19.2.0", + }, + "devDependencies": { + "@agentuity/cli": "workspace:*", + "@types/bun": "latest", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^4.6.0", + "typescript": "^5", + "vite": "^7.2.7", + }, + }, "packages/auth": { "name": "@agentuity/auth", "version": "1.0.1", @@ -687,9 +711,9 @@ "@agentuity/workbench": ["@agentuity/workbench@workspace:packages/workbench"], - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.59", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s7T5etVW7k4y3p1TDpTGa7xBddvSPWTvRa+EHAI0MPCrVh2dVFmAk+Dm6I8zlr1vz45SBpHvKR3SP29aGXdrUQ=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.32", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7clZRr07P9rpur39t1RrbIe7x8jmwnwUWI8tZs+BvAfX3NFgdSVGGIaT7bTz2pb08jmLXzTSDbrOTqAQ7uBkBQ=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9aRTVM1P1u4yUIjBpco/WCF1WXr/DgWKuDYgLLHdENS8kiEuxDOPJuGbc/6+7EwQ6ZqSh0UOgeqvHfGJfU23Qg=="], "@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="], @@ -701,13 +725,13 @@ "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og=="], - "@ai-sdk/react": ["@ai-sdk/react@3.0.71", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.13", "ai": "6.0.69", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-YuawpIjNhZY9AharnEV1gvG7DMNVNK58yRxm1Ubaxx22I0RtoaXzpIv/+3UquFrjSJKQ4T/vRY3iog6baMCycg=="], + "@ai-sdk/react": ["@ai-sdk/react@3.0.74", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.13", "ai": "6.0.72", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-L8N9HNM9Vt3rxORhX6+KCrsYRI6ZXGz1q8o/ysw6+Sx3MC0pqSZLiaKYifIYe2TSWgLP5mWcGlA5hHPuq5Jdfw=="], "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.1", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.4" } }, "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="], - "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.7", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.5" } }, "sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA=="], + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.8", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.5" } }, "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ=="], "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], @@ -743,7 +767,7 @@ "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - "@babel/generator": ["@babel/generator@7.29.0", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ=="], + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], @@ -799,17 +823,17 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.1", "", {}, "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ=="], - "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + "@csstools/css-calc": ["@csstools/css-calc@3.0.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-q4d82GTl8BIlh/dTnVsWmxnbWJeb3kiU8eUH71UxlxnS+WIaALmtzTL8gR15PkYOexMQYVk0CO4qIG93C1IvPA=="], - "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.1", "", { "dependencies": { "@csstools/color-helpers": "^6.0.1", "@csstools/css-calc": "^3.0.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw=="], - "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.26", "", {}, "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA=="], - "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], "@datasert/cronjs-parser": ["@datasert/cronjs-parser@1.4.0", "", {}, "sha512-zHGlrWanS4Zjgf0aMi/sp/HTSa2xWDEtXW9xshhlGf/jPx3zTIqfX14PZnoFF7XVOwzC49Zy0SFWG90rlRY36Q=="], @@ -981,7 +1005,7 @@ "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="], - "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -1005,23 +1029,23 @@ "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], - "@next/env": ["@next/env@15.5.11", "", {}, "sha512-g9s5SS9gC7GJCEOR3OV3zqs7C5VddqxP9X+/6BpMbdXRkqsWfFf2CJPBZNvNEtAkKTNuRgRXAgNxSAXzfLdaTg=="], + "@next/env": ["@next/env@15.5.12", "", {}, "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw=="], "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], @@ -1033,9 +1057,9 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.49", "", { "dependencies": { "@opencode-ai/sdk": "1.1.49", "zod": "4.1.8" } }, "sha512-+FEE730fLJtoHCta5MXixOIzI9Cjos700QDNnAx6mA8YjFzO+kABnyqLQrCgZ9wUPJgiKH9bnHxT7AdRjWsNPw=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.53", "", { "dependencies": { "@opencode-ai/sdk": "1.1.53", "zod": "4.1.8" } }, "sha512-9ye7Wz2kESgt02AUDaMea4hXxj6XhWwKAG8NwFhrw09Ux54bGaMJFt1eIS8QQGIMaD+Lp11X4QdyEg96etEBJw=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.49", "", {}, "sha512-F5ZkgiqOiV+z3U4zeBLvrmNZv5MwNFMTWM+HWhChD+/UEswIebQKk9UMz9lPX4fswexIJdFPwFI/TBdNyZfKMg=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.53", "", {}, "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -1503,15 +1527,15 @@ "@tanstack/react-devtools": ["@tanstack/react-devtools@0.7.11", "", { "dependencies": { "@tanstack/devtools": "0.7.0" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-a2Lmz8x+JoDrsU6f7uKRcyyY+k8mA/n5mb9h7XJ3Fz/y3+sPV9t7vAW1s5lyNkQyyDt6V1Oim99faLthoJSxMw=="], - "@tanstack/react-router": ["@tanstack/react-router@1.158.0", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.158.0", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-kvTaO6zjq9WWPyo1wwSZx95AjJ9KOvu23cOMgKeDdDQWKF3Z9q3fwhToKMKJoC11T2Vuivz+o/anrxCcOvdRzw=="], + "@tanstack/react-router": ["@tanstack/react-router@1.158.1", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.158.1", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-ZRBhs0tJDPeYGVrBhXPkGs+mOKqKKMM4OfvYSNvWIYZGfs8KQcqxPaN8OnUvKsnAGtzwusVWDpBipqVZWJd0lA=="], - "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.158.0", "", { "dependencies": { "@tanstack/router-devtools-core": "1.158.0" }, "peerDependencies": { "@tanstack/react-router": "^1.158.0", "@tanstack/router-core": "^1.158.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-uhciBlsPW67xbDCFyc2RQS00OergfNXYxendGUO2HGy4uTr79aQSCb4l6v+sdlPwUTlzwkAtM6e1illIexF15Q=="], + "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.158.1", "", { "dependencies": { "@tanstack/router-devtools-core": "1.158.1" }, "peerDependencies": { "@tanstack/react-router": "^1.158.1", "@tanstack/router-core": "^1.158.1", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-H0iTfsLNkadF/JhJnu/pUxlxOiLjE0866vFqXK/7EYVcyYwx2uWQuGxEkyF7a04oXXrbEImAOoXDRBQcZ9T5Zw=="], "@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="], - "@tanstack/router-core": ["@tanstack/router-core@1.158.0", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-dRMcWY0UB/6OZqSCx/7iUvom0ol18rHSQladygVT8mlth7uxYx3n5BNse8C03efIE8y1Bx+VDOBAKpAZ9BgKog=="], + "@tanstack/router-core": ["@tanstack/router-core@1.158.1", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-8B9X3GzN1JWsqa+OTgg2k+LrayLQYmgtv26b96difyrRS32DaDBvEpU3xXDaLNmi/+zoqG1ffAcDT4D6tyC2hw=="], - "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.158.0", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.158.0", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-8FUKfjh8Xz9T9O5yYaiVE0Va5aCMncQyVPKb7yy5M/buDnx9Kh0bPjw/eUZJWftOyxW6/WeR605yjOdx/PnqNw=="], + "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.158.1", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.158.1", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-iGCqmIJ5NXMIuyFwJgfikEmRrceT3tmynMTMSuVxFiv9+Dlk1tsp8bsYS+UGhyY4beoASsRnlikAeNAMsCjhwA=="], "@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], @@ -1653,7 +1677,7 @@ "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], - "@types/node": ["@types/node@22.19.8", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA=="], + "@types/node": ["@types/node@22.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], @@ -1667,7 +1691,7 @@ "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], - "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], + "@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -1683,7 +1707,7 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@types/vscode": ["@types/vscode@1.108.1", "", {}, "sha512-DerV0BbSzt87TbrqmZ7lRDIYaMiqvP8tmJTzW2p49ZBVtGUnGAu2RGQd1Wv4XMzEVUpaHbsemVM5nfuQJj7H6w=="], + "@types/vscode": ["@types/vscode@1.109.0", "", {}, "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw=="], "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], @@ -1711,7 +1735,7 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="], - "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.2", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg=="], + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -1773,13 +1797,13 @@ "agentuity-vscode": ["agentuity-vscode@workspace:packages/vscode"], - "ai": ["ai@6.0.69", "", { "dependencies": { "@ai-sdk/gateway": "3.0.32", "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zIURMSnNroaVvu47Bm3XhC2y3LRsm8jmkwBgupxF+N7q/s6MpIiv04w1ltlnWqC8+T2PT2rN+f0sUhF+vArkwg=="], + "ai": ["ai@6.0.72", "", { "dependencies": { "@ai-sdk/gateway": "3.0.35", "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-D3TzDX6LzYL8qwi1A0rLnmuUexqDcCu4LSg77hcDHsqNRkaGspGItkz1U3RnN3ojv31XQYI9VmoWpkj44uvIUA=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], - "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1897,7 +1921,7 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001767", "", {}, "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001768", "", {}, "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -2133,8 +2157,6 @@ "e2e-web-tests": ["e2e-web-tests@workspace:apps/testing/e2e-web"], - "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "editions": ["editions@6.22.0", "", { "dependencies": { "version-range": "^4.15.0" } }, "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ=="], @@ -2259,7 +2281,7 @@ "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], - "framer-motion": ["framer-motion@12.31.0", "", { "dependencies": { "motion-dom": "^12.30.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Tnd0FU05zGRFI3JJmBegXonF1rfuzYeuXd1QSdQ99Ysnppk0yWBWSW2wUsqzRpS5nv0zPNx+y0wtDj4kf0q5RQ=="], + "framer-motion": ["framer-motion@12.33.0", "", { "dependencies": { "motion-dom": "^12.33.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ=="], "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], @@ -2295,7 +2317,7 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], - "get-tsconfig": ["get-tsconfig@4.13.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w=="], + "get-tsconfig": ["get-tsconfig@4.13.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-vp8Cj/+9Q/ibZUrq1rhy8mCTQpCk31A3uu9wc1C50yAb3x2pFHOsGdAZQ7jD86ARayyxZUViYeIztW+GE8dcrg=="], "git-up": ["git-up@8.1.1", "", { "dependencies": { "is-ssh": "^1.4.0", "parse-url": "^9.2.0" } }, "sha512-FDenSF3fVqBYSaJoYy1KSc2wosx0gCvKP+c+PRBht7cAaiCeQlBtfBDX9vgnNOHmdePlSFITVcn4pFfcgNvx3g=="], @@ -2417,7 +2439,7 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -2503,7 +2525,7 @@ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], - "jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="], + "jackspeak": ["jackspeak@4.2.1", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-GPBXyfcZSGujjddPeA+V34bW70ZJT7jzCEbloVasSH4yjiqWqXHX8iZQtZdVbOhc5esSeAIuiSmMutRZQB/olg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -2773,9 +2795,9 @@ "monaco-editor": ["monaco-editor@0.52.2", "", {}, "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ=="], - "motion": ["motion@12.31.0", "", { "dependencies": { "framer-motion": "^12.31.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-KpZQik3LLFdsiaLdFXQGnty84KcDvvdvBCHSvA9aH+RjQTP6jkJGyngRPSngau13ARUI6TbPphf/Vv/QxwxRJQ=="], + "motion": ["motion@12.33.0", "", { "dependencies": { "framer-motion": "^12.33.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-TcND7PijsrTeIA9SRVUB8TOJQ+6mJnJ5K4a9oAJZvyI0Zy47Gq5oofU+VkTxbLcvDoKXnHspQcII2mnk3TbFsQ=="], - "motion-dom": ["motion-dom@12.30.1", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-QXB+iFJRzZTqL+Am4a1CRoHdH+0Nq12wLdqQQZZsfHlp9AMt6PA098L/61oVZsDA+Ep3QSGudzpViyRrhYhGcQ=="], + "motion-dom": ["motion-dom@12.33.0", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ=="], "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], @@ -2795,7 +2817,7 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next": ["next@15.5.11", "", { "dependencies": { "@next/env": "15.5.11", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-x64": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-musl": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L2KPiKmqTDpRdeVDdPjhf43g2/VPe0NCNndq7OKDCgOLWtxe1kbr/zXGIZtYY7kZEAjRf7Bj/mwUFSr+tYC2Yg=="], + "next": ["next@15.5.12", "", { "dependencies": { "@next/env": "15.5.12", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.12", "@next/swc-darwin-x64": "15.5.12", "@next/swc-linux-arm64-gnu": "15.5.12", "@next/swc-linux-arm64-musl": "15.5.12", "@next/swc-linux-x64-gnu": "15.5.12", "@next/swc-linux-x64-musl": "15.5.12", "@next/swc-win32-arm64-msvc": "15.5.12", "@next/swc-win32-x64-msvc": "15.5.12", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA=="], "nextjs-app": ["nextjs-app@workspace:apps/testing/nextjs-app"], @@ -2841,7 +2863,7 @@ "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], - "openai": ["openai@6.17.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA=="], + "openai": ["openai@6.18.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-odLRYyz9rlzz6g8gKn61RM2oP5UUm428sE2zOxZqS9MzVfD5/XW8UoEjpnRkzTuScXP7ZbP/m7fC+bl8jCOZZw=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -3177,8 +3199,6 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], @@ -3195,8 +3215,6 @@ "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -3273,9 +3291,9 @@ "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], - "tldts": ["tldts@7.0.21", "", { "dependencies": { "tldts-core": "^7.0.21" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Plu6V8fF/XU6d2k8jPtlQf5F4Xx2hAin4r2C2ca7wR8NK5MbRTo9huLUWRe28f3Uk8bYZfg74tit/dSjc18xnw=="], + "tldts": ["tldts@7.0.22", "", { "dependencies": { "tldts-core": "^7.0.22" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw=="], - "tldts-core": ["tldts-core@7.0.21", "", {}, "sha512-oVOMdHvgjqyzUZH1rOESgJP1uNe2bVrfK0jUHHmiM2rpEiRbf3j4BrsIc6JigJRbHGanQwuZv/R+LTcHsw+bLA=="], + "tldts-core": ["tldts-core@7.0.22", "", {}, "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw=="], "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], @@ -3407,6 +3425,8 @@ "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + "webrtc-test": ["webrtc-test@workspace:apps/testing/webrtc-test"], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], @@ -3429,8 +3449,6 @@ "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], @@ -3471,7 +3489,7 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@agentuity/auth/@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="], + "@agentuity/auth/@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], "@agentuity/drizzle/drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], @@ -3511,12 +3529,6 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - - "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], "@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], @@ -3703,6 +3715,8 @@ "@terascope/fetch-github-release/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], + "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "@textlint/linter-formatter/pluralize": ["pluralize@2.0.0", "", {}, "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw=="], @@ -3711,9 +3725,9 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@vscode/vsce/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@vscode/vsce/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "better-auth/kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], @@ -3747,7 +3761,7 @@ "docs/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "docs/ai": ["ai@5.0.125", "", { "dependencies": { "@ai-sdk/gateway": "2.0.31", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-M4gVfuxOP7eywi9zKbonVKGYMYV2IMuj9jki5/7FpogzNq+P9u7/ZvBivt4iu5A3z/V5BJojZ8vdBsEqbGnsHA=="], + "docs/ai": ["ai@5.0.128", "", { "dependencies": { "@ai-sdk/gateway": "2.0.34", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-861sVBlBBx4ZNHnSfSjtWFQFz2KbJ/uXxdYOkaLSRf3tkvCLrNo1hrFsgGFMMTzPT/X/iXW26OoFQiO3aSvoog=="], "docs/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -3779,7 +3793,7 @@ "integration-suite/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "jsonwebtoken/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "jsonwebtoken/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], @@ -3793,13 +3807,13 @@ "nextjs-app-agentuity/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "node-abi/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], - "normalize-package-data/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "normalize-package-data/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -3825,7 +3839,7 @@ "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "simple-get/decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], @@ -3845,13 +3859,13 @@ "tanstack-start-agentuity/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + "vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "webrtc-test/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@agentuity/workbench/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], @@ -3905,12 +3919,6 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - - "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@opentelemetry/sdk-trace-node/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -3955,6 +3963,8 @@ "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "docs/@ai-sdk/groq/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], @@ -3969,7 +3979,7 @@ "docs/@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - "docs/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.31", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HcglYbIFqKhUYqK5qSNc2RwYxVyuhADy2R/8HMSTaUqHVFfUIFyCyDHeuNBxJcSDtHVMebPdn6Bxa0YogeiwcA=="], + "docs/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+MZ/sY1i3MH/pB2W8DHZy9WOmlDdlR/FOJ/GwCVittVnbBCkMLIocIuR0sHuHawwFpTkFZziRW7Y98s/v+XMGA=="], "docs/ai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], @@ -4015,57 +4025,61 @@ "tanstack-start-agentuity/@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], + "webrtc-test/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "webrtc-test/@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "@terascope/fetch-github-release/yargs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], diff --git a/docs/webrtc-architecture.md b/docs/webrtc-architecture.md new file mode 100644 index 000000000..2138dec8e --- /dev/null +++ b/docs/webrtc-architecture.md @@ -0,0 +1,156 @@ +# WebRTC Architecture Guide + +This guide explains the WebRTC architecture options and when to use each approach. + +## Current Architecture: Mesh Networking + +The Agentuity SDK uses **mesh networking** for WebRTC connections: + +``` + Peer A + / \ + / \ +Peer B ---- Peer C +``` + +Each peer maintains a direct RTCPeerConnection to every other peer in the room. + +### How It Works + +1. **Signaling Server** (WebRTCRoomManager) handles room membership and message relay +2. **WebRTCManager** creates one RTCPeerConnection per remote peer +3. Each peer sends their media/data directly to all other peers +4. Perfect negotiation handles SDP offer/answer collisions + +### Pros + +- **Simple architecture** - No media server needed +- **Low latency** - Direct peer-to-peer connections +- **Privacy** - Media never touches a central server +- **Cost effective** - Only signaling server required + +### Cons + +- **O(N²) connections** - Each peer connects to all others +- **Bandwidth scales poorly** - Each peer uploads N-1 copies of their stream +- **CPU intensive** - Encoding happens once per peer +- **Unreliable for mobile** - Limited uplink bandwidth + +### Recommended Limits + +| Scenario | Max Peers | +| ------------------- | --------- | +| Audio only | 8-10 | +| Video (low quality) | 4-6 | +| Video (HD) | 3-4 | +| Mobile devices | 2-3 | + +## When to Consider an SFU + +A **Selective Forwarding Unit (SFU)** is a media server that receives streams from each peer and selectively forwards them: + +``` + Peer A + | + v + [ SFU ] + / \ + v v +Peer B Peer C +``` + +### SFU Benefits + +- **O(N) connections** - Each peer connects only to the SFU +- **Bandwidth efficient** - Each peer uploads once, SFU distributes +- **Scalable** - Supports 50+ participants +- **Adaptive bitrate** - SFU can select different quality levels +- **Server-side recording** - Easy to record at the SFU +- **Simulcast support** - Peers send multiple quality levels + +### SFU Drawbacks + +- **Added latency** - Extra hop through the server +- **Server costs** - Media servers require significant resources +- **Complexity** - More infrastructure to manage +- **Privacy concerns** - Media passes through server + +### When to Use SFU + +Consider an SFU when you need: + +- More than 4-6 participants regularly +- Mobile client support with unreliable connections +- Server-side recording or transcription +- Bandwidth adaptation based on network conditions +- Large-scale webinars or broadcasts + +## SFU Options + +### Open Source + +- **[mediasoup](https://mediasoup.org/)** - Highly performant, Node.js based +- **[Janus](https://janus.conf.meetecho.com/)** - Versatile, plugin-based +- **[Pion](https://pion.ly/)** - Go-based, modular + +### Commercial + +- **[LiveKit](https://livekit.io/)** - Open-source SFU with cloud option +- **[Daily.co](https://www.daily.co/)** - Fully managed WebRTC +- **[Twilio Video](https://www.twilio.com/video)** - Enterprise-grade + +## Migrating from Mesh to SFU + +If you outgrow mesh networking, the migration path involves: + +1. **Signaling changes** - Peers negotiate with SFU, not each other +2. **Track model** - Change from peer-to-peer to publish/subscribe +3. **Quality selection** - SFU handles bandwidth adaptation + +The Agentuity SDK's track abstraction and stats APIs remain useful: + +```typescript +// Current mesh approach +const manager = new WebRTCManager({ + signalUrl: 'wss://example.com/signal', + roomId: 'my-room', +}); + +// Future SFU approach (conceptual) +const manager = new SFUManager({ + sfuUrl: 'wss://sfu.example.com/room/my-room', + token: 'auth-token', +}); + +// Same APIs work for both: +await manager.startScreenShare(); +const stats = await manager.getQualitySummary(trackId); +manager.startRecording('local'); +``` + +## Hybrid Approaches + +For flexibility, consider: + +### MCU for Recording + Mesh for Live + +- Use mesh for low-latency live communication +- Connect an MCU (Multipoint Control Unit) for server-side recording/compositing + +### SFU with Cascading + +- Deploy SFUs in multiple regions +- Cascade media between SFUs for global scale + +## Summary + +| Feature | Mesh | SFU | +| ------------------------- | -------------------- | ----------------------- | +| Max participants | 4-6 | 50+ | +| Server cost | Low (signaling only) | High (media processing) | +| Latency | Lowest | Low-Medium | +| Bandwidth efficiency | Poor | Good | +| Server-side features | Limited | Full (recording, etc.) | +| Implementation complexity | Simple | Moderate-High | + +**Start with mesh** (current SDK) for small groups. Migrate to SFU when you consistently need more than 4-6 participants or require server-side features like recording and transcription. diff --git a/docs/webrtc-scaling-sfu.md b/docs/webrtc-scaling-sfu.md new file mode 100644 index 000000000..2370e4120 --- /dev/null +++ b/docs/webrtc-scaling-sfu.md @@ -0,0 +1,75 @@ +# WebRTC Scaling Guide: Mesh vs SFU + +This guide explains when to use a mesh topology versus an SFU (Selective Forwarding Unit) for WebRTC calls, and how to scale Agentuity-based apps beyond small rooms. + +## Mesh Topology (Peer-to-Peer) + +**How it works:** every participant sends media directly to every other participant. + +**Pros** +- Simple to set up (no media server required) +- Lowest latency for small rooms +- Works well for 2–4 participants + +**Cons** +- Upload bandwidth grows with each peer +- CPU usage grows with each peer (encoding multiple streams) +- Unreliable in larger rooms or constrained networks + +## SFU Topology (Selective Forwarding Unit) + +**How it works:** each participant sends a single stream to a server (SFU) which forwards it to other participants. + +**Pros** +- Upload bandwidth stays constant per peer +- Lower CPU usage on clients (one encode) +- Better scaling for 5+ participants +- Server can manage bandwidth, simulcast, and quality tiers + +**Cons** +- Requires operating or integrating a media server +- Slightly higher latency (one extra hop) +- Adds infrastructure complexity + +## When to Use an SFU + +As a rule of thumb, consider an SFU when **5+ peers** join the same room or when you need: + +- Large rooms (webinars, classrooms, all-hands) +- Mobile or low-bandwidth participants +- Better quality control (simulcast / adaptive bitrate) +- Recording at the server side + +For 2–4 participants, a mesh is often simpler and performant enough. + +## Integrating Cloudflare Calls (SFU) + +Cloudflare Calls provides a managed SFU you can use to scale beyond mesh. + +High-level steps: + +1. **Create a Calls application** in Cloudflare and obtain API credentials. +2. **Create a session** on your backend and exchange tokens with clients. +3. **Join the SFU** using the Calls WebRTC endpoint and ICE server list. +4. **Publish / subscribe** to media tracks based on your app needs. + +> Tip: Use your backend as the source of truth for room membership and token issuance. + +## Decision Matrix + +| Room Size | Topology | Why | +|---|---|---| +| 1–2 peers | Mesh | Lowest latency, minimal setup | +| 3–4 peers | Mesh | Still manageable bandwidth/CPU | +| 5–8 peers | SFU | Upload/CPU starts to spike | +| 9+ peers | SFU | Mesh becomes impractical | + +## Migration Strategy + +If you start with mesh and later need SFU: + +1. Keep signaling the same (room IDs, peer state) +2. Swap media transport to SFU for rooms above a threshold +3. Fall back to mesh for small rooms to minimize cost + +This hybrid approach lets you scale gradually without a full rewrite. diff --git a/docs/webrtc-turn-configuration.md b/docs/webrtc-turn-configuration.md new file mode 100644 index 000000000..46ff551c1 --- /dev/null +++ b/docs/webrtc-turn-configuration.md @@ -0,0 +1,292 @@ +# TURN Server Configuration Guide + +This guide explains how to configure TURN servers for WebRTC connections in your Agentuity applications. + +## When is TURN Required? + +WebRTC uses ICE (Interactive Connectivity Establishment) to find the best path between peers. The connection types, in order of preference: + +1. **Host** - Direct connection (same network) +2. **Server Reflexive (srflx)** - Connection via STUN (works through most NATs) +3. **Relay** - Connection through TURN server (guaranteed to work) + +TURN is required when: + +- Peers are behind **symmetric NAT** (common in corporate networks) +- **Firewall rules** block UDP traffic +- **Enterprise environments** with restrictive network policies +- **Mobile networks** with carrier-grade NAT + +## ICE Server Configuration + +### Default Configuration (STUN Only) + +By default, WebRTCManager uses public Google STUN servers: + +```typescript +const manager = new WebRTCManager({ + signalUrl: 'wss://example.com/signal', + roomId: 'my-room', + // Uses default STUN servers +}); +``` + +### Adding TURN Servers + +For production use, configure both STUN and TURN: + +```typescript +const manager = new WebRTCManager({ + signalUrl: 'wss://example.com/signal', + roomId: 'my-room', + iceServers: [ + // STUN server + { urls: 'stun:stun.example.com:3478' }, + + // TURN server with UDP (fastest, when allowed) + { + urls: 'turn:turn.example.com:3478?transport=udp', + username: 'user', + credential: 'password', + }, + + // TURN server with TCP (fallback when UDP is blocked) + { + urls: 'turn:turn.example.com:3478?transport=tcp', + username: 'user', + credential: 'password', + }, + + // TURN over TLS on port 443 (works through most firewalls) + { + urls: 'turns:turn.example.com:443?transport=tcp', + username: 'user', + credential: 'password', + }, + ], +}); +``` + +### Recommended Configuration for Maximum Compatibility + +```typescript +const iceServers: RTCIceServer[] = [ + { urls: 'stun:stun.example.com:3478' }, + // TURN UDP - best performance + { + urls: 'turn:turn.example.com:3478?transport=udp', + username: credentials.username, + credential: credentials.password, + }, + // TURN TCP - fallback + { + urls: 'turn:turn.example.com:3478?transport=tcp', + username: credentials.username, + credential: credentials.password, + }, + // TURNS (TLS) on 443 - works through most firewalls + { + urls: 'turns:turn.example.com:443?transport=tcp', + username: credentials.username, + credential: credentials.password, + }, +]; +``` + +## Credential Management + +### Long-Term Credentials + +Simple but less secure. Credentials are static: + +```typescript +{ + urls: 'turn:turn.example.com:3478', + username: 'static-user', + credential: 'static-password', +} +``` + +### Time-Limited Credentials (Recommended) + +Generate short-lived credentials from your server: + +```typescript +// Server-side (e.g., in your API) +function generateTurnCredentials(userId: string): { username: string; credential: string } { + const ttl = 86400; // 24 hours + const timestamp = Math.floor(Date.now() / 1000) + ttl; + const username = `${timestamp}:${userId}`; + + // HMAC-SHA1 with your TURN shared secret + const hmac = crypto.createHmac('sha1', TURN_SECRET); + hmac.update(username); + const credential = hmac.digest('base64'); + + return { username, credential }; +} + +// Client-side +const credentials = await fetch('/api/turn-credentials').then((r) => r.json()); + +const manager = new WebRTCManager({ + signalUrl: 'wss://example.com/signal', + roomId: 'my-room', + iceServers: [ + { urls: 'stun:stun.example.com:3478' }, + { + urls: 'turn:turn.example.com:3478', + username: credentials.username, + credential: credentials.credential, + }, + ], +}); +``` + +## Setting Up coturn + +[coturn](https://github.com/coturn/coturn) is the most popular open-source TURN server. + +### Basic coturn Configuration + +```conf +# /etc/turnserver.conf + +# Network +listening-port=3478 +tls-listening-port=5349 + +# Use your actual external IP +external-ip=203.0.113.1 + +# Domain +realm=example.com + +# Authentication +lt-cred-mech +user=username:password + +# Or for time-limited credentials +use-auth-secret +static-auth-secret=your-secret-here + +# TLS (for TURNS) +cert=/etc/ssl/certs/turn.example.com.pem +pkey=/etc/ssl/private/turn.example.com.key + +# Logging +log-file=/var/log/turnserver.log +verbose +``` + +### Running coturn with Docker + +```bash +docker run -d \ + --name coturn \ + --network host \ + coturn/coturn \ + -n \ + --listening-port=3478 \ + --tls-listening-port=5349 \ + --external-ip='$(detect-external-ip)' \ + --realm=example.com \ + --use-auth-secret \ + --static-auth-secret=your-secret-here \ + --cert=/etc/ssl/certs/turn.pem \ + --pkey=/etc/ssl/private/turn.key +``` + +## Verifying TURN Usage + +Use the connection stats API to verify TURN is being used: + +```typescript +const summary = await manager.getQualitySummary(peerId); + +if (summary?.candidatePair?.usingRelay) { + console.log('Connection is using TURN relay'); + console.log('Local candidate type:', summary.candidatePair.localType); + console.log('Remote candidate type:', summary.candidatePair.remoteType); +} +``` + +## Hosted TURN Services + +If you don't want to run your own TURN server: + +- [Twilio STUN/TURN](https://www.twilio.com/stun-turn) - Pay-per-use +- [Xirsys](https://xirsys.com/) - TURN-as-a-service +- [Metered](https://www.metered.ca/stun-turn) - STUN/TURN service + +## Example: Free TURN Service (Metered Free Tier) + +Metered offers a free tier with short-lived credentials. Generate credentials from their API and pass them into `iceServers`: + +```typescript +// Example response from your backend (values are placeholders) +const meteredCredentials = { + urls: [ + 'stun:stun.metered.ca:80', + 'turn:turn.metered.ca:80?transport=udp', + 'turn:turn.metered.ca:443?transport=tcp', + 'turns:turn.metered.ca:443?transport=tcp', + ], + username: 'METERED_USERNAME', + credential: 'METERED_CREDENTIAL', +}; + +const manager = new WebRTCManager({ + signalUrl: 'wss://example.com/signal', + roomId: 'my-room', + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { + urls: meteredCredentials.urls, + username: meteredCredentials.username, + credential: meteredCredentials.credential, + }, + ], +}); +``` + +> Note: Free tiers are great for development and testing. For production, ensure you understand the provider's limits and SLAs. + +## Example: Twilio / Xirsys TURN Credentials + +Twilio and Xirsys provide time-limited ICE server lists via their APIs. Fetch credentials from your backend and pass through: + +```typescript +// Backend returns provider-issued ICE servers +const { iceServers } = await fetch('/api/turn-credentials').then((r) => r.json()); + +const manager = new WebRTCManager({ + signalUrl: 'wss://example.com/signal', + roomId: 'my-room', + iceServers, +}); +``` + +## Troubleshooting + +### Connection Fails in Corporate Networks + +1. Ensure TURNS on port 443 is configured +2. Check that your TURN server allows TCP transport +3. Verify TLS certificates are valid + +### High Latency When Using TURN + +- TURN adds latency by design (relay through server) +- Ensure TURN server is geographically close to users +- Consider deploying multiple TURN servers in different regions + +### Credentials Rejected + +- For time-limited credentials, ensure server time is synchronized +- Verify the HMAC secret matches between server and coturn +- Check that credentials haven't expired + +### Testing TURN Connectivity + +Use [Trickle ICE](https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/) to test your TURN server configuration before deploying. diff --git a/e2e/webrtc.pw.ts b/e2e/webrtc.pw.ts new file mode 100644 index 000000000..025375add --- /dev/null +++ b/e2e/webrtc.pw.ts @@ -0,0 +1,1404 @@ +import { test, expect, type Page, type BrowserContext, type Browser } from '@playwright/test'; + +async function waitForPageLoad(page: Page) { + await expect(page.locator('h1')).toContainText('WebRTC', { timeout: 10000 }); +} + +async function createPeer(browser: Browser): Promise<{ context: BrowserContext; page: Page }> { + const context = await browser.newContext({ permissions: ['camera', 'microphone'] }); + const page = await context.newPage(); + await page.addInitScript(() => { + const mediaDevices = navigator.mediaDevices; + if (!mediaDevices) return; + if (!mediaDevices.getDisplayMedia) { + mediaDevices.getDisplayMedia = async () => + mediaDevices.getUserMedia({ video: true, audio: false }); + return; + } + const original = mediaDevices.getDisplayMedia.bind(mediaDevices); + mediaDevices.getDisplayMedia = async (constraints?: DisplayMediaStreamOptions) => { + try { + return await original(constraints); + } catch { + return mediaDevices.getUserMedia({ video: true, audio: false }); + } + }; + }); + return { context, page }; +} + +async function connectPeer( + page: Page, + roomId: string, + options?: { + enableVideo?: boolean; + enableAudio?: boolean; + maxReconnectAttempts?: number; + autoReconnect?: boolean; + } +): Promise { + await page.goto('/webrtc'); + await waitForPageLoad(page); + await page.getByTestId('room-id-input').clear(); + await page.getByTestId('room-id-input').fill(roomId); + // Explicitly handle video/audio checkbox state (both true and false) + // to ensure consistent state regardless of page defaults + if (options?.enableVideo === true) { + await page.getByTestId('enable-video').check(); + } else if (options?.enableVideo === false) { + await page.getByTestId('enable-video').uncheck(); + } + if (options?.enableAudio === true) { + await page.getByTestId('enable-audio').check(); + } else if (options?.enableAudio === false) { + await page.getByTestId('enable-audio').uncheck(); + } + if (options?.autoReconnect !== undefined) { + await page.getByTestId('auto-reconnect-toggle').setChecked(options.autoReconnect); + } + if (options?.maxReconnectAttempts !== undefined) { + await page.getByTestId('max-reconnect-input').fill(String(options.maxReconnectAttempts)); + } + await page.getByTestId('connect-btn').click(); +} + +test.describe('WebRTC Data Channels', () => { + test.describe('Single Peer', () => { + test('should connect to signaling server and reach signaling state', async ({ page }) => { + await page.goto('/webrtc'); + await waitForPageLoad(page); + + // Verify initial state + await expect(page.getByTestId('connection-state')).toContainText('idle'); + + // Connect + await page.getByTestId('connect-btn').click(); + + // Should transition to signaling (waiting for peer) + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Should have peer ID assigned + await expect(page.getByTestId('peer-id')).not.toContainText('N/A', { timeout: 5000 }); + }); + + test('should disconnect cleanly', async ({ page }) => { + await page.goto('/webrtc'); + await waitForPageLoad(page); + + // Connect + await page.getByTestId('connect-btn').click(); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Disconnect + await page.getByTestId('disconnect-btn').click(); + + // Should return to idle + await expect(page.getByTestId('connection-state')).toContainText('idle'); + await expect(page.getByTestId('peer-id')).toContainText('N/A'); + }); + + test('should join custom room', async ({ page }) => { + await page.goto('/webrtc'); + await waitForPageLoad(page); + + // Set custom room ID + const roomInput = page.getByTestId('room-id-input'); + await roomInput.clear(); + await roomInput.fill('custom-room-123'); + + // Connect + await page.getByTestId('connect-btn').click(); + + // Should connect successfully + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + }); + }); + + test.describe('Two Peers', () => { + let context1: BrowserContext; + let context2: BrowserContext; + let page1: Page; + let page2: Page; + + test.beforeEach(async ({ browser }) => { + // Create two separate browser contexts for two peers + ({ context: context1, page: page1 } = await createPeer(browser)); + ({ context: context2, page: page2 } = await createPeer(browser)); + }); + + test.afterEach(async () => { + await context1.close(); + await context2.close(); + }); + + test('should establish peer connection between two browsers', async () => { + const roomId = `test-room-${Date.now()}`; + + // Navigate both pages + await Promise.all([page1.goto('/webrtc'), page2.goto('/webrtc')]); + await Promise.all([waitForPageLoad(page1), waitForPageLoad(page2)]); + + // Set same room ID for both + await page1.getByTestId('room-id-input').clear(); + await page1.getByTestId('room-id-input').fill(roomId); + await page2.getByTestId('room-id-input').clear(); + await page2.getByTestId('room-id-input').fill(roomId); + + // Connect first peer + await page1.getByTestId('connect-btn').click(); + await expect(page1.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Connect second peer + await page2.getByTestId('connect-btn').click(); + + // Both should eventually reach connected state + await expect(page1.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + await expect(page2.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + + // Both should have peer IDs assigned + await expect(page1.getByTestId('peer-id')).not.toContainText('N/A', { + timeout: 5000, + }); + await expect(page2.getByTestId('peer-id')).not.toContainText('N/A', { + timeout: 5000, + }); + }); + + test('should open data channel between peers', async () => { + const roomId = `data-channel-${Date.now()}`; + + await Promise.all([page1.goto('/webrtc'), page2.goto('/webrtc')]); + await Promise.all([waitForPageLoad(page1), waitForPageLoad(page2)]); + + // Set same room ID + await page1.getByTestId('room-id-input').clear(); + await page1.getByTestId('room-id-input').fill(roomId); + await page2.getByTestId('room-id-input').clear(); + await page2.getByTestId('room-id-input').fill(roomId); + + // Connect both peers + await page1.getByTestId('connect-btn').click(); + await page2.getByTestId('connect-btn').click(); + + // Wait for connection + await expect(page1.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + await expect(page2.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + + // Data channel should be open on both + await expect(page1.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 5000, + }); + await expect(page2.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 5000, + }); + }); + + test('should send and receive string messages', async () => { + const roomId = `messaging-${Date.now()}`; + + await Promise.all([page1.goto('/webrtc'), page2.goto('/webrtc')]); + await Promise.all([waitForPageLoad(page1), waitForPageLoad(page2)]); + + // Set same room ID and connect + await page1.getByTestId('room-id-input').clear(); + await page1.getByTestId('room-id-input').fill(roomId); + await page2.getByTestId('room-id-input').clear(); + await page2.getByTestId('room-id-input').fill(roomId); + + await page1.getByTestId('connect-btn').click(); + await page2.getByTestId('connect-btn').click(); + + // Wait for data channel to open + await expect(page1.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 10000, + }); + await expect(page2.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 10000, + }); + + // Send message from peer 1 to peer 2 + await page1.getByTestId('message-input').fill('Hello from peer 1!'); + await page1.getByTestId('send-btn').click(); + + // Peer 2 should receive the message + await expect(page2.getByTestId('message-remote').first()).toContainText( + 'Hello from peer 1!', + { timeout: 5000 } + ); + + // Peer 1 should see their own message as local + await expect(page1.getByTestId('message-local').first()).toContainText( + 'Hello from peer 1!' + ); + + // Send message from peer 2 to peer 1 + await page2.getByTestId('message-input').fill('Hello from peer 2!'); + await page2.getByTestId('send-btn').click(); + + // Peer 1 should receive the message + await expect(page1.getByTestId('message-remote').first()).toContainText( + 'Hello from peer 2!', + { timeout: 5000 } + ); + }); + + test('should send and receive JSON messages', async () => { + const roomId = `json-test-${Date.now()}`; + + await Promise.all([page1.goto('/webrtc'), page2.goto('/webrtc')]); + await Promise.all([waitForPageLoad(page1), waitForPageLoad(page2)]); + + await page1.getByTestId('room-id-input').clear(); + await page1.getByTestId('room-id-input').fill(roomId); + await page2.getByTestId('room-id-input').clear(); + await page2.getByTestId('room-id-input').fill(roomId); + + await page1.getByTestId('connect-btn').click(); + await page2.getByTestId('connect-btn').click(); + + // Wait for data channel + await expect(page1.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 10000, + }); + await expect(page2.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 10000, + }); + + // Send JSON from peer 1 + await page1.getByTestId('send-json-btn').click(); + + // Peer 2 should receive JSON with ping type + await expect(page2.getByTestId('message-remote').first()).toContainText('ping', { + timeout: 5000, + }); + }); + + test('should handle peer disconnect gracefully', async () => { + const roomId = `disconnect-${Date.now()}`; + + await Promise.all([page1.goto('/webrtc'), page2.goto('/webrtc')]); + await Promise.all([waitForPageLoad(page1), waitForPageLoad(page2)]); + + await page1.getByTestId('room-id-input').clear(); + await page1.getByTestId('room-id-input').fill(roomId); + await page2.getByTestId('room-id-input').clear(); + await page2.getByTestId('room-id-input').fill(roomId); + + await page1.getByTestId('connect-btn').click(); + await page2.getByTestId('connect-btn').click(); + + // Wait for connection + await expect(page1.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + await expect(page2.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + + // Peer 2 disconnects + await page2.getByTestId('disconnect-btn').click(); + + // Peer 1 should detect the disconnect and go back to signaling + await expect(page1.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Peer 1's remote peer ID should be cleared or show waiting + await expect(page1.getByTestId('data-channel-state')).toContainText('Closed', { + timeout: 5000, + }); + }); + + test('should handle room rejoin after disconnect', async () => { + const roomId = `rejoin-${Date.now()}`; + + await Promise.all([page1.goto('/webrtc'), page2.goto('/webrtc')]); + await Promise.all([waitForPageLoad(page1), waitForPageLoad(page2)]); + + await page1.getByTestId('room-id-input').clear(); + await page1.getByTestId('room-id-input').fill(roomId); + await page2.getByTestId('room-id-input').clear(); + await page2.getByTestId('room-id-input').fill(roomId); + + // First connection + await page1.getByTestId('connect-btn').click(); + await page2.getByTestId('connect-btn').click(); + + await expect(page1.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + + // Peer 2 disconnects + await page2.getByTestId('disconnect-btn').click(); + await expect(page2.getByTestId('connection-state')).toContainText('idle'); + + // Peer 2 rejoins + await page2.getByTestId('connect-btn').click(); + + // Both should reconnect + await expect(page1.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + await expect(page2.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + + // Data channel should work again + await expect(page1.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 5000, + }); + await expect(page2.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 5000, + }); + }); + }); +}); + +test.describe('Multi-Peer Mesh', () => { + test('should connect three peers in the same room', async ({ browser }) => { + const roomId = `mesh-3-${Date.now()}`; + const peers = await Promise.all([ + createPeer(browser), + createPeer(browser), + createPeer(browser), + ]); + try { + await Promise.all(peers.map((peer) => connectPeer(peer.page, roomId))); + await Promise.all( + peers.map((peer) => + expect(peer.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }) + ) + ); + } finally { + await Promise.all(peers.map((peer) => peer.context.close())); + } + }); + + test('should connect four peers in the same room', async ({ browser }) => { + const roomId = `mesh-4-${Date.now()}`; + const peers = await Promise.all([ + createPeer(browser), + createPeer(browser), + createPeer(browser), + createPeer(browser), + ]); + try { + await Promise.all(peers.map((peer) => connectPeer(peer.page, roomId))); + await Promise.all( + peers.map((peer) => + expect(peer.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }) + ) + ); + } finally { + await Promise.all(peers.map((peer) => peer.context.close())); + } + }); + + test('should handle peer leave and rejoin with three peers', async ({ browser }) => { + const roomId = `mesh-rejoin-${Date.now()}`; + const peers = await Promise.all([ + createPeer(browser), + createPeer(browser), + createPeer(browser), + ]); + try { + // Connect all peers sequentially with small delays to help mesh formation + for (const peer of peers) { + await connectPeer(peer.page, roomId); + await peer.page.waitForTimeout(500); + } + // Wait for all peers to be fully connected + await Promise.all( + peers.map((peer) => + expect(peer.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 20000, + }) + ) + ); + + // peers[2] (third peer) disconnects + await peers[2].page.getByTestId('disconnect-btn').click(); + await expect(peers[2].page.getByTestId('connection-state')).toContainText('idle', { + timeout: 5000, + }); + + // Remaining peers (peers[0] and peers[1]) should still be connected + await expect(peers[0].page.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + await expect(peers[1].page.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + + // peers[2] (third peer) rejoins + await peers[2].page.getByTestId('connect-btn').click(); + await expect(peers[2].page.getByTestId('connection-state')).toContainText('connected', { + timeout: 20000, + }); + } finally { + await Promise.all(peers.map((peer) => peer.context.close())); + } + }); + + test('should send data channel messages with three peers', async ({ browser }) => { + const roomId = `mesh-chat-${Date.now()}`; + const peers = await Promise.all([ + createPeer(browser), + createPeer(browser), + createPeer(browser), + ]); + try { + // Connect all peers sequentially with small delays to help mesh formation + for (const peer of peers) { + await connectPeer(peer.page, roomId); + await peer.page.waitForTimeout(500); + } + // Wait for all peers to be fully connected + await Promise.all( + peers.map((peer) => + expect(peer.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 20000, + }) + ) + ); + // Wait for data channels to open on all peers + await Promise.all( + peers.map((peer) => + expect(peer.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 20000, + }) + ) + ); + // Small delay to allow all mesh data channels to stabilize + await peers[0].page.waitForTimeout(1000); + + await peers[0].page.getByTestId('message-input').fill('Hello mesh peers'); + await peers[0].page.getByTestId('send-btn').click(); + + await expect(peers[1].page.getByTestId('message-remote').first()).toContainText( + 'Hello mesh peers', + { timeout: 15000 } + ); + await expect(peers[2].page.getByTestId('message-remote').first()).toContainText( + 'Hello mesh peers', + { timeout: 15000 } + ); + } finally { + await Promise.all(peers.map((peer) => peer.context.close())); + } + }); +}); + +test.describe('Screen Sharing', () => { + test('should start and stop screen sharing locally', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `screen-local-${Date.now()}`; + try { + await connectPeer(page, roomId, { enableVideo: true }); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + await page.getByTestId('start-screen-share-btn').click(); + await expect(page.getByTestId('screen-share-state')).toContainText('On'); + + await page.getByTestId('stop-screen-share-btn').click(); + await expect(page.getByTestId('screen-share-state')).toContainText('Off'); + } finally { + await context.close(); + } + }); + + test('should notify remote peers on screen share', async ({ browser }) => { + const roomId = `screen-remote-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + await Promise.all([ + connectPeer(peer1.page, roomId, { enableVideo: true }), + connectPeer(peer2.page, roomId, { enableVideo: true }), + ]); + await expect(peer1.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }); + await expect(peer2.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }); + + await peer1.page.getByTestId('start-screen-share-btn').click(); + await expect(peer1.page.getByTestId('screen-share-state')).toContainText('On'); + await expect(peer2.page.getByTestId('messages')).toContainText('screen-share'); + + await peer1.page.getByTestId('stop-screen-share-btn').click(); + await expect(peer1.page.getByTestId('screen-share-state')).toContainText('Off'); + // Use regex to tolerate whitespace variations in JSON formatting + await expect(peer2.page.getByTestId('messages')).toContainText(/"active"\s*:\s*false/); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); +}); + +test.describe('Recording', () => { + test('should record local stream and produce a blob', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `recording-${Date.now()}`; + try { + await connectPeer(page, roomId, { enableVideo: true, enableAudio: true }); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + await page.getByTestId('start-recording-btn').click(); + await page.waitForTimeout(1500); + await page.getByTestId('stop-recording-btn').click(); + + await expect(page.getByTestId('recording-state')).toContainText('inactive'); + const sizeText = await page.getByTestId('recording-size').innerText(); + const sizeMatch = sizeText.match(/(\d+) bytes/); + expect(sizeMatch ? Number(sizeMatch[1]) : 0).toBeGreaterThan(0); + } finally { + await context.close(); + } + }); +}); + +test.describe('Reconnection', () => { + test('should reconnect after WebSocket disconnect', async ({ browser }) => { + const roomId = `reconnect-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + await Promise.all([connectPeer(peer1.page, roomId), connectPeer(peer2.page, roomId)]); + await expect(peer1.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }); + await expect(peer2.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }); + + await peer1.page.getByTestId('force-ws-close-btn').click(); + await expect(peer1.page.getByTestId('reconnect-status')).toContainText('reconnecting', { + timeout: 15000, + }); + + await expect(peer1.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 20000, + }); + await expect(peer1.page.getByTestId('reconnect-status')).toContainText('reconnected'); + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 15000, + }); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); + + test('should stop after max reconnect attempts', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `reconnect-fail-${Date.now()}`; + try { + await connectPeer(page, roomId, { autoReconnect: true, maxReconnectAttempts: 1 }); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Set an invalid signal URL before forcing WS close to make reconnection fail + // Note: page.route() only blocks HTTP requests, not WebSocket connections + await page.getByTestId('set-invalid-signal-url-btn').click(); + await page.getByTestId('force-ws-close-btn').click(); + + await expect(page.getByTestId('reconnect-status')).toContainText('failed', { + timeout: 20000, + }); + } finally { + await context.close(); + } + }); +}); + +test.describe('Cursor Tracking', () => { + test('should display cursor canvas when connected', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `cursor-canvas-${Date.now()}`; + try { + await connectPeer(page, roomId); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Cursor canvas should be visible when connected + await expect(page.getByTestId('cursor-canvas')).toBeVisible(); + } finally { + await context.close(); + } + }); + + test('should send cursor position on mouse move', async ({ browser }) => { + const roomId = `cursor-move-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + await Promise.all([connectPeer(peer1.page, roomId), connectPeer(peer2.page, roomId)]); + + // Wait for connection and data channel + await expect(peer1.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }); + await expect(peer2.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }); + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 10000, + }); + await expect(peer2.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 10000, + }); + + // Get the canvas element on peer1 and move mouse + const canvas = peer1.page.getByTestId('cursor-canvas'); + await canvas.hover({ position: { x: 300, y: 150 } }); + + // Wait a bit for cursor data to be sent through data channel + await peer1.page.waitForTimeout(200); + + // Move mouse to trigger more cursor updates + await canvas.hover({ position: { x: 100, y: 100 } }); + await peer1.page.waitForTimeout(200); + + // The cursor channel should work - verify canvas is still visible + await expect(peer1.page.getByTestId('cursor-canvas')).toBeVisible(); + await expect(peer2.page.getByTestId('cursor-canvas')).toBeVisible(); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); + + test('should not show cursor canvas when disconnected', async ({ page }) => { + await page.goto('/webrtc'); + await waitForPageLoad(page); + + // In idle state, cursor canvas should not be visible + await expect(page.getByTestId('cursor-canvas')).not.toBeVisible(); + }); +}); + +test.describe('Media Controls', () => { + test('should toggle audio mute state', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `audio-mute-${Date.now()}`; + try { + await connectPeer(page, roomId, { enableAudio: true }); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Initial state - audio should not be muted + const muteBtn = page.getByTestId('mute-audio-btn'); + await expect(muteBtn).toBeVisible(); + await expect(muteBtn).toContainText('Mute'); + + // Click to mute + await muteBtn.click(); + await expect(muteBtn).toContainText('Unmute'); + + // Click to unmute + await muteBtn.click(); + await expect(muteBtn).toContainText('Mute'); + } finally { + await context.close(); + } + }); + + test('should toggle video mute state', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `video-mute-${Date.now()}`; + try { + await connectPeer(page, roomId, { enableVideo: true }); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Initial state - video should not be muted + const muteBtn = page.getByTestId('mute-video-btn'); + await expect(muteBtn).toBeVisible(); + await expect(muteBtn).toContainText('Hide'); + + // Click to hide video + await muteBtn.click(); + await expect(muteBtn).toContainText('Show'); + + // Click to show video + await muteBtn.click(); + await expect(muteBtn).toContainText('Hide'); + } finally { + await context.close(); + } + }); + + test('should maintain mute state after toggle', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `mute-persist-${Date.now()}`; + try { + await connectPeer(page, roomId, { enableAudio: true, enableVideo: true }); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Toggle both mute states + await page.getByTestId('mute-audio-btn').click(); + await page.getByTestId('mute-video-btn').click(); + + // Verify both are muted + await expect(page.getByTestId('mute-audio-btn')).toContainText('Unmute'); + await expect(page.getByTestId('mute-video-btn')).toContainText('Show'); + + // Wait and verify state persists + await page.waitForTimeout(500); + await expect(page.getByTestId('mute-audio-btn')).toContainText('Unmute'); + await expect(page.getByTestId('mute-video-btn')).toContainText('Show'); + } finally { + await context.close(); + } + }); +}); + +test.describe('Error Handling', () => { + test('should display error on connection failure', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `error-test-${Date.now()}`; + try { + await connectPeer(page, roomId, { autoReconnect: false }); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Set invalid signal URL and force close to trigger error + await page.getByTestId('set-invalid-signal-url-btn').click(); + await page.getByTestId('force-ws-close-btn').click(); + + // With auto-reconnect disabled and an invalid signal URL, should reach idle or error state + await expect(page.getByTestId('connection-state')).toContainText(/idle|error/, { + timeout: 10000, + }); + } finally { + await context.close(); + } + }); +}); + +test.describe('Media Stream Assignment', () => { + test('should assign local stream to video element', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `local-stream-${Date.now()}`; + try { + await connectPeer(page, roomId, { enableVideo: true }); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Local video element should be visible + const localVideo = page.getByTestId('local-video'); + await expect(localVideo).toBeVisible(); + + // Check that video has a srcObject assigned (stream is playing) + const hasSrcObject = await localVideo.evaluate((el) => { + const video = el as HTMLVideoElement; + return video.srcObject !== null; + }); + expect(hasSrcObject).toBe(true); + } finally { + await context.close(); + } + }); + + test('should assign remote stream to video element when peer connects', async ({ browser }) => { + const roomId = `remote-stream-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + // Connect peer1 first + await connectPeer(peer1.page, roomId, { enableVideo: true }); + await expect(peer1.page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Connect peer2 + await connectPeer(peer2.page, roomId, { enableVideo: true }); + + // Wait for connection + await expect(peer1.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }); + await expect(peer2.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }); + + // Wait for remote peer ID to be populated (triggers video element render) + await expect(peer1.page.getByTestId('remote-peer-id')).not.toContainText('Waiting...', { + timeout: 15000, + }); + + // Remote video element should now be visible + await expect(peer1.page.locator(`[data-testid^="remote-video-"]`).first()).toBeVisible({ + timeout: 10000, + }); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); +}); + +test.describe('Peer List Updates', () => { + test('should show remote peer ID when peer joins', async ({ browser }) => { + const roomId = `peer-join-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + // Connect peer1 first + await connectPeer(peer1.page, roomId); + await expect(peer1.page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Remote peer should show "Waiting..." initially + await expect(peer1.page.getByTestId('remote-peer-id')).toContainText('Waiting...'); + + // Connect peer2 + await connectPeer(peer2.page, roomId); + + // Wait for connection + await expect(peer1.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }); + + // Remote peer ID should now be shown (not "Waiting...") + await expect(peer1.page.getByTestId('remote-peer-id')).not.toContainText('Waiting...', { + timeout: 5000, + }); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); + + test('should clear remote peer ID when peer disconnects', async ({ browser }) => { + const roomId = `peer-leave-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + await Promise.all([connectPeer(peer1.page, roomId), connectPeer(peer2.page, roomId)]); + + // Wait for full connection with data channel + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 15000, + }); + await expect(peer2.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 15000, + }); + + // Peer2 disconnects + await peer2.page.getByTestId('disconnect-btn').click(); + + // Peer1 should see remote peer cleared (back to Waiting...) + await expect(peer1.page.getByTestId('remote-peer-id')).toContainText('Waiting...', { + timeout: 10000, + }); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); + + test('should show peer IDs on both sides when fully connected', async ({ browser }) => { + const roomId = `peer-bidirectional-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + // Connect both peers + await Promise.all([connectPeer(peer1.page, roomId), connectPeer(peer2.page, roomId)]); + + // Wait for both to have data channel open (full connectivity) + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 15000, + }); + await expect(peer2.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 15000, + }); + + // Test that messages can be sent (proves both sides see each other) + await peer1.page.getByTestId('message-input').fill('hello from peer1'); + await peer1.page.getByTestId('send-btn').click(); + + // Peer2 should receive the message (proves peer1 knows about peer2) + await expect(peer2.page.getByTestId('messages')).toContainText('hello from peer1', { + timeout: 5000, + }); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); +}); + +test.describe('State Cleanup on Disconnect', () => { + test('should reset connection state to idle on disconnect', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `cleanup-state-${Date.now()}`; + try { + await connectPeer(page, roomId); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Disconnect + await page.getByTestId('disconnect-btn').click(); + + // Should be idle + await expect(page.getByTestId('connection-state')).toContainText('idle'); + } finally { + await context.close(); + } + }); + + test('should clear peer ID on disconnect', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `cleanup-peerid-${Date.now()}`; + try { + await connectPeer(page, roomId); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Wait for peer ID to be assigned + await expect(page.getByTestId('peer-id')).not.toContainText('N/A', { timeout: 5000 }); + + // Disconnect + await page.getByTestId('disconnect-btn').click(); + + // Peer ID should be cleared + await expect(page.getByTestId('peer-id')).toContainText('N/A'); + } finally { + await context.close(); + } + }); + + test('should clear remote peer IDs on disconnect', async ({ browser }) => { + const roomId = `cleanup-remote-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + await Promise.all([connectPeer(peer1.page, roomId), connectPeer(peer2.page, roomId)]); + + // Wait for full connection with data channel open + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 15000, + }); + + // Disconnect peer1 + await peer1.page.getByTestId('disconnect-btn').click(); + + // Remote peer IDs should be cleared (back to Waiting...) + await expect(peer1.page.getByTestId('remote-peer-id')).toContainText('Waiting...'); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); + + test('should close data channel on disconnect', async ({ browser }) => { + const roomId = `cleanup-datachannel-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + await Promise.all([connectPeer(peer1.page, roomId), connectPeer(peer2.page, roomId)]); + + // Wait for data channel to open + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 15000, + }); + + // Disconnect + await peer1.page.getByTestId('disconnect-btn').click(); + + // Data channel should be closed + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Closed'); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); + + test('should reset screen share state on disconnect', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `cleanup-screenshare-${Date.now()}`; + try { + await connectPeer(page, roomId, { enableVideo: true }); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Start screen share + await page.getByTestId('start-screen-share-btn').click(); + await expect(page.getByTestId('screen-share-state')).toContainText('On'); + + // Disconnect + await page.getByTestId('disconnect-btn').click(); + + // Screen share state should be reset to Off + await expect(page.getByTestId('screen-share-state')).toContainText('Off'); + } finally { + await context.close(); + } + }); + + test('should reset reconnect status on disconnect', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `cleanup-reconnect-${Date.now()}`; + try { + await connectPeer(page, roomId); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Verify initial reconnect status + await expect(page.getByTestId('reconnect-status')).toContainText('idle'); + + // Disconnect + await page.getByTestId('disconnect-btn').click(); + + // Reconnect status should remain idle after disconnect + await expect(page.getByTestId('reconnect-status')).toContainText('idle'); + } finally { + await context.close(); + } + }); +}); + +test.describe('State Transitions', () => { + test('should complete full state lifecycle: idle → signaling → connected → idle', async ({ + browser, + }) => { + const roomId = `state-lifecycle-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + // Navigate peer1 first + await peer1.page.goto('/webrtc'); + await waitForPageLoad(peer1.page); + + // Verify initial state is 'idle' + await expect(peer1.page.getByTestId('connection-state')).toContainText('idle'); + + // Set room ID + await peer1.page.getByTestId('room-id-input').clear(); + await peer1.page.getByTestId('room-id-input').fill(roomId); + + // Connect - should transition to 'signaling' + await peer1.page.getByTestId('connect-btn').click(); + await expect(peer1.page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Connect second peer to trigger 'connected' state + await connectPeer(peer2.page, roomId); + await expect(peer1.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + await expect(peer2.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 10000, + }); + + // Disconnect - should return to 'idle' + await peer1.page.getByTestId('disconnect-btn').click(); + await expect(peer1.page.getByTestId('connection-state')).toContainText('idle'); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); +}); + +test.describe('Recording Metadata', () => { + test('should set recording MIME type correctly', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `recording-mime-${Date.now()}`; + try { + await connectPeer(page, roomId, { enableVideo: true, enableAudio: true }); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Initial MIME should be N/A + await expect(page.getByTestId('recording-mime')).toContainText('N/A'); + + // Start and stop recording + await page.getByTestId('start-recording-btn').click(); + await page.waitForTimeout(1000); + await page.getByTestId('stop-recording-btn').click(); + + // After recording, MIME type should be set (webm or mp4) + await expect(page.getByTestId('recording-mime')).not.toContainText('N/A', { + timeout: 5000, + }); + const mimeText = await page.getByTestId('recording-mime').innerText(); + expect(mimeText).toMatch(/video\/(webm|mp4)/); + } finally { + await context.close(); + } + }); + + test('should show recording size after recording stops', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `recording-size-${Date.now()}`; + try { + await connectPeer(page, roomId, { enableVideo: true, enableAudio: true }); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Initial size should be N/A + await expect(page.getByTestId('recording-size')).toContainText('N/A'); + + // Start recording + await page.getByTestId('start-recording-btn').click(); + await expect(page.getByTestId('recording-state')).toContainText('recording', { + timeout: 5000, + }); + + // Wait for recording to accumulate data + await page.waitForTimeout(2000); + + // Stop recording + await page.getByTestId('stop-recording-btn').click(); + + // Size should now show bytes and be > 0 + await expect(page.getByTestId('recording-size')).toContainText('bytes', { + timeout: 5000, + }); + const sizeText = await page.getByTestId('recording-size').innerText(); + const sizeMatch = sizeText.match(/(\d+) bytes/); + expect(sizeMatch).toBeTruthy(); + expect(Number(sizeMatch?.[1])).toBeGreaterThan(0); + } finally { + await context.close(); + } + }); +}); + +test.describe('Message Metadata', () => { + test('should display sent messages as local and received as remote', async ({ browser }) => { + const roomId = `message-metadata-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + await Promise.all([connectPeer(peer1.page, roomId), connectPeer(peer2.page, roomId)]); + + // Wait for data channels to open + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 10000, + }); + await expect(peer2.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 10000, + }); + + // Peer 1 sends a message + await peer1.page.getByTestId('message-input').fill('Test message from peer 1'); + await peer1.page.getByTestId('send-btn').click(); + + // On peer 1, message should appear as local (data-testid="message-local") + await expect(peer1.page.getByTestId('message-local').first()).toContainText( + 'Test message from peer 1', + { timeout: 5000 } + ); + + // On peer 2, same message should appear as remote (data-testid="message-remote") + await expect(peer2.page.getByTestId('message-remote').first()).toContainText( + 'Test message from peer 1', + { timeout: 5000 } + ); + + // Verify the local message shows "You:" prefix + const localMessageText = await peer1.page.getByTestId('message-local').first().innerText(); + expect(localMessageText).toContain('You:'); + + // Verify the remote message shows "Remote" prefix + const remoteMessageText = await peer2.page + .getByTestId('message-remote') + .first() + .innerText(); + expect(remoteMessageText).toContain('Remote'); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); + + test('should include timestamps in messages', async ({ browser }) => { + const roomId = `message-timestamp-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + await Promise.all([connectPeer(peer1.page, roomId), connectPeer(peer2.page, roomId)]); + + // Wait for data channels to open + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 10000, + }); + + // Send JSON message which includes timestamp + const beforeSend = Date.now(); + await peer1.page.getByTestId('send-json-btn').click(); + const afterSend = Date.now(); + + // Verify message appears on peer2 + await expect(peer2.page.getByTestId('message-remote').first()).toContainText('ping', { + timeout: 5000, + }); + + // The JSON message should contain a timestamp field + const messageContent = await peer2.page.getByTestId('message-remote').first().innerText(); + expect(messageContent).toContain('timestamp'); + + // Extract and verify timestamp is within expected range + const timestampMatch = messageContent.match(/"timestamp":\s*(\d+)/); + expect(timestampMatch).toBeTruthy(); + const timestamp = Number(timestampMatch?.[1]); + expect(timestamp).toBeGreaterThanOrEqual(beforeSend); + expect(timestamp).toBeLessThanOrEqual(afterSend + 1000); // Allow 1s tolerance + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); +}); + +test.describe('Connection Info Display', () => { + test('should display valid peer ID after connection', async ({ browser }) => { + const { context, page } = await createPeer(browser); + const roomId = `peer-id-display-${Date.now()}`; + try { + await connectPeer(page, roomId); + await expect(page.getByTestId('connection-state')).toContainText('signaling', { + timeout: 5000, + }); + + // Wait for peer ID to be assigned + await expect(page.getByTestId('peer-id')).not.toContainText('N/A', { timeout: 5000 }); + + // Verify peer ID format (should be non-empty string) + const peerIdText = await page.getByTestId('peer-id').innerText(); + const peerId = peerIdText.replace('My Peer ID: ', '').trim(); + + // Peer ID should not be empty and should have reasonable length + expect(peerId.length).toBeGreaterThan(0); + expect(peerId).not.toBe('N/A'); + expect(peerId).not.toBe('null'); + expect(peerId).not.toBe('undefined'); + } finally { + await context.close(); + } + }); + + test('should show both local and remote peer IDs when connected', async ({ browser }) => { + const roomId = `peer-ids-both-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + await Promise.all([connectPeer(peer1.page, roomId), connectPeer(peer2.page, roomId)]); + + // Wait for full connection with data channel open + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 15000, + }); + await expect(peer2.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 15000, + }); + + // Verify both peers have valid peer IDs (not N/A) + await expect(peer1.page.getByTestId('peer-id')).not.toContainText('N/A', { + timeout: 5000, + }); + await expect(peer2.page.getByTestId('peer-id')).not.toContainText('N/A', { + timeout: 5000, + }); + + // Get peer IDs + const peer1IdText = await peer1.page.getByTestId('peer-id').innerText(); + const peer1Id = peer1IdText.replace('My Peer ID: ', '').trim(); + const peer2IdText = await peer2.page.getByTestId('peer-id').innerText(); + const peer2Id = peer2IdText.replace('My Peer ID: ', '').trim(); + + // Peer IDs should be different (unique per peer) + expect(peer1Id).not.toBe(peer2Id); + expect(peer1Id.length).toBeGreaterThan(5); + expect(peer2Id.length).toBeGreaterThan(5); + + // Verify connectivity by sending a message - this proves peers see each other + await peer1.page.getByTestId('message-input').fill('verification message'); + await peer1.page.getByTestId('send-btn').click(); + await expect(peer2.page.getByTestId('message-remote').first()).toContainText( + 'verification message', + { timeout: 5000 } + ); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); +}); + +test.describe('Data Channel State Display', () => { + test('should show correct data channel state text for both peers', async ({ browser }) => { + const roomId = `data-channel-display-${Date.now()}`; + const peer1 = await createPeer(browser); + const peer2 = await createPeer(browser); + try { + // Initially check data channel is closed + await peer1.page.goto('/webrtc'); + await waitForPageLoad(peer1.page); + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Closed'); + + // Connect both peers + await peer1.page.getByTestId('room-id-input').clear(); + await peer1.page.getByTestId('room-id-input').fill(roomId); + await peer1.page.getByTestId('connect-btn').click(); + await connectPeer(peer2.page, roomId); + + // Wait for connection + await expect(peer1.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }); + await expect(peer2.page.getByTestId('connection-state')).toContainText('connected', { + timeout: 15000, + }); + + // Both peers should show "Open" for data channel + await expect(peer1.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 5000, + }); + await expect(peer2.page.getByTestId('data-channel-state')).toContainText('Open', { + timeout: 5000, + }); + + // Verify exact text format + const peer1ChannelText = await peer1.page.getByTestId('data-channel-state').innerText(); + const peer2ChannelText = await peer2.page.getByTestId('data-channel-state').innerText(); + expect(peer1ChannelText).toContain('Data Channel:'); + expect(peer2ChannelText).toContain('Data Channel:'); + } finally { + await Promise.all([peer1.context.close(), peer2.context.close()]); + } + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 04ee4a580..4d1216df0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -162,4 +162,23 @@ export { type WorkbenchConfig, } from './workbench-config'; +// webrtc.ts exports +export type { + SDPDescription, + ICECandidate, + SignalMessage, + SignalMsg, + WebRTCConnectionState, + WebRTCDisconnectReason, + DataChannelConfig, + DataChannelMessage, + DataChannelState, + WebRTCSignalingCallbacks, + ConnectionQualitySummary, + RecordingOptions, + RecordingHandle, + RecordingState, + TrackSource, +} 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 000000000..f9343f1cd --- /dev/null +++ b/packages/core/src/webrtc.ts @@ -0,0 +1,259 @@ +/** + * 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'; + +// ============================================================================= +// Data Channel Types +// ============================================================================= + +/** + * Configuration for creating a data channel. + */ +export interface DataChannelConfig { + /** Unique label for the channel */ + label: string; + /** Whether messages are ordered (default: true) */ + ordered?: boolean; + /** Maximum retransmit time in milliseconds */ + maxPacketLifeTime?: number; + /** Maximum number of retransmissions */ + maxRetransmits?: number; + /** Sub-protocol name */ + protocol?: string; +} + +/** + * Message types for data channel communication. + */ +export type DataChannelMessage = + | { type: 'string'; data: string } + | { type: 'binary'; data: ArrayBuffer } + | { type: 'json'; data: unknown }; + +/** + * Data channel state. + */ +export type DataChannelState = 'connecting' | 'open' | 'closing' | 'closed'; + +// ============================================================================= +// Connection Quality / Stats Types +// ============================================================================= + +/** + * Normalized connection quality summary. + * Derived from RTCPeerConnection.getStats() for easy consumption. + */ +export interface ConnectionQualitySummary { + /** Round-trip time in milliseconds */ + rtt?: number; + /** Packet loss percentage (0-100) */ + packetLossPercent?: number; + /** Jitter in milliseconds (audio) */ + jitter?: number; + /** Current bitrate in bits per second */ + bitrate?: { + audio?: { inbound?: number; outbound?: number }; + video?: { inbound?: number; outbound?: number }; + }; + /** Video metrics */ + video?: { + framesPerSecond?: number; + framesDropped?: number; + frameWidth?: number; + frameHeight?: number; + }; + /** ICE candidate pair info */ + candidatePair?: { + localType?: string; + remoteType?: string; + protocol?: string; + usingRelay?: boolean; + }; + /** Timestamp when stats were collected */ + timestamp: number; +} + +/** + * Recording options for MediaRecorder. + */ +export interface RecordingOptions { + /** MIME type for recording (default: 'video/webm;codecs=vp9,opus' or 'audio/webm;codecs=opus') */ + mimeType?: string; + /** Audio bits per second */ + audioBitsPerSecond?: number; + /** Video bits per second */ + videoBitsPerSecond?: number; +} + +/** + * Recording handle for controlling an active recording. + */ +export interface RecordingHandle { + /** Stop recording and get the blob */ + stop(): Promise; + /** Pause recording */ + pause(): void; + /** Resume recording */ + resume(): void; + /** Current recording state */ + readonly state: RecordingState; +} + +/** + * Recording state. + */ +export type RecordingState = 'inactive' | 'recording' | 'paused'; + +// ============================================================================= +// Track Source Types +// ============================================================================= + +/** + * Abstract track source interface for custom media sources. + * Implementations can provide camera, screen share, or custom tracks. + * + * Note: This interface is implemented in @agentuity/frontend where + * browser APIs (MediaStream) are available. + */ +export interface TrackSource { + /** Get the media stream from this source (returns browser MediaStream) */ + getStream(): Promise; + /** Stop the source and release resources */ + stop(): void; + /** Source type identifier */ + readonly type: 'user-media' | 'display-media' | 'custom'; +} + +// ============================================================================= +// 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/README.md b/packages/frontend/README.md index 03894fab9..c4682079b 100644 --- a/packages/frontend/README.md +++ b/packages/frontend/README.md @@ -18,6 +18,7 @@ npm install @agentuity/frontend - **Reconnection Logic**: Exponential backoff reconnection manager for WebSockets and SSE - **Type Definitions**: Shared TypeScript types for route registries - **Memoization**: JSON-based equality checking +- **WebRTC**: Multi-peer connections with data channels, screen sharing, and recording ## Usage @@ -60,6 +61,36 @@ import { deserializeData } from '@agentuity/frontend'; const data = deserializeData('{"key":"value"}'); ``` +### WebRTC + +Multi-peer WebRTC connections with auto-reconnection and data channels: + +```typescript +import { WebRTCManager } from '@agentuity/frontend'; + +const manager = new WebRTCManager({ + signalUrl: 'ws://localhost:3500/api/webrtc/signal', + roomId: 'my-room', + autoReconnect: true, +}); + +manager.on('peerConnected', (peerId) => console.log('Peer joined:', peerId)); +manager.on('dataChannelMessage', ({ channel, data }) => console.log(channel, data)); + +await manager.connect(); +manager.sendJSON('chat', { message: 'Hello!' }); +``` + +Key features: +- Multi-peer mesh networking +- Auto-reconnection with exponential backoff +- Data channels (JSON, binary, ArrayBuffer) +- Screen sharing and recording +- ICE/connection timeout detection +- Connection quality stats API + +For detailed architecture, see [docs/webrtc-architecture.md](../../docs/webrtc-architecture.md). + ## License Apache-2.0 diff --git a/packages/frontend/src/index.ts b/packages/frontend/src/index.ts index 7126a94f8..72bc6cd6f 100644 --- a/packages/frontend/src/index.ts +++ b/packages/frontend/src/index.ts @@ -23,6 +23,29 @@ export { type EventStreamManagerOptions, type EventStreamManagerState, } from './eventstream-manager'; +export { + WebRTCManager, + UserMediaSource, + DisplayMediaSource, + CustomStreamSource, + type WebRTCManagerOptions, + type WebRTCManagerState, + type WebRTCClientCallbacks, + type TrackSource, +} from './webrtc-manager'; + +// Re-export core WebRTC types for convenience +export type { + WebRTCConnectionState, + WebRTCDisconnectReason, + DataChannelConfig, + DataChannelMessage, + DataChannelState, + ConnectionQualitySummary, + RecordingOptions, + RecordingHandle, + RecordingState, +} from '@agentuity/core'; // 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 000000000..c6bb411af --- /dev/null +++ b/packages/frontend/src/webrtc-manager.ts @@ -0,0 +1,1631 @@ +import type { + SignalMessage, + WebRTCConnectionState, + WebRTCDisconnectReason, + DataChannelConfig, + DataChannelState, + ConnectionQualitySummary, + RecordingOptions, + RecordingHandle, + RecordingState, + TrackSource as CoreTrackSource, +} from '@agentuity/core'; +import { createReconnectManager, type ReconnectManager } from './reconnect'; + +/** + * Track source interface extended for browser environment. + */ +export interface TrackSource extends Omit { + getStream(): Promise; +} + +// ============================================================================= +// Track Sources +// ============================================================================= + +/** + * User media (camera/microphone) track source. + */ +export class UserMediaSource implements TrackSource { + readonly type = 'user-media' as const; + private stream: MediaStream | null = null; + + constructor(private constraints: MediaStreamConstraints = { video: true, audio: true }) {} + + async getStream(): Promise { + this.stream = await navigator.mediaDevices.getUserMedia(this.constraints); + return this.stream; + } + + stop(): void { + if (this.stream) { + for (const track of this.stream.getTracks()) { + track.stop(); + } + this.stream = null; + } + } +} + +/** + * Display media (screen share) track source. + */ +export class DisplayMediaSource implements TrackSource { + readonly type = 'display-media' as const; + private stream: MediaStream | null = null; + + constructor(private constraints: DisplayMediaStreamOptions = { video: true, audio: false }) {} + + async getStream(): Promise { + this.stream = await navigator.mediaDevices.getDisplayMedia(this.constraints); + return this.stream; + } + + stop(): void { + if (this.stream) { + for (const track of this.stream.getTracks()) { + track.stop(); + } + this.stream = null; + } + } +} + +/** + * Custom stream track source - wraps a user-provided MediaStream. + */ +export class CustomStreamSource implements TrackSource { + readonly type = 'custom' as const; + + constructor(private stream: MediaStream) {} + + async getStream(): Promise { + return this.stream; + } + + stop(): void { + for (const track of this.stream.getTracks()) { + track.stop(); + } + } +} + +// ============================================================================= +// Per-Peer Session +// ============================================================================= + +/** + * Represents a connection to a single remote peer. + */ +interface PeerSession { + peerId: string; + pc: RTCPeerConnection; + remoteStream: MediaStream | null; + dataChannels: Map; + makingOffer: boolean; + ignoreOffer: boolean; + hasRemoteDescription: boolean; + pendingCandidates: RTCIceCandidateInit[]; + isOfferer: boolean; + negotiationStarted: boolean; + lastStats?: RTCStatsReport; + lastStatsTime?: number; + hasIceCandidate?: boolean; + iceGatheringTimer?: ReturnType | null; +} + +// ============================================================================= +// Callbacks +// ============================================================================= + +/** + * Callbacks for WebRTC client state changes and events. + * All callbacks are optional - only subscribe to events you care about. + */ +export interface WebRTCClientCallbacks { + /** + * Called on every state transition. + */ + onStateChange?: ( + from: WebRTCConnectionState, + to: WebRTCConnectionState, + reason?: string + ) => void; + + /** + * Called when connected to at least one peer. + */ + onConnect?: () => void; + + /** + * Called when disconnected from all peers. + */ + onDisconnect?: (reason: WebRTCDisconnectReason) => void; + + /** + * Called when local media stream is acquired. + */ + onLocalStream?: (stream: MediaStream) => void; + + /** + * Called when a remote media stream is received. + */ + onRemoteStream?: (peerId: string, stream: MediaStream) => void; + + /** + * Called when a new track is added to a stream. + */ + onTrackAdded?: (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void; + + /** + * Called when a track is removed from a stream. + */ + onTrackRemoved?: (peerId: string, track: MediaStreamTrack) => void; + + /** + * Called when a peer joins the room. + */ + onPeerJoined?: (peerId: string) => void; + + /** + * Called when a peer leaves the room. + */ + onPeerLeft?: (peerId: string) => void; + + /** + * Called when negotiation starts with a peer. + */ + onNegotiationStart?: (peerId: string) => void; + + /** + * Called when negotiation completes with a peer. + */ + onNegotiationComplete?: (peerId: string) => void; + + /** + * Called for each ICE candidate generated. + */ + onIceCandidate?: (peerId: string, candidate: RTCIceCandidateInit) => void; + + /** + * Called when ICE connection state changes for a peer. + */ + onIceStateChange?: (peerId: string, state: string) => void; + + /** + * Called when an error occurs. + */ + onError?: (error: Error, state: WebRTCConnectionState) => void; + + /** + * Called when a data channel is opened. + */ + onDataChannelOpen?: (peerId: string, label: string) => void; + + /** + * Called when a data channel is closed. + */ + onDataChannelClose?: (peerId: string, label: string) => void; + + /** + * Called when a message is received on a data channel. + * + * **Note:** String messages are automatically parsed as JSON if valid. + * - If the message is valid JSON, `data` will be the parsed object/array/value + * - If the message is not valid JSON, `data` will be the raw string + * - Binary messages (ArrayBuffer) are passed through unchanged + * + * To distinguish between parsed JSON and raw strings, check the type: + * ```ts + * onDataChannelMessage: (peerId, label, data) => { + * if (typeof data === 'string') { + * // Raw string (failed JSON parse) + * } else if (data instanceof ArrayBuffer) { + * // Binary data + * } else { + * // Parsed JSON object/array/primitive + * } + * } + * ``` + */ + onDataChannelMessage?: ( + peerId: string, + label: string, + data: string | ArrayBuffer | unknown + ) => void; + + /** + * Called when a data channel error occurs. + */ + onDataChannelError?: (peerId: string, label: string, error: Error) => void; + + /** + * Called when screen sharing starts. + */ + onScreenShareStart?: () => void; + + /** + * Called when screen sharing stops. + */ + onScreenShareStop?: () => void; + + /** + * Called when a reconnect attempt is scheduled. + */ + onReconnecting?: (attempt: number) => void; + + /** + * Called after a successful reconnection. + */ + onReconnected?: () => void; + + /** + * Called when reconnect attempts are exhausted. + */ + onReconnectFailed?: () => void; +} + +// ============================================================================= +// Options and State +// ============================================================================= + +/** + * 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: auto-determined) */ + polite?: boolean; + /** ICE servers configuration */ + iceServers?: RTCIceServer[]; + /** + * Media source configuration. + * - `false`: Data-only mode (no media) + * - `MediaStreamConstraints`: Use getUserMedia with these constraints + * - `TrackSource`: Use a custom track source + * Default: { video: true, audio: true } + */ + media?: MediaStreamConstraints | TrackSource | false; + /** + * Data channels to create when connection is established. + * Only the offerer (late joiner) creates channels; the answerer receives them. + */ + dataChannels?: DataChannelConfig[]; + /** + * Callbacks for state changes and events. + */ + callbacks?: WebRTCClientCallbacks; + /** + * Whether to auto-reconnect on WebSocket/ICE failures (default: true) + */ + autoReconnect?: boolean; + /** + * Maximum reconnection attempts before giving up (default: 5) + */ + maxReconnectAttempts?: number; + /** + * Connection timeout in ms for connecting/negotiating (default: 30000) + */ + connectionTimeout?: number; + /** + * ICE gathering timeout in ms (default: 10000) + */ + iceGatheringTimeout?: number; +} + +/** + * WebRTC manager state + */ +export interface WebRTCManagerState { + state: WebRTCConnectionState; + peerId: string | null; + remotePeerIds: string[]; + isAudioMuted: boolean; + isVideoMuted: boolean; + isScreenSharing: 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' }, +]; + +// ============================================================================= +// WebRTCManager +// ============================================================================= + +/** + * Framework-agnostic WebRTC connection manager with multi-peer mesh networking, + * perfect negotiation, media/data channel handling, and screen sharing. + * + * 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(s) + * - negotiating: SDP/ICE exchange in progress with at least one peer + * - connected: At least one peer is connected + * + * @example + * ```ts + * const manager = new WebRTCManager({ + * signalUrl: 'wss://example.com/call/signal', + * roomId: 'my-room', + * callbacks: { + * onStateChange: (from, to, reason) => console.log(`${from} → ${to}`, reason), + * onConnect: () => console.log('Connected!'), + * onRemoteStream: (peerId, stream) => { remoteVideos[peerId].srcObject = stream; }, + * }, + * }); + * + * await manager.connect(); + * ``` + */ +export class WebRTCManager { + private ws: WebSocket | null = null; + private localStream: MediaStream | null = null; + private trackSource: TrackSource | null = null; + private previousVideoTrack: MediaStreamTrack | null = null; + + private peerId: string | null = null; + private peers = new Map(); + private isAudioMuted = false; + private isVideoMuted = false; + private isScreenSharing = false; + + private _state: WebRTCConnectionState = 'idle'; + private isConnecting = false; + private basePolite: boolean | undefined; + + private options: WebRTCManagerOptions; + private callbacks: WebRTCClientCallbacks; + private reconnectManager: ReconnectManager; + private isReconnecting = false; + private intentionalClose = false; + private connectionTimeoutId: ReturnType | null = null; + + private recordings = new Map(); + + constructor(options: WebRTCManagerOptions) { + this.options = { + ...options, + autoReconnect: options.autoReconnect ?? true, + maxReconnectAttempts: options.maxReconnectAttempts ?? 5, + connectionTimeout: options.connectionTimeout ?? 30000, + iceGatheringTimeout: options.iceGatheringTimeout ?? 10000, + }; + this.basePolite = options.polite; + this.callbacks = options.callbacks ?? {}; + this.reconnectManager = createReconnectManager({ + onReconnect: () => { + void this.reconnect(); + }, + baseDelay: 1000, + factor: 2, + maxDelay: 30000, + jitter: 0, + enabled: () => this.shouldAutoReconnect(), + }); + } + + /** + * Current connection state + */ + get state(): WebRTCConnectionState { + return this._state; + } + + /** + * Get current manager state + */ + getState(): WebRTCManagerState { + return { + state: this._state, + peerId: this.peerId, + remotePeerIds: Array.from(this.peers.keys()), + isAudioMuted: this.isAudioMuted, + isVideoMuted: this.isVideoMuted, + isScreenSharing: this.isScreenSharing, + }; + } + + /** + * Get local media stream + */ + getLocalStream(): MediaStream | null { + return this.localStream; + } + + /** + * Get remote media streams keyed by peer ID + */ + getRemoteStreams(): Map { + const streams = new Map(); + for (const [peerId, session] of this.peers) { + if (session.remoteStream) { + streams.set(peerId, session.remoteStream); + } + } + return streams; + } + + /** + * Get a specific peer's remote stream + */ + getRemoteStream(peerId: string): MediaStream | null { + return this.peers.get(peerId)?.remoteStream ?? null; + } + + /** + * Whether this manager is in data-only mode (no media streams). + */ + get isDataOnly(): boolean { + return this.options.media === false; + } + + /** + * Get connected peer count + */ + get peerCount(): number { + return this.peers.size; + } + + // ========================================================================= + // State Machine + // ========================================================================= + + private setState(newState: WebRTCConnectionState, reason?: string): void { + const prevState = this._state; + if (prevState === newState) return; + + this._state = newState; + this.handleStateTimeouts(newState); + this.callbacks.onStateChange?.(prevState, newState, reason); + + if (newState === 'connected' && prevState !== 'connected') { + this.callbacks.onConnect?.(); + } + + if (newState === 'idle' && prevState !== 'idle') { + const disconnectReason = this.mapToDisconnectReason(reason); + this.callbacks.onDisconnect?.(disconnectReason); + } + } + + private mapToDisconnectReason(reason?: string): WebRTCDisconnectReason { + if (reason === 'hangup') return 'hangup'; + if (reason === 'peer-left') return 'peer-left'; + if (reason?.includes('timeout')) return 'timeout'; + return 'error'; + } + + private handleStateTimeouts(state: WebRTCConnectionState): void { + if (state === 'connecting' || state === 'negotiating') { + this.startConnectionTimeout(); + return; + } + this.clearConnectionTimeout(); + } + + private startConnectionTimeout(): void { + this.clearConnectionTimeout(); + const timeoutMs = this.options.connectionTimeout ?? 30000; + this.connectionTimeoutId = setTimeout(() => { + if (this._state === 'connecting' || this._state === 'negotiating') { + const error = new Error('WebRTC connection timed out'); + this.callbacks.onError?.(error, this._state); + this.handleTimeout('connection-timeout'); + } + }, timeoutMs); + } + + private clearConnectionTimeout(): void { + if (this.connectionTimeoutId) { + clearTimeout(this.connectionTimeoutId); + this.connectionTimeoutId = null; + } + } + + private handleTimeout(reason: string): void { + this.intentionalClose = true; + this.cleanupPeerSessions(); + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.peerId = null; + this.setState('idle', reason); + this.intentionalClose = false; + } + + private shouldAutoReconnect(): boolean { + return (this.options.autoReconnect ?? true) && !this.intentionalClose; + } + + private updateConnectionState(): void { + const connectedPeers = Array.from(this.peers.values()).filter( + (p) => p.pc.iceConnectionState === 'connected' || p.pc.iceConnectionState === 'completed' + ); + + if (connectedPeers.length > 0) { + if (this._state !== 'connected') { + this.setState('connected', 'peer connected'); + } + } else if (this.peers.size > 0) { + if (this._state === 'connected') { + this.setState('negotiating', 'no connected peers'); + } + } else if (this._state === 'connected' || this._state === 'negotiating') { + this.setState('signaling', 'all peers left'); + } + } + + private send(msg: SignalMessage): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } + + // ========================================================================= + // Connection + // ========================================================================= + + /** + * Connect to the signaling server and start the call + */ + async connect(): Promise { + if (this._state !== 'idle' || this.isConnecting) return; + this.isConnecting = true; + this.intentionalClose = false; + this.reconnectManager.reset(); + + this.setState('connecting', 'connect() called'); + + try { + await this.ensureLocalStream(); + this.openWebSocket(); + } catch (err) { + // Clean up local media on failure + if (this.localStream) { + for (const track of this.localStream.getTracks()) { + track.stop(); + } + this.localStream = null; + } + if (this.trackSource) { + this.trackSource.stop(); + this.trackSource = null; + } + const error = err instanceof Error ? err : new Error(String(err)); + this.callbacks.onError?.(error, this._state); + this.isConnecting = false; + this.setState('idle', 'error'); + } finally { + this.isConnecting = false; + } + } + + private async ensureLocalStream(): Promise { + if (this.options.media === false || this.localStream) return; + if (this.options.media && typeof this.options.media === 'object' && 'getStream' in this.options.media) { + this.trackSource = this.options.media; + } else { + const constraints = (this.options.media as MediaStreamConstraints) ?? { + video: true, + audio: true, + }; + this.trackSource = new UserMediaSource(constraints); + } + this.localStream = await this.trackSource.getStream(); + this.callbacks.onLocalStream?.(this.localStream); + } + + private openWebSocket(): void { + if (this.ws) { + const previous = this.ws; + this.ws = null; + previous.onclose = null; + previous.onerror = null; + previous.onmessage = null; + previous.onopen = null; + previous.close(); + } + + this.ws = new WebSocket(this.options.signalUrl); + + this.ws.onopen = () => { + this.setState('signaling', 'WebSocket opened'); + this.send({ t: 'join', roomId: this.options.roomId }); + if (this.isReconnecting) { + this.isReconnecting = false; + this.reconnectManager.recordSuccess(); + this.callbacks.onReconnected?.(); + } + }; + + this.ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) as SignalMessage; + void this.handleSignalingMessage(msg).catch((err) => { + this.callbacks.onError?.( + err instanceof Error ? err : new Error(String(err)), + this._state + ); + }); + } catch (_err) { + this.callbacks.onError?.(new Error('Invalid signaling message'), this._state); + } + }; + + this.ws.onerror = () => { + const error = new Error('WebSocket connection error'); + this.callbacks.onError?.(error, this._state); + }; + + this.ws.onclose = () => { + if (this._state === 'idle') return; + if (this.intentionalClose) { + this.setState('idle', 'WebSocket closed'); + return; + } + this.handleConnectionLoss('WebSocket closed'); + }; + } + + private handleConnectionLoss(reason: string): void { + this.cleanupPeerSessions(); + this.peerId = null; + if (this.shouldAutoReconnect()) { + this.scheduleReconnect(reason); + } else { + this.setState('idle', reason); + } + } + + private scheduleReconnect(reason: string): void { + const nextAttempt = this.reconnectManager.getAttempts() + 1; + const maxAttempts = this.options.maxReconnectAttempts ?? 5; + if (nextAttempt > maxAttempts) { + this.callbacks.onReconnectFailed?.(); + this.setState('idle', 'reconnect-failed'); + return; + } + + this.isReconnecting = true; + this.callbacks.onReconnecting?.(nextAttempt); + this.setState('connecting', `reconnecting:${reason}`); + this.reconnectManager.recordFailure(); + } + + private async reconnect(): Promise { + if (!this.shouldAutoReconnect()) return; + this.cleanupPeerSessions(); + this.peerId = null; + try { + await this.ensureLocalStream(); + this.openWebSocket(); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.callbacks.onError?.(error, this._state); + this.scheduleReconnect('reconnect-error'); + } + } + + private async handleSignalingMessage(msg: SignalMessage): Promise { + switch (msg.t) { + case 'joined': + this.peerId = msg.peerId; + for (const existingPeerId of msg.peers) { + await this.createPeerSession(existingPeerId, true); + } + break; + + case 'peer-joined': + this.callbacks.onPeerJoined?.(msg.peerId); + await this.createPeerSession(msg.peerId, false); + break; + + case 'peer-left': + this.callbacks.onPeerLeft?.(msg.peerId); + this.closePeerSession(msg.peerId); + this.updateConnectionState(); + break; + + case 'sdp': + await this.handleRemoteSDP(msg.from, msg.description); + break; + + case 'ice': + await this.handleRemoteICE(msg.from, msg.candidate); + break; + + case 'error': { + const error = new Error(msg.message); + this.callbacks.onError?.(error, this._state); + break; + } + } + } + + // ========================================================================= + // Peer Session Management + // ========================================================================= + + private async createPeerSession(remotePeerId: string, isOfferer: boolean): Promise { + if (this.peers.has(remotePeerId)) { + return this.peers.get(remotePeerId)!; + } + + const iceServers = this.options.iceServers ?? DEFAULT_ICE_SERVERS; + const pc = new RTCPeerConnection({ iceServers }); + + const session: PeerSession = { + peerId: remotePeerId, + pc, + remoteStream: null, + dataChannels: new Map(), + makingOffer: false, + ignoreOffer: false, + hasRemoteDescription: false, + pendingCandidates: [], + isOfferer, + negotiationStarted: false, + hasIceCandidate: false, + iceGatheringTimer: null, + }; + + this.peers.set(remotePeerId, session); + + if (this.localStream) { + for (const track of this.localStream.getTracks()) { + pc.addTrack(track, this.localStream); + } + } + + pc.ontrack = (event) => { + if (event.streams?.[0]) { + if (session.remoteStream !== event.streams[0]) { + session.remoteStream = event.streams[0]; + this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream); + } + } else { + if (!session.remoteStream) { + session.remoteStream = new MediaStream([event.track]); + this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream); + } else { + session.remoteStream.addTrack(event.track); + this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream); + } + } + + this.callbacks.onTrackAdded?.(remotePeerId, event.track, session.remoteStream!); + this.updateConnectionState(); + }; + + pc.ondatachannel = (event) => { + this.setupDataChannel(session, event.channel); + }; + + pc.onicecandidate = (event) => { + if (event.candidate) { + session.hasIceCandidate = true; + if (session.iceGatheringTimer) { + clearTimeout(session.iceGatheringTimer); + session.iceGatheringTimer = null; + } + this.callbacks.onIceCandidate?.(remotePeerId, event.candidate.toJSON()); + this.send({ + t: 'ice', + from: this.peerId!, + to: remotePeerId, + candidate: event.candidate.toJSON(), + }); + } + }; + + this.scheduleIceGatheringTimeout(session); + + pc.onnegotiationneeded = async () => { + // If we're not the offerer and haven't received a remote description yet, + // don't send an offer - wait for the other peer's offer + if (!session.isOfferer && !session.hasRemoteDescription && !session.negotiationStarted) { + return; + } + + try { + session.makingOffer = true; + await pc.setLocalDescription(); + this.send({ + t: 'sdp', + from: this.peerId!, + to: remotePeerId, + description: pc.localDescription!, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.callbacks.onError?.(error, this._state); + } finally { + session.makingOffer = false; + } + }; + + pc.oniceconnectionstatechange = () => { + const iceState = pc.iceConnectionState; + this.callbacks.onIceStateChange?.(remotePeerId, iceState); + this.updateConnectionState(); + + if (iceState === 'failed') { + const error = new Error(`ICE connection failed for peer ${remotePeerId}`); + this.callbacks.onError?.(error, this._state); + this.handleConnectionLoss('ice-failed'); + } + }; + + if (isOfferer) { + if (this.options.dataChannels) { + for (const config of this.options.dataChannels) { + const channel = pc.createDataChannel(config.label, { + ordered: config.ordered ?? true, + maxPacketLifeTime: config.maxPacketLifeTime, + maxRetransmits: config.maxRetransmits, + protocol: config.protocol, + }); + this.setupDataChannel(session, channel); + } + } + + this.setState('negotiating', 'creating offer'); + this.callbacks.onNegotiationStart?.(remotePeerId); + await this.createOffer(session); + } + + return session; + } + + private async createOffer(session: PeerSession): Promise { + try { + session.makingOffer = true; + session.negotiationStarted = true; + const offer = await session.pc.createOffer(); + await session.pc.setLocalDescription(offer); + + this.send({ + t: 'sdp', + from: this.peerId!, + to: session.peerId, + description: session.pc.localDescription!, + }); + } finally { + session.makingOffer = false; + } + } + + private async handleRemoteSDP( + fromPeerId: string, + description: RTCSessionDescriptionInit + ): Promise { + let session = this.peers.get(fromPeerId); + if (!session) { + session = await this.createPeerSession(fromPeerId, false); + } + + const pc = session.pc; + const isOffer = description.type === 'offer'; + const polite = this.basePolite ?? !this.isOffererFor(fromPeerId); + const offerCollision = isOffer && (session.makingOffer || pc.signalingState !== 'stable'); + + session.ignoreOffer = !polite && offerCollision; + if (session.ignoreOffer) return; + + if (this._state === 'signaling') { + this.setState('negotiating', 'received SDP'); + this.callbacks.onNegotiationStart?.(fromPeerId); + } + + await pc.setRemoteDescription(description); + session.hasRemoteDescription = true; + + for (const candidate of session.pendingCandidates) { + try { + await pc.addIceCandidate(candidate); + } catch (err) { + if (!session.ignoreOffer) { + console.warn('Failed to add buffered ICE candidate:', err); + } + } + } + session.pendingCandidates = []; + + if (isOffer) { + session.negotiationStarted = true; + await pc.setLocalDescription(); + this.send({ + t: 'sdp', + from: this.peerId!, + to: fromPeerId, + description: pc.localDescription!, + }); + } + + this.callbacks.onNegotiationComplete?.(fromPeerId); + } + + private isOffererFor(remotePeerId: string): boolean { + return this.peerId! > remotePeerId; + } + + private async handleRemoteICE( + fromPeerId: string, + candidate: RTCIceCandidateInit + ): Promise { + const session = this.peers.get(fromPeerId); + if (!session || !session.hasRemoteDescription) { + if (session) { + session.pendingCandidates.push(candidate); + } + return; + } + + try { + await session.pc.addIceCandidate(candidate); + } catch (err) { + if (!session.ignoreOffer) { + console.warn('Failed to add ICE candidate:', err); + } + } + } + + private closePeerSession(peerId: string): void { + const session = this.peers.get(peerId); + if (!session) return; + + // Clear ICE gathering timer if exists + if (session.iceGatheringTimer) { + clearTimeout(session.iceGatheringTimer); + session.iceGatheringTimer = null; + } + + // Close data channels + for (const channel of session.dataChannels.values()) { + channel.close(); + } + session.dataChannels.clear(); + + // Clear all event handlers before closing to prevent memory leaks + const pc = session.pc; + pc.ontrack = null; + pc.ondatachannel = null; + pc.onicecandidate = null; + pc.onnegotiationneeded = null; + pc.oniceconnectionstatechange = null; + + pc.close(); + this.peers.delete(peerId); + } + + private cleanupPeerSessions(): void { + for (const peerId of this.peers.keys()) { + this.closePeerSession(peerId); + } + this.peers.clear(); + } + + private scheduleIceGatheringTimeout(session: PeerSession): void { + const timeoutMs = this.options.iceGatheringTimeout ?? 10000; + if (timeoutMs <= 0) return; + if (session.iceGatheringTimer) { + clearTimeout(session.iceGatheringTimer); + } + session.iceGatheringTimer = setTimeout(() => { + if (!session.hasIceCandidate) { + console.warn(`ICE gathering timeout for peer ${session.peerId}`); + } + }, timeoutMs); + } + + // ========================================================================= + // Data Channel + // ========================================================================= + + private setupDataChannel(session: PeerSession, channel: RTCDataChannel): void { + const label = channel.label; + const peerId = session.peerId; + session.dataChannels.set(label, channel); + + channel.onopen = () => { + this.callbacks.onDataChannelOpen?.(peerId, label); + if (this.isDataOnly && this._state !== 'connected') { + this.updateConnectionState(); + } + }; + + channel.onclose = () => { + session.dataChannels.delete(label); + this.callbacks.onDataChannelClose?.(peerId, label); + }; + + channel.onmessage = (event) => { + const data = event.data; + if (typeof data === 'string') { + try { + const parsed = JSON.parse(data); + this.callbacks.onDataChannelMessage?.(peerId, label, parsed); + } catch { + this.callbacks.onDataChannelMessage?.(peerId, label, data); + } + } else { + this.callbacks.onDataChannelMessage?.(peerId, label, data); + } + }; + + channel.onerror = (event) => { + const error = + event instanceof ErrorEvent + ? new Error(event.message) + : new Error('Data channel error'); + this.callbacks.onDataChannelError?.(peerId, label, error); + }; + } + + /** + * Create a new data channel to all connected peers. + */ + createDataChannel(config: DataChannelConfig): Map { + const channels = new Map(); + for (const [peerId, session] of this.peers) { + const channel = session.pc.createDataChannel(config.label, { + ordered: config.ordered ?? true, + maxPacketLifeTime: config.maxPacketLifeTime, + maxRetransmits: config.maxRetransmits, + protocol: config.protocol, + }); + this.setupDataChannel(session, channel); + channels.set(peerId, channel); + } + return channels; + } + + /** + * Get a data channel by label from a specific peer. + */ + getDataChannel(peerId: string, label: string): RTCDataChannel | undefined { + return this.peers.get(peerId)?.dataChannels.get(label); + } + + /** + * Get all open data channel labels. + */ + getDataChannelLabels(): string[] { + const labels = new Set(); + for (const session of this.peers.values()) { + for (const label of session.dataChannels.keys()) { + labels.add(label); + } + } + return Array.from(labels); + } + + /** + * Get the state of a data channel for a specific peer. + */ + getDataChannelState(peerId: string, label: string): DataChannelState | null { + const channel = this.peers.get(peerId)?.dataChannels.get(label); + return channel ? (channel.readyState as DataChannelState) : null; + } + + /** + * Send a string message to all peers on a data channel. + */ + sendString(label: string, data: string): boolean { + let sent = false; + for (const session of this.peers.values()) { + const channel = session.dataChannels.get(label); + if (channel?.readyState === 'open') { + channel.send(data); + sent = true; + } + } + return sent; + } + + /** + * Send a string message to a specific peer. + */ + sendStringTo(peerId: string, label: string, data: string): boolean { + const channel = this.peers.get(peerId)?.dataChannels.get(label); + if (!channel || channel.readyState !== 'open') return false; + channel.send(data); + return true; + } + + /** + * Send binary data to all peers on a data channel. + */ + sendBinary(label: string, data: ArrayBuffer | Uint8Array): boolean { + let sent = false; + const buffer = + data instanceof ArrayBuffer + ? data + : (() => { + const buf = new ArrayBuffer(data.byteLength); + new Uint8Array(buf).set(data); + return buf; + })(); + + for (const session of this.peers.values()) { + const channel = session.dataChannels.get(label); + if (channel?.readyState === 'open') { + channel.send(buffer); + sent = true; + } + } + return sent; + } + + /** + * Send binary data to a specific peer. + */ + sendBinaryTo(peerId: string, label: string, data: ArrayBuffer | Uint8Array): boolean { + const channel = this.peers.get(peerId)?.dataChannels.get(label); + if (!channel || channel.readyState !== 'open') return false; + + if (data instanceof ArrayBuffer) { + channel.send(data); + } else { + const buffer = new ArrayBuffer(data.byteLength); + new Uint8Array(buffer).set(data); + channel.send(buffer); + } + return true; + } + + /** + * Send JSON data to all peers on a data channel. + */ + sendJSON(label: string, data: unknown): boolean { + return this.sendString(label, JSON.stringify(data)); + } + + /** + * Send JSON data to a specific peer. + */ + sendJSONTo(peerId: string, label: string, data: unknown): boolean { + return this.sendStringTo(peerId, label, JSON.stringify(data)); + } + + /** + * Close a specific data channel on all peers. + */ + closeDataChannel(label: string): boolean { + let closed = false; + for (const session of this.peers.values()) { + const channel = session.dataChannels.get(label); + if (channel) { + channel.close(); + session.dataChannels.delete(label); + closed = true; + } + } + return closed; + } + + // ========================================================================= + // Media Controls + // ========================================================================= + + /** + * 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; + } + + // ========================================================================= + // Screen Sharing + // ========================================================================= + + /** + * Start screen sharing, replacing the current video track. + * @param options - Display media constraints + */ + async startScreenShare( + options: DisplayMediaStreamOptions = { video: true, audio: false } + ): Promise { + if (this.isScreenSharing || this.isDataOnly) return; + + const screenStream = await navigator.mediaDevices.getDisplayMedia(options); + const screenTrack = screenStream.getVideoTracks()[0]; + + if (!screenTrack) { + throw new Error('Failed to get screen video track'); + } + + if (this.localStream) { + const currentVideoTrack = this.localStream.getVideoTracks()[0]; + if (currentVideoTrack) { + this.previousVideoTrack = currentVideoTrack; + this.localStream.removeTrack(currentVideoTrack); + } + this.localStream.addTrack(screenTrack); + } + + for (const session of this.peers.values()) { + const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video'); + if (sender) { + await sender.replaceTrack(screenTrack); + } else { + session.pc.addTrack(screenTrack, this.localStream!); + } + } + + screenTrack.onended = () => { + this.stopScreenShare(); + }; + + this.isScreenSharing = true; + this.callbacks.onScreenShareStart?.(); + } + + /** + * Stop screen sharing and restore the previous video track. + */ + async stopScreenShare(): Promise { + if (!this.isScreenSharing) return; + + const screenTrack = this.localStream?.getVideoTracks()[0]; + if (screenTrack) { + screenTrack.stop(); + this.localStream?.removeTrack(screenTrack); + } + + if (this.previousVideoTrack && this.localStream) { + this.localStream.addTrack(this.previousVideoTrack); + + for (const session of this.peers.values()) { + const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video'); + if (sender) { + await sender.replaceTrack(this.previousVideoTrack); + } + } + } + + this.previousVideoTrack = null; + this.isScreenSharing = false; + this.callbacks.onScreenShareStop?.(); + } + + // ========================================================================= + // Connection Stats + // ========================================================================= + + /** + * Get raw stats for a peer connection. + */ + async getRawStats(peerId: string): Promise { + const session = this.peers.get(peerId); + if (!session) return null; + return session.pc.getStats(); + } + + /** + * Get raw stats for all peer connections. + */ + async getAllRawStats(): Promise> { + const stats = new Map(); + for (const [peerId, session] of this.peers) { + stats.set(peerId, await session.pc.getStats()); + } + return stats; + } + + /** + * Get a normalized quality summary for a peer connection. + */ + async getQualitySummary(peerId: string): Promise { + const session = this.peers.get(peerId); + if (!session) return null; + + const stats = await session.pc.getStats(); + return this.parseStatsToSummary(stats, session); + } + + /** + * Get quality summaries for all peer connections. + */ + async getAllQualitySummaries(): Promise> { + const summaries = new Map(); + for (const [peerId, session] of this.peers) { + const stats = await session.pc.getStats(); + summaries.set(peerId, this.parseStatsToSummary(stats, session)); + } + return summaries; + } + + private parseStatsToSummary( + stats: RTCStatsReport, + session: PeerSession + ): ConnectionQualitySummary { + const summary: ConnectionQualitySummary = { timestamp: Date.now() }; + const now = Date.now(); + const prevStats = session.lastStats; + const prevTime = session.lastStatsTime ?? now; + const timeDelta = (now - prevTime) / 1000; + + const bitrate: ConnectionQualitySummary['bitrate'] = {}; + + stats.forEach((report) => { + if (report.type === 'candidate-pair' && report.state === 'succeeded') { + summary.rtt = report.currentRoundTripTime + ? report.currentRoundTripTime * 1000 + : undefined; + + const localCandidateId = report.localCandidateId; + const remoteCandidateId = report.remoteCandidateId; + const localCandidate = this.getStatReport(stats, localCandidateId); + const remoteCandidate = this.getStatReport(stats, remoteCandidateId); + + summary.candidatePair = { + localType: localCandidate?.candidateType, + remoteType: remoteCandidate?.candidateType, + protocol: localCandidate?.protocol, + usingRelay: + localCandidate?.candidateType === 'relay' || + remoteCandidate?.candidateType === 'relay', + }; + } + + if (report.type === 'inbound-rtp' && report.kind === 'audio') { + summary.jitter = report.jitter ? report.jitter * 1000 : undefined; + if (report.packetsLost !== undefined && report.packetsReceived !== undefined) { + const total = report.packetsLost + report.packetsReceived; + if (total > 0) { + summary.packetLossPercent = (report.packetsLost / total) * 100; + } + } + + if (prevStats && timeDelta > 0) { + const prev = this.findMatchingReport(prevStats, report.id); + if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) { + bitrate.audio = bitrate.audio ?? {}; + bitrate.audio.inbound = + ((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta; + } + } + } + + if (report.type === 'inbound-rtp' && report.kind === 'video') { + summary.video = { + framesPerSecond: report.framesPerSecond, + framesDropped: report.framesDropped, + frameWidth: report.frameWidth, + frameHeight: report.frameHeight, + }; + + if (prevStats && timeDelta > 0) { + const prev = this.findMatchingReport(prevStats, report.id); + if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) { + bitrate.video = bitrate.video ?? {}; + bitrate.video.inbound = + ((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta; + } + } + } + + if (report.type === 'outbound-rtp' && prevStats && timeDelta > 0) { + const prev = this.findMatchingReport(prevStats, report.id); + if (prev?.bytesSent !== undefined && report.bytesSent !== undefined) { + const bps = ((report.bytesSent - prev.bytesSent) * 8) / timeDelta; + if (report.kind === 'audio') { + bitrate.audio = bitrate.audio ?? {}; + bitrate.audio.outbound = bps; + } else if (report.kind === 'video') { + bitrate.video = bitrate.video ?? {}; + bitrate.video.outbound = bps; + } + } + } + }); + + if (Object.keys(bitrate).length > 0) { + summary.bitrate = bitrate; + } + + session.lastStats = stats; + session.lastStatsTime = now; + + return summary; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private findMatchingReport(stats: RTCStatsReport, id: string): any { + return this.getStatReport(stats, id); + } + + // RTCStatsReport extends Map but bun-types may not expose .get() properly + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private getStatReport(stats: RTCStatsReport, id: string): any { + // Use Map.prototype.get via cast + const mapLike = stats as unknown as Map; + return mapLike.get(id); + } + + // ========================================================================= + // Recording + // ========================================================================= + + /** + * Start recording a stream. + * @param streamId - 'local' for local stream, or a peer ID for remote stream + * @param options - Recording options + */ + startRecording(streamId: string, options?: RecordingOptions): RecordingHandle | null { + const stream = streamId === 'local' ? this.localStream : this.getRemoteStream(streamId); + if (!stream) return null; + + const mimeType = this.selectMimeType(stream, options?.mimeType); + if (!mimeType) return null; + + const recorder = new MediaRecorder(stream, { + mimeType, + audioBitsPerSecond: options?.audioBitsPerSecond, + videoBitsPerSecond: options?.videoBitsPerSecond, + }); + + const chunks: Blob[] = []; + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + chunks.push(event.data); + } + }; + + this.recordings.set(streamId, { recorder, chunks }); + recorder.start(1000); + + return { + stop: () => + new Promise((resolve) => { + recorder.onstop = () => { + this.recordings.delete(streamId); + resolve(new Blob(chunks, { type: mimeType })); + }; + recorder.stop(); + }), + pause: () => recorder.pause(), + resume: () => recorder.resume(), + get state(): RecordingState { + return recorder.state as RecordingState; + }, + }; + } + + /** + * Check if a stream is being recorded. + */ + isRecording(streamId: string): boolean { + const recording = this.recordings.get(streamId); + return recording?.recorder.state === 'recording'; + } + + /** + * Stop all recordings and return the blobs. + */ + async stopAllRecordings(): Promise> { + const blobs = new Map(); + const promises: Promise[] = []; + + for (const [streamId, { recorder, chunks }] of this.recordings) { + const mimeType = recorder.mimeType; + promises.push( + new Promise((resolve) => { + const timeout = setTimeout(() => { + console.warn(`Recording stop timeout for stream ${streamId}`); + resolve(); // Don't block other recordings + }, 5000); + + recorder.onstop = () => { + clearTimeout(timeout); + blobs.set(streamId, new Blob(chunks, { type: mimeType })); + resolve(); + }; + recorder.stop(); + }) + ); + } + + await Promise.all(promises); + this.recordings.clear(); + return blobs; + } + + private selectMimeType(stream: MediaStream, preferred?: string): string | null { + if (preferred && MediaRecorder.isTypeSupported(preferred)) { + return preferred; + } + + const hasVideo = stream.getVideoTracks().length > 0; + const hasAudio = stream.getAudioTracks().length > 0; + + const videoTypes = [ + 'video/webm;codecs=vp9,opus', + 'video/webm;codecs=vp8,opus', + 'video/webm', + 'video/mp4', + ]; + const audioTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg']; + + const candidates = hasVideo ? videoTypes : hasAudio ? audioTypes : []; + for (const type of candidates) { + if (MediaRecorder.isTypeSupported(type)) { + return type; + } + } + return null; + } + + // ========================================================================= + // Cleanup + // ========================================================================= + + /** + * End the call and disconnect from all peers + */ + hangup(): void { + this.intentionalClose = true; + this.reconnectManager.cancel(); + this.clearConnectionTimeout(); + this.cleanupPeerSessions(); + + if (this.isScreenSharing) { + const screenTrack = this.localStream?.getVideoTracks()[0]; + screenTrack?.stop(); + } + + if (this.trackSource) { + this.trackSource.stop(); + this.trackSource = null; + } + this.localStream = null; + this.previousVideoTrack = null; + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.peerId = null; + this.isScreenSharing = false; + this.setState('idle', 'hangup'); + this.intentionalClose = false; + } + + /** + * Clean up all resources + */ + dispose(): void { + this.stopAllRecordings(); + this.hangup(); + this.reconnectManager.dispose(); + } +} diff --git a/packages/frontend/test/webrtc-manager.test.ts b/packages/frontend/test/webrtc-manager.test.ts new file mode 100644 index 000000000..2c57de49b --- /dev/null +++ b/packages/frontend/test/webrtc-manager.test.ts @@ -0,0 +1,132 @@ +import { test, expect } from 'bun:test'; +import { WebRTCManager } from '../src/webrtc-manager'; + +type PartialStats = Record & { id: string; type: string }; + +function createStatsReport(reports: PartialStats[]): RTCStatsReport { + const map = new Map(); + for (const report of reports) { + map.set(report.id, report); + } + return map as unknown as RTCStatsReport; +} + +test('getQualitySummary returns expected shape', async () => { + const manager = new WebRTCManager({ signalUrl: 'ws://localhost', roomId: 'test' }); + + const stats = createStatsReport([ + { + id: 'pair-1', + type: 'candidate-pair', + state: 'succeeded', + currentRoundTripTime: 0.05, + localCandidateId: 'local-1', + remoteCandidateId: 'remote-1', + }, + { + id: 'local-1', + type: 'local-candidate', + candidateType: 'host', + protocol: 'udp', + }, + { + id: 'remote-1', + type: 'remote-candidate', + candidateType: 'relay', + protocol: 'udp', + }, + { + id: 'audio-in', + type: 'inbound-rtp', + kind: 'audio', + jitter: 0.02, + packetsLost: 5, + packetsReceived: 95, + }, + { + id: 'video-in', + type: 'inbound-rtp', + kind: 'video', + framesPerSecond: 30, + framesDropped: 2, + frameWidth: 1280, + frameHeight: 720, + }, + ]); + + const session = { + pc: { getStats: async () => stats }, + lastStats: undefined, + lastStatsTime: undefined, + } as unknown as { pc: { getStats: () => Promise }; lastStats?: RTCStatsReport }; + + (manager as unknown as { peers: Map }).peers.set('peer-1', session); + + const summary = await manager.getQualitySummary('peer-1'); + if (!summary) throw new Error('Summary missing'); + + expect(summary.timestamp).toBeDefined(); + expect(summary.rtt).toBe(50); + expect(summary.jitter).toBe(20); + expect(summary.packetLossPercent).toBe(5); + expect(summary.candidatePair?.usingRelay).toBe(true); + expect(summary.video?.frameWidth).toBe(1280); +}); + +test('bitrate calculation uses previous stats snapshot', () => { + const manager = new WebRTCManager({ signalUrl: 'ws://localhost', roomId: 'test' }); + const now = 2000; + const originalNow = Date.now; + Date.now = () => now; + + try { + const prevStats = createStatsReport([ + { + id: 'audio-in', + type: 'inbound-rtp', + kind: 'audio', + bytesReceived: 1000, + }, + { + id: 'video-out', + type: 'outbound-rtp', + kind: 'video', + bytesSent: 2000, + }, + ]); + + const currentStats = createStatsReport([ + { + id: 'audio-in', + type: 'inbound-rtp', + kind: 'audio', + bytesReceived: 3000, + }, + { + id: 'video-out', + type: 'outbound-rtp', + kind: 'video', + bytesSent: 5000, + }, + ]); + + const session = { + lastStats: prevStats, + lastStatsTime: now - 1000, + } as unknown as { lastStats?: RTCStatsReport; lastStatsTime?: number }; + + const summary = (manager as unknown as { + parseStatsToSummary: (stats: RTCStatsReport, session: unknown) => { bitrate?: unknown }; + }).parseStatsToSummary(currentStats, session); + + expect(summary.bitrate).toBeDefined(); + const bitrate = summary.bitrate as { + audio?: { inbound?: number }; + video?: { outbound?: number }; + }; + expect(bitrate.audio?.inbound).toBe(16000); + expect(bitrate.video?.outbound).toBe(24000); + } finally { + Date.now = originalNow; + } +}); diff --git a/packages/react/README.md b/packages/react/README.md index f53a9b18b..db7d7834a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -92,6 +92,58 @@ function ChatComponent() { } ``` +### 4. WebRTC Communication + +For peer-to-peer video/audio calls and data channels: + +```tsx +import { useWebRTCCall } from '@agentuity/react'; + +function VideoChat() { + const { + state, + localVideoRef, + remoteStreams, + remotePeerIds, + connect, + hangup, + muteAudio, + muteVideo, + sendJSON, + isAudioMuted, + isVideoMuted, + } = useWebRTCCall({ + roomId: 'my-room', + signalUrl: '/api/webrtc/signal', + media: { video: true, audio: true }, + dataChannels: [{ label: 'chat' }], + }); + + return ( +
+
Status: {state}
+
+ ); +} +``` + +For detailed architecture, see [docs/webrtc-architecture.md](../../docs/webrtc-architecture.md). + ## API Reference ### AgentuityProvider diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9dfbabd8b..dcd6bbc9a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -29,6 +29,13 @@ export { type SSERouteOutput, type EventStreamOptions, } from './eventstream'; +export { + useWebRTCCall, + type UseWebRTCCallOptions, + type UseWebRTCCallResult, + type WebRTCConnectionState, + type WebRTCClientCallbacks, +} from './webrtc'; export { useAPI, type RouteKey, @@ -82,6 +89,13 @@ export { type EventStreamCallbacks, type EventStreamManagerOptions, type EventStreamManagerState, + WebRTCManager, + UserMediaSource, + DisplayMediaSource, + CustomStreamSource, + 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 new file mode 100644 index 000000000..beeb771e3 --- /dev/null +++ b/packages/react/src/webrtc.tsx @@ -0,0 +1,483 @@ +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { + WebRTCManager, + buildUrl, + type WebRTCManagerOptions, + type WebRTCClientCallbacks, + type TrackSource, +} from '@agentuity/frontend'; +import type { + WebRTCConnectionState, + DataChannelConfig, + DataChannelState, + ConnectionQualitySummary, + RecordingHandle, + RecordingOptions, +} from '@agentuity/core'; + +export type { + WebRTCClientCallbacks, + DataChannelConfig, + DataChannelState, + ConnectionQualitySummary, +}; +import { AgentuityContext } from './context'; + +export type { 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 */ + polite?: boolean; + /** ICE servers configuration */ + iceServers?: RTCIceServer[]; + /** + * Media source configuration. + * - `false`: Data-only mode (no media) + * - `MediaStreamConstraints`: Use getUserMedia with these constraints + * - `TrackSource`: Use a custom track source + * Default: { video: true, audio: true } + */ + media?: MediaStreamConstraints | TrackSource | false; + /** + * Data channels to create when connection is established. + * Only the offerer (late joiner) creates channels; the answerer receives them. + */ + dataChannels?: DataChannelConfig[]; + /** + * Whether to auto-reconnect on WebSocket/ICE failures (default: true) + */ + autoReconnect?: boolean; + /** + * Maximum reconnection attempts before giving up (default: 5) + */ + maxReconnectAttempts?: number; + /** + * Connection timeout in ms for connecting/negotiating (default: 30000) + */ + connectionTimeout?: number; + /** + * ICE gathering timeout in ms (default: 10000) + */ + iceGatheringTimeout?: number; + /** 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; + /** Current connection state */ + state: WebRTCConnectionState; + /** Current error if any */ + error: Error | null; + /** Local peer ID assigned by server */ + peerId: string | null; + /** Remote peer IDs */ + remotePeerIds: string[]; + /** Remote streams keyed by peer ID */ + remoteStreams: Map; + /** Whether audio is muted */ + isAudioMuted: boolean; + /** Whether video is muted */ + isVideoMuted: boolean; + /** Whether this is a data-only connection (no media) */ + isDataOnly: boolean; + /** Whether screen sharing is active */ + isScreenSharing: 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; + + // Screen sharing + /** Start screen sharing */ + startScreenShare: (options?: DisplayMediaStreamOptions) => Promise; + /** Stop screen sharing */ + stopScreenShare: () => Promise; + + // Data channel methods + /** Create a new data channel to all peers */ + createDataChannel: (config: DataChannelConfig) => Map; + /** Get all open data channel labels */ + getDataChannelLabels: () => string[]; + /** Get the state of a data channel for a specific peer */ + getDataChannelState: (peerId: string, label: string) => DataChannelState | null; + /** Send a string message to all peers */ + sendString: (label: string, data: string) => boolean; + /** Send a string message to a specific peer */ + sendStringTo: (peerId: string, label: string, data: string) => boolean; + /** Send binary data to all peers */ + sendBinary: (label: string, data: ArrayBuffer | Uint8Array) => boolean; + /** Send binary data to a specific peer */ + sendBinaryTo: (peerId: string, label: string, data: ArrayBuffer | Uint8Array) => boolean; + /** Send JSON data to all peers */ + sendJSON: (label: string, data: unknown) => boolean; + /** Send JSON data to a specific peer */ + sendJSONTo: (peerId: string, label: string, data: unknown) => boolean; + /** Close a specific data channel on all peers */ + closeDataChannel: (label: string) => boolean; + + // Stats + /** Get quality summary for a peer */ + getQualitySummary: (peerId: string) => Promise; + /** Get quality summaries for all peers */ + getAllQualitySummaries: () => Promise>; + + // Recording + /** Start recording a stream */ + startRecording: (streamId: string, options?: RecordingOptions) => RecordingHandle | null; + /** Check if a stream is being recorded */ + isRecording: (streamId: string) => boolean; + /** Stop all recordings */ + stopAllRecordings: () => Promise>; +} + +/** + * React hook for WebRTC peer-to-peer audio/video/data calls. + * + * Supports multi-peer mesh networking, screen sharing, recording, and stats. + * + * @example + * ```tsx + * function VideoCall({ roomId }: { roomId: string }) { + * const { + * localVideoRef, + * state, + * remotePeerIds, + * remoteStreams, + * hangup, + * muteAudio, + * isAudioMuted, + * startScreenShare, + * } = useWebRTCCall({ + * roomId, + * signalUrl: '/call/signal', + * callbacks: { + * onStateChange: (from, to, reason) => console.log(`${from} → ${to}`, reason), + * onRemoteStream: (peerId, stream) => console.log(`Got stream from ${peerId}`), + * }, + * }); + * + * return ( + *
+ *
+ * ); + * } + * ``` + */ +export function useWebRTCCall(options: UseWebRTCCallOptions): UseWebRTCCallResult { + const context = useContext(AgentuityContext); + + const managerRef = useRef(null); + const localVideoRef = useRef(null); + + const [state, setState] = useState('idle'); + const [error, setError] = useState(null); + const [peerId, setPeerId] = useState(null); + const [remotePeerIds, setRemotePeerIds] = useState([]); + const [remoteStreams, setRemoteStreams] = useState>(new Map()); + const [isAudioMuted, setIsAudioMuted] = useState(false); + const [isVideoMuted, setIsVideoMuted] = useState(false); + const [isScreenSharing, setIsScreenSharing] = useState(false); + + const userCallbacksRef = useRef(options.callbacks); + userCallbacksRef.current = options.callbacks; + + const signalUrl = useMemo(() => { + if (options.signalUrl.startsWith('ws://') || options.signalUrl.startsWith('wss://')) { + return options.signalUrl; + } + const base = context?.baseUrl ?? window.location.origin; + const wsBase = base.replace(/^http(s?):/, 'ws$1:'); + return buildUrl(wsBase, options.signalUrl); + }, [context?.baseUrl, options.signalUrl]); + + const managerOptions = useMemo((): WebRTCManagerOptions => { + return { + signalUrl, + roomId: options.roomId, + polite: options.polite, + iceServers: options.iceServers, + media: options.media, + dataChannels: options.dataChannels, + autoReconnect: options.autoReconnect, + maxReconnectAttempts: options.maxReconnectAttempts, + connectionTimeout: options.connectionTimeout, + iceGatheringTimeout: options.iceGatheringTimeout, + callbacks: { + onStateChange: (from, to, reason) => { + setState(to); + if (managerRef.current) { + const managerState = managerRef.current.getState(); + setPeerId(managerState.peerId); + setRemotePeerIds(managerState.remotePeerIds); + setIsScreenSharing(managerState.isScreenSharing); + } + userCallbacksRef.current?.onStateChange?.(from, to, reason); + }, + onConnect: () => { + userCallbacksRef.current?.onConnect?.(); + }, + onDisconnect: (reason) => { + userCallbacksRef.current?.onDisconnect?.(reason); + }, + onLocalStream: (stream) => { + if (localVideoRef.current) { + localVideoRef.current.srcObject = stream; + } + userCallbacksRef.current?.onLocalStream?.(stream); + }, + onRemoteStream: (remotePeerId, stream) => { + setRemoteStreams((prev) => { + const next = new Map(prev); + next.set(remotePeerId, stream); + return next; + }); + userCallbacksRef.current?.onRemoteStream?.(remotePeerId, stream); + }, + onTrackAdded: (remotePeerId, track, stream) => { + userCallbacksRef.current?.onTrackAdded?.(remotePeerId, track, stream); + }, + onTrackRemoved: (remotePeerId, track) => { + userCallbacksRef.current?.onTrackRemoved?.(remotePeerId, track); + }, + onPeerJoined: (id) => { + setRemotePeerIds((prev) => (prev.includes(id) ? prev : [...prev, id])); + userCallbacksRef.current?.onPeerJoined?.(id); + }, + onPeerLeft: (id) => { + setRemotePeerIds((prev) => prev.filter((p) => p !== id)); + setRemoteStreams((prev) => { + const next = new Map(prev); + next.delete(id); + return next; + }); + userCallbacksRef.current?.onPeerLeft?.(id); + }, + onNegotiationStart: (remotePeerId) => { + userCallbacksRef.current?.onNegotiationStart?.(remotePeerId); + }, + onNegotiationComplete: (remotePeerId) => { + userCallbacksRef.current?.onNegotiationComplete?.(remotePeerId); + }, + onIceCandidate: (remotePeerId, candidate) => { + userCallbacksRef.current?.onIceCandidate?.(remotePeerId, candidate); + }, + onIceStateChange: (remotePeerId, iceState) => { + userCallbacksRef.current?.onIceStateChange?.(remotePeerId, iceState); + }, + onError: (err, currentState) => { + setError(err); + userCallbacksRef.current?.onError?.(err, currentState); + }, + onDataChannelOpen: (remotePeerId, label) => { + userCallbacksRef.current?.onDataChannelOpen?.(remotePeerId, label); + }, + onDataChannelClose: (remotePeerId, label) => { + userCallbacksRef.current?.onDataChannelClose?.(remotePeerId, label); + }, + onDataChannelMessage: (remotePeerId, label, data) => { + userCallbacksRef.current?.onDataChannelMessage?.(remotePeerId, label, data); + }, + onDataChannelError: (remotePeerId, label, err) => { + userCallbacksRef.current?.onDataChannelError?.(remotePeerId, label, err); + }, + onScreenShareStart: () => { + setIsScreenSharing(true); + userCallbacksRef.current?.onScreenShareStart?.(); + }, + onScreenShareStop: () => { + setIsScreenSharing(false); + userCallbacksRef.current?.onScreenShareStop?.(); + }, + onReconnecting: (attempt) => { + userCallbacksRef.current?.onReconnecting?.(attempt); + }, + onReconnected: () => { + userCallbacksRef.current?.onReconnected?.(); + }, + onReconnectFailed: () => { + userCallbacksRef.current?.onReconnectFailed?.(); + }, + }, + }; + }, [ + signalUrl, + options.roomId, + options.polite, + options.iceServers, + options.media, + options.dataChannels, + options.autoReconnect, + options.maxReconnectAttempts, + options.connectionTimeout, + options.iceGatheringTimeout, + ]); + + useEffect(() => { + const manager = new WebRTCManager(managerOptions); + managerRef.current = manager; + + 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(); + setRemotePeerIds([]); + setRemoteStreams(new Map()); + }, []); + + const muteAudio = useCallback((muted: boolean) => { + managerRef.current?.muteAudio(muted); + setIsAudioMuted(muted); + }, []); + + const muteVideo = useCallback((muted: boolean) => { + managerRef.current?.muteVideo(muted); + setIsVideoMuted(muted); + }, []); + + const startScreenShare = useCallback(async (opts?: DisplayMediaStreamOptions) => { + await managerRef.current?.startScreenShare(opts); + }, []); + + const stopScreenShare = useCallback(async () => { + await managerRef.current?.stopScreenShare(); + }, []); + + const createDataChannel = useCallback((config: DataChannelConfig) => { + return managerRef.current?.createDataChannel(config) ?? new Map(); + }, []); + + const getDataChannelLabels = useCallback(() => { + return managerRef.current?.getDataChannelLabels() ?? []; + }, []); + + const getDataChannelState = useCallback((remotePeerId: string, label: string) => { + return managerRef.current?.getDataChannelState(remotePeerId, label) ?? null; + }, []); + + const sendString = useCallback((label: string, data: string) => { + return managerRef.current?.sendString(label, data) ?? false; + }, []); + + const sendStringTo = useCallback((remotePeerId: string, label: string, data: string) => { + return managerRef.current?.sendStringTo(remotePeerId, label, data) ?? false; + }, []); + + const sendBinary = useCallback((label: string, data: ArrayBuffer | Uint8Array) => { + return managerRef.current?.sendBinary(label, data) ?? false; + }, []); + + const sendBinaryTo = useCallback( + (remotePeerId: string, label: string, data: ArrayBuffer | Uint8Array) => { + return managerRef.current?.sendBinaryTo(remotePeerId, label, data) ?? false; + }, + [] + ); + + const sendJSON = useCallback((label: string, data: unknown) => { + return managerRef.current?.sendJSON(label, data) ?? false; + }, []); + + const sendJSONTo = useCallback((remotePeerId: string, label: string, data: unknown) => { + return managerRef.current?.sendJSONTo(remotePeerId, label, data) ?? false; + }, []); + + const closeDataChannel = useCallback((label: string) => { + return managerRef.current?.closeDataChannel(label) ?? false; + }, []); + + const getQualitySummary = useCallback(async (remotePeerId: string) => { + return managerRef.current?.getQualitySummary(remotePeerId) ?? null; + }, []); + + const getAllQualitySummaries = useCallback(async () => { + return managerRef.current?.getAllQualitySummaries() ?? new Map(); + }, []); + + const startRecording = useCallback((streamId: string, opts?: RecordingOptions) => { + return managerRef.current?.startRecording(streamId, opts) ?? null; + }, []); + + const isRecordingFn = useCallback((streamId: string) => { + return managerRef.current?.isRecording(streamId) ?? false; + }, []); + + const stopAllRecordings = useCallback(async () => { + return managerRef.current?.stopAllRecordings() ?? new Map(); + }, []); + + return { + localVideoRef, + state, + error, + peerId, + remotePeerIds, + remoteStreams, + isAudioMuted, + isVideoMuted, + isDataOnly: options.media === false, + isScreenSharing, + connect, + hangup, + muteAudio, + muteVideo, + startScreenShare, + stopScreenShare, + createDataChannel, + getDataChannelLabels, + getDataChannelState, + sendString, + sendStringTo, + sendBinary, + sendBinaryTo, + sendJSON, + sendJSONTo, + closeDataChannel, + getQualitySummary, + getAllQualitySummaries, + startRecording, + isRecording: isRecordingFn, + stopAllRecordings, + }; +} diff --git a/packages/runtime/src/handlers/index.ts b/packages/runtime/src/handlers/index.ts index 346f6d206..9327f4548 100644 --- a/packages/runtime/src/handlers/index.ts +++ b/packages/runtime/src/handlers/index.ts @@ -10,3 +10,4 @@ export { } from './sse'; export { stream, type StreamHandler } from './stream'; export { cron, type CronHandler, type CronMetadata } from './cron'; +export { webrtc, type WebRTCHandler, type WebRTCOptions } from './webrtc'; diff --git a/packages/runtime/src/handlers/webrtc.ts b/packages/runtime/src/handlers/webrtc.ts new file mode 100644 index 000000000..c588b3d36 --- /dev/null +++ b/packages/runtime/src/handlers/webrtc.ts @@ -0,0 +1,125 @@ +import type { Context, MiddlewareHandler } from 'hono'; +import { upgradeWebSocket } from 'hono/bun'; +import { context as otelContext, ROOT_CONTEXT } from '@opentelemetry/api'; +import { getAgentAsyncLocalStorage } from '../_context'; +import type { Env } from '../app'; +import { WebRTCRoomManager, type WebRTCOptions } from '../webrtc-signaling'; +import type { WebSocketConnection } from './websocket'; + +export type { WebRTCOptions }; + +/** + * Handler function for WebRTC signaling connections. + * Receives the Hono context and WebRTCRoomManager. + */ +export type WebRTCHandler = ( + c: Context, + roomManager: WebRTCRoomManager +) => void | Promise; + +/** + * Creates a WebRTC signaling middleware for peer-to-peer communication. + * + * This middleware sets up a WebSocket-based signaling server that handles: + * - Room membership and peer discovery + * - SDP offer/answer relay + * - ICE candidate relay + * + * Use with router.get() to create a WebRTC signaling endpoint: + * + * @example + * ```typescript + * import { createRouter, webrtc } from '@agentuity/runtime'; + * + * const router = createRouter(); + * + * // Basic signaling endpoint + * router.get('/call/signal', webrtc()); + * + * // With options + * router.get('/call/signal', webrtc({ maxPeers: 4 })); + * + * // With callbacks for monitoring + * router.get('/call/signal', webrtc({ + * maxPeers: 2, + * callbacks: { + * onRoomCreated: (roomId) => console.log(`Room ${roomId} created`), + * onPeerJoin: (peerId, roomId) => console.log(`${peerId} joined ${roomId}`), + * onPeerLeave: (peerId, roomId, reason) => { + * console.log(`${peerId} left ${roomId}: ${reason}`); + * }, + * }, + * })); + * ``` + * + * @param options - Configuration options for WebRTC signaling + * @returns Hono middleware handler for WebSocket upgrade + */ +export function webrtc(options?: WebRTCOptions): MiddlewareHandler { + const roomManager = new WebRTCRoomManager(options); + + const wsHandler = upgradeWebSocket((_c: Context) => { + let currentWs: WebSocketConnection | undefined; + // we need a Privder interface here with AsyncLocalStorage and KV + const asyncLocalStorage = getAgentAsyncLocalStorage(); + const capturedContext = asyncLocalStorage.getStore(); + + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onOpen: (_event: Event, ws: any) => { + otelContext.with(ROOT_CONTEXT, () => { + if (capturedContext) { + asyncLocalStorage.run(capturedContext, () => { + currentWs = { + onOpen: () => {}, + onMessage: () => {}, + onClose: () => {}, + send: (data) => ws.send(data), + }; + }); + } else { + currentWs = { + onOpen: () => {}, + onMessage: () => {}, + onClose: () => {}, + send: (data) => ws.send(data), + }; + } + }); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onMessage: (event: MessageEvent, _ws: any) => { + if (currentWs) { + otelContext.with(ROOT_CONTEXT, () => { + if (capturedContext) { + asyncLocalStorage.run(capturedContext, () => { + roomManager.handleMessage(currentWs!, String(event.data)); + }); + } else { + roomManager.handleMessage(currentWs!, String(event.data)); + } + }); + } + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onClose: (_event: CloseEvent, _ws: any) => { + if (currentWs) { + otelContext.with(ROOT_CONTEXT, () => { + if (capturedContext) { + asyncLocalStorage.run(capturedContext, () => { + roomManager.handleDisconnect(currentWs!); + }); + } else { + roomManager.handleDisconnect(currentWs!); + } + }); + } + }, + }; + }); + + const middleware: MiddlewareHandler = (c, next) => + (wsHandler as unknown as MiddlewareHandler)(c, next); + + return middleware; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index afe513f8e..2e6dbd154 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -77,7 +77,7 @@ export { registerDevModeRoutes } from './devmode'; // router.ts exports export { type HonoEnv, type WebSocketConnection, createRouter } from './router'; -// protocol handler exports (websocket, sse, stream, cron) +// protocol handler exports (websocket, sse, stream, cron, webrtc) export { websocket, type WebSocketHandler, @@ -91,8 +91,20 @@ export { cron, type CronHandler, type CronMetadata, + webrtc, + type WebRTCHandler, } from './handlers'; +// webrtc-signaling.ts exports +export { + type SignalMessage, + type SDPDescription, + type ICECandidate, + type WebRTCOptions, + type WebRTCSignalingCallbacks, + WebRTCRoomManager, +} from './webrtc-signaling'; + // eval.ts exports export { EvalHandlerResultSchema, diff --git a/packages/runtime/src/router.ts b/packages/runtime/src/router.ts index 2abba021c..684846a4a 100644 --- a/packages/runtime/src/router.ts +++ b/packages/runtime/src/router.ts @@ -83,6 +83,7 @@ declare module 'hono' { * - **sse()** - Server-Sent Events (import { sse } from '@agentuity/runtime') * - **stream()** - Streaming responses (import { stream } from '@agentuity/runtime') * - **cron()** - Scheduled tasks (import { cron } from '@agentuity/runtime') + * - **webrtc()** - WebRTC signaling (import { webrtc } from '@agentuity/runtime') * * @template E - Environment type (Hono Env) * @template S - Schema type for route definitions diff --git a/packages/runtime/src/webrtc-signaling.ts b/packages/runtime/src/webrtc-signaling.ts new file mode 100644 index 000000000..ad401fd7a --- /dev/null +++ b/packages/runtime/src/webrtc-signaling.ts @@ -0,0 +1,288 @@ +import type { WebSocketConnection } from './handlers/websocket'; +import type { + SDPDescription, + ICECandidate, + SignalMessage, + WebRTCSignalingCallbacks, +} from '@agentuity/core'; + +export type { SDPDescription, ICECandidate, SignalMessage, WebRTCSignalingCallbacks }; + +/** + * Configuration options for WebRTC signaling. + */ +export interface WebRTCOptions { + /** Maximum number of peers per room (default: 2) */ + maxPeers?: number; + /** Callbacks for signaling events */ + callbacks?: WebRTCSignalingCallbacks; +} + +interface PeerConnection { + ws: WebSocketConnection; + roomId: string; +} + +/** + * In-memory room manager for WebRTC signaling. + * Tracks rooms and their connected peers. + * + * @example + * ```ts + * import { createRouter, webrtc } from '@agentuity/runtime'; + * + * const router = createRouter(); + * + * // Basic usage + * router.get('/call/signal', webrtc()); + * + * // With callbacks for monitoring + * router.get('/call/signal', webrtc({ + * maxPeers: 2, + * callbacks: { + * onRoomCreated: (roomId) => console.log(`Room ${roomId} created`), + * onPeerJoin: (peerId, roomId) => console.log(`${peerId} joined ${roomId}`), + * onPeerLeave: (peerId, roomId, reason) => { + * analytics.track('peer_left', { peerId, roomId, reason }); + * }, + * onMessage: (type, from, to, roomId) => { + * metrics.increment(`webrtc.${type}`); + * }, + * }, + * })); + * ``` + */ +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; + private callbacks: WebRTCSignalingCallbacks; + + constructor(options?: WebRTCOptions) { + this.maxPeers = options?.maxPeers ?? 2; + this.callbacks = options?.callbacks ?? {}; + } + + private generatePeerId(): string { + return `peer-${Date.now()}-${++this.peerIdCounter}`; + } + + private send(ws: WebSocketConnection, msg: SignalMessage): void { + ws.send(JSON.stringify(msg)); + } + + private broadcast(roomId: string, msg: SignalMessage, 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); + const isNewRoom = !room; + + // 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) { + const error = new Error(`Room is full (max ${this.maxPeers} peers)`); + this.callbacks.onError?.(error, undefined, roomId); + this.send(ws, { t: 'error', message: error.message }); + 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 }); + + // Fire callbacks + if (isNewRoom) { + this.callbacks.onRoomCreated?.(roomId); + } + this.callbacks.onPeerJoin?.(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); + + // Fire callback + this.callbacks.onPeerLeave?.(peerId, roomId, 'disconnect'); + + // Notify remaining peers + this.broadcast(roomId, { t: 'peer-left', peerId }); + + // Clean up empty room + if (room.size === 0) { + this.rooms.delete(roomId); + this.callbacks.onRoomDestroyed?.(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) { + const error = new Error('Not in a room'); + this.callbacks.onError?.(error); + this.send(ws, { t: 'error', message: error.message }); + return; + } + + const { peerId, roomId } = peerInfo; + const room = this.rooms.get(roomId); + if (!room) return; + + // Fire callback + this.callbacks.onMessage?.('sdp', peerId, to, roomId); + + // Server injects 'from' to prevent spoofing + const msg: SignalMessage = { 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) { + const error = new Error('Not in a room'); + this.callbacks.onError?.(error); + this.send(ws, { t: 'error', message: error.message }); + return; + } + + const { peerId, roomId } = peerInfo; + const room = this.rooms.get(roomId); + if (!room) return; + + // Fire callback + this.callbacks.onMessage?.('ice', peerId, to, roomId); + + // Server injects 'from' to prevent spoofing + const msg: SignalMessage = { 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: SignalMessage; + try { + msg = JSON.parse(data); + } catch { + const error = new Error('Invalid JSON'); + this.callbacks.onError?.(error); + this.send(ws, { t: 'error', message: error.message }); + return; + } + + // Validate message format + if (!msg || typeof msg.t !== 'string') { + const error = new Error('Invalid message format'); + this.callbacks.onError?.(error); + this.send(ws, { t: 'error', message: error.message }); + return; + } + + switch (msg.t) { + case 'join': + if (!msg.roomId || typeof msg.roomId !== 'string') { + this.send(ws, { t: 'error', message: 'Missing or invalid roomId' }); + return; + } + this.handleJoin(ws, msg.roomId); + break; + case 'sdp': + if (!msg.description || typeof msg.description !== 'object') { + this.send(ws, { t: 'error', message: 'Missing or invalid description' }); + return; + } + this.handleSDP(ws, msg.to, msg.description); + break; + case 'ice': + if (!msg.candidate || typeof msg.candidate !== 'object') { + this.send(ws, { t: 'error', message: 'Missing or invalid candidate' }); + return; + } + 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 000000000..2b2c1cdf0 --- /dev/null +++ b/packages/runtime/test/webrtc-signaling.test.ts @@ -0,0 +1,440 @@ +import { describe, test, expect, beforeEach } from 'bun:test'; +import { + WebRTCRoomManager, + type SignalMsg, + type SDPDescription, + type ICECandidate, + type WebRTCSignalingCallbacks, +} 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); + }); + }); + + describe('callbacks', () => { + test('should call onRoomCreated when first peer joins', () => { + const events: string[] = []; + const callbacks: WebRTCSignalingCallbacks = { + onRoomCreated: (roomId) => events.push(`room-created:${roomId}`), + }; + const manager = new WebRTCRoomManager({ callbacks }); + const ws = createMockWs(); + + manager.handleJoin(ws, 'room-1'); + + expect(events).toContain('room-created:room-1'); + }); + + test('should call onPeerJoin when peer joins', () => { + const events: string[] = []; + const callbacks: WebRTCSignalingCallbacks = { + onPeerJoin: (peerId, roomId) => events.push(`peer-join:${peerId}:${roomId}`), + }; + const manager = new WebRTCRoomManager({ callbacks }); + const ws = createMockWs(); + + manager.handleJoin(ws, 'room-1'); + + expect(events.length).toBe(1); + expect(events[0]).toMatch(/^peer-join:peer-.*:room-1$/); + }); + + test('should call onPeerLeave when peer disconnects', () => { + const events: string[] = []; + const callbacks: WebRTCSignalingCallbacks = { + onPeerLeave: (peerId, roomId, reason) => + events.push(`peer-leave:${peerId}:${roomId}:${reason}`), + }; + const manager = new WebRTCRoomManager({ callbacks }); + const ws = createMockWs(); + + manager.handleJoin(ws, 'room-1'); + manager.handleDisconnect(ws); + + expect(events.length).toBe(1); + expect(events[0]).toMatch(/^peer-leave:peer-.*:room-1:disconnect$/); + }); + + test('should call onRoomDestroyed when last peer leaves', () => { + const events: string[] = []; + const callbacks: WebRTCSignalingCallbacks = { + onRoomDestroyed: (roomId) => events.push(`room-destroyed:${roomId}`), + }; + const manager = new WebRTCRoomManager({ callbacks }); + const ws = createMockWs(); + + manager.handleJoin(ws, 'room-1'); + manager.handleDisconnect(ws); + + expect(events).toContain('room-destroyed:room-1'); + }); + + test('should call onMessage for SDP messages', () => { + const events: string[] = []; + const callbacks: WebRTCSignalingCallbacks = { + onMessage: (type, from, to, roomId) => + events.push(`message:${type}:${from}:${to}:${roomId}`), + }; + const manager = new WebRTCRoomManager({ callbacks }); + const ws1 = createMockWs(); + const ws2 = createMockWs(); + + manager.handleJoin(ws1, 'room-1'); + manager.handleJoin(ws2, 'room-1'); + + const sdp: SDPDescription = { type: 'offer', sdp: 'test-sdp' }; + manager.handleSDP(ws1, undefined, sdp); + + expect(events.length).toBe(1); + expect(events[0]).toMatch(/^message:sdp:peer-.*:undefined:room-1$/); + }); + + test('should call onMessage for ICE messages', () => { + const events: string[] = []; + const callbacks: WebRTCSignalingCallbacks = { + onMessage: (type, from, to, roomId) => + events.push(`message:${type}:${from}:${to}:${roomId}`), + }; + const manager = new WebRTCRoomManager({ callbacks }); + const ws1 = createMockWs(); + const ws2 = createMockWs(); + + manager.handleJoin(ws1, 'room-1'); + manager.handleJoin(ws2, 'room-1'); + + const candidate: ICECandidate = { candidate: 'test-candidate' }; + manager.handleICE(ws1, undefined, candidate); + + expect(events.length).toBe(1); + expect(events[0]).toMatch(/^message:ice:peer-.*:undefined:room-1$/); + }); + + test('should call onError for room full errors', () => { + const errors: Error[] = []; + const callbacks: WebRTCSignalingCallbacks = { + onError: (error) => errors.push(error), + }; + const manager = new WebRTCRoomManager({ maxPeers: 1, callbacks }); + const ws1 = createMockWs(); + const ws2 = createMockWs(); + + manager.handleJoin(ws1, 'room-1'); + manager.handleJoin(ws2, 'room-1'); + + expect(errors.length).toBe(1); + expect(errors[0].message).toContain('full'); + }); + }); +}); diff --git a/packages/workbench/README.md b/packages/workbench/README.md new file mode 100644 index 000000000..1a2bcfa3a --- /dev/null +++ b/packages/workbench/README.md @@ -0,0 +1,126 @@ +# @agentuity/workbench + +React UI components for building agent testing and debugging interfaces. Provides a pre-built workbench UI with chat, schema visualization, and real-time agent interaction. + +## Installation + +```bash +bun add @agentuity/workbench +``` + +## Overview + +`@agentuity/workbench` provides a complete UI toolkit for testing and interacting with Agentuity agents during development. It includes chat interfaces, schema visualization, and WebSocket-based real-time communication. + +## Features + +- **Chat Interface**: Pre-built chat component with AI message rendering +- **Schema Visualization**: Display and interact with agent input/output schemas +- **Real-time Communication**: WebSocket-based connection to running agents +- **Theme Support**: Light and dark mode with customizable theming +- **Markdown Rendering**: Code blocks with syntax highlighting + +## Quick Start + +### Basic Usage + +```tsx +import { App, WorkbenchProvider } from '@agentuity/workbench'; +import '@agentuity/workbench/styles'; + +function MyWorkbench() { + return ( + + + + ); +} +``` + +### Custom Configuration + +```tsx +import { createWorkbench, WorkbenchProvider, Chat } from '@agentuity/workbench'; +import '@agentuity/workbench/styles'; + +const workbench = createWorkbench({ + route: '/workbench', + headers: { + Authorization: 'Bearer token', + }, +}); + +function CustomWorkbench() { + return ( + + + + ); +} +``` + +### Server-Side Usage + +For server components or SSR, import from the server entry point: + +```typescript +import { createWorkbench } from '@agentuity/workbench/server'; + +const workbench = createWorkbench({ + route: '/workbench', +}); +``` + +## Components + +### App + +The main workbench application component with full UI. + +### Chat + +Chat interface component for agent conversations. + +### Schema + +Schema visualization component for displaying agent input/output types. + +### StatusIndicator + +Connection status indicator showing real-time connectivity. + +### WorkbenchProvider + +Context provider for workbench state management. + +```tsx +import { WorkbenchProvider, useWorkbench } from '@agentuity/workbench'; + +function MyComponent() { + const workbench = useWorkbench(); + // Access workbench state and methods +} +``` + +## Styling + +Import styles based on your setup: + +```tsx +// For integration into existing apps (minimal styles) +import '@agentuity/workbench/styles'; + +// For standalone usage (includes all dependencies) +import '@agentuity/workbench/styles-standalone'; + +// Base styles only +import '@agentuity/workbench/base'; +``` + +## Peer Dependencies + +This package requires React 19+ and several Radix UI components. See `package.json` for the complete list. + +## License + +Apache 2.0 diff --git a/playwright.config.ts b/playwright.config.ts index c5ff1f795..5002a278f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,11 +9,15 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: process.env.CI ? 4 : undefined, // CI runner has 4 vCPUs, tests use unique room IDs so can run in parallel reporter: 'html', use: { baseURL: 'http://localhost:3500', trace: 'on-first-retry', + permissions: ['camera', 'microphone'], + launchOptions: { + args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'], + }, }, projects: [ {