Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ It runs on [bun], using the human-readable [Trimsock] protocol.
might need!
- Manage one or multiple games in a single *nohub* instance
- Metrics via [Prometheus] - always be aware how your server is doing!
- WebSocket support for web-based games (Godot web exports, browser games)

## Usage

Expand All @@ -46,12 +47,9 @@ docker image].
To run the *nohub* docker image, make sure to expose the necessary ports:

```sh
docker run -p 9980:9980 -p 9981:9981 ghcr.io/foxssake/nohub:main
docker run -p 9980:9980 -p 9981:9981 -p 9982:9982 ghcr.io/foxssake/nohub:main
```

This exposes port `9980` for clients to connect on, and port `9981` to
serve metrics.

#### Using bun

Alternatively, *nohub* can be run from source, using the following steps:
Expand Down
8 changes: 4 additions & 4 deletions docs/source/getting-started/using-nohub.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ Establishing a connection
^^^^^^^^^^^^^^^^^^^^^^^^^

Connect to the desired *nohub* server over TCP using `StreamPeerTCP`_. Once the
connection has finished, create a ``NohubClient`` instance with the connection:
connection has finished, create a ``NohubTCPClient`` instance with the connection:

.. highlight:: gdscript
.. code::

var connection: StreamPeerTCP
var client: NohubClient
var client: NohubTCPClient

func _ready():
# Use public instance
Expand All @@ -62,7 +62,7 @@ connection has finished, create a ``NohubClient`` instance with the connection:
push_error("Failed to establish connection to nohub at %s:%d - status: %d" % [host, port, connection.get_status()])
return

client = NohubClient.new(connection)
client = NohubTCPClient.new(connection)
print("Successfully connected to nohub at %s:%d!" % [host, port])


Expand All @@ -72,7 +72,7 @@ Creating a lobby
^^^^^^^^^^^^^^^^

With the client instantiated, all of the supported commands are accessible.
Let's see how to create a lobby using the ``NohubClient.create_lobby()``
Let's see how to create a lobby using the ``NohubTCPClient.create_lobby()``
method:

.. highlight:: gdscript
Expand Down
29 changes: 29 additions & 0 deletions docs/source/server-guide/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,35 @@ TCP
Recognizes simple numbers ( ``1024`` ), or human-readable sizes ( ``100b``,
``1kb``, etc. ).

WebSocket
---------

.. glossary::

``NOHUB_WEBSOCKET_ENABLED``
Enable or disable the WebSocket proxy service. The WebSocket proxy allows
web-based clients (like Godot web exports) to connect to nohub.

Defaults to ``true``.

``NOHUB_WEBSOCKET_HOST``
WebSocket host to listen on. Set to ``*`` to listen on all available
interfaces, or to ``0.0.0.0`` to only listen over IPv4.

Defaults to ``*``.

``NOHUB_WEBSOCKET_PORT``
WebSocket port to listen on. This is where WebSocket clients can connect
to access nohub functionality.

Defaults to ``9982``.

``NOHUB_WEBSOCKET_PATH``
WebSocket endpoint path. Clients should connect to this path on the
WebSocket server.

Defaults to ``/ws``.

Games
-----

Expand Down
122 changes: 63 additions & 59 deletions nohub.gd/addons/nohub.gd/nohub_client.gd
Original file line number Diff line number Diff line change
@@ -1,66 +1,41 @@
extends RefCounted
class_name NohubClient

## Nohub client implementation
##
## This class provides access to all the functionality implemented in nohub.
## This is done via a TCP connection. To use this client, establish a connection
## to the desired nohub server using [StreamPeerTCP], and instantiate the
## client.
## [br][br]
## Make sure to regularly poll the client using [method poll]. Otherwise, client
## calls will never return.
## [br][br]
## Every operation returns a [NohubResult]. If the operation is successful, the
## result object contains the data returned by nohub. Otherwise, the result will
## contain the error. This results in calls like this:
## [codeblock]
## var result := await nohub_client.list_lobbies()
## if result.is_success():
## var lobbies := result.value()
## # ...
## else:
## push_error(result.error())
## [/codeblock]
##
## @tutorial(Getting started): https://foxssake.github.io/nohub/getting-started/using-nohub.html#with-godot
## @tutorial(Understanding nohub): https://foxssake.github.io/nohub/understanding-nohub/index.html


var _connection: StreamPeerTCP
var _reactor: TrimsockTCPClientReactor


## Construct a client using the specified [param connection]
func _init(connection: StreamPeerTCP):
_connection = connection
_connection.set_no_delay(true)

_reactor = TrimsockTCPClientReactor.new(connection)

## Poll the client
## [br][br]
## This will poll the underlying connection and process any incoming commands.
func poll() -> void:
_reactor.poll()
## Base class for nohub clients
## This class provides the common interface for nohub clients.
## Specific implementations should extend this class and implement the required methods.

