commit a536668a0b6ee3855eeeaebe7d4535c4e97ca3c8 Author: Alexander Zinn Date: Fri Sep 5 00:36:54 2025 -0400 fixed tests diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc new file mode 120000 index 0000000..6100270 --- /dev/null +++ b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc @@ -0,0 +1 @@ +../../CLAUDE.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# 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.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..0318377 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@techniker-me:registry=https://registry-node.techniker.me diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8100b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5dcfdc --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# WebRTC Broadcasting Application + +A full-stack TypeScript application using Bun that enables one publisher to broadcast a stream to multiple subscribers using WebRTC. + +## Architecture + +This application follows SOLID principles with clean separation of concerns: + +### Backend Services +- **ClientManager**: Manages connected clients and their roles +- **SignalingService**: Handles WebSocket signaling between publisher and subscribers +- **Server**: Bun-based HTTP/WebSocket server with routing + +### Frontend Services +- **WebSocketClient**: Manages WebSocket connections and messaging +- **MediaHandler**: Handles media capture and stream management +- **UIController**: Manages user interface updates and interactions +- **PublisherRTCManager**: Manages WebRTC peer connections for publishers +- **SubscriberRTCManager**: Manages WebRTC peer connections for subscribers + +## Features + +- **One-to-Many Broadcasting**: Single publisher streams to multiple subscribers +- **Real-time Signaling**: WebSocket-based signaling for WebRTC negotiation +- **Responsive UI**: Clean, modern interface for both publisher and subscriber +- **Connection Management**: Automatic handling of client connections and disconnections +- **Live Subscriber Count**: Publisher can see how many subscribers are watching +- **Error Handling**: Robust error handling and user feedback + +## Getting Started + +### Prerequisites +- Bun runtime installed +- Modern web browser with WebRTC support +- Camera and microphone access (for publisher) + +### Installation + +1. Install dependencies: +```bash +bun install +``` + +2. Choose your architecture: + +#### **Mesh Architecture (Simple)** +```bash +bun run dev +``` +- **Best for:** 1-10 subscribers +- **Advantages:** Lowest latency, simple setup +- **Limitations:** Publisher bandwidth scales linearly with subscribers + +#### **SFU Architecture (Scalable)** +```bash +bun run sfu +``` +- **Best for:** 10+ subscribers +- **Advantages:** Constant publisher bandwidth, server-side optimization +- **Features:** Adaptive bitrate, stream forwarding, scalable to hundreds + +### Usage + +#### **Mesh Version** (http://localhost:3000) +1. **Access the application**: + - Main page: http://localhost:3000 + - Publisher interface: http://localhost:3000/publisher + - Subscriber interface: http://localhost:3000/subscriber + +2. **Publishing a stream**: + - Open the publisher interface + - Click "Start Broadcasting" + - Allow camera and microphone access + - Direct peer-to-peer connections to subscribers + +3. **Subscribing to a stream**: + - Open the subscriber interface (can open multiple tabs/windows) + - Click "Connect to Stream" + - Direct connection to publisher + +#### **SFU Version** (http://localhost:3001) +1. **Access the SFU application**: + - Main page: http://localhost:3001 + - Publisher interface: http://localhost:3001/publisher + - Subscriber interface: http://localhost:3001/subscriber + - Live statistics: http://localhost:3001/stats + +2. **Publishing via SFU**: + - Open the publisher interface + - Click "Start Broadcasting" + - Stream goes to SFU server, then forwarded to all subscribers + - **Bandwidth stays constant** regardless of subscriber count + +3. **Subscribing via SFU**: + - Open the subscriber interface (open many tabs to test scalability!) + - Click "Connect to Stream" + - Receive optimized stream from SFU server + - **Adaptive quality** based on connection + +## **Architecture Comparison** + +| Feature | **Mesh** | **SFU** | +|---------|----------|---------| +| **Best Use Case** | 1-10 subscribers | 10+ subscribers | +| **Publisher Bandwidth** | Scales with subscribers | Constant | +| **Server Resources** | Minimal | Medium | +| **Latency** | Lowest | Low | +| **Scalability** | Poor (max ~10) | Excellent (100s) | +| **Setup Complexity** | Simple | Moderate | +| **Connection Type** | Peer-to-peer | Server-mediated | + +### **When to Choose Each:** + +#### **Choose Mesh When:** +- Small audience (< 10 viewers) +- Lowest possible latency required +- Simple deployment preferred +- Publisher has excellent upload bandwidth + +#### **Choose SFU When:** +- Large audience (10+ viewers) +- Publisher has limited upload bandwidth +- Need adaptive bitrate streaming +- Want server-side stream management +- Scaling to hundreds of viewers + +## **Testing** + +Comprehensive test suites for both architectures: + +### **Run All Tests** +```bash +bun test +``` + +### **Architecture-Specific Tests** +```bash +# Test Mesh Architecture (Basic) +bun run test:basic + +# Test SFU Architecture +bun run test:sfu +``` + +### **Test Coverage** +- ✅ **41 Total Tests** - All passing +- ✅ **Mesh Architecture** (5 tests) - Basic functionality, signaling, client management +- ✅ **SFU Architecture** (36 tests) - Client management, signaling service, integration tests +- ✅ **Unit Tests** - Individual service testing with mocks +- ✅ **Integration Tests** - Full message flow and scaling scenarios + +### Development + +- **Hot reload**: Both servers run with hot reload enabled for development +- **TypeScript**: Full TypeScript support with proper type checking +- **SOLID Principles**: Codebase follows SOLID principles for maintainability +- **Live Statistics**: SFU version includes real-time performance monitoring +- **Test-Driven**: Comprehensive test coverage for all functionality + +### API Endpoints + +- `GET /`: Main landing page +- `GET /publisher`: Publisher interface +- `GET /subscriber`: Subscriber interface +- `WebSocket /?role=publisher|subscriber`: WebSocket endpoint for signaling + +### Project Structure + +``` +├── src/ +│ ├── interfaces/ # TypeScript interfaces +│ └── services/ # Backend services +├── public/ +│ ├── js/ +│ │ ├── interfaces/ # Frontend interfaces +│ │ └── services/ # Frontend services +│ ├── publisher.html # Publisher interface +│ └── subscriber.html # Subscriber interface +├── server.ts # Main server file +└── package.json # Project configuration +``` + +## Technology Stack + +- **Runtime**: Bun +- **Backend**: TypeScript, WebSockets +- **Frontend**: TypeScript, WebRTC API, HTML5 Media API +- **Real-time Communication**: WebRTC + WebSocket signaling +- **Architecture**: SOLID principles, dependency injection + +## Browser Support + +- Chrome 60+ +- Firefox 60+ +- Safari 13+ +- Edge 80+ + +## License + +MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..54080aa --- /dev/null +++ b/bun.lock @@ -0,0 +1,194 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "webrtc-broadcast", + "dependencies": { + "@techniker-me/tools": "^2025.0.16", + "mediasoup": "3.19.1", + "mediasoup-client": "3.15.6", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/jest": "30.0.0", + "@types/node": "24.3.1", + "bun-types": "latest", + "typescript": "5.9.2", + }, + "peerDependencies": { + "typescript": "5", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jest/diff-sequences": ["@jest/diff-sequences@30.0.1", "", {}, "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw=="], + + "@jest/expect-utils": ["@jest/expect-utils@30.1.2", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A=="], + + "@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], + + "@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + + "@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], + + "@jest/types": ["@jest/types@30.0.5", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], + + "@techniker-me/tools": ["@techniker-me/tools@2025.0.16", "https://registry-node.techniker.me/@techniker-me/tools/-/tools-2025.0.16.tgz", {}, "sha512-Ul2yj1vd4lCO8g7IW2pHkAsdeRVEUMqGpiIvSedCc1joVXEWPbh4GESW83kMHtisjFjjlZIzb3EVlCE0BCiBWQ=="], + + "@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], + + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + + "@types/events-alias": ["@types/events@3.0.3", "", {}, "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g=="], + + "@types/ini": ["@types/ini@4.1.1", "", {}, "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg=="], + + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + + "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], + + "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + + "@types/jest": ["@types/jest@30.0.0", "", { "dependencies": { "expect": "^30.0.0", "pretty-format": "^30.0.0" } }, "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@24.3.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g=="], + + "@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="], + + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], + + "@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "awaitqueue": ["awaitqueue@3.2.4", "", { "dependencies": { "debug": "^4.4.1" } }, "sha512-aZMQSpozgcAfKFLkhTRUMtiDo40EUb8KkXBrI2uHOysDzgRt/prnrt0oh9N8Mp7tRKtusmWxOimt8npn1vorug=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browser-dtector": ["browser-dtector@4.1.0", "", {}, "sha512-ZnLAE4aknz8f7UWJ9/QJ9rNlHjBi59aVu6a73WGnUUAIXak6+2AbY/I1fnRPJhuqn94Lce63u9ecKNP+D9f71w=="], + + "bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "ci-info": ["ci-info@4.3.0", "", {}, "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + + "events-alias": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "expect": ["expect@30.1.2", "", { "dependencies": { "@jest/expect-utils": "30.1.2", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.1.2", "jest-message-util": "30.1.0", "jest-mock": "30.0.5", "jest-util": "30.0.5" } }, "sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg=="], + + "fake-mediastreamtrack": ["fake-mediastreamtrack@2.1.4", "", { "dependencies": { "uuid": "^11.1.0" } }, "sha512-dvhHee735zKWp6RQ2YJW8dkwsT42br54tqMGDmsxzXbRPOsDv+0njbZW29B+ObS+AJlIPDIQFGEAJTr1OKh6pw=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "flatbuffers": ["flatbuffers@25.2.10", "", {}, "sha512-7JlN9ZvLDG1McO3kbX0k4v+SUAg48L1rIwEvN6ZQl/eCtgJz9UylTMzE9wrmYrcorgxm3CX/3T/w5VAub99UUw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "h264-profile-level-id": ["h264-profile-level-id@2.2.3", "", { "dependencies": { "debug": "^4.4.1" } }, "sha512-LhhB3zIIu2TTZFyBC57s+pBddH6eTz8NMr6CwlWWRdy2kurhxxOU5TS0CguJ3i9MEc2B6a0aElAwBxMqS6FdLA=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "ini": ["ini@5.0.0", "", {}, "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "jest-diff": ["jest-diff@30.1.2", "", { "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.0.5" } }, "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ=="], + + "jest-matcher-utils": ["jest-matcher-utils@30.1.2", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.1.2", "pretty-format": "30.0.5" } }, "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ=="], + + "jest-message-util": ["jest-message-util@30.1.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.0.5", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", "pretty-format": "30.0.5", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg=="], + + "jest-mock": ["jest-mock@30.0.5", "", { "dependencies": { "@jest/types": "30.0.5", "@types/node": "*", "jest-util": "30.0.5" } }, "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ=="], + + "jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + + "jest-util": ["jest-util@30.0.5", "", { "dependencies": { "@jest/types": "30.0.5", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "mediasoup": ["mediasoup@3.19.1", "", { "dependencies": { "@types/ini": "^4.1.1", "debug": "^4.4.1", "flatbuffers": "^25.2.10", "h264-profile-level-id": "^2.2.3", "ini": "^5.0.0", "node-fetch": "^3.3.2", "supports-color": "^10.2.0", "tar": "^7.4.3" } }, "sha512-F3Gwyi11+ulSu/yAZjjI5Uh+Mz3lHKkZ6kNZjWsDiPFLIZRISwnzZKyiq82jK8IuSmnWaWDf7fZcs8SXKKsneg=="], + + "mediasoup-client": ["mediasoup-client@3.15.6", "", { "dependencies": { "@types/debug": "^4.1.12", "@types/events-alias": "npm:@types/events@^3.0.3", "awaitqueue": "^3.2.4", "browser-dtector": "^4.1.0", "debug": "^4.4.1", "events-alias": "npm:events@^3.3.0", "fake-mediastreamtrack": "^2.1.4", "h264-profile-level-id": "^2.2.3", "sdp-transform": "^2.15.0", "supports-color": "^10.2.0" } }, "sha512-6pairpRJfwlc7G1dreTFh+gwfmIo8FE1Usr63577cpakjT1lVz/exOn/26S5B6kaTPOzL1KOtsBpJO0G0NJ1PA=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], + + "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pretty-format": ["pretty-format@30.0.5", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "sdp-transform": ["sdp-transform@2.15.0", "", { "bin": { "sdp-verify": "checker.js" } }, "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw=="], + + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + + "supports-color": ["supports-color@10.2.0", "", {}, "sha512-5eG9FQjEjDbAlI5+kdpdyPIBMRH4GfTVDGREVupaZHmVoppknhM29b/S9BkQz7cathp85BVgRi/As3Siln7e0Q=="], + + "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e98b581 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1060 @@ +{ + "name": "webrtc-broadcast", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "webrtc-broadcast", + "version": "1.0.0", + "dependencies": { + "mediasoup": "3.19.1", + "mediasoup-client": "3.15.6" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/jest": "30.0.0", + "@types/node": "24.3.1", + "bun-types": "latest", + "typescript": "5.9.2" + }, + "peerDependencies": { + "typescript": "5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.1.2.tgz", + "integrity": "sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bun": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.2.21.tgz", + "integrity": "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.2.21" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/events-alias": { + "name": "@types/events", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", + "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", + "license": "MIT" + }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", + "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/awaitqueue": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-3.2.4.tgz", + "integrity": "sha512-aZMQSpozgcAfKFLkhTRUMtiDo40EUb8KkXBrI2uHOysDzgRt/prnrt0oh9N8Mp7tRKtusmWxOimt8npn1vorug==", + "license": "ISC", + "dependencies": { + "debug": "^4.4.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mediasoup" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-dtector": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/browser-dtector/-/browser-dtector-4.1.0.tgz", + "integrity": "sha512-ZnLAE4aknz8f7UWJ9/QJ9rNlHjBi59aVu6a73WGnUUAIXak6+2AbY/I1fnRPJhuqn94Lce63u9ecKNP+D9f71w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/bun-types": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.2.21.tgz", + "integrity": "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "peerDependencies": { + "@types/react": "^19" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/events-alias": { + "name": "events", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.1.2.tgz", + "integrity": "sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.1.2", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.1.2", + "jest-message-util": "30.1.0", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fake-mediastreamtrack": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/fake-mediastreamtrack/-/fake-mediastreamtrack-2.1.4.tgz", + "integrity": "sha512-dvhHee735zKWp6RQ2YJW8dkwsT42br54tqMGDmsxzXbRPOsDv+0njbZW29B+ObS+AJlIPDIQFGEAJTr1OKh6pw==", + "license": "ISC", + "dependencies": { + "uuid": "^11.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flatbuffers": { + "version": "25.2.10", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.2.10.tgz", + "integrity": "sha512-7JlN9ZvLDG1McO3kbX0k4v+SUAg48L1rIwEvN6ZQl/eCtgJz9UylTMzE9wrmYrcorgxm3CX/3T/w5VAub99UUw==", + "license": "Apache-2.0" + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/h264-profile-level-id": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/h264-profile-level-id/-/h264-profile-level-id-2.2.3.tgz", + "integrity": "sha512-LhhB3zIIu2TTZFyBC57s+pBddH6eTz8NMr6CwlWWRdy2kurhxxOU5TS0CguJ3i9MEc2B6a0aElAwBxMqS6FdLA==", + "license": "ISC", + "dependencies": { + "debug": "^4.4.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mediasoup" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jest-diff": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.2.tgz", + "integrity": "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.2.tgz", + "integrity": "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.1.2", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", + "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mediasoup": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/mediasoup/-/mediasoup-3.19.1.tgz", + "integrity": "sha512-F3Gwyi11+ulSu/yAZjjI5Uh+Mz3lHKkZ6kNZjWsDiPFLIZRISwnzZKyiq82jK8IuSmnWaWDf7fZcs8SXKKsneg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "@types/ini": "^4.1.1", + "debug": "^4.4.1", + "flatbuffers": "^25.2.10", + "h264-profile-level-id": "^2.2.3", + "ini": "^5.0.0", + "node-fetch": "^3.3.2", + "supports-color": "^10.2.0", + "tar": "^7.4.3" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mediasoup" + } + }, + "node_modules/mediasoup-client": { + "version": "3.15.6", + "resolved": "https://registry.npmjs.org/mediasoup-client/-/mediasoup-client-3.15.6.tgz", + "integrity": "sha512-6pairpRJfwlc7G1dreTFh+gwfmIo8FE1Usr63577cpakjT1lVz/exOn/26S5B6kaTPOzL1KOtsBpJO0G0NJ1PA==", + "license": "ISC", + "dependencies": { + "@types/debug": "^4.1.12", + "@types/events-alias": "npm:@types/events@^3.0.3", + "awaitqueue": "^3.2.4", + "browser-dtector": "^4.1.0", + "debug": "^4.4.1", + "events-alias": "npm:events@^3.3.0", + "fake-mediastreamtrack": "^2.1.4", + "h264-profile-level-id": "^2.2.3", + "sdp-transform": "^2.15.0", + "supports-color": "^10.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mediasoup" + } + }, + "node_modules/mediasoup-client/node_modules/supports-color": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.0.tgz", + "integrity": "sha512-5eG9FQjEjDbAlI5+kdpdyPIBMRH4GfTVDGREVupaZHmVoppknhM29b/S9BkQz7cathp85BVgRi/As3Siln7e0Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mediasoup/node_modules/supports-color": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.0.tgz", + "integrity": "sha512-5eG9FQjEjDbAlI5+kdpdyPIBMRH4GfTVDGREVupaZHmVoppknhM29b/S9BkQz7cathp85BVgRi/As3Siln7e0Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c75e9de --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "webrtc-broadcast", + "version": "1.0.0", + "description": "WebRTC broadcasting application with one publisher and multiple subscribers", + "main": "src/server.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun --hot src", + "start": "bun src", + "sfu": "bun --hot src/sfu-demo-server.ts", + "sfu-start": "bun src/sfu-demo-server.ts", + "test": "bun test", + "test:basic": "bun test tests/basic.test.ts", + "test:sfu": "bun test tests/sfu/" + }, + "dependencies": { + "@techniker-me/tools": "^2025.0.16", + "mediasoup": "3.19.1", + "mediasoup-client": "3.15.6" + }, + "devDependencies": { + "@types/bun": "latest", + "bun-types": "latest", + "typescript": "5.9.2", + "@types/jest": "30.0.0", + "@types/node": "24.3.1" + }, + "peerDependencies": { + "typescript": "5" + } +} diff --git a/public/js/interfaces/IWebRTCClient.ts b/public/js/interfaces/IWebRTCClient.ts new file mode 100644 index 0000000..80cc888 --- /dev/null +++ b/public/js/interfaces/IWebRTCClient.ts @@ -0,0 +1,25 @@ +export interface ISignalingMessage { + type: 'offer' | 'answer' | 'ice-candidate' | 'join' | 'leave' | 'publisher-joined' | 'publisher-left'; + data?: any; + senderId?: string; + targetId?: string; +} + +export interface IWebRTCClient { + connect(): Promise; + disconnect(): void; + sendMessage(message: ISignalingMessage): void; + isConnected(): boolean; +} + +export interface IMediaHandler { + getLocalStream(): Promise; + stopLocalStream(): void; + getLocalVideo(): HTMLVideoElement | null; +} + +export interface IUIController { + updateStatus(status: string, className: string): void; + updateSubscribersCount(count: number): void; + setButtonStates(startEnabled: boolean, stopEnabled: boolean): void; +} \ No newline at end of file diff --git a/public/js/publisher.ts b/public/js/publisher.ts new file mode 100644 index 0000000..7f5640a --- /dev/null +++ b/public/js/publisher.ts @@ -0,0 +1,120 @@ +import { WebSocketClient, WebSocketClientStatusMapping } from './services/WebSocketClient.ts'; +import { MediaHandler } from './services/MediaHandler.ts'; +import { UIController } from './services/UIController.ts'; +import { PublisherRTCManager } from './services/PublisherRTCManager.ts'; +import type { ISignalingMessage } from './interfaces/IWebRTCClient.ts'; +import { DisposableList } from '@techniker-me/tools'; + +class Publisher { + private readonly _disposables: DisposableList = new DisposableList(); + private wsClient: WebSocketClient; + private mediaHandler: MediaHandler; + private uiController: UIController; + private rtcManager: PublisherRTCManager; + private isStreaming = false; + + constructor() { + this.wsClient = new WebSocketClient('publisher'); + this.mediaHandler = new MediaHandler('localVideo'); + this.uiController = new UIController('status', 'subscribersCount', 'startBtn', 'stopBtn'); + this.rtcManager = new PublisherRTCManager((count) => { + this.uiController.updateSubscribersCount(count); + }); + + this.setupEventHandlers(); + this.setupWebSocketHandlers(); + } + + private setupEventHandlers(): void { + this.uiController.onButtonClick('startBtn', () => this.startBroadcasting()); + this.uiController.onButtonClick('stopBtn', () => this.stopBroadcasting()); + } + + private setupWebSocketHandlers(): void { + this._disposables.add(this.wsClient.status.subscribe((status) => { + this.uiController.updateStatus(WebSocketClientStatusMapping.convertWebSocketClientStatusToWebSocketClientStatusType(status), status); + })); + + this.wsClient.onMessage('join', (message) => { + this.uiController.updateStatus('Connected to server', 'connected'); + }); + + this.wsClient.onMessage('answer', async (message) => { + if (message.senderId) { + await this.rtcManager.handleAnswer(message.senderId, message.data); + } + }); + + this.wsClient.onMessage('ice-candidate', async (message) => { + if (message.senderId && message.data) { + await this.rtcManager.handleIceCandidate(message.senderId, message.data); + } + }); + + this.rtcManager.setOnIceCandidate((subscriberId, candidate) => { + const message: ISignalingMessage = { + type: 'ice-candidate', + data: candidate, + targetId: subscriberId + }; + this.wsClient.sendMessage(message); + }); + } + + private async startBroadcasting(): Promise { + try { + this.uiController.updateStatus('Starting broadcast...', 'waiting'); + this.uiController.setButtonStates(false, false); + + await this.wsClient.connect(); + const stream = await this.mediaHandler.getLocalStream(); + this.rtcManager.setLocalStream(stream); + + this.isStreaming = true; + this.uiController.updateStatus('Broadcasting - Ready for subscribers', 'connected'); + this.uiController.setButtonStates(false, true); + + this.startOfferLoop(); + } catch (error) { + console.error('Error starting broadcast:', error); + this.uiController.updateStatus('Failed to start broadcast', 'disconnected'); + this.uiController.setButtonStates(true, false); + } + } + + private stopBroadcasting(): void { + this.isStreaming = false; + this.mediaHandler.stopLocalStream(); + this.rtcManager.closeAllConnections(); + this.wsClient.disconnect(); + + this.uiController.updateStatus('Broadcast stopped', 'disconnected'); + this.uiController.setButtonStates(true, false); + this.uiController.updateSubscribersCount(0); + } + + private async startOfferLoop(): Promise { + const offerToNewSubscribers = async () => { + if (!this.isStreaming) return; + + try { + const offer = await this.rtcManager.createOfferForSubscriber('broadcast'); + const message: ISignalingMessage = { + type: 'offer', + data: offer + }; + this.wsClient.sendMessage(message); + } catch (error) { + console.error('Error creating offer:', error); + } + + if (this.isStreaming) { + setTimeout(offerToNewSubscribers, 2000); + } + }; + + setTimeout(offerToNewSubscribers, 1000); + } +} + +new Publisher(); \ No newline at end of file diff --git a/public/js/services/MediaHandler.ts b/public/js/services/MediaHandler.ts new file mode 100644 index 0000000..43e613c --- /dev/null +++ b/public/js/services/MediaHandler.ts @@ -0,0 +1,55 @@ +import type { IMediaHandler } from '../interfaces/IWebRTCClient.ts'; + +export class MediaHandler implements IMediaHandler { + private localStream: MediaStream | null = null; + private videoElement: HTMLVideoElement | null = null; + + constructor(videoElementId?: string) { + if (videoElementId) { + this.videoElement = document.getElementById(videoElementId) as HTMLVideoElement; + } + } + + async getLocalStream(): Promise { + try { + this.localStream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 1280 }, + height: { ideal: 720 }, + frameRate: { ideal: 30 } + }, + audio: true + }); + + if (this.videoElement) { + this.videoElement.srcObject = this.localStream; + } + + return this.localStream; + } catch (error) { + console.error('Error accessing media devices:', error); + throw error; + } + } + + stopLocalStream(): void { + if (this.localStream) { + this.localStream.getTracks().forEach(track => { + track.stop(); + }); + this.localStream = null; + } + + if (this.videoElement) { + this.videoElement.srcObject = null; + } + } + + getLocalVideo(): HTMLVideoElement | null { + return this.videoElement; + } + + getCurrentStream(): MediaStream | null { + return this.localStream; + } +} \ No newline at end of file diff --git a/public/js/services/PublisherRTCManager.ts b/public/js/services/PublisherRTCManager.ts new file mode 100644 index 0000000..bdff216 --- /dev/null +++ b/public/js/services/PublisherRTCManager.ts @@ -0,0 +1,103 @@ +import type { ISignalingMessage } from '../interfaces/IWebRTCClient.ts'; + +export class PublisherRTCManager { + private peerConnections: Map = new Map(); + private localStream: MediaStream | null = null; + private onSubscriberCountChange?: (count: number) => void; + + private rtcConfiguration: RTCConfiguration = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] + }; + + constructor(onSubscriberCountChange?: (count: number) => void) { + this.onSubscriberCountChange = onSubscriberCountChange; + } + + setLocalStream(stream: MediaStream): void { + this.localStream = stream; + } + + async createOfferForSubscriber(subscriberId: string): Promise { + const peerConnection = this.createPeerConnection(subscriberId); + + if (this.localStream) { + this.localStream.getTracks().forEach(track => { + peerConnection.addTrack(track, this.localStream!); + }); + } + + const offer = await peerConnection.createOffer(); + await peerConnection.setLocalDescription(offer); + + return offer; + } + + async handleAnswer(subscriberId: string, answer: RTCSessionDescriptionInit): Promise { + const peerConnection = this.peerConnections.get(subscriberId); + if (peerConnection) { + await peerConnection.setRemoteDescription(answer); + } + } + + async handleIceCandidate(subscriberId: string, candidate: RTCIceCandidateInit): Promise { + const peerConnection = this.peerConnections.get(subscriberId); + if (peerConnection && candidate) { + await peerConnection.addIceCandidate(candidate); + } + } + + removeSubscriber(subscriberId: string): void { + const peerConnection = this.peerConnections.get(subscriberId); + if (peerConnection) { + peerConnection.close(); + this.peerConnections.delete(subscriberId); + this.updateSubscriberCount(); + } + } + + closeAllConnections(): void { + this.peerConnections.forEach((pc, id) => { + pc.close(); + }); + this.peerConnections.clear(); + this.updateSubscriberCount(); + } + + private createPeerConnection(subscriberId: string): RTCPeerConnection { + const peerConnection = new RTCPeerConnection(this.rtcConfiguration); + + peerConnection.onicecandidate = (event) => { + if (event.candidate) { + this.onIceCandidate?.(subscriberId, event.candidate); + } + }; + + peerConnection.onconnectionstatechange = () => { + console.log(`Connection state for ${subscriberId}:`, peerConnection.connectionState); + if (peerConnection.connectionState === 'failed' || + peerConnection.connectionState === 'disconnected') { + this.removeSubscriber(subscriberId); + } + }; + + this.peerConnections.set(subscriberId, peerConnection); + this.updateSubscriberCount(); + + return peerConnection; + } + + private updateSubscriberCount(): void { + if (this.onSubscriberCountChange) { + this.onSubscriberCountChange(this.peerConnections.size); + } + } + + private onIceCandidate?: (subscriberId: string, candidate: RTCIceCandidate) => void; + + setOnIceCandidate(handler: (subscriberId: string, candidate: RTCIceCandidate) => void): void { + this.onIceCandidate = handler; + } +} \ No newline at end of file diff --git a/public/js/services/SubscriberRTCManager.ts b/public/js/services/SubscriberRTCManager.ts new file mode 100644 index 0000000..1ce3db5 --- /dev/null +++ b/public/js/services/SubscriberRTCManager.ts @@ -0,0 +1,98 @@ +import type { ISignalingMessage } from '../interfaces/IWebRTCClient.ts'; + +export class SubscriberRTCManager { + private peerConnection: RTCPeerConnection | null = null; + private remoteVideo: HTMLVideoElement | null = null; + private onStreamReceived?: (stream: MediaStream) => void; + private onConnectionStateChange?: (state: string) => void; + + private rtcConfiguration: RTCConfiguration = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] + }; + + constructor( + videoElementId: string, + onStreamReceived?: (stream: MediaStream) => void, + onConnectionStateChange?: (state: string) => void + ) { + this.remoteVideo = document.getElementById(videoElementId) as HTMLVideoElement; + this.onStreamReceived = onStreamReceived; + this.onConnectionStateChange = onConnectionStateChange; + } + + async handleOffer(offer: RTCSessionDescriptionInit): Promise { + this.createPeerConnection(); + + if (!this.peerConnection) { + throw new Error('Failed to create peer connection'); + } + + await this.peerConnection.setRemoteDescription(offer); + const answer = await this.peerConnection.createAnswer(); + await this.peerConnection.setLocalDescription(answer); + + return answer; + } + + async handleIceCandidate(candidate: RTCIceCandidateInit): Promise { + if (this.peerConnection && candidate) { + await this.peerConnection.addIceCandidate(candidate); + } + } + + disconnect(): void { + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + + if (this.remoteVideo) { + this.remoteVideo.srcObject = null; + } + } + + private createPeerConnection(): void { + this.peerConnection = new RTCPeerConnection(this.rtcConfiguration); + + this.peerConnection.onicecandidate = (event) => { + if (event.candidate) { + this.onIceCandidate?.(event.candidate); + } + }; + + this.peerConnection.ontrack = (event) => { + console.log('Received remote stream'); + const [remoteStream] = event.streams; + + if (this.remoteVideo) { + this.remoteVideo.srcObject = remoteStream; + } + + if (this.onStreamReceived) { + this.onStreamReceived(remoteStream); + } + }; + + this.peerConnection.onconnectionstatechange = () => { + const state = this.peerConnection?.connectionState || 'unknown'; + console.log('Connection state changed:', state); + + if (this.onConnectionStateChange) { + this.onConnectionStateChange(state); + } + }; + + this.peerConnection.onicegatheringstatechange = () => { + console.log('ICE gathering state:', this.peerConnection?.iceGatheringState); + }; + } + + private onIceCandidate?: (candidate: RTCIceCandidate) => void; + + setOnIceCandidate(handler: (candidate: RTCIceCandidate) => void): void { + this.onIceCandidate = handler; + } +} \ No newline at end of file diff --git a/public/js/services/UIController.ts b/public/js/services/UIController.ts new file mode 100644 index 0000000..1709e1d --- /dev/null +++ b/public/js/services/UIController.ts @@ -0,0 +1,53 @@ +import type { IUIController } from '../interfaces/IWebRTCClient.ts'; + +export class UIController implements IUIController { + private statusElement: HTMLElement; + private subscribersCountElement: HTMLElement | null; + private startButton: HTMLButtonElement | null; + private stopButton: HTMLButtonElement | null; + + constructor( + statusElementId: string, + subscribersCountElementId?: string, + startButtonId?: string, + stopButtonId?: string + ) { + this.statusElement = document.getElementById(statusElementId)!; + this.subscribersCountElement = subscribersCountElementId + ? document.getElementById(subscribersCountElementId) + : null; + this.startButton = startButtonId + ? document.getElementById(startButtonId) as HTMLButtonElement + : null; + this.stopButton = stopButtonId + ? document.getElementById(stopButtonId) as HTMLButtonElement + : null; + } + + updateStatus(status: string, className: string): void { + this.statusElement.textContent = status; + this.statusElement.className = `status ${className}`; + } + + updateSubscribersCount(count: number): void { + if (this.subscribersCountElement) { + this.subscribersCountElement.textContent = `Subscribers: ${count}`; + } + } + + setButtonStates(startEnabled: boolean, stopEnabled: boolean): void { + if (this.startButton) { + this.startButton.disabled = !startEnabled; + } + if (this.stopButton) { + this.stopButton.disabled = !stopEnabled; + } + } + + onButtonClick(buttonId: string, handler: () => void): void { + const button = document.getElementById(buttonId); + if (button) { + button.addEventListener('click', handler); + } + } +} \ No newline at end of file diff --git a/public/js/services/WebSocketClient.ts b/public/js/services/WebSocketClient.ts new file mode 100644 index 0000000..d322b7b --- /dev/null +++ b/public/js/services/WebSocketClient.ts @@ -0,0 +1,129 @@ +import { assertUnreachable, ReadOnlySubject, Subject } from '@techniker-me/tools'; +import type { ISignalingMessage, IWebRTCClient } from '../interfaces/IWebRTCClient.ts'; + +export enum WebSocketClientStatus { + Offline = 0, + Connecting = 1, + Online = 2, + Reconnecting = 3, + Error = 4, + Closed = 5, +} + +export type WebSocketClientStatusType = 'offline' | 'connecting' | 'online' | 'reconnecting' | 'error' | 'closed'; + +export class WebSocketClientStatusMapping { + public static convertWebSocketClientStatusToWebSocketClientStatusType(status: WebSocketClientStatus): WebSocketClientStatusType { + switch (status) { + case WebSocketClientStatus.Offline: + return 'offline'; + case WebSocketClientStatus.Connecting: + return 'connecting'; + case WebSocketClientStatus.Online: + return 'online'; + case WebSocketClientStatus.Reconnecting: + return 'reconnecting'; + case WebSocketClientStatus.Error: + return 'error'; + case WebSocketClientStatus.Closed: + return 'closed'; + default: + assertUnreachable(status); + } + } + + public static convertWebSocketClientStatusTypeToWebSocketClientStatus(status: WebSocketClientStatusType): WebSocketClientStatus { + switch (status) { + case 'offline': + return WebSocketClientStatus.Offline; + case 'connecting': + return WebSocketClientStatus.Connecting; + case 'online': + return WebSocketClientStatus.Online; + case 'reconnecting': + return WebSocketClientStatus.Reconnecting; + case 'error': + return WebSocketClientStatus.Error; + case 'closed': + return WebSocketClientStatus.Closed; + default: + assertUnreachable(status); + } + } +} + +export class WebSocketClient implements IWebRTCClient { + private readonly _status: Subject = new Subject(WebSocketClientStatus.Offline); + private readonly _readOnlyStatus: ReadOnlySubject = new ReadOnlySubject(this._status); + private ws: WebSocket | null = null; + private role: 'publisher' | 'subscriber'; + private messageHandlers: Map void> = new Map(); + + constructor(role: 'publisher' | 'subscriber') { + this.role = role; + } + + get status(): ReadOnlySubject { + return this._readOnlyStatus; + } + + async connect(): Promise { + this._status.value = WebSocketClientStatus.Online; + return new Promise((resolve, reject) => { + const wsUrl = `ws://localhost:3000/ws?role=${this.role}`; + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this._status.value = WebSocketClientStatus.Online; + resolve(); + }; + + this.ws.onerror = (error) => { + this._status.value = WebSocketClientStatus.Error; + reject(error); + }; + + this.ws.onmessage = (event) => { + try { + const message: ISignalingMessage = JSON.parse(event.data); + this.handleMessage(message); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + }; + + this.ws.onclose = () => { + this._status.value = WebSocketClientStatus.Closed; + this.ws = null; + }; + }); + } + + disconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + sendMessage(message: ISignalingMessage): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + + isConnected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } + + onMessage(type: string, handler: (message: ISignalingMessage) => void): void { + this.messageHandlers.set(type, handler); + } + + private handleMessage(message: ISignalingMessage): void { + const handler = this.messageHandlers.get(message.type); + if (handler) { + handler(message); + } + } +} \ No newline at end of file diff --git a/public/js/sfu-publisher-simple.ts b/public/js/sfu-publisher-simple.ts new file mode 100644 index 0000000..c4dc0f8 --- /dev/null +++ b/public/js/sfu-publisher-simple.ts @@ -0,0 +1,262 @@ +interface SFUMessage { + type: string; + data?: any; +} + +class SFUPublisher { + private ws: WebSocket | null = null; + private peerConnection: RTCPeerConnection | null = null; + private localStream: MediaStream | null = null; + private isStreaming = false; + + private rtcConfiguration: RTCConfiguration = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] + }; + + // UI Elements + private statusElement: HTMLElement; + private subscribersCountElement: HTMLElement; + private producersCountElement: HTMLElement; + private startButton: HTMLButtonElement; + private stopButton: HTMLButtonElement; + private localVideo: HTMLVideoElement; + + constructor() { + this.statusElement = document.getElementById('status')!; + this.subscribersCountElement = document.getElementById('subscribersCount')!; + this.producersCountElement = document.getElementById('producersCount')!; + this.startButton = document.getElementById('startBtn') as HTMLButtonElement; + this.stopButton = document.getElementById('stopBtn') as HTMLButtonElement; + this.localVideo = document.getElementById('localVideo') as HTMLVideoElement; + + this.setupEventHandlers(); + } + + private setupEventHandlers(): void { + this.startButton.addEventListener('click', () => this.startBroadcasting()); + this.stopButton.addEventListener('click', () => this.stopBroadcasting()); + } + + private async startBroadcasting(): Promise { + try { + this.updateStatus('Connecting to SFU server...', 'waiting'); + this.setButtonStates(false, false); + + await this.connectToServer(); + await this.getLocalStream(); + await this.createPeerConnection(); + await this.startSFUNegotiation(); + + this.isStreaming = true; + this.updateStatus('Broadcasting via SFU - Optimized for scalability!', 'connected'); + this.setButtonStates(false, true); + this.updateStats(); + + } catch (error) { + console.error('Error starting broadcast:', error); + this.updateStatus('Failed to start broadcast: ' + error.message, 'disconnected'); + this.setButtonStates(true, false); + } + } + + private async stopBroadcasting(): Promise { + this.isStreaming = false; + + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + + if (this.localStream) { + this.localStream.getTracks().forEach(track => track.stop()); + this.localStream = null; + } + + if (this.localVideo) { + this.localVideo.srcObject = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.updateStatus('Broadcast stopped', 'disconnected'); + this.setButtonStates(true, false); + this.resetStats(); + } + + private async connectToServer(): Promise { + return new Promise((resolve, reject) => { + this.ws = new WebSocket('ws://localhost:3001?role=publisher'); + + this.ws.onopen = () => resolve(); + this.ws.onerror = (error) => reject(error); + + this.ws.onmessage = async (event) => { + try { + const message: SFUMessage = JSON.parse(event.data); + await this.handleServerMessage(message); + } catch (error) { + console.error('Failed to parse message:', error); + } + }; + + this.ws.onclose = () => { + if (this.isStreaming) { + this.updateStatus('Connection to server lost', 'disconnected'); + } + }; + }); + } + + private async handleServerMessage(message: SFUMessage): Promise { + console.log('Received message:', message.type); + + switch (message.type) { + case 'join': + console.log('Joined as publisher:', message.data); + break; + case 'routerRtpCapabilities': + console.log('Received router capabilities'); + // In a full SFU implementation, this would configure the device + break; + case 'webRtcTransportCreated': + console.log('Transport created, connecting...'); + break; + case 'webRtcTransportConnected': + console.log('Transport connected, starting to produce'); + break; + case 'produced': + console.log('Producer created:', message.data.producerId); + break; + case 'error': + console.error('Server error:', message.data.message); + break; + default: + console.log('Unknown message type:', message.type); + } + } + + private async getLocalStream(): Promise { + this.localStream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 1280, max: 1920 }, + height: { ideal: 720, max: 1080 }, + frameRate: { ideal: 30, max: 60 } + }, + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }); + + this.localVideo.srcObject = this.localStream; + } + + private async createPeerConnection(): Promise { + this.peerConnection = new RTCPeerConnection(this.rtcConfiguration); + + // Add local stream to peer connection + if (this.localStream) { + this.localStream.getTracks().forEach(track => { + this.peerConnection!.addTrack(track, this.localStream!); + }); + } + + this.peerConnection.onicecandidate = (event) => { + if (event.candidate) { + this.sendMessage({ + type: 'ice-candidate', + data: event.candidate + }); + } + }; + + this.peerConnection.onconnectionstatechange = () => { + console.log('Connection state:', this.peerConnection?.connectionState); + }; + } + + private async startSFUNegotiation(): Promise { + // Simulate SFU negotiation process + // In a real SFU implementation, this would involve: + // 1. Getting router RTP capabilities + // 2. Creating WebRTC transport + // 3. Connecting transport + // 4. Creating producers for audio/video + + this.sendMessage({ type: 'getRouterRtpCapabilities' }); + + // Simulate successful setup + setTimeout(() => { + this.sendMessage({ type: 'createWebRtcTransport' }); + }, 100); + + setTimeout(() => { + this.sendMessage({ + type: 'connectWebRtcTransport', + data: { dtlsParameters: { fingerprints: [], role: 'client' } } + }); + }, 200); + + setTimeout(() => { + if (this.localStream) { + const videoTrack = this.localStream.getVideoTracks()[0]; + const audioTrack = this.localStream.getAudioTracks()[0]; + + if (videoTrack) { + this.sendMessage({ + type: 'produce', + data: { + kind: 'video', + rtpParameters: { codecs: [], encodings: [] } + } + }); + } + + if (audioTrack) { + this.sendMessage({ + type: 'produce', + data: { + kind: 'audio', + rtpParameters: { codecs: [], encodings: [] } + } + }); + } + } + }, 300); + } + + private sendMessage(message: SFUMessage): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + + private updateStatus(status: string, className: string): void { + this.statusElement.textContent = status; + this.statusElement.className = `status ${className}`; + } + + private setButtonStates(startEnabled: boolean, stopEnabled: boolean): void { + this.startButton.disabled = !startEnabled; + this.stopButton.disabled = !stopEnabled; + } + + private updateStats(): void { + this.subscribersCountElement.textContent = '0'; + this.producersCountElement.textContent = this.isStreaming ? '2' : '0'; + } + + private resetStats(): void { + this.subscribersCountElement.textContent = '0'; + this.producersCountElement.textContent = '0'; + } +} + +new SFUPublisher(); \ No newline at end of file diff --git a/public/js/sfu-publisher.ts b/public/js/sfu-publisher.ts new file mode 100644 index 0000000..5b12e55 --- /dev/null +++ b/public/js/sfu-publisher.ts @@ -0,0 +1,303 @@ +import { Device } from 'mediasoup-client'; +import type { RtpCapabilities, Transport, Producer } from 'mediasoup-client/lib/types'; + +interface SFUMessage { + type: string; + data?: any; +} + +class SFUPublisher { + private ws: WebSocket | null = null; + private device: Device | null = null; + private producerTransport: Transport | null = null; + private videoProducer: Producer | null = null; + private audioProducer: Producer | null = null; + private localStream: MediaStream | null = null; + private isStreaming = false; + + // UI Elements + private statusElement: HTMLElement; + private subscribersCountElement: HTMLElement; + private producersCountElement: HTMLElement; + private startButton: HTMLButtonElement; + private stopButton: HTMLButtonElement; + private localVideo: HTMLVideoElement; + + constructor() { + this.statusElement = document.getElementById('status')!; + this.subscribersCountElement = document.getElementById('subscribersCount')!; + this.producersCountElement = document.getElementById('producersCount')!; + this.startButton = document.getElementById('startBtn') as HTMLButtonElement; + this.stopButton = document.getElementById('stopBtn') as HTMLButtonElement; + this.localVideo = document.getElementById('localVideo') as HTMLVideoElement; + + this.setupEventHandlers(); + } + + private setupEventHandlers(): void { + this.startButton.addEventListener('click', () => this.startBroadcasting()); + this.stopButton.addEventListener('click', () => this.stopBroadcasting()); + } + + private async startBroadcasting(): Promise { + try { + this.updateStatus('Connecting to SFU server...', 'waiting'); + this.setButtonStates(false, false); + + await this.connectToServer(); + await this.initializeDevice(); + await this.createTransport(); + await this.getLocalStream(); + await this.startProducing(); + + this.isStreaming = true; + this.updateStatus('Broadcasting via SFU - Scalable for many viewers!', 'connected'); + this.setButtonStates(false, true); + this.updateStats(); + + } catch (error) { + console.error('Error starting broadcast:', error); + this.updateStatus('Failed to start broadcast', 'disconnected'); + this.setButtonStates(true, false); + } + } + + private async stopBroadcasting(): Promise { + this.isStreaming = false; + + if (this.videoProducer) { + this.videoProducer.close(); + this.videoProducer = null; + } + + if (this.audioProducer) { + this.audioProducer.close(); + this.audioProducer = null; + } + + if (this.producerTransport) { + this.producerTransport.close(); + this.producerTransport = null; + } + + if (this.localStream) { + this.localStream.getTracks().forEach(track => track.stop()); + this.localStream = null; + } + + if (this.localVideo) { + this.localVideo.srcObject = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.updateStatus('Broadcast stopped', 'disconnected'); + this.setButtonStates(true, false); + this.resetStats(); + } + + private async connectToServer(): Promise { + return new Promise((resolve, reject) => { + this.ws = new WebSocket('ws://localhost:3001?role=publisher'); + + this.ws.onopen = () => resolve(); + this.ws.onerror = (error) => reject(error); + + this.ws.onmessage = (event) => { + try { + const message: SFUMessage = JSON.parse(event.data); + this.handleServerMessage(message); + } catch (error) { + console.error('Failed to parse message:', error); + } + }; + + this.ws.onclose = () => { + if (this.isStreaming) { + this.updateStatus('Connection to server lost', 'disconnected'); + } + }; + }); + } + + private async handleServerMessage(message: SFUMessage): Promise { + switch (message.type) { + case 'join': + console.log('Joined as publisher:', message.data); + break; + case 'routerRtpCapabilities': + if (this.device) { + await this.device.load({ routerRtpCapabilities: message.data.rtpCapabilities }); + } + break; + case 'webRtcTransportCreated': + if (this.producerTransport) { + await this.producerTransport.connect({ + dtlsParameters: message.data.dtlsParameters + }); + } + break; + case 'produced': + console.log('Producer created:', message.data.producerId); + break; + default: + console.log('Unknown message type:', message.type); + } + } + + private async initializeDevice(): Promise { + this.device = new Device(); + + // Get router RTP capabilities + this.sendMessage({ type: 'getRouterRtpCapabilities' }); + + // Wait for router capabilities + return new Promise((resolve) => { + const checkDevice = () => { + if (this.device?.loaded) { + resolve(); + } else { + setTimeout(checkDevice, 100); + } + }; + checkDevice(); + }); + } + + private async createTransport(): Promise { + this.sendMessage({ type: 'createWebRtcTransport' }); + + // Wait for transport creation response + return new Promise((resolve) => { + const originalHandler = this.handleServerMessage.bind(this); + + this.handleServerMessage = async (message: SFUMessage) => { + if (message.type === 'webRtcTransportCreated') { + this.producerTransport = this.device!.createSendTransport({ + id: message.data.id, + iceParameters: message.data.iceParameters, + iceCandidates: message.data.iceCandidates, + dtlsParameters: message.data.dtlsParameters + }); + + this.producerTransport.on('connect', async ({ dtlsParameters }, callback, errback) => { + try { + this.sendMessage({ + type: 'connectWebRtcTransport', + data: { dtlsParameters } + }); + callback(); + } catch (error) { + errback(error); + } + }); + + this.producerTransport.on('produce', async (parameters, callback, errback) => { + try { + this.sendMessage({ + type: 'produce', + data: { + kind: parameters.kind, + rtpParameters: parameters.rtpParameters + } + }); + + // Wait for producer ID response + const waitForProducer = () => { + if (this.ws) { + const tempHandler = this.ws.onmessage; + this.ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === 'produced') { + callback({ id: msg.data.producerId }); + this.ws!.onmessage = tempHandler; + } + }; + } + }; + waitForProducer(); + } catch (error) { + errback(error); + } + }); + + this.handleServerMessage = originalHandler; + resolve(); + } else { + originalHandler(message); + } + }; + }); + } + + private async getLocalStream(): Promise { + this.localStream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 1280, max: 1920 }, + height: { ideal: 720, max: 1080 }, + frameRate: { ideal: 30, max: 60 } + }, + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }); + + this.localVideo.srcObject = this.localStream; + } + + private async startProducing(): Promise { + if (!this.localStream || !this.producerTransport) return; + + const videoTrack = this.localStream.getVideoTracks()[0]; + const audioTrack = this.localStream.getAudioTracks()[0]; + + if (videoTrack) { + this.videoProducer = await this.producerTransport.produce({ + track: videoTrack, + encodings: [ + { maxBitrate: 100000, scaleResolutionDownBy: 4 }, + { maxBitrate: 300000, scaleResolutionDownBy: 2 }, + { maxBitrate: 900000, scaleResolutionDownBy: 1 } + ] + }); + } + + if (audioTrack) { + this.audioProducer = await this.producerTransport.produce({ track: audioTrack }); + } + } + + private sendMessage(message: SFUMessage): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + + private updateStatus(status: string, className: string): void { + this.statusElement.textContent = status; + this.statusElement.className = `status ${className}`; + } + + private setButtonStates(startEnabled: boolean, stopEnabled: boolean): void { + this.startButton.disabled = !startEnabled; + this.stopButton.disabled = !stopEnabled; + } + + private updateStats(): void { + // This would be updated by server stats in a real implementation + this.subscribersCountElement.textContent = '0'; + this.producersCountElement.textContent = this.isStreaming ? '2' : '0'; // Video + Audio + } + + private resetStats(): void { + this.subscribersCountElement.textContent = '0'; + this.producersCountElement.textContent = '0'; + } +} + +new SFUPublisher(); \ No newline at end of file diff --git a/public/js/sfu-subscriber-simple.ts b/public/js/sfu-subscriber-simple.ts new file mode 100644 index 0000000..ba5537a --- /dev/null +++ b/public/js/sfu-subscriber-simple.ts @@ -0,0 +1,271 @@ +interface SFUMessage { + type: string; + data?: any; +} + +class SFUSubscriber { + private ws: WebSocket | null = null; + private peerConnection: RTCPeerConnection | null = null; + private isConnected = false; + + private rtcConfiguration: RTCConfiguration = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] + }; + + // UI Elements + private statusElement: HTMLElement; + private qualityIndicator: HTMLElement; + private bitrateIndicator: HTMLElement; + private latencyIndicator: HTMLElement; + private connectButton: HTMLButtonElement; + private disconnectButton: HTMLButtonElement; + private remoteVideo: HTMLVideoElement; + private videoPlaceholder: HTMLElement; + + constructor() { + this.statusElement = document.getElementById('status')!; + this.qualityIndicator = document.getElementById('qualityIndicator')!; + this.bitrateIndicator = document.getElementById('bitrateIndicator')!; + this.latencyIndicator = document.getElementById('latencyIndicator')!; + this.connectButton = document.getElementById('connectBtn') as HTMLButtonElement; + this.disconnectButton = document.getElementById('disconnectBtn') as HTMLButtonElement; + this.remoteVideo = document.getElementById('remoteVideo') as HTMLVideoElement; + this.videoPlaceholder = document.getElementById('videoPlaceholder')!; + + this.setupEventHandlers(); + } + + private setupEventHandlers(): void { + this.connectButton.addEventListener('click', () => this.connect()); + this.disconnectButton.addEventListener('click', () => this.disconnect()); + } + + private async connect(): Promise { + try { + this.updateStatus('Connecting to SFU server...', 'waiting'); + this.setButtonStates(false, false); + + await this.connectToServer(); + await this.createPeerConnection(); + await this.startSFUConsumption(); + + this.isConnected = true; + this.updateStatus('Connected - Waiting for stream via SFU', 'waiting'); + this.setButtonStates(false, true); + + } catch (error) { + console.error('Error connecting:', error); + this.updateStatus('Failed to connect to server: ' + error.message, 'disconnected'); + this.setButtonStates(true, false); + } + } + + private disconnect(): void { + this.isConnected = false; + + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.showVideoPlaceholder(); + this.updateStatus('Disconnected', 'disconnected'); + this.setButtonStates(true, false); + this.resetIndicators(); + } + + private async connectToServer(): Promise { + return new Promise((resolve, reject) => { + this.ws = new WebSocket('ws://localhost:3001?role=subscriber'); + + this.ws.onopen = () => resolve(); + this.ws.onerror = (error) => reject(error); + + this.ws.onmessage = async (event) => { + try { + const message: SFUMessage = JSON.parse(event.data); + await this.handleServerMessage(message); + } catch (error) { + console.error('Failed to parse message:', error); + } + }; + + this.ws.onclose = () => { + if (this.isConnected) { + this.updateStatus('Connection to server lost', 'disconnected'); + } + }; + }); + } + + private async handleServerMessage(message: SFUMessage): Promise { + console.log('Received message:', message.type); + + switch (message.type) { + case 'join': + console.log('Joined as subscriber:', message.data); + break; + case 'newProducer': + console.log('New producer available:', message.data.producerId); + this.updateStatus('Publisher found - Requesting stream', 'waiting'); + // Request to consume the producer + this.sendMessage({ + type: 'consume', + data: { + producerId: message.data.producerId, + rtpCapabilities: {} // Simplified + } + }); + break; + case 'consumed': + console.log('Consumer created:', message.data.consumerId); + this.updateStatus('Connected - Receiving optimized stream', 'connected'); + this.hideVideoPlaceholder(); + this.updateStreamIndicators(); + // Resume the consumer + this.sendMessage({ + type: 'resume', + data: { consumerId: message.data.consumerId } + }); + break; + case 'resumed': + console.log('Consumer resumed:', message.data.consumerId); + break; + case 'producers': + console.log('Available producers:', message.data.producers); + if (message.data.producers.length > 0) { + // Try to consume the first available producer + this.sendMessage({ + type: 'consume', + data: { + producerId: message.data.producers[0].id, + rtpCapabilities: {} + } + }); + } + break; + case 'error': + console.error('Server error:', message.data.message); + this.updateStatus('Server error: ' + message.data.message, 'disconnected'); + break; + default: + console.log('Unknown message type:', message.type); + } + } + + private async createPeerConnection(): Promise { + this.peerConnection = new RTCPeerConnection(this.rtcConfiguration); + + this.peerConnection.ontrack = (event) => { + console.log('Received remote stream'); + const [remoteStream] = event.streams; + this.remoteVideo.srcObject = remoteStream; + this.hideVideoPlaceholder(); + this.updateStatus('Connected - Receiving stream via SFU', 'connected'); + this.updateStreamIndicators(); + }; + + this.peerConnection.onicecandidate = (event) => { + if (event.candidate) { + this.sendMessage({ + type: 'ice-candidate', + data: event.candidate + }); + } + }; + + this.peerConnection.onconnectionstatechange = () => { + console.log('Connection state:', this.peerConnection?.connectionState); + + switch (this.peerConnection?.connectionState) { + case 'connected': + this.updateStatus('Connected - Receiving stream via SFU', 'connected'); + break; + case 'connecting': + this.updateStatus('Connecting to stream...', 'waiting'); + break; + case 'disconnected': + case 'failed': + this.updateStatus('Connection lost', 'disconnected'); + this.showVideoPlaceholder(); + break; + } + }; + } + + private async startSFUConsumption(): Promise { + // Simulate SFU consumption process + // In a real SFU implementation, this would involve: + // 1. Getting router RTP capabilities + // 2. Creating WebRTC transport for receiving + // 3. Requesting available producers + // 4. Creating consumers for available streams + + this.sendMessage({ type: 'getRouterRtpCapabilities' }); + + setTimeout(() => { + this.sendMessage({ type: 'createWebRtcTransport' }); + }, 100); + + setTimeout(() => { + this.sendMessage({ + type: 'connectWebRtcTransport', + data: { dtlsParameters: { fingerprints: [], role: 'client' } } + }); + }, 200); + + setTimeout(() => { + this.sendMessage({ type: 'getProducers' }); + }, 300); + } + + private sendMessage(message: SFUMessage): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + + private updateStatus(status: string, className: string): void { + this.statusElement.textContent = status; + this.statusElement.className = `status ${className}`; + } + + private setButtonStates(connectEnabled: boolean, disconnectEnabled: boolean): void { + this.connectButton.disabled = !connectEnabled; + this.disconnectButton.disabled = !disconnectEnabled; + } + + private hideVideoPlaceholder(): void { + this.videoPlaceholder.style.display = 'none'; + this.remoteVideo.style.display = 'block'; + } + + private showVideoPlaceholder(): void { + this.videoPlaceholder.style.display = 'flex'; + this.remoteVideo.style.display = 'none'; + this.remoteVideo.srcObject = null; + } + + private updateStreamIndicators(): void { + // Simulate SFU optimizations + this.qualityIndicator.textContent = 'HD'; + this.bitrateIndicator.textContent = '2.5M'; + this.latencyIndicator.textContent = '45ms'; + } + + private resetIndicators(): void { + this.qualityIndicator.textContent = '-'; + this.bitrateIndicator.textContent = '-'; + this.latencyIndicator.textContent = '-'; + } +} + +new SFUSubscriber(); \ No newline at end of file diff --git a/public/js/subscriber.ts b/public/js/subscriber.ts new file mode 100644 index 0000000..339d791 --- /dev/null +++ b/public/js/subscriber.ts @@ -0,0 +1,153 @@ +import { WebSocketClient, WebSocketClientStatusMapping } from './services/WebSocketClient.ts'; +import { UIController } from './services/UIController.ts'; +import { SubscriberRTCManager } from './services/SubscriberRTCManager.ts'; +import type { ISignalingMessage } from './interfaces/IWebRTCClient.ts'; +import { DisposableList } from '@techniker-me/tools'; + +class Subscriber { + private readonly _disposables: DisposableList = new DisposableList(); + private wsClient: WebSocketClient; + private uiController: UIController; + private rtcManager: SubscriberRTCManager; + private isConnected = false; + private videoPlaceholder: HTMLElement | null; + private remoteVideo: HTMLVideoElement | null; + + constructor() { + this.wsClient = new WebSocketClient('subscriber'); + this.uiController = new UIController('status', undefined, 'connectBtn', 'disconnectBtn'); + this.videoPlaceholder = document.getElementById('videoPlaceholder'); + this.remoteVideo = document.getElementById('remoteVideo') as HTMLVideoElement; + + this.rtcManager = new SubscriberRTCManager( + 'remoteVideo', + (stream) => this.onStreamReceived(stream), + (state) => this.onConnectionStateChange(state) + ); + + this.setupEventHandlers(); + this.setupWebSocketHandlers(); + } + + private setupEventHandlers(): void { + this.uiController.onButtonClick('connectBtn', () => this.connect()); + this.uiController.onButtonClick('disconnectBtn', () => this.disconnect()); + } + + private setupWebSocketHandlers(): void { + this._disposables.add(this.wsClient.status.subscribe((status) => { + this.uiController.updateStatus(WebSocketClientStatusMapping.convertWebSocketClientStatusToWebSocketClientStatusType(status), status); + })); + + this.wsClient.onMessage('join', (message) => { + this.uiController.updateStatus('Connected - Waiting for publisher', 'waiting'); + this.isConnected = true; + this.uiController.setButtonStates(false, true); + }); + + this.wsClient.onMessage('publisher-joined', (message) => { + this.uiController.updateStatus('Publisher available - Requesting stream', 'waiting'); + }); + + this.wsClient.onMessage('publisher-left', (message) => { + this.uiController.updateStatus('Publisher disconnected', 'disconnected'); + this.showVideoPlaceholder(); + }); + + this.wsClient.onMessage('offer', async (message) => { + try { + const answer = await this.rtcManager.handleOffer(message.data); + const answerMessage: ISignalingMessage = { + type: 'answer', + data: answer, + targetId: message.senderId + }; + this.wsClient.sendMessage(answerMessage); + + this.uiController.updateStatus('Connecting to stream...', 'waiting'); + } catch (error) { + console.error('Error handling offer:', error); + this.uiController.updateStatus('Failed to connect to stream', 'disconnected'); + } + }); + + this.wsClient.onMessage('ice-candidate', async (message) => { + if (message.data) { + await this.rtcManager.handleIceCandidate(message.data); + } + }); + + this.rtcManager.setOnIceCandidate((candidate) => { + const message: ISignalingMessage = { + type: 'ice-candidate', + data: candidate + }; + this.wsClient.sendMessage(message); + }); + } + + private async connect(): Promise { + try { + this.uiController.updateStatus('Connecting to server...', 'waiting'); + this.uiController.setButtonStates(false, false); + + await this.wsClient.connect(); + } catch (error) { + console.error('Error connecting:', error); + this.uiController.updateStatus('Failed to connect to server', 'disconnected'); + this.uiController.setButtonStates(true, false); + } + } + + private disconnect(): void { + this.isConnected = false; + this.rtcManager.disconnect(); + this.wsClient.disconnect(); + + this.uiController.updateStatus('Disconnected', 'disconnected'); + this.uiController.setButtonStates(true, false); + this.showVideoPlaceholder(); + } + + private onStreamReceived(stream: MediaStream): void { + console.log('Stream received, showing video'); + this.uiController.updateStatus('Connected - Receiving stream', 'connected'); + this.hideVideoPlaceholder(); + } + + private onConnectionStateChange(state: string): void { + switch (state) { + case 'connected': + this.uiController.updateStatus('Connected - Receiving stream', 'connected'); + break; + case 'connecting': + this.uiController.updateStatus('Connecting to stream...', 'waiting'); + break; + case 'disconnected': + case 'failed': + this.uiController.updateStatus('Connection lost', 'disconnected'); + this.showVideoPlaceholder(); + break; + } + } + + private hideVideoPlaceholder(): void { + if (this.videoPlaceholder) { + this.videoPlaceholder.style.display = 'none'; + } + if (this.remoteVideo) { + this.remoteVideo.style.display = 'block'; + } + } + + private showVideoPlaceholder(): void { + if (this.videoPlaceholder) { + this.videoPlaceholder.style.display = 'flex'; + } + if (this.remoteVideo) { + this.remoteVideo.style.display = 'none'; + } + } +} + +new Subscriber(); \ No newline at end of file diff --git a/public/publisher.html b/public/publisher.html new file mode 100644 index 0000000..3e381a1 --- /dev/null +++ b/public/publisher.html @@ -0,0 +1,108 @@ + + + + + + Publisher - WebRTC Broadcast + + + +
+

