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/agentuity.json b/apps/testing/e2e-web/agentuity.json new file mode 100644 index 000000000..d06037786 --- /dev/null +++ b/apps/testing/e2e-web/agentuity.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://agentuity.dev/schema/cli/v1/agentuity.json", + "projectId": "proj_796602fdc85ccde2da7530a809b8a440", + "orgId": "org_38GAeCRXBdW3KqNPi4WRm957Xia", + "region": "local", + "deployment": { + "resources": { + "memory": "500Mi", + "cpu": "500m", + "disk": "500Mi" + }, + "domains": [] + } +} 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..1b0b43e48 --- /dev/null +++ b/apps/testing/e2e-web/src/web/WebRTCTestPage.tsx @@ -0,0 +1,613 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { WebRTCManager, type WebRTCConnectionState } from '@agentuity/frontend'; + +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); + + // Media options + const [enableVideo, setEnableVideo] = useState(false); + const [enableAudio, setEnableAudio] = useState(false); + const [isAudioMuted, setIsAudioMuted] = useState(false); + const [isVideoMuted, setIsVideoMuted] = useState(false); + + // 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 connect = useCallback(() => { + if (managerRef.current) { + managerRef.current.dispose(); + } + + 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, + 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([]); + }, + 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; + peerColorsRef.current.set(remotePeerId, CURSOR_COLORS[colorIndex]); + } + } + }, + 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); + }, + }, + }); + + managerRef.current = manager; + manager.connect(); + + const checkPeerId = setInterval(() => { + const managerState = manager.getState(); + if (managerState.peerId) { + setPeerId(managerState.peerId); + clearInterval(checkPeerId); + } + }, 100); + }, [roomId, enableVideo, enableAudio]); + + 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]); + + // 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, state]); + + const disconnect = useCallback(() => { + if (managerRef.current) { + managerRef.current.dispose(); + managerRef.current = null; + } + setState('idle'); + setPeerId(null); + setRemotePeerIds([]); + setRemoteStreams(new Map()); + setDataChannelOpen(false); + setCursorChannelOpen(false); + setRemoteCursors(new Map()); + }, []); + + 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 () => { + if (managerRef.current) { + managerRef.current.dispose(); + } + }; + }, []); + + return ( +
+

WebRTC Data Channel Test