var _reactor: TrimsockReactor

func _is_ready() -> bool:
push_error("_is_ready() must be implemented by subclass")
return false

func _get_reactor() -> TrimsockReactor:
return _reactor

## Specify the game ID used by this client
## [br][br]
## See [url=https://foxssake.github.io/nohub/understanding-nohub/concepts.html#games]Games[/url].
func set_game(id: String) -> NohubResult:
if not _is_ready():
return NohubResult.of_error("NotConnected", "Client is not connected to server")

var request := TrimsockCommand.request("session/set-game")\
.with_params([id])
return await _bool_request(request)

## Create a lobby
func create_lobby(address: String, data: Dictionary = {}) -> NohubResult.Lobby:
if not _is_ready():
return NohubResult.of_error("NotConnected", "Client is not connected to server")

var request := TrimsockCommand.request("lobby/create")\
.with_params([address])
for key in data:
request.with_kv_pairs([TrimsockCommand.pair_of(key, data[key])])

var xchg := _reactor.submit_request(request)
var response := await xchg.read()
var xchg: TrimsockExchange = _get_reactor().submit_request(request)
var response: TrimsockCommand = await xchg.read()

if response.is_success():
return NohubResult.Lobby.of_value(_command_to_lobby(response))
Expand All @@ -72,10 +47,13 @@ func create_lobby(address: String, data: Dictionary = {}) -> NohubResult.Lobby:
## If [param properties] is specified, only the listed properties will be
## returned from the lobby's custom data.
func get_lobby(id: String, properties: Array[String] = []) -> NohubResult.Lobby:
if not _is_ready():
return NohubResult.of_error("NotConnected", "Client is not connected to server")

var request := TrimsockCommand.request("lobby/get")\
.with_params([id] + properties)
var xchg := _reactor.submit_request(request)
var response := await xchg.read()
var xchg: TrimsockExchange = _get_reactor().submit_request(request)
var response: TrimsockCommand = await xchg.read()

if response.is_success():
return NohubResult.Lobby.of_value(_command_to_lobby(response))
Expand All @@ -87,27 +65,33 @@ func get_lobby(id: String, properties: Array[String] = []) -> NohubResult.Lobby:
## If [param properties] is specified, only the listed properties will be
## returned from the lobby's custom data.
func list_lobbies(properties: Array[String] = []) -> NohubResult.LobbyList:
if not _is_ready():
return NohubResult.of_error("NotConnected", "Client is not connected to server")

var result := [] as Array[NohubLobby]
var request := TrimsockCommand.request("lobby/list")\
.with_params(properties)

var xchg := _reactor.submit_request(request)
var xchg: TrimsockExchange = _get_reactor().submit_request(request)
while xchg.is_open():
var cmd := await xchg.read()
var cmd: TrimsockCommand = await xchg.read()

if cmd.is_error():
return _command_to_error(cmd)
if not cmd.is_stream_chunk():
continue

result.append(_command_to_lobby(cmd))
result.push_back(_command_to_lobby(cmd))

return NohubResult.LobbyList.of_value(result)

## Delete a lobby using its ID
## [br][br]
## Only the lobby's owner can delete the lobby.
func delete_lobby(lobby_id: String) -> NohubResult:
if not _is_ready():
return NohubResult.of_error("NotConnected", "Client is not connected to server")

var request := TrimsockCommand.request("lobby/delete")\
.with_params([lobby_id])
return await _bool_request(request)
Expand All @@ -117,11 +101,14 @@ func delete_lobby(lobby_id: String) -> NohubResult:
## The response will contain the lobby's address. This string can be used to
## connect.
func join_lobby(lobby_id: String) -> NohubResult.Address:
if not _is_ready():
return NohubResult.of_error("NotConnected", "Client is not connected to server")

var request := TrimsockCommand.request("lobby/join")\
.with_params([lobby_id])

var xchg := _reactor.submit_request(request)
var response := await xchg.read()
var xchg: TrimsockExchange = _get_reactor().submit_request(request)
var response: TrimsockCommand = await xchg.read()

if response.is_success():
return NohubResult.Address.of_value(response.params[0])
Expand All @@ -132,6 +119,9 @@ func join_lobby(lobby_id: String) -> NohubResult.Address:
## [br][br]
## Only the lobby's owner can lock the lobby.
func lock_lobby(lobby_id: String) -> NohubResult:
if not _is_ready():
return NohubResult.of_error("NotConnected", "Client is not connected to server")

var request := TrimsockCommand.request("lobby/lock")\
.with_params([lobby_id])
return await _bool_request(request)
Expand All @@ -140,6 +130,9 @@ func lock_lobby(lobby_id: String) -> NohubResult:
## [br][br]
## Only the lobby's owner can unlock the lobby. Lobbies are unlocked by default.
func unlock_lobby(lobby_id: String) -> NohubResult:
if not _is_ready():
return NohubResult.of_error("NotConnected", "Client is not connected to server")