Publisher Interface

+ +
Disconnected
+ +
Subscribers: 0
+ +
+ + +
+ + +
+ + + + \ No newline at end of file diff --git a/public/sfu-publisher.html b/public/sfu-publisher.html new file mode 100644 index 0000000..02b5e4d --- /dev/null +++ b/public/sfu-publisher.html @@ -0,0 +1,187 @@ + + + + + + SFU Publisher - WebRTC Broadcast + + + +
+
SFU ARCHITECTURE
+

Publisher Interface

+
Selective Forwarding Unit - Scalable Broadcasting
+ +
Disconnected
+ +
+
+
0
+
Subscribers
+
+
+
0
+
Streams
+
+
+ +
+ + +
+ + + +
+

🚀 SFU Benefits

+
    +
  • Scalable: Your bandwidth stays constant regardless of subscriber count
  • +
  • Efficient: Server handles stream distribution and optimization
  • +
  • Reliable: Server manages connection quality and recovery
  • +
  • Adaptive: Automatic bitrate adjustment for different devices
  • +
+
+
+ + + + \ No newline at end of file diff --git a/public/sfu-subscriber.html b/public/sfu-subscriber.html new file mode 100644 index 0000000..4be03b9 --- /dev/null +++ b/public/sfu-subscriber.html @@ -0,0 +1,213 @@ + + + + + + SFU Subscriber - WebRTC Broadcast + + + +
+
SFU SUBSCRIBER
+

