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..8b2c0232a --- /dev/null +++ b/apps/testing/webrtc-test/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "search.exclude": { + "**/.git/**": true, + "**/node_modules/**": true, + "**/bun.lock": true, + "**/.agentuity/**": true + }, + "json.schemas": [ + { + "fileMatch": [ + "agentuity.json" + ], + "url": "https://agentuity.dev/schema/cli/v1/agentuity.json" + } + ] +} \ No newline at end of file diff --git a/apps/testing/webrtc-test/AGENTS.md b/apps/testing/webrtc-test/AGENTS.md new file mode 100644 index 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/packages/core/src/index.ts b/packages/core/src/index.ts index d2b57f393..003b639b5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -124,4 +124,15 @@ export { type WorkbenchConfig, } from './workbench-config'; +// webrtc.ts exports +export type { + SDPDescription, + ICECandidate, + SignalMessage, + SignalMsg, + WebRTCConnectionState, + WebRTCDisconnectReason, + WebRTCSignalingCallbacks, +} from './webrtc'; + // Client code moved to @agentuity/frontend for better bundler compatibility diff --git a/packages/core/src/webrtc.ts b/packages/core/src/webrtc.ts new file mode 100644 index 000000000..7cca51a13 --- /dev/null +++ b/packages/core/src/webrtc.ts @@ -0,0 +1,132 @@ +/** + * WebRTC signaling types shared between server and client. + */ + +// ============================================================================= +// Signaling Protocol Types +// ============================================================================= + +/** + * SDP (Session Description Protocol) description for WebRTC negotiation. + */ +export interface SDPDescription { + type: 'offer' | 'answer' | 'pranswer' | 'rollback'; + sdp?: string; +} + +/** + * ICE (Interactive Connectivity Establishment) candidate for NAT traversal. + */ +export interface ICECandidate { + candidate?: string; + sdpMid?: string | null; + sdpMLineIndex?: number | null; + usernameFragment?: string | null; +} + +/** + * Signaling message protocol for WebRTC peer communication. + * + * Message types: + * - `join`: Client requests to join a room + * - `joined`: Server confirms join with peer ID and existing peers + * - `peer-joined`: Server notifies when another peer joins the room + * - `peer-left`: Server notifies when a peer leaves the room + * - `sdp`: SDP offer/answer exchange between peers + * - `ice`: ICE candidate exchange between peers + * - `error`: Error message from server + */ +export type SignalMessage = + | { t: 'join'; roomId: string } + | { t: 'joined'; peerId: string; roomId: string; peers: string[] } + | { t: 'peer-joined'; peerId: string } + | { t: 'peer-left'; peerId: string } + | { t: 'sdp'; from: string; to?: string; description: SDPDescription } + | { t: 'ice'; from: string; to?: string; candidate: ICECandidate } + | { t: 'error'; message: string }; + +/** + * @deprecated Use `SignalMessage` instead. Alias for backwards compatibility. + */ +export type SignalMsg = SignalMessage; + +// ============================================================================= +// Frontend State Machine Types +// ============================================================================= + +/** + * WebRTC connection states for the frontend state machine. + * + * State transitions: + * - idle → connecting: connect() called + * - connecting → signaling: WebSocket opened, joined room + * - connecting → idle: error or cancel + * - signaling → negotiating: peer joined, SDP exchange started + * - signaling → idle: hangup or WebSocket closed + * - negotiating → connected: ICE complete, media flowing + * - negotiating → signaling: peer left during negotiation + * - negotiating → idle: error or hangup + * - connected → negotiating: renegotiation needed + * - connected → signaling: peer left + * - connected → idle: hangup or WebSocket closed + */ +export type WebRTCConnectionState = 'idle' | 'connecting' | 'signaling' | 'negotiating' | 'connected'; + +/** + * Reasons for disconnection. + */ +export type WebRTCDisconnectReason = 'hangup' | 'error' | 'peer-left' | 'timeout'; + +// ============================================================================= +// Backend Signaling Callbacks +// ============================================================================= + +/** + * Callbacks for WebRTC signaling server events. + * All callbacks are optional - only subscribe to events you care about. + */ +export interface WebRTCSignalingCallbacks { + /** + * Called when a new room is created. + * @param roomId - The room ID + */ + onRoomCreated?: (roomId: string) => void; + + /** + * Called when a room is destroyed (last peer left). + * @param roomId - The room ID + */ + onRoomDestroyed?: (roomId: string) => void; + + /** + * Called when a peer joins a room. + * @param peerId - The peer's ID + * @param roomId - The room ID + */ + onPeerJoin?: (peerId: string, roomId: string) => void; + + /** + * Called when a peer leaves a room. + * @param peerId - The peer's ID + * @param roomId - The room ID + * @param reason - Why the peer left + */ + onPeerLeave?: (peerId: string, roomId: string, reason: 'disconnect' | 'kicked') => void; + + /** + * Called when a signaling message is relayed. + * @param type - Message type ('sdp' or 'ice') + * @param from - Sender peer ID + * @param to - Target peer ID (undefined for broadcast) + * @param roomId - The room ID + */ + onMessage?: (type: 'sdp' | 'ice', from: string, to: string | undefined, roomId: string) => void; + + /** + * Called when an error occurs. + * @param error - The error that occurred + * @param peerId - The peer ID if applicable + * @param roomId - The room ID if applicable + */ + onError?: (error: Error, peerId?: string, roomId?: string) => void; +} diff --git a/packages/frontend/src/index.ts b/packages/frontend/src/index.ts index 7126a94f8..9a3407194 100644 --- a/packages/frontend/src/index.ts +++ b/packages/frontend/src/index.ts @@ -23,6 +23,17 @@ export { type EventStreamManagerOptions, type EventStreamManagerState, } from './eventstream-manager'; +export { + WebRTCManager, + type WebRTCStatus, + type WebRTCCallbacks, + type WebRTCManagerOptions, + type WebRTCManagerState, + type WebRTCClientCallbacks, +} from './webrtc-manager'; + +// Re-export core WebRTC types for convenience +export type { WebRTCConnectionState, WebRTCDisconnectReason } from '@agentuity/core'; // Export client implementation (local to this package) export { createClient } from './client/index'; diff --git a/packages/frontend/src/webrtc-manager.ts b/packages/frontend/src/webrtc-manager.ts new file mode 100644 index 000000000..97e3548ff --- /dev/null +++ b/packages/frontend/src/webrtc-manager.ts @@ -0,0 +1,636 @@ +import type { + SignalMessage, + WebRTCConnectionState, + WebRTCDisconnectReason, +} from '@agentuity/core'; + +/** + * 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. + * @param from - Previous state + * @param to - New state + * @param reason - Optional reason for the transition + */ + onStateChange?: (from: WebRTCConnectionState, to: WebRTCConnectionState, reason?: string) => void; + + /** + * Called when connection is fully established. + */ + onConnect?: () => void; + + /** + * Called when disconnected from the call. + * @param reason - Why the disconnection happened + */ + onDisconnect?: (reason: WebRTCDisconnectReason) => void; + + /** + * Called when local media stream is acquired. + * @param stream - The local MediaStream + */ + onLocalStream?: (stream: MediaStream) => void; + + /** + * Called when remote media stream is received. + * @param stream - The remote MediaStream + */ + onRemoteStream?: (stream: MediaStream) => void; + + /** + * Called when a new track is added to a stream. + * @param track - The added track + * @param stream - The stream containing the track + */ + onTrackAdded?: (track: MediaStreamTrack, stream: MediaStream) => void; + + /** + * Called when a track is removed from a stream. + * @param track - The removed track + */ + onTrackRemoved?: (track: MediaStreamTrack) => void; + + /** + * Called when a peer joins the room. + * @param peerId - The peer's ID + */ + onPeerJoined?: (peerId: string) => void; + + /** + * Called when a peer leaves the room. + * @param peerId - The peer's ID + */ + onPeerLeft?: (peerId: string) => void; + + /** + * Called when SDP/ICE negotiation starts. + */ + onNegotiationStart?: () => void; + + /** + * Called when SDP/ICE negotiation completes successfully. + */ + onNegotiationComplete?: () => void; + + /** + * Called for each ICE candidate generated. + * @param candidate - The ICE candidate + */ + onIceCandidate?: (candidate: RTCIceCandidateInit) => void; + + /** + * Called when ICE connection state changes. + * @param state - The new ICE connection state + */ + onIceStateChange?: (state: string) => void; + + /** + * Called when an error occurs. + * @param error - The error that occurred + * @param state - The state when the error occurred + */ + onError?: (error: Error, state: WebRTCConnectionState) => void; +} + +/** + * @deprecated Use `WebRTCConnectionState` from @agentuity/core instead. + */ +export type WebRTCStatus = 'disconnected' | 'connecting' | 'signaling' | 'connected'; + +/** + * @deprecated Use `WebRTCClientCallbacks` from @agentuity/core instead. + */ +export interface WebRTCCallbacks { + onLocalStream?: (stream: MediaStream) => void; + onRemoteStream?: (stream: MediaStream) => void; + onStatusChange?: (status: WebRTCStatus) => void; + onError?: (error: Error) => void; + onPeerJoined?: (peerId: string) => void; + onPeerLeft?: (peerId: string) => void; +} + +/** + * Options for WebRTCManager + */ +export interface WebRTCManagerOptions { + /** WebSocket signaling URL */ + signalUrl: string; + /** Room ID to join */ + roomId: string; + /** Whether this peer is "polite" in perfect negotiation (default: true) */ + polite?: boolean; + /** ICE servers configuration */ + iceServers?: RTCIceServer[]; + /** Media constraints for getUserMedia */ + media?: MediaStreamConstraints; + /** + * Callbacks for state changes and events. + * Supports both legacy WebRTCCallbacks and new WebRTCClientCallbacks. + */ + callbacks?: WebRTCClientCallbacks; +} + +/** + * WebRTC manager state + */ +export interface WebRTCManagerState { + state: WebRTCConnectionState; + peerId: string | null; + remotePeerId: string | null; + isAudioMuted: boolean; + isVideoMuted: boolean; + /** @deprecated Use `state` instead */ + status: WebRTCStatus; +} + +/** + * 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' }, +]; + +/** + * Map new state to legacy status for backwards compatibility + */ +function stateToStatus(state: WebRTCConnectionState): WebRTCStatus { + if (state === 'idle') return 'disconnected'; + if (state === 'negotiating') return 'connecting'; + return state as WebRTCStatus; +} + +/** + * Framework-agnostic WebRTC connection manager with signaling, + * perfect negotiation, and media stream handling. + * + * Uses an explicit state machine for connection lifecycle: + * - idle: No resources allocated, ready to connect + * - connecting: Acquiring media + opening WebSocket + * - signaling: In room, waiting for peer + * - negotiating: SDP/ICE exchange in progress + * - connected: Media flowing + * + * @example + * ```ts + * const manager = new WebRTCManager({ + * signalUrl: 'wss://example.com/call/signal', + * roomId: 'my-room', + * callbacks: { + * onStateChange: (from, to, reason) => { + * console.log(`State: ${from} → ${to}`, reason); + * }, + * onConnect: () => console.log('Connected!'), + * onDisconnect: (reason) => console.log('Disconnected:', reason), + * onLocalStream: (stream) => { localVideo.srcObject = stream; }, + * onRemoteStream: (stream) => { remoteVideo.srcObject = stream; }, + * onError: (error, state) => console.error(`Error in ${state}:`, error), + * }, + * }); + * + * await manager.connect(); + * ``` + */ +export class WebRTCManager { + private ws: WebSocket | null = null; + private pc: RTCPeerConnection | null = null; + private localStream: MediaStream | null = null; + private remoteStream: MediaStream | null = null; + + private peerId: string | null = null; + private remotePeerId: string | null = null; + private isAudioMuted = false; + private isVideoMuted = false; + + // State machine + private _state: WebRTCConnectionState = 'idle'; + + // Perfect negotiation state + private makingOffer = false; + private ignoreOffer = false; + private polite: boolean; + + // ICE candidate buffering - buffer until remote description is set + private pendingCandidates: RTCIceCandidateInit[] = []; + private hasRemoteDescription = false; + + private options: WebRTCManagerOptions; + private callbacks: WebRTCClientCallbacks; + + constructor(options: WebRTCManagerOptions) { + this.options = options; + this.polite = options.polite ?? true; + this.callbacks = options.callbacks ?? {}; + } + + /** + * Current connection state + */ + get state(): WebRTCConnectionState { + return this._state; + } + + /** + * Get current manager state + */ + getState(): WebRTCManagerState { + return { + state: this._state, + status: stateToStatus(this._state), + peerId: this.peerId, + remotePeerId: this.remotePeerId, + isAudioMuted: this.isAudioMuted, + isVideoMuted: this.isVideoMuted, + }; + } + + /** + * Get local media stream + */ + getLocalStream(): MediaStream | null { + return this.localStream; + } + + /** + * Get remote media stream + */ + getRemoteStream(): MediaStream | null { + return this.remoteStream; + } + + /** + * Transition to a new state with callback notifications + */ + private setState(newState: WebRTCConnectionState, reason?: string): void { + const prevState = this._state; + if (prevState === newState) return; + + this._state = newState; + + // Fire state change callback + this.callbacks.onStateChange?.(prevState, newState, reason); + + // Fire connect/disconnect callbacks + if (newState === 'connected' && prevState !== 'connected') { + this.callbacks.onConnect?.(); + this.callbacks.onNegotiationComplete?.(); + } + + if (newState === 'idle' && prevState !== 'idle') { + const disconnectReason = this.mapToDisconnectReason(reason); + this.callbacks.onDisconnect?.(disconnectReason); + } + + if (newState === 'negotiating' && prevState !== 'negotiating') { + this.callbacks.onNegotiationStart?.(); + } + } + + private mapToDisconnectReason(reason?: string): WebRTCDisconnectReason { + if (reason === 'hangup') return 'hangup'; + if (reason === 'peer-left') return 'peer-left'; + if (reason === 'timeout') return 'timeout'; + return 'error'; + } + + private send(msg: SignalMessage): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } + + /** + * Connect to the signaling server and start the call + */ + async connect(): Promise { + if (this._state !== 'idle') return; + + this.setState('connecting', 'connect() called'); + + try { + // Get local media + const mediaConstraints = this.options.media ?? { video: true, audio: true }; + this.localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints); + this.callbacks.onLocalStream?.(this.localStream); + + // Connect to signaling server + this.ws = new WebSocket(this.options.signalUrl); + + this.ws.onopen = () => { + this.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; + // If there's already a peer in the room, we're the offerer (impolite) + if (msg.peers.length > 0) { + this.remotePeerId = msg.peers[0]; + // Late joiner is impolite (makes the offer, wins collisions) + this.polite = this.options.polite ?? false; + await this.createPeerConnection(); + this.setState('negotiating', 'creating offer'); + await this.createOffer(); + } else { + // First peer is polite (waits for offers, yields on collision) + this.polite = this.options.polite ?? true; + } + break; + + case 'peer-joined': + this.remotePeerId = msg.peerId; + this.callbacks.onPeerJoined?.(msg.peerId); + // New peer joined, wait for their offer (they initiate) + await this.createPeerConnection(); + break; + + case 'peer-left': + this.callbacks.onPeerLeft?.(msg.peerId); + if (msg.peerId === this.remotePeerId) { + this.remotePeerId = null; + this.closePeerConnection(); + this.setState('signaling', 'peer-left'); + } + break; + + case 'sdp': + if (this._state === 'signaling') { + this.setState('negotiating', 'received SDP'); + } + await this.handleRemoteSDP(msg.description); + break; + + case 'ice': + await this.handleRemoteICE(msg.candidate); + break; + + case 'error': { + const error = new Error(msg.message); + this.callbacks.onError?.(error, this._state); + break; + } + } + } + + private async createPeerConnection(): Promise { + if (this.pc) return; + + const iceServers = this.options.iceServers ?? DEFAULT_ICE_SERVERS; + this.pc = new RTCPeerConnection({ iceServers }); + + // Add local tracks + if (this.localStream) { + for (const track of this.localStream.getTracks()) { + this.pc.addTrack(track, this.localStream); + this.callbacks.onTrackAdded?.(track, this.localStream); + } + } + + // Handle remote tracks + this.pc.ontrack = (event) => { + // Use the stream from the event if available (preferred - already has track) + // Otherwise create a new stream with the track + if (event.streams?.[0]) { + if (this.remoteStream !== event.streams[0]) { + this.remoteStream = event.streams[0]; + this.callbacks.onRemoteStream?.(this.remoteStream); + } + } else { + // Fallback: create stream with track already included + if (!this.remoteStream) { + this.remoteStream = new MediaStream([event.track]); + this.callbacks.onRemoteStream?.(this.remoteStream); + } else { + this.remoteStream.addTrack(event.track); + // Re-trigger callback so video element updates + this.callbacks.onRemoteStream?.(this.remoteStream); + } + } + + this.callbacks.onTrackAdded?.(event.track, this.remoteStream!); + + if (this._state !== 'connected') { + this.setState('connected', 'track received'); + } + }; + + // Handle ICE candidates + this.pc.onicecandidate = (event) => { + if (event.candidate) { + this.callbacks.onIceCandidate?.(event.candidate.toJSON()); + this.send({ + t: 'ice', + from: this.peerId!, + to: this.remotePeerId ?? undefined, + candidate: event.candidate.toJSON(), + }); + } + }; + + // Perfect negotiation: handle negotiation needed + this.pc.onnegotiationneeded = async () => { + try { + this.makingOffer = true; + await this.pc!.setLocalDescription(); + this.send({ + t: 'sdp', + from: this.peerId!, + to: this.remotePeerId ?? undefined, + description: this.pc!.localDescription!, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.callbacks.onError?.(error, this._state); + } finally { + this.makingOffer = false; + } + }; + + this.pc.oniceconnectionstatechange = () => { + const iceState = this.pc?.iceConnectionState; + if (iceState) { + this.callbacks.onIceStateChange?.(iceState); + } + + if (iceState === 'disconnected') { + this.setState('signaling', 'ICE disconnected'); + } else if (iceState === 'connected') { + this.setState('connected', 'ICE connected'); + } else if (iceState === 'failed') { + const error = new Error('ICE connection failed'); + this.callbacks.onError?.(error, this._state); + } + }; + } + + private async createOffer(): Promise { + if (!this.pc) return; + + try { + this.makingOffer = true; + const offer = await this.pc.createOffer(); + await this.pc.setLocalDescription(offer); + + this.send({ + t: 'sdp', + from: this.peerId!, + to: this.remotePeerId ?? undefined, + description: this.pc.localDescription!, + }); + } finally { + this.makingOffer = false; + } + } + + private async handleRemoteSDP(description: RTCSessionDescriptionInit): Promise { + if (!this.pc) { + await this.createPeerConnection(); + } + + const pc = this.pc!; + const isOffer = description.type === 'offer'; + + // Perfect negotiation: collision detection + const offerCollision = isOffer && (this.makingOffer || pc.signalingState !== 'stable'); + + this.ignoreOffer = !this.polite && offerCollision; + if (this.ignoreOffer) return; + + await pc.setRemoteDescription(description); + this.hasRemoteDescription = true; + + // Flush buffered ICE candidates now that remote description is set + for (const candidate of this.pendingCandidates) { + try { + await pc.addIceCandidate(candidate); + } catch (err) { + // Ignore errors for candidates that arrived during collision + if (!this.ignoreOffer) { + console.warn('Failed to add buffered ICE candidate:', err); + } + } + } + this.pendingCandidates = []; + + if (isOffer) { + await pc.setLocalDescription(); + this.send({ + t: 'sdp', + from: this.peerId!, + to: this.remotePeerId ?? undefined, + description: pc.localDescription!, + }); + } + } + + private async handleRemoteICE(candidate: RTCIceCandidateInit): Promise { + // Buffer candidates until peer connection AND remote description are ready + if (!this.pc || !this.hasRemoteDescription) { + this.pendingCandidates.push(candidate); + return; + } + + try { + await this.pc.addIceCandidate(candidate); + } catch (err) { + if (!this.ignoreOffer) { + // Log but don't propagate - some ICE failures are normal + console.warn('Failed to add ICE candidate:', err); + } + } + } + + private closePeerConnection(): void { + if (this.pc) { + this.pc.close(); + this.pc = null; + } + this.remoteStream = null; + this.pendingCandidates = []; + this.makingOffer = false; + this.ignoreOffer = false; + this.hasRemoteDescription = false; + } + + /** + * End the call and disconnect + */ + hangup(): void { + this.closePeerConnection(); + + if (this.localStream) { + for (const track of this.localStream.getTracks()) { + track.stop(); + this.callbacks.onTrackRemoved?.(track); + } + this.localStream = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.peerId = null; + this.remotePeerId = null; + this.setState('idle', 'hangup'); + } + + /** + * Mute or unmute audio + */ + muteAudio(muted: boolean): void { + if (this.localStream) { + for (const track of this.localStream.getAudioTracks()) { + track.enabled = !muted; + } + } + this.isAudioMuted = muted; + } + + /** + * Mute or unmute video + */ + muteVideo(muted: boolean): void { + if (this.localStream) { + for (const track of this.localStream.getVideoTracks()) { + track.enabled = !muted; + } + } + this.isVideoMuted = muted; + } + + /** + * Clean up all resources + */ + dispose(): void { + this.hangup(); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7fc036661..bc986ae91 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -29,6 +29,14 @@ export { type SSERouteOutput, type EventStreamOptions, } from './eventstream'; +export { + useWebRTCCall, + type UseWebRTCCallOptions, + type UseWebRTCCallResult, + type WebRTCStatus, + type WebRTCConnectionState, + type WebRTCClientCallbacks, +} from './webrtc'; export { useAPI, type RouteKey, @@ -81,6 +89,11 @@ export { type EventStreamCallbacks, type EventStreamManagerOptions, type EventStreamManagerState, + WebRTCManager, + type WebRTCCallbacks, + type WebRTCManagerOptions, + type WebRTCManagerState, + type WebRTCDisconnectReason, // Client type exports (createClient is exported from ./client.ts) type Client, type ClientOptions, diff --git a/packages/react/src/webrtc.tsx b/packages/react/src/webrtc.tsx new file mode 100644 index 000000000..0ffccecb9 --- /dev/null +++ b/packages/react/src/webrtc.tsx @@ -0,0 +1,271 @@ +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { + WebRTCManager, + buildUrl, + type WebRTCStatus, + type WebRTCManagerOptions, + type WebRTCConnectionState, + type WebRTCClientCallbacks, +} from '@agentuity/frontend'; + +export type { WebRTCClientCallbacks }; +import { AgentuityContext } from './context'; + +export type { WebRTCStatus, WebRTCConnectionState }; + +/** + * Options for useWebRTCCall hook + */ +export interface UseWebRTCCallOptions { + /** Room ID to join */ + roomId: string; + /** WebSocket signaling URL (e.g., '/call/signal' or full URL) */ + signalUrl: string; + /** Whether this peer is "polite" in perfect negotiation (default: true for first joiner) */ + polite?: boolean; + /** ICE servers configuration */ + iceServers?: RTCIceServer[]; + /** Media constraints for getUserMedia */ + media?: MediaStreamConstraints; + /** Whether to auto-connect on mount (default: true) */ + autoConnect?: boolean; + /** + * Optional callbacks for WebRTC events. + * These are called in addition to the hook's internal state management. + */ + callbacks?: Partial; +} + +/** + * Return type for useWebRTCCall hook + */ +export interface UseWebRTCCallResult { + /** Ref to attach to local video element */ + localVideoRef: React.RefObject; + /** Ref to attach to remote video element */ + remoteVideoRef: React.RefObject; + /** Current connection state (new state machine) */ + state: WebRTCConnectionState; + /** @deprecated Use `state` instead. Current connection status */ + status: WebRTCStatus; + /** Current error if any */ + error: Error | null; + /** Local peer ID assigned by server */ + peerId: string | null; + /** Remote peer ID */ + remotePeerId: string | null; + /** Whether audio is muted */ + isAudioMuted: boolean; + /** Whether video is muted */ + isVideoMuted: boolean; + /** Manually start the connection (if autoConnect is false) */ + connect: () => void; + /** End the call */ + hangup: () => void; + /** Mute or unmute audio */ + muteAudio: (muted: boolean) => void; + /** Mute or unmute video */ + muteVideo: (muted: boolean) => void; +} + +/** + * Map new state to legacy status for backwards compatibility + */ +function stateToStatus(state: WebRTCConnectionState): WebRTCStatus { + if (state === 'idle') return 'disconnected'; + if (state === 'negotiating') return 'connecting'; + return state as WebRTCStatus; +} + +/** + * React hook for WebRTC peer-to-peer audio/video calls. + * + * Handles WebRTC signaling, media capture, and peer connection management. + * + * @example + * ```tsx + * function VideoCall({ roomId }: { roomId: string }) { + * const { + * localVideoRef, + * remoteVideoRef, + * state, + * hangup, + * muteAudio, + * isAudioMuted, + * } = useWebRTCCall({ + * roomId, + * signalUrl: '/call/signal', + * callbacks: { + * onStateChange: (from, to, reason) => { + * console.log(`State: ${from} → ${to}`, reason); + * }, + * onConnect: () => console.log('Connected!'), + * onDisconnect: (reason) => console.log('Disconnected:', reason), + * }, + * }); + * + * return ( + *
+ *
+ * ); + * } + * ``` + */ +export function useWebRTCCall(options: UseWebRTCCallOptions): UseWebRTCCallResult { + const context = useContext(AgentuityContext); + + const managerRef = useRef(null); + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + + const [state, setState] = useState('idle'); + const [error, setError] = useState(null); + const [peerId, setPeerId] = useState(null); + const [remotePeerId, setRemotePeerId] = useState(null); + const [isAudioMuted, setIsAudioMuted] = useState(false); + const [isVideoMuted, setIsVideoMuted] = useState(false); + + // Store user callbacks in a ref to avoid recreating manager + const userCallbacksRef = useRef(options.callbacks); + userCallbacksRef.current = options.callbacks; + + // Build full signaling URL + const signalUrl = useMemo(() => { + // If it's already a full URL, use as-is + if (options.signalUrl.startsWith('ws://') || options.signalUrl.startsWith('wss://')) { + return options.signalUrl; + } + + // Build from context base URL + const base = context?.baseUrl ?? window.location.origin; + const wsBase = base.replace(/^http(s?):/, 'ws$1:'); + return buildUrl(wsBase, options.signalUrl); + }, [context?.baseUrl, options.signalUrl]); + + // Create manager options - use refs to avoid recreating manager on state changes + const managerOptions = useMemo((): WebRTCManagerOptions => { + return { + signalUrl, + roomId: options.roomId, + polite: options.polite, + iceServers: options.iceServers, + media: options.media, + callbacks: { + onStateChange: (from, to, reason) => { + setState(to); + if (managerRef.current) { + const managerState = managerRef.current.getState(); + setPeerId(managerState.peerId); + setRemotePeerId(managerState.remotePeerId); + } + 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: (stream) => { + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = stream; + } + userCallbacksRef.current?.onRemoteStream?.(stream); + }, + onTrackAdded: (track, stream) => { + userCallbacksRef.current?.onTrackAdded?.(track, stream); + }, + onTrackRemoved: (track) => { + userCallbacksRef.current?.onTrackRemoved?.(track); + }, + onPeerJoined: (id) => { + setRemotePeerId(id); + userCallbacksRef.current?.onPeerJoined?.(id); + }, + onPeerLeft: (id) => { + setRemotePeerId((current) => (current === id ? null : current)); + userCallbacksRef.current?.onPeerLeft?.(id); + }, + onNegotiationStart: () => { + userCallbacksRef.current?.onNegotiationStart?.(); + }, + onNegotiationComplete: () => { + userCallbacksRef.current?.onNegotiationComplete?.(); + }, + onIceCandidate: (candidate) => { + userCallbacksRef.current?.onIceCandidate?.(candidate); + }, + onIceStateChange: (iceState) => { + userCallbacksRef.current?.onIceStateChange?.(iceState); + }, + onError: (err, currentState) => { + setError(err); + userCallbacksRef.current?.onError?.(err, currentState); + }, + }, + }; + }, [signalUrl, options.roomId, options.polite, options.iceServers, options.media]); + + // Initialize manager + useEffect(() => { + const manager = new WebRTCManager(managerOptions); + managerRef.current = manager; + + // Auto-connect if enabled (default: true) + if (options.autoConnect !== false) { + manager.connect(); + } + + return () => { + manager.dispose(); + managerRef.current = null; + }; + }, [managerOptions, options.autoConnect]); + + const connect = useCallback(() => { + managerRef.current?.connect(); + }, []); + + const hangup = useCallback(() => { + managerRef.current?.hangup(); + }, []); + + const muteAudio = useCallback((muted: boolean) => { + managerRef.current?.muteAudio(muted); + setIsAudioMuted(muted); + }, []); + + const muteVideo = useCallback((muted: boolean) => { + managerRef.current?.muteVideo(muted); + setIsVideoMuted(muted); + }, []); + + return { + localVideoRef, + remoteVideoRef, + state, + status: stateToStatus(state), + error, + peerId, + remotePeerId, + isAudioMuted, + isVideoMuted, + connect, + hangup, + muteAudio, + muteVideo, + }; +} 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..9de12f554 --- /dev/null +++ b/packages/runtime/src/handlers/webrtc.ts @@ -0,0 +1,124 @@ +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; + 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..327c13b44 --- /dev/null +++ b/packages/runtime/test/webrtc-signaling.test.ts @@ -0,0 +1,437 @@ +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'); + }); + }); +});