+ +
+ +
+ +
+ + +
+ +
+ {state === 'idle' ? ( + + ) : ( + + )} +
+ +
+
+ State: {state} +
+
+ My Peer ID: {peerId || 'N/A'} +
+
+ Remote Peers:{' '} + {remotePeerIds.length > 0 ? remotePeerIds.join(', ') : 'Waiting...'} +
+
+ Data Channel: {dataChannelOpen ? 'Open' : 'Closed'} +
+ {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
+
+ + {/* 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, i) => ( +
+ {msg.from === 'local' ? 'You' : `Remote (${msg.peerId})`}:{' '} + {msg.data} +
+ )) + )} +
+
+ ); +} diff --git a/apps/testing/integration-suite/src/test/cli-env-secrets.ts b/apps/testing/integration-suite/src/test/cli-env-secrets.ts index 07fddeadd..5f2375a9c 100644 --- a/apps/testing/integration-suite/src/test/cli-env-secrets.ts +++ b/apps/testing/integration-suite/src/test/cli-env-secrets.ts @@ -134,9 +134,7 @@ test('cli-env-secrets', 'env-set-allows-whitelisted-agentuity-auth-secret', asyn // Should succeed or at least get past validation // Note: Due to _SECRET suffix, auto-detection may prompt and store as secret (default Y in non-TTY) assert( - output.includes('set successfully') || - output.includes('Setting') || - result.success === true, + output.includes('set successfully') || output.includes('Setting') || result.success === true, `Should allow AGENTUITY_AUTH_SECRET: ${output}` ); diff --git a/apps/testing/integration-suite/src/test/cli-org-env-secrets.ts b/apps/testing/integration-suite/src/test/cli-org-env-secrets.ts index cdbf6f59b..d05d1500d 100644 --- a/apps/testing/integration-suite/src/test/cli-org-env-secrets.ts +++ b/apps/testing/integration-suite/src/test/cli-org-env-secrets.ts @@ -36,8 +36,10 @@ test('cli-org-env-secrets', 'org-env-set-creates-variable', async () => { }); const output = (result.stdout || '') + (result.stderr || ''); - console.log(`[DEBUG] Set result - exitCode: ${result.exitCode}, stdout: ${result.stdout?.slice(0, 200)}, stderr: ${result.stderr?.slice(0, 200)}`); - + console.log( + `[DEBUG] Set result - exitCode: ${result.exitCode}, stdout: ${result.stdout?.slice(0, 200)}, stderr: ${result.stderr?.slice(0, 200)}` + ); + assert( Boolean(result.success || output.includes('set successfully')), `Org env set should succeed: ${output}` @@ -94,10 +96,12 @@ test('cli-org-env-secrets', 'org-env-get-retrieves-variable', async () => { const setResult = await cliAgent.run({ command: `cloud env set ${testKey} ${testValue} --org`, }); - console.log(`[DEBUG] get-test: Set result - exitCode: ${setResult.exitCode}, success: ${setResult.success}`); + console.log( + `[DEBUG] get-test: Set result - exitCode: ${setResult.exitCode}, success: ${setResult.success}` + ); console.log(`[DEBUG] get-test: Set stdout: ${setResult.stdout?.slice(0, 300)}`); console.log(`[DEBUG] get-test: Set stderr: ${setResult.stderr?.slice(0, 300)}`); - + assert(setResult.success, `Set should succeed before get: ${setResult.stderr}`); // Get the variable @@ -105,7 +109,9 @@ test('cli-org-env-secrets', 'org-env-get-retrieves-variable', async () => { const getResult = await cliAgent.run({ command: `cloud env get ${testKey} --org`, }); - console.log(`[DEBUG] get-test: Get result - exitCode: ${getResult.exitCode}, success: ${getResult.success}`); + console.log( + `[DEBUG] get-test: Get result - exitCode: ${getResult.exitCode}, success: ${getResult.success}` + ); console.log(`[DEBUG] get-test: Get stdout: ${getResult.stdout?.slice(0, 300)}`); console.log(`[DEBUG] get-test: Get stderr: ${getResult.stderr?.slice(0, 300)}`); @@ -132,9 +138,11 @@ test('cli-org-env-secrets', 'org-env-list-shows-variables', async () => { const setResult = await cliAgent.run({ command: `cloud env set ${testKey} ${testValue} --org`, }); - console.log(`[DEBUG] Set result - exitCode: ${setResult.exitCode}, stdout: ${setResult.stdout?.slice(0, 300)}`); + console.log( + `[DEBUG] Set result - exitCode: ${setResult.exitCode}, stdout: ${setResult.stdout?.slice(0, 300)}` + ); console.log(`[DEBUG] Set stderr: ${setResult.stderr?.slice(0, 300)}`); - + assert(setResult.success, `Set should succeed before list: ${setResult.stderr}`); // List org variables @@ -142,7 +150,9 @@ test('cli-org-env-secrets', 'org-env-list-shows-variables', async () => { const listResult = await cliAgent.run({ command: 'cloud env list --org', }); - console.log(`[DEBUG] List result - exitCode: ${listResult.exitCode}, stdout: ${listResult.stdout?.slice(0, 500)}`); + console.log( + `[DEBUG] List result - exitCode: ${listResult.exitCode}, stdout: ${listResult.stdout?.slice(0, 500)}` + ); const listOutput = (listResult.stdout || '') + (listResult.stderr || ''); assert(listOutput.includes(testKey), `Should list key: ${listOutput}`); @@ -166,20 +176,26 @@ test('cli-org-env-secrets', 'org-env-delete-removes-variable', async () => { const setResult = await cliAgent.run({ command: `cloud env set ${testKey} ${testValue} --org`, }); - console.log(`[DEBUG] delete-test: Set result - exitCode: ${setResult.exitCode}, success: ${setResult.success}, stdout: ${setResult.stdout?.slice(0, 200)}`); + console.log( + `[DEBUG] delete-test: Set result - exitCode: ${setResult.exitCode}, success: ${setResult.success}, stdout: ${setResult.stdout?.slice(0, 200)}` + ); // Verify the variable was set before trying to delete const verifyResult = await cliAgent.run({ command: `cloud env get ${testKey} --org`, }); - console.log(`[DEBUG] delete-test: Verify after set - exitCode: ${verifyResult.exitCode}, success: ${verifyResult.success}`); - + console.log( + `[DEBUG] delete-test: Verify after set - exitCode: ${verifyResult.exitCode}, success: ${verifyResult.success}` + ); + // Delete the variable console.log(`[DEBUG] delete-test: Deleting ${testKey}`); const deleteResult = await cliAgent.run({ command: `cloud env delete ${testKey} --org`, }); - console.log(`[DEBUG] delete-test: Delete result - exitCode: ${deleteResult.exitCode}, success: ${deleteResult.success}, stdout: ${deleteResult.stdout?.slice(0, 200)}`); + console.log( + `[DEBUG] delete-test: Delete result - exitCode: ${deleteResult.exitCode}, success: ${deleteResult.success}, stdout: ${deleteResult.stdout?.slice(0, 200)}` + ); const deleteOutput = (deleteResult.stdout || '') + (deleteResult.stderr || ''); assert( @@ -207,9 +223,11 @@ test('cli-org-env-secrets', 'org-env-list-no-mask-shows-secrets', async () => { const setResult = await cliAgent.run({ command: `cloud env set ${testKey} ${testValue} --secret --org`, }); - console.log(`[DEBUG] no-mask-test: Set result - exitCode: ${setResult.exitCode}, success: ${setResult.success}`); + console.log( + `[DEBUG] no-mask-test: Set result - exitCode: ${setResult.exitCode}, success: ${setResult.success}` + ); console.log(`[DEBUG] no-mask-test: Set stdout: ${setResult.stdout?.slice(0, 300)}`); - + assert(setResult.success, `Set secret should succeed: ${setResult.stderr}`); // List with --no-mask @@ -594,17 +612,21 @@ test('cli-org-env-secrets', 'org-env-list-env-only-filter', async () => { const setEnvResult = await cliAgent.run({ command: `cloud env set ${envKey} env_value --org`, }); - console.log(`[DEBUG] env-only-test: Set env result - exitCode: ${setEnvResult.exitCode}, success: ${setEnvResult.success}`); + console.log( + `[DEBUG] env-only-test: Set env result - exitCode: ${setEnvResult.exitCode}, success: ${setEnvResult.success}` + ); console.log(`[DEBUG] env-only-test: Set env stdout: ${setEnvResult.stdout?.slice(0, 300)}`); - + assert(setEnvResult.success, `Set env var should succeed: ${setEnvResult.stderr}`); - + console.log(`[DEBUG] env-only-test: Setting secret ${secretKey}`); const setSecretResult = await cliAgent.run({ command: `cloud env set ${secretKey} secret_value --secret --org`, }); - console.log(`[DEBUG] env-only-test: Set secret result - exitCode: ${setSecretResult.exitCode}, success: ${setSecretResult.success}`); - + console.log( + `[DEBUG] env-only-test: Set secret result - exitCode: ${setSecretResult.exitCode}, success: ${setSecretResult.success}` + ); + assert(setSecretResult.success, `Set secret should succeed: ${setSecretResult.stderr}`); // List with --env-only filter @@ -614,7 +636,7 @@ test('cli-org-env-secrets', 'org-env-list-env-only-filter', async () => { }); console.log(`[DEBUG] env-only-test: List result - exitCode: ${listResult.exitCode}`); console.log(`[DEBUG] env-only-test: List stdout: ${listResult.stdout?.slice(0, 500)}`); - + const listOutput = (listResult.stdout || '') + (listResult.stderr || ''); assert(listOutput.includes(envKey), `Should include env var: ${listOutput}`); 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..6767817a9 --- /dev/null +++ b/apps/testing/webrtc-test/.gitignore @@ -0,0 +1,43 @@ +# dependencies (bun install) + +node_modules + +# output + +out +dist +*.tgz + +# code coverage + +coverage +*.lcov + +# logs + +/logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]\*.json + +# dotenv environment variable files + +.env +.env.\* + +# caches + +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs + +.idea + +# Finder (MacOS) folder config + +.DS_Store + +# Agentuity build files + +.agentuity diff --git a/apps/testing/webrtc-test/.vscode/settings.json b/apps/testing/webrtc-test/.vscode/settings.json new file mode 100644 index 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'; + +export function App() { + const [count, setCount] = useState(0); + const { run, data: agentResult } = useAgent('simple'); + const { connected, send, data: wsMessage } = useAgentWebsocket('websocket'); + + useEffect(() => { + // Send WebSocket message every second + const interval = setInterval(() => { + send(`Message at ${new Date().toISOString()}`); + }, 1000); + return () => clearInterval(interval); + }, [send]); + + return ( +
+ +

My Agentuity App

+ +
+

Count: {count}

+ +
+ +
+ +

{agentResult}

+
+ +
+ WebSocket: + {connected ? JSON.stringify(wsMessage) : 'Not connected'} +
+
+
+ ); +} +``` + +## Static Assets + +Place static files in the **public/** folder: + +``` +src/web/public/ +├── logo.svg +├── styles.css +└── script.js +``` + +Reference them in your HTML or components: + +```html + + + +``` + +```typescript +// In React components +Logo +``` + +## Styling + +### Inline Styles + +```typescript +
+ Styled content +
+``` + +### CSS Files + +Create `public/styles.css`: + +```css +body { + background-color: #09090b; + color: #fff; + font-family: sans-serif; +} +``` + +Import in `index.html`: + +```html + +``` + +### Style Tag in Component + +```typescript +
+ + +
+``` + +## Best Practices + +- Wrap your app with **AgentuityProvider** for hooks to work +- Use **useAgent** for one-off agent calls +- Use **useAgentWebsocket** for bidirectional real-time communication +- Use **useAgentEventStream** for server-to-client streaming +- Place reusable components in separate files +- Keep static assets in the **public/** folder +- Use TypeScript for type safety +- Handle loading and error states in UI + +## Rules + +- **App.tsx** must export a function named `App` +- **frontend.tsx** must render the `App` component to `#root` +- **index.html** must have a `
` +- All agents are accessible via `useAgent('agentName')` +- The web app is served at `/` by default +- Static files in `public/` are served at `/public/*` +- Module script tag: `` diff --git a/apps/testing/webrtc-test/src/web/App.tsx b/apps/testing/webrtc-test/src/web/App.tsx new file mode 100644 index 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 d45bd8db6..bbf93f8e5 100644 --- a/bun.lock +++ b/bun.lock @@ -291,6 +291,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": "0.1.16", @@ -3458,6 +3482,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=="], @@ -3560,10 +3586,14 @@ "@agentuity/test-utils/bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "@agentuity/workbench/@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@agentuity/workbench/@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=="], "@agentuity/workbench/bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.14", "", { "dependencies": { "tailwindcss": "4.0.0-beta.9" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-Ge8M8DQsRDErCzH/uI8pYjx5vZWXxQvnwM/xMQMElxQqHieGbAopfYo/q/kllkPkRbFHiwhnHwTpRMAMJZCjug=="], + "@agentuity/workbench/bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], @@ -3992,6 +4022,12 @@ "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "webrtc-test/@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + + "webrtc-test/@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], + + "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/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -4340,6 +4376,12 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "webrtc-test/@types/bun/bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + + "webrtc-test/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "webrtc-test/@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "@traceloop/instrumentation-anthropic/@opentelemetry/instrumentation/require-in-the-middle/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "@traceloop/instrumentation-bedrock/@opentelemetry/instrumentation/require-in-the-middle/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], 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-turn-configuration.md b/docs/webrtc-turn-configuration.md new file mode 100644 index 000000000..8add114a1 --- /dev/null +++ b/docs/webrtc-turn-configuration.md @@ -0,0 +1,244 @@ +# 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 + +## 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..dd15d7100 --- /dev/null +++ b/e2e/webrtc.pw.ts @@ -0,0 +1,315 @@ +import { test, expect, type Page, type BrowserContext } from '@playwright/test'; + +async function waitForPageLoad(page: Page) { + await expect(page.locator('h1')).toContainText('WebRTC', { timeout: 10000 }); +} + +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 + context1 = await browser.newContext(); + context2 = await browser.newContext(); + page1 = await context1.newPage(); + page2 = await context2.newPage(); + }); + + 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, + }); + }); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ac79d7b01..17f76df26 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1533,7 +1533,11 @@ async function registerSubcommand( !normalized.requiresRegion && (options as Record).register === false; - if ((normalized.requiresRegion || normalized.optionalRegion) && ctx.apiClient && !skipRegion) { + if ( + (normalized.requiresRegion || normalized.optionalRegion) && + ctx.apiClient && + !skipRegion + ) { const apiClient: APIClientType = ctx.apiClient as APIClientType; const regions = await tui.spinner({ message: 'Fetching cloud regions', diff --git a/packages/cli/src/cmd/cloud/env/delete.ts b/packages/cli/src/cmd/cloud/env/delete.ts index afce9b778..fba6a3d8e 100644 --- a/packages/cli/src/cmd/cloud/env/delete.ts +++ b/packages/cli/src/cmd/cloud/env/delete.ts @@ -16,7 +16,10 @@ import { resolveOrgId, isOrgScope } from './org-util'; const EnvDeleteResponseSchema = z.object({ success: z.boolean().describe('Whether the operation succeeded'), key: z.string().describe('Variable key that was deleted'), - path: z.string().optional().describe('Local file path where variable was removed (project scope only)'), + path: z + .string() + .optional() + .describe('Local file path where variable was removed (project scope only)'), secret: z.boolean().describe('Whether a secret was deleted'), scope: z.enum(['project', 'org']).describe('The scope from which the variable was deleted'), }); @@ -30,7 +33,10 @@ export const deleteSubcommand = createSubcommand({ examples: [ { command: getCommand('env delete OLD_FEATURE_FLAG'), description: 'Delete variable' }, { command: getCommand('env rm API_KEY'), description: 'Delete a secret' }, - { command: getCommand('env rm OPENAI_API_KEY --org'), description: 'Delete org-level secret' }, + { + command: getCommand('env rm OPENAI_API_KEY --org'), + description: 'Delete org-level secret', + }, ], requires: { auth: true, apiClient: true }, optional: { project: true }, @@ -42,7 +48,9 @@ export const deleteSubcommand = createSubcommand({ org: z .union([z.boolean(), z.string()]) .optional() - .describe('delete from organization level (use --org for default org, or --org for specific org)'), + .describe( + 'delete from organization level (use --org for default org, or --org for specific org)' + ), }), response: EnvDeleteResponseSchema, }, @@ -53,7 +61,9 @@ export const deleteSubcommand = createSubcommand({ // Require project context if not using org scope if (!useOrgScope && !project) { - tui.fatal('Project context required. Run from a project directory or use --org for organization scope.'); + tui.fatal( + 'Project context required. Run from a project directory or use --org for organization scope.' + ); } // Validate key doesn't start with reserved AGENTUITY_ prefix (except AGENTUITY_PUBLIC_) @@ -76,7 +86,10 @@ export const deleteSubcommand = createSubcommand({ const isEnv = orgData.env?.[args.key] !== undefined; if (!isSecret && !isEnv) { - tui.fatal(`Variable '${args.key}' not found in organization`, ErrorCode.RESOURCE_NOT_FOUND); + tui.fatal( + `Variable '${args.key}' not found in organization`, + ErrorCode.RESOURCE_NOT_FOUND + ); } // Delete from cloud diff --git a/packages/cli/src/cmd/cloud/env/get.ts b/packages/cli/src/cmd/cloud/env/get.ts index 7839757ed..141469cbc 100644 --- a/packages/cli/src/cmd/cloud/env/get.ts +++ b/packages/cli/src/cmd/cloud/env/get.ts @@ -21,7 +21,10 @@ export const getSubcommand = createSubcommand({ { command: getCommand('env get NODE_ENV'), description: 'Get environment variable' }, { command: getCommand('env get API_KEY'), description: 'Get a secret value' }, { command: getCommand('env get API_KEY --no-mask'), description: 'Show unmasked value' }, - { command: getCommand('env get OPENAI_API_KEY --org'), description: 'Get org-level variable' }, + { + command: getCommand('env get OPENAI_API_KEY --org'), + description: 'Get org-level variable', + }, ], requires: { auth: true, apiClient: true }, optional: { project: true }, @@ -34,7 +37,9 @@ export const getSubcommand = createSubcommand({ org: z .union([z.boolean(), z.string()]) .optional() - .describe('get from organization level (use --org for default org, or --org for specific org)'), + .describe( + 'get from organization level (use --org for default org, or --org for specific org)' + ), }), response: EnvGetResponseSchema, }, @@ -46,7 +51,9 @@ export const getSubcommand = createSubcommand({ // Require project context if not using org scope if (!useOrgScope && !project) { - tui.fatal('Project context required. Run from a project directory or use --org for organization scope.'); + tui.fatal( + 'Project context required. Run from a project directory or use --org for organization scope.' + ); } let value: string | undefined; diff --git a/packages/cli/src/cmd/cloud/env/import.ts b/packages/cli/src/cmd/cloud/env/import.ts index 38d713a47..7d4e45e6c 100644 --- a/packages/cli/src/cmd/cloud/env/import.ts +++ b/packages/cli/src/cmd/cloud/env/import.ts @@ -21,7 +21,10 @@ const EnvImportResponseSchema = z.object({ envCount: z.number().describe('Number of env vars imported'), secretCount: z.number().describe('Number of secrets imported'), skipped: z.number().describe('Number of items skipped'), - path: z.string().optional().describe('Local file path where variables were saved (project scope only)'), + path: z + .string() + .optional() + .describe('Local file path where variables were saved (project scope only)'), file: z.string().describe('Source file path'), scope: z.enum(['project', 'org']).describe('The scope where variables were imported'), }); @@ -29,13 +32,7 @@ const EnvImportResponseSchema = z.object({ export const importSubcommand = createSubcommand({ name: 'import', description: 'Import environment variables and secrets from a file to cloud and local .env', - tags: [ - 'mutating', - 'creates-resource', - 'slow', - 'api-intensive', - 'requires-auth', - ], + tags: ['mutating', 'creates-resource', 'slow', 'api-intensive', 'requires-auth'], examples: [ { command: getCommand('cloud env import .env.backup'), @@ -72,7 +69,9 @@ export const importSubcommand = createSubcommand({ // Require project context if not using org scope if (!useOrgScope && !project) { - tui.fatal('Project context required. Run from a project directory or use --org for organization scope.'); + tui.fatal( + 'Project context required. Run from a project directory or use --org for organization scope.' + ); } // Read the import file @@ -87,7 +86,7 @@ export const importSubcommand = createSubcommand({ secretCount: 0, skipped: 0, file: args.file, - scope: useOrgScope ? 'org' as const : 'project' as const, + scope: useOrgScope ? ('org' as const) : ('project' as const), }; } @@ -103,7 +102,7 @@ export const importSubcommand = createSubcommand({ secretCount: 0, skipped: Object.keys(importedVars).length, file: args.file, - scope: useOrgScope ? 'org' as const : 'project' as const, + scope: useOrgScope ? ('org' as const) : ('project' as const), }; } diff --git a/packages/cli/src/cmd/cloud/env/list.ts b/packages/cli/src/cmd/cloud/env/list.ts index 63323179a..89c456002 100644 --- a/packages/cli/src/cmd/cloud/env/list.ts +++ b/packages/cli/src/cmd/cloud/env/list.ts @@ -23,7 +23,10 @@ export const listSubcommand = createSubcommand({ { command: getCommand('env list --no-mask'), description: 'Show unmasked values' }, { command: getCommand('env list --secrets'), description: 'List only secrets' }, { command: getCommand('env list --env-only'), description: 'List only env vars' }, - { command: getCommand('env list --org'), description: 'List only organization-level variables' }, + { + command: getCommand('env list --org'), + description: 'List only organization-level variables', + }, ], requires: { auth: true, apiClient: true }, optional: { project: true }, @@ -48,7 +51,9 @@ export const listSubcommand = createSubcommand({ if (ctx.opts?.org) { return `/settings/organization/env`; } - return ctx.project ? `/projects/${encodeURIComponent(ctx.project.projectId)}/settings` : undefined; + return ctx.project + ? `/projects/${encodeURIComponent(ctx.project.projectId)}/settings` + : undefined; }, async handler(ctx) { @@ -56,7 +61,8 @@ export const listSubcommand = createSubcommand({ const useOrgScope = isOrgScope(opts?.org); // Build combined result with type info and scope - const result: Record = {}; + const result: Record = + {}; // Filter based on options const showEnv = !opts?.secrets; @@ -132,7 +138,9 @@ export const listSubcommand = createSubcommand({ } } } else { - tui.fatal('Project context required. Run from a project directory or use --org for organization scope.'); + tui.fatal( + 'Project context required. Run from a project directory or use --org for organization scope.' + ); } // Skip TUI output in JSON mode @@ -142,10 +150,10 @@ export const listSubcommand = createSubcommand({ } else { tui.newline(); - const projectCount = Object.values(result).filter(v => v.scope === 'project').length; - const orgCount = Object.values(result).filter(v => v.scope === 'org').length; - const secretCount = Object.values(result).filter(v => v.secret).length; - const envCount = Object.values(result).filter(v => !v.secret).length; + const projectCount = Object.values(result).filter((v) => v.scope === 'project').length; + const orgCount = Object.values(result).filter((v) => v.scope === 'org').length; + const secretCount = Object.values(result).filter((v) => v.secret).length; + const envCount = Object.values(result).filter((v) => !v.secret).length; const parts: string[] = []; if (envCount > 0) parts.push(`${envCount} env`); @@ -165,7 +173,9 @@ export const listSubcommand = createSubcommand({ const displayValue = shouldMask && secret ? tui.maskSecret(value) : value; const typeIndicator = secret ? ' [secret]' : ''; const scopeIndicator = !useOrgScope ? ` [${scope}]` : ''; - console.log(`${tui.bold(key)}=${displayValue}${tui.muted(typeIndicator + scopeIndicator)}`); + console.log( + `${tui.bold(key)}=${displayValue}${tui.muted(typeIndicator + scopeIndicator)}` + ); } } } diff --git a/packages/cli/src/cmd/cloud/env/org-util.ts b/packages/cli/src/cmd/cloud/env/org-util.ts index f9d65713d..fda027acb 100644 --- a/packages/cli/src/cmd/cloud/env/org-util.ts +++ b/packages/cli/src/cmd/cloud/env/org-util.ts @@ -5,7 +5,7 @@ import { listOrganizations } from '@agentuity/server'; /** * Resolves the organization ID for org-scoped env operations. - * + * * @param apiClient - The API client * @param config - The CLI config (may be null) * @param orgOption - The --org option value (true for default/prompt, or explicit org ID) diff --git a/packages/cli/src/cmd/cloud/env/pull.ts b/packages/cli/src/cmd/cloud/env/pull.ts index 3f8ab9125..e5149a731 100644 --- a/packages/cli/src/cmd/cloud/env/pull.ts +++ b/packages/cli/src/cmd/cloud/env/pull.ts @@ -61,16 +61,21 @@ export const pullSubcommand = createSubcommand({ // Organization scope const orgId = await resolveOrgId(apiClient, config, opts!.org!); - const orgData = await tui.spinner('Pulling environment variables from organization', () => { - return orgEnvGet(apiClient, { id: orgId, mask: false }); - }); + const orgData = await tui.spinner( + 'Pulling environment variables from organization', + () => { + return orgEnvGet(apiClient, { id: orgId, mask: false }); + } + ); cloudEnv = { ...orgData.env, ...orgData.secrets }; scope = 'org'; } else { // Project scope if (!project) { - tui.fatal('Project context required. Run from a project directory or use --org for organization scope.'); + tui.fatal( + 'Project context required. Run from a project directory or use --org for organization scope.' + ); } const projectData = await tui.spinner('Pulling environment variables from cloud', () => { diff --git a/packages/cli/src/cmd/cloud/env/push.ts b/packages/cli/src/cmd/cloud/env/push.ts index 66591fc46..36f19d91b 100644 --- a/packages/cli/src/cmd/cloud/env/push.ts +++ b/packages/cli/src/cmd/cloud/env/push.ts @@ -24,13 +24,7 @@ const EnvPushResponseSchema = z.object({ export const pushSubcommand = createSubcommand({ name: 'push', description: 'Push environment variables and secrets from local .env file to cloud', - tags: [ - 'mutating', - 'updates-resource', - 'slow', - 'api-intensive', - 'requires-auth', - ], + tags: ['mutating', 'updates-resource', 'slow', 'api-intensive', 'requires-auth'], idempotent: true, examples: [ { command: getCommand('env push'), description: 'Push all variables to cloud (project)' }, @@ -73,7 +67,7 @@ export const pushSubcommand = createSubcommand({ envCount: 0, secretCount: 0, source: envFilePath, - scope: useOrgScope ? 'org' as const : 'project' as const, + scope: useOrgScope ? ('org' as const) : ('project' as const), }; } @@ -123,7 +117,9 @@ export const pushSubcommand = createSubcommand({ } else { // Project scope (existing behavior) if (!project) { - tui.fatal('Project context required. Run from a project directory or use --org for organization scope.'); + tui.fatal( + 'Project context required. Run from a project directory or use --org for organization scope.' + ); } await tui.spinner('Pushing variables to cloud', () => { diff --git a/packages/cli/src/cmd/cloud/env/set.ts b/packages/cli/src/cmd/cloud/env/set.ts index 30919c2ae..be31f386d 100644 --- a/packages/cli/src/cmd/cloud/env/set.ts +++ b/packages/cli/src/cmd/cloud/env/set.ts @@ -18,7 +18,10 @@ import { resolveOrgId, isOrgScope } from './org-util'; const EnvSetResponseSchema = z.object({ success: z.boolean().describe('Whether the operation succeeded'), key: z.string().describe('Environment variable key'), - path: z.string().optional().describe('Local file path where env var was saved (project scope only)'), + path: z + .string() + .optional() + .describe('Local file path where env var was saved (project scope only)'), secret: z.boolean().describe('Whether the value was stored as a secret'), scope: z.enum(['project', 'org']).describe('The scope where the variable was set'), }); @@ -58,7 +61,9 @@ export const setSubcommand = createSubcommand({ org: z .union([z.boolean(), z.string()]) .optional() - .describe('set at organization level (use --org for default org, or --org for specific org)'), + .describe( + 'set at organization level (use --org for default org, or --org for specific org)' + ), }), response: EnvSetResponseSchema, }, @@ -69,7 +74,9 @@ export const setSubcommand = createSubcommand({ // Require project context if not using org scope if (!useOrgScope && !project) { - tui.fatal('Project context required. Run from a project directory or use --org for organization scope.'); + tui.fatal( + 'Project context required. Run from a project directory or use --org for organization scope.' + ); } let isSecret = opts?.secret ?? false; diff --git a/packages/cli/src/cmd/project/template-flow.ts b/packages/cli/src/cmd/project/template-flow.ts index b8c5028f4..99e755d4a 100644 --- a/packages/cli/src/cmd/project/template-flow.ts +++ b/packages/cli/src/cmd/project/template-flow.ts @@ -337,9 +337,7 @@ export async function runCreateFlow(options: CreateFlowOptions): Promise d.name).join(', ') || '(none)'}` - ); + logger.debug(`Database names: ${resources.db.map((d) => d.name).join(', ') || '(none)'}`); logger.debug( `Storage buckets: ${resources.s3.map((b) => b.bucket_name).join(', ') || '(none)'}` ); diff --git a/packages/cli/src/schema-parser.ts b/packages/cli/src/schema-parser.ts index 457fdb7f7..324b8453a 100644 --- a/packages/cli/src/schema-parser.ts +++ b/packages/cli/src/schema-parser.ts @@ -85,12 +85,15 @@ function isBooleanStringUnion(schema: unknown): boolean { // Zod 3: type is _def.typeName const optUnknown = opt as unknown as Record; const optDef = optUnknown?._def as Record | undefined; - const optType = (optUnknown?.type as string) || (optDef?.typeName as string) || (optDef?.type as string); + const optType = + (optUnknown?.type as string) || (optDef?.typeName as string) || (optDef?.type as string); types.add(optType); } - return (types.has('boolean') || types.has('ZodBoolean')) && - (types.has('string') || types.has('ZodString')); + return ( + (types.has('boolean') || types.has('ZodBoolean')) && + (types.has('string') || types.has('ZodString')) + ); } function getShape(schema: ZodType): Record { diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 62316b257..5bca06def 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -834,7 +834,9 @@ export function showLoggedOutMessage(appBaseUrl: string, hasProfile = false): vo // Box format: "║ " + content + "║" = 48 chars total // Content area = 46 chars, with leading space = 45 chars for URL + padding const urlPadding = Math.max(0, 45 - signupURL.length); - const showNewLine = showInline ? '' : `║ ${RESET}${link(signupURL)}${YELLOW}${' '.repeat(urlPadding)}║`; + const showNewLine = showInline + ? '' + : `║ ${RESET}${link(signupURL)}${YELLOW}${' '.repeat(urlPadding)}║`; const lines = [ '╔══════════════════════════════════════════════╗', diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 2d36a2d4b..0ef829318 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -62,7 +62,6 @@ export const ConfigSchema = zod.object({ }) .optional() .describe('the gravity client information'), - }); export type Config = zod.infer; diff --git a/packages/cli/test/schema-parser.test.ts b/packages/cli/test/schema-parser.test.ts index 7ed166acf..06af5de9e 100644 --- a/packages/cli/test/schema-parser.test.ts +++ b/packages/cli/test/schema-parser.test.ts @@ -6,10 +6,7 @@ describe('parseOptionsSchema', () => { describe('optionalString type detection', () => { test('detects z.union([z.boolean(), z.string()]) as optionalString', () => { const schema = z.object({ - org: z - .union([z.boolean(), z.string()]) - .optional() - .describe('organization flag'), + org: z.union([z.boolean(), z.string()]).optional().describe('organization flag'), }); const parsed = parseOptionsSchema(schema); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d2b57f393..01d3ccb7d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -124,4 +124,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/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..4eb8e3c40 --- /dev/null +++ b/packages/frontend/src/webrtc-manager.ts @@ -0,0 +1,1372 @@ +import type { + SignalMessage, + WebRTCConnectionState, + WebRTCDisconnectReason, + DataChannelConfig, + DataChannelState, + ConnectionQualitySummary, + RecordingOptions, + RecordingHandle, + RecordingState, + TrackSource as CoreTrackSource, +} from '@agentuity/core'; + +/** + * 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; +} + +// ============================================================================= +// 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. + */ + 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; +} + +// ============================================================================= +// 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; +} + +/** + * 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 basePolite: boolean | undefined; + + private options: WebRTCManagerOptions; + private callbacks: WebRTCClientCallbacks; + + private recordings = new Map(); + + constructor(options: WebRTCManagerOptions) { + this.options = options; + this.basePolite = options.polite; + this.callbacks = options.callbacks ?? {}; + } + + /** + * 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.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 === 'timeout') return 'timeout'; + return 'error'; + } + + 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') return; + + this.setState('connecting', 'connect() called'); + + try { + if (this.options.media !== false) { + 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); + } + + this.ws = new WebSocket(this.options.signalUrl); + + this.ws.onopen = () => { + this.setState('signaling', 'WebSocket opened'); + this.send({ t: 'join', roomId: this.options.roomId }); + }; + + this.ws.onmessage = (event) => { + const msg = JSON.parse(event.data) as SignalMessage; + this.handleSignalingMessage(msg); + }; + + this.ws.onerror = () => { + const error = new Error('WebSocket connection error'); + this.callbacks.onError?.(error, this._state); + }; + + this.ws.onclose = () => { + if (this._state !== 'idle') { + this.setState('idle', 'WebSocket closed'); + } + }; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.callbacks.onError?.(error, this._state); + this.setState('idle', 'error'); + } + } + + private async handleSignalingMessage(msg: 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, + }; + + 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) { + this.callbacks.onIceCandidate?.(remotePeerId, event.candidate.toJSON()); + this.send({ + t: 'ice', + from: this.peerId!, + to: remotePeerId, + candidate: event.candidate.toJSON(), + }); + } + }; + + 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); + } + }; + + 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; + + for (const channel of session.dataChannels.values()) { + channel.close(); + } + session.dataChannels.clear(); + + session.pc.close(); + this.peers.delete(peerId); + } + + // ========================================================================= + // 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 (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) => { + recorder.onstop = () => { + 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 { + for (const peerId of this.peers.keys()) { + this.closePeerSession(peerId); + } + this.peers.clear(); + + 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'); + } + + /** + * Clean up all resources + */ + dispose(): void { + this.stopAllRecordings(); + this.hangup(); + } +} diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 0a2c9b4a8..3cb36f1b3 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -91,10 +91,8 @@ Edit `~/.config/opencode/opencode.json` to point to your local package: ```jsonc { - "$schema": "https://opencode.ai/config.json", - "plugin": [ - "/path/to/agentuity/sdk/packages/opencode" - ] + "$schema": "https://opencode.ai/config.json", + "plugin": ["/path/to/agentuity/sdk/packages/opencode"], } ``` diff --git a/packages/opencode/src/types.ts b/packages/opencode/src/types.ts index cbaf38103..f67998f49 100644 --- a/packages/opencode/src/types.ts +++ b/packages/opencode/src/types.ts @@ -91,9 +91,7 @@ export interface PluginClient { }) => void; }; tui?: { - showToast?: (options: { - body: { message: string }; - }) => void; + showToast?: (options: { body: { message: string } }) => void; }; } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7fc036661..e4e18ac97 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, @@ -81,6 +88,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..6f627de7f --- /dev/null +++ b/packages/react/src/webrtc.tsx @@ -0,0 +1,450 @@ +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-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, + 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?.(); + }, + }, + }; + }, [ + signalUrl, + options.roomId, + options.polite, + options.iceServers, + options.media, + options.dataChannels, + ]); + + 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 8432f7daa..30a4fc918 100644 --- a/packages/runtime/src/handlers/index.ts +++ b/packages/runtime/src/handlers/index.ts @@ -9,3 +9,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 0e5d0a173..38fcef095 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -75,7 +75,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, @@ -88,8 +88,21 @@ export { cron, type CronHandler, type CronMetadata, + webrtc, + type WebRTCHandler, } from './handlers'; +// webrtc-signaling.ts exports +export { + type SignalMsg, + 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 72f05a8db..6112058ea 100644 --- a/packages/runtime/src/router.ts +++ b/packages/runtime/src/router.ts @@ -49,6 +49,15 @@ declare module 'hono' { * ``` */ cron(schedule: string, ...args: any[]): this; + + /** + * @deprecated Use the `webrtc` middleware instead: + * ```typescript + * import { webrtc } from '@agentuity/runtime'; + * router.get('/call/signal', webrtc({ maxPeers: 2 })); + * ``` + */ + webrtc(path: string, ...args: any[]): this; } } @@ -62,6 +71,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 @@ -228,5 +238,18 @@ export const createRouter = (): ); }; + _router.webrtc = (path: string, ..._args: any[]) => { + throw new Error( + `router.webrtc() is deprecated and has been removed.\n\n` + + `Migration: Use the webrtc middleware instead:\n\n` + + ` import { createRouter, webrtc } from '@agentuity/runtime';\n\n` + + ` const router = createRouter();\n\n` + + ` // Before (deprecated):\n` + + ` // router.webrtc('${path}');\n\n` + + ` // After:\n` + + ` router.get('${path}/signal', webrtc({ maxPeers: 10 }));` + ); + }; + return router; }; diff --git a/packages/runtime/src/webrtc-signaling.ts b/packages/runtime/src/webrtc-signaling.ts new file mode 100644 index 000000000..ffd0b697f --- /dev/null +++ b/packages/runtime/src/webrtc-signaling.ts @@ -0,0 +1,273 @@ +import type { WebSocketConnection } from './handlers/websocket'; +import type { + SDPDescription, + ICECandidate, + SignalMessage, + WebRTCSignalingCallbacks, +} from '@agentuity/core'; + +export type { SDPDescription, ICECandidate, SignalMessage, WebRTCSignalingCallbacks }; + +/** + * @deprecated Use `SignalMessage` instead. Alias for backwards compatibility. + */ +export type SignalMsg = SignalMessage; + +/** + * 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; + } + + switch (msg.t) { + case 'join': + this.handleJoin(ws, msg.roomId); + break; + case 'sdp': + this.handleSDP(ws, msg.to, msg.description); + break; + case 'ice': + this.handleICE(ws, msg.to, msg.candidate); + break; + default: + this.send(ws, { + t: 'error', + message: `Unknown message type: ${(msg as { t: string }).t}`, + }); + } + } + + /** + * Get room stats for debugging + */ + getRoomStats(): { roomCount: number; totalPeers: number } { + let totalPeers = 0; + for (const room of this.rooms.values()) { + totalPeers += room.size; + } + return { roomCount: this.rooms.size, totalPeers }; + } +} diff --git a/packages/runtime/test/webrtc-signaling.test.ts b/packages/runtime/test/webrtc-signaling.test.ts new file mode 100644 index 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/server/src/api/org/env-delete.ts b/packages/server/src/api/org/env-delete.ts index a63dd0ca2..2c9d7d3c0 100644 --- a/packages/server/src/api/org/env-delete.ts +++ b/packages/server/src/api/org/env-delete.ts @@ -18,10 +18,7 @@ type OrgEnvDeleteResponse = z.infer; * Provide arrays of keys to delete. * Requires admin/owner role. */ -export async function orgEnvDelete( - client: APIClient, - request: OrgEnvDeleteRequest -): Promise { +export async function orgEnvDelete(client: APIClient, request: OrgEnvDeleteRequest): Promise { const { id, env, secrets } = request; const resp = await client.request>( diff --git a/packages/server/src/api/org/env-get.ts b/packages/server/src/api/org/env-get.ts index d4c51dce1..df3ce2fec 100644 --- a/packages/server/src/api/org/env-get.ts +++ b/packages/server/src/api/org/env-get.ts @@ -23,10 +23,7 @@ export type OrgEnv = z.infer; * Secrets are masked by default unless mask=false is specified. * Note: Unmasked values require admin/owner role. */ -export async function orgEnvGet( - client: APIClient, - request: OrgEnvGetRequest -): Promise { +export async function orgEnvGet(client: APIClient, request: OrgEnvGetRequest): Promise { const { id, mask = true } = request; const resp = await client.get( diff --git a/packages/server/src/api/org/env-update.ts b/packages/server/src/api/org/env-update.ts index bdd9ae6dd..61d227584 100644 --- a/packages/server/src/api/org/env-update.ts +++ b/packages/server/src/api/org/env-update.ts @@ -19,10 +19,7 @@ type OrgEnvUpdateResponse = z.infer; * Requires admin/owner role. * Keys starting with 'AGENTUITY_' (except AGENTUITY_PUBLIC_) are filtered out. */ -export async function orgEnvUpdate( - client: APIClient, - request: OrgEnvUpdateRequest -): Promise { +export async function orgEnvUpdate(client: APIClient, request: OrgEnvUpdateRequest): Promise { const { id, env, secrets } = request; const resp = await client.request>( diff --git a/packages/server/src/api/sandbox/index.ts b/packages/server/src/api/sandbox/index.ts index bb2df1113..fcb05e5e6 100644 --- a/packages/server/src/api/sandbox/index.ts +++ b/packages/server/src/api/sandbox/index.ts @@ -21,7 +21,12 @@ export type { } from './execution'; export { SandboxResponseError, writeAndDrain } from './util'; export { SandboxClient } from './client'; -export type { SandboxClientOptions, SandboxClientRunIO, SandboxInstance, ExecuteOptions } from './client'; +export type { + SandboxClientOptions, + SandboxClientRunIO, + SandboxInstance, + ExecuteOptions, +} from './client'; export { sandboxWriteFiles, sandboxReadFile, diff --git a/packages/server/test/org-env.test.ts b/packages/server/test/org-env.test.ts index 508d0d2ff..8dca53c0c 100644 --- a/packages/server/test/org-env.test.ts +++ b/packages/server/test/org-env.test.ts @@ -126,13 +126,10 @@ describe('org env API', () => { expect(init?.method).toBe('PUT'); const body = JSON.parse(init?.body as string); expect(body.env).toEqual({ NEW_VAR: 'value' }); - return new Response( - JSON.stringify({ success: true }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - } - ); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); }); const client = new APIClient('https://api.example.com', createMockLogger(), 'test-key'); @@ -146,13 +143,10 @@ describe('org env API', () => { expect(init?.method).toBe('PUT'); const body = JSON.parse(init?.body as string); expect(body.secrets).toEqual({ API_KEY: 'secret-value' }); - return new Response( - JSON.stringify({ success: true }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - } - ); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); }); const client = new APIClient('https://api.example.com', createMockLogger(), 'test-key'); @@ -165,13 +159,10 @@ describe('org env API', () => { const body = JSON.parse(init?.body as string); expect(body.env).toEqual({ LOG_LEVEL: 'debug' }); expect(body.secrets).toEqual({ DB_PASSWORD: 'password123' }); - return new Response( - JSON.stringify({ success: true }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - } - ); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); }); const client = new APIClient('https://api.example.com', createMockLogger(), 'test-key'); @@ -213,13 +204,10 @@ describe('org env API', () => { expect(init?.method).toBe('DELETE'); const body = JSON.parse(init?.body as string); expect(body.env).toEqual(['OLD_VAR']); - return new Response( - JSON.stringify({ success: true }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - } - ); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); }); const client = new APIClient('https://api.example.com', createMockLogger(), 'test-key'); @@ -233,13 +221,10 @@ describe('org env API', () => { expect(init?.method).toBe('DELETE'); const body = JSON.parse(init?.body as string); expect(body.secrets).toEqual(['OLD_SECRET']); - return new Response( - JSON.stringify({ success: true }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - } - ); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); }); const client = new APIClient('https://api.example.com', createMockLogger(), 'test-key'); @@ -252,13 +237,10 @@ describe('org env API', () => { const body = JSON.parse(init?.body as string); expect(body.env).toEqual(['VAR1', 'VAR2']); expect(body.secrets).toEqual(['SECRET1']); - return new Response( - JSON.stringify({ success: true }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - } - ); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); }); const client = new APIClient('https://api.example.com', createMockLogger(), 'test-key'); @@ -287,9 +269,7 @@ describe('org env API', () => { const client = new APIClient('https://api.example.com', createMockLogger(), 'test-key'); - await expect( - orgEnvDelete(client, { id: 'org-123', env: ['VAR'] }) - ).rejects.toThrow(); + await expect(orgEnvDelete(client, { id: 'org-123', env: ['VAR'] })).rejects.toThrow(); }); }); }); diff --git a/packages/server/test/sandbox-client.test.ts b/packages/server/test/sandbox-client.test.ts index 1a79b35fa..8542169dc 100644 --- a/packages/server/test/sandbox-client.test.ts +++ b/packages/server/test/sandbox-client.test.ts @@ -310,9 +310,9 @@ describe('SandboxClient', () => { const { Readable } = await import('node:stream'); const stdin = new Readable({ read() {} }); - await expect( - client.run({ command: { exec: ['cat'] } }, { stdin }) - ).rejects.toThrow('SandboxClient.run(): stdin streaming requires an API key'); + await expect(client.run({ command: { exec: ['cat'] } }, { stdin })).rejects.toThrow( + 'SandboxClient.run(): stdin streaming requires an API key' + ); }); test('should call sandbox create API with oneshot mode', async () => {