Browse Source

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.
Fabio Alessandrelli 2 years ago
parent
commit
364e8cbfb8

+ 26 - 20
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.
-
-Messages without payload must still end with a newline and are the following:
-
-- `J: ` (or `J: <ROOM>`), must be sent by client immediately after connection to get a lobby assigned or join a known one.
-  This messages is also sent by server back to the client to notify assigned lobby, or simply a successful join.
-- `I: <ID>`, sent by server to identify the client when it joins a room.
-- `N: <ID>`, sent by server to notify new peers in the same lobby.
-- `D: <ID>`, sent by server to notify when a peer in the same lobby disconnects.
-- `S: `, sent by client to seal the lobby (only the client that created it is allowed to seal a lobby).
-
-When a lobby is sealed, no new client will be able to join, and the lobby will be destroyed (and clients disconnected) after 10 seconds.
-
-Messages with payload (used to transfer WebRTC parameters) are:
-
-- `O: <ID>`, used to send an offer.
-- `A: <ID>`, used to send an answer.
-- `C: <ID>`, used to send a candidate.
-
-When sending the parameter, a client will set `<ID>` as the destination peer, the server will replace it with the id of the sending peer, and rely it to the proper destination.
+The protocol is JSON based, and uses messages in the form:
+
+```
+{
+  "id": "number",
+  "type": "number",
+  "data": "string",
+}
+```
+
+With `type` being the message type, `id` being a connected peer or `0`, and `data` being the message specific data.
+
+Messages are the following:
+
+- `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.
+
+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
 

+ 34 - 26
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)

+ 69 - 77
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
+	ws.close()
 
 
-func _connected(protocol = ""):
-	client.get_peer(1).set_write_mode(WebSocketPeer.WRITE_MODE_TEXT)
-	if autojoin:
+func _process(delta):
+	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 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: "):
+	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
-		emit_signal("peer_connected", src_id)
-	elif type.begins_with("D: "):
+		peer_connected.emit(src_id)
+	elif type == Message.PEER_DISCONNECT:
 		# Client connected
-		emit_signal("peer_disconnected", src_id)
-	elif type.begins_with("O: "):
+		peer_disconnected.emit(src_id)
+	elif type == Message.OFFER:
 		# Offer received
-		emit_signal("offer_received", src_id, req[1])
-	elif type.begins_with("A: "):
+		offer_received.emit(src_id, msg.data)
+	elif type == Message.ANSWER:
 		# Answer received
-		emit_signal("answer_received", src_id, req[1])
-	elif type.begins_with("C: "):
+		answer_received.emit(src_id, msg.data)
+	elif type == Message.CANDIDATE:
 		# Candidate received
-		var candidate: PackedStringArray = req[1].split("\n", false)
+		var candidate: PackedStringArray = msg.data.split("\n", false)
 		if candidate.size() != 3:
-			return
+			return false
 		if not candidate[1].is_valid_int():
-			return
-		emit_signal("candidate_received", src_id, candidate[0], int(candidate[1]), candidate[2])
+			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):
-	return client.get_peer(1).put_packet(("J: %s\n" % lobby).to_utf8())
+func join_lobby(lobby: String):
+	return _send_msg(Message.JOIN, 0 if mesh else 1, lobby)
 
 
 func seal_lobby():
-	return client.get_peer(1).put_packet("S: \n".to_utf8())
+	return _send_msg(Message.SEAL, 0)
 
 
 func send_candidate(id, mid, index, sdp) -> int:
-	return _send_msg("C", id, "\n%s\n%d\n%s" % [mid, index, sdp])
+	return _send_msg(Message.CANDIDATE, id, "\n%s\n%d\n%s" % [mid, index, sdp])
 
 
 func send_offer(id, offer) -> int:
-	return _send_msg("O", id, offer)
+	return _send_msg(Message.OFFER, id, offer)
 
 
 func send_answer(id, answer) -> int:
-	return _send_msg("A", id, answer)
-
+	return _send_msg(Message.ANSWER, 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())
 
-
-func _process(delta):
-	var status: int = client.get_connection_status()
-	if status == WebSocketClient.CONNECTION_CONNECTING or status == WebSocketClient.CONNECTION_CONNECTED:
-		client.poll()
+func _send_msg(type: int, id: int, data:="") -> int:
+	return ws.send_text(JSON.stringify({
+		"type": type,
+		"id": id,
+		"data": data
+	}))

+ 42 - 40
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 _mp_server_connected():
+	_log("[Multiplayer] Server connected (I am %d)" % client.rtc_mp.get_unique_id())
 
-func _disconnected():
-	_log("Signaling server disconnected: %d - %s" % [client.code, client.reason])
 
+func _mp_server_disconnect():
+	_log("[Multiplayer] Server disconnected (I am %d)" % client.rtc_mp.get_unique_id())
 
-func _lobby_joined(lobby):
-	_log("Joined lobby %s" % lobby)
 
+func _mp_peer_connected(id: int):
+	_log("[Multiplayer] Peer %d connected" % id)
 
-func _lobby_sealed():
-	_log("Lobby has been sealed")
 
+func _mp_peer_disconnected(id: int):
+	_log("[Multiplayer] Peer %d disconnected" % id)
 
-func _mp_connected():
-	_log("Multiplayer is connected (I am %d)" % client.rtc_mp.get_unique_id())
 
+func _connected(id):
+	_log("[Signaling] Server connected with ID: %d" % id)
 
-func _mp_server_disconnect():
-	_log("Multiplayer is disconnected (I am %d)" % client.rtc_mp.get_unique_id())
 
+func _disconnected():
+	_log("[Signaling] Server disconnected: %d - %s" % [client.code, client.reason])
 
-func _mp_peer_connected(id: int):
-	_log("Multiplayer peer %d connected" % id)
 
+func _lobby_joined(lobby):
+	_log("[Signaling] Joined lobby %s" % lobby)
 
-func _mp_peer_disconnected(id: int):
-	_log("Multiplayer peer %d disconnected" % id)
+
+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():
+	client.seal_lobby()
 
 
-func _on_Seal_pressed():
-	client.seal_lobby()
+func _on_start_pressed():
+	client.start(host.text, room.text, mesh.button_pressed)
 
 
-func stop():
+func _on_stop_pressed():
 	client.stop()

+ 65 - 52
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="Mesh" type="CheckBox" parent="VBoxContainer/Connect"]
+layout_mode = 2
+offset_left = 954.0
+offset_right = 1024.0
+offset_bottom = 31.0
+button_pressed = true
+text = "Mesh"
+
 [node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
-offset_top = 32.0
+layout_mode = 2
+offset_top = 35.0
 offset_right = 1024.0
-offset_bottom = 52.0
-custom_constants/separation = 10
-__meta__ = {
-"_edit_use_anchors_": false
-}
+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"]

+ 8 - 0
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()

+ 47 - 54
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"]

+ 3 - 3
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"
 

+ 118 - 107
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
+			if not mesh and p.id != host:
+				# Only host is visible when using client-server
 				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)
+			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)
-
-
-func _peer_connected(id, protocol = ""):
-	peers[id] = Peer.new(id)
+				p.ws.close()
+				to_remove.push_back(p.id)
 
+	# Remove stale peers
+	for id in to_remove:
+		peers.erase(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
-
-	var type = req[0]
-	if type.length() < 3: # Invalid type size
+	if not str(parsed.type).is_valid_int() or not str(parsed.id).is_valid_int():
 		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))
+	var msg := {
+		"type": str(parsed.type).to_int(),
+		"id": str(parsed.id).to_int(),
+		"data": parsed.data
+	}
 
-	if not peers[id].lobby: # Messages across peers are only allowed in same lobby
-		return false
+	if msg.type == Message.JOIN:
+		if peer.lobby: # Peer must not have joined a lobby already!
+			return false
+		return _join_lobby(peer, msg.data, msg.id == 0)
 
-	if not lobbies.has(peers[id].lobby): # Lobby not found?
+	if not lobbies.has(peer.lobby): # Lobby not found?
 		return false
 
-	var lobby = lobbies[peers[id].lobby]
+	var lobby = lobbies[peer.lobby]
 
-	if type.begins_with("S: "):
+	if msg.type == Message.SEAL:
 		# 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_int(): # Destination id is not an integer
-		return false
+		return lobby.seal(peer.id)
 
-	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 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
+	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
+
+	return false # Unknown message

+ 52 - 0
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"
+		}],
+	}
+};

+ 0 - 318
networking/webrtc_signaling/server_node/.eslintrc.json

@@ -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"
-        ]
-    }
-}

+ 6 - 3
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"

+ 138 - 95
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";
-
-function randomInt (low, high) {
+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 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);
 	});
 });