Initial deploy bundle for WebRTC signaling server and multipeer demo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xin Wang
2026-07-19 16:31:41 +08:00
commit 31828ead62
12 changed files with 1248 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
._*
.DS_Store

9
error/404.html Normal file
View File

@@ -0,0 +1,9 @@
<!--
! Excerpted from "Programming WebRTC",
! published by The Pragmatic Bookshelf.
! Copyrights apply to this code. It may not be used to create training material,
! courses, books, articles, and the like. Contact us if you are in doubt.
! We make no guarantees that this code is fit for any purpose.
! Visit https://pragprog.com/titles/ksrtc for more book information.
-->
<pre>404: Not Found</pre>

9
error/500.html Normal file
View File

@@ -0,0 +1,9 @@
<!--
! Excerpted from "Programming WebRTC",
! published by The Pragmatic Bookshelf.
! Copyrights apply to this code. It may not be used to create training material,
! courses, books, articles, and the like. Contact us if you are in doubt.
! We make no guarantees that this code is fit for any purpose.
! Visit https://pragprog.com/titles/ksrtc for more book information.
-->
<pre>500: Server Error</pre>

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "ksrtc-deploy",
"version": "0.6.0",
"private": true,
"description": "Deployment example for Programming WebRTC: Build Real-Time Streaming Applications for the Web. Pragmatic Programmers, 2023.",
"author": "Karl Stolley <karl.stolley@gmail.com> (https://stolley.dev/)",
"homepage": "https://pragprog.com/titles/ksrtc/programming-webrtc/",
"bugs": {
"url": "https://forum.devtalk.com/c/community/pragprog-customers/85?tags=book-programming-webrtc",
"email": "karl.stolley@gmail.com"
},
"license": "UNLICENSED",
"scripts": {
"start": "PUBLIC=www DEBUG=signaling-server: node ./scripts/start-server"
},
"dependencies": {
"express": "^4.18.2",
"http-errors": "~1.6",
"morgan": "~1.9",
"socket.io": "^4.6.1"
}
}

15
scripts/post-receive Normal file
View File

@@ -0,0 +1,15 @@
#! /usr/bin/env zsh
WEB_ROOT=/var/www
APP_DOMAIN=prag.webrtc.gallery
GIT_REPO=$HOME/repos/$APP_DOMAIN.git
TMP_GIT_CLONE=/tmp/$APP_DOMAIN
DEPLOYED_APP=$WEB_ROOT/$APP_DOMAIN/app
git clone $GIT_REPO $TMP_GIT_CLONE
cd $TMP_GIT_CLONE
npm install
rm -rf $DEPLOYED_APP
mv $TMP_GIT_CLONE $DEPLOYED_APP
exit

81
scripts/start-server Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
'use strict';
const {app, io, public_dir} = require('../server');
const debug = require('debug')(process.env.DEBUG || '');
const fs = require('fs');
const ifaces = require('os').networkInterfaces();
/*
Because of proxying, it's safe to use http:// on
the server. No need to load up keys here.
*/
const protocol = 'http';
const server = require(protocol).createServer(app);
/**
* Use the PORT environment variable or default to 3000.
*/
const port = process.env.PORT ?? '3000';
app.set('port', port);
/**
* Attach socket.io to the web server.
*/
io.attach(server);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', handleError);
server.on('listening', handleListening);
/**
* Event listener for HTTP server "error" event.
*/
function handleError(error) {
if (error.syscall !== 'listen') {
throw error;
}
switch (error.code) {
case 'EADDRINUSE':
console.error(`Port ${port} is already being used`);
process.exit(1);
break;
case 'EACCES':
console.error(`Port ${port} requires elevated user privileges (sudo)`);
process.exit(1);
break;
default:
throw error;
}
}
/**
* Callback for server listen event
*/
function handleListening() {
const address = server.address();
// Inspired by https://github.com/http-party/http-server/blob/master/bin/http-server#L163
const interfaces = [];
Object.keys(ifaces).forEach(function(dev) {
ifaces[dev].forEach(function(details) {
// Node v. 18+ returns a number (4, 6) for family;
// earlier versions returned IPv4 or IPv6. This handles
// both cases.
if (details.family.toString().endsWith('4')) {
interfaces.push(`-> ${protocol}://${details.address}:${address.port}/`);
}
});
});
debug(
` ** Serving from the ${public_dir}/ directory. **`
);
}

