Add webrtc signaling example

This commit is contained in:
Fabio Alessandrelli
2019-06-14 05:53:02 +02:00
parent 0bbe5ef586
commit 2a57c64c30
14 changed files with 1325 additions and 0 deletions

View File

@@ -0,0 +1 @@
webrtc

View File

@@ -0,0 +1,32 @@
# A WebSocket signaling server/client for WebRTC.
This demo is devided in 4 parts:
- The `server` folder contains the signaling server implementation written in GDScript (so it can be run by a game server running Godot)
- The `server_node` folder contains the signaling server implementation written in Node.js (if you don't plan to run a game server but only match-making).
- The `client` part contains the client implementation in GDScript.
- Itself divided into raw protocol and `WebRTCMultiplayer` handling.
- The `demo` contains a small app that uses it.
**NOTE**: You must extract the [latest version](https://github.com/godotengine/webrtc-native/releases) of the WebRTC GDNative plugin in the project folder to run from desktop.
## Protocol
The protocol is text based, and composed by a command and possibly multiple payload arguments, each separated by a new line.
Messages without payload must still end with a newline and are the following:
- `J: ` (or `J: <ROOM>`), must be sent by client immediately after connection to get a lobby assigned or join a known one.
This messages is also sent by server back to the client to notify assigned lobby, or simply a successful join.
- `I: <ID>`, sent by server to identify the client when it joins a room.
- `N: <ID>`, sent by server to notify new peers in the same lobby.
- `D: <ID>`, sent by server to notify when a peer in the same lobby disconnects.
- `S: `, sent by client to seal the lobby (only the client that created it is allowed to seal a lobby).
When a lobby is sealed, no new client will be able to join, and the lobby will be destroyed (and clients disconnected) after 10 seconds.
Messages with payload (used to transfer WebRTC parameters) are:
- `O: <ID>`, used to send an offer.
- `A: <ID>`, used to send an answer.
- `C: <ID>`, used to send a candidate.
When sending the parameter, a client will set `<ID>` as the destination peer, the server will replace it with the id of the sending peer, and rely it to the proper destination.

View File

@@ -0,0 +1,86 @@
extends "ws_webrtc_client.gd"
var rtc_mp : WebRTCMultiplayer = WebRTCMultiplayer.new()
var sealed = false
func _init():
connect("connected", self, "connected")
connect("disconnected", self, "disconnected")
connect("offer_received", self, "offer_received")
connect("answer_received", self, "answer_received")
connect("candidate_received", self, "candidate_received")
connect("lobby_joined", self, "lobby_joined")
connect("lobby_sealed", self, "lobby_sealed")
connect("peer_connected", self, "peer_connected")
connect("peer_disconnected", self, "peer_disconnected")
func start(url, lobby = ""):
stop()
sealed = false
self.lobby = lobby
connect_to_url(url)
func stop():
rtc_mp.close()
close()
func _create_peer(id : int):
var peer : WebRTCPeerConnection = WebRTCPeerConnection.new()
peer.initialize({
"iceServers": [ { "urls": ["stun:stun.l.google.com:19302"] } ]
})
peer.connect("session_description_created", self, "_offer_created", [id])
peer.connect("ice_candidate_created", self, "_new_ice_candidate", [id])
rtc_mp.add_peer(peer, id)
if id > rtc_mp.get_unique_id():
peer.create_offer()
return peer
func _new_ice_candidate(mid_name : String, index_name : int, sdp_name : String, id : int):
send_candidate(id, mid_name, index_name, sdp_name)
func _offer_created(type : String, data : String, id : int):
if not rtc_mp.has_peer(id):
return
print("created", type)
rtc_mp.get_peer(id).connection.set_local_description(type, data)
if type == "offer": send_offer(id, data)
else: send_answer(id, data)
func connected(id : int):
print("Connected %d" % id)
rtc_mp.initialize(id, true)
func lobby_joined(lobby : String):
self.lobby = lobby
func lobby_sealed():
sealed = true
func disconnected():
print("Disconnected: %d: %s" % [code, reason])
if not sealed:
stop() # Unexpected disconnect
func peer_connected(id : int):
print("Peer connected %d" % id)
_create_peer(id)
func peer_disconnected(id : int):
if rtc_mp.has_peer(id): rtc_mp.remove_peer(id)
func offer_received(id : int, offer : String):
print("Got offer: %d" % id)
if rtc_mp.has_peer(id):
rtc_mp.get_peer(id).connection.set_remote_description("offer", offer)
func answer_received(id : int, answer : String):
print("Got answer: %d" % id)
if rtc_mp.has_peer(id):
rtc_mp.get_peer(id).connection.set_remote_description("answer", answer)
func candidate_received(id : int, mid : String, index : int, sdp : String):
if rtc_mp.has_peer(id):
rtc_mp.get_peer(id).connection.add_ice_candidate(mid, index, sdp)

View File

@@ -0,0 +1,116 @@
extends Node
export var autojoin = true
export var lobby = "" # Will create a new lobby if empty
var client : WebSocketClient = WebSocketClient.new()
var code = 1000
var reason = "Unknown"
signal lobby_joined(lobby)
signal connected(id)
signal disconnected()
signal peer_connected(id)
signal peer_disconnected(id)
signal offer_received(id, offer)
signal answer_received(id, answer)
signal candidate_received(id, mid, index, sdp)
signal lobby_sealed()
func _init():
client.connect("data_received", self, "_parse_msg")
client.connect("connection_established", self, "_connected")
client.connect("connection_closed", self, "_closed")
client.connect("connection_error", self, "_closed")
client.connect("server_close_request", self, "_close_request")
func connect_to_url(url : String):
close()
code = 1000
reason = "Unknown"
client.connect_to_url(url)
func close():
client.disconnect_from_host()
func _closed(was_clean : bool = false):
emit_signal("disconnected")
func _close_request(code : int, reason : String):
self.code = code
self.reason = reason
func _connected(protocol = ""):
client.get_peer(1).set_write_mode(WebSocketPeer.WRITE_MODE_TEXT)
if autojoin:
join_lobby(lobby)
func _parse_msg():
var pkt_str : String = client.get_peer(1).get_packet().get_string_from_utf8()
var req : PoolStringArray = pkt_str.split('\n', true, 1)
if req.size() != 2: # Invalid request size
return
var type : String = req[0]
if type.length() < 3: # Invalid type size
return
if type.begins_with("J: "):
emit_signal("lobby_joined", type.substr(3, type.length() - 3))
return
elif type.begins_with("S: "):
emit_signal("lobby_sealed")
return
var src_str : String = type.substr(3, type.length() - 3)
if not src_str.is_valid_integer(): # Source id is not an integer
return
var src_id : int = int(src_str)
if type.begins_with("I: "):
emit_signal("connected", src_id)
elif type.begins_with("N: "):
# Client connected
emit_signal("peer_connected", src_id)
elif type.begins_with("D: "):
# Client connected
emit_signal("peer_disconnected", src_id)
elif type.begins_with("O: "):
# Offer received
emit_signal("offer_received", src_id, req[1])
elif type.begins_with("A: "):
# Answer received
emit_signal("answer_received", src_id, req[1])
elif type.begins_with("C: "):
# Candidate received
var candidate : PoolStringArray = req[1].split('\n', false)
if candidate.size() != 3:
return
if not candidate[1].is_valid_integer():
return
emit_signal("candidate_received", src_id, candidate[0], int(candidate[1]), candidate[2])
func join_lobby(lobby : String):
return client.get_peer(1).put_packet(("J: %s\n" % lobby).to_utf8())
func seal_lobby():
return client.get_peer(1).put_packet("S: \n".to_utf8())
func send_candidate(id : int, mid : String, index : int, sdp : String) -> int:
return _send_msg("C", id, "\n%s\n%d\n%s" % [mid, index, sdp])
func send_offer(id : int, offer : String) -> int:
return _send_msg("O", id, offer)
func send_answer(id : int, answer : String) -> int:
return _send_msg("A", id, answer)
func _send_msg(type : String, id : int, data : String) -> int:
return client.get_peer(1).put_packet(("%s: %d\n%s" % [type, id, data]).to_utf8())
func _process(delta):
var status : int = client.get_connection_status()
if status == WebSocketClient.CONNECTION_CONNECTING or status == WebSocketClient.CONNECTION_CONNECTED:
client.poll()

View File

@@ -0,0 +1,64 @@
extends Control
onready var client = $Client
func _ready():
client.connect("lobby_joined", self, "_lobby_joined")
client.connect("lobby_sealed", self, "_lobby_sealed")
client.connect("connected", self, "_connected")
client.connect("disconnected", self, "_disconnected")
client.rtc_mp.connect("peer_connected", self, "_mp_peer_connected")
client.rtc_mp.connect("peer_disconnected", self, "_mp_peer_disconnected")
client.rtc_mp.connect("server_disconnected", self, "_mp_server_disconnect")
client.rtc_mp.connect("connection_succeeded", self, "_mp_connected")
func _process(delta):
client.rtc_mp.poll()
while client.rtc_mp.get_available_packet_count() > 0:
_log(client.rtc_mp.get_packet().get_string_from_utf8())
func _connected(id):
_log("Signaling server connected with ID: %d" % id)
func _disconnected():
_log("Signaling server disconnected: %d - %s" % [client.code, client.reason])
func _lobby_joined(lobby):
_log("Joined lobby %s" % lobby)
func _lobby_sealed():
_log("Lobby has been sealed")
func _mp_connected():
_log("Multiplayer is connected (I am %d)" % client.rtc_mp.get_unique_id())
func _mp_server_disconnect():
_log("Multiplayer is disconnected (I am %d)" % client.rtc_mp.get_unique_id())
func _mp_peer_connected(id : int):
_log("Multiplayer peer %d connected" % id)
func _mp_peer_disconnected(id : int):
_log("Multiplayer peer %d disconnected" % id)
func _log(msg):
print(msg)
$vbox/TextEdit.text += str(msg) + "\n"
func ping():
_log(client.rtc_mp.put_packet("ping".to_utf8()))
func _on_Peers_pressed():
var d = client.rtc_mp.get_peers()
_log(d)
for k in d:
_log(client.rtc_mp.get_peer(k))
func start():
client.start($vbox/connect/host.text, $vbox/connect/RoomSecret.text)
func _on_Seal_pressed():
client.seal_lobby()
func stop():
client.stop()

View File

@@ -0,0 +1,104 @@
[gd_scene load_steps=3 format=2]
[ext_resource path="res://demo/client_ui.gd" type="Script" id=1]
[ext_resource path="res://client/multiplayer_client.gd" type="Script" id=2]
[node name="ClientUI" type="Control"]
margin_right = 1024.0
margin_bottom = 600.0
size_flags_horizontal = 3
size_flags_vertical = 3
script = ExtResource( 1 )
__meta__ = {
"_edit_use_anchors_": true
}
[node name="Client" type="Node" parent="."]
script = ExtResource( 2 )
[node name="vbox" type="VBoxContainer" parent="."]
anchor_right = 1.0
anchor_bottom = 1.0
__meta__ = {
"_edit_use_anchors_": false
}
[node name="connect" type="HBoxContainer" parent="vbox"]
margin_right = 1024.0
margin_bottom = 24.0
[node name="Label" type="Label" parent="vbox/connect"]
margin_top = 5.0
margin_right = 73.0
margin_bottom = 19.0
text = "Connect to:"
[node name="host" type="LineEdit" parent="vbox/connect"]
margin_left = 77.0
margin_right = 921.0
margin_bottom = 24.0
size_flags_horizontal = 3
text = "ws://localhost:9080"
[node name="Room" type="Label" parent="vbox/connect"]
margin_left = 925.0
margin_right = 962.0
margin_bottom = 24.0
size_flags_vertical = 5
text = "Room"
valign = 1
[node name="RoomSecret" type="LineEdit" parent="vbox/connect"]
margin_left = 966.0
margin_right = 1024.0
margin_bottom = 24.0
placeholder_text = "secret"
[node name="HBoxContainer" type="HBoxContainer" parent="vbox"]
margin_top = 28.0
margin_right = 1024.0
margin_bottom = 48.0
__meta__ = {
"_edit_use_anchors_": false
}
[node name="Start" type="Button" parent="vbox/HBoxContainer"]
margin_right = 41.0
margin_bottom = 20.0
text = "Start"
[node name="Stop" type="Button" parent="vbox/HBoxContainer"]
margin_left = 45.0
margin_right = 85.0
margin_bottom = 20.0
text = "Stop"
[node name="Seal" type="Button" parent="vbox/HBoxContainer"]
margin_left = 89.0
margin_right = 127.0
margin_bottom = 20.0
text = "Seal"
[node name="Ping" type="Button" parent="vbox/HBoxContainer"]
margin_left = 131.0
margin_right = 170.0
margin_bottom = 20.0
text = "Ping"
[node name="Peers" type="Button" parent="vbox/HBoxContainer"]
margin_left = 174.0
margin_right = 256.0
margin_bottom = 20.0
text = "Print peers"
[node name="TextEdit" type="TextEdit" parent="vbox"]
margin_top = 52.0
margin_right = 1024.0
margin_bottom = 600.0
size_flags_vertical = 3
readonly = true
[connection signal="pressed" from="vbox/HBoxContainer/Start" to="." method="start"]
[connection signal="pressed" from="vbox/HBoxContainer/Stop" to="." method="stop"]
[connection signal="pressed" from="vbox/HBoxContainer/Seal" to="." method="_on_Seal_pressed"]
[connection signal="pressed" from="vbox/HBoxContainer/Ping" to="." method="ping"]
[connection signal="pressed" from="vbox/HBoxContainer/Peers" to="." method="_on_Peers_pressed"]

View File

@@ -0,0 +1,14 @@
extends Control
func _ready():
if OS.get_name() == "HTML5":
$vbox/Signaling.hide()
func _on_listen_toggled(button_pressed):
if button_pressed:
$Server.listen(int($vbox/Signaling/port.value))
else:
$Server.stop()
func _on_LinkButton_pressed():
OS.shell_open("https://github.com/godotengine/webrtc-native/releases")

View File

@@ -0,0 +1,91 @@
[gd_scene load_steps=4 format=2]
[ext_resource path="res://demo/main.gd" type="Script" id=1]
[ext_resource path="res://demo/client_ui.tscn" type="PackedScene" id=2]
[ext_resource path="res://server/ws_webrtc_server.gd" type="Script" id=3]
[node name="Control" type="Control"]
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource( 1 )
__meta__ = {
"_edit_use_anchors_": true
}
[node name="vbox" type="VBoxContainer" parent="."]
anchor_right = 1.0
anchor_bottom = 1.0
custom_constants/separation = 50
__meta__ = {
"_edit_use_anchors_": true
}
[node name="Signaling" type="HBoxContainer" parent="vbox"]
margin_right = 1024.0
margin_bottom = 24.0
[node name="Label" type="Label" parent="vbox/Signaling"]
margin_top = 5.0
margin_right = 104.0
margin_bottom = 19.0
text = "Signaling server:"
[node name="port" type="SpinBox" parent="vbox/Signaling"]
margin_left = 108.0
margin_right = 182.0
margin_bottom = 24.0
min_value = 1025.0
max_value = 65535.0
value = 9080.0
[node name="listen" type="Button" parent="vbox/Signaling"]
margin_left = 186.0
margin_right = 237.0
margin_bottom = 24.0
toggle_mode = true
text = "Listen"
[node name="CenterContainer" type="CenterContainer" parent="vbox/Signaling"]
margin_left = 241.0
margin_right = 1024.0
margin_bottom = 24.0
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="LinkButton" type="LinkButton" parent="vbox/Signaling/CenterContainer"]
margin_left = 118.0
margin_top = 5.0
margin_right = 664.0
margin_bottom = 19.0
text = "Make sure to download the GDNative WebRTC Plugin and place it in the project folder"
[node name="Clients" type="GridContainer" parent="vbox"]
margin_top = 74.0
margin_right = 1024.0
margin_bottom = 600.0
size_flags_horizontal = 3
size_flags_vertical = 3
columns = 2
[node name="ClientUI" parent="vbox/Clients" instance=ExtResource( 2 )]
margin_right = 510.0
margin_bottom = 261.0
[node name="ClientUI2" parent="vbox/Clients" instance=ExtResource( 2 )]
margin_left = 514.0
margin_bottom = 261.0
[node name="ClientUI3" parent="vbox/Clients" instance=ExtResource( 2 )]
margin_top = 265.0
margin_right = 510.0
margin_bottom = 526.0
[node name="ClientUI4" parent="vbox/Clients" instance=ExtResource( 2 )]
margin_left = 514.0
margin_top = 265.0
margin_bottom = 526.0
[node name="Server" type="Node" parent="."]
script = ExtResource( 3 )
[connection signal="toggled" from="vbox/Signaling/listen" to="." method="_on_listen_toggled"]
[connection signal="pressed" from="vbox/Signaling/CenterContainer/LinkButton" to="." method="_on_LinkButton_pressed"]

View File

@@ -0,0 +1,34 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=4
_global_script_classes=[ ]
_global_script_class_icons={
}
[application]
config/name="WebRTC Signaling Example"
run/main_scene="res://demo/main.tscn"
[debug]
gdscript/warnings/shadowed_variable=false
gdscript/warnings/unused_argument=false
gdscript/warnings/return_value_discarded=false
[gdnative]
singletons=[ "res://webrtc/webrtc.tres" ]
singletons_disabled=[ ]
[network]
modules/webrtc_gdnative_script="res://demo/webrtc/webrtc.gdns"

View File

@@ -0,0 +1,195 @@
extends Node
const TIMEOUT = 1000 # Unresponsive clients times out after 1 sec
const SEAL_TIME = 10000 # A sealed room will be closed after this time
const ALFNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
var _alfnum = ALFNUM.to_ascii()
class Peer extends Reference:
var id = -1
var lobby = ""
var time = OS.get_ticks_msec()
func _init(peer_id):
id = peer_id
class Lobby extends Reference:
var peers : Array = []
var host : int = -1
var sealed : bool = false
var time = 0
func _init(host_id : int):
host = host_id
func join(peer_id : int, server : WebSocketServer) -> bool:
if sealed: return false
if not server.has_peer(peer_id): return false
var new_peer : WebSocketPeer = server.get_peer(peer_id)
new_peer.put_packet(("I: %d\n" % (1 if peer_id == host else peer_id)).to_utf8())
for p in peers:
if not server.has_peer(p):
continue
server.get_peer(p).put_packet(("N: %d\n" % peer_id).to_utf8())
new_peer.put_packet(("N: %d\n" % (1 if p == host else p)).to_utf8())
peers.push_back(peer_id)
return true
func leave(peer_id : int, server : WebSocketServer) -> bool:
if not peers.has(peer_id): return false
peers.erase(peer_id)
var close = false
if peer_id == host:
# The room host disconnected, will disconnect all peers
close = true
if sealed: return close
# Notify other peers
for p in peers:
if not server.has_peer(p): return close
if close:
# Disconnect peers
server.disconnect_peer(p)
else:
# Notify disconnection
server.get_peer(p).put_packet(("D: %d\n" % peer_id).to_utf8())
return close
func seal(peer_id : int, server : WebSocketServer) -> bool:
# Only host can seal the room
if host != peer_id: return false
sealed = true
for p in peers:
server.get_peer(p).put_packet("S: \n".to_utf8())
time = OS.get_ticks_msec()
return true
var rand : RandomNumberGenerator = RandomNumberGenerator.new()
var lobbies : Dictionary = {}
var server : WebSocketServer = WebSocketServer.new()
var peers : Dictionary = {}
func _init():
server.connect("data_received", self, "_on_data")
server.connect("client_connected", self, "_peer_connected")
server.connect("client_disconnected", self, "_peer_disconnected")
func _process(delta):
poll()
func listen(port : int):
stop()
rand.seed = OS.get_unix_time()
server.listen(port)
func stop():
server.stop()
peers.clear()
func poll():
if not server.is_listening():
return
server.poll()
# Peers timeout
for p in peers.values():
if p.lobby == "" and OS.get_ticks_msec() - p.time > TIMEOUT:
server.disconnect_peer(p.id)
# Lobby seal
for k in lobbies:
if not lobbies[k].sealed:
continue
if lobbies[k].time + SEAL_TIME < OS.get_ticks_msec():
# Close lobby
for p in lobbies[k].peers:
server.disconnect_peer(p)
func _peer_connected(id : int, protocol = ""):
peers[id] = Peer.new(id)
func _peer_disconnected(id : int, was_clean : bool = false):
var lobby = peers[id].lobby
print("Peer %d disconnected from lobby: '%s'" % [id, lobby])
if lobby and lobbies.has(lobby):
peers[id].lobby = ""
if lobbies[lobby].leave(id, server):
# If true, lobby host has disconnected, so delete it.
print("Deleted lobby %s" % lobby)
lobbies.erase(lobby)
peers.erase(id)
func _join_lobby(peer, lobby : String) -> bool:
if lobby == "":
for i in range(0, 32):
lobby += char(_alfnum[rand.randi_range(0, ALFNUM.length()-1)])
lobbies[lobby] = Lobby.new(peer.id)
elif not lobbies.has(lobby):
return false
lobbies[lobby].join(peer.id, server)
peer.lobby = lobby
# Notify peer of its lobby
server.get_peer(peer.id).put_packet(("J: %s\n" % lobby).to_utf8())
print("Peer %d joined lobby: '%s'" % [peer.id, lobby])
return true
func _on_data(id : int):
if not _parse_msg(id):
print("Parse message failed from peer %d" % id)
server.disconnect_peer(id)
func _parse_msg(id : int) -> bool:
var pkt_str : String = server.get_peer(id).get_packet().get_string_from_utf8()
var req : PoolStringArray = pkt_str.split('\n', true, 1)
if req.size() != 2: # Invalid request size
return false
var type : String = req[0]
if type.length() < 3: # Invalid type size
return false
if type.begins_with("J: "):
if peers[id].lobby: # Peer must not have joined a lobby already!
return false
return _join_lobby(peers[id], type.substr(3, type.length() - 3))
if not peers[id].lobby: # Messages across peers are only allowed in same lobby
return false
if not lobbies.has(peers[id].lobby): # Lobby not found?
return false
var lobby = lobbies[peers[id].lobby]
if type.begins_with("S: "):
# Client is sealing the room
return lobby.seal(id, server)
var dest_str : String = type.substr(3, type.length() - 3)
if not dest_str.is_valid_integer(): # Destination id is not an integer
return false
var dest_id : int = int(dest_str)
if dest_id == NetworkedMultiplayerPeer.TARGET_PEER_SERVER:
dest_id = lobby.host
if not peers.has(dest_id): # Destination ID not connected
return false
if peers[dest_id].lobby != peers[id].lobby: # Trying to contact someone not in same lobby
return false
if id == lobby.host:
id = NetworkedMultiplayerPeer.TARGET_PEER_SERVER
if type.begins_with("O: "):
# Client is making an offer
server.get_peer(dest_id).put_packet(("O: %d\n%s" % [id, req[1]]).to_utf8())
elif type.begins_with("A: "):
# Client is making an answer
server.get_peer(dest_id).put_packet(("A: %d\n%s" % [id, req[1]]).to_utf8())
elif type.begins_with("C: "):
# Client is making an answer
server.get_peer(dest_id).put_packet(("C: %d\n%s" % [id, req[1]]).to_utf8())
return true

View File

@@ -0,0 +1,318 @@
{
"env": {
"browser": true,
"commonjs": true,
"es6": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018
},
"rules": {
"accessor-pairs": "error",
"array-bracket-newline": "error",
"array-bracket-spacing": "error",
"array-callback-return": "error",
"array-element-newline": "error",
"arrow-body-style": "error",
"arrow-parens": "error",
"arrow-spacing": "error",
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": [
"error",
"1tbs"
],
"callback-return": "error",
"camelcase": "error",
"capitalized-comments": [
"error",
"always"
],
"class-methods-use-this": "error",
"comma-dangle": "error",
"comma-spacing": [
"error",
{
"after": true,
"before": false
}
],
"comma-style": "error",
"complexity": "error",
"computed-property-spacing": [
"error",
"never"
],
"consistent-return": "error",
"consistent-this": "error",
"curly": "off",
"default-case": "error",
"dot-location": "error",
"dot-notation": "error",
"eol-last": "error",
"eqeqeq": "error",
"func-call-spacing": "error",
"func-name-matching": "error",
"func-names": "error",
"func-style": [
"error",
"declaration"
],
"function-paren-newline": "error",
"generator-star-spacing": "error",
"global-require": "error",
"guard-for-in": "error",
"handle-callback-err": "error",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
"implicit-arrow-linebreak": "error",
"indent": [
"error",
"tab"
],
"indent-legacy": "off",
"init-declarations": "error",
"jsx-quotes": "error",
"key-spacing": "error",
"keyword-spacing": [
"error",
{
"after": true,
"before": true
}
],
"line-comment-position": "off",
"linebreak-style": [
"error",
"unix"
],
"lines-around-comment": "error",
"lines-around-directive": "error",
"lines-between-class-members": [
"error",
"never"
],
"max-classes-per-file": "off",
"max-depth": "error",
"max-len": [
"error",
{
"code": 80,
"tabWidth": 8
}
],
"max-lines": "error",
"max-lines-per-function": "error",
"max-nested-callbacks": "error",
"max-params": "error",
"max-statements": "off",
"max-statements-per-line": "error",
"multiline-comment-style": [
"error",
"separate-lines"
],
"new-cap": "error",
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
"newline-per-chained-call": "error",
"no-alert": "error",
"no-array-constructor": "error",
"no-async-promise-executor": "error",
"no-await-in-loop": "error",
"no-bitwise": "error",
"no-buffer-constructor": "error",
"no-caller": "error",
"no-catch-shadow": "error",
"no-confusing-arrow": "error",
"no-console": "off",
"no-continue": "error",
"no-div-regex": "error",
"no-duplicate-imports": "error",
"no-else-return": "error",
"no-empty-function": "error",
"no-eq-null": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-bind": "error",
"no-extra-label": "error",
"no-extra-parens": "error",
"no-floating-decimal": "error",
"no-implicit-coercion": "error",
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-inline-comments": "off",
"no-inner-declarations": [
"error",
"functions"
],
"no-invalid-this": "error",
"no-iterator": "error",
"no-label-var": "error",
"no-labels": "error",
"no-lone-blocks": "error",
"no-lonely-if": "error",
"no-loop-func": "error",
"no-magic-numbers": "off",
"no-misleading-character-class": "error",
"no-mixed-operators": "off",
"no-mixed-requires": "error",
"no-multi-assign": "error",
"no-multi-spaces": "error",
"no-multi-str": "error",
"no-multiple-empty-lines": "error",
"no-native-reassign": "error",
"no-negated-condition": "error",
"no-negated-in-lhs": "error",
"no-nested-ternary": "error",
"no-new": "error",
"no-new-func": "error",
"no-new-object": "error",
"no-new-require": "error",
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "error",
"no-path-concat": "error",
"no-plusplus": "off",
"no-process-env": "error",
"no-process-exit": "error",
"no-proto": "error",
"no-prototype-builtins": "error",
"no-restricted-globals": "error",
"no-restricted-imports": "error",
"no-restricted-modules": "error",
"no-restricted-properties": "error",
"no-restricted-syntax": "error",
"no-return-assign": "error",
"no-return-await": "error",
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": "error",
"no-shadow-restricted-names": "error",
"no-spaced-func": "error",
"no-sync": "error",
"no-tabs": [
"error",
{
"allowIndentationTabs": true
}
],
"no-template-curly-in-string": "error",
"no-ternary": "error",
"no-throw-literal": "error",
"no-trailing-spaces": "error",
"no-undef-init": "error",
"no-undefined": "error",
"no-underscore-dangle": "error",
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": "error",
"no-unused-expressions": "error",
"no-use-before-define": "error",
"no-useless-call": "error",
"no-useless-catch": "error",
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-constructor": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-void": "error",
"no-warning-comments": "error",
"no-whitespace-before-property": "error",
"no-with": "error",
"nonblock-statement-body-position": "error",
"object-curly-newline": "error",
"object-curly-spacing": [
"error",
"always"
],
"object-property-newline": "error",
"object-shorthand": "error",
"one-var": "off",
"one-var-declaration-per-line": "error",
"operator-assignment": [
"error",
"always"
],
"operator-linebreak": "error",
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "off",
"prefer-const": "error",
"prefer-destructuring": "error",
"prefer-named-capture-group": "error",
"prefer-numeric-literals": "error",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "error",
"prefer-reflect": "off",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "off",
"quote-props": "off",
"quotes": "error",
"radix": [
"error",
"as-needed"
],
"require-atomic-updates": "error",
"require-await": "error",
"require-jsdoc": "off",
"require-unicode-regexp": "error",
"rest-spread-spacing": "error",
"semi": "error",
"semi-spacing": [
"error",
{
"after": true,
"before": false
}
],
"semi-style": [
"error",
"last"
],
"sort-imports": "error",
"sort-keys": "error",
"sort-vars": "error",
"space-before-blocks": "error",
"space-before-function-paren": "error",
"space-in-parens": "error",
"space-infix-ops": "error",
"space-unary-ops": "error",
"spaced-comment": [
"error",
"always"
],
"strict": [
"error",
"never"
],
"switch-colon-spacing": "error",
"symbol-description": "error",
"template-curly-spacing": [
"error",
"never"
],
"template-tag-spacing": "error",
"unicode-bom": [
"error",
"never"
],
"valid-jsdoc": "error",
"vars-on-top": "error",
"wrap-iife": "error",
"wrap-regex": "error",
"yield-star-spacing": "error",
"yoda": [
"error",
"never"
]
}
}

