commit 1e47627c271760bb99c7f06067bb3cf7da30656a Author: Altair-sh Date: Fri Sep 19 06:59:13 2025 +0500 it works!!! diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bfab0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +build/ +package-lock.json +config.json +*.log diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d8b83df --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..cb97e3d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "tabWidth": 4, + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 120 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d43689 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/default-config.json b/default-config.json new file mode 100644 index 0000000..842f722 --- /dev/null +++ b/default-config.json @@ -0,0 +1,4 @@ +{ + "host": "127.0.0.1", + "port": 4004 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..63c4560 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..efb5f58 --- /dev/null +++ b/public/index.html @@ -0,0 +1,82 @@ + + + + + screensy + + + + + + + + + + + + + + + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..1e3fec0 --- /dev/null +++ b/public/styles.css @@ -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; + } +} diff --git a/src/client/screensy.ts b/src/client/screensy.ts new file mode 100644 index 0000000..f46d644 --- /dev/null +++ b/src/client/screensy.ts @@ -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 +} + +/** + * 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 { + // Lambda that returns a listener for the given resolve lambda + const listener = (resolve: (value: Event | PromiseLike) => 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + showPopup('broadcaster-disconnected') + document.body.removeChild(this.videoElement) + } + + /** + * Handles incoming WebRTC messages. + * + * @param msg + * @private + */ + private async handleWebRTCMessage(msg: MessageWebRTCViewer): Promise { + 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 { + 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 = 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 { + 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 { + // 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 { + 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) diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json new file mode 100644 index 0000000..14f4b2c --- /dev/null +++ b/src/client/tsconfig.json @@ -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"] +} diff --git a/src/server/server.ts b/src/server/server.ts new file mode 100644 index 0000000..79995a4 --- /dev/null +++ b/src/server/server.ts @@ -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() + + /** + * 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() diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json new file mode 100644 index 0000000..1f4e73b --- /dev/null +++ b/src/server/tsconfig.json @@ -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"] +}