From 66d1feb03ca07e0820cf48c4becb6116eb3db79e Mon Sep 17 00:00:00 2001 From: Alberto Klocker Date: Fri, 26 Dec 2025 12:00:12 +1000 Subject: [PATCH 1/6] websocket proxy --- README.md | 6 +- docs/source/server-guide/configuration.rst | 29 ++++ nohub/Dockerfile | 2 +- nohub/src/config.ts | 8 ++ nohub/src/nohub.ts | 7 + nohub/src/websocket/websocket.module.ts | 150 +++++++++++++++++++++ 6 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 nohub/src/websocket/websocket.module.ts diff --git a/README.md b/README.md index fbc4123..628fde2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/docs/source/server-guide/configuration.rst b/docs/source/server-guide/configuration.rst index d4905b0..ea9e3f4 100644 --- a/docs/source/server-guide/configuration.rst +++ b/docs/source/server-guide/configuration.rst @@ -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 ----- diff --git a/nohub/Dockerfile b/nohub/Dockerfile index b485e3c..416102b 100644 --- a/nohub/Dockerfile +++ b/nohub/Dockerfile @@ -31,6 +31,6 @@ COPY --from=prerelease /usr/src/foxssake/nohub/src src # run the app USER bun -EXPOSE 9980/tcp +EXPOSE 9980/tcp 9981/tcp 9982/tcp ENTRYPOINT [ "bun", "." ] diff --git a/nohub/src/config.ts b/nohub/src/config.ts index 9d4ed2d..7039205 100644 --- a/nohub/src/config.ts +++ b/nohub/src/config.ts @@ -10,6 +10,13 @@ export function readConfig(env: ConfigEnv) { commandBufferSize: byteSize(env.NOHUB_TCP_COMMAND_BUFFER_SIZE) ?? 8192, }, + websocket: { + enabled: bool(env.NOHUB_WEBSOCKET_ENABLED) ?? true, + host: env.NOHUB_WEBSOCKET_HOST ?? "*", + port: integer(env.NOHUB_WEBSOCKET_PORT) ?? 9982, + path: env.NOHUB_WEBSOCKET_PATH ?? "/ws", + }, + metrics: { enabled: bool(env.NOHUB_METRICS_ENABLED) ?? true, host: env.NOHUB_METRICS_HOST ?? "*", @@ -55,6 +62,7 @@ export function readDefaultConfig() { export type AppConfig = ReturnType; export type TcpConfig = AppConfig["tcp"]; +export type WebSocketConfig = AppConfig["websocket"]; export type MetricsConfig = AppConfig["metrics"]; export type GamesConfig = AppConfig["games"]; export type LobbiesConfig = AppConfig["lobbies"]; diff --git a/nohub/src/nohub.ts b/nohub/src/nohub.ts index 67d7772..11e34a7 100644 --- a/nohub/src/nohub.ts +++ b/nohub/src/nohub.ts @@ -10,6 +10,7 @@ import { MetricsModule } from "./metrics/metrics.module"; import type { Module } from "./module"; import type { SessionData } from "./sessions/session"; import { SessionModule } from "./sessions/session.module"; +import { WebSocketModule } from "./websocket/websocket.module"; export type NohubReactor = BunSocketReactor; @@ -19,6 +20,7 @@ export class NohubModules { readonly gameModule: GameModule; readonly lobbyModule: LobbyModule; readonly sessionModule: SessionModule; + readonly webSocketModule: WebSocketModule; readonly all: Module[]; @@ -37,12 +39,14 @@ export class NohubModules { config.sessions, this.metricsModule.metricsHolder, ); + this.webSocketModule = new WebSocketModule(this.config.websocket); this.all = [ this.metricsModule, this.gameModule, this.lobbyModule, this.sessionModule, + this.webSocketModule, ]; } } @@ -167,6 +171,9 @@ export class Nohub { if (!this.socket) return; rootLogger.info("Shutting down"); + + this.modules.webSocketModule.shutdown(); + this.socket?.stop(true); rootLogger.info("Socket closed"); } diff --git a/nohub/src/websocket/websocket.module.ts b/nohub/src/websocket/websocket.module.ts new file mode 100644 index 0000000..1f9e933 --- /dev/null +++ b/nohub/src/websocket/websocket.module.ts @@ -0,0 +1,150 @@ +import type { ServerWebSocket } from "bun"; +import type { WebSocketConfig } from "@src/config"; +import { rootLogger } from "@src/logger"; +import type { Module } from "@src/module"; +import type { Nohub } from "@src/nohub"; + +interface WebSocketData { + tcpSocket: TCPSocket; + buffer: Buffer[]; +} + +export class WebSocketModule implements Module { + private server?: Bun.Server; + private nohub?: Nohub; + private readonly logger = rootLogger.child({ module: "websocket" }); + + constructor(private readonly config: WebSocketConfig) {} + + attachTo(app: Nohub): void { + this.nohub = app; + + if (!this.config.enabled) { + this.logger.info("WebSocket proxy disabled"); + return; + } + + this.startWebSocketServer(); + } + + private async startWebSocketServer() { + if (!this.nohub) { + throw new Error("WebSocket module not attached to nohub instance"); + } + + const tcpHost = this.nohub.config.tcp.host === "*" ? "localhost" : this.nohub.config.tcp.host; + const tcpPort = this.nohub.config.tcp.port; + + this.server = Bun.serve({ + hostname: this.config.host === "*" ? undefined : this.config.host, + port: this.config.port, + + fetch: async (req, server) => { + const url = new URL(req.url); + + if (url.pathname !== this.config.path) { + return new Response("Not Found", { status: 404 }); + } + + if (server.upgrade(req)) { + return; + } + + return new Response("Upgrade failed", { status: 400 }); + }, + + websocket: { + async open(ws: ServerWebSocket) { + try { + // Create TCP connection to the nohub server + const tcpSocket = await Bun.connect({ + hostname: tcpHost, + port: tcpPort, + socket: { + data(socket, data) { + // Forward TCP data to WebSocket + if (ws.readyState === 1) { // WebSocket.OPEN + ws.send(data); + } + }, + error(socket, error) { + rootLogger.error({ error }, "TCP socket error in WebSocket proxy"); + ws.close(); + }, + close(socket) { + if (ws.readyState === 1) { // WebSocket.OPEN + ws.close(); + } + } + } + }); + + ws.data = { + tcpSocket, + buffer: [] + }; + + rootLogger.debug("WebSocket client connected, TCP bridge established"); + } catch (error) { + rootLogger.error({ error }, "Failed to establish TCP connection for WebSocket client"); + ws.close(); + } + }, + + message(ws: ServerWebSocket, message) { + // Forward WebSocket message to TCP socket + if (ws.data?.tcpSocket) { + try { + let data: Buffer; + if (message instanceof Buffer) { + data = message; + } else if (typeof message === "string") { + data = Buffer.from(message, "utf8"); + } else { + data = Buffer.from(message); + } + + ws.data.tcpSocket.write(data); + } catch (error) { + rootLogger.error({ error }, "Failed to forward WebSocket message to TCP"); + ws.close(); + } + } + }, + + close(ws: ServerWebSocket) { + // Close TCP connection when WebSocket closes + if (ws.data?.tcpSocket) { + try { + ws.data.tcpSocket.end(); + } catch (error) { + rootLogger.error({ error }, "Error closing TCP socket"); + } + } + rootLogger.debug("WebSocket client disconnected"); + }, + + error(ws: ServerWebSocket, error) { + rootLogger.error({ error }, "WebSocket error"); + if (ws.data?.tcpSocket) { + try { + ws.data.tcpSocket.end(); + } catch (e) { + // Were tearing down the socket, ignore errors + } + } + } + } + }); + + this.logger.info("WebSocket proxy listening on %s:%d%s", + this.config.host, this.config.port, this.config.path); + } + + shutdown(): void { + if (this.server) { + this.server.stop(true); + this.logger.info("WebSocket proxy stopped"); + } + } +} \ No newline at end of file From 1a2ccfd6e657b6d0aafd98d25c326eaab1b60c1d Mon Sep 17 00:00:00 2001 From: Alberto Klocker Date: Fri, 26 Dec 2025 12:08:48 +1000 Subject: [PATCH 2/6] websocket gdscript client --- nohub.gd/addons/nohub.gd/nohub_client.gd | 127 +++++++++--------- nohub.gd/addons/nohub.gd/nohub_tcp_client.gd | 43 ++++++ .../addons/nohub.gd/nohub_tcp_client.gd.uid | 1 + .../addons/nohub.gd/nohub_websocket_client.gd | 76 +++++++++++ .../nohub.gd/nohub_websocket_client.gd.uid | 1 + .../trimsock.gd/reactors/trimsock_ws.gd.uid | 1 + .../trimsock.gd/reactors/ws_client_reactor.gd | 35 +++++ .../reactors/ws_client_reactor.gd.uid | 1 + 8 files changed, 225 insertions(+), 60 deletions(-) create mode 100644 nohub.gd/addons/nohub.gd/nohub_tcp_client.gd create mode 100644 nohub.gd/addons/nohub.gd/nohub_tcp_client.gd.uid create mode 100644 nohub.gd/addons/nohub.gd/nohub_websocket_client.gd create mode 100644 nohub.gd/addons/nohub.gd/nohub_websocket_client.gd.uid create mode 100644 nohub.gd/addons/trimsock.gd/reactors/trimsock_ws.gd.uid create mode 100644 nohub.gd/addons/trimsock.gd/reactors/ws_client_reactor.gd create mode 100644 nohub.gd/addons/trimsock.gd/reactors/ws_client_reactor.gd.uid diff --git a/nohub.gd/addons/nohub.gd/nohub_client.gd b/nohub.gd/addons/nohub.gd/nohub_client.gd index 9efa88e..0ca6044 100644 --- a/nohub.gd/addons/nohub.gd/nohub_client.gd +++ b/nohub.gd/addons/nohub.gd/nohub_client.gd @@ -1,66 +1,44 @@ -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 +## Query a lobby by ID +## [br][br] +## If [param properties] is specified, only the listed properties will be +## returned from the lobby's custom data. 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)) @@ -72,10 +50,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)) @@ -87,27 +68,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) @@ -117,11 +104,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]) @@ -132,6 +122,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) @@ -140,6 +133,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) @@ -148,6 +144,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) @@ -156,6 +155,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) @@ -165,6 +167,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) @@ -176,9 +181,12 @@ 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 @@ -186,8 +194,8 @@ func whereami() -> String: 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: @@ -199,11 +207,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, "") \ No newline at end of file diff --git a/nohub.gd/addons/nohub.gd/nohub_tcp_client.gd b/nohub.gd/addons/nohub.gd/nohub_tcp_client.gd new file mode 100644 index 0000000..2e52a7c --- /dev/null +++ b/nohub.gd/addons/nohub.gd/nohub_tcp_client.gd @@ -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 diff --git a/nohub.gd/addons/nohub.gd/nohub_tcp_client.gd.uid b/nohub.gd/addons/nohub.gd/nohub_tcp_client.gd.uid new file mode 100644 index 0000000..d3184d5 --- /dev/null +++ b/nohub.gd/addons/nohub.gd/nohub_tcp_client.gd.uid @@ -0,0 +1 @@ +uid://bvxo06717lxwx diff --git a/nohub.gd/addons/nohub.gd/nohub_websocket_client.gd b/nohub.gd/addons/nohub.gd/nohub_websocket_client.gd new file mode 100644 index 0000000..855e533 --- /dev/null +++ b/nohub.gd/addons/nohub.gd/nohub_websocket_client.gd @@ -0,0 +1,76 @@ +extends NohubClient +class_name NohubWebSocketClient + +## WebSocket-based Nohub client implementation +## +## This class provides access to all nohub functionality via a WebSocket connection. +## This is particularly useful for Godot web exports that cannot create TCP connections. +## The WebSocket client connects to a WebSocket proxy service that bridges to the nohub TCP server. +## [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. +## [codeblock] +## var client := NohubWebSocketClient.new() +## await client.connect_to("ws://localhost:9982/ws") +## +## var result := await 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/concepts.html + +var _socket: WebSocketPeer +var _is_connected: bool = false + +func _init(): + _socket = WebSocketPeer.new() + _reactor = TrimsockWSClientReactor.new(_socket) + +## Connect to the nohub WebSocket proxy server +## [br][br] +## [param url] should be in the format "ws://hostname:port/path" or "wss://..." for secure connections. +## The default nohub WebSocket proxy listens on port 9982 with path "/ws". +func connect_to(url: String) -> NohubResult: + var error := _socket.connect_to_url(url) + if error != OK: + return NohubResult.of_error("ConnectionError", "Failed to connect to WebSocket: " + str(error)) + + var timeout := Time.get_ticks_msec() + 5000 # 5 second timeout + while _socket.get_ready_state() == WebSocketPeer.STATE_CONNECTING: + _socket.poll() + await Engine.get_main_loop().process_frame + if Time.get_ticks_msec() > timeout: + return NohubResult.of_error("ConnectionTimeout", "Connection timed out") + + if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN: + return NohubResult.of_error("ConnectionFailed", "Failed to establish WebSocket connection") + + _is_connected = true + return NohubResult.of_success() + +func is_connected_to_server() -> bool: + return _is_connected and _socket.get_ready_state() == WebSocketPeer.STATE_OPEN + +func disconnect_from_server() -> void: + if _socket: + _socket.close() + _is_connected = false + +func poll() -> void: + if _socket: + _socket.poll() + if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN: + _is_connected = false + _reactor.poll() + +func _is_ready() -> bool: + return is_connected_to_server() diff --git a/nohub.gd/addons/nohub.gd/nohub_websocket_client.gd.uid b/nohub.gd/addons/nohub.gd/nohub_websocket_client.gd.uid new file mode 100644 index 0000000..40b4633 --- /dev/null +++ b/nohub.gd/addons/nohub.gd/nohub_websocket_client.gd.uid @@ -0,0 +1 @@ +uid://ceuuqmcq7vdwr diff --git a/nohub.gd/addons/trimsock.gd/reactors/trimsock_ws.gd.uid b/nohub.gd/addons/trimsock.gd/reactors/trimsock_ws.gd.uid new file mode 100644 index 0000000..cd5452f --- /dev/null +++ b/nohub.gd/addons/trimsock.gd/reactors/trimsock_ws.gd.uid @@ -0,0 +1 @@ +uid://q352swcpo7bh diff --git a/nohub.gd/addons/trimsock.gd/reactors/ws_client_reactor.gd b/nohub.gd/addons/trimsock.gd/reactors/ws_client_reactor.gd new file mode 100644 index 0000000..099cee2 --- /dev/null +++ b/nohub.gd/addons/trimsock.gd/reactors/ws_client_reactor.gd @@ -0,0 +1,35 @@ +extends TrimsockReactor +class_name TrimsockWSClientReactor + +var _socket: WebSocketPeer + +func _init(socket: WebSocketPeer): + _socket = socket + attach(_socket) + +func submit(command: TrimsockCommand) -> TrimsockExchange: + return send(_socket, command) + +func submit_request(command: TrimsockCommand) -> TrimsockExchange: + return request(_socket, command) + +func submit_stream(command: TrimsockCommand) -> TrimsockExchange: + return stream(_socket, command) + +func _poll() -> void: + if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN: + return + + while _socket.get_available_packet_count() > 0: + var packet := _socket.get_packet() + _ingest(_socket, packet) + +func _write(target: Variant, command: TrimsockCommand) -> void: + assert(target is WebSocketPeer, "Invalid target!") + var socket := target as WebSocketPeer + var data := command.serialize() + + if data is PackedByteArray: + socket.send_text((data as PackedByteArray).get_string_from_utf8()) + else: + socket.send_text(str(data)) \ No newline at end of file diff --git a/nohub.gd/addons/trimsock.gd/reactors/ws_client_reactor.gd.uid b/nohub.gd/addons/trimsock.gd/reactors/ws_client_reactor.gd.uid new file mode 100644 index 0000000..abc194b --- /dev/null +++ b/nohub.gd/addons/trimsock.gd/reactors/ws_client_reactor.gd.uid @@ -0,0 +1 @@ +uid://1wgxo1ydsa2y From e8b1268a4af731027d775f1971221b53ddae73ad Mon Sep 17 00:00:00 2001 From: Alberto Klocker Date: Fri, 26 Dec 2025 12:30:15 +1000 Subject: [PATCH 3/6] updated docs --- docs/source/getting-started/using-nohub.rst | 8 ++++---- nohub.gd/browser.tscn | 4 ++-- nohub.gd/getting_started.tscn | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/source/getting-started/using-nohub.rst b/docs/source/getting-started/using-nohub.rst index 4109d5d..f03b981 100644 --- a/docs/source/getting-started/using-nohub.rst +++ b/docs/source/getting-started/using-nohub.rst @@ -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 @@ -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]) @@ -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 diff --git a/nohub.gd/browser.tscn b/nohub.gd/browser.tscn index 91baafb..48dbbb6 100644 --- a/nohub.gd/browser.tscn +++ b/nohub.gd/browser.tscn @@ -42,7 +42,7 @@ var time: float = 0.0 var _connection: StreamPeerTCP = null -var _client: NohubClient = null +var _client: NohubTCPClient = null var _last_list: float = -1. var _lobbies_in_list: Array[NohubLobby] = [] @@ -134,7 +134,7 @@ func _connect() -> void: popup(\"Couldn't connect to nohub at %s:%d - %s\" % [host, port, error_string(err)]) return - _client = NohubClient.new(_connection) + _client = NohubTCPClient.new(_connection) func _disconnect() -> void: if _connection == null: diff --git a/nohub.gd/getting_started.tscn b/nohub.gd/getting_started.tscn index 4aa4aed..6820610 100644 --- a/nohub.gd/getting_started.tscn +++ b/nohub.gd/getting_started.tscn @@ -4,7 +4,7 @@ script/source = "extends Control var connection: StreamPeerTCP -var client: NohubClient +var client: NohubTCPClient func _ready(): # Establishing a connection @@ -27,7 +27,7 @@ func _ready(): 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]) # Creating a lobby From 44ba7797bc20df2d6d9db7d778acc1f6c04357ca Mon Sep 17 00:00:00 2001 From: Alberto Klocker Date: Fri, 26 Dec 2025 12:44:22 +1000 Subject: [PATCH 4/6] fixed comment --- nohub.gd/addons/nohub.gd/nohub_client.gd | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nohub.gd/addons/nohub.gd/nohub_client.gd b/nohub.gd/addons/nohub.gd/nohub_client.gd index 0ca6044..11d6f76 100644 --- a/nohub.gd/addons/nohub.gd/nohub_client.gd +++ b/nohub.gd/addons/nohub.gd/nohub_client.gd @@ -24,10 +24,7 @@ func set_game(id: String) -> NohubResult: .with_params([id]) return await _bool_request(request) -## Query a lobby by ID -## [br][br] -## If [param properties] is specified, only the listed properties will be -## returned from the lobby's custom data. +## 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") From bf266ea8b43d27692b50cc50fea839d934f9dec9 Mon Sep 17 00:00:00 2001 From: Alberto Klocker Date: Sat, 21 Mar 2026 16:48:00 +1000 Subject: [PATCH 5/6] Catch startup failure --- nohub/src/websocket/websocket.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nohub/src/websocket/websocket.module.ts b/nohub/src/websocket/websocket.module.ts index 1f9e933..307d5be 100644 --- a/nohub/src/websocket/websocket.module.ts +++ b/nohub/src/websocket/websocket.module.ts @@ -24,7 +24,7 @@ export class WebSocketModule implements Module { return; } - this.startWebSocketServer(); + this.startWebSocketServer().catch((err) => this.logger.error(err, "Failed to start WebSocket server")); } private async startWebSocketServer() { From 5d5e94d8a0a134069989bd3b8b9ded71c3ea3d5e Mon Sep 17 00:00:00 2001 From: Alberto Klocker Date: Sat, 21 Mar 2026 16:51:58 +1000 Subject: [PATCH 6/6] module logger --- nohub/src/websocket/websocket.module.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/nohub/src/websocket/websocket.module.ts b/nohub/src/websocket/websocket.module.ts index 307d5be..e8454c2 100644 --- a/nohub/src/websocket/websocket.module.ts +++ b/nohub/src/websocket/websocket.module.ts @@ -34,6 +34,7 @@ export class WebSocketModule implements Module { const tcpHost = this.nohub.config.tcp.host === "*" ? "localhost" : this.nohub.config.tcp.host; const tcpPort = this.nohub.config.tcp.port; + const logger = this.logger; this.server = Bun.serve({ hostname: this.config.host === "*" ? undefined : this.config.host, @@ -68,7 +69,7 @@ export class WebSocketModule implements Module { } }, error(socket, error) { - rootLogger.error({ error }, "TCP socket error in WebSocket proxy"); + logger.error({ error }, "TCP socket error in WebSocket proxy"); ws.close(); }, close(socket) { @@ -84,9 +85,9 @@ export class WebSocketModule implements Module { buffer: [] }; - rootLogger.debug("WebSocket client connected, TCP bridge established"); + logger.debug("WebSocket client connected, TCP bridge established"); } catch (error) { - rootLogger.error({ error }, "Failed to establish TCP connection for WebSocket client"); + logger.error({ error }, "Failed to establish TCP connection for WebSocket client"); ws.close(); } }, @@ -106,7 +107,7 @@ export class WebSocketModule implements Module { ws.data.tcpSocket.write(data); } catch (error) { - rootLogger.error({ error }, "Failed to forward WebSocket message to TCP"); + logger.error({ error }, "Failed to forward WebSocket message to TCP"); ws.close(); } } @@ -118,14 +119,14 @@ export class WebSocketModule implements Module { try { ws.data.tcpSocket.end(); } catch (error) { - rootLogger.error({ error }, "Error closing TCP socket"); + logger.error({ error }, "Error closing TCP socket"); } } - rootLogger.debug("WebSocket client disconnected"); + logger.debug("WebSocket client disconnected"); }, error(ws: ServerWebSocket, error) { - rootLogger.error({ error }, "WebSocket error"); + logger.error({ error }, "WebSocket error"); if (ws.data?.tcpSocket) { try { ws.data.tcpSocket.end();