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

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

+ +

Received: {data}

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

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

+ {error &&

Error: {error.message}

} +

Data: {data}

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

My Agentuity App

+ +
+

Count: {count}

+ +
+ +
+ +

{agentResult}

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

WebRTC Video Call Demo

+

Powered by Agentuity

+
+ + {!joined ? ( +
+

Join a Room

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

Open this page in two browser tabs to test

+
+ ) : ( +
+
+ {status} + {peerId && You: {peerId}} + {remotePeerId && Remote: {remotePeerId}} +
+ + {error &&
Error: {error.message}
} + +
+
+
+
+
+
+ +
+ + + +
+
+ )} + + +
+ ); +} diff --git a/apps/testing/webrtc-test/src/web/frontend.tsx b/apps/testing/webrtc-test/src/web/frontend.tsx new file mode 100644 index 00000000..96996781 --- /dev/null +++ b/apps/testing/webrtc-test/src/web/frontend.tsx @@ -0,0 +1,29 @@ +/** + * This file is the entry point for the React app, it sets up the root + * element and renders the App component to the DOM. + * + * It is included in `src/index.html`. + */ + +import React, { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AgentuityProvider } from '@agentuity/react'; +import { App } from './App'; + +const elem = document.getElementById('root')!; +const app = ( + + + + + +); + +if (import.meta.hot) { + // With hot module reloading, `import.meta.hot.data` is persisted. + const root = (import.meta.hot.data.root ??= createRoot(elem)); + root.render(app); +} else { + // The hot module reloading API is not available in production. + createRoot(elem).render(app); +} diff --git a/apps/testing/webrtc-test/src/web/index.html b/apps/testing/webrtc-test/src/web/index.html new file mode 100644 index 00000000..781191e6 --- /dev/null +++ b/apps/testing/webrtc-test/src/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Agentuity + Bun + React + + + +
+ + diff --git a/apps/testing/webrtc-test/src/web/public/.gitkeep b/apps/testing/webrtc-test/src/web/public/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/testing/webrtc-test/src/web/public/favicon.ico b/apps/testing/webrtc-test/src/web/public/favicon.ico new file mode 100644 index 00000000..21f46e6f 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 00000000..9b379e0f --- /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 707c7739..b2b38c1c 100644 --- a/bun.lock +++ b/bun.lock @@ -62,11 +62,11 @@ "name": "e2e-web-tests", "version": "0.0.1", "dependencies": { - "@agentuity/cli": "file:/Users/jhaynie/code/sdk/dist/packages/agentuity-cli-0.0.100.tgz", - "@agentuity/core": "file:/Users/jhaynie/code/sdk/dist/packages/agentuity-core-0.0.100.tgz", - "@agentuity/react": "file:/Users/jhaynie/code/sdk/dist/packages/agentuity-react-0.0.100.tgz", - "@agentuity/runtime": "file:/Users/jhaynie/code/sdk/dist/packages/agentuity-runtime-0.0.100.tgz", - "@agentuity/schema": "file:/Users/jhaynie/code/sdk/dist/packages/agentuity-schema-0.0.100.tgz", + "@agentuity/cli": "workspace:*", + "@agentuity/core": "workspace:*", + "@agentuity/react": "workspace:*", + "@agentuity/runtime": "workspace:*", + "@agentuity/schema": "workspace:*", "hono": "^4.7.13", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -95,6 +95,25 @@ "@types/react-dom": "^19.2.3", }, }, + "apps/testing/webrtc-test": { + "name": "webrtc-test", + "version": "0.0.1", + "dependencies": { + "@agentuity/react": "workspace:*", + "@agentuity/runtime": "workspace:*", + "@agentuity/schema": "workspace:*", + "@agentuity/workbench": "workspace:*", + "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", + "typescript": "^5", + }, + }, "packages/auth": { "name": "@agentuity/auth", "version": "0.0.100", @@ -2965,6 +2984,8 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "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@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], @@ -3215,16 +3236,6 @@ "decompress-response/mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "e2e-web-tests/@agentuity/cli": ["@agentuity/cli@/Users/jhaynie/code/sdk/dist/packages/agentuity-cli-0.0.100.tgz", { "dependencies": { "@agentuity/core": "0.0.100", "@agentuity/server": "0.0.100", "@datasert/cronjs-parser": "^1.4.0", "@terascope/fetch-github-release": "^2.2.1", "@vitejs/plugin-react": "^5.1.2", "acorn-loose": "^8.5.2", "adm-zip": "^0.5.16", "astring": "^1.9.0", "cli-table3": "^0.6.5", "commander": "^14.0.2", "enquirer": "^2.4.1", "git-url-parse": "^16.1.0", "json-colorizer": "^3.0.1", "json5": "^2.2.3", "tar": "^7.5.2", "tar-fs": "^3.1.1", "typescript": "^5.9.0", "vite": "^7.2.7", "zod": "^4.1.12" }, "bin": { "agentuity": "./bin/cli.ts" } }], - - "e2e-web-tests/@agentuity/core": ["@agentuity/core@/Users/jhaynie/code/sdk/dist/packages/agentuity-core-0.0.100.tgz", { "dependencies": { "zod": "^4.1.12" } }], - - "e2e-web-tests/@agentuity/react": ["@agentuity/react@/Users/jhaynie/code/sdk/dist/packages/agentuity-react-0.0.100.tgz", { "dependencies": { "@agentuity/core": "0.0.100", "@agentuity/frontend": "0.0.100" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "^19.0.0" } }], - - "e2e-web-tests/@agentuity/runtime": ["@agentuity/runtime@/Users/jhaynie/code/sdk/dist/packages/agentuity-runtime-0.0.100.tgz", { "dependencies": { "@agentuity/core": "0.0.100", "@agentuity/schema": "0.0.100", "@agentuity/server": "0.0.100", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.207.0", "@opentelemetry/auto-instrumentations-node": "^0.66.0", "@opentelemetry/core": "^2.2.0", "@opentelemetry/exporter-logs-otlp-http": "^0.207.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.207.0", "@opentelemetry/exporter-trace-otlp-http": "^0.207.0", "@opentelemetry/host-metrics": "^0.36.2", "@opentelemetry/otlp-exporter-base": "^0.207.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.207.0", "@opentelemetry/sdk-metrics": "^2.2.0", "@opentelemetry/sdk-node": "^0.207.0", "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@traceloop/ai-semantic-conventions": "0.21.0", "@traceloop/node-server-sdk": "0.21.1", "@types/mailparser": "^3.4.6", "hono": "^4.7.13", "mailparser": "^3.7.1", "zod": "^4.1.12" } }], - - "e2e-web-tests/@agentuity/schema": ["@agentuity/schema@/Users/jhaynie/code/sdk/dist/packages/agentuity-schema-0.0.100.tgz", { "dependencies": { "@agentuity/core": "0.0.100" } }], - "eslint/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], "eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], @@ -3481,16 +3492,6 @@ "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - "e2e-web-tests/@agentuity/cli/@agentuity/core": ["@agentuity/core@workspace:packages/core"], - - "e2e-web-tests/@agentuity/react/@agentuity/core": ["@agentuity/core@workspace:packages/core"], - - "e2e-web-tests/@agentuity/runtime/@agentuity/core": ["@agentuity/core@workspace:packages/core"], - - "e2e-web-tests/@agentuity/runtime/@agentuity/schema": ["@agentuity/schema@workspace:packages/schema"], - - "e2e-web-tests/@agentuity/schema/@agentuity/core": ["@agentuity/core@workspace:packages/core"], - "normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "prebuild-install/tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], diff --git a/packages/cli/src/cmd/build/ast.ts b/packages/cli/src/cmd/build/ast.ts index 9cea0b92..a2e934c7 100644 --- a/packages/cli/src/cmd/build/ast.ts +++ b/packages/cli/src/cmd/build/ast.ts @@ -1336,6 +1336,17 @@ export async function parseRoute( } break; } + case 'webrtc': { + // webrtc() registers a signaling endpoint at ${path}/signal + type = 'websocket'; + method = 'get'; + const theaction = action as ASTLiteral; + if (theaction.type === 'Literal') { + suffix = `${String(theaction.value)}/signal`; + break; + } + break; + } case 'sms': { type = method; method = 'post'; diff --git a/packages/cli/src/cmd/dev/index.ts b/packages/cli/src/cmd/dev/index.ts index 2c00062c..5c5f8384 100644 --- a/packages/cli/src/cmd/dev/index.ts +++ b/packages/cli/src/cmd/dev/index.ts @@ -132,7 +132,9 @@ export const command = createCommand({ : null; // Track previous metadata for sync diffing - let previousMetadata: Awaited> | undefined; + let previousMetadata: + | Awaited> + | undefined; let devmode: DevmodeResponse | undefined; let gravityBin: string | undefined; @@ -358,67 +360,67 @@ export const command = createCommand({ await tui.spinner({ message: 'Building dev bundle', callback: async () => { - const { generateEntryFile } = await import('../build/entry-generator'); - await generateEntryFile({ - rootDir, - projectId: project?.projectId ?? '', - deploymentId, - logger, - mode: 'dev', - }); - - // Bundle the app with LLM patches (dev mode = no minification) - const { installExternalsAndBuild } = await import('../build/vite/server-bundler'); - await installExternalsAndBuild({ - rootDir, - dev: true, // DevMode: no minification, inline sourcemaps - logger, - }); - - // Generate metadata file (needed for eval ID lookup at runtime) - const { discoverAgents } = await import('../build/vite/agent-discovery'); - const { discoverRoutes } = await import('../build/vite/route-discovery'); - const { generateMetadata, writeMetadataFile } = await import( - '../build/vite/metadata-generator' - ); - - const srcDir = join(rootDir, 'src'); - const agents = await discoverAgents( - srcDir, - project?.projectId ?? '', - deploymentId, - logger - ); - const { routes } = await discoverRoutes( - srcDir, - project?.projectId ?? '', - deploymentId, - logger - ); - - const metadata = await generateMetadata({ - rootDir, - projectId: project?.projectId ?? '', - orgId: project?.orgId ?? '', - deploymentId, - agents, - routes, - dev: true, - logger, - }); - - writeMetadataFile(rootDir, metadata, true, logger); + const { generateEntryFile } = await import('../build/entry-generator'); + await generateEntryFile({ + rootDir, + projectId: project?.projectId ?? '', + deploymentId, + logger, + mode: 'dev', + }); + + // Bundle the app with LLM patches (dev mode = no minification) + const { installExternalsAndBuild } = await import('../build/vite/server-bundler'); + await installExternalsAndBuild({ + rootDir, + dev: true, // DevMode: no minification, inline sourcemaps + logger, + }); + + // Generate metadata file (needed for eval ID lookup at runtime) + const { discoverAgents } = await import('../build/vite/agent-discovery'); + const { discoverRoutes } = await import('../build/vite/route-discovery'); + const { generateMetadata, writeMetadataFile } = await import( + '../build/vite/metadata-generator' + ); - // Sync metadata with backend (creates agents and evals in the database) - if (syncService && project?.projectId) { - await syncService.sync( - metadata, - previousMetadata, - project.projectId, - deploymentId + const srcDir = join(rootDir, 'src'); + const agents = await discoverAgents( + srcDir, + project?.projectId ?? '', + deploymentId, + logger ); - previousMetadata = metadata; - } + const { routes } = await discoverRoutes( + srcDir, + project?.projectId ?? '', + deploymentId, + logger + ); + + const metadata = await generateMetadata({ + rootDir, + projectId: project?.projectId ?? '', + orgId: project?.orgId ?? '', + deploymentId, + agents, + routes, + dev: true, + logger, + }); + + writeMetadataFile(rootDir, metadata, true, logger); + + // Sync metadata with backend (creates agents and evals in the database) + if (syncService && project?.projectId) { + await syncService.sync( + metadata, + previousMetadata, + project.projectId, + deploymentId + ); + previousMetadata = metadata; + } }, clearOnSuccess: true, }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0e5f40d5..58c7d4ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -91,4 +91,15 @@ export { type WorkbenchConfig, } from './workbench-config'; +// webrtc.ts exports +export type { + SDPDescription, + ICECandidate, + SignalMessage, + SignalMsg, + WebRTCConnectionState, + WebRTCDisconnectReason, + WebRTCSignalingCallbacks, +} from './webrtc'; + // Client code moved to @agentuity/frontend for better bundler compatibility diff --git a/packages/core/src/webrtc.ts b/packages/core/src/webrtc.ts new file mode 100644 index 00000000..7cca51a1 --- /dev/null +++ b/packages/core/src/webrtc.ts @@ -0,0 +1,132 @@ +/** + * WebRTC signaling types shared between server and client. + */ + +// ============================================================================= +// Signaling Protocol Types +// ============================================================================= + +/** + * SDP (Session Description Protocol) description for WebRTC negotiation. + */ +export interface SDPDescription { + type: 'offer' | 'answer' | 'pranswer' | 'rollback'; + sdp?: string; +} + +/** + * ICE (Interactive Connectivity Establishment) candidate for NAT traversal. + */ +export interface ICECandidate { + candidate?: string; + sdpMid?: string | null; + sdpMLineIndex?: number | null; + usernameFragment?: string | null; +} + +/** + * Signaling message protocol for WebRTC peer communication. + * + * Message types: + * - `join`: Client requests to join a room + * - `joined`: Server confirms join with peer ID and existing peers + * - `peer-joined`: Server notifies when another peer joins the room + * - `peer-left`: Server notifies when a peer leaves the room + * - `sdp`: SDP offer/answer exchange between peers + * - `ice`: ICE candidate exchange between peers + * - `error`: Error message from server + */ +export type SignalMessage = + | { t: 'join'; roomId: string } + | { t: 'joined'; peerId: string; roomId: string; peers: string[] } + | { t: 'peer-joined'; peerId: string } + | { t: 'peer-left'; peerId: string } + | { t: 'sdp'; from: string; to?: string; description: SDPDescription } + | { t: 'ice'; from: string; to?: string; candidate: ICECandidate } + | { t: 'error'; message: string }; + +/** + * @deprecated Use `SignalMessage` instead. Alias for backwards compatibility. + */ +export type SignalMsg = SignalMessage; + +// ============================================================================= +// Frontend State Machine Types +// ============================================================================= + +/** + * WebRTC connection states for the frontend state machine. + * + * State transitions: + * - idle → connecting: connect() called + * - connecting → signaling: WebSocket opened, joined room + * - connecting → idle: error or cancel + * - signaling → negotiating: peer joined, SDP exchange started + * - signaling → idle: hangup or WebSocket closed + * - negotiating → connected: ICE complete, media flowing + * - negotiating → signaling: peer left during negotiation + * - negotiating → idle: error or hangup + * - connected → negotiating: renegotiation needed + * - connected → signaling: peer left + * - connected → idle: hangup or WebSocket closed + */ +export type WebRTCConnectionState = 'idle' | 'connecting' | 'signaling' | 'negotiating' | 'connected'; + +/** + * Reasons for disconnection. + */ +export type WebRTCDisconnectReason = 'hangup' | 'error' | 'peer-left' | 'timeout'; + +// ============================================================================= +// Backend Signaling Callbacks +// ============================================================================= + +/** + * Callbacks for WebRTC signaling server events. + * All callbacks are optional - only subscribe to events you care about. + */ +export interface WebRTCSignalingCallbacks { + /** + * Called when a new room is created. + * @param roomId - The room ID + */ + onRoomCreated?: (roomId: string) => void; + + /** + * Called when a room is destroyed (last peer left). + * @param roomId - The room ID + */ + onRoomDestroyed?: (roomId: string) => void; + + /** + * Called when a peer joins a room. + * @param peerId - The peer's ID + * @param roomId - The room ID + */ + onPeerJoin?: (peerId: string, roomId: string) => void; + + /** + * Called when a peer leaves a room. + * @param peerId - The peer's ID + * @param roomId - The room ID + * @param reason - Why the peer left + */ + onPeerLeave?: (peerId: string, roomId: string, reason: 'disconnect' | 'kicked') => void; + + /** + * Called when a signaling message is relayed. + * @param type - Message type ('sdp' or 'ice') + * @param from - Sender peer ID + * @param to - Target peer ID (undefined for broadcast) + * @param roomId - The room ID + */ + onMessage?: (type: 'sdp' | 'ice', from: string, to: string | undefined, roomId: string) => void; + + /** + * Called when an error occurs. + * @param error - The error that occurred + * @param peerId - The peer ID if applicable + * @param roomId - The room ID if applicable + */ + onError?: (error: Error, peerId?: string, roomId?: string) => void; +} diff --git a/packages/frontend/src/index.ts b/packages/frontend/src/index.ts index 48178564..178713e7 100644 --- a/packages/frontend/src/index.ts +++ b/packages/frontend/src/index.ts @@ -18,6 +18,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 00000000..388156fa --- /dev/null +++ b/packages/frontend/src/webrtc-manager.ts @@ -0,0 +1,635 @@ +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 28b26ba5..b683c1a5 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -30,6 +30,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, @@ -65,6 +73,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 00000000..dc5d80b2 --- /dev/null +++ b/packages/react/src/webrtc.tsx @@ -0,0 +1,272 @@ +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); + }, + }, + }; + // eslint-disable-next-line + }, [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/index.ts b/packages/runtime/src/index.ts index 14a4737f..ff234761 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -59,6 +59,17 @@ export { registerDevModeRoutes } from './devmode'; // router.ts exports export { type HonoEnv, type WebSocketConnection, createRouter } from './router'; +// 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 { type EvalContext, diff --git a/packages/runtime/src/router.ts b/packages/runtime/src/router.ts index 011cf539..af1e3b92 100644 --- a/packages/runtime/src/router.ts +++ b/packages/runtime/src/router.ts @@ -14,6 +14,7 @@ import { hash, returnResponse } from './_util'; import type { Env } from './app'; import { getAgentAsyncLocalStorage } from './_context'; import { parseEmail, type Email } from './io/email'; +import { WebRTCRoomManager, type WebRTCOptions } from './webrtc-signaling'; // Re-export both Env types export type { Env }; @@ -274,6 +275,28 @@ declare module 'hono' { middleware: MiddlewareHandler, handler: (c: Context) => (stream: any) => void ): this; + + /** + * Create a WebRTC signaling endpoint for peer-to-peer audio/video communication. + * + * Registers a WebSocket signaling route at `${path}/signal` that handles: + * - Room membership and peer discovery + * - SDP offer/answer relay + * - ICE candidate relay + * + * @param path - The base route path (e.g., '/call') + * @param options - Optional configuration for the WebRTC endpoint + * + * @example + * ```typescript + * // Create a WebRTC signaling endpoint at /call/signal + * router.webrtc('/call'); + * + * // With options + * router.webrtc('/call', { maxPeers: 2 }); + * ``` + */ + webrtc(path: string, options?: WebRTCOptions): this; } } @@ -284,6 +307,7 @@ declare module 'hono' { * - **stream()** - Stream responses with ReadableStream * - **websocket()** - WebSocket connections * - **sse()** - Server-Sent Events + * - **webrtc()** - WebRTC signaling for peer-to-peer communication * - **email()** - Email handler routing * - **sms()** - SMS handler routing * - **cron()** - Scheduled task routing @@ -715,5 +739,41 @@ export const createRouter = (): } }; + _router.webrtc = (path: string, options?: WebRTCOptions) => { + const roomManager = new WebRTCRoomManager(options); + const signalPath = `${path}/signal`; + + // Use the existing websocket implementation for the signaling route + const wrapper = upgradeWebSocket((_c: Context) => { + let currentWs: WebSocketConnection | undefined; + + return { + onOpen: (_event: any, ws: any) => { + currentWs = { + onOpen: () => {}, + onMessage: () => {}, + onClose: () => {}, + send: (data) => ws.send(data), + }; + }, + onMessage: (event: any, _ws: any) => { + if (currentWs) { + roomManager.handleMessage(currentWs, String(event.data)); + } + }, + onClose: (_event: any, _ws: any) => { + if (currentWs) { + roomManager.handleDisconnect(currentWs); + } + }, + }; + }); + + const wsMiddleware: MiddlewareHandler = (c, next) => + (wrapper as unknown as MiddlewareHandler)(c, next); + + return router.get(signalPath, wsMiddleware); + }; + return router; }; diff --git a/packages/runtime/src/webrtc-signaling.ts b/packages/runtime/src/webrtc-signaling.ts new file mode 100644 index 00000000..eb25d264 --- /dev/null +++ b/packages/runtime/src/webrtc-signaling.ts @@ -0,0 +1,269 @@ +import type { WebSocketConnection } from './router'; +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 + * // Basic usage + * router.webrtc('/call'); + * + * // With callbacks for monitoring + * router.webrtc('/call', { + * 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 00000000..327c13b4 --- /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'); + }); + }); +});