fixed tests
This commit is contained in:
1
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
Symbolic link
1
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
Symbolic link
@@ -0,0 +1 @@
|
||||
../../CLAUDE.md
|
||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -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
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@techniker-me:registry=https://registry-node.techniker.me
|
||||
111
CLAUDE.md
Normal file
111
CLAUDE.md
Normal file
@@ -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 <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
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 <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||
200
README.md
Normal file
200
README.md
Normal file
@@ -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
|
||||
194
bun.lock
Normal file
194
bun.lock
Normal file
@@ -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=="],
|
||||
}
|
||||
}
|
||||
1060
package-lock.json
generated
Normal file
1060
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
25
public/js/interfaces/IWebRTCClient.ts
Normal file
25
public/js/interfaces/IWebRTCClient.ts
Normal file
@@ -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<void>;
|
||||
disconnect(): void;
|
||||
sendMessage(message: ISignalingMessage): void;
|
||||
isConnected(): boolean;
|
||||
}
|
||||
|
||||
export interface IMediaHandler {
|
||||
getLocalStream(): Promise<MediaStream>;
|
||||
stopLocalStream(): void;
|
||||
getLocalVideo(): HTMLVideoElement | null;
|
||||
}
|
||||
|
||||
export interface IUIController {
|
||||
updateStatus(status: string, className: string): void;
|
||||
updateSubscribersCount(count: number): void;
|
||||
setButtonStates(startEnabled: boolean, stopEnabled: boolean): void;
|
||||
}
|
||||
120
public/js/publisher.ts
Normal file
120
public/js/publisher.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
55
public/js/services/MediaHandler.ts
Normal file
55
public/js/services/MediaHandler.ts
Normal file
@@ -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<MediaStream> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
103
public/js/services/PublisherRTCManager.ts
Normal file
103
public/js/services/PublisherRTCManager.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { ISignalingMessage } from '../interfaces/IWebRTCClient.ts';
|
||||
|
||||
export class PublisherRTCManager {
|
||||
private peerConnections: Map<string, RTCPeerConnection> = 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<RTCSessionDescriptionInit> {
|
||||
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<void> {
|
||||
const peerConnection = this.peerConnections.get(subscriberId);
|
||||
if (peerConnection) {
|
||||
await peerConnection.setRemoteDescription(answer);
|
||||
}
|
||||
}
|
||||
|
||||
async handleIceCandidate(subscriberId: string, candidate: RTCIceCandidateInit): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
98
public/js/services/SubscriberRTCManager.ts
Normal file
98
public/js/services/SubscriberRTCManager.ts
Normal file
@@ -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<RTCSessionDescriptionInit> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
53
public/js/services/UIController.ts
Normal file
53
public/js/services/UIController.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
public/js/services/WebSocketClient.ts
Normal file
129
public/js/services/WebSocketClient.ts
Normal file
@@ -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<WebSocketClientStatus> = new Subject<WebSocketClientStatus>(WebSocketClientStatus.Offline);
|
||||
private readonly _readOnlyStatus: ReadOnlySubject<WebSocketClientStatus> = new ReadOnlySubject<WebSocketClientStatus>(this._status);
|
||||
private ws: WebSocket | null = null;
|
||||
private role: 'publisher' | 'subscriber';
|
||||
private messageHandlers: Map<string, (message: ISignalingMessage) => void> = new Map();
|
||||
|
||||
constructor(role: 'publisher' | 'subscriber') {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
get status(): ReadOnlySubject<WebSocketClientStatus> {
|
||||
return this._readOnlyStatus;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
262
public/js/sfu-publisher-simple.ts
Normal file
262
public/js/sfu-publisher-simple.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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();
|
||||
303
public/js/sfu-publisher.ts
Normal file
303
public/js/sfu-publisher.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
271
public/js/sfu-subscriber-simple.ts
Normal file
271
public/js/sfu-subscriber-simple.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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();
|
||||
153
public/js/subscriber.ts
Normal file
153
public/js/subscriber.ts
Normal file
@@ -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<void> {
|
||||
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();
|
||||
108
public/publisher.html
Normal file
108
public/publisher.html
Normal file
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Publisher - WebRTC Broadcast</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
.start-btn {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
.start-btn:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
.stop-btn {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
.stop-btn:hover {
|
||||
background-color: #da190b;
|
||||
}
|
||||
.stop-btn:disabled, .start-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
video {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: auto;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 5px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.status {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.status.connected {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.status.disconnected {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.subscribers-count {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Publisher Interface</h1>
|
||||
|
||||
<div class="status" id="status">Disconnected</div>
|
||||
|
||||
<div class="subscribers-count" id="subscribersCount">Subscribers: 0</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="startBtn" class="start-btn">Start Broadcasting</button>
|
||||
<button id="stopBtn" class="stop-btn" disabled>Stop Broadcasting</button>
|
||||
</div>
|
||||
|
||||
<video id="localVideo" autoplay muted playsinline></video>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./js/publisher.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
187
public/sfu-publisher.html
Normal file
187
public/sfu-publisher.html
Normal file
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SFU Publisher - WebRTC Broadcast</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.sfu-badge {
|
||||
background: linear-gradient(45deg, #2196F3, #21CBF3);
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
font-style: italic;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.start-btn {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
.start-btn:hover:not(:disabled) {
|
||||
background-color: #45a049;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.stop-btn {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
.stop-btn:hover:not(:disabled) {
|
||||
background-color: #da190b;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.stop-btn:disabled, .start-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
video {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: auto;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.status {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status.connected {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.status.disconnected {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.status.waiting {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #2196F3;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sfu-benefits {
|
||||
background: #e3f2fd;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
border-left: 4px solid #2196F3;
|
||||
}
|
||||
.sfu-benefits h3 {
|
||||
margin-top: 0;
|
||||
color: #1976d2;
|
||||
}
|
||||
.sfu-benefits ul {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.sfu-benefits li {
|
||||
color: #333;
|
||||
margin: 5px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="sfu-badge">SFU ARCHITECTURE</div>
|
||||
<h1>Publisher Interface</h1>
|
||||
<div class="subtitle">Selective Forwarding Unit - Scalable Broadcasting</div>
|
||||
|
||||
<div class="status" id="status">Disconnected</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="subscribersCount">0</div>
|
||||
<div class="stat-label">Subscribers</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="producersCount">0</div>
|
||||
<div class="stat-label">Streams</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="startBtn" class="start-btn">Start Broadcasting</button>
|
||||
<button id="stopBtn" class="stop-btn" disabled>Stop Broadcasting</button>
|
||||
</div>
|
||||
|
||||
<video id="localVideo" autoplay muted playsinline></video>
|
||||
|
||||
<div class="sfu-benefits">
|
||||
<h3>🚀 SFU Benefits</h3>
|
||||
<ul>
|
||||
<li><strong>Scalable:</strong> Your bandwidth stays constant regardless of subscriber count</li>
|
||||
<li><strong>Efficient:</strong> Server handles stream distribution and optimization</li>
|
||||
<li><strong>Reliable:</strong> Server manages connection quality and recovery</li>
|
||||
<li><strong>Adaptive:</strong> Automatic bitrate adjustment for different devices</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./js/sfu-publisher-simple.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
213
public/sfu-subscriber.html
Normal file
213
public/sfu-subscriber.html
Normal file
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SFU Subscriber - WebRTC Broadcast</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.sfu-badge {
|
||||
background: linear-gradient(45deg, #4CAF50, #45a049);
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
font-style: italic;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.connect-btn {
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
.connect-btn:hover:not(:disabled) {
|
||||
background-color: #1976D2;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.disconnect-btn {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
.disconnect-btn:hover:not(:disabled) {
|
||||
background-color: #da190b;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.disconnect-btn:disabled, .connect-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
video {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: auto;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
background-color: #000;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.status {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status.connected {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.status.disconnected {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.status.waiting {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: 360px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: 2px dashed #ccc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
.stream-info {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 20px 0;
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.info-item {
|
||||
text-align: center;
|
||||
}
|
||||
.info-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
}
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sfu-benefits {
|
||||
background: #e8f5e8;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
border-left: 4px solid #4CAF50;
|
||||
}
|
||||
.sfu-benefits h3 {
|
||||
margin-top: 0;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.sfu-benefits ul {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.sfu-benefits li {
|
||||
color: #333;
|
||||
margin: 5px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="sfu-badge">SFU SUBSCRIBER</div>
|
||||
<h1>Subscriber Interface</h1>
|
||||
<div class="subtitle">Optimized Stream Reception via SFU</div>
|
||||
|
||||
<div class="status" id="status">Disconnected</div>
|
||||
|
||||
<div class="stream-info">
|
||||
<div class="info-item">
|
||||
<div class="info-value" id="qualityIndicator">-</div>
|
||||
<div class="info-label">Quality</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-value" id="bitrateIndicator">-</div>
|
||||
<div class="info-label">Bitrate</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-value" id="latencyIndicator">-</div>
|
||||
<div class="info-label">Latency</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="connectBtn" class="connect-btn">Connect to Stream</button>
|
||||
<button id="disconnectBtn" class="disconnect-btn" disabled>Disconnect</button>
|
||||
</div>
|
||||
|
||||
<video id="remoteVideo" autoplay playsinline style="display: none;"></video>
|
||||
<div id="videoPlaceholder" class="video-placeholder">
|
||||
🎥 Waiting for stream...
|
||||
</div>
|
||||
|
||||
<div class="sfu-benefits">
|
||||
<h3>📺 SFU Subscriber Advantages</h3>
|
||||
<ul>
|
||||
<li><strong>Optimized Delivery:</strong> Server selects best stream quality for your connection</li>
|
||||
<li><strong>Adaptive Bitrate:</strong> Automatically adjusts to your bandwidth</li>
|
||||
<li><strong>Lower Latency:</strong> Efficient server-side forwarding</li>
|
||||
<li><strong>Reliable Playback:</strong> Server handles connection recovery</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./js/sfu-subscriber-simple.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
124
public/subscriber.html
Normal file
124
public/subscriber.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Subscriber - WebRTC Broadcast</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
.connect-btn {
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
.connect-btn:hover {
|
||||
background-color: #1976D2;
|
||||
}
|
||||
.disconnect-btn {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
.disconnect-btn:hover {
|
||||
background-color: #da190b;
|
||||
}
|
||||
.disconnect-btn:disabled, .connect-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
video {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: auto;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 5px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
background-color: #000;
|
||||
}
|
||||
.status {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.status.connected {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.status.disconnected {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.status.waiting {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: 360px;
|
||||
background-color: #f0f0f0;
|
||||
border: 2px dashed #ccc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Subscriber Interface</h1>
|
||||
|
||||
<div class="status" id="status">Disconnected</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="connectBtn" class="connect-btn">Connect to Stream</button>
|
||||
<button id="disconnectBtn" class="disconnect-btn" disabled>Disconnect</button>
|
||||
</div>
|
||||
|
||||
<video id="remoteVideo" autoplay playsinline style="display: none;"></video>
|
||||
<div id="videoPlaceholder" class="video-placeholder">
|
||||
Waiting for stream...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./js/subscriber.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
65
server.ts
Normal file
65
server.ts
Normal file
@@ -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<object, string>();
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 3000,
|
||||
routes: {
|
||||
'/': () => new Response(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>WebRTC Broadcasting</h1>
|
||||
<p><a href="/publisher">Publisher Interface</a></p>
|
||||
<p><a href="/subscriber">Subscriber Interface</a></p>
|
||||
</body>
|
||||
</html>
|
||||
`, { headers: { 'Content-Type': 'text/html' } }),
|
||||
'/publisher': publisherHtml,
|
||||
'/subscriber': subscriberHtml,
|
||||
},
|
||||
websocket: {
|
||||
open(ws: ServerWebSocket<unknown>) {
|
||||
// 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<unknown>, 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<unknown>) {
|
||||
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`);
|
||||
106
src/ServerFactory.ts
Normal file
106
src/ServerFactory.ts
Normal file
@@ -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<object, string>();
|
||||
|
||||
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(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>WebRTC Broadcasting</h1>
|
||||
<p><a href="/publisher">Publisher Interface</a></p>
|
||||
<p><a href="/subscriber">Subscriber Interface</a></p>
|
||||
</body>
|
||||
</html>
|
||||
`, { headers: { 'Content-Type': 'text/html' } }),
|
||||
'/publisher': publisherHtml,
|
||||
'/subscriber': subscriberHtml,
|
||||
},
|
||||
websocket: {
|
||||
open(ws: ServerWebSocket<unknown>) {
|
||||
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<unknown>, 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<unknown>) {
|
||||
console.log('WebSocket closed');
|
||||
const clientId = clientSessions.get(ws);
|
||||
if (clientId) {
|
||||
signalingService.handleDisconnection(clientId);
|
||||
clientSessions.delete(ws);
|
||||
}
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true
|
||||
}
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
}
|
||||
88
src/config/mediaServerConfig.ts
Normal file
88
src/config/mediaServerConfig.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
7
src/index.ts
Normal file
7
src/index.ts
Normal file
@@ -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`);
|
||||
81
src/interfaces/ISFUTypes.ts
Normal file
81
src/interfaces/ISFUTypes.ts
Normal file
@@ -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<string, Consumer>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
12
src/interfaces/ISignalingMessage.ts
Normal file
12
src/interfaces/ISignalingMessage.ts
Normal file
@@ -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';
|
||||
}
|
||||
63
src/services/ClientManager.ts
Normal file
63
src/services/ClientManager.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { IWebSocketClient, ISignalingMessage } from '../interfaces/ISignalingMessage.ts';
|
||||
|
||||
export class ClientManager {
|
||||
private clients: Map<string, IWebSocketClient> = 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
97
src/services/MediaServerManager.ts
Normal file
97
src/services/MediaServerManager.ts
Normal file
@@ -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<void> {
|
||||
// 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<void> {
|
||||
if (this.router) {
|
||||
this.router.close();
|
||||
}
|
||||
if (this.worker) {
|
||||
this.worker.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
117
src/services/SFUClientManager.ts
Normal file
117
src/services/SFUClientManager.ts
Normal file
@@ -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<string, ISFUClient> = new Map();
|
||||
private producers: Map<string, Producer> = new Map(); // ProducerId -> Producer
|
||||
private consumers: Map<string, Consumer> = 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
|
||||
};
|
||||
}
|
||||
}
|
||||
285
src/services/SFUSignalingService.ts
Normal file
285
src/services/SFUSignalingService.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
const rtpCapabilities = this.mediaServer.getRtpCapabilities();
|
||||
this.sendMessage(client.id, {
|
||||
type: 'routerRtpCapabilities',
|
||||
data: { rtpCapabilities }
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCreateWebRtcTransport(client: ISFUClient): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
116
src/services/SignalingService.ts
Normal file
116
src/services/SignalingService.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
442
src/sfu-demo-server.ts
Normal file
442
src/sfu-demo-server.ts
Normal file
@@ -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<string, SFUSimClient> = new Map();
|
||||
private clientSessions: Map<any, string> = new Map();
|
||||
private serverStats = {
|
||||
totalClients: 0,
|
||||
publishers: 0,
|
||||
subscribers: 0,
|
||||
streamsForwarded: 0,
|
||||
totalBandwidth: 0
|
||||
};
|
||||
|
||||
async start(): Promise<void> {
|
||||
const server = Bun.serve({
|
||||
port: 3001,
|
||||
routes: {
|
||||
'/': () => new Response(`
|
||||
<html>
|
||||
<head>
|
||||
<title>SFU Demo Server</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; }
|
||||
.hero { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 30px; }
|
||||
.hero h1 { margin: 0 0 10px 0; font-size: 2.5em; }
|
||||
.hero p { margin: 0; font-size: 1.2em; opacity: 0.9; }
|
||||
.links { display: flex; gap: 20px; justify-content: center; margin: 30px 0; }
|
||||
.link { background: #2196F3; color: white; padding: 15px 30px; text-decoration: none; border-radius: 8px; font-weight: bold; transition: all 0.3s; }
|
||||
.link:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(33,150,243,0.3); }
|
||||
.link.stats { background: #4CAF50; }
|
||||
.features { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 30px 0; }
|
||||
.feature { background: #f5f5f5; padding: 20px; border-radius: 8px; border-left: 4px solid #2196F3; }
|
||||
.feature h3 { margin: 0 0 10px 0; color: #1976d2; }
|
||||
.vs { background: #fff3e0; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #ff9800; }
|
||||
.vs h3 { color: #f57c00; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hero">
|
||||
<h1>🚀 SFU Demo Server</h1>
|
||||
<p>Selective Forwarding Unit - Scalable WebRTC Broadcasting</p>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a href="/publisher" class="link">📡 Publisher Interface</a>
|
||||
<a href="/subscriber" class="link">📺 Subscriber Interface</a>
|
||||
<a href="/stats" class="link stats">📊 Live Statistics</a>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<h3>🔄 Stream Forwarding</h3>
|
||||
<p>Server receives one stream from publisher and forwards optimized versions to all subscribers</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>📈 Scalability</h3>
|
||||
<p>Publisher bandwidth stays constant regardless of subscriber count</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>⚡ Adaptive Bitrate</h3>
|
||||
<p>Server automatically adjusts stream quality based on subscriber capabilities</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>🛡️ Reliability</h3>
|
||||
<p>Server handles connection management, recovery, and optimization</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vs">
|
||||
<h3>📊 SFU vs Mesh Comparison</h3>
|
||||
<p><strong>Mesh:</strong> Publisher bandwidth = Subscriber count × Stream bitrate (doesn't scale)</p>
|
||||
<p><strong>SFU:</strong> Publisher bandwidth = 1 × Stream bitrate (scales to thousands)</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, { 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<void> {
|
||||
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<void> {
|
||||
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 = `
|
||||
<html>
|
||||
<head>
|
||||
<title>SFU Demo Statistics</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
h1 { color: #333; text-align: center; margin-bottom: 30px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin-bottom: 30px; }
|
||||
.stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center; }
|
||||
.stat-value { font-size: 2.5em; font-weight: bold; color: #2196F3; margin-bottom: 5px; }
|
||||
.stat-label { color: #666; font-size: 0.9em; text-transform: uppercase; }
|
||||
.clients-table { background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.clients-table table { width: 100%; border-collapse: collapse; }
|
||||
.clients-table th { background: #2196F3; color: white; padding: 12px; text-align: left; }
|
||||
.clients-table td { padding: 12px; border-bottom: 1px solid #eee; }
|
||||
.clients-table tr:hover { background: #f5f5f5; }
|
||||
.refresh { text-align: center; margin: 20px 0; color: #666; }
|
||||
.sfu-advantage { background: #e8f5e8; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #4CAF50; }
|
||||
</style>
|
||||
<meta http-equiv="refresh" content="3">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📊 SFU Demo Server Statistics</h1>
|
||||
<div class="refresh">🔄 Auto-refreshing every 3 seconds</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${this.serverStats.totalClients}</div>
|
||||
<div class="stat-label">Total Clients</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${this.serverStats.publishers}</div>
|
||||
<div class="stat-label">Publishers</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${this.serverStats.subscribers}</div>
|
||||
<div class="stat-label">Subscribers</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${this.serverStats.streamsForwarded}</div>
|
||||
<div class="stat-label">Streams Forwarded</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${Math.round(this.serverStats.totalBandwidth / 1000)}K</div>
|
||||
<div class="stat-label">Total Bandwidth</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${Math.round(uptime)}s</div>
|
||||
<div class="stat-label">Server Uptime</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sfu-advantage">
|
||||
<h3>🚀 SFU Scaling Advantage</h3>
|
||||
<p><strong>Traditional Mesh:</strong> ${this.serverStats.publishers} publishers × ${this.serverStats.subscribers} subscribers = ${this.serverStats.publishers * this.serverStats.subscribers} connections</p>
|
||||
<p><strong>SFU Architecture:</strong> ${this.serverStats.publishers} + ${this.serverStats.subscribers} = ${this.serverStats.publishers + this.serverStats.subscribers} connections</p>
|
||||
<p><strong>Bandwidth Saved:</strong> ${this.serverStats.subscribers > 0 ? Math.round(((this.serverStats.subscribers - 1) / this.serverStats.subscribers) * 100) : 0}% publisher bandwidth reduction</p>
|
||||
</div>
|
||||
|
||||
<div class="clients-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client ID</th>
|
||||
<th>Role</th>
|
||||
<th>Quality</th>
|
||||
<th>Bitrate</th>
|
||||
<th>Connected</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${clients.map(client => `
|
||||
<tr>
|
||||
<td>${client.id.substring(0, 8)}...</td>
|
||||
<td>${client.role}</td>
|
||||
<td>${client.quality || '-'}</td>
|
||||
<td>${client.bitrate ? (client.bitrate / 1000).toFixed(1) + 'K' : '-'}</td>
|
||||
<td>${Math.round((Date.now() - client.connectedAt) / 1000)}s ago</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin-top: 30px;"><a href="/">← Back to Home</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
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);
|
||||
});
|
||||
192
src/sfu-server.ts
Normal file
192
src/sfu-server.ts
Normal file
@@ -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<any, string> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.mediaServer = new MediaServerManager(mediaServerConfig);
|
||||
this.clientManager = new SFUClientManager();
|
||||
this.signalingService = new SFUSignalingService(this.mediaServer, this.clientManager);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Initialize media server
|
||||
await this.mediaServer.initialize();
|
||||
|
||||
// Start HTTP/WebSocket server
|
||||
const server = Bun.serve({
|
||||
port: 3001,
|
||||
routes: {
|
||||
'/': () => new Response(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>WebRTC SFU Broadcasting</h1>
|
||||
<p><strong>SFU Architecture</strong> - Scalable for many subscribers</p>
|
||||
<p><a href="/publisher">Publisher Interface</a></p>
|
||||
<p><a href="/subscriber">Subscriber Interface</a></p>
|
||||
<p><a href="/stats">Server Statistics</a></p>
|
||||
<hr>
|
||||
<h2>Architecture Benefits:</h2>
|
||||
<ul>
|
||||
<li>✅ <strong>Scalable:</strong> Constant publisher bandwidth</li>
|
||||
<li>✅ <strong>Efficient:</strong> Server handles stream forwarding</li>
|
||||
<li>✅ <strong>Reliable:</strong> Server-side bandwidth management</li>
|
||||
<li>✅ <strong>Adaptive:</strong> Multiple bitrate support</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`, { 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<void> {
|
||||
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 = `
|
||||
<html>
|
||||
<head>
|
||||
<title>SFU Server Statistics</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
.stat { background: #f5f5f5; padding: 15px; margin: 10px 0; border-radius: 5px; }
|
||||
.value { font-weight: bold; color: #2196F3; font-size: 1.2em; }
|
||||
h1 { color: #333; }
|
||||
.refresh { margin: 20px 0; }
|
||||
</style>
|
||||
<meta http-equiv="refresh" content="5">
|
||||
</head>
|
||||
<body>
|
||||
<h1>📊 SFU Server Statistics</h1>
|
||||
<div class="refresh">🔄 Auto-refreshing every 5 seconds</div>
|
||||
|
||||
<div class="stat">
|
||||
<strong>Total Clients:</strong> <span class="value">${stats.clients}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<strong>Publishers:</strong> <span class="value">${stats.publishers}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<strong>Subscribers:</strong> <span class="value">${stats.subscribers}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<strong>Active Producers:</strong> <span class="value">${stats.producers}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<strong>Active Consumers:</strong> <span class="value">${stats.consumers}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<strong>MediaSoup Worker PID:</strong> <span class="value">${stats.mediaServer.workerId}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<strong>Router ID:</strong> <span class="value">${stats.mediaServer.routerId}</span>
|
||||
</div>
|
||||
|
||||
<p><a href="/">← Back to Home</a></p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(html, {
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
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);
|
||||
});
|
||||
146
tests/ClientManager.test.ts
Normal file
146
tests/ClientManager.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
176
tests/SignalingService.test.ts
Normal file
176
tests/SignalingService.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
130
tests/basic.test.ts
Normal file
130
tests/basic.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
288
tests/frontend/MediaHandler.test.ts
Normal file
288
tests/frontend/MediaHandler.test.ts
Normal file
@@ -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;
|
||||
});
|
||||
});
|
||||
198
tests/frontend/UIController.test.ts
Normal file
198
tests/frontend/UIController.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
205
tests/frontend/WebSocketClient.test.ts
Normal file
205
tests/frontend/WebSocketClient.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
233
tests/integration.test.ts
Normal file
233
tests/integration.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
344
tests/sfu/SFUClientManager.test.ts
Normal file
344
tests/sfu/SFUClientManager.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
335
tests/sfu/SFUDemoIntegration.test.ts
Normal file
335
tests/sfu/SFUDemoIntegration.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
441
tests/sfu/SFUSignalingService.test.ts
Normal file
441
tests/sfu/SFUSignalingService.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
153
tests/websocket.test.ts
Normal file
153
tests/websocket.test.ts
Normal file
@@ -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<object, string>();
|
||||
|
||||
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<unknown>, message: string | Buffer) {
|
||||
const clientId = clientSessions.get(ws);
|
||||
if (!clientId) return;
|
||||
// Message handling logic would go here
|
||||
},
|
||||
close(ws: ServerWebSocket<unknown>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user