101
server.js Normal file
View File

@@ -0,0 +1,101 @@
/***
* Excerpted from "Programming WebRTC",
* published by The Pragmatic Bookshelf.
* Copyrights apply to this code. It may not be used to create training material,
* courses, books, articles, and the like. Contact us if you are in doubt.
* We make no guarantees that this code is fit for any purpose.
* Visit https://pragprog.com/titles/ksrtc for more book information.
***/
'use strict';
// Load up necessary modules
const crypto = require('crypto');
// snip, snip...
const createError = require('http-errors');
const express = require('express');
const path = require('path');
const logger = require('morgan');
const io = require('socket.io')();
// Create an Express app
const app = express();
// Set the public directory to serve from
const public_dir = process.env.PUBLIC ?? 'www';
// Log activity to the console
app.use(logger('dev'));
// Serve static files from the `www/` directory
app.use(express.static(path.join(__dirname, public_dir)));
// Catch 404 errors and forward them to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// Handle errors with the error handler
app.use(function(err, req, res, next) {
// Set the error code
res.status(err.status || 500);
// Respond with a static error page (404 or 500)
res.sendFile(`error/${err.status}.html`, { root: __dirname });
});
/**
* The main monkey business:
* Signaling with the socket server, Socket.io
*/
const mp_namespaces = io.of(/^\/[a-z]{4}\-[a-z]{4}\-[a-z]{4}$/);
mp_namespaces.on('connect', function(socket) {
const namespace = socket.nsp;
const expiry_in_hours = 4; // credentials last for four hours
const secret = process.env.TURNSECRET || 'your secret goes here';
const credentials = createCoturnCredentials(expiry_in_hours, secret);
const peers = [];
for (let peer of namespace.sockets.keys()) {
peers.push(peer);
}
console.log(` Socket namespace: ${namespace.name}`);
// Send the array of connected-peer IDs and the TURN credentials
// to the connecting peer
socket.emit('connected peers', { peers, credentials });
// Send the connecting peer ID to all connected peers
socket.broadcast.emit('connected peer', socket.id);
socket.on('signal', function({ recipient, sender, signal }) {
socket.to(recipient).emit('signal', { recipient, sender, signal });
});
socket.on('disconnect', function() {
namespace.emit('disconnected peer', socket.id);
});
});
/**
* End of the main monkey business.
*/
function createCoturnCredentials(expiry_in_hours, secret) {
// JavaScript timestamps are in milliseconds, so divide by 1000
const now = Math.round(Date.now() / 1000);
const expiry = (now + (expiry_in_hours * 60 * 60)).toString();
const hmac = crypto.createHmac('sha1', secret);
hmac.setEncoding('base64');
hmac.write(expiry);
hmac.end();
return {
username: expiry,
password: hmac.read()
};
}
// Export the Express app and Socket.io instances for use in
// /scripts/start-server:
module.exports = {app, io, public_dir};

166
www/css/screen.css Normal file
View File