Subscriber Interface

+
Optimized Stream Reception via SFU
+ +
Disconnected
+ +
+
+
-
+
Quality
+
+
+
-
+
Bitrate
+
+
+
-
+
Latency
+
+
+ +
+ + +
+ + +
+ 🎥 Waiting for stream... +
+ +
+

📺 SFU Subscriber Advantages

+
    +
  • Optimized Delivery: Server selects best stream quality for your connection
  • +
  • Adaptive Bitrate: Automatically adjusts to your bandwidth
  • +
  • Lower Latency: Efficient server-side forwarding
  • +
  • Reliable Playback: Server handles connection recovery
  • +
+
+
+ + + + \ No newline at end of file diff --git a/public/subscriber.html b/public/subscriber.html new file mode 100644 index 0000000..624c602 --- /dev/null +++ b/public/subscriber.html @@ -0,0 +1,124 @@ + + + + + + Subscriber - WebRTC Broadcast + + + +
+

Subscriber Interface

+ +
Disconnected
+ +
+ + +
+ + +
+ Waiting for stream... +
+
+ + + + \ No newline at end of file diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..861bae6 --- /dev/null +++ b/server.ts @@ -0,0 +1,65 @@ +import type { ServerWebSocket } from 'bun'; +import { ClientManager } from './src/services/ClientManager.ts'; +import { SignalingService } from './src/services/SignalingService.ts'; +import type { ISignalingMessage } from './src/interfaces/ISignalingMessage.ts'; +import publisherHtml from './public/publisher.html'; +import subscriberHtml from './public/subscriber.html'; + +const clientManager = new ClientManager(); +const signalingService = new SignalingService(clientManager); +const clientSessions = new Map(); + +const server = Bun.serve({ + port: 3000, + routes: { + '/': () => new Response(` + + +

WebRTC Broadcasting

+

Publisher Interface

+

Subscriber Interface

+ + + `, { headers: { 'Content-Type': 'text/html' } }), + '/publisher': publisherHtml, + '/subscriber': subscriberHtml, + }, + websocket: { + open(ws: ServerWebSocket) { + // Store the WebSocket connection without role validation initially + // Role will be determined from the first message + const clientId = signalingService.handleConnection(ws, 'unknown'); + clientSessions.set(ws, clientId); + }, + + message(ws: ServerWebSocket, message: string | Buffer) { + const clientId = clientSessions.get(ws); + if (!clientId) return; + + try { + const parsedMessage: ISignalingMessage = JSON.parse( + typeof message === 'string' ? message : message.toString() + ); + signalingService.handleMessage(clientId, parsedMessage); + } catch (error) { + console.error('Failed to parse message:', error); + } + }, + + close(ws: ServerWebSocket) { + const clientId = clientSessions.get(ws); + if (clientId) { + signalingService.handleDisconnection(clientId); + clientSessions.delete(ws); + } + } + }, + development: { + hmr: true, + console: true + } +}); + +console.log(`WebRTC Broadcasting server running on http://localhost:${server.port}`); +console.log(`Publisher: http://localhost:${server.port}/publisher`); +console.log(`Subscriber: http://localhost:${server.port}/subscriber`); \ No newline at end of file diff --git a/src/ServerFactory.ts b/src/ServerFactory.ts new file mode 100644 index 0000000..ce064e3 --- /dev/null +++ b/src/ServerFactory.ts @@ -0,0 +1,106 @@ +import { ClientManager } from './services/ClientManager.ts'; +import { SignalingService } from './services/SignalingService.ts'; +import type { ISignalingMessage } from './interfaces/ISignalingMessage.ts'; +import publisherHtml from '../public/publisher.html'; +import subscriberHtml from '../public/subscriber.html'; +import type { Server, ServerWebSocket } from 'bun'; + + +export class ServerFactory { + private constructor() { + throw new Error('ServerFactory is a static class and cannot be instantiated'); + } + + public static createServer(port: number) { + + const clientManager = new ClientManager(); + const signalingService = new SignalingService(clientManager); + const clientSessions = new Map(); + + const server = Bun.serve({ + port: port, + fetch: (request: Request, server: Server) => { + const success = server.upgrade(request); + if (success) { + // Bun automatically returns a 101 Switching Protocols + // if the upgrade succeeds + return undefined; + } + + // handle HTTP request normally + return new Response("Hello world!"); + }, + routes: { + '/': () => new Response(` + + +

WebRTC Broadcasting

+

Publisher Interface

+

Subscriber Interface

+ + + `, { headers: { 'Content-Type': 'text/html' } }), + '/publisher': publisherHtml, + '/subscriber': subscriberHtml, + }, + websocket: { + open(ws: ServerWebSocket) { + console.log('WebSocket opened'); + // Safely access the request URL from the WebSocket's data property + let url: URL; + try { + // ws.data is of type unknown, so we need to assert its shape + const data = ws.data as { url?: string }; + if (!data || typeof data.url !== 'string') { + ws.close(1002, 'Missing or invalid URL in WebSocket data'); + return; + } + url = new URL(data.url, 'http://localhost:3000'); + } catch (e) { + ws.close(1002, 'Malformed URL in WebSocket data'); + return; + } + const role = url.searchParams.get('role') as 'publisher' | 'subscriber'; + + if (role !== 'publisher' && role !== 'subscriber') { + ws.close(1002, 'Invalid role parameter'); + return; + } + + const clientId = signalingService.handleConnection(ws, role); + clientSessions.set(ws, clientId); + }, + + message(ws: ServerWebSocket, message: string | Buffer) { + console.log('WebSocket message received [%o]', message); + const clientId = clientSessions.get(ws); + if (!clientId) return; + + try { + const parsedMessage: ISignalingMessage = JSON.parse( + typeof message === 'string' ? message : message.toString() + ); + signalingService.handleMessage(clientId, parsedMessage); + } catch (error) { + console.error('Failed to parse message:', error); + } + }, + + close(ws: ServerWebSocket) { + console.log('WebSocket closed'); + const clientId = clientSessions.get(ws); + if (clientId) { + signalingService.handleDisconnection(clientId); + clientSessions.delete(ws); + } + } + }, + development: { + hmr: true, + console: true + } + }); + + return server; + } +} \ No newline at end of file diff --git a/src/config/mediaServerConfig.ts b/src/config/mediaServerConfig.ts new file mode 100644 index 0000000..b67ea6c --- /dev/null +++ b/src/config/mediaServerConfig.ts @@ -0,0 +1,88 @@ +import type { IMediaServerConfig } from '../interfaces/ISFUTypes.ts'; + +export const mediaServerConfig: IMediaServerConfig = { + listenIp: '0.0.0.0', + announcedIp: '127.0.0.1', // Change to your server's public IP in production + mediasoupSettings: { + worker: { + logLevel: 'warn', + logTags: [ + 'info', + 'ice', + 'dtls', + 'rtp', + 'srtp', + 'rtcp', + 'rtx', + 'bwe', + 'score', + 'simulcast', + 'svc', + 'sctp' + ], + rtcMinPort: 10000, + rtcMaxPort: 10100 + }, + router: { + mediaCodecs: [ + { + kind: 'audio' as const, + mimeType: 'audio/opus', + clockRate: 48000, + channels: 2 + }, + { + kind: 'video' as const, + mimeType: 'video/VP8', + clockRate: 90000, + parameters: { + 'x-google-start-bitrate': 1000 + } + }, + { + kind: 'video' as const, + mimeType: 'video/VP9', + clockRate: 90000, + parameters: { + 'profile-id': 2, + 'x-google-start-bitrate': 1000 + } + }, + { + kind: 'video' as const, + mimeType: 'video/h264', + clockRate: 90000, + parameters: { + 'packetization-mode': 1, + 'profile-level-id': '4d0032', + 'level-asymmetry-allowed': 1, + 'x-google-start-bitrate': 1000 + } + }, + { + kind: 'video' as const, + mimeType: 'video/H264', + clockRate: 90000, + parameters: { + 'packetization-mode': 1, + 'profile-level-id': '42e01f', + 'level-asymmetry-allowed': 1, + 'x-google-start-bitrate': 1000 + } + } + ] + }, + webRtcTransport: { + listenIps: [ + { + ip: '0.0.0.0', + announcedIp: '127.0.0.1' // Change to your server's public IP in production + } + ], + initialAvailableOutgoingBitrate: 1000000, + minimumAvailableOutgoingBitrate: 600000, + maxSctpMessageSize: 262144, + maxIncomingBitrate: 1500000 + } + } +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9d5c652 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,7 @@ +import { ServerFactory } from './ServerFactory.ts'; + +const server = ServerFactory.createServer(3000); + +console.log(`WebRTC Broadcasting server running on http://localhost:${server.port}`); +console.log(`Publisher: http://localhost:${server.port}/publisher`); +console.log(`Subscriber: http://localhost:${server.port}/subscriber`); \ No newline at end of file diff --git a/src/interfaces/ISFUTypes.ts b/src/interfaces/ISFUTypes.ts new file mode 100644 index 0000000..73d35e5 --- /dev/null +++ b/src/interfaces/ISFUTypes.ts @@ -0,0 +1,81 @@ +import type { + Router, + Transport, + Producer, + Consumer, + WebRtcTransport, + PlainTransport, + RtpCapabilities, + DtlsParameters, + IceCandidate, + IceParameters, + MediaKind, + RtpParameters +} from 'mediasoup/node/lib/types'; + +export interface ISFUClient { + id: string; + role: 'publisher' | 'subscriber'; + ws: any; + transport?: WebRtcTransport; + producer?: Producer; + consumers: Map; +} + +export interface IMediaServerConfig { + listenIp: string; + announcedIp: string; + mediasoupSettings: { + worker: { + logLevel: 'debug' | 'warn' | 'error'; + logTags: string[]; + rtcMinPort: number; + rtcMaxPort: number; + }; + router: { + mediaCodecs: Array<{ + kind: MediaKind; + mimeType: string; + clockRate: number; + channels?: number; + parameters?: any; + }>; + }; + webRtcTransport: { + listenIps: Array<{ + ip: string; + announcedIp?: string; + }>; + initialAvailableOutgoingBitrate: number; + minimumAvailableOutgoingBitrate: number; + maxSctpMessageSize: number; + maxIncomingBitrate: number; + }; + }; +} + +export interface ISFUSignalingMessage { + type: 'join' | 'leave' | 'getRouterRtpCapabilities' | 'createWebRtcTransport' | + 'connectWebRtcTransport' | 'produce' | 'consume' | 'resume' | 'pause' | + 'close' | 'getProducers' | 'restartIce'; + data?: any; + clientId?: string; +} + +export interface ITransportOptions { + id: string; + iceParameters: IceParameters; + iceCandidates: IceCandidate[]; + dtlsParameters: DtlsParameters; +} + +export interface IProducerOptions { + id: string; + kind: MediaKind; + rtpParameters: RtpParameters; +} + +export interface IConsumerOptions { + producerId: string; + rtpCapabilities: RtpCapabilities; +} \ No newline at end of file diff --git a/src/interfaces/ISignalingMessage.ts b/src/interfaces/ISignalingMessage.ts new file mode 100644 index 0000000..971cf22 --- /dev/null +++ b/src/interfaces/ISignalingMessage.ts @@ -0,0 +1,12 @@ +export interface ISignalingMessage { + type: 'offer' | 'answer' | 'ice-candidate' | 'join' | 'leave' | 'publisher-joined' | 'publisher-left'; + data?: any; + senderId?: string; + targetId?: string; +} + +export interface IWebSocketClient { + id: string; + ws: any; + role: 'publisher' | 'subscriber' | 'unknown'; +} \ No newline at end of file diff --git a/src/services/ClientManager.ts b/src/services/ClientManager.ts new file mode 100644 index 0000000..3792e12 --- /dev/null +++ b/src/services/ClientManager.ts @@ -0,0 +1,63 @@ +import type { IWebSocketClient, ISignalingMessage } from '../interfaces/ISignalingMessage.ts'; + +export class ClientManager { + private clients: Map = new Map(); + private publisher: IWebSocketClient | null = null; + + addClient(client: IWebSocketClient): void { + this.clients.set(client.id, client); + + if (client.role === 'publisher') { + this.publisher = client; + this.notifySubscribersPublisherJoined(); + } + } + + removeClient(clientId: string): void { + const client = this.clients.get(clientId); + if (!client) return; + + if (client.role === 'publisher') { + this.publisher = null; + this.notifySubscribersPublisherLeft(); + } + + this.clients.delete(clientId); + } + + getClient(clientId: string): IWebSocketClient | undefined { + return this.clients.get(clientId); + } + + getPublisher(): IWebSocketClient | null { + return this.publisher; + } + + getSubscribers(): IWebSocketClient[] { + return Array.from(this.clients.values()).filter(client => client.role === 'subscriber'); + } + + getAllClients(): IWebSocketClient[] { + return Array.from(this.clients.values()); + } + + private notifySubscribersPublisherJoined(): void { + const message: ISignalingMessage = { + type: 'publisher-joined' + }; + + this.getSubscribers().forEach(subscriber => { + subscriber.ws.send(JSON.stringify(message)); + }); + } + + private notifySubscribersPublisherLeft(): void { + const message: ISignalingMessage = { + type: 'publisher-left' + }; + + this.getSubscribers().forEach(subscriber => { + subscriber.ws.send(JSON.stringify(message)); + }); + } +} \ No newline at end of file diff --git a/src/services/MediaServerManager.ts b/src/services/MediaServerManager.ts new file mode 100644 index 0000000..c5d8c4c --- /dev/null +++ b/src/services/MediaServerManager.ts @@ -0,0 +1,97 @@ +import * as mediasoup from 'mediasoup'; +import type { Worker, Router } from 'mediasoup/node/lib/types'; +import type { IMediaServerConfig } from '../interfaces/ISFUTypes.ts'; + +export class MediaServerManager { + private worker: Worker | null = null; + private router: Router | null = null; + private config: IMediaServerConfig; + + constructor(config: IMediaServerConfig) { + this.config = config; + } + + async initialize(): Promise { + // Create mediasoup worker + this.worker = await mediasoup.createWorker({ + logLevel: this.config.mediasoupSettings.worker.logLevel, + logTags: this.config.mediasoupSettings.worker.logTags, + rtcMinPort: this.config.mediasoupSettings.worker.rtcMinPort, + rtcMaxPort: this.config.mediasoupSettings.worker.rtcMaxPort, + }); + + this.worker.on('died', () => { + console.error('Mediasoup worker died, exiting in 2 seconds... [pid:%d]', this.worker?.pid); + setTimeout(() => process.exit(1), 2000); + }); + + // Create router + this.router = await this.worker.createRouter({ + mediaCodecs: this.config.mediasoupSettings.router.mediaCodecs, + }); + + console.log('MediaServer initialized successfully'); + console.log('Worker PID:', this.worker.pid); + console.log('Router ID:', this.router.id); + } + + getRouter(): Router { + if (!this.router) { + throw new Error('Router not initialized'); + } + return this.router; + } + + getWorker(): Worker { + if (!this.worker) { + throw new Error('Worker not initialized'); + } + return this.worker; + } + + async createWebRtcTransport() { + const router = this.getRouter(); + + const transport = await router.createWebRtcTransport({ + listenIps: this.config.mediasoupSettings.webRtcTransport.listenIps, + enableUdp: true, + enableTcp: true, + preferUdp: true, + initialAvailableOutgoingBitrate: + this.config.mediasoupSettings.webRtcTransport.initialAvailableOutgoingBitrate, + minimumAvailableOutgoingBitrate: + this.config.mediasoupSettings.webRtcTransport.minimumAvailableOutgoingBitrate, + maxSctpMessageSize: this.config.mediasoupSettings.webRtcTransport.maxSctpMessageSize, + maxIncomingBitrate: this.config.mediasoupSettings.webRtcTransport.maxIncomingBitrate, + }); + + transport.on('dtlsstatechange', (dtlsState) => { + if (dtlsState === 'closed') { + transport.close(); + } + }); + + transport.on('@close', () => { + console.log('Transport closed'); + }); + + transport.on('routerclose', () => { + transport.close(); + }); + + return transport; + } + + getRtpCapabilities() { + return this.getRouter().rtpCapabilities; + } + + async close(): Promise { + if (this.router) { + this.router.close(); + } + if (this.worker) { + this.worker.close(); + } + } +} \ No newline at end of file diff --git a/src/services/SFUClientManager.ts b/src/services/SFUClientManager.ts new file mode 100644 index 0000000..9829718 --- /dev/null +++ b/src/services/SFUClientManager.ts @@ -0,0 +1,117 @@ +import type { WebRtcTransport, Producer, Consumer } from 'mediasoup/node/lib/types'; +import type { ISFUClient } from '../interfaces/ISFUTypes.ts'; + +export class SFUClientManager { + private clients: Map = new Map(); + private producers: Map = new Map(); // ProducerId -> Producer + private consumers: Map = new Map(); // ConsumerId -> Consumer + + addClient(client: ISFUClient): void { + this.clients.set(client.id, client); + console.log(`Client ${client.id} (${client.role}) added`); + } + + removeClient(clientId: string): void { + const client = this.clients.get(clientId); + if (!client) return; + + // Close transport + if (client.transport) { + client.transport.close(); + } + + // Close producer if publisher + if (client.producer) { + this.producers.delete(client.producer.id); + client.producer.close(); + } + + // Close consumers if subscriber + client.consumers.forEach((consumer, consumerId) => { + this.consumers.delete(consumerId); + consumer.close(); + }); + + this.clients.delete(clientId); + console.log(`Client ${clientId} removed`); + } + + getClient(clientId: string): ISFUClient | undefined { + return this.clients.get(clientId); + } + + setClientTransport(clientId: string, transport: WebRtcTransport): void { + const client = this.clients.get(clientId); + if (client) { + client.transport = transport; + } + } + + setClientProducer(clientId: string, producer: Producer): void { + const client = this.clients.get(clientId); + if (client) { + client.producer = producer; + this.producers.set(producer.id, producer); + console.log(`Producer ${producer.id} created for client ${clientId}`); + } + } + + addClientConsumer(clientId: string, consumer: Consumer): void { + const client = this.clients.get(clientId); + if (client) { + client.consumers.set(consumer.id, consumer); + this.consumers.set(consumer.id, consumer); + console.log(`Consumer ${consumer.id} created for client ${clientId}`); + } + } + + removeClientConsumer(clientId: string, consumerId: string): void { + const client = this.clients.get(clientId); + if (client) { + const consumer = client.consumers.get(consumerId); + if (consumer) { + consumer.close(); + client.consumers.delete(consumerId); + this.consumers.delete(consumerId); + } + } + } + + getPublishers(): ISFUClient[] { + return Array.from(this.clients.values()).filter(client => + client.role === 'publisher' && client.producer + ); + } + + getSubscribers(): ISFUClient[] { + return Array.from(this.clients.values()).filter(client => + client.role === 'subscriber' + ); + } + + getAllProducers(): Producer[] { + return Array.from(this.producers.values()); + } + + getProducer(producerId: string): Producer | undefined { + return this.producers.get(producerId); + } + + getConsumer(consumerId: string): Consumer | undefined { + return this.consumers.get(consumerId); + } + + getClientCount(): number { + return this.clients.size; + } + + getStats() { + return { + clients: this.clients.size, + publishers: this.getPublishers().length, + subscribers: this.getSubscribers().length, + producers: this.producers.size, + consumers: this.consumers.size + }; + } +} \ No newline at end of file diff --git a/src/services/SFUSignalingService.ts b/src/services/SFUSignalingService.ts new file mode 100644 index 0000000..be5d250 --- /dev/null +++ b/src/services/SFUSignalingService.ts @@ -0,0 +1,285 @@ +import type { RtpCapabilities, DtlsParameters, IceCandidate, IceParameters, RtpParameters } from 'mediasoup/node/lib/types'; +import { MediaServerManager } from './MediaServerManager.ts'; +import { SFUClientManager } from './SFUClientManager.ts'; +import type { ISFUClient, ISFUSignalingMessage } from '../interfaces/ISFUTypes.ts'; + +export class SFUSignalingService { + private mediaServer: MediaServerManager; + private clientManager: SFUClientManager; + + constructor(mediaServer: MediaServerManager, clientManager: SFUClientManager) { + this.mediaServer = mediaServer; + this.clientManager = clientManager; + } + + handleConnection(ws: any, role: 'publisher' | 'subscriber'): string { + const clientId = this.generateClientId(); + const client: ISFUClient = { + id: clientId, + role, + ws, + consumers: new Map() + }; + + this.clientManager.addClient(client); + + // Send join confirmation + this.sendMessage(clientId, { + type: 'join', + data: { clientId, role } + }); + + return clientId; + } + + handleDisconnection(clientId: string): void { + this.clientManager.removeClient(clientId); + } + + async handleMessage(clientId: string, message: ISFUSignalingMessage): Promise { + const client = this.clientManager.getClient(clientId); + if (!client) { + console.warn(`Message from unknown client: ${clientId}`); + return; + } + + try { + switch (message.type) { + case 'getRouterRtpCapabilities': + await this.handleGetRouterRtpCapabilities(client); + break; + case 'createWebRtcTransport': + await this.handleCreateWebRtcTransport(client); + break; + case 'connectWebRtcTransport': + await this.handleConnectWebRtcTransport(client, message.data); + break; + case 'produce': + await this.handleProduce(client, message.data); + break; + case 'consume': + await this.handleConsume(client, message.data); + break; + case 'resume': + await this.handleResume(client, message.data); + break; + case 'pause': + await this.handlePause(client, message.data); + break; + case 'getProducers': + await this.handleGetProducers(client); + break; + case 'restartIce': + await this.handleRestartIce(client, message.data); + break; + default: + console.warn(`Unknown message type: ${message.type}`); + } + } catch (error) { + console.error(`Error handling message ${message.type} from client ${clientId}:`, error); + this.sendMessage(clientId, { + type: 'error', + data: { message: error.message } + }); + } + } + + private async handleGetRouterRtpCapabilities(client: ISFUClient): Promise { + const rtpCapabilities = this.mediaServer.getRtpCapabilities(); + this.sendMessage(client.id, { + type: 'routerRtpCapabilities', + data: { rtpCapabilities } + }); + } + + private async handleCreateWebRtcTransport(client: ISFUClient): Promise { + const transport = await this.mediaServer.createWebRtcTransport(); + this.clientManager.setClientTransport(client.id, transport); + + const transportOptions = { + id: transport.id, + iceParameters: transport.iceParameters, + iceCandidates: transport.iceCandidates, + dtlsParameters: transport.dtlsParameters + }; + + this.sendMessage(client.id, { + type: 'webRtcTransportCreated', + data: transportOptions + }); + } + + private async handleConnectWebRtcTransport(client: ISFUClient, data: { + dtlsParameters: DtlsParameters + }): Promise { + if (!client.transport) { + throw new Error('Transport not found'); + } + + await client.transport.connect({ dtlsParameters: data.dtlsParameters }); + + this.sendMessage(client.id, { + type: 'webRtcTransportConnected', + data: {} + }); + } + + private async handleProduce(client: ISFUClient, data: { + kind: 'audio' | 'video', + rtpParameters: RtpParameters + }): Promise { + if (!client.transport) { + throw new Error('Transport not found'); + } + + if (client.role !== 'publisher') { + throw new Error('Only publishers can produce'); + } + + const producer = await client.transport.produce({ + kind: data.kind, + rtpParameters: data.rtpParameters + }); + + this.clientManager.setClientProducer(client.id, producer); + + this.sendMessage(client.id, { + type: 'produced', + data: { producerId: producer.id } + }); + + // Notify all subscribers about new producer + this.notifySubscribersNewProducer(producer.id); + } + + private async handleConsume(client: ISFUClient, data: { + producerId: string, + rtpCapabilities: RtpCapabilities + }): Promise { + if (!client.transport) { + throw new Error('Transport not found'); + } + + if (client.role !== 'subscriber') { + throw new Error('Only subscribers can consume'); + } + + const producer = this.clientManager.getProducer(data.producerId); + if (!producer) { + throw new Error('Producer not found'); + } + + const router = this.mediaServer.getRouter(); + + if (!router.canConsume({ + producerId: data.producerId, + rtpCapabilities: data.rtpCapabilities + })) { + throw new Error('Cannot consume'); + } + + const consumer = await client.transport.consume({ + producerId: data.producerId, + rtpCapabilities: data.rtpCapabilities, + paused: true // Start paused + }); + + this.clientManager.addClientConsumer(client.id, consumer); + + this.sendMessage(client.id, { + type: 'consumed', + data: { + consumerId: consumer.id, + producerId: data.producerId, + kind: consumer.kind, + rtpParameters: consumer.rtpParameters + } + }); + } + + private async handleResume(client: ISFUClient, data: { consumerId: string }): Promise { + const consumer = this.clientManager.getConsumer(data.consumerId); + if (!consumer) { + throw new Error('Consumer not found'); + } + + await consumer.resume(); + + this.sendMessage(client.id, { + type: 'resumed', + data: { consumerId: data.consumerId } + }); + } + + private async handlePause(client: ISFUClient, data: { consumerId: string }): Promise { + const consumer = this.clientManager.getConsumer(data.consumerId); + if (!consumer) { + throw new Error('Consumer not found'); + } + + await consumer.pause(); + + this.sendMessage(client.id, { + type: 'paused', + data: { consumerId: data.consumerId } + }); + } + + private async handleGetProducers(client: ISFUClient): Promise { + const producers = this.clientManager.getAllProducers(); + const producerList = producers.map(producer => ({ + id: producer.id, + kind: producer.kind + })); + + this.sendMessage(client.id, { + type: 'producers', + data: { producers: producerList } + }); + } + + private async handleRestartIce(client: ISFUClient, data: any): Promise { + if (!client.transport) { + throw new Error('Transport not found'); + } + + const iceParameters = await client.transport.restartIce(); + + this.sendMessage(client.id, { + type: 'iceRestarted', + data: { iceParameters } + }); + } + + private notifySubscribersNewProducer(producerId: string): void { + const subscribers = this.clientManager.getSubscribers(); + + subscribers.forEach(subscriber => { + this.sendMessage(subscriber.id, { + type: 'newProducer', + data: { producerId } + }); + }); + } + + private sendMessage(clientId: string, message: any): void { + const client = this.clientManager.getClient(clientId); + if (client && client.ws) { + client.ws.send(JSON.stringify(message)); + } + } + + private generateClientId(): string { + return Math.random().toString(36).substring(2, 15) + Date.now().toString(36); + } + + getStats() { + return { + ...this.clientManager.getStats(), + mediaServer: { + workerId: this.mediaServer.getWorker().pid, + routerId: this.mediaServer.getRouter().id + } + }; + } +} \ No newline at end of file diff --git a/src/services/SignalingService.ts b/src/services/SignalingService.ts new file mode 100644 index 0000000..37ffeec --- /dev/null +++ b/src/services/SignalingService.ts @@ -0,0 +1,116 @@ +import type { ISignalingMessage, IWebSocketClient } from '../interfaces/ISignalingMessage.ts'; +import { ClientManager } from './ClientManager.ts'; + +export class SignalingService { + private clientManager: ClientManager; + + constructor(clientManager: ClientManager) { + this.clientManager = clientManager; + } + + handleConnection(ws: any, role: 'publisher' | 'subscriber' | 'unknown'): string { + const clientId = this.generateClientId(); + const client: IWebSocketClient = { + id: clientId, + ws, + role + }; + + this.clientManager.addClient(client); + + // Only send join message if role is known + if (role !== 'unknown') { + ws.send(JSON.stringify({ + type: 'join', + data: { clientId, role } + })); + } + + return clientId; + } + + handleDisconnection(clientId: string): void { + this.clientManager.removeClient(clientId); + } + + handleMessage(clientId: string, message: ISignalingMessage): void { + const client = this.clientManager.getClient(clientId); + if (!client) return; + + switch (message.type) { + case 'join': + this.handleJoin(client, message); + break; + case 'offer': + this.handleOffer(client, message); + break; + case 'answer': + this.handleAnswer(client, message); + break; + case 'ice-candidate': + this.handleIceCandidate(client, message); + break; + } + } + + private handleJoin(client: IWebSocketClient, message: ISignalingMessage): void { + if (client.role === 'unknown' && message.data?.role) { + const role = message.data.role as 'publisher' | 'subscriber'; + if (role === 'publisher' || role === 'subscriber') { + client.role = role; + client.ws.send(JSON.stringify({ + type: 'join', + data: { clientId: client.id, role } + })); + } else { + client.ws.close(1002, 'Invalid role parameter'); + } + } + } + + private handleOffer(sender: IWebSocketClient, message: ISignalingMessage): void { + if (sender.role === 'publisher') { + const subscribers = this.clientManager.getSubscribers(); + subscribers.forEach(subscriber => { + subscriber.ws.send(JSON.stringify({ + ...message, + senderId: sender.id + })); + }); + } + } + + private handleAnswer(sender: IWebSocketClient, message: ISignalingMessage): void { + const publisher = this.clientManager.getPublisher(); + if (publisher && sender.role === 'subscriber') { + publisher.ws.send(JSON.stringify({ + ...message, + senderId: sender.id + })); + } + } + + private handleIceCandidate(sender: IWebSocketClient, message: ISignalingMessage): void { + if (sender.role === 'publisher') { + const subscribers = this.clientManager.getSubscribers(); + subscribers.forEach(subscriber => { + subscriber.ws.send(JSON.stringify({ + ...message, + senderId: sender.id + })); + }); + } else if (sender.role === 'subscriber') { + const publisher = this.clientManager.getPublisher(); + if (publisher) { + publisher.ws.send(JSON.stringify({ + ...message, + senderId: sender.id + })); + } + } + } + + private generateClientId(): string { + return Math.random().toString(36).substring(2, 15); + } +} \ No newline at end of file diff --git a/src/sfu-demo-server.ts b/src/sfu-demo-server.ts new file mode 100644 index 0000000..ea34937 --- /dev/null +++ b/src/sfu-demo-server.ts @@ -0,0 +1,442 @@ +// Simplified SFU Demo Server - Shows SFU Architecture Concepts +// This demonstrates SFU principles without requiring full mediasoup integration +import publisherHtml from './public/sfu-publisher.html'; +import subscriberHtml from './public/sfu-subscriber.html'; + +// Enhanced client for SFU simulation +interface SFUSimClient { + id: string; + role: 'publisher' | 'subscriber'; + ws: any; + streamId?: string; + quality?: 'low' | 'medium' | 'high'; + bitrate?: number; + connectedAt: number; +} + +class SFUDemoServer { + private clients: Map = new Map(); + private clientSessions: Map = new Map(); + private serverStats = { + totalClients: 0, + publishers: 0, + subscribers: 0, + streamsForwarded: 0, + totalBandwidth: 0 + }; + + async start(): Promise { + const server = Bun.serve({ + port: 3001, + routes: { + '/': () => new Response(` + + + SFU Demo Server + + + +
+

🚀 SFU Demo Server

+

Selective Forwarding Unit - Scalable WebRTC Broadcasting

+
+ + + +
+
+

🔄 Stream Forwarding

+

Server receives one stream from publisher and forwards optimized versions to all subscribers

+
+
+

📈 Scalability

+

Publisher bandwidth stays constant regardless of subscriber count

+
+
+

⚡ Adaptive Bitrate

+

Server automatically adjusts stream quality based on subscriber capabilities

+
+
+

🛡️ Reliability

+

Server handles connection management, recovery, and optimization

+
+
+ +
+

📊 SFU vs Mesh Comparison

+

Mesh: Publisher bandwidth = Subscriber count × Stream bitrate (doesn't scale)

+

SFU: Publisher bandwidth = 1 × Stream bitrate (scales to thousands)

+
+ + + `, { headers: { 'Content-Type': 'text/html' } }), + '/publisher': publisherHtml, + '/subscriber': subscriberHtml, + '/stats': this.handleStatsRequest.bind(this) + }, + websocket: { + open: this.handleWebSocketOpen.bind(this), + message: this.handleWebSocketMessage.bind(this), + close: this.handleWebSocketClose.bind(this) + }, + development: { + hmr: true, + console: true + } + }); + + // Simulate SFU processing + setInterval(() => { + this.simulateSFUProcessing(); + }, 1000); + + console.log(`🚀 SFU Demo Server running on http://localhost:${server.port}`); + console.log(`📡 Publisher: http://localhost:${server.port}/publisher`); + console.log(`📺 Subscriber: http://localhost:${server.port}/subscriber`); + console.log(`📊 Stats: http://localhost:${server.port}/stats`); + console.log('\n🎯 SFU Demo Features:'); + console.log(' • Simulates SFU stream forwarding'); + console.log(' • Shows scalability advantages'); + console.log(' • Demonstrates adaptive bitrate'); + console.log(' • Real-time statistics'); + } + + private handleWebSocketOpen(ws: any, req: Request): void { + const url = new URL(req.url); + const role = url.searchParams.get('role') as 'publisher' | 'subscriber'; + + if (!role || (role !== 'publisher' && role !== 'subscriber')) { + ws.close(1002, 'Invalid role parameter'); + return; + } + + const clientId = this.generateClientId(); + const client: SFUSimClient = { + id: clientId, + role, + ws, + quality: 'medium', + bitrate: role === 'publisher' ? 2500 : 0, + connectedAt: Date.now() + }; + + this.clients.set(clientId, client); + this.clientSessions.set(ws, clientId); + this.updateStats(); + + // Send join confirmation + ws.send(JSON.stringify({ + type: 'join', + data: { clientId, role } + })); + + console.log(`🔌 Client connected: ${clientId} (${role})`); + + // Simulate SFU capabilities exchange + if (role === 'publisher') { + setTimeout(() => { + ws.send(JSON.stringify({ + type: 'routerRtpCapabilities', + data: { rtpCapabilities: this.getMockRtpCapabilities() } + })); + }, 100); + } + } + + private async handleWebSocketMessage(ws: any, message: string | ArrayBuffer): Promise { + const clientId = this.clientSessions.get(ws); + if (!clientId) return; + + try { + const parsedMessage: any = JSON.parse(message.toString()); + await this.handleSFUMessage(clientId, parsedMessage); + } catch (error) { + console.error('Failed to handle message:', error); + } + } + + private async handleSFUMessage(clientId: string, message: any): Promise { + const client = this.clients.get(clientId); + if (!client) return; + + switch (message.type) { + case 'getRouterRtpCapabilities': + client.ws.send(JSON.stringify({ + type: 'routerRtpCapabilities', + data: { rtpCapabilities: this.getMockRtpCapabilities() } + })); + break; + + case 'createWebRtcTransport': + client.ws.send(JSON.stringify({ + type: 'webRtcTransportCreated', + data: { + id: this.generateClientId(), + iceParameters: {}, + iceCandidates: [], + dtlsParameters: {} + } + })); + break; + + case 'connectWebRtcTransport': + client.ws.send(JSON.stringify({ + type: 'webRtcTransportConnected', + data: {} + })); + break; + + case 'produce': + if (client.role === 'publisher') { + const producerId = this.generateClientId(); + client.streamId = producerId; + client.ws.send(JSON.stringify({ + type: 'produced', + data: { producerId } + })); + + // Notify all subscribers about new stream + this.notifySubscribersNewStream(producerId); + this.serverStats.streamsForwarded++; + } + break; + + case 'consume': + if (client.role === 'subscriber') { + const consumerId = this.generateClientId(); + client.ws.send(JSON.stringify({ + type: 'consumed', + data: { + consumerId, + producerId: message.data.producerId, + kind: 'video', + rtpParameters: {} + } + })); + } + break; + + case 'resume': + client.ws.send(JSON.stringify({ + type: 'resumed', + data: { consumerId: message.data.consumerId } + })); + break; + + case 'getProducers': + const publishers = Array.from(this.clients.values()) + .filter(c => c.role === 'publisher' && c.streamId); + + client.ws.send(JSON.stringify({ + type: 'producers', + data: { + producers: publishers.map(p => ({ + id: p.streamId, + kind: 'video' + })) + } + })); + break; + } + } + + private handleWebSocketClose(ws: any): void { + const clientId = this.clientSessions.get(ws); + if (clientId) { + const client = this.clients.get(clientId); + console.log(`🔌 Client disconnected: ${clientId} (${client?.role})`); + + this.clients.delete(clientId); + this.clientSessions.delete(ws); + this.updateStats(); + } + } + + private notifySubscribersNewStream(producerId: string): void { + const subscribers = Array.from(this.clients.values()) + .filter(client => client.role === 'subscriber'); + + subscribers.forEach(subscriber => { + subscriber.ws.send(JSON.stringify({ + type: 'newProducer', + data: { producerId } + })); + }); + } + + private simulateSFUProcessing(): void { + // Simulate SFU bandwidth optimization + const publishers = Array.from(this.clients.values()).filter(c => c.role === 'publisher'); + const subscribers = Array.from(this.clients.values()).filter(c => c.role === 'subscriber'); + + // Calculate total bandwidth (SFU efficiency) + let totalBandwidth = 0; + publishers.forEach(pub => totalBandwidth += pub.bitrate || 0); + subscribers.forEach(sub => totalBandwidth += (sub.bitrate || 1000)); // Receive bitrate + + this.serverStats.totalBandwidth = totalBandwidth; + + // Simulate adaptive bitrate for subscribers + subscribers.forEach(subscriber => { + const qualities = ['low', 'medium', 'high']; + const bitrates = [800, 1500, 2500]; + const qualityIndex = Math.floor(Math.random() * 3); + + subscriber.quality = qualities[qualityIndex]; + subscriber.bitrate = bitrates[qualityIndex]; + }); + } + + private updateStats(): void { + const clients = Array.from(this.clients.values()); + this.serverStats.totalClients = clients.length; + this.serverStats.publishers = clients.filter(c => c.role === 'publisher').length; + this.serverStats.subscribers = clients.filter(c => c.role === 'subscriber').length; + } + + private handleStatsRequest(): Response { + const clients = Array.from(this.clients.values()); + const uptime = process.uptime(); + + const html = ` + + + SFU Demo Statistics + + + + +
+

📊 SFU Demo Server Statistics

+
🔄 Auto-refreshing every 3 seconds
+ +
+
+
${this.serverStats.totalClients}
+
Total Clients
+
+
+
${this.serverStats.publishers}
+
Publishers
+
+
+
${this.serverStats.subscribers}
+
Subscribers
+
+
+
${this.serverStats.streamsForwarded}
+
Streams Forwarded
+
+
+
${Math.round(this.serverStats.totalBandwidth / 1000)}K
+
Total Bandwidth
+
+
+
${Math.round(uptime)}s
+
Server Uptime
+
+
+ +
+

🚀 SFU Scaling Advantage

+

Traditional Mesh: ${this.serverStats.publishers} publishers × ${this.serverStats.subscribers} subscribers = ${this.serverStats.publishers * this.serverStats.subscribers} connections

+

SFU Architecture: ${this.serverStats.publishers} + ${this.serverStats.subscribers} = ${this.serverStats.publishers + this.serverStats.subscribers} connections

+

Bandwidth Saved: ${this.serverStats.subscribers > 0 ? Math.round(((this.serverStats.subscribers - 1) / this.serverStats.subscribers) * 100) : 0}% publisher bandwidth reduction

+
+ +
+ + + + + + + + + + + + ${clients.map(client => ` + + + + + + + + `).join('')} + +
Client IDRoleQualityBitrateConnected
${client.id.substring(0, 8)}...${client.role}${client.quality || '-'}${client.bitrate ? (client.bitrate / 1000).toFixed(1) + 'K' : '-'}${Math.round((Date.now() - client.connectedAt) / 1000)}s ago
+
+ +

← Back to Home

+
+ + + `; + + return new Response(html, { + headers: { 'Content-Type': 'text/html' } + }); + } + + private getMockRtpCapabilities() { + return { + codecs: [ + { + mimeType: 'video/VP8', + clockRate: 90000 + }, + { + mimeType: 'audio/opus', + clockRate: 48000, + channels: 2 + } + ] + }; + } + + private generateClientId(): string { + return Math.random().toString(36).substring(2, 15) + Date.now().toString(36); + } +} + +// Start the SFU Demo server +const sfuServer = new SFUDemoServer(); +sfuServer.start().catch((error) => { + console.error('❌ Failed to start SFU server:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/sfu-server.ts b/src/sfu-server.ts new file mode 100644 index 0000000..ed3003a --- /dev/null +++ b/src/sfu-server.ts @@ -0,0 +1,192 @@ +import { MediaServerManager } from '../src/services/MediaServerManager.ts'; +import { SFUClientManager } from '../src/services/SFUClientManager.ts'; +import { SFUSignalingService } from '../src/services/SFUSignalingService.ts'; +import { mediaServerConfig } from '../src/config/mediaServerConfig.ts'; +import type { ISFUSignalingMessage } from '../src/interfaces/ISFUTypes.ts'; +import publisherHtml from './public/sfu-publisher.html'; +import subscriberHtml from './public/sfu-subscriber.html'; + +class SFUServer { + private mediaServer: MediaServerManager; + private clientManager: SFUClientManager; + private signalingService: SFUSignalingService; + private clientSessions: Map = new Map(); + + constructor() { + this.mediaServer = new MediaServerManager(mediaServerConfig); + this.clientManager = new SFUClientManager(); + this.signalingService = new SFUSignalingService(this.mediaServer, this.clientManager); + } + + async start(): Promise { + // Initialize media server + await this.mediaServer.initialize(); + + // Start HTTP/WebSocket server + const server = Bun.serve({ + port: 3001, + routes: { + '/': () => new Response(` + + +

WebRTC SFU Broadcasting

+

SFU Architecture - Scalable for many subscribers

+

Publisher Interface

+

Subscriber Interface

+

Server Statistics

+
+

Architecture Benefits:

+
    +
  • Scalable: Constant publisher bandwidth
  • +
  • Efficient: Server handles stream forwarding
  • +
  • Reliable: Server-side bandwidth management
  • +
  • Adaptive: Multiple bitrate support
  • +
+ + + `, { headers: { 'Content-Type': 'text/html' } }), + '/publisher': publisherHtml, + '/subscriber': subscriberHtml, + '/stats': this.handleStatsRequest.bind(this), + }, + websocket: { + open: this.handleWebSocketOpen.bind(this), + message: this.handleWebSocketMessage.bind(this), + close: this.handleWebSocketClose.bind(this) + }, + development: { + hmr: true, + console: true + } + }); + + console.log(`🚀 SFU WebRTC Broadcasting server running on http://localhost:${server.port}`); + console.log(`📡 Publisher: http://localhost:${server.port}/publisher`); + console.log(`📺 Subscriber: http://localhost:${server.port}/subscriber`); + console.log(`📊 Stats: http://localhost:${server.port}/stats`); + console.log('\n🔧 SFU Features:'); + console.log(' • Scalable to hundreds of subscribers'); + console.log(' • Server-side stream forwarding'); + console.log(' • Adaptive bitrate control'); + console.log(' • Bandwidth optimization'); + } + + private handleWebSocketOpen(ws: any, req: Request): void { + const url = new URL(req.url); + const role = url.searchParams.get('role') as 'publisher' | 'subscriber'; + + if (!role || (role !== 'publisher' && role !== 'subscriber')) { + ws.close(1002, 'Invalid role parameter'); + return; + } + + const clientId = this.signalingService.handleConnection(ws, role); + this.clientSessions.set(ws, clientId); + + console.log(`🔌 Client connected: ${clientId} (${role})`); + } + + private async handleWebSocketMessage(ws: any, message: string | ArrayBuffer): Promise { + const clientId = this.clientSessions.get(ws); + if (!clientId) return; + + try { + const parsedMessage: ISFUSignalingMessage = JSON.parse(message.toString()); + await this.signalingService.handleMessage(clientId, parsedMessage); + } catch (error) { + console.error('Failed to parse/handle message:', error); + } + } + + private handleWebSocketClose(ws: any): void { + const clientId = this.clientSessions.get(ws); + if (clientId) { + this.signalingService.handleDisconnection(clientId); + this.clientSessions.delete(ws); + console.log(`🔌 Client disconnected: ${clientId}`); + } + } + + private handleStatsRequest(): Response { + const stats = this.signalingService.getStats(); + + const html = ` + + + SFU Server Statistics + + + + +

📊 SFU Server Statistics

+
🔄 Auto-refreshing every 5 seconds
+ +
+ Total Clients: ${stats.clients} +
+ +
+ Publishers: ${stats.publishers} +
+ +
+ Subscribers: ${stats.subscribers} +
+ +
+ Active Producers: ${stats.producers} +
+ +
+ Active Consumers: ${stats.consumers} +
+ +
+ MediaSoup Worker PID: ${stats.mediaServer.workerId} +
+ +
+ Router ID: ${stats.mediaServer.routerId} +
+ +

← Back to Home

+ + + `; + + return new Response(html, { + headers: { 'Content-Type': 'text/html' } + }); + } + + async stop(): Promise { + await this.mediaServer.close(); + } +} + +// Start the SFU server +const sfuServer = new SFUServer(); + +// Handle graceful shutdown +process.on('SIGINT', async () => { + console.log('🛑 Shutting down SFU server...'); + await sfuServer.stop(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + console.log('🛑 Shutting down SFU server...'); + await sfuServer.stop(); + process.exit(0); +}); + +sfuServer.start().catch((error) => { + console.error('❌ Failed to start SFU server:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/tests/ClientManager.test.ts b/tests/ClientManager.test.ts new file mode 100644 index 0000000..f20aa5e --- /dev/null +++ b/tests/ClientManager.test.ts @@ -0,0 +1,146 @@ +import { test, expect, describe, beforeEach } from "bun:test"; +import { ClientManager } from "../src/services/ClientManager.ts"; +import type { IWebSocketClient } from "../src/interfaces/ISignalingMessage.ts"; + +class MockWebSocket { + sentMessages: string[] = []; + closed = false; + + send(data: string) { + this.sentMessages.push(data); + } + + close() { + this.closed = true; + } +} + +describe("ClientManager", () => { + let clientManager: ClientManager; + let mockWs: MockWebSocket; + + beforeEach(() => { + clientManager = new ClientManager(); + mockWs = new MockWebSocket(); + }); + + test("should add a publisher client", () => { + const client: IWebSocketClient = { + id: "publisher-1", + ws: mockWs, + role: "publisher" + }; + + clientManager.addClient(client); + + expect(clientManager.getClient("publisher-1")).toBe(client); + expect(clientManager.getPublisher()).toBe(client); + }); + + test("should add subscriber clients", () => { + const subscriber1: IWebSocketClient = { + id: "subscriber-1", + ws: mockWs, + role: "subscriber" + }; + + const subscriber2: IWebSocketClient = { + id: "subscriber-2", + ws: new MockWebSocket(), + role: "subscriber" + }; + + clientManager.addClient(subscriber1); + clientManager.addClient(subscriber2); + + const subscribers = clientManager.getSubscribers(); + expect(subscribers).toHaveLength(2); + expect(subscribers).toContain(subscriber1); + expect(subscribers).toContain(subscriber2); + }); + + test("should notify subscribers when publisher joins", () => { + const subscriber1: IWebSocketClient = { + id: "subscriber-1", + ws: mockWs, + role: "subscriber" + }; + + const subscriber2: IWebSocketClient = { + id: "subscriber-2", + ws: new MockWebSocket(), + role: "subscriber" + }; + + clientManager.addClient(subscriber1); + clientManager.addClient(subscriber2); + + const publisher: IWebSocketClient = { + id: "publisher-1", + ws: new MockWebSocket(), + role: "publisher" + }; + + clientManager.addClient(publisher); + + expect(mockWs.sentMessages).toContain( + JSON.stringify({ type: 'publisher-joined' }) + ); + expect((subscriber2.ws as MockWebSocket).sentMessages).toContain( + JSON.stringify({ type: 'publisher-joined' }) + ); + }); + + test("should remove client and notify subscribers when publisher leaves", () => { + const subscriber: IWebSocketClient = { + id: "subscriber-1", + ws: mockWs, + role: "subscriber" + }; + + const publisher: IWebSocketClient = { + id: "publisher-1", + ws: new MockWebSocket(), + role: "publisher" + }; + + clientManager.addClient(subscriber); + clientManager.addClient(publisher); + + clientManager.removeClient("publisher-1"); + + expect(clientManager.getClient("publisher-1")).toBeUndefined(); + expect(clientManager.getPublisher()).toBeNull(); + expect(mockWs.sentMessages).toContain( + JSON.stringify({ type: 'publisher-left' }) + ); + }); + + test("should handle removing non-existent client", () => { + expect(() => { + clientManager.removeClient("non-existent"); + }).not.toThrow(); + }); + + test("should get all clients", () => { + const publisher: IWebSocketClient = { + id: "publisher-1", + ws: mockWs, + role: "publisher" + }; + + const subscriber: IWebSocketClient = { + id: "subscriber-1", + ws: new MockWebSocket(), + role: "subscriber" + }; + + clientManager.addClient(publisher); + clientManager.addClient(subscriber); + + const allClients = clientManager.getAllClients(); + expect(allClients).toHaveLength(2); + expect(allClients).toContain(publisher); + expect(allClients).toContain(subscriber); + }); +}); \ No newline at end of file diff --git a/tests/SignalingService.test.ts b/tests/SignalingService.test.ts new file mode 100644 index 0000000..bc220e7 --- /dev/null +++ b/tests/SignalingService.test.ts @@ -0,0 +1,176 @@ +import { test, expect, describe, beforeEach } from "bun:test"; +import { SignalingService } from "../src/services/SignalingService.ts"; +import { ClientManager } from "../src/services/ClientManager.ts"; +import type { ISignalingMessage } from "../src/interfaces/ISignalingMessage.ts"; + +class MockWebSocket { + sentMessages: string[] = []; + closed = false; + + send(data: string) { + this.sentMessages.push(data); + } + + close() { + this.closed = true; + } +} + +describe("SignalingService", () => { + let signalingService: SignalingService; + let clientManager: ClientManager; + let mockWs: MockWebSocket; + + beforeEach(() => { + clientManager = new ClientManager(); + signalingService = new SignalingService(clientManager); + mockWs = new MockWebSocket(); + }); + + test("should handle publisher connection", () => { + const clientId = signalingService.handleConnection(mockWs, "publisher"); + + expect(typeof clientId).toBe("string"); + expect(clientId.length).toBeGreaterThan(0); + expect(mockWs.sentMessages[0]).toBe( + JSON.stringify({ + type: 'join', + data: { clientId, role: 'publisher' } + }) + ); + expect(clientManager.getPublisher()?.id).toBe(clientId); + }); + + test("should handle subscriber connection", () => { + const clientId = signalingService.handleConnection(mockWs, "subscriber"); + + expect(typeof clientId).toBe("string"); + expect(clientId.length).toBeGreaterThan(0); + expect(mockWs.sentMessages[0]).toBe( + JSON.stringify({ + type: 'join', + data: { clientId, role: 'subscriber' } + }) + ); + + const subscribers = clientManager.getSubscribers(); + expect(subscribers).toHaveLength(1); + expect(subscribers[0].id).toBe(clientId); + }); + + test("should handle disconnection", () => { + const clientId = signalingService.handleConnection(mockWs, "publisher"); + expect(clientManager.getPublisher()).not.toBeNull(); + + signalingService.handleDisconnection(clientId); + expect(clientManager.getPublisher()).toBeNull(); + }); + + test("should handle offer from publisher to subscribers", () => { + // Add publisher + const publisherId = signalingService.handleConnection(mockWs, "publisher"); + + // Add subscribers + const subscriber1Ws = new MockWebSocket(); + const subscriber2Ws = new MockWebSocket(); + signalingService.handleConnection(subscriber1Ws, "subscriber"); + signalingService.handleConnection(subscriber2Ws, "subscriber"); + + const offerMessage: ISignalingMessage = { + type: "offer", + data: { sdp: "fake-offer-sdp", type: "offer" } + }; + + signalingService.handleMessage(publisherId, offerMessage); + + expect(subscriber1Ws.sentMessages).toContain( + JSON.stringify({ + ...offerMessage, + senderId: publisherId + }) + ); + expect(subscriber2Ws.sentMessages).toContain( + JSON.stringify({ + ...offerMessage, + senderId: publisherId + }) + ); + }); + + test("should handle answer from subscriber to publisher", () => { + // Add publisher + const publisherWs = new MockWebSocket(); + const publisherId = signalingService.handleConnection(publisherWs, "publisher"); + + // Add subscriber + const subscriberWs = new MockWebSocket(); + const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber"); + + const answerMessage: ISignalingMessage = { + type: "answer", + data: { sdp: "fake-answer-sdp", type: "answer" } + }; + + signalingService.handleMessage(subscriberId, answerMessage); + + expect(publisherWs.sentMessages).toContain( + JSON.stringify({ + ...answerMessage, + senderId: subscriberId + }) + ); + }); + + test("should handle ice candidates from publisher to subscribers", () => { + const publisherId = signalingService.handleConnection(mockWs, "publisher"); + + const subscriberWs = new MockWebSocket(); + signalingService.handleConnection(subscriberWs, "subscriber"); + + const iceCandidateMessage: ISignalingMessage = { + type: "ice-candidate", + data: { candidate: "fake-ice-candidate" } + }; + + signalingService.handleMessage(publisherId, iceCandidateMessage); + + expect(subscriberWs.sentMessages).toContain( + JSON.stringify({ + ...iceCandidateMessage, + senderId: publisherId + }) + ); + }); + + test("should handle ice candidates from subscriber to publisher", () => { + const publisherWs = new MockWebSocket(); + const publisherId = signalingService.handleConnection(publisherWs, "publisher"); + + const subscriberId = signalingService.handleConnection(mockWs, "subscriber"); + + const iceCandidateMessage: ISignalingMessage = { + type: "ice-candidate", + data: { candidate: "fake-ice-candidate" } + }; + + signalingService.handleMessage(subscriberId, iceCandidateMessage); + + expect(publisherWs.sentMessages).toContain( + JSON.stringify({ + ...iceCandidateMessage, + senderId: subscriberId + }) + ); + }); + + test("should ignore messages from non-existent clients", () => { + const message: ISignalingMessage = { + type: "offer", + data: { sdp: "fake-sdp" } + }; + + expect(() => { + signalingService.handleMessage("non-existent-id", message); + }).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/tests/basic.test.ts b/tests/basic.test.ts new file mode 100644 index 0000000..5148b80 --- /dev/null +++ b/tests/basic.test.ts @@ -0,0 +1,130 @@ +import { test, expect, describe } from "bun:test"; +import { ClientManager } from "../src/services/ClientManager.ts"; +import { SignalingService } from "../src/services/SignalingService.ts"; +import type { IWebSocketClient } from "../src/interfaces/ISignalingMessage.ts"; + +class MockWebSocket { + sentMessages: string[] = []; + send(data: string) { + this.sentMessages.push(data); + } + close() {} +} + +describe("Basic WebRTC Broadcasting Tests", () => { + test("ClientManager should add and retrieve clients", () => { + const clientManager = new ClientManager(); + const mockWs = new MockWebSocket(); + + const client: IWebSocketClient = { + id: "test-client", + ws: mockWs, + role: "publisher" + }; + + clientManager.addClient(client); + + expect(clientManager.getClient("test-client")).toBe(client); + expect(clientManager.getPublisher()).toBe(client); + }); + + test("ClientManager should track subscribers separately", () => { + const clientManager = new ClientManager(); + + const subscriber1: IWebSocketClient = { + id: "sub-1", + ws: new MockWebSocket(), + role: "subscriber" + }; + + const subscriber2: IWebSocketClient = { + id: "sub-2", + ws: new MockWebSocket(), + role: "subscriber" + }; + + clientManager.addClient(subscriber1); + clientManager.addClient(subscriber2); + + const subscribers = clientManager.getSubscribers(); + expect(subscribers.length).toBe(2); + expect(subscribers).toContain(subscriber1); + expect(subscribers).toContain(subscriber2); + }); + + test("SignalingService should create client connections", () => { + const clientManager = new ClientManager(); + const signalingService = new SignalingService(clientManager); + const mockWs = new MockWebSocket(); + + const clientId = signalingService.handleConnection(mockWs, "publisher"); + + expect(typeof clientId).toBe("string"); + expect(clientId.length).toBeGreaterThan(0); + expect(mockWs.sentMessages.length).toBe(1); + + const message = JSON.parse(mockWs.sentMessages[0]); + expect(message.type).toBe("join"); + expect(message.data.clientId).toBe(clientId); + expect(message.data.role).toBe("publisher"); + }); + + test("SignalingService should handle disconnections", () => { + const clientManager = new ClientManager(); + const signalingService = new SignalingService(clientManager); + const mockWs = new MockWebSocket(); + + const clientId = signalingService.handleConnection(mockWs, "publisher"); + expect(clientManager.getPublisher()).toBeTruthy(); + + signalingService.handleDisconnection(clientId); + expect(clientManager.getPublisher()).toBeNull(); + }); + + test("Complete publisher-subscriber flow", () => { + const clientManager = new ClientManager(); + const signalingService = new SignalingService(clientManager); + + // Add publisher + const publisherWs = new MockWebSocket(); + const publisherId = signalingService.handleConnection(publisherWs, "publisher"); + + // Add subscriber + const subscriberWs = new MockWebSocket(); + const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber"); + + // Clear initial connection messages + publisherWs.sentMessages = []; + subscriberWs.sentMessages = []; + + // Send offer from publisher + const offerMessage = { + type: "offer" as const, + data: { sdp: "test-offer" } + }; + + signalingService.handleMessage(publisherId, offerMessage); + + // Check subscriber received the offer + expect(subscriberWs.sentMessages.length).toBe(1); + const receivedMessage = JSON.parse(subscriberWs.sentMessages[0]); + expect(receivedMessage.type).toBe("offer"); + expect(receivedMessage.data.sdp).toBe("test-offer"); + expect(receivedMessage.senderId).toBe(publisherId); + + // Send answer from subscriber + const answerMessage = { + type: "answer" as const, + data: { sdp: "test-answer" } + }; + + signalingService.handleMessage(subscriberId, answerMessage); + + // Check publisher received the answer + expect(publisherWs.sentMessages.length).toBe(1); + const publisherMessage = JSON.parse(publisherWs.sentMessages[0]); + expect(publisherMessage.type).toBe("answer"); + expect(publisherMessage.data.sdp).toBe("test-answer"); + expect(publisherMessage.senderId).toBe(subscriberId); + }); +}); \ No newline at end of file diff --git a/tests/frontend/MediaHandler.test.ts b/tests/frontend/MediaHandler.test.ts new file mode 100644 index 0000000..910afd2 --- /dev/null +++ b/tests/frontend/MediaHandler.test.ts @@ -0,0 +1,288 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; + +// Mock MediaStream and related APIs +class MockMediaStreamTrack { + kind: string; + id: string; + label: string = ''; + enabled: boolean = true; + muted: boolean = false; + readyState: 'live' | 'ended' = 'live'; + + constructor(kind: string) { + this.kind = kind; + this.id = Math.random().toString(36); + } + + stop() { + this.readyState = 'ended'; + } + + clone() { + return new MockMediaStreamTrack(this.kind); + } +} + +class MockMediaStream { + id: string; + active: boolean = true; + private tracks: MockMediaStreamTrack[] = []; + + constructor(tracks?: MockMediaStreamTrack[]) { + this.id = Math.random().toString(36); + if (tracks) { + this.tracks = [...tracks]; + } + } + + getTracks() { + return [...this.tracks]; + } + + getVideoTracks() { + return this.tracks.filter(track => track.kind === 'video'); + } + + getAudioTracks() { + return this.tracks.filter(track => track.kind === 'audio'); + } + + addTrack(track: MockMediaStreamTrack) { + this.tracks.push(track); + } + + removeTrack(track: MockMediaStreamTrack) { + const index = this.tracks.indexOf(track); + if (index > -1) { + this.tracks.splice(index, 1); + } + } +} + +class MockHTMLVideoElement { + srcObject: MockMediaStream | null = null; + autoplay: boolean = false; + muted: boolean = false; + + play() { + return Promise.resolve(); + } + + pause() {} +} + +// Mock navigator.mediaDevices +let mockGetUserMediaCalls: any[] = []; +const mockGetUserMedia = (constraints: any) => { + mockGetUserMediaCalls.push(constraints); + const tracks = []; + if (constraints.video) tracks.push(new MockMediaStreamTrack('video')); + if (constraints.audio) tracks.push(new MockMediaStreamTrack('audio')); + return Promise.resolve(new MockMediaStream(tracks)); +}; + +(global as any).navigator = { + mediaDevices: { + getUserMedia: mockGetUserMedia + } +}; + +// Mock document.getElementById +let getElementByIdReturn: any = null; +(global as any).document = { + getElementById: () => getElementByIdReturn +}; + +// Import after mocking +import { MediaHandler } from "../../public/js/services/MediaHandler.ts"; + +describe("MediaHandler", () => { + let mediaHandler: MediaHandler; + let mockVideoElement: MockHTMLVideoElement; + + beforeEach(() => { + mockVideoElement = new MockHTMLVideoElement(); + getElementByIdReturn = mockVideoElement; + mockGetUserMediaCalls.length = 0; // Clear calls array + }); + + test("should initialize with video element", () => { + mediaHandler = new MediaHandler('testVideo'); + + // The MediaHandler should have found and stored the video element + expect(mediaHandler.getLocalVideo()).toBe(mockVideoElement); + }); + + test("should initialize without video element", () => { + mediaHandler = new MediaHandler(); + + expect(mediaHandler.getLocalVideo()).toBeNull(); + }); + + test("should get local stream successfully", async () => { + mediaHandler = new MediaHandler('testVideo'); + + const mockTracks = [ + new MockMediaStreamTrack('video'), + new MockMediaStreamTrack('audio') + ]; + const mockStream = new MockMediaStream(mockTracks); + + // Override mock to return this specific stream and track calls + const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia; + const getUserMediaCalls: any[] = []; + (global as any).navigator.mediaDevices.getUserMedia = (constraints: any) => { + getUserMediaCalls.push(constraints); + return Promise.resolve(mockStream); + }; + + const stream = await mediaHandler.getLocalStream(); + + expect(getUserMediaCalls).toHaveLength(1); + expect(getUserMediaCalls[0]).toEqual({ + video: { + width: { ideal: 1280 }, + height: { ideal: 720 }, + frameRate: { ideal: 30 } + }, + audio: true + }); + + expect(stream).toBe(mockStream); + expect(mockVideoElement.srcObject).toBe(mockStream); + + // Restore original + (global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia; + }); + + test("should handle getUserMedia error", async () => { + mediaHandler = new MediaHandler('testVideo'); + + const error = new Error('Camera access denied'); + + // Override mock to reject with error + const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia; + const originalConsoleError = console.error; + const errorLogs: any[] = []; + console.error = (...args: any[]) => errorLogs.push(args); + + (global as any).navigator.mediaDevices.getUserMedia = () => Promise.reject(error); + + await expect(mediaHandler.getLocalStream()).rejects.toThrow('Camera access denied'); + + expect(errorLogs.length).toBeGreaterThan(0); + expect(errorLogs[0][0]).toBe('Error accessing media devices:'); + expect(errorLogs[0][1]).toBe(error); + + // Restore originals + console.error = originalConsoleError; + (global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia; + }); + + test("should stop local stream", async () => { + mediaHandler = new MediaHandler('testVideo'); + + const mockTracks = [ + new MockMediaStreamTrack('video'), + new MockMediaStreamTrack('audio') + ]; + const mockStream = new MockMediaStream(mockTracks); + + // Override mock to return this specific stream + const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia; + (global as any).navigator.mediaDevices.getUserMedia = () => Promise.resolve(mockStream); + + await mediaHandler.getLocalStream(); + + // Track the initial state of the tracks + const initialStates = mockTracks.map(track => track.readyState); + + mediaHandler.stopLocalStream(); + + // Check that all tracks were stopped (readyState should be 'ended') + mockTracks.forEach(track => { + expect(track.readyState).toBe('ended'); + }); + + expect(mockVideoElement.srcObject).toBeNull(); + expect(mediaHandler.getCurrentStream()).toBeNull(); + + // Restore original + (global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia; + }); + + test("should handle stopping stream when no stream exists", () => { + mediaHandler = new MediaHandler('testVideo'); + + expect(() => { + mediaHandler.stopLocalStream(); + }).not.toThrow(); + + expect(mockVideoElement.srcObject).toBeNull(); + }); + + test("should get current stream", async () => { + mediaHandler = new MediaHandler('testVideo'); + + expect(mediaHandler.getCurrentStream()).toBeNull(); + + const mockStream = new MockMediaStream([new MockMediaStreamTrack('video')]); + + // Override mock to return this specific stream + const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia; + (global as any).navigator.mediaDevices.getUserMedia = () => Promise.resolve(mockStream); + + await mediaHandler.getLocalStream(); + + // Restore original + (global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia; + + expect(mediaHandler.getCurrentStream()).toBe(mockStream); + }); + + test("should work without video element", async () => { + getElementByIdReturn = null; + mediaHandler = new MediaHandler('nonExistentVideo'); + + const mockStream = new MockMediaStream([new MockMediaStreamTrack('video')]); + + // Override mock to return this specific stream + const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia; + (global as any).navigator.mediaDevices.getUserMedia = () => Promise.resolve(mockStream); + + const stream = await mediaHandler.getLocalStream(); + + expect(stream).toBe(mockStream); + expect(mediaHandler.getLocalVideo()).toBeNull(); + + // Restore original + (global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia; + }); + + test("should handle multiple calls to getLocalStream", async () => { + mediaHandler = new MediaHandler('testVideo'); + + const mockStream1 = new MockMediaStream([new MockMediaStreamTrack('video')]); + const mockStream2 = new MockMediaStream([new MockMediaStreamTrack('video')]); + + let callCount = 0; + const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia; + (global as any).navigator.mediaDevices.getUserMedia = () => { + callCount++; + return Promise.resolve(callCount === 1 ? mockStream1 : mockStream2); + }; + + const stream1 = await mediaHandler.getLocalStream(); + const stream2 = await mediaHandler.getLocalStream(); + + expect(stream1).toBe(mockStream1); + expect(stream2).toBe(mockStream2); + expect(callCount).toBe(2); + + // The video element should have the latest stream + expect(mockVideoElement.srcObject).toBe(mockStream2); + + // Restore original + (global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia; + }); +}); \ No newline at end of file diff --git a/tests/frontend/UIController.test.ts b/tests/frontend/UIController.test.ts new file mode 100644 index 0000000..c8907a1 --- /dev/null +++ b/tests/frontend/UIController.test.ts @@ -0,0 +1,198 @@ +import { test, expect, describe, beforeEach } from "bun:test"; + +// Simple mock function for tests +function mockFn() { + let callCount = 0; + const fn = () => { callCount++; }; + + Object.defineProperty(fn, 'toHaveBeenCalledTimes', { + value: (expected: number) => { + if (callCount !== expected) { + throw new Error(`Expected ${expected} calls, got ${callCount}`); + } + return true; + } + }); + + return fn; +} + +// Mock HTML elements +class MockHTMLElement { + public textContent: string = ''; + public className: string = ''; + public disabled: boolean = false; + private eventListeners: { [key: string]: (() => void)[] } = {}; + + addEventListener(event: string, handler: () => void) { + if (!this.eventListeners[event]) { + this.eventListeners[event] = []; + } + this.eventListeners[event].push(handler); + } + + click() { + const handlers = this.eventListeners['click'] || []; + handlers.forEach(handler => handler()); + } +} + +// Mock document.getElementById +const mockElements: { [id: string]: MockHTMLElement } = {}; +const getElementByIdCalls: string[] = []; +(global as any).document = { + getElementById: (id: string) => { + getElementByIdCalls.push(id); + return mockElements[id] || null; + } +}; + +// Import after mocking +import { UIController } from "../../public/js/services/UIController.ts"; + +describe("UIController", () => { + let uiController: UIController; + let statusElement: MockHTMLElement; + let subscribersElement: MockHTMLElement; + let startButton: MockHTMLElement; + let stopButton: MockHTMLElement; + + beforeEach(() => { + // Reset mock elements + statusElement = new MockHTMLElement(); + subscribersElement = new MockHTMLElement(); + startButton = new MockHTMLElement(); + stopButton = new MockHTMLElement(); + + mockElements['status'] = statusElement; + mockElements['subscribers'] = subscribersElement; + mockElements['startBtn'] = startButton; + mockElements['stopBtn'] = stopButton; + + // Clear call history + getElementByIdCalls.length = 0; + + uiController = new UIController('status', 'subscribers', 'startBtn', 'stopBtn'); + }); + + test("should initialize with all elements", () => { + expect(getElementByIdCalls).toContain('status'); + expect(getElementByIdCalls).toContain('subscribers'); + expect(getElementByIdCalls).toContain('startBtn'); + expect(getElementByIdCalls).toContain('stopBtn'); + }); + + test("should initialize with minimal elements", () => { + getElementByIdCalls.length = 0; // Clear previous calls + const minimalController = new UIController('status'); + expect(getElementByIdCalls).toContain('status'); + }); + + test("should update status correctly", () => { + uiController.updateStatus('Connected', 'connected'); + + expect(statusElement.textContent).toBe('Connected'); + expect(statusElement.className).toBe('status connected'); + }); + + test("should update subscribers count", () => { + uiController.updateSubscribersCount(5); + + expect(subscribersElement.textContent).toBe('Subscribers: 5'); + }); + + test("should handle missing subscribers element", () => { + const controllerWithoutSubs = new UIController('status'); + + expect(() => { + controllerWithoutSubs.updateSubscribersCount(5); + }).not.toThrow(); + }); + + test("should set button states", () => { + uiController.setButtonStates(false, true); + + expect(startButton.disabled).toBe(true); + expect(stopButton.disabled).toBe(false); + + uiController.setButtonStates(true, false); + + expect(startButton.disabled).toBe(false); + expect(stopButton.disabled).toBe(true); + }); + + test("should handle missing buttons", () => { + const controllerWithoutButtons = new UIController('status'); + + expect(() => { + controllerWithoutButtons.setButtonStates(true, false); + }).not.toThrow(); + }); + + test("should add button click handlers", () => { + const startHandler = mockFn(); + const stopHandler = mockFn(); + + uiController.onButtonClick('startBtn', startHandler); + uiController.onButtonClick('stopBtn', stopHandler); + + startButton.click(); + expect(startHandler.toHaveBeenCalledTimes(1)).toBe(true); + + stopButton.click(); + expect(stopHandler.toHaveBeenCalledTimes(1)).toBe(true); + }); + + test("should handle non-existent button click handlers", () => { + const handler = mockFn(); + + expect(() => { + uiController.onButtonClick('nonExistentBtn', handler); + }).not.toThrow(); + }); + + test("should handle multiple clicks", () => { + const handler = mockFn(); + uiController.onButtonClick('startBtn', handler); + + startButton.click(); + startButton.click(); + startButton.click(); + + expect(handler.toHaveBeenCalledTimes(3)).toBe(true); + }); + + test("should support multiple handlers on same button", () => { + const handler1 = mockFn(); + const handler2 = mockFn(); + + uiController.onButtonClick('startBtn', handler1); + uiController.onButtonClick('startBtn', handler2); + + startButton.click(); + + expect(handler1.toHaveBeenCalledTimes(1)).toBe(true); + expect(handler2.toHaveBeenCalledTimes(1)).toBe(true); + }); + + test("should update status with different classes", () => { + uiController.updateStatus('Connecting...', 'waiting'); + expect(statusElement.className).toBe('status waiting'); + + uiController.updateStatus('Connected', 'connected'); + expect(statusElement.className).toBe('status connected'); + + uiController.updateStatus('Error occurred', 'error'); + expect(statusElement.className).toBe('status error'); + }); + + test("should update subscribers count with zero", () => { + uiController.updateSubscribersCount(0); + expect(subscribersElement.textContent).toBe('Subscribers: 0'); + }); + + test("should update subscribers count with large numbers", () => { + uiController.updateSubscribersCount(1000); + expect(subscribersElement.textContent).toBe('Subscribers: 1000'); + }); +}); \ No newline at end of file diff --git a/tests/frontend/WebSocketClient.test.ts b/tests/frontend/WebSocketClient.test.ts new file mode 100644 index 0000000..69a6e73 --- /dev/null +++ b/tests/frontend/WebSocketClient.test.ts @@ -0,0 +1,205 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; + +// Mock WebSocket for testing +class MockWebSocket { + public readyState = WebSocket.CONNECTING; + public url: string; + public sentMessages: string[] = []; + + public onopen: (() => void) | null = null; + public onclose: (() => void) | null = null; + public onmessage: ((event: { data: string }) => void) | null = null; + public onerror: ((error: any) => void) | null = null; + + constructor(url: string) { + this.url = url; + // Simulate successful connection + setTimeout(() => { + this.readyState = WebSocket.OPEN; + if (this.onopen) { + this.onopen(); + } + }, 10); + } + + send(data: string) { + if (this.readyState === WebSocket.OPEN) { + this.sentMessages.push(data); + } + } + + close() { + this.readyState = WebSocket.CLOSED; + if (this.onclose) { + this.onclose(); + } + } + + simulateMessage(data: any) { + if (this.onmessage) { + this.onmessage({ data: JSON.stringify(data) }); + } + } + + simulateError(error: any) { + if (this.onerror) { + this.onerror(error); + } + } +} + +// Mock the global WebSocket +(global as any).WebSocket = MockWebSocket; + +// Import after mocking +import { WebSocketClient } from "../../public/js/services/WebSocketClient.ts"; +import type { ISignalingMessage } from "../../public/js/interfaces/IWebRTCClient.ts"; + +describe("WebSocketClient", () => { + let wsClient: WebSocketClient; + let mockWs: MockWebSocket; + + beforeEach(() => { + wsClient = new WebSocketClient('publisher'); + // Access the internal WebSocket instance for testing + }); + + test("should connect as publisher", async () => { + const connectPromise = wsClient.connect(); + + // Wait for connection + await connectPromise; + + expect(wsClient.isConnected()).toBe(true); + }); + + test("should connect as subscriber", async () => { + const subscriberClient = new WebSocketClient('subscriber'); + await subscriberClient.connect(); + + expect(subscriberClient.isConnected()).toBe(true); + }); + + test("should handle connection error", async () => { + const errorClient = new WebSocketClient('publisher'); + + // Start connection but simulate error before it completes + const connectPromise = errorClient.connect(); + + // Simulate error during connection + setTimeout(() => { + // Access the internal WebSocket and trigger error + const ws = (errorClient as any).ws; + if (ws && ws.onerror) { + ws.onerror(new Error('Connection failed')); + } + }, 5); + + await expect(connectPromise).rejects.toThrow(); + }); + + test("should send messages when connected", async () => { + await wsClient.connect(); + + const message: ISignalingMessage = { + type: 'offer', + data: { sdp: 'test-sdp' } + }; + + wsClient.sendMessage(message); + + const ws = (wsClient as any).ws as MockWebSocket; + expect(ws.sentMessages).toHaveLength(1); + expect(JSON.parse(ws.sentMessages[0])).toEqual(message); + }); + + test("should not send messages when disconnected", () => { + const message: ISignalingMessage = { + type: 'offer', + data: { sdp: 'test-sdp' } + }; + + wsClient.sendMessage(message); + + // Should not have sent anything since not connected + const ws = (wsClient as any).ws; + expect(ws).toBeNull(); + }); + + test("should handle incoming messages", async () => { + await wsClient.connect(); + + const receivedMessages: ISignalingMessage[] = []; + wsClient.onMessage('join', (message) => { + receivedMessages.push(message); + }); + + const testMessage: ISignalingMessage = { + type: 'join', + data: { clientId: 'test-id', role: 'publisher' } + }; + + const ws = (wsClient as any).ws as MockWebSocket; + ws.simulateMessage(testMessage); + + expect(receivedMessages).toHaveLength(1); + expect(receivedMessages[0]).toEqual(testMessage); + }); + + test("should handle multiple message types", async () => { + await wsClient.connect(); + + const joinMessages: ISignalingMessage[] = []; + const offerMessages: ISignalingMessage[] = []; + + wsClient.onMessage('join', (message) => joinMessages.push(message)); + wsClient.onMessage('offer', (message) => offerMessages.push(message)); + + const ws = (wsClient as any).ws as MockWebSocket; + + ws.simulateMessage({ type: 'join', data: { clientId: 'test' } }); + ws.simulateMessage({ type: 'offer', data: { sdp: 'test-sdp' } }); + ws.simulateMessage({ type: 'join', data: { clientId: 'test2' } }); + + expect(joinMessages).toHaveLength(2); + expect(offerMessages).toHaveLength(1); + }); + + test("should disconnect properly", async () => { + await wsClient.connect(); + expect(wsClient.isConnected()).toBe(true); + + wsClient.disconnect(); + expect(wsClient.isConnected()).toBe(false); + }); + + test("should handle malformed JSON messages gracefully", async () => { + await wsClient.connect(); + + // Mock console.error to verify error logging + const originalConsoleError = console.error; + const errorLogs: any[] = []; + console.error = (...args: any[]) => errorLogs.push(args); + + const ws = (wsClient as any).ws as MockWebSocket; + if (ws.onmessage) { + ws.onmessage({ data: 'invalid-json' }); + } + + expect(errorLogs.length).toBeGreaterThan(0); + expect(errorLogs[0][0]).toBe('Failed to parse WebSocket message:'); + + // Restore console.error + console.error = originalConsoleError; + }); + + test("should handle close event", async () => { + await wsClient.connect(); + expect(wsClient.isConnected()).toBe(true); + + const ws = (wsClient as any).ws as MockWebSocket; + ws.close(); + + expect(wsClient.isConnected()).toBe(false); + }); +}); \ No newline at end of file diff --git a/tests/integration.test.ts b/tests/integration.test.ts new file mode 100644 index 0000000..b242473 --- /dev/null +++ b/tests/integration.test.ts @@ -0,0 +1,233 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { ClientManager } from "../src/services/ClientManager.ts"; +import { SignalingService } from "../src/services/SignalingService.ts"; + +interface MockWebSocketMessage { + type: string; + data?: any; + senderId?: string; +} + +class MockWebSocket { + private messageHandler: ((event: { data: string }) => void) | null = null; + private openHandler: (() => void) | null = null; + private closeHandler: (() => void) | null = null; + + public sentMessages: string[] = []; + public readyState = WebSocket.OPEN; + public url: string; + + constructor(url: string) { + this.url = url; + // Simulate connection opening + setTimeout(() => { + if (this.openHandler) { + this.openHandler(); + } + }, 10); + } + + send(data: string) { + this.sentMessages.push(data); + } + + close() { + this.readyState = WebSocket.CLOSED; + if (this.closeHandler) { + this.closeHandler(); + } + } + + set onmessage(handler: (event: { data: string }) => void) { + this.messageHandler = handler; + } + + set onopen(handler: () => void) { + this.openHandler = handler; + } + + set onclose(handler: () => void) { + this.closeHandler = handler; + } + + set onerror(handler: (error: any) => void) { + // Mock implementation + } + + simulateMessage(message: MockWebSocketMessage) { + if (this.messageHandler) { + this.messageHandler({ data: JSON.stringify(message) }); + } + } + + getLastMessage(): MockWebSocketMessage | null { + if (this.sentMessages.length === 0) return null; + return JSON.parse(this.sentMessages[this.sentMessages.length - 1]); + } + + getAllMessages(): MockWebSocketMessage[] { + return this.sentMessages.map(msg => JSON.parse(msg)); + } +} + +describe("WebSocket Signaling Integration", () => { + let clientManager: ClientManager; + let signalingService: SignalingService; + + beforeEach(() => { + clientManager = new ClientManager(); + signalingService = new SignalingService(clientManager); + }); + + test("should handle complete publisher-subscriber flow", () => { + // Setup subscribers first + const subscriber1Ws = new MockWebSocket("ws://test?role=subscriber"); + const subscriber1Id = signalingService.handleConnection(subscriber1Ws, "subscriber"); + + const subscriber2Ws = new MockWebSocket("ws://test?role=subscriber"); + const subscriber2Id = signalingService.handleConnection(subscriber2Ws, "subscriber"); + + // Setup publisher (this should notify existing subscribers) + const publisherWs = new MockWebSocket("ws://test?role=publisher"); + const publisherId = signalingService.handleConnection(publisherWs, "publisher"); + + // Verify initial connections + expect(publisherWs.getLastMessage()).toEqual({ + type: 'join', + data: { clientId: publisherId, role: 'publisher' } + }); + + // Verify subscribers get notified about publisher joining + const sub1Messages = subscriber1Ws.getAllMessages(); + const sub2Messages = subscriber2Ws.getAllMessages(); + + expect(sub1Messages).toHaveLength(2); // join + publisher-joined + expect(sub1Messages.some(msg => msg.type === 'publisher-joined')).toBe(true); + expect(sub2Messages).toHaveLength(2); // join + publisher-joined + expect(sub2Messages.some(msg => msg.type === 'publisher-joined')).toBe(true); + + // Test offer flow + const offerMessage = { + type: "offer" as const, + data: { sdp: "fake-offer-sdp", type: "offer" } + }; + + signalingService.handleMessage(publisherId, offerMessage); + + // Verify subscribers received the offer + const sub1LastMessage = subscriber1Ws.getLastMessage(); + const sub2LastMessage = subscriber2Ws.getLastMessage(); + + expect(sub1LastMessage).toEqual({ + ...offerMessage, + senderId: publisherId + }); + expect(sub2LastMessage).toEqual({ + ...offerMessage, + senderId: publisherId + }); + + // Test answer flow + const answerMessage = { + type: "answer" as const, + data: { sdp: "fake-answer-sdp", type: "answer" } + }; + + signalingService.handleMessage(subscriber1Id, answerMessage); + + // Verify publisher received the answer + const publisherLastMessage = publisherWs.getLastMessage(); + expect(publisherLastMessage).toEqual({ + ...answerMessage, + senderId: subscriber1Id + }); + + // Test ICE candidate exchange + const iceCandidateMessage = { + type: "ice-candidate" as const, + data: { candidate: "fake-ice-candidate" } + }; + + // Publisher to subscribers + signalingService.handleMessage(publisherId, iceCandidateMessage); + + expect(subscriber1Ws.getLastMessage()).toEqual({ + ...iceCandidateMessage, + senderId: publisherId + }); + expect(subscriber2Ws.getLastMessage()).toEqual({ + ...iceCandidateMessage, + senderId: publisherId + }); + + // Subscriber to publisher + signalingService.handleMessage(subscriber1Id, iceCandidateMessage); + + expect(publisherWs.getLastMessage()).toEqual({ + ...iceCandidateMessage, + senderId: subscriber1Id + }); + }); + + test("should handle publisher disconnect gracefully", () => { + // Setup + const publisherWs = new MockWebSocket("ws://test?role=publisher"); + const publisherId = signalingService.handleConnection(publisherWs, "publisher"); + + const subscriberWs = new MockWebSocket("ws://test?role=subscriber"); + signalingService.handleConnection(subscriberWs, "subscriber"); + + // Disconnect publisher + signalingService.handleDisconnection(publisherId); + + // Verify subscriber gets publisher-left notification + const subscriberMessages = subscriberWs.getAllMessages(); + expect(subscriberMessages).toHaveLength(2); // join + publisher-left + expect(subscriberMessages.some(msg => msg.type === 'publisher-left')).toBe(true); + + // Verify publisher is removed + expect(clientManager.getPublisher()).toBeNull(); + }); + + test("should handle subscriber disconnect gracefully", () => { + const publisherWs = new MockWebSocket("ws://test?role=publisher"); + signalingService.handleConnection(publisherWs, "publisher"); + + const subscriberWs = new MockWebSocket("ws://test?role=subscriber"); + const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber"); + + // Verify subscriber was added + expect(clientManager.getSubscribers()).toHaveLength(1); + + // Disconnect subscriber + signalingService.handleDisconnection(subscriberId); + + // Verify subscriber was removed + expect(clientManager.getSubscribers()).toHaveLength(0); + expect(clientManager.getClient(subscriberId)).toBeUndefined(); + }); + + test("should handle multiple subscribers connecting and disconnecting", () => { + const publisherWs = new MockWebSocket("ws://test?role=publisher"); + signalingService.handleConnection(publisherWs, "publisher"); + + // Add 3 subscribers + const subscribers = []; + for (let i = 0; i < 3; i++) { + const ws = new MockWebSocket(`ws://test?role=subscriber&id=${i}`); + const id = signalingService.handleConnection(ws, "subscriber"); + subscribers.push({ ws, id }); + } + + expect(clientManager.getSubscribers()).toHaveLength(3); + + // Disconnect middle subscriber + signalingService.handleDisconnection(subscribers[1].id); + expect(clientManager.getSubscribers()).toHaveLength(2); + + // Verify remaining subscribers are still connected + expect(clientManager.getClient(subscribers[0].id)).toBeDefined(); + expect(clientManager.getClient(subscribers[2].id)).toBeDefined(); + expect(clientManager.getClient(subscribers[1].id)).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/tests/sfu/SFUClientManager.test.ts b/tests/sfu/SFUClientManager.test.ts new file mode 100644 index 0000000..117c049 --- /dev/null +++ b/tests/sfu/SFUClientManager.test.ts @@ -0,0 +1,344 @@ +import { test, expect, describe, beforeEach } from "bun:test"; +import { SFUClientManager } from "../../src/services/SFUClientManager.ts"; +import type { ISFUClient } from "../../src/interfaces/ISFUTypes.ts"; + +// Mock WebRTC Transport +class MockWebRtcTransport { + id: string; + closed = false; + + constructor(id: string) { + this.id = id; + } + + close() { + this.closed = true; + } +} + +// Mock Producer +class MockProducer { + id: string; + closed = false; + + constructor(id: string) { + this.id = id; + } + + close() { + this.closed = true; + } +} + +// Mock Consumer +class MockConsumer { + id: string; + closed = false; + + constructor(id: string) { + this.id = id; + } + + close() { + this.closed = true; + } +} + +// Mock WebSocket +class MockWebSocket { + sentMessages: string[] = []; + + send(data: string) { + this.sentMessages.push(data); + } +} + +describe("SFUClientManager", () => { + let clientManager: SFUClientManager; + + beforeEach(() => { + clientManager = new SFUClientManager(); + }); + + test("should add and retrieve clients", () => { + const mockWs = new MockWebSocket(); + const client: ISFUClient = { + id: "client-1", + role: "publisher", + ws: mockWs, + consumers: new Map() + }; + + clientManager.addClient(client); + + const retrievedClient = clientManager.getClient("client-1"); + expect(retrievedClient).toBe(client); + }); + + test("should set and manage client transport", () => { + const mockWs = new MockWebSocket(); + const client: ISFUClient = { + id: "client-1", + role: "publisher", + ws: mockWs, + consumers: new Map() + }; + + clientManager.addClient(client); + + const transport = new MockWebRtcTransport("transport-1") as any; + clientManager.setClientTransport("client-1", transport); + + const retrievedClient = clientManager.getClient("client-1"); + expect(retrievedClient?.transport).toBe(transport); + }); + + test("should manage client producer", () => { + const mockWs = new MockWebSocket(); + const client: ISFUClient = { + id: "publisher-1", + role: "publisher", + ws: mockWs, + consumers: new Map() + }; + + clientManager.addClient(client); + + const producer = new MockProducer("producer-1") as any; + clientManager.setClientProducer("publisher-1", producer); + + const retrievedClient = clientManager.getClient("publisher-1"); + expect(retrievedClient?.producer).toBe(producer); + + const retrievedProducer = clientManager.getProducer("producer-1"); + expect(retrievedProducer).toBe(producer); + }); + + test("should manage client consumers", () => { + const mockWs = new MockWebSocket(); + const client: ISFUClient = { + id: "subscriber-1", + role: "subscriber", + ws: mockWs, + consumers: new Map() + }; + + clientManager.addClient(client); + + const consumer = new MockConsumer("consumer-1") as any; + clientManager.addClientConsumer("subscriber-1", consumer); + + const retrievedClient = clientManager.getClient("subscriber-1"); + expect(retrievedClient?.consumers.has("consumer-1")).toBe(true); + expect(retrievedClient?.consumers.get("consumer-1")).toBe(consumer); + + const retrievedConsumer = clientManager.getConsumer("consumer-1"); + expect(retrievedConsumer).toBe(consumer); + }); + + test("should remove client consumer", () => { + const mockWs = new MockWebSocket(); + const client: ISFUClient = { + id: "subscriber-1", + role: "subscriber", + ws: mockWs, + consumers: new Map() + }; + + clientManager.addClient(client); + + const consumer = new MockConsumer("consumer-1") as any; + clientManager.addClientConsumer("subscriber-1", consumer); + + // Verify consumer is added + expect(clientManager.getConsumer("consumer-1")).toBe(consumer); + + // Remove consumer + clientManager.removeClientConsumer("subscriber-1", "consumer-1"); + + // Verify consumer is removed and closed + expect(clientManager.getConsumer("consumer-1")).toBeUndefined(); + expect(consumer.closed).toBe(true); + + const retrievedClient = clientManager.getClient("subscriber-1"); + expect(retrievedClient?.consumers.has("consumer-1")).toBe(false); + }); + + test("should get publishers and subscribers separately", () => { + const publisherWs = new MockWebSocket(); + const subscriberWs1 = new MockWebSocket(); + const subscriberWs2 = new MockWebSocket(); + + const publisher: ISFUClient = { + id: "publisher-1", + role: "publisher", + ws: publisherWs, + consumers: new Map(), + producer: new MockProducer("producer-1") as any + }; + + const subscriber1: ISFUClient = { + id: "subscriber-1", + role: "subscriber", + ws: subscriberWs1, + consumers: new Map() + }; + + const subscriber2: ISFUClient = { + id: "subscriber-2", + role: "subscriber", + ws: subscriberWs2, + consumers: new Map() + }; + + clientManager.addClient(publisher); + clientManager.addClient(subscriber1); + clientManager.addClient(subscriber2); + + const publishers = clientManager.getPublishers(); + const subscribers = clientManager.getSubscribers(); + + expect(publishers).toHaveLength(1); + expect(publishers[0]).toBe(publisher); + + expect(subscribers).toHaveLength(2); + expect(subscribers).toContain(subscriber1); + expect(subscribers).toContain(subscriber2); + }); + + test("should get all producers", () => { + const publisherWs1 = new MockWebSocket(); + const publisherWs2 = new MockWebSocket(); + + const publisher1: ISFUClient = { + id: "publisher-1", + role: "publisher", + ws: publisherWs1, + consumers: new Map() + }; + + const publisher2: ISFUClient = { + id: "publisher-2", + role: "publisher", + ws: publisherWs2, + consumers: new Map() + }; + + clientManager.addClient(publisher1); + clientManager.addClient(publisher2); + + const producer1 = new MockProducer("producer-1") as any; + const producer2 = new MockProducer("producer-2") as any; + + clientManager.setClientProducer("publisher-1", producer1); + clientManager.setClientProducer("publisher-2", producer2); + + const allProducers = clientManager.getAllProducers(); + expect(allProducers).toHaveLength(2); + expect(allProducers).toContain(producer1); + expect(allProducers).toContain(producer2); + }); + + test("should remove client and cleanup resources", () => { + const mockWs = new MockWebSocket(); + const client: ISFUClient = { + id: "client-1", + role: "publisher", + ws: mockWs, + consumers: new Map() + }; + + clientManager.addClient(client); + + // Add transport, producer, and consumer + const transport = new MockWebRtcTransport("transport-1") as any; + const producer = new MockProducer("producer-1") as any; + const consumer = new MockConsumer("consumer-1") as any; + + clientManager.setClientTransport("client-1", transport); + clientManager.setClientProducer("client-1", producer); + clientManager.addClientConsumer("client-1", consumer); + + // Remove client + clientManager.removeClient("client-1"); + + // Verify client is removed + expect(clientManager.getClient("client-1")).toBeUndefined(); + + // Verify resources are cleaned up + expect(transport.closed).toBe(true); + expect(producer.closed).toBe(true); + expect(consumer.closed).toBe(true); + + // Verify global maps are cleaned up + expect(clientManager.getProducer("producer-1")).toBeUndefined(); + expect(clientManager.getConsumer("consumer-1")).toBeUndefined(); + }); + + test("should provide correct statistics", () => { + const publisherWs = new MockWebSocket(); + const subscriberWs1 = new MockWebSocket(); + const subscriberWs2 = new MockWebSocket(); + + const publisher: ISFUClient = { + id: "publisher-1", + role: "publisher", + ws: publisherWs, + consumers: new Map(), + producer: new MockProducer("producer-1") as any + }; + + const subscriber1: ISFUClient = { + id: "subscriber-1", + role: "subscriber", + ws: subscriberWs1, + consumers: new Map() + }; + + const subscriber2: ISFUClient = { + id: "subscriber-2", + role: "subscriber", + ws: subscriberWs2, + consumers: new Map() + }; + + clientManager.addClient(publisher); + clientManager.addClient(subscriber1); + clientManager.addClient(subscriber2); + + const producer = new MockProducer("producer-1") as any; + const consumer1 = new MockConsumer("consumer-1") as any; + const consumer2 = new MockConsumer("consumer-2") as any; + + clientManager.setClientProducer("publisher-1", producer); + clientManager.addClientConsumer("subscriber-1", consumer1); + clientManager.addClientConsumer("subscriber-2", consumer2); + + const stats = clientManager.getStats(); + + expect(stats.clients).toBe(3); + expect(stats.publishers).toBe(1); + expect(stats.subscribers).toBe(2); + expect(stats.producers).toBe(1); + expect(stats.consumers).toBe(2); + }); + + test("should handle removing non-existent client gracefully", () => { + expect(() => { + clientManager.removeClient("non-existent"); + }).not.toThrow(); + }); + + test("should handle operations on non-existent clients gracefully", () => { + const transport = new MockWebRtcTransport("transport-1") as any; + const producer = new MockProducer("producer-1") as any; + const consumer = new MockConsumer("consumer-1") as any; + + expect(() => { + clientManager.setClientTransport("non-existent", transport); + clientManager.setClientProducer("non-existent", producer); + clientManager.addClientConsumer("non-existent", consumer); + clientManager.removeClientConsumer("non-existent", "consumer-1"); + }).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/tests/sfu/SFUDemoIntegration.test.ts b/tests/sfu/SFUDemoIntegration.test.ts new file mode 100644 index 0000000..250bb76 --- /dev/null +++ b/tests/sfu/SFUDemoIntegration.test.ts @@ -0,0 +1,335 @@ +import { test, expect, describe } from "bun:test"; + +// Mock WebSocket for testing SFU Demo Server functionality +class MockWebSocket { + public readyState = WebSocket.OPEN; + public url: string; + public sentMessages: string[] = []; + + public onopen: (() => void) | null = null; + public onclose: (() => void) | null = null; + public onmessage: ((event: { data: string }) => void) | null = null; + public onerror: ((error: any) => void) | null = null; + + constructor(url: string) { + this.url = url; + setTimeout(() => { + if (this.onopen) { + this.onopen(); + } + }, 10); + } + + send(data: string) { + if (this.readyState === WebSocket.OPEN) { + this.sentMessages.push(data); + } + } + + close() { + this.readyState = WebSocket.CLOSED; + if (this.onclose) { + this.onclose(); + } + } + + simulateMessage(data: any) { + if (this.onmessage) { + this.onmessage({ data: JSON.stringify(data) }); + } + } + + getLastMessage() { + if (this.sentMessages.length === 0) return null; + return JSON.parse(this.sentMessages[this.sentMessages.length - 1]); + } + + getAllMessages() { + return this.sentMessages.map(msg => JSON.parse(msg)); + } +} + +describe("SFU Demo Server Integration", () => { + test("should simulate SFU publisher connection flow", () => { + const publisherWs = new MockWebSocket("ws://localhost:3001?role=publisher"); + + // Simulate server responses for publisher flow + const messages: any[] = []; + + publisherWs.onmessage = (event) => { + const message = JSON.parse(event.data); + messages.push(message); + + // Simulate server responses + if (message.type === 'join') { + expect(message.data.role).toBe('publisher'); + expect(typeof message.data.clientId).toBe('string'); + } + }; + + // Simulate join message from server + publisherWs.simulateMessage({ + type: 'join', + data: { clientId: 'publisher-123', role: 'publisher' } + }); + + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe('join'); + expect(messages[0].data.role).toBe('publisher'); + }); + + test("should simulate SFU subscriber connection flow", () => { + const subscriberWs = new MockWebSocket("ws://localhost:3001?role=subscriber"); + + const messages: any[] = []; + + subscriberWs.onmessage = (event) => { + const message = JSON.parse(event.data); + messages.push(message); + + if (message.type === 'join') { + expect(message.data.role).toBe('subscriber'); + expect(typeof message.data.clientId).toBe('string'); + } + }; + + // Simulate join message from server + subscriberWs.simulateMessage({ + type: 'join', + data: { clientId: 'subscriber-123', role: 'subscriber' } + }); + + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe('join'); + expect(messages[0].data.role).toBe('subscriber'); + }); + + test("should simulate SFU signaling message exchange", () => { + const publisherWs = new MockWebSocket("ws://localhost:3001?role=publisher"); + const subscriberWs = new MockWebSocket("ws://localhost:3001?role=subscriber"); + + const publisherMessages: any[] = []; + const subscriberMessages: any[] = []; + + publisherWs.onmessage = (event) => { + publisherMessages.push(JSON.parse(event.data)); + }; + + subscriberWs.onmessage = (event) => { + subscriberMessages.push(JSON.parse(event.data)); + }; + + // Simulate SFU RTC capabilities exchange + publisherWs.simulateMessage({ + type: 'routerRtpCapabilities', + data: { + rtpCapabilities: { + codecs: [ + { mimeType: 'video/VP8', clockRate: 90000 }, + { mimeType: 'audio/opus', clockRate: 48000 } + ] + } + } + }); + + // Simulate transport creation + publisherWs.simulateMessage({ + type: 'webRtcTransportCreated', + data: { + id: 'transport-123', + iceParameters: { usernameFragment: 'test' }, + iceCandidates: [], + dtlsParameters: { fingerprints: [] } + } + }); + + // Simulate producer created + publisherWs.simulateMessage({ + type: 'produced', + data: { producerId: 'producer-123' } + }); + + // Notify subscriber of new producer + subscriberWs.simulateMessage({ + type: 'newProducer', + data: { producerId: 'producer-123' } + }); + + // Simulate consumer created for subscriber + subscriberWs.simulateMessage({ + type: 'consumed', + data: { + consumerId: 'consumer-123', + producerId: 'producer-123', + kind: 'video', + rtpParameters: {} + } + }); + + // Verify message flow + expect(publisherMessages).toHaveLength(3); + expect(publisherMessages[0].type).toBe('routerRtpCapabilities'); + expect(publisherMessages[1].type).toBe('webRtcTransportCreated'); + expect(publisherMessages[2].type).toBe('produced'); + + expect(subscriberMessages).toHaveLength(2); + expect(subscriberMessages[0].type).toBe('newProducer'); + expect(subscriberMessages[1].type).toBe('consumed'); + expect(subscriberMessages[1].data.producerId).toBe('producer-123'); + }); + + test("should simulate SFU scaling advantages", () => { + // Simulate 1 publisher and multiple subscribers + const publisher = new MockWebSocket("ws://localhost:3001?role=publisher"); + const subscribers = [ + new MockWebSocket("ws://localhost:3001?role=subscriber"), + new MockWebSocket("ws://localhost:3001?role=subscriber"), + new MockWebSocket("ws://localhost:3001?role=subscriber"), + new MockWebSocket("ws://localhost:3001?role=subscriber"), + new MockWebSocket("ws://localhost:3001?role=subscriber") + ]; + + // In mesh architecture, publisher would need 5 connections + // In SFU architecture, publisher needs only 1 connection to server + + const publisherConnections = 1; // SFU: constant regardless of subscribers + const meshConnections = subscribers.length; // Mesh: scales with subscribers + + const bandwidthSaved = (meshConnections - publisherConnections) / meshConnections; + + expect(publisherConnections).toBe(1); + expect(meshConnections).toBe(5); + expect(bandwidthSaved).toBe(0.8); // 80% bandwidth saved for publisher + }); + + test("should simulate SFU adaptive bitrate", () => { + const subscribers = [ + { quality: 'low', expectedBitrate: 800 }, + { quality: 'medium', expectedBitrate: 1500 }, + { quality: 'high', expectedBitrate: 2500 } + ]; + + // SFU can adapt bitrate per subscriber based on their capabilities + subscribers.forEach(subscriber => { + const ws = new MockWebSocket(`ws://localhost:3001?role=subscriber&quality=${subscriber.quality}`); + const messages: any[] = []; + + ws.onmessage = (event) => { + messages.push(JSON.parse(event.data)); + }; + + ws.simulateMessage({ + type: 'consumed', + data: { + consumerId: 'consumer-' + subscriber.quality, + bitrate: subscriber.expectedBitrate, + quality: subscriber.quality + } + }); + + expect(messages).toHaveLength(1); + expect(messages[0].data.quality).toBe(subscriber.quality); + expect(messages[0].data.bitrate).toBe(subscriber.expectedBitrate); + }); + }); + + test("should simulate SFU error handling", () => { + const client = new MockWebSocket("ws://localhost:3001?role=subscriber"); + const messages: any[] = []; + + client.onmessage = (event) => { + messages.push(JSON.parse(event.data)); + }; + + // Simulate server error + client.simulateMessage({ + type: 'error', + data: { message: 'Producer not found' } + }); + + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe('error'); + expect(messages[0].data.message).toContain('Producer not found'); + }); + + test("should simulate SFU statistics tracking", () => { + // Mock server statistics + const stats = { + totalClients: 6, + publishers: 1, + subscribers: 5, + streamsForwarded: 1, + totalBandwidth: 8500, // 1 publisher (2500) + 5 subscribers (1200 each) + uptime: 120 + }; + + // Verify SFU efficiency metrics + expect(stats.publishers).toBe(1); + expect(stats.subscribers).toBe(5); + expect(stats.streamsForwarded).toBe(1); // Server handles 1 stream, forwards to 5 + + // Calculate bandwidth efficiency + const publisherBandwidth = 2500; + const meshTotalBandwidth = publisherBandwidth * stats.subscribers; // 12,500 + const sfuTotalBandwidth = stats.totalBandwidth; // 8,500 + const bandwidthSaved = (meshTotalBandwidth - sfuTotalBandwidth) / meshTotalBandwidth; + + expect(bandwidthSaved).toBeGreaterThan(0.3); // At least 30% bandwidth saved + }); + + test("should simulate SFU reconnection handling", () => { + const client = new MockWebSocket("ws://localhost:3001?role=publisher"); + let reconnectAttempted = false; + + client.onclose = () => { + reconnectAttempted = true; + }; + + // Simulate connection loss + client.close(); + + expect(reconnectAttempted).toBe(true); + expect(client.readyState).toBe(WebSocket.CLOSED); + + // In a real SFU implementation, the client would reconnect + // and the server would maintain stream state + const reconnectedClient = new MockWebSocket("ws://localhost:3001?role=publisher"); + expect(reconnectedClient.readyState).toBe(WebSocket.OPEN); + }); + + test("should validate SFU message types", () => { + const validSFUMessageTypes = [ + 'join', + 'routerRtpCapabilities', + 'webRtcTransportCreated', + 'webRtcTransportConnected', + 'produced', + 'consumed', + 'resumed', + 'paused', + 'newProducer', + 'producers', + 'error' + ]; + + validSFUMessageTypes.forEach(messageType => { + expect(typeof messageType).toBe('string'); + expect(messageType.length).toBeGreaterThan(0); + }); + + // Test message structure + const sampleMessage = { + type: 'consumed', + data: { + consumerId: 'consumer-123', + producerId: 'producer-123', + kind: 'video' + } + }; + + expect(sampleMessage.type).toBe('consumed'); + expect(sampleMessage.data.consumerId).toBe('consumer-123'); + expect(sampleMessage.data.producerId).toBe('producer-123'); + expect(sampleMessage.data.kind).toBe('video'); + }); +}); \ No newline at end of file diff --git a/tests/sfu/SFUSignalingService.test.ts b/tests/sfu/SFUSignalingService.test.ts new file mode 100644 index 0000000..1a955de --- /dev/null +++ b/tests/sfu/SFUSignalingService.test.ts @@ -0,0 +1,441 @@ +import { test, expect, describe, beforeEach } from "bun:test"; +import { SFUSignalingService } from "../../src/services/SFUSignalingService.ts"; +import { SFUClientManager } from "../../src/services/SFUClientManager.ts"; +import type { ISFUSignalingMessage } from "../../src/interfaces/ISFUTypes.ts"; + +// Mock MediaServerManager +class MockMediaServerManager { + private mockRtpCapabilities = { + codecs: [ + { mimeType: 'video/VP8', clockRate: 90000 }, + { mimeType: 'audio/opus', clockRate: 48000 } + ] + }; + + private mockTransport = { + id: 'transport-123', + iceParameters: { usernameFragment: 'test', password: 'test' }, + iceCandidates: [], + dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'test' }] }, + connect: () => Promise.resolve(), + produce: () => Promise.resolve({ id: 'producer-123', kind: 'video' }), + consume: () => Promise.resolve({ id: 'consumer-123', kind: 'video', rtpParameters: {} }), + close: () => {} + }; + + private mockRouter = { + canConsume: () => true + }; + + getRtpCapabilities() { + return this.mockRtpCapabilities; + } + + async createWebRtcTransport() { + return this.mockTransport; + } + + getRouter() { + return this.mockRouter; + } + + getWorker() { + return { pid: 12345 }; + } +} + +// Mock WebSocket +class MockWebSocket { + sentMessages: string[] = []; + + send(data: string) { + this.sentMessages.push(data); + } + + getLastMessage() { + return this.sentMessages.length > 0 ? + JSON.parse(this.sentMessages[this.sentMessages.length - 1]) : null; + } + + getAllMessages() { + return this.sentMessages.map(msg => JSON.parse(msg)); + } +} + +describe("SFUSignalingService", () => { + let signalingService: SFUSignalingService; + let clientManager: SFUClientManager; + let mediaServer: MockMediaServerManager; + + beforeEach(() => { + clientManager = new SFUClientManager(); + mediaServer = new MockMediaServerManager(); + signalingService = new SFUSignalingService(mediaServer as any, clientManager); + }); + + test("should handle client connections", () => { + const mockWs = new MockWebSocket(); + + const clientId = signalingService.handleConnection(mockWs, "publisher"); + + expect(typeof clientId).toBe("string"); + expect(clientId.length).toBeGreaterThan(0); + + const client = clientManager.getClient(clientId); + expect(client).toBeTruthy(); + expect(client?.role).toBe("publisher"); + expect(client?.ws).toBe(mockWs); + + const lastMessage = mockWs.getLastMessage(); + expect(lastMessage.type).toBe("join"); + expect(lastMessage.data.clientId).toBe(clientId); + expect(lastMessage.data.role).toBe("publisher"); + }); + + test("should handle client disconnection", () => { + const mockWs = new MockWebSocket(); + + const clientId = signalingService.handleConnection(mockWs, "subscriber"); + expect(clientManager.getClient(clientId)).toBeTruthy(); + + signalingService.handleDisconnection(clientId); + expect(clientManager.getClient(clientId)).toBeUndefined(); + }); + + test("should handle getRouterRtpCapabilities message", async () => { + const mockWs = new MockWebSocket(); + const clientId = signalingService.handleConnection(mockWs, "publisher"); + + const message: ISFUSignalingMessage = { + type: "getRouterRtpCapabilities" + }; + + await signalingService.handleMessage(clientId, message); + + const lastMessage = mockWs.getLastMessage(); + expect(lastMessage.type).toBe("routerRtpCapabilities"); + expect(lastMessage.data.rtpCapabilities).toBeTruthy(); + expect(lastMessage.data.rtpCapabilities.codecs).toHaveLength(2); + }); + + test("should handle createWebRtcTransport message", async () => { + const mockWs = new MockWebSocket(); + const clientId = signalingService.handleConnection(mockWs, "publisher"); + + const message: ISFUSignalingMessage = { + type: "createWebRtcTransport" + }; + + await signalingService.handleMessage(clientId, message); + + const lastMessage = mockWs.getLastMessage(); + expect(lastMessage.type).toBe("webRtcTransportCreated"); + expect(lastMessage.data.id).toBe("transport-123"); + expect(lastMessage.data.iceParameters).toBeTruthy(); + expect(lastMessage.data.dtlsParameters).toBeTruthy(); + + // Verify transport was set on client + const client = clientManager.getClient(clientId); + expect(client?.transport).toBeTruthy(); + }); + + test("should handle connectWebRtcTransport message", async () => { + const mockWs = new MockWebSocket(); + const clientId = signalingService.handleConnection(mockWs, "publisher"); + + // First create transport + await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" }); + + // Then connect it + const connectMessage: ISFUSignalingMessage = { + type: "connectWebRtcTransport", + data: { + dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'test' }] } + } + }; + + await signalingService.handleMessage(clientId, connectMessage); + + const lastMessage = mockWs.getLastMessage(); + expect(lastMessage.type).toBe("webRtcTransportConnected"); + }); + + test("should handle produce message for publisher", async () => { + const mockWs = new MockWebSocket(); + const clientId = signalingService.handleConnection(mockWs, "publisher"); + + // Create transport first + await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" }); + + // Mock producer creation + const mockProducer = { id: 'producer-123', kind: 'video' }; + const client = clientManager.getClient(clientId); + if (client?.transport) { + (client.transport as any).produce = () => Promise.resolve(mockProducer); + } + + const produceMessage: ISFUSignalingMessage = { + type: "produce", + data: { + kind: "video", + rtpParameters: { codecs: [], encodings: [] } + } + }; + + await signalingService.handleMessage(clientId, produceMessage); + + const messages = mockWs.getAllMessages(); + const producedMessage = messages.find(msg => msg.type === 'produced'); + expect(producedMessage).toBeTruthy(); + expect(producedMessage.data.producerId).toBe('producer-123'); + }); + + test("should reject produce message for subscriber", async () => { + const mockWs = new MockWebSocket(); + const clientId = signalingService.handleConnection(mockWs, "subscriber"); + + await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" }); + + const produceMessage: ISFUSignalingMessage = { + type: "produce", + data: { + kind: "video", + rtpParameters: { codecs: [], encodings: [] } + } + }; + + await signalingService.handleMessage(clientId, produceMessage); + + const lastMessage = mockWs.getLastMessage(); + expect(lastMessage.type).toBe("error"); + expect(lastMessage.data.message).toContain("Only publishers can produce"); + }); + + test("should handle consume message for subscriber", async () => { + // First create a publisher with a producer + const publisherWs = new MockWebSocket(); + const publisherId = signalingService.handleConnection(publisherWs, "publisher"); + await signalingService.handleMessage(publisherId, { type: "createWebRtcTransport" }); + + // Mock producer + const mockProducer = { id: 'producer-123', kind: 'video' }; + clientManager.setClientProducer(publisherId, mockProducer as any); + + // Now create subscriber + const subscriberWs = new MockWebSocket(); + const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber"); + await signalingService.handleMessage(subscriberId, { type: "createWebRtcTransport" }); + + // Mock consumer creation + const mockConsumer = { + id: 'consumer-123', + kind: 'video', + rtpParameters: { codecs: [], encodings: [] } + }; + const subscriberClient = clientManager.getClient(subscriberId); + if (subscriberClient?.transport) { + (subscriberClient.transport as any).consume = () => Promise.resolve(mockConsumer); + } + + const consumeMessage: ISFUSignalingMessage = { + type: "consume", + data: { + producerId: "producer-123", + rtpCapabilities: { codecs: [] } + } + }; + + await signalingService.handleMessage(subscriberId, consumeMessage); + + const lastMessage = subscriberWs.getLastMessage(); + expect(lastMessage.type).toBe("consumed"); + expect(lastMessage.data.consumerId).toBe('consumer-123'); + expect(lastMessage.data.producerId).toBe('producer-123'); + }); + + test("should reject consume message for publisher", async () => { + const mockWs = new MockWebSocket(); + const clientId = signalingService.handleConnection(mockWs, "publisher"); + + await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" }); + + const consumeMessage: ISFUSignalingMessage = { + type: "consume", + data: { + producerId: "producer-123", + rtpCapabilities: { codecs: [] } + } + }; + + await signalingService.handleMessage(clientId, consumeMessage); + + const lastMessage = mockWs.getLastMessage(); + expect(lastMessage.type).toBe("error"); + expect(lastMessage.data.message).toContain("Only subscribers can consume"); + }); + + test("should handle resume message", async () => { + const mockWs = new MockWebSocket(); + const clientId = signalingService.handleConnection(mockWs, "subscriber"); + + // Mock consumer + const mockConsumer = { + id: 'consumer-123', + resume: () => Promise.resolve() + }; + clientManager.addClientConsumer(clientId, mockConsumer as any); + + const resumeMessage: ISFUSignalingMessage = { + type: "resume", + data: { consumerId: "consumer-123" } + }; + + await signalingService.handleMessage(clientId, resumeMessage); + + const lastMessage = mockWs.getLastMessage(); + expect(lastMessage.type).toBe("resumed"); + expect(lastMessage.data.consumerId).toBe("consumer-123"); + }); + + test("should handle pause message", async () => { + const mockWs = new MockWebSocket(); + const clientId = signalingService.handleConnection(mockWs, "subscriber"); + + // Mock consumer + const mockConsumer = { + id: 'consumer-123', + pause: () => Promise.resolve() + }; + clientManager.addClientConsumer(clientId, mockConsumer as any); + + const pauseMessage: ISFUSignalingMessage = { + type: "pause", + data: { consumerId: "consumer-123" } + }; + + await signalingService.handleMessage(clientId, pauseMessage); + + const lastMessage = mockWs.getLastMessage(); + expect(lastMessage.type).toBe("paused"); + expect(lastMessage.data.consumerId).toBe("consumer-123"); + }); + + test("should handle getProducers message", async () => { + // Create publisher with producer + const publisherWs = new MockWebSocket(); + const publisherId = signalingService.handleConnection(publisherWs, "publisher"); + + const mockProducer = { id: 'producer-123', kind: 'video' }; + clientManager.setClientProducer(publisherId, mockProducer as any); + + // Create subscriber requesting producers + const subscriberWs = new MockWebSocket(); + const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber"); + + const getProducersMessage: ISFUSignalingMessage = { + type: "getProducers" + }; + + await signalingService.handleMessage(subscriberId, getProducersMessage); + + const lastMessage = subscriberWs.getLastMessage(); + expect(lastMessage.type).toBe("producers"); + expect(lastMessage.data.producers).toHaveLength(1); + expect(lastMessage.data.producers[0].id).toBe('producer-123'); + expect(lastMessage.data.producers[0].kind).toBe('video'); + }); + + test("should notify subscribers when new producer joins", async () => { + // Create subscribers first + const subscriber1Ws = new MockWebSocket(); + const subscriber1Id = signalingService.handleConnection(subscriber1Ws, "subscriber"); + + const subscriber2Ws = new MockWebSocket(); + const subscriber2Id = signalingService.handleConnection(subscriber2Ws, "subscriber"); + + // Clear initial join messages + subscriber1Ws.sentMessages = []; + subscriber2Ws.sentMessages = []; + + // Create publisher and produce + const publisherWs = new MockWebSocket(); + const publisherId = signalingService.handleConnection(publisherWs, "publisher"); + await signalingService.handleMessage(publisherId, { type: "createWebRtcTransport" }); + + // Mock producer creation and produce + const mockProducer = { id: 'producer-123', kind: 'video' }; + const publisherClient = clientManager.getClient(publisherId); + if (publisherClient?.transport) { + (publisherClient.transport as any).produce = () => Promise.resolve(mockProducer); + } + + await signalingService.handleMessage(publisherId, { + type: "produce", + data: { kind: "video", rtpParameters: { codecs: [], encodings: [] } } + }); + + // Check that subscribers were notified + const sub1LastMessage = subscriber1Ws.getLastMessage(); + const sub2LastMessage = subscriber2Ws.getLastMessage(); + + expect(sub1LastMessage?.type).toBe("newProducer"); + expect(sub1LastMessage?.data.producerId).toBe('producer-123'); + + expect(sub2LastMessage?.type).toBe("newProducer"); + expect(sub2LastMessage?.data.producerId).toBe('producer-123'); + }); + + test("should handle messages from unknown clients gracefully", async () => { + const message: ISFUSignalingMessage = { + type: "getRouterRtpCapabilities" + }; + + expect(async () => { + await signalingService.handleMessage("unknown-client-id", message); + }).not.toThrow(); + }); + + test("should handle unknown message types", async () => { + const mockWs = new MockWebSocket(); + const clientId = signalingService.handleConnection(mockWs, "publisher"); + + const unknownMessage: any = { + type: "unknownMessageType" + }; + + await signalingService.handleMessage(clientId, unknownMessage); + + // Should not crash, just log a warning + expect(true).toBe(true); // Test passes if no exception thrown + }); + + test("should provide service statistics", () => { + // Create some clients + const publisher1Ws = new MockWebSocket(); + const publisher1Id = signalingService.handleConnection(publisher1Ws, "publisher"); + + const subscriber1Ws = new MockWebSocket(); + const subscriber1Id = signalingService.handleConnection(subscriber1Ws, "subscriber"); + + const subscriber2Ws = new MockWebSocket(); + const subscriber2Id = signalingService.handleConnection(subscriber2Ws, "subscriber"); + + // Add some producers and consumers + const mockProducer = { id: 'producer-123', kind: 'video' }; + const mockConsumer1 = { id: 'consumer-123', kind: 'video' }; + const mockConsumer2 = { id: 'consumer-456', kind: 'video' }; + + clientManager.setClientProducer(publisher1Id, mockProducer as any); + clientManager.addClientConsumer(subscriber1Id, mockConsumer1 as any); + clientManager.addClientConsumer(subscriber2Id, mockConsumer2 as any); + + const stats = signalingService.getStats(); + + expect(stats.clients).toBe(3); + expect(stats.publishers).toBe(1); + expect(stats.subscribers).toBe(2); + expect(stats.producers).toBe(1); + expect(stats.consumers).toBe(2); + expect(stats.mediaServer.workerId).toBe(12345); + }); +}); \ No newline at end of file diff --git a/tests/websocket.test.ts b/tests/websocket.test.ts new file mode 100644 index 0000000..d391018 --- /dev/null +++ b/tests/websocket.test.ts @@ -0,0 +1,153 @@ +import { test, expect, describe, beforeAll, afterAll } from "bun:test"; +import type { ServerWebSocket } from 'bun'; +import { ClientManager } from '../src/services/ClientManager.ts'; +import { SignalingService } from '../src/services/SignalingService.ts'; + +let server: any; +let clientManager: ClientManager; +let signalingService: SignalingService; +const clientSessions = new Map(); + +beforeAll(async () => { + clientManager = new ClientManager(); + signalingService = new SignalingService(clientManager); + + server = Bun.serve({ + port: 0, // Use random available port + fetch(req, server) { + // Handle WebSocket upgrade + const url = new URL(req.url); + const role = url.searchParams.get('role'); + + // Reject invalid roles before upgrade + if (role === 'invalid') { + return new Response('Invalid role', { status: 400 }); + } + + if (server.upgrade(req, { data: { role } })) { + return; // do not return a Response + } + return new Response('Test Server'); + }, + websocket: { + open(ws: ServerWebSocket<{ role?: string }>) { + const role = ws.data?.role; + + if (role === 'invalid') { + ws.close(1002, 'Invalid role parameter'); + return; + } + + const clientRole = (role === 'publisher' || role === 'subscriber') ? role : 'unknown'; + const clientId = signalingService.handleConnection(ws, clientRole); + clientSessions.set(ws, clientId); + + // Send join message immediately for valid roles + if (clientRole !== 'unknown') { + ws.send(JSON.stringify({ + type: 'join', + data: { clientId, role: clientRole } + })); + } + }, + message(ws: ServerWebSocket, message: string | Buffer) { + const clientId = clientSessions.get(ws); + if (!clientId) return; + // Message handling logic would go here + }, + close(ws: ServerWebSocket) { + const clientId = clientSessions.get(ws); + if (clientId) { + signalingService.handleDisconnection(clientId); + clientSessions.delete(ws); + } + } + } + }); + + // Wait a bit for server to start + await new Promise(resolve => setTimeout(resolve, 100)); +}); + +afterAll(() => { + if (server) { + server.stop(); + } +}); + +describe("WebSocket Connection Test", () => { + test("should connect to WebSocket server", async () => { + // This test verifies the WebSocket endpoint is working + const ws = new WebSocket(`ws://localhost:${server.port}?role=publisher`); + + const connectionPromise = new Promise((resolve, reject) => { + ws.onopen = () => resolve("connected"); + ws.onerror = (error) => reject(error); + + // Timeout after 5 seconds + setTimeout(() => reject(new Error("Connection timeout")), 5000); + }); + + const result = await connectionPromise; + expect(result).toBe("connected"); + + // Test sending a message + const messagePromise = new Promise((resolve) => { + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + resolve(message); + }; + }); + + // Should receive a join message + const joinMessage = await messagePromise; + expect(joinMessage.type).toBe("join"); + expect(joinMessage.data.role).toBe("publisher"); + + ws.close(); + }); + + test("should handle subscriber connection", async () => { + const ws = new WebSocket(`ws://localhost:${server.port}?role=subscriber`); + + const connectionPromise = new Promise((resolve, reject) => { + ws.onopen = () => resolve("connected"); + ws.onerror = (error) => reject(error); + setTimeout(() => reject(new Error("Connection timeout")), 5000); + }); + + await connectionPromise; + + const messagePromise = new Promise((resolve) => { + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + resolve(message); + }; + }); + + const joinMessage = await messagePromise; + expect(joinMessage.type).toBe("join"); + expect(joinMessage.data.role).toBe("subscriber"); + + ws.close(); + }); + + test("should reject invalid role", async () => { + const ws = new WebSocket(`ws://localhost:${server.port}?role=invalid`); + + const connectionPromise = new Promise((resolve, reject) => { + ws.onopen = () => reject(new Error("Should not connect")); + ws.onerror = (error) => { + // WebSocket connection should fail due to 400 response + resolve("rejected"); + }; + ws.onclose = (event) => { + resolve("rejected"); + }; + setTimeout(() => reject(new Error("Connection timeout")), 5000); + }); + + const result = await connectionPromise; + expect(result).toBe("rejected"); + }); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b34a885 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}