var request := TrimsockCommand.request("lobby/unlock")\
.with_params([lobby_id])
return await _bool_request(request)
Expand All @@ -148,6 +141,9 @@ func unlock_lobby(lobby_id: String) -> NohubResult:
## [br][br]
## Only the lobby's owner can hide the lobby.
func hide_lobby(lobby_id: String) -> NohubResult:
if not _is_ready():
return NohubResult.of_error("NotConnected", "Client is not connected to server")

var request := TrimsockCommand.request("lobby/hide")\
.with_params([lobby_id])
return await _bool_request(request)
Expand All @@ -156,6 +152,9 @@ func hide_lobby(lobby_id: String) -> NohubResult:
## [br][br]
## Only the lobby's owner can hide the lobby. Lobbies are visible by default.
func publish_lobby(lobby_id: String) -> NohubResult:
if not _is_ready():
return NohubResult.of_error("NotConnected", "Client is not connected to server")

var request := TrimsockCommand.request("lobby/publish")\
.with_params([lobby_id])
return await _bool_request(request)
Expand All @@ -165,6 +164,9 @@ func publish_lobby(lobby_id: String) -> NohubResult:
## Note that this method updates the data, instead of adding to it. Only the
## lobby's owner can update the lobby's custom data.
func set_lobby_data(lobby_id: String, data: Dictionary) -> NohubResult:
if not _is_ready():
return NohubResult.of_error("NotConnected", "Client is not connected to server")

var request := TrimsockCommand.request("lobby/set-data")\
.with_params([lobby_id])\
.with_kv_map(data)
Expand All @@ -176,18 +178,21 @@ func set_lobby_data(lobby_id: String, data: Dictionary) -> NohubResult:
## on its configuration, the server might not be able to return a useful address
## - e.g. when running from Docker using a bridge network.
func whereami() -> String:
if not _is_ready():
return ""

var request := TrimsockCommand.request("whereami")
var xchg := _reactor.submit_request(request)
var response := await xchg.read()
var xchg: TrimsockExchange = _get_reactor().submit_request(request)
var response: TrimsockCommand = await xchg.read()

if response.is_success():
return response.text
else:
return ""

func _bool_request(request: TrimsockCommand) -> NohubResult:
var xchg := _reactor.submit_request(request)
var response := await xchg.read()
var xchg: TrimsockExchange = _get_reactor().submit_request(request)
var response: TrimsockCommand = await xchg.read()
if response.is_success():
return NohubResult.of_success()
else:
Expand All @@ -199,11 +204,10 @@ func _command_to_lobby(command: TrimsockCommand) -> NohubLobby:
lobby.is_locked = command.params.find("locked", 1) >= 0
lobby.is_visible = command.params.find("hidden", 1) < 0
lobby.data = command.kv_map

return lobby

func _command_to_error(command: TrimsockCommand) -> NohubResult:
if command.is_error() and command.params.size() >= 2:
return NohubResult.of_error(command.params[0], command.params[1])
else:
return NohubResult.of_error(command.name, "")
return NohubResult.of_error(command.name, "")
43 changes: 43 additions & 0 deletions nohub.gd/addons/nohub.gd/nohub_tcp_client.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
extends NohubClient
class_name NohubTCPClient

## Nohub TCP client implementation
##
## This class provides access to all the functionality implemented in nohub.
## This is done via a TCP connection. To use this client, establish a connection
## to the desired nohub server using [StreamPeerTCP], and instantiate the
## client.
## [br][br]
## Make sure to regularly poll the client using [method poll]. Otherwise, client
## calls will never return.
## [br][br]
## Every operation returns a [NohubResult]. If the operation is successful, the
## result object contains the data returned by nohub. Otherwise, the result will
## contain the error. This results in calls like this:
## [codeblock]
## var result := await nohub_client.list_lobbies()
## if result.is_success():
## var lobbies := result.value()
## # ...
## else:
## push_error(result.error())
## [/codeblock]
##
## @tutorial(Getting started): https://foxssake.github.io/nohub/getting-started/using-nohub.html#with-godot
## @tutorial(Understanding nohub): https://foxssake.github.io/nohub/understanding-nohub/index.html

var _connection: StreamPeerTCP

## Construct a client using the specified [param connection]
func _init(connection: StreamPeerTCP):
_connection = connection
_connection.set_no_delay(true)
_reactor = TrimsockTCPClientReactor.new(connection)

## Poll the client
func poll() -> void:
_reactor.poll()

## Override base class method - TCP client is always "ready" if connection exists
func _is_ready() -> bool:
return _connection != null
1 change: 1 addition & 0 deletions nohub.gd/addons/nohub.gd/nohub_tcp_client.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://bvxo06717lxwx
Loading
Loading