diff --git a/networking/webrtc_signaling/.gitignore b/networking/webrtc_signaling/.gitignore new file mode 100644 index 00000000..d9bb4a8c --- /dev/null +++ b/networking/webrtc_signaling/.gitignore @@ -0,0 +1 @@ +webrtc diff --git a/networking/webrtc_signaling/README.md b/networking/webrtc_signaling/README.md new file mode 100644 index 00000000..13203207 --- /dev/null +++ b/networking/webrtc_signaling/README.md @@ -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: `), 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: `, sent by server to identify the client when it joins a room. +- `N: `, sent by server to notify new peers in the same lobby. +- `D: `, 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: `, used to send an offer. +- `A: `, used to send an answer. +- `C: `, used to send a candidate. + +When sending the parameter, a client will set `` as the destination peer, the server will replace it with the id of the sending peer, and rely it to the proper destination. diff --git a/networking/webrtc_signaling/client/multiplayer_client.gd b/networking/webrtc_signaling/client/multiplayer_client.gd new file mode 100644 index 00000000..4902fb69 --- /dev/null +++ b/networking/webrtc_signaling/client/multiplayer_client.gd @@ -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) \ No newline at end of file diff --git a/networking/webrtc_signaling/client/ws_webrtc_client.gd b/networking/webrtc_signaling/client/ws_webrtc_client.gd new file mode 100644 index 00000000..8470c0f6 --- /dev/null +++ b/networking/webrtc_signaling/client/ws_webrtc_client.gd @@ -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() \ No newline at end of file diff --git a/networking/webrtc_signaling/demo/client_ui.gd b/networking/webrtc_signaling/demo/client_ui.gd new file mode 100644 index 00000000..36e0cf13 --- /dev/null +++ b/networking/webrtc_signaling/demo/client_ui.gd @@ -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() diff --git a/networking/webrtc_signaling/demo/client_ui.tscn b/networking/webrtc_signaling/demo/client_ui.tscn new file mode 100644 index 00000000..2ef2dbd6 --- /dev/null +++ b/networking/webrtc_signaling/demo/client_ui.tscn @@ -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"] diff --git a/networking/webrtc_signaling/demo/main.gd b/networking/webrtc_signaling/demo/main.gd new file mode 100644 index 00000000..6d17afcb --- /dev/null +++ b/networking/webrtc_signaling/demo/main.gd @@ -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") \ No newline at end of file diff --git a/networking/webrtc_signaling/demo/main.tscn b/networking/webrtc_signaling/demo/main.tscn new file mode 100644 index 00000000..0b1377e4 --- /dev/null +++ b/networking/webrtc_signaling/demo/main.tscn @@ -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"] diff --git a/networking/webrtc_signaling/project.godot b/networking/webrtc_signaling/project.godot new file mode 100644 index 00000000..f782dff0 --- /dev/null +++ b/networking/webrtc_signaling/project.godot @@ -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" diff --git a/networking/webrtc_signaling/server/ws_webrtc_server.gd b/networking/webrtc_signaling/server/ws_webrtc_server.gd new file mode 100644 index 00000000..ed6586db --- /dev/null +++ b/networking/webrtc_signaling/server/ws_webrtc_server.gd @@ -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 \ No newline at end of file diff --git a/networking/webrtc_signaling/server_node/.eslintrc.json b/networking/webrtc_signaling/server_node/.eslintrc.json new file mode 100644 index 00000000..a22aae1c --- /dev/null +++ b/networking/webrtc_signaling/server_node/.eslintrc.json @@ -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" + ] + } +} diff --git a/networking/webrtc_signaling/server_node/.gitignore b/networking/webrtc_signaling/server_node/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/networking/webrtc_signaling/server_node/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/networking/webrtc_signaling/server_node/package.json b/networking/webrtc_signaling/server_node/package.json new file mode 100644 index 00000000..7b5b7dc4 --- /dev/null +++ b/networking/webrtc_signaling/server_node/package.json @@ -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" +} diff --git a/networking/webrtc_signaling/server_node/server.js b/networking/webrtc_signaling/server_node/server.js new file mode 100644 index 00000000..46edb977 --- /dev/null +++ b/networking/webrtc_signaling/server_node/server.js @@ -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);