@@ -0,0 +1,166 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
/* stylelint-disable */
a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,html,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video{margin:0;padding:0;border:0;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section{display:block}.lint,html{line-height:1}.lint,body{line-height:inherit}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:after,blockquote:before,q:after,q:before{content:'';content:none}table{border-collapse:collapse;border-spacing:0}
/* stylelint-enable */
/* Begin site styles below */
html {
font-family: "Lucida Grande", Arial, sans-serif;
font-size: 18px;
font-weight: bold;
line-height: 22px;
}
/* Layout */
* {
box-sizing: border-box;
}
#interface {
height: 100vh;
padding: 22px;
display: grid;
grid-gap: 11px;
grid-template-rows: auto 1fr auto;
}
#header > h1 {
margin-bottom: 11px;
}
#footer {
display: flex;
column-gap: 11px;
}
.preserve-access {
position: absolute;
left: -20000px;
}
*[aria-hidden="true"] {
visibility: hidden;
}
/* Forms and Controls */
button,
input {
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
cursor: pointer;
/* Box Styles */
display: block;
border: 0;
border-radius: 3px;
padding: 11px;
}
#call-button {
width: 143px; /* 6.5 typographic grid lines */
margin-right: 11px;
}
button[aria-checked="false"] {
text-decoration: line-through;
color: white;
background: red;
}
button:disabled {
cursor: not-allowed;
text-decoration: unset;
color: revert;
background: revert;
}
.join {
background-color: green;
color: white;
}
.leave {
background-color: #CA0;
color: black;
}
/* Video Elements */
#videos {
align-self: start;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 11px;
}
video {
background-color: #DDD;
display: block;
max-width: 100%;
}
#mic-status {
padding: 11px;
}
figure {
position: relative;
}
figcaption {
color: #EEE;
background: rgba(16,16,16,0.6);
font-weight: normal;
font-size: 14px;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 5.5px;
}
figcaption form {
display: flex;
flex-flow: row wrap;
gap: 5.5px;
}
figcaption form > * {
font-family: inherit;
font-size: inherit;
font-weight: inherit;
flex: 1 0 auto;
}
#username-input {
background-color: inherit;
border: 1px solid #CCC;
color: #EEE;
display: block;
padding: 5.5px;
max-width: 100%;
}
#username-set-btn {
background: rgba(64,64,64,0.8);
color: #EEE;
padding: 5.5px;
}
/* Media Queries */
@media screen and (min-width: 500px) {
#header {
display: flex;
flex-direction: row-reverse;
align-items: baseline;
justify-content: flex-end;
}
#header > * {
flex: 0 0 auto;
}
#header > h1 {
margin-bottom: 0;
}
}
@media screen and (min-width: 680px) {
#interface {
grid-template-rows: auto 1fr auto;
}
#header {
grid-column: 1 / 2;
}
#footer {
grid-column: 1 / 2;
}
}

BIN
www/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
www/img/placeholder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

61
www/index.html Normal file
View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<!--
! Excerpted from "Programming WebRTC",
! published by The Pragmatic Bookshelf.
! Copyrights apply to this code. It may not be used to create training material,
! courses, books, articles, and the like. Contact us if you are in doubt.
! We make no guarantees that this code is fit for any purpose.
! Visit https://pragprog.com/titles/ksrtc for more book information.
-->
<html lang="en">
<head>
<title>WebRTC Demo: Deployed Multipeer</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,shrink-to-fit=no" />
<link rel="stylesheet" href="css/screen.css" />
</head>
<body>
<main id="interface">
<header id="header" role="banner">
<h1>Welcome</h1>
<button class="join" type="button" id="call-button">Join Call</button>
</header>
<article id="videos">
<h2 class="preserve-access">Streaming Videos</h2>
<figure id="self">
<video
autoplay
muted
playsinline
poster="img/placeholder.png">
</video>
<figcaption>
<form id="username-form" action="#null">
<label for="username-input" class="preserve-access">Username</label>
<input type="text" id="username-input" placeholder="Username" />
<button type="submit" id="username-set-btn">Set Username</button>
</form>
</figcaption>
</figure>
</article>
<footer id="footer">
<button
aria-label="Toggle microphone"
role="switch"
aria-checked="true"
type="button"
id="toggle-mic">Mic</button>
<button
aria-label="Toggle camera"
role="switch"
aria-checked="true"
type="button"
id="toggle-cam">Cam</button>
</footer>
</main>
<script src="/socket.io/socket.io.js"></script>
<script
src="https://webrtchacks.github.io/adapter/adapter-latest.js"></script>
<script src="js/main.js"></script>
</body>
</html>

781
www/js/main.js Normal file
View File

