it works!!!
This commit is contained in:
commit
1e47627c27
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
package-lock.json
|
||||||
|
config.json
|
||||||
|
*.log
|
||||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
package-lock.json
|
||||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
||||||
17
README.md
Normal file
17
README.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# screensy-unbloated
|
||||||
|
|
||||||
|
Screen sharing website.
|
||||||
|
Based on https://github.com/screensy/screensy but without a ton of bloat
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. ```sh
|
||||||
|
cp default-config.json config.json
|
||||||
|
```
|
||||||
|
2. Edit `config.json`
|
||||||
|
3.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
4
default-config.json
Normal file
4
default-config.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 4004
|
||||||
|
}
|
||||||
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "screensy-unbloated",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"homepage": "https://github.com/screensy/screensy",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/minimist": "^1.2.5",
|
||||||
|
"@types/node": "^22.18.6",
|
||||||
|
"@types/serve-handler": "^6.1.4",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"@typescript/native-preview": "^7.0.0-dev.20250918.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"serve-handler": "^6.1.6",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "npx tsgo -b src/client src/server",
|
||||||
|
"postbuild": "cp -r public config.json build && cp -r src build/public",
|
||||||
|
"start": "cd build && node ./server.js",
|
||||||
|
"clean": "rm -rf build"
|
||||||
|
}
|
||||||
|
}
|
||||||
82
public/index.html
Normal file
82
public/index.html
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>screensy</title>
|
||||||
|
<meta name="description" content="Simple peer-to-peer screen sharing" />
|
||||||
|
<meta name="application-name" content="screensy" />
|
||||||
|
<meta name="language" content="en" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link href="./styles.css" rel="stylesheet" />
|
||||||
|
<script type="text/javascript" src="./screensy.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<div class="popup">
|
||||||
|
<h1>JavaScript is not supported or not enabled.</h1>
|
||||||
|
<p>
|
||||||
|
Screensy needs JavaScript support, but your browser does not provide it. Use a browser that supports
|
||||||
|
JavaScript and make sure it is enabled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
<div id="access-denied" class="popup hidden">
|
||||||
|
<h1>You denied access to your screen.</h1>
|
||||||
|
<p>
|
||||||
|
Screensy needs access to your screen in order to share it, but you denied access. Try reloading this
|
||||||
|
webpage and provide access when your browser asks you for it. If your browser does not ask you for
|
||||||
|
access, check your browser settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="screensharing-not-supported" class="popup hidden">
|
||||||
|
<h1>Screensharing is not supported or not enabled.</h1>
|
||||||
|
<p>
|
||||||
|
Screensy cannot share your screen, because your browser does not support it, or it is not enabled. Use a
|
||||||
|
browser that supports screensharing and make sure it is enabled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="websockets-not-supported" class="popup hidden">
|
||||||
|
<h1>WebSocket is not supported or not enabled.</h1>
|
||||||
|
<p>
|
||||||
|
Screensy needs WebSocket support, but your browser does not provide it. Use a browser that supports
|
||||||
|
WebSocket and make sure it is enabled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="webrtc-not-supported" class="popup hidden">
|
||||||
|
<h1>WebRTC is not supported or not enabled.</h1>
|
||||||
|
<p>
|
||||||
|
Screensy needs WebRTC support, but your browser does not provide it. Use a browser that supports WebRTC
|
||||||
|
and make sure it is enabled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="mediastream-not-supported" class="popup hidden">
|
||||||
|
<h1>MediaStream is not supported or not enabled.</h1>
|
||||||
|
<p>
|
||||||
|
Screensy needs MediaStream support, but your browser does not provide it. Use a browser that supports
|
||||||
|
MediaStream and make sure it is enabled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="websocket-connect-failed" class="popup hidden">
|
||||||
|
<h1>Could not establish a WebSocket connection.</h1>
|
||||||
|
<p>
|
||||||
|
Screensy needs to establish a WebSocket connection to connect to other users, but it was not able to.
|
||||||
|
Try using a different device or contact the administrator of this website.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="broadcaster-disconnected" class="popup hidden">
|
||||||
|
<h1>The broadcaster has disconnected.</h1>
|
||||||
|
</div>
|
||||||
|
<div id="click-to-share" class="popup clickable hidden">
|
||||||
|
<h1>Click anywhere to share your screen.</h1>
|
||||||
|
</div>
|
||||||
|
<video id="stream" autoplay="autoplay" muted="muted" controls="controls" playsinline="playsinline">
|
||||||
|
<div class="popup">
|
||||||
|
<h1>Video tags are not supported.</h1>
|
||||||
|
<p>
|
||||||
|
Screensy needs video tag support, but your browser does not provide it. Use a browser that supports
|
||||||
|
video tags.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</video>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
67
public/styles.css
Normal file
67
public/styles.css
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
#counter {
|
||||||
|
text-shadow: 1px 1px 3px #333333;
|
||||||
|
color: white;
|
||||||
|
position: absolute;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1.5em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stream {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
user-select: none;
|
||||||
|
max-width: 65ch;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup p {
|
||||||
|
color: #e1e1e1;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 800px) {
|
||||||
|
.popup h1 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup p {
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
}
|
||||||
802
src/client/screensy.ts
Normal file
802
src/client/screensy.ts
Normal file
@ -0,0 +1,802 @@
|
|||||||
|
/**
|
||||||
|
* @source ./screensy.ts
|
||||||
|
*
|
||||||
|
* @licstart The following is the entire license notice for the JavaScript
|
||||||
|
* code in this page.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2021 Stef Gijsberts, Marijn van Wezel
|
||||||
|
*
|
||||||
|
* The JavaScript code in this page is free software: you can redistribute it
|
||||||
|
* and/or modify it under the terms of the GNU General Public License (GNU GPL)
|
||||||
|
* as published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version. The code is distributed
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
|
||||||
|
*
|
||||||
|
* As additional permission under GNU GPL version 3 section 7, you may
|
||||||
|
* distribute non-source (e.g., minimized or compacted) forms of that code
|
||||||
|
* without the copy of the GNU GPL normally required by section 4, provided you
|
||||||
|
* include this license notice and a URL through which recipients can access
|
||||||
|
* the Corresponding Source.
|
||||||
|
*
|
||||||
|
* @licend The above is the entire license notice for the JavaScript code in
|
||||||
|
* this page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface MessageJoin {
|
||||||
|
type: 'join'
|
||||||
|
roomId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the broadcaster a viewer has connected
|
||||||
|
*/
|
||||||
|
interface MessageViewer {
|
||||||
|
type: 'viewer'
|
||||||
|
viewerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the server to resend the VIEWER messages
|
||||||
|
*/
|
||||||
|
interface MessageRequestViewers {
|
||||||
|
type: 'requestviewers'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the broadcaster a viewer has disconnected
|
||||||
|
*/
|
||||||
|
interface MessageViewerDisconnected {
|
||||||
|
type: 'viewerdisconnected'
|
||||||
|
viewerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the viewer the broadcaster has disconnected.
|
||||||
|
*/
|
||||||
|
interface MessageBroadcasterDisconnected {
|
||||||
|
type: 'broadcasterdisconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a WebRTC message between the viewer and the server
|
||||||
|
*/
|
||||||
|
interface MessageWebRTCViewer {
|
||||||
|
type: 'webrtcviewer'
|
||||||
|
kind: 'offer' | 'answer' | 'candidate'
|
||||||
|
message: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a WebRTC message between the server and the broadcaster
|
||||||
|
*/
|
||||||
|
interface MessageWebRTCBroadcaster {
|
||||||
|
type: 'webrtcbroadcaster'
|
||||||
|
viewerId: string
|
||||||
|
kind: 'offer' | 'answer' | 'candidate'
|
||||||
|
message: any
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message =
|
||||||
|
| MessageViewer
|
||||||
|
| MessageViewerDisconnected
|
||||||
|
| MessageBroadcasterDisconnected
|
||||||
|
| MessageWebRTCViewer
|
||||||
|
| MessageWebRTCBroadcaster
|
||||||
|
| MessageRequestViewers
|
||||||
|
| MessageJoin
|
||||||
|
|
||||||
|
interface MessageSender {
|
||||||
|
(msg: Message): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause execution until the listener/event has fired on the given target.
|
||||||
|
*
|
||||||
|
* @see https://stackoverflow.com/a/63718685
|
||||||
|
*/
|
||||||
|
function wait(target: EventTarget, listenerName: string): Promise<Event> {
|
||||||
|
// Lambda that returns a listener for the given resolve lambda
|
||||||
|
const listener = (resolve: (value: Event | PromiseLike<Event>) => void) => (event: Event) => {
|
||||||
|
target.removeEventListener(listenerName, listener(resolve))
|
||||||
|
resolve(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, _reject) => {
|
||||||
|
target.addEventListener(listenerName, listener(resolve))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the popup with the given name. Does nothing if the popup does not
|
||||||
|
* exist.
|
||||||
|
*
|
||||||
|
* @param name Name of the popup to display
|
||||||
|
*/
|
||||||
|
function showPopup(name: string): void {
|
||||||
|
const element = document.getElementById(name)
|
||||||
|
|
||||||
|
if (element == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
element.classList.remove('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides the popup with the given name. Does nothing if the popup is not visible or if
|
||||||
|
* the popup does not exist.
|
||||||
|
*
|
||||||
|
* @param name Name of the popup to hide
|
||||||
|
*/
|
||||||
|
function hidePopup(name: string): void {
|
||||||
|
const element = document.getElementById(name)
|
||||||
|
|
||||||
|
if (element == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
element.classList.add('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
/**
|
||||||
|
* Handles the messages received from the signaling server.
|
||||||
|
*
|
||||||
|
* @param msg
|
||||||
|
*/
|
||||||
|
handleMessage(msg: Message): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a broadcaster. The broadcaster is responsible for capturing and sending
|
||||||
|
* their screen (and maybe audio) to all peers.
|
||||||
|
*/
|
||||||
|
class Broadcaster implements Client {
|
||||||
|
public onviewerjoin: ((viewerId: string) => void) | null = null
|
||||||
|
public onviewerleave: ((viewerId: string) => void) | null = null
|
||||||
|
|
||||||
|
private readonly sendMessage: MessageSender
|
||||||
|
private readonly rtcConfig: RTCConfiguration
|
||||||
|
private readonly mediaStream: MediaStream
|
||||||
|
|
||||||
|
private readonly viewers: { [id: string]: RTCPeerConnection } = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcaster constructor.
|
||||||
|
*
|
||||||
|
* @param sendMessage Function to send a message to the server
|
||||||
|
* @param rtcConfig The WebRTC configuration to use for the WebRTC connection
|
||||||
|
* @param mediaStream The MediaStream to broadcast
|
||||||
|
*/
|
||||||
|
constructor(sendMessage: MessageSender, rtcConfig: RTCConfiguration, mediaStream: MediaStream) {
|
||||||
|
this.sendMessage = sendMessage
|
||||||
|
this.rtcConfig = rtcConfig
|
||||||
|
this.mediaStream = mediaStream
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
async handleMessage(msg: MessageViewer | MessageViewerDisconnected | MessageWebRTCBroadcaster): Promise<void> {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'viewer':
|
||||||
|
await this.addViewer(msg.viewerId)
|
||||||
|
break
|
||||||
|
case 'viewerdisconnected':
|
||||||
|
await this.removeViewer(msg.viewerId)
|
||||||
|
break
|
||||||
|
case 'webrtcbroadcaster':
|
||||||
|
await this.handleWebRTCMessage(msg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a viewer to the peer-to-peer connection.
|
||||||
|
*
|
||||||
|
* @param viewerId
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async addViewer(viewerId: string): Promise<void> {
|
||||||
|
const viewerConnection = new RTCPeerConnection(this.rtcConfig)
|
||||||
|
|
||||||
|
for (const track of await this.mediaStream.getTracks()) {
|
||||||
|
viewerConnection.addTrack(track, this.mediaStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewerConnection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
|
||||||
|
if (!event.candidate) return
|
||||||
|
|
||||||
|
this.sendMessage({
|
||||||
|
type: 'webrtcbroadcaster',
|
||||||
|
kind: 'candidate',
|
||||||
|
viewerId: viewerId,
|
||||||
|
message: event.candidate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
viewerConnection.onicegatheringstatechange = async (_event: Event) => {
|
||||||
|
if (viewerConnection.iceGatheringState !== 'complete') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sender of await viewerConnection.getSenders()) {
|
||||||
|
if (sender.track == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const rtcSendParameters = sender.getParameters()
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#currently_compatible_implementation
|
||||||
|
if (!rtcSendParameters.encodings) {
|
||||||
|
rtcSendParameters.encodings = [{}]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sender.track.kind === 'audio') {
|
||||||
|
rtcSendParameters.encodings[0].maxBitrate = 960000 // 960 Kbps, does gek
|
||||||
|
} else if (sender.track.kind === 'video') {
|
||||||
|
// @ts-ignore
|
||||||
|
rtcSendParameters.encodings[0].maxFramerate = 30
|
||||||
|
rtcSendParameters.encodings[0].maxBitrate = 100000000 // 100 Mbps
|
||||||
|
}
|
||||||
|
|
||||||
|
await sender.setParameters(rtcSendParameters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const offer = await viewerConnection.createOffer()
|
||||||
|
await viewerConnection.setLocalDescription(offer)
|
||||||
|
const localDescription = viewerConnection.localDescription
|
||||||
|
|
||||||
|
if (localDescription == null) {
|
||||||
|
throw 'No local description available.'
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sendMessage({
|
||||||
|
type: 'webrtcbroadcaster',
|
||||||
|
kind: 'offer',
|
||||||
|
viewerId: viewerId,
|
||||||
|
message: localDescription,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.viewers[viewerId] = viewerConnection
|
||||||
|
|
||||||
|
if (this.onviewerjoin != null) {
|
||||||
|
this.onviewerjoin(viewerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a viewer from the peer-to-peer connection.
|
||||||
|
*
|
||||||
|
* @param viewerId
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async removeViewer(viewerId: string): Promise<void> {
|
||||||
|
if (this.viewers[viewerId] == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.viewers[viewerId].close()
|
||||||
|
delete this.viewers[viewerId]
|
||||||
|
|
||||||
|
if (this.onviewerleave != null) {
|
||||||
|
this.onviewerleave(viewerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles incoming WebRTC messages.
|
||||||
|
*
|
||||||
|
* @param msg
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async handleWebRTCMessage(msg: MessageWebRTCBroadcaster): Promise<void> {
|
||||||
|
const kind = msg.kind
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'candidate':
|
||||||
|
if (this.viewers[msg.viewerId] == null) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.viewers[msg.viewerId].addIceCandidate(new RTCIceCandidate(msg.message))
|
||||||
|
break
|
||||||
|
case 'answer':
|
||||||
|
if (this.viewers[msg.viewerId] == null) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.viewers[msg.viewerId].setRemoteDescription(msg.message)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a viewer.
|
||||||
|
*/
|
||||||
|
class Viewer implements Client {
|
||||||
|
private readonly sendMessage: MessageSender
|
||||||
|
private readonly rtcConfig: RTCConfiguration
|
||||||
|
private readonly videoElement: HTMLVideoElement
|
||||||
|
|
||||||
|
private broadcasterPeerConnection: RTCPeerConnection | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewer constructor.
|
||||||
|
*
|
||||||
|
* @param sendMessage Function to send a message to the server
|
||||||
|
* @param rtcConfig The WebRTC configuration to use for the WebRTC connection
|
||||||
|
* @param videoElement The element to project the received MediaStream onto
|
||||||
|
*/
|
||||||
|
constructor(sendMessage: MessageSender, rtcConfig: RTCConfiguration, videoElement: HTMLVideoElement) {
|
||||||
|
this.sendMessage = sendMessage
|
||||||
|
this.rtcConfig = rtcConfig
|
||||||
|
this.videoElement = videoElement
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
async handleMessage(msg: MessageBroadcasterDisconnected | MessageWebRTCViewer): Promise<void> {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'broadcasterdisconnected':
|
||||||
|
await this.handleBroadcasterDisconnect()
|
||||||
|
break
|
||||||
|
case 'webrtcviewer':
|
||||||
|
await this.handleWebRTCMessage(msg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a disconnect of the broadcaster.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async handleBroadcasterDisconnect(): Promise<void> {
|
||||||
|
showPopup('broadcaster-disconnected')
|
||||||
|
document.body.removeChild(this.videoElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles incoming WebRTC messages.
|
||||||
|
*
|
||||||
|
* @param msg
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async handleWebRTCMessage(msg: MessageWebRTCViewer): Promise<void> {
|
||||||
|
const kind = msg.kind
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'candidate':
|
||||||
|
if (this.broadcasterPeerConnection == null) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.broadcasterPeerConnection.addIceCandidate(new RTCIceCandidate(msg.message))
|
||||||
|
break
|
||||||
|
case 'offer':
|
||||||
|
await this.handleOffer(msg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles incoming WebRTC offer.
|
||||||
|
*
|
||||||
|
* @param msg
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async handleOffer(msg: MessageWebRTCViewer): Promise<void> {
|
||||||
|
this.broadcasterPeerConnection = new RTCPeerConnection(this.rtcConfig)
|
||||||
|
|
||||||
|
this.broadcasterPeerConnection.ontrack = (event: RTCTrackEvent) => {
|
||||||
|
this.videoElement.srcObject = event.streams[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcasterPeerConnection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
|
||||||
|
if (event.candidate == null) return
|
||||||
|
|
||||||
|
this.sendMessage({
|
||||||
|
type: 'webrtcviewer',
|
||||||
|
kind: 'candidate',
|
||||||
|
message: event.candidate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.broadcasterPeerConnection.setRemoteDescription(msg.message)
|
||||||
|
|
||||||
|
const answer = await this.broadcasterPeerConnection.createAnswer()
|
||||||
|
await this.broadcasterPeerConnection.setLocalDescription(answer)
|
||||||
|
|
||||||
|
if (this.broadcasterPeerConnection == null) {
|
||||||
|
throw 'No local description available.'
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sendMessage({
|
||||||
|
type: 'webrtcviewer',
|
||||||
|
kind: 'answer',
|
||||||
|
message: this.broadcasterPeerConnection.localDescription,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Room {
|
||||||
|
private readonly roomId: string
|
||||||
|
private readonly videoElement: HTMLVideoElement
|
||||||
|
private readonly webSocket: WebSocket
|
||||||
|
private readonly sendMessage: MessageSender
|
||||||
|
private readonly rtcConfig: RTCConfiguration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room constructor.
|
||||||
|
*
|
||||||
|
* @param roomId The ID of this room
|
||||||
|
*/
|
||||||
|
constructor(roomId: string) {
|
||||||
|
this.roomId = roomId
|
||||||
|
this.videoElement = <HTMLVideoElement>document.getElementById('stream')
|
||||||
|
|
||||||
|
const secureProtocol = window.location.protocol === 'https:'
|
||||||
|
const protocol = secureProtocol ? 'wss' : 'ws'
|
||||||
|
const webSocketUrl = `${protocol}://${location.host}`
|
||||||
|
console.log('connecting to websocket', webSocketUrl)
|
||||||
|
this.webSocket = new WebSocket(webSocketUrl)
|
||||||
|
this.webSocket.onerror = () => showPopup('websocket-connect-failed')
|
||||||
|
|
||||||
|
this.sendMessage = async (message: Message) => this.webSocket.send(JSON.stringify(message))
|
||||||
|
this.rtcConfig = {
|
||||||
|
iceServers: [
|
||||||
|
{ urls: 'stun:' + location.hostname },
|
||||||
|
{
|
||||||
|
urls: 'turn:' + location.hostname,
|
||||||
|
username: 'screensy',
|
||||||
|
credential: 'screensy',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
iceCandidatePoolSize: 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.videoElement.onpause = (_event: Event) => this.videoElement.play()
|
||||||
|
window.onunload = window.onbeforeunload = () => this.webSocket.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins the room.
|
||||||
|
*/
|
||||||
|
async join() {
|
||||||
|
// Wait until the socket opens
|
||||||
|
await wait(this.webSocket, 'open')
|
||||||
|
|
||||||
|
this.webSocket.onmessage = async (event: MessageEvent) => {
|
||||||
|
const messageData = JSON.parse(event.data)
|
||||||
|
const isBroadcaster = messageData.type === 'broadcast'
|
||||||
|
|
||||||
|
if (isBroadcaster && !('getDisplayMedia' in navigator.mediaDevices)) {
|
||||||
|
showPopup('screensharing-not-supported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = isBroadcaster ? await this.setupBroadcaster() : await this.setupViewer()
|
||||||
|
|
||||||
|
this.webSocket.onmessage = (event: MessageEvent) => client.handleMessage(JSON.parse(event.data))
|
||||||
|
|
||||||
|
if (isBroadcaster) {
|
||||||
|
await this.sendMessage({ type: 'requestviewers' })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setDocumentTitle()
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sendMessage({
|
||||||
|
type: 'join',
|
||||||
|
roomId: this.roomId.toLowerCase(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the document's title to the room name.
|
||||||
|
*/
|
||||||
|
private setDocumentTitle() {
|
||||||
|
const roomIdWords = this.roomId.split(/(?=[A-Z])/)
|
||||||
|
document.title = roomIdWords.join(' ') + ' | screensy'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a Broadcaster instance.
|
||||||
|
*/
|
||||||
|
private async setupBroadcaster(): Promise<Broadcaster> {
|
||||||
|
const mediaStream = await this.getDisplayMediaStream()
|
||||||
|
const broadcaster = new Broadcaster(this.sendMessage, this.rtcConfig, mediaStream)
|
||||||
|
const counterElement: HTMLParagraphElement = document.createElement('p')
|
||||||
|
|
||||||
|
counterElement.id = 'counter'
|
||||||
|
counterElement.innerText = '0'
|
||||||
|
|
||||||
|
broadcaster.onviewerjoin = (_viewerId: string) => {
|
||||||
|
const currentCounter = parseInt(counterElement.innerText)
|
||||||
|
counterElement.innerText = (currentCounter + 1).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcaster.onviewerleave = (_viewerId: string) => {
|
||||||
|
const currentCounter = parseInt(counterElement.innerText)
|
||||||
|
counterElement.innerText = (currentCounter - 1).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.prepend(counterElement)
|
||||||
|
this.videoElement.srcObject = mediaStream
|
||||||
|
|
||||||
|
return broadcaster
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a Viewer instance.
|
||||||
|
*/
|
||||||
|
private async setupViewer(): Promise<Viewer> {
|
||||||
|
// The client is assigned the role of viewer
|
||||||
|
return new Viewer(this.sendMessage, this.rtcConfig, this.videoElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user's display MediaStream.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async getDisplayMediaStream(): Promise<MediaStream> {
|
||||||
|
showPopup('click-to-share')
|
||||||
|
|
||||||
|
await wait(document, 'click')
|
||||||
|
|
||||||
|
const videoConstraints: MediaTrackConstraints | boolean = true
|
||||||
|
const audioConstraints: MediaTrackConstraints | boolean = {
|
||||||
|
channelCount: { ideal: 2 },
|
||||||
|
sampleRate: { ideal: 192000 },
|
||||||
|
// @ts-ignore
|
||||||
|
noiseSuppression: { ideal: false },
|
||||||
|
echoCancellation: { ideal: false },
|
||||||
|
autoGainControl: { ideal: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaConstraints: MediaStreamConstraints = {
|
||||||
|
video: videoConstraints,
|
||||||
|
audio: audioConstraints,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaDevices: MediaDevices = window.navigator.mediaDevices
|
||||||
|
|
||||||
|
// @ts-ignore getDisplayMedia is not supported by TypeScript :(
|
||||||
|
const displayMedia = mediaDevices.getDisplayMedia(mediaConstraints)
|
||||||
|
|
||||||
|
// If the promise is resolved, remove the popup from the screen
|
||||||
|
displayMedia.then(() => {
|
||||||
|
hidePopup('click-to-share')
|
||||||
|
})
|
||||||
|
|
||||||
|
// If the promise is rejected, tell the user about the failure
|
||||||
|
displayMedia.catch(() => {
|
||||||
|
hidePopup('click-to-share')
|
||||||
|
showPopup('access-denied')
|
||||||
|
})
|
||||||
|
|
||||||
|
return displayMedia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a random readable room name and returns the words as a string array.
|
||||||
|
*
|
||||||
|
* @source https://github.com/jitsi/js-utils/blob/master/random/roomNameGenerator.js
|
||||||
|
*/
|
||||||
|
function generateRoomName(): string {
|
||||||
|
const adjectives = [
|
||||||
|
'large',
|
||||||
|
'small',
|
||||||
|
'beautiful',
|
||||||
|
'heavenly',
|
||||||
|
'red',
|
||||||
|
'yellow',
|
||||||
|
'green',
|
||||||
|
'orange',
|
||||||
|
'purple',
|
||||||
|
'massive',
|
||||||
|
'tasty',
|
||||||
|
'cheap',
|
||||||
|
'fancy',
|
||||||
|
'expensive',
|
||||||
|
'crazy',
|
||||||
|
'round',
|
||||||
|
'triangular',
|
||||||
|
'powered',
|
||||||
|
'blue',
|
||||||
|
'heavy',
|
||||||
|
'square',
|
||||||
|
'rectangular',
|
||||||
|
'lit',
|
||||||
|
'authentic',
|
||||||
|
'broken',
|
||||||
|
'busy',
|
||||||
|
'original',
|
||||||
|
'special',
|
||||||
|
'thick',
|
||||||
|
'thin',
|
||||||
|
'pleasant',
|
||||||
|
'sharp',
|
||||||
|
'steady',
|
||||||
|
'happy',
|
||||||
|
'delighted',
|
||||||
|
'stunning',
|
||||||
|
]
|
||||||
|
|
||||||
|
const pluralNouns = [
|
||||||
|
'monsters',
|
||||||
|
'people',
|
||||||
|
'cars',
|
||||||
|
'buttons',
|
||||||
|
'vegetables',
|
||||||
|
'students',
|
||||||
|
'computers',
|
||||||
|
'robots',
|
||||||
|
'lamps',
|
||||||
|
'doors',
|
||||||
|
'wizards',
|
||||||
|
'books',
|
||||||
|
'shirts',
|
||||||
|
'pens',
|
||||||
|
'guitars',
|
||||||
|
'bottles',
|
||||||
|
'microphones',
|
||||||
|
'pants',
|
||||||
|
'drums',
|
||||||
|
'plants',
|
||||||
|
'batteries',
|
||||||
|
'barrels',
|
||||||
|
'birds',
|
||||||
|
'coins',
|
||||||
|
'clothes',
|
||||||
|
'deals',
|
||||||
|
'crosses',
|
||||||
|
'devices',
|
||||||
|
'desktops',
|
||||||
|
'diamonds',
|
||||||
|
'fireworks',
|
||||||
|
'funds',
|
||||||
|
'guitars',
|
||||||
|
'pianos',
|
||||||
|
'harmonies',
|
||||||
|
'levels',
|
||||||
|
'mayors',
|
||||||
|
'mechanics',
|
||||||
|
'networks',
|
||||||
|
'ponds',
|
||||||
|
'trees',
|
||||||
|
'proofs',
|
||||||
|
'flowers',
|
||||||
|
'houses',
|
||||||
|
'speakers',
|
||||||
|
'phones',
|
||||||
|
'chargers',
|
||||||
|
]
|
||||||
|
|
||||||
|
const verbs = [
|
||||||
|
'break',
|
||||||
|
'roll',
|
||||||
|
'flip',
|
||||||
|
'grow',
|
||||||
|
'bake',
|
||||||
|
'create',
|
||||||
|
'cook',
|
||||||
|
'smack',
|
||||||
|
'drink',
|
||||||
|
'close',
|
||||||
|
'display',
|
||||||
|
'run',
|
||||||
|
'move',
|
||||||
|
'flop',
|
||||||
|
'wrap',
|
||||||
|
'enter',
|
||||||
|
'dig',
|
||||||
|
'fly',
|
||||||
|
'swim',
|
||||||
|
'draw',
|
||||||
|
'celebrate',
|
||||||
|
'communicate',
|
||||||
|
'encompass',
|
||||||
|
'forgive',
|
||||||
|
'negotiate',
|
||||||
|
'pioneer',
|
||||||
|
'photograph',
|
||||||
|
'play',
|
||||||
|
'scratch',
|
||||||
|
'stabilize',
|
||||||
|
'weigh',
|
||||||
|
'wrap',
|
||||||
|
'yield',
|
||||||
|
'return',
|
||||||
|
'update',
|
||||||
|
'understand',
|
||||||
|
'propose',
|
||||||
|
'succeed',
|
||||||
|
'stretch',
|
||||||
|
'submit',
|
||||||
|
]
|
||||||
|
|
||||||
|
const adverbs = [
|
||||||
|
'gingerly',
|
||||||
|
'thoroughly',
|
||||||
|
'heavily',
|
||||||
|
'crazily',
|
||||||
|
'mostly',
|
||||||
|
'fast',
|
||||||
|
'slowly',
|
||||||
|
'merrily',
|
||||||
|
'quickly',
|
||||||
|
'heavenly',
|
||||||
|
'cheerfully',
|
||||||
|
'honestly',
|
||||||
|
'politely',
|
||||||
|
'bravely',
|
||||||
|
'vivaciously',
|
||||||
|
'fortunately',
|
||||||
|
'innocently',
|
||||||
|
'kindly',
|
||||||
|
'eagerly',
|
||||||
|
'elegantly',
|
||||||
|
'vividly',
|
||||||
|
'reasonably',
|
||||||
|
'rudely',
|
||||||
|
'wisely',
|
||||||
|
'thankfully',
|
||||||
|
'wholly',
|
||||||
|
'adorably',
|
||||||
|
'happily',
|
||||||
|
'firmly',
|
||||||
|
'fast',
|
||||||
|
'simply',
|
||||||
|
'wickedly',
|
||||||
|
]
|
||||||
|
|
||||||
|
const idxAdjective = Math.floor(Math.random() * adjectives.length)
|
||||||
|
const idxPluralNoun = Math.floor(Math.random() * pluralNouns.length)
|
||||||
|
const idxVerb = Math.floor(Math.random() * verbs.length)
|
||||||
|
const idxAdverb = Math.floor(Math.random() * adverbs.length)
|
||||||
|
|
||||||
|
const words = [adjectives[idxAdjective], pluralNouns[idxPluralNoun], verbs[idxVerb], adverbs[idxAdverb]]
|
||||||
|
|
||||||
|
// @see https://flaviocopes.com/how-to-uppercase-first-letter-javascript/
|
||||||
|
return words.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(_event: Event) {
|
||||||
|
if (window.location.hash == '') {
|
||||||
|
// Redirect the user to a room
|
||||||
|
window.location.replace('#' + generateRoomName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user manually changes the hash, force a reload
|
||||||
|
window.onhashchange = (_event: Event) => {
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('WebSocket' in window)) {
|
||||||
|
showPopup('websockets-not-supported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('mediaDevices' in navigator)) {
|
||||||
|
showPopup('mediastream-not-supported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('RTCPeerConnection' in window)) {
|
||||||
|
showPopup('webrtc-not-supported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = new Room(window.location.hash.substring(1))
|
||||||
|
await room.join()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', main)
|
||||||
16
src/client/tsconfig.json
Normal file
16
src/client/tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ES2015",
|
||||||
|
"target": "ES2015",
|
||||||
|
"sourceMap": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"strict": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"outDir": "../../build/public"
|
||||||
|
},
|
||||||
|
"files": ["screensy.ts"]
|
||||||
|
}
|
||||||
449
src/server/server.ts
Normal file
449
src/server/server.ts
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
import * as websocket from 'ws'
|
||||||
|
import * as http from 'http'
|
||||||
|
import serveHandler = require('serve-handler')
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main entry point.
|
||||||
|
*/
|
||||||
|
function main(): void {
|
||||||
|
const config = require('./config.json') as Config
|
||||||
|
|
||||||
|
console.log(`Starting server on ${config.host}:${config.port}`)
|
||||||
|
const webServer = http.createServer((req, res) => {
|
||||||
|
serveHandler(req, res, {
|
||||||
|
public: 'public',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
webServer.on('request', (req) => {
|
||||||
|
console.log(`[${req.method}] ${req.url}`)
|
||||||
|
})
|
||||||
|
webServer.on('upgrade', (req, _) => {
|
||||||
|
console.log(`[${req.method}] ${req.url}\n` + ` Upgrade: ${req.headers.upgrade}`)
|
||||||
|
})
|
||||||
|
webServer.listen(config.port, config.host)
|
||||||
|
|
||||||
|
const websocketServer = new websocket.Server({
|
||||||
|
server: webServer,
|
||||||
|
})
|
||||||
|
|
||||||
|
const broadcastServer = new Server()
|
||||||
|
websocketServer.on('connection', (socket: WebSocket) => broadcastServer.onConnection(socket))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the server the client wants to join the given room
|
||||||
|
*/
|
||||||
|
interface MessageJoin {
|
||||||
|
type: 'join'
|
||||||
|
roomId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the client that it is a broadcaster
|
||||||
|
*/
|
||||||
|
interface MessageBroadcast {
|
||||||
|
type: 'broadcast'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the client that it is a viewer
|
||||||
|
*/
|
||||||
|
interface MessageView {
|
||||||
|
type: 'view'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the server to resend the VIEWER messages
|
||||||
|
*/
|
||||||
|
interface MessageRequestViewers {
|
||||||
|
type: 'requestviewers'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the broadcaster a viewer has connected
|
||||||
|
*/
|
||||||
|
interface MessageViewer {
|
||||||
|
type: 'viewer'
|
||||||
|
viewerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a WebRTC message between the viewer and the server
|
||||||
|
*/
|
||||||
|
interface MessageWebRTCViewer {
|
||||||
|
type: 'webrtcviewer'
|
||||||
|
kind: 'offer' | 'answer' | 'candidate'
|
||||||
|
message: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a WebRTC message between the server and the broadcaster
|
||||||
|
*/
|
||||||
|
interface MessageWebRTCBroadcaster {
|
||||||
|
type: 'webrtcbroadcaster'
|
||||||
|
viewerId: string
|
||||||
|
kind: 'offer' | 'answer' | 'candidate'
|
||||||
|
message: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the broadcaster a viewer has disconnected
|
||||||
|
*/
|
||||||
|
interface MessageViewerDisconnected {
|
||||||
|
type: 'viewerdisconnected'
|
||||||
|
viewerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the viewer the broadcaster has disconnected.
|
||||||
|
*/
|
||||||
|
interface MessageBroadcasterDisconnected {
|
||||||
|
type: 'broadcasterdisconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
type FromBroadcasterMessage = MessageJoin | MessageWebRTCBroadcaster | MessageRequestViewers
|
||||||
|
type FromViewerMessage = MessageJoin | MessageWebRTCViewer
|
||||||
|
|
||||||
|
class Server {
|
||||||
|
/**
|
||||||
|
* Object containing kv-pairs of room IDs and room class instances.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private rooms = new Map<string, Room>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a new WebSocket connection.
|
||||||
|
*
|
||||||
|
* @param socket
|
||||||
|
*/
|
||||||
|
onConnection(socket: WebSocket): void {
|
||||||
|
socket.onmessage = (event: MessageEvent) => {
|
||||||
|
let message
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data)
|
||||||
|
} catch (e) {
|
||||||
|
// The JSON message is invalid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type != 'join') {
|
||||||
|
// No messages are valid until a client has sent a "JOIN"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = message.roomId
|
||||||
|
|
||||||
|
if (roomId == null || roomId.length < 1) {
|
||||||
|
// No, or an invalid roomId was given in the message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.rooms.has(roomId)) {
|
||||||
|
this.rooms.get(roomId)?.addViewer(socket)
|
||||||
|
} else {
|
||||||
|
this.newRoom(roomId, socket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new room with the given roomId and broadcaster. Throws an exception
|
||||||
|
* if the roomId is already taken.
|
||||||
|
*
|
||||||
|
* @param roomId
|
||||||
|
* @param broadcaster
|
||||||
|
*/
|
||||||
|
newRoom(roomId: string, broadcaster: WebSocket): void {
|
||||||
|
if (this.rooms.has(roomId)) {
|
||||||
|
throw (
|
||||||
|
'Attempted to create room with the same ID as an existing room. ' +
|
||||||
|
'This likely indicates an error in the server implementation.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rooms.set(roomId, new Room(broadcaster))
|
||||||
|
broadcaster.onclose = (_event: CloseEvent) => this.closeRoom(roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the room with the given roomId.
|
||||||
|
*
|
||||||
|
* @param roomId The ID of the room to close
|
||||||
|
*/
|
||||||
|
closeRoom(roomId: string): void {
|
||||||
|
this.rooms.get(roomId)?.closeRoom()
|
||||||
|
this.rooms.delete(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a screensharing room.
|
||||||
|
*/
|
||||||
|
class Room {
|
||||||
|
private counter: number = 0
|
||||||
|
|
||||||
|
private broadcaster: WebSocket
|
||||||
|
private viewers: { [id: string]: WebSocket } = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room constructor.
|
||||||
|
*
|
||||||
|
* @param broadcaster The WebSocket of the broadcaster of this room
|
||||||
|
*/
|
||||||
|
constructor(broadcaster: WebSocket) {
|
||||||
|
this.broadcaster = broadcaster
|
||||||
|
|
||||||
|
broadcaster.onmessage = (event: MessageEvent) => {
|
||||||
|
let message
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data)
|
||||||
|
} catch (e) {
|
||||||
|
// The JSON message is invalid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleBroadcasterMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the client that he has been assigned the role "broadcaster"
|
||||||
|
const message: MessageBroadcast = {
|
||||||
|
type: 'broadcast',
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcaster.send(JSON.stringify(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called whenever a broadcaster sends a message.
|
||||||
|
*
|
||||||
|
* @param msg The message
|
||||||
|
*/
|
||||||
|
handleBroadcasterMessage(msg: any): void {
|
||||||
|
if (!instanceOfFromBroadcasterMessage(msg)) {
|
||||||
|
// The given message is not valid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'webrtcbroadcaster': {
|
||||||
|
const viewerId = msg.viewerId
|
||||||
|
const viewer = this.viewers[viewerId]
|
||||||
|
|
||||||
|
if (viewer == null) {
|
||||||
|
// No viewer with the ID "viewerId" exists
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const message: MessageWebRTCViewer = {
|
||||||
|
type: 'webrtcviewer',
|
||||||
|
kind: msg.kind,
|
||||||
|
message: msg.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer.send(JSON.stringify(message))
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'requestviewers': {
|
||||||
|
for (const viewerId in this.viewers) {
|
||||||
|
const messageViewer: MessageViewer = {
|
||||||
|
type: 'viewer',
|
||||||
|
viewerId: viewerId,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcaster.send(JSON.stringify(messageViewer))
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called to add a new viewer to this room.
|
||||||
|
*
|
||||||
|
* @param viewer The WebSocket of the viewer that joined the room
|
||||||
|
*/
|
||||||
|
addViewer(viewer: WebSocket): void {
|
||||||
|
const id: string = (this.counter++).toString()
|
||||||
|
|
||||||
|
viewer.onmessage = (event: MessageEvent) => {
|
||||||
|
let message
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data)
|
||||||
|
} catch (e) {
|
||||||
|
// The JSON message is invalid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleViewerMessage(id, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer.onclose = (_event: CloseEvent) => this.handleViewerDisconnect(id)
|
||||||
|
|
||||||
|
// Tell the client that he has been assigned the role "viewer"
|
||||||
|
const messageView: MessageView = {
|
||||||
|
type: 'view',
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer.send(JSON.stringify(messageView))
|
||||||
|
|
||||||
|
// Tell the broadcaster a viewer has connected
|
||||||
|
const messageViewer: MessageViewer = {
|
||||||
|
type: 'viewer',
|
||||||
|
viewerId: id,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcaster.send(JSON.stringify(messageViewer))
|
||||||
|
this.viewers[id] = viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called whenever a viewer sends a message.
|
||||||
|
*
|
||||||
|
* @param viewerId The ID of the viewer that sent the message
|
||||||
|
* @param msg The message
|
||||||
|
*/
|
||||||
|
handleViewerMessage(viewerId: string, msg: any): void {
|
||||||
|
if (!instanceOfFromViewerMessage(msg)) {
|
||||||
|
// The given message is not valid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'webrtcviewer': {
|
||||||
|
const message: MessageWebRTCBroadcaster = {
|
||||||
|
type: 'webrtcbroadcaster',
|
||||||
|
kind: msg.kind,
|
||||||
|
message: msg.message,
|
||||||
|
viewerId: viewerId,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcaster.send(JSON.stringify(message))
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called whenever a viewer disconnects.
|
||||||
|
*
|
||||||
|
* @param viewerId The ID of the viewer that disconnected
|
||||||
|
*/
|
||||||
|
handleViewerDisconnect(viewerId: string): void {
|
||||||
|
if (!(viewerId in this.viewers)) {
|
||||||
|
throw (
|
||||||
|
'Attempted to remove nonexistent viewer from room. ' +
|
||||||
|
'This likely indicates an error in the server implementation.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.viewers[viewerId]
|
||||||
|
|
||||||
|
// Notify the broadcaster of the disconnect
|
||||||
|
const message: MessageViewerDisconnected = {
|
||||||
|
type: 'viewerdisconnected',
|
||||||
|
viewerId: viewerId,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcaster.send(JSON.stringify(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the room and tells all viewers the broadcaster has disconnected.
|
||||||
|
*/
|
||||||
|
closeRoom(): void {
|
||||||
|
for (const viewerId in this.viewers) {
|
||||||
|
const viewer = this.viewers[viewerId]
|
||||||
|
const messageBroadcasterDisconnected: MessageBroadcasterDisconnected = {
|
||||||
|
type: 'broadcasterdisconnected',
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer.send(JSON.stringify(messageBroadcasterDisconnected))
|
||||||
|
viewer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if and only if the given object is a FromBroadcasterMessage.
|
||||||
|
*
|
||||||
|
* @param object
|
||||||
|
*/
|
||||||
|
function instanceOfFromBroadcasterMessage(object: any): object is FromBroadcasterMessage {
|
||||||
|
return (
|
||||||
|
instanceOfMessageJoin(object) ||
|
||||||
|
instanceOfMessageWebRTCBroadcaster(object) ||
|
||||||
|
instanceOfMessageRequestViewers(object)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if and only if the given object is a FromViewerMessage.
|
||||||
|
*
|
||||||
|
* @param object
|
||||||
|
*/
|
||||||
|
function instanceOfFromViewerMessage(object: any): object is FromViewerMessage {
|
||||||
|
return instanceOfMessageJoin(object) || instanceOfMessageWebRTCViewer(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if and only if the given object is a MessageJoin.
|
||||||
|
*
|
||||||
|
* @param object
|
||||||
|
*/
|
||||||
|
function instanceOfMessageJoin(object: any): object is MessageJoin {
|
||||||
|
const goodType = 'type' in object && object.type === 'join'
|
||||||
|
const goodRoomId = 'roomId' in object && typeof object.roomId === 'string'
|
||||||
|
|
||||||
|
return goodType && goodRoomId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if and only if the given object is a MessageWebRTCBroadcaster.
|
||||||
|
*
|
||||||
|
* @param object
|
||||||
|
*/
|
||||||
|
function instanceOfMessageWebRTCBroadcaster(object: any): object is MessageWebRTCBroadcaster {
|
||||||
|
const goodType = 'type' in object && object.type === 'webrtcbroadcaster'
|
||||||
|
const goodViewerId = 'viewerId' in object && typeof object.viewerId === 'string'
|
||||||
|
const goodKind = 'kind' in object && ['offer', 'answer', 'candidate'].includes(object.kind)
|
||||||
|
const goodMessage = 'message' in object
|
||||||
|
|
||||||
|
return goodType && goodViewerId && goodKind && goodMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if and only if the given object is a MessageRequestViewers.
|
||||||
|
*
|
||||||
|
* @param object
|
||||||
|
*/
|
||||||
|
function instanceOfMessageRequestViewers(object: any): object is MessageRequestViewers {
|
||||||
|
return 'type' in object && object.type === 'requestviewers'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if and only if the given object is a MessageWebRTCViewer.
|
||||||
|
*
|
||||||
|
* @param object
|
||||||
|
*/
|
||||||
|
function instanceOfMessageWebRTCViewer(object: any): object is MessageWebRTCViewer {
|
||||||
|
const goodType = 'type' in object && object.type === 'webrtcviewer'
|
||||||
|
const goodKind = 'kind' in object && ['offer', 'answer', 'candidate'].includes(object.kind)
|
||||||
|
const goodMessage = 'message' in object
|
||||||
|
|
||||||
|
return goodType && goodKind && goodMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
18
src/server/tsconfig.json
Normal file
18
src/server/tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "ES2018",
|
||||||
|
"sourceMap": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"strictPropertyInitialization": true,
|
||||||
|
"strict": true,
|
||||||
|
"lib": ["ES2018", "DOM"],
|
||||||
|
"outDir": "../../build"
|
||||||
|
},
|
||||||
|
"files": ["server.ts"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user