it works!!!

This commit is contained in:
Altair-sh 2025-09-19 06:59:13 +05:00
commit 1e47627c27
12 changed files with 1491 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
build/
package-lock.json
config.json
*.log

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
package-lock.json

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"tabWidth": 4,
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 120
}

17
README.md Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"host": "127.0.0.1",
"port": 4004
}

23
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}