@@ -0,0 +1,781 @@
/***
* Excerpted from "Programming WebRTC",
* published by The Pragmatic Bookshelf.
* Copyrights apply to this code. It may not be used to create training material,
* courses, books, articles, and the like. Contact us if you are in doubt.
* We make no guarantees that this code is fit for any purpose.
* Visit https://pragprog.com/titles/ksrtc for more book information.
***/
'use strict';
/**
* Global Variables: $self and $peers
*/
const $self = {
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
},
mediaConstraints: { audio: true, video: true },
mediaDevices: { audioinput: [], videoinput: [] },
mediaStream: new MediaStream(),
mediaTracks: {},
features: {
audio: false,
video: true,
},
};
const $peers = new Map();
/**
* Signaling-Channel Setup
*/
const namespace = prepareNamespace(window.location.hash, true);
const sc = io.connect('/' + namespace, { autoConnect: false });
registerScCallbacks();
/**
* =========================================================================
* Begin Application-Specific Code
* =========================================================================
*/
/**
* User-Interface Setup
*/
document.querySelector('#toggle-mic')
.setAttribute('aria-checked', $self.features.audio);
document.querySelector('#header h1')
.innerText = 'Welcome to Room #' + namespace;
document.querySelector('#call-button')
.addEventListener('click', handleCallButton);
document.querySelector('#footer')
.addEventListener('click', handleMediaButtons);
document.querySelector('#username-form')
.addEventListener('submit', handleUsernameForm);
/**
* User-Media Setup and Events
*/
requestUserMedia($self.mediaConstraints);
// These events can fire in rapid succession when,
// for example, a camera with built-in mic is connected.
// That's why it's necessary to debounce to 500ms before
// executing the callback. Otherwise, the callback might
// prompt users to access the camera, and then again to
// access the camera and the mic both.
navigator.mediaDevices.ondevicechange = debounce(handleMediaDeviceChange, 500);
/**
* User-Interface Functions and Callbacks
*/
function handleCallButton(event) {
const call_button = event.target;
if (call_button.className === 'join') {
console.log('Joining the call...');
call_button.className = 'leave';
call_button.innerText = 'Leave Call';
joinCall();
} else {
console.log('Leaving the call...');
call_button.className = 'join';
call_button.innerText = 'Join Call';
leaveCall();
}
}
function joinCall() {
sc.open();
}
function leaveCall() {
sc.close();
for (let id of $peers.keys()) {
resetPeer(id);
}
}
function handleMediaButtons(event) {
const target = event.target;
if (target.tagName !== 'BUTTON') return;
switch (target.id) {
case 'toggle-mic':
toggleMic(target);
break;
case 'toggle-cam':
toggleCam(target);
break;
}
}
function toggleMic(button) {
const audio = $self.mediaTracks.audio;
const enabled_state = audio.enabled = !audio.enabled;
$self.features.audio = enabled_state;
button.setAttribute('aria-checked', enabled_state);
for (let id of $peers.keys()) {
shareFeatures(id, 'audio');
}
}
function toggleCam(button) {
const video = $self.mediaTracks.video;
const enabled_state = video.enabled = !video.enabled;
$self.features.video = enabled_state;
button.setAttribute('aria-checked', enabled_state);
for (let id of $peers.keys()) {
shareFeatures(id, 'video');
}
if (enabled_state) {
$self.mediaStream.addTrack($self.mediaTracks.video);
} else {
$self.mediaStream.removeTrack($self.mediaTracks.video);
displayStream($self.mediaStream);
}
}
function enableOrDisableMediaToggleButtons() {
const audio_button = document.querySelector('#toggle-mic');
const video_button = document.querySelector('#toggle-cam');
// Set the disabled attribute's value based on the
// available media devices
audio_button.disabled = $self.mediaDevices.audioinput.length === 0;
video_button.disabled = $self.mediaDevices.videoinput.length === 0;
}
function handleUsernameForm(e) {
e.preventDefault();
const form = e.target;
const username = form.querySelector('#username-input').value;
const figcaption = document.querySelector('#self figcaption');
figcaption.innerText = username;
$self.features.username = username;
for (let id of $peers.keys()) {
shareFeatures(id, 'username');
}
}
/**
* User-Media and Data-Channel Functions
*/
async function detectAvailableMediaDevices() {
// Assume there are no devices until we detect them
$self.mediaDevices.audioinput = [];
$self.mediaDevices.videoinput = [];
const devices = await navigator.mediaDevices.enumerateDevices();
for (let device of devices) {
// Only interested in audio and video inputs
const input_kinds = ['audioinput', 'videoinput'];
if (input_kinds.includes(device.kind)) {
$self.mediaDevices[device.kind].push(device);
}
}
}
async function handleMediaDeviceChange() {
const previous_devices =
$self.mediaDevices.audioinput.length > 0
|| $self.mediaDevices.videoinput.length > 0;
// First things first: on any device change,
// update the list of available media devices
await detectAvailableMediaDevices();
const available_devices =
$self.mediaDevices.audioinput.length > 0
|| $self.mediaDevices.videoinput.length > 0;
// Case One: A device has been plugged in and
// no other devices were previously available
if (!previous_devices && available_devices) {
// Request user media as though the app had just been opened,
// but await it so that we can ensure there's media available
// before adding it to the connection
await requestUserMedia($self.mediaConstraints);
for (let id of $peers.keys()) {
addStreamingMedia(id);
shareFeatures(id, 'audio', 'video');
}
}
// Case Two: The current device has been unplugged
// and there are now no devices available
if (!available_devices) {
// Reset all the media properties
$self.media = false;
$self.mediaTracks = {};
$self.mediaStream = new MediaStream();
// Toggle off the media buttons, and null out
// the self-side stream
enableOrDisableMediaToggleButtons();
displayStream(null);
// Remove tracks from peer connections
for (let id of $peers.keys()) {
removeStreamingMedia(id);
}
}
}
async function requestUserMedia(media_constraints) {
// Duplicate the media constraints so as to not affect
// the original values on $self
const refined_media_constraints =
JSON.parse(JSON.stringify(media_constraints));
// See what devices are available
await detectAvailableMediaDevices();
// Refine media constraints based on device availability
if ($self.mediaDevices.audioinput.length === 0) {
// There's no audio device, so ensure that constraint is false
refined_media_constraints.audio = false;
}
if ($self.mediaDevices.videoinput.length === 0) {
// There's no video device, so ensure that constraint is false
refined_media_constraints.video = false;
}
// Disable or re-enable toggle buttons
enableOrDisableMediaToggleButtons();
if (!refined_media_constraints.audio &&
!refined_media_constraints.video) {
// If no media is available, we exit, as passing
// { audio: false, video: false } as a constraint
// results in a TypeError
return;
}
try {
$self.media = await navigator.mediaDevices
.getUserMedia(refined_media_constraints);
// Detect the devices again, now that permissions
// have been granted; this helps capture a value
// on the "label" field on MediaDeviceInfo for
// easier inspection
await detectAvailableMediaDevices();
} catch(e) {
console.error(e.name, e.message);
}
// Hold onto audio- and video-track references
$self.mediaTracks.audio = $self.media.getAudioTracks()[0];
$self.mediaTracks.video = $self.media.getVideoTracks()[0];
if ($self.mediaTracks.audio) {
// Mute the audio if `$self.features.audio` evaluates to `false`
$self.mediaTracks.audio.enabled = !!$self.features.audio;
// Add audio track to mediaStream
$self.mediaStream.addTrack($self.mediaTracks.audio);
}
if ($self.mediaTracks.video) {
// Toggle off the camera if `$self.features.video` evaluates to `false`
$self.mediaTracks.video.enabled = !!$self.features.video;
// Add video track to mediaStream
$self.mediaStream.addTrack($self.mediaTracks.video);
}
displayStream($self.mediaStream);
}
function createVideoStructure(id) {
const figure = document.createElement('figure');
const figcaption = document.createElement('figcaption');
const video = document.createElement('video');
const attributes = {
autoplay: '',
playsinline: '',
poster: 'img/placeholder.png',
};
const attributes_list = Object.keys(attributes);
// Set attributes
figure.id = `peer-${id}`;
figcaption.innerText = id;
for (let attr of attributes_list) {
video.setAttribute(attr, attributes[attr]);
}
// Append the video and figcaption elements
figure.appendChild(video);
figure.appendChild(figcaption);
// Return the complete figure
return figure;
}
function displayStream(stream, id = 'self') {
const selector = id === 'self' ? '#self' : `#peer-${id}`;
let video_structure = document.querySelector(selector);
if (!video_structure) {
const videos = document.querySelector('#videos');
video_structure = createVideoStructure(id);
videos.appendChild(video_structure);
}
video_structure.querySelector('video').srcObject = stream;
}
function addStreamingMedia(id) {
const peer = $peers.get(id);
const tracks = Object.keys($self.mediaTracks);
for (let track of tracks) {
if ($self.mediaTracks[track]) {
peer.connection.addTrack($self.mediaTracks[track]);
}
}
}
function removeStreamingMedia(id) {
const peer = $peers.get(id);
// Detect the senders on the connection; we need
// them to remove tracks from the peer connection
const senders = peer.connection.getSenders();
const senders_list = Object.keys(senders);
// Loop through the senders, and pull the tracks
// off them, one by one
for (let sender of senders_list) {
const track = senders[sender].track;
if (track) {
// Remove the track on its associated sender
peer.connection.removeTrack(senders[sender]);
}
}
// Send a 'removeAllTracks' feature to clean things up
// on the receiving peer's side.
shareFeatures(id, 'removeAllTracks');
}
function addFeaturesChannel(id) {
const peer = $peers.get(id);
const featureFunctions = {
audio: function() {
const username = peer.features.username ? peer.features.username : id;
showUsernameAndMuteStatus(username);
},
removeAllTracks: function() {
const tracks_list = Object.keys(peer.mediaTracks);
for (let track of tracks_list) {
peer.mediaStream.removeTrack(peer.mediaTracks[track]);
}
// Empty out the media tracks object
peer.mediaTracks = {};
// Create a brand-new media stream in case tracks
// need to be added later
peer.mediaStream = new MediaStream();
// But null out the display of the media stream
// for the time being
displayStream(null, id);
},
username: function() {
// Update the username
showUsernameAndMuteStatus(peer.features.username);
},
video: function() {
// This is all just to display the poster image,
// rather than a black frame
if (peer.mediaTracks.video) {
if (peer.features.video) {
peer.mediaStream.addTrack(peer.mediaTracks.video);
} else {
peer.mediaStream.removeTrack(peer.mediaTracks.video);
displayStream(peer.mediaStream, id);
}
}
},
};
peer.featuresChannel =
peer.connection.createDataChannel('features',
{ negotiated: true, id: 110 });
peer.featuresChannel.onopen = function() {
// send features information just as soon as the channel opens
peer.featuresChannel.send(JSON.stringify($self.features));
};
peer.featuresChannel.onmessage = function(event) {
const features = JSON.parse(event.data);
const features_list = Object.keys(features);
for (let f of features_list) {
// update the corresponding features field on $peer
peer.features[f] = features[f];
// if there's a corresponding function, run it
if (typeof featureFunctions[f] === 'function') {
featureFunctions[f]();
}
}
};
function showUsernameAndMuteStatus(username) {
const fc = document.querySelector(`#peer-${id} figcaption`);
if (peer.features.audio) {
fc.innerText = username;
} else {
fc.innerText = `${username} (Muted)`;
}
}
}
function shareFeatures(id, ...features) {
const peer = $peers.get(id);
const featuresToShare = {};
if (!peer.featuresChannel) return;
for (let f of features) {
featuresToShare[f] =
$self.features[f] ? $self.features[f] : 'true';
}
try {
peer.featuresChannel.send(JSON.stringify(featuresToShare));
} catch(e) {
console.error('Error sending features:', e);
}
}
/**
* Call Features & Reset Functions
*/
function initializePeer(id, polite) {
$peers.set(id, {
connection: new RTCPeerConnection($self.rtcConfig),
mediaStream: new MediaStream(),
mediaTracks: {},
features: {},
selfStates: {
isPolite: polite,
isMakingOffer: false,
isIgnoringOffer: false,
isSettingRemoteAnswerPending: false,
isSuppressingInitialOffer: false,
},
});
}
function establishCallFeatures(id) {
registerRtcCallbacks(id);
addFeaturesChannel(id);
// Always set up a display stream, even
// for users who might not have streaming
// media. Set the stream itself to null
displayStream(null, id);
addStreamingMedia(id);
}
function resetPeer(id) {
const peer = $peers.get(id);
displayStream(null, id);
document.querySelector(`#peer-${id}`).remove();
peer.connection.close();
$peers.delete(id);
}
/**
* WebRTC Functions and Callbacks
*/
function registerRtcCallbacks(id) {
const peer = $peers.get(id);
peer.connection
.onconnectionstatechange = handleRtcConnectionStateChange(id);
peer.connection
.onnegotiationneeded = handleRtcConnectionNegotiation(id);
peer.connection
.onicecandidate = handleRtcIceCandidate(id);
peer.connection
.ontrack = handleRtcPeerTrack(id);
}
function handleRtcPeerTrack(id) {
return function({ track }) {
const peer = $peers.get(id);
console.log(`Handle incoming ${track.kind} track from peer ID: ${id}`);
peer.mediaTracks[track.kind] = track;
peer.mediaStream.addTrack(track);
displayStream(peer.mediaStream, id);
};
}
/**
* =========================================================================
* End Application-Specific Code
* =========================================================================
*/
/**
* Reusable WebRTC Functions and Callbacks
*/
function handleRtcConnectionNegotiation(id) {
return async function() {
const peer = $peers.get(id);
const self_state = peer.selfStates;
// peers suppressing initial offers should do nothing but exit
if (self_state.isSuppressingInitialOffer) return;
// older browsers do not automatically create an offer when they
// call `setLocalDescription`
try {
self_state.isMakingOffer = true;
await peer.connection.setLocalDescription();
} catch(e) {
// manually create the offer with the automatic version fails
const offer = await peer.connection.createOffer();
await peer.connection.setLocalDescription(offer);
} finally {
// however the local description is set, send it over the
// signaling channel
sc.emit('signal',
{ recipient: id, sender: $self.id,
signal: { description: peer.connection.localDescription } });
self_state.isMakingOffer = false;
}
};
}
function handleRtcIceCandidate(id) {
return function({ candidate }) {
if (candidate) {
console.log(`Handling ICE candidate, type '${ candidate.type }'...`);
}
sc.emit('signal', { recipient: id, sender: $self.id,
signal: { candidate } });
};
}
function handleRtcConnectionStateChange(id) {
return function() {
const peer = $peers.get(id);
const connection_state = peer.connection.connectionState;
// Assume *some* element will take a unique peer ID
const peer_element = document.querySelector(`#peer-${id}`);
if (peer_element) {
peer_element.dataset.connectionState = connection_state;
}
console.log(`Connection state '${connection_state}' for Peer ID: ${id}`);
};
}
/**
* Signaling-Channel Functions and Callbacks
*/
function registerScCallbacks() {
sc.on('connect', handleScConnect);
sc.on('connected peers', handleScConnectedPeers);
sc.on('connected peer', handleScConnectedPeer);
sc.on('disconnected peer', handleScDisconnectedPeer);
sc.on('signal', handleScSignal);
}
function handleScConnect() {
console.log('Successfully connected to the signaling server!');
$self.id = sc.id;
console.log(`Self ID: ${$self.id}`);
}
function handleScConnectedPeers({ peers, credentials }) {
const ids = peers;
console.log(`Connected peer IDs: ${ids.join(', ')}`);
console.log(`TURN Credentials: ${JSON.stringify(credentials)}`);
// addCredentialedTurnServer('turn:coturn.example.com:3478', credentials);
for (let id of ids) {
if (id === $self.id) continue;
// be polite with already-connected peers
initializePeer(id, true);
establishCallFeatures(id);
}
}
function handleScConnectedPeer(id) {
console.log(`Newly connected peer ID: ${id}`);
// be impolite with each newly connecting peer
initializePeer(id, false);
establishCallFeatures(id);
}
function handleScDisconnectedPeer(id) {
console.log(`Disconnected peer ID: ${id}`);
resetPeer(id);
}
function resetAndRetryConnection(id) {
const peer = $peers.get(id);
// Preserve the original politeness value
const is_polite = peer.selfStates.isPolite;
// Re-initialize the peer
initializePeer(id, is_polite);
const self_state = peer.selfStates;
// Set the offer-suppression state property to `true` for the
// polite peer
self_state.isSuppressingInitialOffer = is_polite;
// Reestablish the call features, which triggers
// the `negotiationneeded` event and its callback again
establishCallFeatures(id);
// Inform the impolite peer to reset, too:
if (is_polite) {
sc.emit('signal', { description: { type: '_reset' } });
}
}
async function handleScSignal({ sender,
signal: { candidate, description } }) {
const id = sender;
const peer = $peers.get(id);
const self_state = peer.selfStates;
if (description) {
if (description.type === '_reset') {
// Reset and retry the connection, and exit the `handleScSignal()`
// function
resetAndRetryConnection(id);
return;
}
const ready_for_offer =
!self_state.isMakingOffer &&
(peer.connection.signalingState === 'stable'
|| self_state.isSettingRemoteAnswerPending);
const offer_collision =
description.type === 'offer' && !ready_for_offer;
self_state.isIgnoringOffer = !self_state.isPolite && offer_collision;
if (self_state.isIgnoringOffer) {
return;
}
self_state.isSettingRemoteAnswerPending = description.type === 'answer';
await peer.connection.setRemoteDescription(description);
self_state.isSettingRemoteAnswerPending = false;
if (description.type === 'offer') {
await peer.connection.setLocalDescription();
sc.emit('signal', { recipient: id, sender: $self.id,
signal: { description: peer.connection.localDescription } });
}
} else if (candidate) {
// Handle ICE candidates
try {
await peer.connection.addIceCandidate(candidate);
} catch(e) {
// Log error unless state is ignoring offers
// and candidate is not an empty string
if (!self_state.isIgnoringOffer && candidate.candidate.length > 1) {
console.error(`Unable to add ICE candidate for peer ID: ${id}.`, e);
}
}
}
}
/**
* Utility Functions
*/
function addCredentialedTurnServer(server_string, { username, password }) {
// Add TURN server and credentials to iceServers array
$self.rtcConfig.iceServers.push({
urls: server_string,
username: username,
password: password,
});
}
function prepareNamespace(hash, set_location) {
let ns = hash.replace(/^#/, ''); // remove # from the hash
if (/^[a-z]{4}-[a-z]{4}-[a-z]{4}$/.test(ns)) {
console.log(`Checked existing namespace '${ns}'`);
return ns;
}
ns = generateRandomAlphaString('-', 4, 4, 4);
console.log(`Created new namespace '${ns}'`);
if (set_location) window.location.hash = ns;
return ns;
}
function generateRandomAlphaString(separator, ...groups) {
const alphabet = 'abcdefghijklmnopqrstuvwxyz';
let ns = [];
for (let group of groups) {
let str = '';
for (let i = 0; i < group; i++) {
str += alphabet[Math.floor(Math.random() * alphabet.length)];
}
ns.push(str);
}
return ns.join(separator);
}
function debounce(callback_function, wait_in_milliseconds) {
let timeout;
return (...args) => {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(
() => callback_function.apply(context, args),
wait_in_milliseconds);
};
}