View File

@@ -0,0 +1 @@
node_modules/

View File

@@ -0,0 +1,17 @@
{
"name": "signaling_server",
"version": "1.0.0",
"description": "",
"main": "server.js",
"dependencies": {
"ws": "^7.0.0"
},
"devDependencies": {
"eslint": "^5.16.0"
},
"scripts": {
"test": "eslint server.js && echo \"Lint OK\" && exit 0"
},
"author": "Fabio Alessandrelli",
"license": "MIT"
}

View File

@@ -0,0 +1,252 @@
const WebSocket = require("ws");
const crypto = require("crypto");
const MAX_PEERS = 4096;
const MAX_LOBBIES = 1024;
const PORT = 9080;
const ALFNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const NO_LOBBY_TIMEOUT = 1000;
const SEAL_CLOSE_TIMEOUT = 10000;
const PING_INTERVAL = 10000;
const STR_NO_LOBBY = "Have not joined lobby yet";
const STR_HOST_DISCONNECTED = "Room host has disconnected";
const STR_ONLY_HOST_CAN_SEAL = "Only host can seal the lobby";
const STR_SEAL_COMPLETE = "Seal complete";
const STR_TOO_MANY_LOBBIES = "Too many lobbies open, disconnecting";
const STR_ALREADY_IN_LOBBY = "Already in a lobby";
const STR_LOBBY_DOES_NOT_EXISTS = "Lobby does not exists";
const STR_LOBBY_IS_SEALED = "Lobby is sealed";
const STR_INVALID_FORMAT = "Invalid message format";
const STR_NEED_LOBBY = "Invalid message when not in a lobby";
const STR_SERVER_ERROR = "Server error, lobby not found";
const STR_INVALID_DEST = "Invalid destination";
const STR_INVALID_CMD = "Invalid command";
const STR_TOO_MANY_PEERS = "Too many peers connected";
const STR_INVALID_TRANSFER_MODE = "Invalid transfer mode, must be text";
function randomInt (low, high) {
return Math.floor(Math.random() * (high - low + 1) + low);
}
function randomId () {
return Math.abs(new Int32Array(crypto.randomBytes(4).buffer)[0]);
}
function randomSecret () {
let out = "";
for (let i = 0; i < 16; i++) {
out += ALFNUM[randomInt(0, ALFNUM.length - 1)];
}
return out;
}
const wss = new WebSocket.Server({ port: PORT });
class ProtoError extends Error {
constructor (code, message) {
super(message);
this.code = code;
}
}
class Peer {
constructor (id, ws) {
this.id = id;
this.ws = ws;
this.lobby = "";
// Close connection after 1 sec if client has not joined a lobby
this.timeout = setTimeout(() => {
if (!this.lobby) ws.close(4000, STR_NO_LOBBY);
}, NO_LOBBY_TIMEOUT);
}
}
class Lobby {
constructor (name, host) {
this.name = name;
this.host = host;
this.peers = [];
this.sealed = false;
this.closeTimer = -1;
}
getPeerId (peer) {
if (this.host === peer.id) return 1;
return peer.id;
}
join (peer) {
const assigned = this.getPeerId(peer);
peer.ws.send(`I: ${assigned}\n`);
this.peers.forEach((p) => {
p.ws.send(`N: ${assigned}\n`);
peer.ws.send(`N: ${this.getPeerId(p)}\n`);
});
this.peers.push(peer);
}
leave (peer) {
const idx = this.peers.findIndex((p) => peer === p);
if (idx === -1) return false;
const assigned = this.getPeerId(peer);
const close = assigned === 1;
this.peers.forEach((p) => {
// Room host disconnected, must close.
if (close) p.ws.close(4000, STR_HOST_DISCONNECTED);
// Notify peer disconnect.
else p.ws.send(`D: ${assigned}\n`);
});
this.peers.splice(idx, 1);
if (close && this.closeTimer >= 0) {
// We are closing already.
clearTimeout(this.closeTimer);
this.closeTimer = -1;
}
return close;
}
seal (peer) {
// Only host can seal
if (peer.id !== this.host) {
throw new ProtoError(4000, STR_ONLY_HOST_CAN_SEAL);
}
this.sealed = true;
this.peers.forEach((p) => {
p.ws.send("S: \n");
});
console.log(`Peer ${peer.id} sealed lobby ${this.name} ` +
`with ${this.peers.length} peers`);
this.closeTimer = setTimeout(() => {
// Close peer connection to host (and thus the lobby)
this.peers.forEach((p) => {
p.ws.close(1000, STR_SEAL_COMPLETE);
});
}, SEAL_CLOSE_TIMEOUT);
}
}
const lobbies = new Map();
let peersCount = 0;
function joinLobby (peer, pLobby) {
let lobbyName = pLobby;
if (lobbyName === "") {
if (lobbies.size >= MAX_LOBBIES) {
throw new ProtoError(4000, STR_TOO_MANY_LOBBIES);
}
// Peer must not already be in a lobby
if (peer.lobby !== "") {
throw new ProtoError(4000, STR_ALREADY_IN_LOBBY);
}
lobbyName = randomSecret();
lobbies.set(lobbyName, new Lobby(lobbyName, peer.id));
console.log(`Peer ${peer.id} created lobby ${lobbyName}`);
console.log(`Open lobbies: ${lobbies.size}`);
}
const lobby = lobbies.get(lobbyName);
if (!lobby) throw new ProtoError(4000, STR_LOBBY_DOES_NOT_EXISTS);
if (lobby.sealed) throw new ProtoError(4000, STR_LOBBY_IS_SEALED);
peer.lobby = lobbyName;
console.log(`Peer ${peer.id} joining lobby ${lobbyName} ` +
`with ${lobby.peers.length} peers`);
lobby.join(peer);
peer.ws.send(`J: ${lobbyName}\n`);
}
function parseMsg (peer, msg) {
const sep = msg.indexOf("\n");
if (sep < 0) throw new ProtoError(4000, STR_INVALID_FORMAT);
const cmd = msg.slice(0, sep);
if (cmd.length < 3) throw new ProtoError(4000, STR_INVALID_FORMAT);
const data = msg.slice(sep);
// Lobby joining.
if (cmd.startsWith("J: ")) {
joinLobby(peer, cmd.substr(3).trim());
return;
}
if (!peer.lobby) throw new ProtoError(4000, STR_NEED_LOBBY);
const lobby = lobbies.get(peer.lobby);
if (!lobby) throw new ProtoError(4000, STR_SERVER_ERROR);
// Lobby sealing.
if (cmd.startsWith("S: ")) {
lobby.seal(peer);
return;
}
// Message relaying format:
//
// [O|A|C]: DEST_ID\n
// PAYLOAD
//
// O: Client is sending an offer.
// A: Client is sending an answer.
// C: Client is sending a candidate.
let destId = parseInt(cmd.substr(3).trim());
// Dest is not an ID.
if (!destId) throw new ProtoError(4000, STR_INVALID_DEST);
if (destId === 1) destId = lobby.host;
const dest = lobby.peers.find((e) => e.id === destId);
// Dest is not in this room.
if (!dest) throw new ProtoError(4000, STR_INVALID_DEST);
function isCmd (what) {
return cmd.startsWith(`${what}: `);
}
if (isCmd("O") || isCmd("A") || isCmd("C")) {
dest.ws.send(cmd[0] + ": " + lobby.getPeerId(peer) + data);
return;
}
throw new ProtoError(4000, STR_INVALID_CMD);
}
wss.on("connection", (ws) => {
if (peersCount >= MAX_PEERS) {
ws.close(4000, STR_TOO_MANY_PEERS);
return;
}
peersCount++;
const id = randomId();
const peer = new Peer(id, ws);
ws.on("message", (message) => {
if (typeof message !== "string") {
ws.close(4000, STR_INVALID_TRANSFER_MODE);
return;
}
try {
parseMsg(peer, message);
} catch (e) {
const code = e.code || 4000;
console.log(`Error parsing message from ${id}:\n` +
message);
ws.close(code, e.message);
}
});
ws.on("close", (code, reason) => {
peersCount--;
console.log(`Connection with peer ${peer.id} closed ` +
`with reason ${code}: ${reason}`);
if (peer.lobby && lobbies.has(peer.lobby) &&
lobbies.get(peer.lobby).leave(peer)) {
lobbies.delete(peer.lobby);
console.log(`Deleted lobby ${peer.lobby}`);
console.log(`Open lobbies: ${lobbies.size}`);
peer.lobby = "";
}
if (peer.timeout >= 0) {
clearTimeout(peer.timeout);
peer.timeout = -1;
}
});
ws.on("error", (error) => {
console.error(error);
});
});
const interval = setInterval(() => { // eslint-disable-line no-unused-vars
wss.clients.forEach((ws) => {
ws.ping();
});
}, PING_INTERVAL);