From 364e8cbfb8a03cc645ecf03dfdf51450abd907b6 Mon Sep 17 00:00:00 2001 From: Fabio Alessandrelli Date: Tue, 15 Nov 2022 00:10:20 +0100 Subject: [PATCH] Update WebRTC signaling demo to Godot beta4. The signaling server protocol has been rewritten to use JSON format to be more readable. Lobbies now support both mesh and client/server modes (selected during creation). The client/server mode uses the SceneMultiplayer relay mode as implemented in beta4. The demo now uses an RPC for pinging, and connects to the MultiplayerAPI instead of using the raw MultiplayerPeer. --- networking/webrtc_signaling/README.md | 36 +- .../client/multiplayer_client.gd | 60 ++-- .../client/ws_webrtc_client.gd | 198 ++++++----- networking/webrtc_signaling/demo/client_ui.gd | 94 +++--- .../webrtc_signaling/demo/client_ui.tscn | 119 ++++--- networking/webrtc_signaling/demo/main.gd | 8 + networking/webrtc_signaling/demo/main.tscn | 101 +++--- networking/webrtc_signaling/project.godot | 6 +- .../server/ws_webrtc_server.gd | 225 +++++++------ .../webrtc_signaling/server_node/.eslintrc.js | 52 +++ .../server_node/.eslintrc.json | 318 ------------------ .../webrtc_signaling/server_node/package.json | 9 +- .../webrtc_signaling/server_node/server.js | 231 +++++++------ 13 files changed, 635 insertions(+), 822 deletions(-) create mode 100644 networking/webrtc_signaling/server_node/.eslintrc.js delete mode 100644 networking/webrtc_signaling/server_node/.eslintrc.json diff --git a/networking/webrtc_signaling/README.md b/networking/webrtc_signaling/README.md index 74628340..7a0804a4 100644 --- a/networking/webrtc_signaling/README.md +++ b/networking/webrtc_signaling/README.md @@ -18,26 +18,32 @@ Check out this demo on the asset library: https://godotengine.org/asset-library/ ## Protocol -The protocol is text based, and composed by a command and possibly multiple payload arguments, each separated by a new line. +The protocol is JSON based, and uses messages in the form: -Messages without payload must still end with a newline and are the following: +``` +{ + "id": "number", + "type": "number", + "data": "string", +} +``` -- `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). +With `type` being the message type, `id` being a connected peer or `0`, and `data` being the message specific data. -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 are the following: -Messages with payload (used to transfer WebRTC parameters) are: +- `0 = JOIN`, must be sent by client immediately after connection to get a lobby assigned or join a known one (via the `data` field). + This messages is also sent by server back to the client to notify the assigned lobby, or simply a successful join. +- `1 = ID`, sent by server to identify the client when it joins a room (the `id` field will contain the be assigned ID). +- `2 = PEER_CONNECT`, sent by server to notify new peers in the same lobby (the `id` field will contain the ID of the new peer). +- `3 = PEER_DISCONNECT`, sent by server to notify when a peer in the same lobby disconnects (the `id` field will contain the ID of the disconnected peer). +- `4 = OFFER`, sent by the client when creating a WebRTC offer then relayed back by the server to the destination peer. +- `5 = ANSWER`, sent by the client when creating a WebRTC answer then relayed back by the server to the destination peer. +- `6 = CANDIDATE`, sent by the client when generating new WebRTC candidates then relayed back by the server to the destination peer. +- `7 = SEAL`, sent by client to seal the lobby (only the client that created it is allowed to seal a lobby), and then back by the server to notify success. + 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. -- `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. +For relayed messages (i.e. for `OFFER`, `ANSWER`, and `CANDIDATE`), the client will set the `id` field as the destination peer, then the server will replace it with the id of the sending peer, and send it to the proper destination. ## Screenshots diff --git a/networking/webrtc_signaling/client/multiplayer_client.gd b/networking/webrtc_signaling/client/multiplayer_client.gd index 04c32204..2fe7c9a1 100644 --- a/networking/webrtc_signaling/client/multiplayer_client.gd +++ b/networking/webrtc_signaling/client/multiplayer_client.gd @@ -1,30 +1,32 @@ extends "ws_webrtc_client.gd" -var rtc_mp: WebRTCMultiplayer = WebRTCMultiplayer.new() -var sealed = false +var rtc_mp: WebRTCMultiplayerPeer = WebRTCMultiplayerPeer.new() +var sealed := false func _init(): - connect(&"connected", self.connected) - connect(&"disconnected", self.disconnected) + connected.connect(_connected) + disconnected.connect(_disconnected) - connect(&"offer_received", self.offer_received) - connect(&"answer_received", self.answer_received) - connect(&"candidate_received", self.candidate_received) + offer_received.connect(_offer_received) + answer_received.connect(_answer_received) + candidate_received.connect(_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) + lobby_joined.connect(_lobby_joined) + lobby_sealed.connect(_lobby_sealed) + peer_connected.connect(_peer_connected) + peer_disconnected.connect(_peer_disconnected) -func start(url, lobby = ""): +func start(url, lobby = "", mesh:=true): stop() sealed = false + self.mesh = mesh self.lobby = lobby connect_to_url(url) func stop(): + multiplayer.multiplayer_peer = null rtc_mp.close() close() @@ -34,10 +36,10 @@ func _create_peer(id): 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]) + peer.session_description_created.connect(_offer_created.bind(id)) + peer.ice_candidate_created.connect(_new_ice_candidate.bind(id)) rtc_mp.add_peer(peer, id) - if id > rtc_mp.get_unique_id(): + if id < rtc_mp.get_unique_id(): # So lobby creator never creates offers. peer.create_offer() return peer @@ -55,46 +57,52 @@ func _offer_created(type, data, id): else: send_answer(id, data) -func connected(id): - print("Connected %d" % id) - rtc_mp.initialize(id, true) +func _connected(id, use_mesh): + print("Connected %d, mesh: %s" % [id, use_mesh]) + if use_mesh: + rtc_mp.create_mesh(id) + elif id == 1: + rtc_mp.create_server() + else: + rtc_mp.create_client(id) + multiplayer.multiplayer_peer = rtc_mp -func lobby_joined(lobby): +func _lobby_joined(lobby): self.lobby = lobby -func lobby_sealed(): +func _lobby_sealed(): sealed = true -func disconnected(): +func _disconnected(): print("Disconnected: %d: %s" % [code, reason]) if not sealed: stop() # Unexpected disconnect -func peer_connected(id): +func _peer_connected(id): print("Peer connected %d" % id) _create_peer(id) -func peer_disconnected(id): +func _peer_disconnected(id): if rtc_mp.has_peer(id): rtc_mp.remove_peer(id) -func offer_received(id, offer): +func _offer_received(id, offer): 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, answer): +func _answer_received(id, answer): 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, mid, index, sdp): +func _candidate_received(id, mid, index, sdp): if rtc_mp.has_peer(id): rtc_mp.get_peer(id).connection.add_ice_candidate(mid, index, sdp) diff --git a/networking/webrtc_signaling/client/ws_webrtc_client.gd b/networking/webrtc_signaling/client/ws_webrtc_client.gd index d50737a9..35726830 100644 --- a/networking/webrtc_signaling/client/ws_webrtc_client.gd +++ b/networking/webrtc_signaling/client/ws_webrtc_client.gd @@ -1,14 +1,17 @@ extends Node -@export var autojoin = true -@export var lobby = "" # Will create a new lobby if empty. +enum Message {JOIN, ID, PEER_CONNECT, PEER_DISCONNECT, OFFER, ANSWER, CANDIDATE, SEAL} -var client: WebSocketClient = WebSocketClient.new() +@export var autojoin := true +@export var lobby := "" # Will create a new lobby if empty. +@export var mesh := true # Will use the lobby host as relay otherwise. + +var ws: WebSocketPeer = WebSocketPeer.new() var code = 1000 var reason = "Unknown" signal lobby_joined(lobby) -signal connected(id) +signal connected(id, use_mesh) signal disconnected() signal peer_connected(id) signal peer_disconnected(id) @@ -17,113 +20,102 @@ 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): close() code = 1000 reason = "Unknown" - client.connect_to_url(url) + ws.connect_to_url(url) func close(): - client.disconnect_from_host() - - -func _closed(was_clean = false): - emit_signal("disconnected") - - -func _close_request(code, reason): - 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: PackedStringArray = 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_int(): # 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: PackedStringArray = req[1].split("\n", false) - if candidate.size() != 3: - return - if not candidate[1].is_valid_int(): - return - emit_signal("candidate_received", src_id, candidate[0], int(candidate[1]), candidate[2]) - - -func join_lobby(lobby): - 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, mid, index, sdp) -> int: - return _send_msg("C", id, "\n%s\n%d\n%s" % [mid, index, sdp]) - - -func send_offer(id, offer) -> int: - return _send_msg("O", id, offer) - - -func send_answer(id, answer) -> int: - return _send_msg("A", id, answer) - - -func _send_msg(type, id, data) -> int: - return client.get_peer(1).put_packet(("%s: %d\n%s" % [type, id, data]).to_utf8()) + ws.close() func _process(delta): - var status: int = client.get_connection_status() - if status == WebSocketClient.CONNECTION_CONNECTING or status == WebSocketClient.CONNECTION_CONNECTED: - client.poll() + var old_state: int = ws.get_ready_state() + if old_state == WebSocketPeer.STATE_CLOSED: + return + ws.poll() + var state = ws.get_ready_state() + if state != old_state and state == WebSocketPeer.STATE_OPEN and autojoin: + join_lobby(lobby) + while state == WebSocketPeer.STATE_OPEN and ws.get_available_packet_count(): + if not _parse_msg(): + print("Error parsing message from server.") + if state == WebSocketPeer.STATE_CLOSED: + code = ws.get_close_code() + reason = ws.get_close_reason() + disconnected.emit() + + +func _parse_msg(): + var parsed = JSON.parse_string(ws.get_packet().get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("type") or not parsed.has("id") or \ + typeof(parsed.get("data")) != TYPE_STRING: + return false + + var msg := parsed as Dictionary + if not str(msg.type).is_valid_int() or not str(msg.id).is_valid_int(): + return false + + var type := str(msg.type).to_int() + var src_id := str(msg.id).to_int() + + if type == Message.ID: + connected.emit(src_id, msg.data == "true") + elif type == Message.JOIN: + lobby_joined.emit(msg.data) + elif type == Message.SEAL: + lobby_sealed.emit() + elif type == Message.PEER_CONNECT: + # Client connected + peer_connected.emit(src_id) + elif type == Message.PEER_DISCONNECT: + # Client connected + peer_disconnected.emit(src_id) + elif type == Message.OFFER: + # Offer received + offer_received.emit(src_id, msg.data) + elif type == Message.ANSWER: + # Answer received + answer_received.emit(src_id, msg.data) + elif type == Message.CANDIDATE: + # Candidate received + var candidate: PackedStringArray = msg.data.split("\n", false) + if candidate.size() != 3: + return false + if not candidate[1].is_valid_int(): + return false + candidate_received.emit(src_id, candidate[0], candidate[1].to_int(), candidate[2]) + else: + return false + return true # Parsed + + +func join_lobby(lobby: String): + return _send_msg(Message.JOIN, 0 if mesh else 1, lobby) + + +func seal_lobby(): + return _send_msg(Message.SEAL, 0) + + +func send_candidate(id, mid, index, sdp) -> int: + return _send_msg(Message.CANDIDATE, id, "\n%s\n%d\n%s" % [mid, index, sdp]) + + +func send_offer(id, offer) -> int: + return _send_msg(Message.OFFER, id, offer) + + +func send_answer(id, answer) -> int: + return _send_msg(Message.ANSWER, id, answer) + + +func _send_msg(type: int, id: int, data:="") -> int: + return ws.send_text(JSON.stringify({ + "type": type, + "id": id, + "data": data + })) diff --git a/networking/webrtc_signaling/demo/client_ui.gd b/networking/webrtc_signaling/demo/client_ui.gd index 989debc2..b8878627 100644 --- a/networking/webrtc_signaling/demo/client_ui.gd +++ b/networking/webrtc_signaling/demo/client_ui.gd @@ -1,54 +1,58 @@ extends Control @onready var client = $Client +@onready var host = $VBoxContainer/Connect/Host +@onready var room = $VBoxContainer/Connect/RoomSecret +@onready var mesh = $VBoxContainer/Connect/Mesh 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) + client.lobby_joined.connect(_lobby_joined) + client.lobby_sealed.connect(_lobby_sealed) + client.connected.connect(_connected) + client.disconnected.connect(_disconnected) + + multiplayer.connected_to_server.connect(_mp_server_connected) + multiplayer.connection_failed.connect(_mp_server_disconnect) + multiplayer.server_disconnected.connect(_mp_server_disconnect) + multiplayer.peer_connected.connect(_mp_peer_connected) + multiplayer.peer_disconnected.connect(_mp_peer_disconnected) -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()) +@rpc(any_peer, call_local) +func ping(argument): + _log("[Multiplayer] Ping from peer %d: arg: %s" % [multiplayer.get_remote_sender_id(), argument]) -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_connected(): + _log("[Multiplayer] Server 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()) + _log("[Multiplayer] Server disconnected (I am %d)" % client.rtc_mp.get_unique_id()) func _mp_peer_connected(id: int): - _log("Multiplayer peer %d connected" % id) + _log("[Multiplayer] Peer %d connected" % id) func _mp_peer_disconnected(id: int): - _log("Multiplayer peer %d disconnected" % id) + _log("[Multiplayer] Peer %d disconnected" % id) + + +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("[Signaling] Joined lobby %s" % lobby) + + +func _lobby_sealed(): + _log("[Signaling] Lobby has been sealed") func _log(msg): @@ -56,24 +60,22 @@ func _log(msg): $VBoxContainer/TextEdit.text += str(msg) + "\n" -func ping(): - _log(client.rtc_mp.put_packet("ping".to_utf8())) +func _on_peers_pressed(): + _log(multiplayer.get_peers()) -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 _on_ping_pressed(): + randomize() + ping.rpc(randf()) -func start(): - client.start($VBoxContainer/Connect/Host.text, $VBoxContainer/Connect/RoomSecret.text) - - -func _on_Seal_pressed(): +func _on_seal_pressed(): client.seal_lobby() -func stop(): +func _on_start_pressed(): + client.start(host.text, room.text, mesh.button_pressed) + + +func _on_stop_pressed(): client.stop() diff --git a/networking/webrtc_signaling/demo/client_ui.tscn b/networking/webrtc_signaling/demo/client_ui.tscn index 3cf5833f..b1757570 100644 --- a/networking/webrtc_signaling/demo/client_ui.tscn +++ b/networking/webrtc_signaling/demo/client_ui.tscn @@ -1,107 +1,120 @@ -[gd_scene load_steps=3 format=2] +[gd_scene load_steps=3 format=3 uid="uid://cpwp4xx6mv5p"] -[ext_resource path="res://demo/client_ui.gd" type="Script" id=1] -[ext_resource path="res://client/multiplayer_client.gd" type="Script" id=2] +[ext_resource type="Script" path="res://demo/client_ui.gd" id="1"] +[ext_resource type="Script" path="res://client/multiplayer_client.gd" id="2"] [node name="ClientUI" type="Control"] +layout_mode = 3 +anchors_preset = 0 offset_right = 1024.0 offset_bottom = 600.0 size_flags_horizontal = 3 size_flags_vertical = 3 -script = ExtResource( 1 ) -__meta__ = { -"_edit_use_anchors_": true -} +script = ExtResource("1") [node name="Client" type="Node" parent="."] -script = ExtResource( 2 ) +script = ExtResource("2") [node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 -custom_constants/separation = 8 -__meta__ = { -"_edit_use_anchors_": false -} +grow_horizontal = 2 +grow_vertical = 2 [node name="Connect" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 offset_right = 1024.0 -offset_bottom = 24.0 +offset_bottom = 31.0 [node name="Label" type="Label" parent="VBoxContainer/Connect"] -offset_top = 5.0 -offset_right = 73.0 -offset_bottom = 19.0 +layout_mode = 2 +offset_top = 2.0 +offset_right = 89.0 +offset_bottom = 28.0 text = "Connect to:" [node name="Host" type="LineEdit" parent="VBoxContainer/Connect"] -offset_left = 77.0 -offset_right = 921.0 -offset_bottom = 24.0 +layout_mode = 2 +offset_left = 93.0 +offset_right = 829.0 +offset_bottom = 31.0 size_flags_horizontal = 3 text = "ws://localhost:9080" [node name="Room" type="Label" parent="VBoxContainer/Connect"] -offset_left = 925.0 -offset_right = 962.0 -offset_bottom = 24.0 +layout_mode = 2 +offset_left = 833.0 +offset_right = 879.0 +offset_bottom = 31.0 size_flags_vertical = 5 text = "Room" -valign = 1 [node name="RoomSecret" type="LineEdit" parent="VBoxContainer/Connect"] -offset_left = 966.0 -offset_right = 1024.0 -offset_bottom = 24.0 +layout_mode = 2 +offset_left = 883.0 +offset_right = 950.0 +offset_bottom = 31.0 placeholder_text = "secret" -[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"] -offset_top = 32.0 +[node name="Mesh" type="CheckBox" parent="VBoxContainer/Connect"] +layout_mode = 2 +offset_left = 954.0 offset_right = 1024.0 -offset_bottom = 52.0 -custom_constants/separation = 10 -__meta__ = { -"_edit_use_anchors_": false -} +offset_bottom = 31.0 +button_pressed = true +text = "Mesh" + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 +offset_top = 35.0 +offset_right = 1024.0 +offset_bottom = 66.0 [node name="Start" type="Button" parent="VBoxContainer/HBoxContainer"] -offset_right = 41.0 -offset_bottom = 20.0 +layout_mode = 2 +offset_right = 46.0 +offset_bottom = 31.0 text = "Start" [node name="Stop" type="Button" parent="VBoxContainer/HBoxContainer"] -offset_left = 51.0 -offset_right = 91.0 -offset_bottom = 20.0 +layout_mode = 2 +offset_left = 50.0 +offset_right = 93.0 +offset_bottom = 31.0 text = "Stop" [node name="Seal" type="Button" parent="VBoxContainer/HBoxContainer"] -offset_left = 101.0 -offset_right = 139.0 -offset_bottom = 20.0 +layout_mode = 2 +offset_left = 97.0 +offset_right = 137.0 +offset_bottom = 31.0 text = "Seal" [node name="Ping" type="Button" parent="VBoxContainer/HBoxContainer"] -offset_left = 149.0 -offset_right = 188.0 -offset_bottom = 20.0 +layout_mode = 2 +offset_left = 141.0 +offset_right = 183.0 +offset_bottom = 31.0 text = "Ping" [node name="Peers" type="Button" parent="VBoxContainer/HBoxContainer"] -offset_left = 198.0 +layout_mode = 2 +offset_left = 187.0 offset_right = 280.0 -offset_bottom = 20.0 +offset_bottom = 31.0 text = "Print peers" [node name="TextEdit" type="TextEdit" parent="VBoxContainer"] -offset_top = 60.0 +layout_mode = 2 +offset_top = 70.0 offset_right = 1024.0 offset_bottom = 600.0 size_flags_vertical = 3 -readonly = true -[connection signal="pressed" from="VBoxContainer/HBoxContainer/Start" to="." method="start"] -[connection signal="pressed" from="VBoxContainer/HBoxContainer/Stop" to="." method="stop"] -[connection signal="pressed" from="VBoxContainer/HBoxContainer/Seal" to="." method="_on_Seal_pressed"] -[connection signal="pressed" from="VBoxContainer/HBoxContainer/Ping" to="." method="ping"] -[connection signal="pressed" from="VBoxContainer/HBoxContainer/Peers" to="." method="_on_Peers_pressed"] +[connection signal="pressed" from="VBoxContainer/HBoxContainer/Start" to="." method="_on_start_pressed"] +[connection signal="pressed" from="VBoxContainer/HBoxContainer/Stop" to="." method="_on_stop_pressed"] +[connection signal="pressed" from="VBoxContainer/HBoxContainer/Seal" to="." method="_on_seal_pressed"] +[connection signal="pressed" from="VBoxContainer/HBoxContainer/Ping" to="." method="_on_ping_pressed"] +[connection signal="pressed" from="VBoxContainer/HBoxContainer/Peers" to="." method="_on_peers_pressed"] diff --git a/networking/webrtc_signaling/demo/main.gd b/networking/webrtc_signaling/demo/main.gd index b35c4637..d4165489 100644 --- a/networking/webrtc_signaling/demo/main.gd +++ b/networking/webrtc_signaling/demo/main.gd @@ -1,5 +1,13 @@ extends Control +func _enter_tree(): + for c in $VBoxContainer/Clients.get_children(): + # So each child gets its own separate MultiplayerAPI. + get_tree().set_multiplayer( + MultiplayerAPI.create_default_interface(), + NodePath("%s/VBoxContainer/Clients/%s" % [get_path(), c.name]) + ) + func _ready(): if OS.get_name() == "HTML5": $VBoxContainer/Signaling.hide() diff --git a/networking/webrtc_signaling/demo/main.tscn b/networking/webrtc_signaling/demo/main.tscn index ea3e7859..fa463fbc 100644 --- a/networking/webrtc_signaling/demo/main.tscn +++ b/networking/webrtc_signaling/demo/main.tscn @@ -1,100 +1,93 @@ -[gd_scene load_steps=4 format=2] +[gd_scene load_steps=4 format=3 uid="uid://5p1bp2kcs0py"] -[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] +[ext_resource type="Script" path="res://demo/main.gd" id="1"] +[ext_resource type="PackedScene" uid="uid://cpwp4xx6mv5p" path="res://demo/client_ui.tscn" id="2"] +[ext_resource type="Script" path="res://server/ws_webrtc_server.gd" id="3"] [node name="Control" type="Control"] +layout_mode = 3 anchor_left = 0.0136719 anchor_top = 0.0166667 anchor_right = 0.986328 anchor_bottom = 0.983333 -offset_top = 4.32134e-07 -offset_bottom = -9.53674e-06 -script = ExtResource( 1 ) -__meta__ = { -"_edit_use_anchors_": true -} +script = ExtResource("1") [node name="VBoxContainer" type="VBoxContainer" parent="."] +anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 -custom_constants/separation = 50 -__meta__ = { -"_edit_use_anchors_": true -} +grow_horizontal = 2 +grow_vertical = 2 [node name="Signaling" type="HBoxContainer" parent="VBoxContainer"] -offset_right = 995.0 -offset_bottom = 24.0 +offset_right = 1120.0 +offset_bottom = 31.0 [node name="Label" type="Label" parent="VBoxContainer/Signaling"] -offset_top = 5.0 -offset_right = 104.0 -offset_bottom = 19.0 +offset_top = 2.0 +offset_right = 127.0 +offset_bottom = 28.0 text = "Signaling server:" [node name="Port" type="SpinBox" parent="VBoxContainer/Signaling"] -offset_left = 108.0 -offset_right = 182.0 -offset_bottom = 24.0 +offset_left = 131.0 +offset_right = 214.0 +offset_bottom = 31.0 min_value = 1025.0 max_value = 65535.0 value = 9080.0 [node name="ListenButton" type="Button" parent="VBoxContainer/Signaling"] -offset_left = 186.0 -offset_right = 237.0 -offset_bottom = 24.0 +offset_left = 218.0 +offset_right = 273.0 +offset_bottom = 31.0 toggle_mode = true text = "Listen" [node name="CenterContainer" type="CenterContainer" parent="VBoxContainer/Signaling"] -offset_left = 241.0 -offset_right = 995.0 -offset_bottom = 24.0 +offset_left = 277.0 +offset_right = 1120.0 +offset_bottom = 31.0 size_flags_horizontal = 3 size_flags_vertical = 3 [node name="LinkButton" type="LinkButton" parent="VBoxContainer/Signaling/CenterContainer"] -offset_left = 104.0 -offset_top = 5.0 -offset_right = 650.0 -offset_bottom = 19.0 +offset_left = 91.0 +offset_top = 4.0 +offset_right = 752.0 +offset_bottom = 27.0 text = "Make sure to download the GDNative WebRTC Plugin and place it in the project folder" [node name="Clients" type="GridContainer" parent="VBoxContainer"] -offset_top = 74.0 -offset_right = 995.0 -offset_bottom = 579.0 +offset_top = 35.0 +offset_right = 1120.0 +offset_bottom = 626.0 size_flags_horizontal = 3 size_flags_vertical = 3 -custom_constants/vseparation = 15 -custom_constants/hseparation = 15 columns = 2 -[node name="ClientUI" parent="VBoxContainer/Clients" instance=ExtResource( 2 )] -offset_right = 490.0 -offset_bottom = 245.0 +[node name="ClientUI" parent="VBoxContainer/Clients" instance=ExtResource("2")] +offset_right = 558.0 +offset_bottom = 294.0 -[node name="ClientUI2" parent="VBoxContainer/Clients" instance=ExtResource( 2 )] -offset_left = 505.0 -offset_right = 995.0 -offset_bottom = 245.0 +[node name="ClientUI2" parent="VBoxContainer/Clients" instance=ExtResource("2")] +offset_left = 562.0 +offset_right = 1120.0 +offset_bottom = 294.0 -[node name="ClientUI3" parent="VBoxContainer/Clients" instance=ExtResource( 2 )] -offset_top = 260.0 -offset_right = 490.0 -offset_bottom = 505.0 +[node name="ClientUI3" parent="VBoxContainer/Clients" instance=ExtResource("2")] +offset_top = 298.0 +offset_right = 558.0 +offset_bottom = 591.0 -[node name="ClientUI4" parent="VBoxContainer/Clients" instance=ExtResource( 2 )] -offset_left = 505.0 -offset_top = 260.0 -offset_right = 995.0 -offset_bottom = 505.0 +[node name="ClientUI4" parent="VBoxContainer/Clients" instance=ExtResource("2")] +offset_left = 562.0 +offset_top = 298.0 +offset_right = 1120.0 +offset_bottom = 591.0 [node name="Server" type="Node" parent="."] -script = ExtResource( 3 ) +script = ExtResource("3") [connection signal="toggled" from="VBoxContainer/Signaling/ListenButton" to="." method="_on_listen_toggled"] [connection signal="pressed" from="VBoxContainer/Signaling/CenterContainer/LinkButton" to="." method="_on_LinkButton_pressed"] diff --git a/networking/webrtc_signaling/project.godot b/networking/webrtc_signaling/project.godot index 0152eef7..e3c8c1d7 100644 --- a/networking/webrtc_signaling/project.godot +++ b/networking/webrtc_signaling/project.godot @@ -6,7 +6,7 @@ ; [section] ; section goes between [] ; param=value ; assign values to parameters -config_version=4 +config_version=5 [application] @@ -16,16 +16,16 @@ This demo is devided in 4 parts. The protocol is text based, and composed by a command and possibly multiple payload arguments, each separated by a new line." run/main_scene="res://demo/main.tscn" +config/features=PackedStringArray("4.0") [debug] gdscript/warnings/shadowed_variable=false -gdscript/warnings/unused_argument=false gdscript/warnings/return_value_discarded=false +gdscript/warnings/unused_argument=false [display] -window/dpi/allow_hidpi=true window/stretch/mode="2d" window/stretch/aspect="expand" diff --git a/networking/webrtc_signaling/server/ws_webrtc_server.gd b/networking/webrtc_signaling/server/ws_webrtc_server.gd index 4907044e..7b04533e 100644 --- a/networking/webrtc_signaling/server/ws_webrtc_server.gd +++ b/networking/webrtc_signaling/server/ws_webrtc_server.gd @@ -1,210 +1,221 @@ extends Node +enum Message {JOIN, ID, PEER_CONNECT, PEER_DISCONNECT, OFFER, ANSWER, CANDIDATE, SEAL} + 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() +var _alfnum = ALFNUM.to_ascii_buffer() var rand: RandomNumberGenerator = RandomNumberGenerator.new() var lobbies: Dictionary = {} -var server: WebSocketServer = WebSocketServer.new() +var tcp_server := TCPServer.new() var peers: Dictionary = {} class Peer extends RefCounted: var id = -1 var lobby = "" - var time = OS.get_ticks_msec() + var time = Time.get_ticks_msec() + var ws = WebSocketPeer.new() - func _init(peer_id): + + func _init(peer_id, tcp): id = peer_id + ws.accept_stream(tcp) + func is_ws_open() -> bool: + return ws.get_ready_state() == WebSocketPeer.STATE_OPEN + + + func send(type: int, id: int, data:=""): + return ws.send_text(JSON.stringify({ + "type": type, + "id": id, + "data": data, + })) + class Lobby extends RefCounted: - var peers: Array = [] + var peers: = {} var host: int = -1 var sealed: bool = false var time = 0 + var mesh := true - func _init(host_id: int): + func _init(host_id: int, use_mesh: bool): host = host_id + mesh = use_mesh - func join(peer_id, server) -> bool: + func join(peer: Peer) -> 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): + if not peer.is_ws_open(): return false + peer.send(Message.ID, (1 if peer.id == host else peer.id), "true" if mesh else "") + for p in peers.values(): + if not p.is_ws_open(): 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) + if not mesh and p.id != host: + # Only host is visible when using client-server + continue + p.send(Message.PEER_CONNECT, peer.id) + peer.send(Message.PEER_CONNECT, (1 if p.id == host else p.id)) + peers[peer.id] = peer return true - func leave(peer_id, server) -> bool: - if not peers.has(peer_id): return false - peers.erase(peer_id) + func leave(peer: Peer) -> bool: + if not peers.has(peer.id): return false + peers.erase(peer.id) var close = false - if peer_id == host: + 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 + for p in peers.values(): + if not p.is_ws_open(): + continue if close: # Disconnect peers. - server.disconnect_peer(p) + p.ws.close() else: # Notify disconnection. - server.get_peer(p).put_packet(("D: %d\n" % peer_id).to_utf8()) + p.send(Message.PEER_DISCONNECT, peer.id) return close - func seal(peer_id, server) -> bool: + func seal(peer_id: int) -> 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() + for p in peers.values(): + if not p.is_ws_open(): + continue + p.send(Message.SEAL, 0) + time = Time.get_ticks_msec() + peers.clear() return true - -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): stop() - rand.seed = OS.get_unix_time() - server.listen(port) + rand.seed = Time.get_unix_time_from_system() + tcp_server.listen(port) func stop(): - server.stop() + tcp_server.stop() peers.clear() func poll(): - if not server.is_listening(): + if not tcp_server.is_listening(): return - server.poll() + if tcp_server.is_connection_available(): + var id = randi() % (1 << 31) + peers[id] = Peer.new(id, tcp_server.take_connection()) - # Peers timeout. + # Poll peers. + var to_remove := [] for p in peers.values(): - if p.lobby == "" and OS.get_ticks_msec() - p.time > TIMEOUT: - server.disconnect_peer(p.id) + # Peers timeout. + if p.lobby == "" and Time.get_ticks_msec() - p.time > TIMEOUT: + p.ws.close() + p.ws.poll() + while p.is_ws_open() and p.ws.get_available_packet_count(): + if not _parse_msg(p): + print("Parse message failed from peer %d" % p.id) + to_remove.push_back(p.id) + p.ws.close() + break + var state = p.ws.get_ready_state() + if state == WebSocketPeer.STATE_CLOSED: + print("Peer %d disconnected from lobby: '%s'" % [p.id, p.lobby]) + # Remove from lobby (and lobby itself if host). + if lobbies.has(p.lobby) and lobbies[p.lobby].leave(p): + print("Deleted lobby %s" % p.lobby) + lobbies.erase(p.lobby) + # Remove from peers + to_remove.push_back(p.id) + # Lobby seal. for k in lobbies: if not lobbies[k].sealed: continue - if lobbies[k].time + SEAL_TIME < OS.get_ticks_msec(): + if lobbies[k].time + SEAL_TIME < Time.get_ticks_msec(): # Close lobby. for p in lobbies[k].peers: - server.disconnect_peer(p) + p.ws.close() + to_remove.push_back(p.id) + + # Remove stale peers + for id in to_remove: + peers.erase(id) -func _peer_connected(id, protocol = ""): - peers[id] = Peer.new(id) - - -func _peer_disconnected(id, was_clean = 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) -> bool: +func _join_lobby(peer: Peer, lobby: String, mesh: bool) -> 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) + lobbies[lobby] = Lobby.new(peer.id, mesh) elif not lobbies.has(lobby): return false - lobbies[lobby].join(peer.id, server) + lobbies[lobby].join(peer) peer.lobby = lobby # Notify peer of its lobby - server.get_peer(peer.id).put_packet(("J: %s\n" % lobby).to_utf8()) + peer.send(Message.JOIN, 0, lobby) print("Peer %d joined lobby: '%s'" % [peer.id, lobby]) return true -func _on_data(id): - if not _parse_msg(id): - print("Parse message failed from peer %d" % id) - server.disconnect_peer(id) - - -func _parse_msg(id) -> bool: - var pkt_str: String = server.get_peer(id).get_packet().get_string_from_utf8() - - var req = pkt_str.split("\n", true, 1) - if req.size() != 2: # Invalid request size +func _parse_msg(peer: Peer) -> bool: + var pkt_str: String = peer.ws.get_packet().get_string_from_utf8() + var parsed = JSON.parse_string(pkt_str) + if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("type") or not parsed.has("id") or \ + typeof(parsed.get("data")) != TYPE_STRING: + return false + if not str(parsed.type).is_valid_int() or not str(parsed.id).is_valid_int(): return false - var type = req[0] - if type.length() < 3: # Invalid type size - return false + var msg := { + "type": str(parsed.type).to_int(), + "id": str(parsed.id).to_int(), + "data": parsed.data + } - if type.begins_with("J: "): - if peers[id].lobby: # Peer must not have joined a lobby already! + if msg.type == Message.JOIN: + if peer.lobby: # Peer must not have joined a lobby already! return false - return _join_lobby(peers[id], type.substr(3, type.length() - 3)) + return _join_lobby(peer, msg.data, msg.id == 0) - if not peers[id].lobby: # Messages across peers are only allowed in same lobby + if not lobbies.has(peer.lobby): # Lobby not found? return false - if not lobbies.has(peers[id].lobby): # Lobby not found? - return false + var lobby = lobbies[peer.lobby] - var lobby = lobbies[peers[id].lobby] - - if type.begins_with("S: "): + if msg.type == Message.SEAL: # Client is sealing the room - return lobby.seal(id, server) + return lobby.seal(peer.id) - var dest_str: String = type.substr(3, type.length() - 3) - if not dest_str.is_valid_int(): # Destination id is not an integer - return false - - var dest_id: int = int(dest_str) - if dest_id == NetworkedMultiplayerPeer.TARGET_PEER_SERVER: + var dest_id: int = msg.id + if dest_id == MultiplayerPeer.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 + if peers[dest_id].lobby != peer.lobby: # Trying to contact someone not in same lobby return false - if id == lobby.host: - id = NetworkedMultiplayerPeer.TARGET_PEER_SERVER + if msg.type in [Message.OFFER, Message.ANSWER, Message.CANDIDATE]: + var source = MultiplayerPeer.TARGET_PEER_SERVER if peer.id == lobby.host else peer.id + peers[dest_id].send(msg.type, source, msg.data) + return true - 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 + return false # Unknown message diff --git a/networking/webrtc_signaling/server_node/.eslintrc.js b/networking/webrtc_signaling/server_node/.eslintrc.js new file mode 100644 index 00000000..199ba202 --- /dev/null +++ b/networking/webrtc_signaling/server_node/.eslintrc.js @@ -0,0 +1,52 @@ +module.exports = { + "env": { + "browser": true, + "es2021": true, + }, + "extends": [ + "airbnb-base", + ], + "parserOptions": { + "ecmaVersion": 12, + }, + "ignorePatterns": "*.externs.js", + "rules": { + "no-console": "off", + "func-names": "off", + // Use tabs for consistency with the C++ codebase. + "indent": ["error", "tab"], + "max-len": "off", + "no-else-return": ["error", {allowElseIf: true}], + "curly": ["error", "all"], + "brace-style": ["error", "1tbs", { "allowSingleLine": false }], + "no-bitwise": "off", + "no-continue": "off", + "no-self-assign": "off", + "no-tabs": "off", + "no-param-reassign": ["error", { "props": false }], + "no-plusplus": "off", + "no-unused-vars": ["error", { "args": "none" }], + "prefer-destructuring": "off", + "prefer-rest-params": "off", + "prefer-spread": "off", + "camelcase": "off", + "no-underscore-dangle": "off", + "max-classes-per-file": "off", + "prefer-arrow-callback": "off", + // Messes up with copyright headers in source files. + "spaced-comment": "off", + // Completely breaks emscripten libraries. + "object-shorthand": "off", + // Closure compiler (exported properties) + "quote-props": ["error", "consistent"], + "dot-notation": "off", + // No comma dangle for functions (it's madness, and ES2017) + "comma-dangle": ["error", { + "arrays": "always-multiline", + "objects": "always-multiline", + "imports": "always-multiline", + "exports": "always-multiline", + "functions": "never" + }], + } +}; diff --git a/networking/webrtc_signaling/server_node/.eslintrc.json b/networking/webrtc_signaling/server_node/.eslintrc.json deleted file mode 100644 index 10419820..00000000 --- a/networking/webrtc_signaling/server_node/.eslintrc.json +++ /dev/null @@ -1,318 +0,0 @@ -{ - "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/package.json b/networking/webrtc_signaling/server_node/package.json index 7b5b7dc4..0e6cdf44 100644 --- a/networking/webrtc_signaling/server_node/package.json +++ b/networking/webrtc_signaling/server_node/package.json @@ -4,13 +4,16 @@ "description": "", "main": "server.js", "dependencies": { - "ws": "^7.0.0" + "ws": "^7.5.9" }, "devDependencies": { - "eslint": "^5.16.0" + "eslint": "^8.28.0", + "eslint-config-airbnb-base": "^14.2.1", + "eslint-plugin-import": "^2.23.4" }, "scripts": { - "test": "eslint server.js && echo \"Lint OK\" && exit 0" + "lint": "eslint server.js && echo \"Lint OK\" && exit 0", + "format": "eslint server.js --fix && 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 index 46edb977..9cf5a21a 100644 --- a/networking/webrtc_signaling/server_node/server.js +++ b/networking/webrtc_signaling/server_node/server.js @@ -1,99 +1,129 @@ -const WebSocket = require("ws"); -const crypto = require("crypto"); +const WebSocket = require('ws'); +const crypto = require('crypto'); const MAX_PEERS = 4096; const MAX_LOBBIES = 1024; const PORT = 9080; -const ALFNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +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"; +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) { +const CMD = { + JOIN: 0, // eslint-disable-line sort-keys + ID: 1, // eslint-disable-line sort-keys + PEER_CONNECT: 2, // eslint-disable-line sort-keys + PEER_DISCONNECT: 3, // eslint-disable-line sort-keys + OFFER: 4, // eslint-disable-line sort-keys + ANSWER: 5, // eslint-disable-line sort-keys + CANDIDATE: 6, // eslint-disable-line sort-keys + SEAL: 7, // eslint-disable-line sort-keys +}; + +function randomInt(low, high) { return Math.floor(Math.random() * (high - low + 1) + low); } -function randomId () { +function randomId() { return Math.abs(new Int32Array(crypto.randomBytes(4).buffer)[0]); } -function randomSecret () { - let out = ""; +function randomSecret() { + let out = ''; for (let i = 0; i < 16; i++) { out += ALFNUM[randomInt(0, ALFNUM.length - 1)]; } return out; } +function ProtoMessage(type, id, data) { + return JSON.stringify({ + 'type': type, + 'id': id, + 'data': data || '', + }); +} + const wss = new WebSocket.Server({ port: PORT }); class ProtoError extends Error { - constructor (code, message) { + constructor(code, message) { super(message); this.code = code; } } class Peer { - constructor (id, ws) { + constructor(id, ws) { this.id = id; this.ws = ws; - this.lobby = ""; + 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); + if (!this.lobby) { + ws.close(4000, STR_NO_LOBBY); + } }, NO_LOBBY_TIMEOUT); } } class Lobby { - constructor (name, host) { + constructor(name, host, mesh) { this.name = name; this.host = host; + this.mesh = mesh; this.peers = []; this.sealed = false; this.closeTimer = -1; } - getPeerId (peer) { - if (this.host === peer.id) return 1; + + getPeerId(peer) { + if (this.host === peer.id) { + return 1; + } return peer.id; } - join (peer) { + + join(peer) { const assigned = this.getPeerId(peer); - peer.ws.send(`I: ${assigned}\n`); + peer.ws.send(ProtoMessage(CMD.ID, assigned, this.mesh ? 'true' : '')); this.peers.forEach((p) => { - p.ws.send(`N: ${assigned}\n`); - peer.ws.send(`N: ${this.getPeerId(p)}\n`); + p.ws.send(ProtoMessage(CMD.PEER_CONNECT, assigned)); + peer.ws.send(ProtoMessage(CMD.PEER_CONNECT, this.getPeerId(p))); }); this.peers.push(peer); } - leave (peer) { + + leave(peer) { const idx = this.peers.findIndex((p) => peer === p); - if (idx === -1) return false; + 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`); + if (close) { // Room host disconnected, must close. + p.ws.close(4000, STR_HOST_DISCONNECTED); + } else { // Notify peer disconnect. + p.ws.send(ProtoMessage(CMD.PEER_DISCONNECT, assigned)); + } }); this.peers.splice(idx, 1); if (close && this.closeTimer >= 0) { @@ -103,17 +133,18 @@ class Lobby { } return close; } - seal (peer) { + + 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"); + p.ws.send(ProtoMessage(CMD.SEAL, 0)); }); - console.log(`Peer ${peer.id} sealed lobby ${this.name} ` + - `with ${this.peers.length} peers`); + 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) => { @@ -126,83 +157,95 @@ class Lobby { const lobbies = new Map(); let peersCount = 0; -function joinLobby (peer, pLobby) { +function joinLobby(peer, pLobby, mesh) { let lobbyName = pLobby; - if (lobbyName === "") { + 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 !== "") { + if (peer.lobby !== '') { throw new ProtoError(4000, STR_ALREADY_IN_LOBBY); } lobbyName = randomSecret(); - lobbies.set(lobbyName, new Lobby(lobbyName, peer.id)); + lobbies.set(lobbyName, new Lobby(lobbyName, peer.id, mesh)); 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); + 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`); + console.log(`Peer ${peer.id} joining lobby ${lobbyName} ` + + `with ${lobby.peers.length} peers`); lobby.join(peer); - peer.ws.send(`J: ${lobbyName}\n`); + peer.ws.send(ProtoMessage(CMD.JOIN, 0, lobbyName)); } -function parseMsg (peer, msg) { - const sep = msg.indexOf("\n"); - if (sep < 0) throw new ProtoError(4000, STR_INVALID_FORMAT); +function parseMsg(peer, msg) { + let json = null; + try { + json = JSON.parse(msg); + } catch (e) { + 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 type = typeof (json['type']) === 'number' ? Math.floor(json['type']) : -1; + const id = typeof (json['id']) === 'number' ? Math.floor(json['id']) : -1; + const data = typeof (json['data']) === 'string' ? json['data'] : ''; - const data = msg.slice(sep); + if (type < 0 || id < 0) { + throw new ProtoError(4000, STR_INVALID_FORMAT); + } // Lobby joining. - if (cmd.startsWith("J: ")) { - joinLobby(peer, cmd.substr(3).trim()); + if (type === CMD.JOIN) { + joinLobby(peer, data, id === 0); return; } - if (!peer.lobby) throw new ProtoError(4000, STR_NEED_LOBBY); + 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); + if (!lobby) { + throw new ProtoError(4000, STR_SERVER_ERROR); + } // Lobby sealing. - if (cmd.startsWith("S: ")) { + if (type === CMD.SEAL) { 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); + // { + // "type": CMD.[OFFER|ANSWER|CANDIDATE], + // "id": DEST_ID, + // "data": PAYLOAD + // } + if (type === CMD.OFFER || type === CMD.ANSWER || type === CMD.CANDIDATE) { + let destId = id; + if (id === 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); + } + dest.ws.send(ProtoMessage(type, lobby.getPeerId(peer), data)); return; } throw new ProtoError(4000, STR_INVALID_CMD); } -wss.on("connection", (ws) => { +wss.on('connection', (ws) => { if (peersCount >= MAX_PEERS) { ws.close(4000, STR_TOO_MANY_PEERS); return; @@ -210,8 +253,8 @@ wss.on("connection", (ws) => { peersCount++; const id = randomId(); const peer = new Peer(id, ws); - ws.on("message", (message) => { - if (typeof message !== "string") { + ws.on('message', (message) => { + if (typeof message !== 'string') { ws.close(4000, STR_INVALID_TRANSFER_MODE); return; } @@ -219,28 +262,28 @@ wss.on("connection", (ws) => { parseMsg(peer, message); } catch (e) { const code = e.code || 4000; - console.log(`Error parsing message from ${id}:\n` + - message); + console.log(`Error parsing message from ${id}:\n${ + message}`); ws.close(code, e.message); } }); - ws.on("close", (code, reason) => { + 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)) { + 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 = ""; + peer.lobby = ''; } if (peer.timeout >= 0) { clearTimeout(peer.timeout); peer.timeout = -1; } }); - ws.on("error", (error) => { + ws.on('error', (error) => { console.error(error); }); });