Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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