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.
This commit is contained in:
Fabio Alessandrelli
2022-11-15 00:10:20 +01:00
parent 93509019a9
commit 364e8cbfb8
13 changed files with 635 additions and 822 deletions

View File

@@ -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: <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).
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: <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.
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

View File

@@ -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)

View File

@@ -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
}))

View File

@@ -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()

View File

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

View File

@@ -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()

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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);
});
});