From c6cb55647b2fd0b53112c932892dfcb5dea93a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 29 May 2023 23:22:01 +0200 Subject: [PATCH 01/34] chore: Strip unused code (#3) Closes #1 --- .github/workflows/ci.yml | 1 - README.md | 57 ++- docs/connection-orchestration.md | 116 ----- docs/handshake.md | 27 - docs/noray.md | 54 -- docs/udp-relays.md | 188 ------- package.json | 7 +- pnpm-lock.yaml | 78 +-- src/ajv.mjs | 5 - src/client.mjs | 45 -- src/connection/connection.attempt.mjs | 57 --- .../connection.attempt.processor.mjs | 75 --- src/connection/connection.attempt.queue.mjs | 104 ---- src/connection/connections.mjs | 37 -- src/connection/message.templates.mjs | 30 -- src/games/game.data.mjs | 24 - src/games/game.repository.mjs | 17 - src/games/games.mjs | 39 -- src/games/validation.mjs | 22 - src/lobbies/lobbies.client.mjs | 124 ----- src/lobbies/lobbies.mjs | 33 -- src/lobbies/lobby.data.mjs | 70 --- src/lobbies/lobby.participant.repository.mjs | 94 ---- src/lobbies/lobby.repository.mjs | 20 - src/lobbies/lobby.service.mjs | 251 ---------- src/lobbies/message.templates.mjs | 69 --- src/lobbies/subjects/create.lobby.mjs | 59 --- src/lobbies/subjects/delete.lobby.mjs | 60 --- src/lobbies/subjects/join.lobby.mjs | 52 -- src/lobbies/subjects/leave.lobby.mjs | 61 --- src/lobbies/subjects/list.lobbies.mjs | 41 -- src/lobbies/validation.mjs | 23 - src/natty.client.mjs | 35 -- src/natty.mjs | 17 - src/notifications/notification.service.mjs | 43 -- src/notifications/notifications.mjs | 6 - src/relay/udp.remote.registrar.mjs | 97 ---- src/sessions/session.cleanup.mjs | 58 --- src/sessions/session.client.mjs | 51 -- src/sessions/session.data.mjs | 48 -- src/sessions/session.repository.mjs | 34 -- src/sessions/session.service.mjs | 104 ---- src/sessions/session.subjects.mjs | 57 --- src/sessions/sessions.mjs | 27 - .../validators/require.session.game.mjs | 43 -- src/sessions/validators/require.session.mjs | 48 -- .../validators/require.session.user.mjs | 43 -- src/users/user.mjs | 25 - src/users/user.repository.mjs | 10 - src/users/users.mjs | 3 - src/validators/extract.mapper.validator.mjs | 44 -- src/validators/require.body.mjs | 21 - src/validators/require.header.mjs | 44 -- src/validators/require.schema.mjs | 37 -- src/validators/validator.mjs | 22 - test/e2e/connection.test.mjs | 40 -- test/e2e/context.mjs | 29 -- test/e2e/lobbies.test.mjs | 172 ------- test/e2e/session.test.mjs | 76 --- test/e2e/udp.relay.test.mjs | 2 +- test/mocking.mjs | 27 - .../connection.attempt.processor.test.mjs | 136 ----- .../connection.attempt.queue.test.mjs | 53 -- test/spec/games/games.test.mjs | 28 -- test/spec/lobbies/lobby.data.test.mjs | 41 -- test/spec/lobbies/lobby.service.test.mjs | 466 ------------------ test/spec/relay/udp.remote.registrar.test.mjs | 101 ---- .../validators/require.session.game.test.mjs | 84 ---- .../validators/require.session.test.mjs | 81 --- .../validators/require.session.user.test.mjs | 84 ---- .../extract.mapper.validator.test.mjs | 130 ----- test/spec/validators/require.body.test.mjs | 40 -- test/spec/validators/require.header.test.mjs | 33 -- test/spec/validators/require.schema.test.mjs | 50 -- 74 files changed, 63 insertions(+), 4467 deletions(-) delete mode 100644 docs/connection-orchestration.md delete mode 100644 docs/handshake.md delete mode 100644 docs/noray.md delete mode 100644 docs/udp-relays.md delete mode 100644 src/ajv.mjs delete mode 100644 src/client.mjs delete mode 100644 src/connection/connection.attempt.mjs delete mode 100644 src/connection/connection.attempt.processor.mjs delete mode 100644 src/connection/connection.attempt.queue.mjs delete mode 100644 src/connection/connections.mjs delete mode 100644 src/connection/message.templates.mjs delete mode 100644 src/games/game.data.mjs delete mode 100644 src/games/game.repository.mjs delete mode 100644 src/games/games.mjs delete mode 100644 src/games/validation.mjs delete mode 100644 src/lobbies/lobbies.client.mjs delete mode 100644 src/lobbies/lobbies.mjs delete mode 100644 src/lobbies/lobby.data.mjs delete mode 100644 src/lobbies/lobby.participant.repository.mjs delete mode 100644 src/lobbies/lobby.repository.mjs delete mode 100644 src/lobbies/lobby.service.mjs delete mode 100644 src/lobbies/message.templates.mjs delete mode 100644 src/lobbies/subjects/create.lobby.mjs delete mode 100644 src/lobbies/subjects/delete.lobby.mjs delete mode 100644 src/lobbies/subjects/join.lobby.mjs delete mode 100644 src/lobbies/subjects/leave.lobby.mjs delete mode 100644 src/lobbies/subjects/list.lobbies.mjs delete mode 100644 src/lobbies/validation.mjs delete mode 100644 src/natty.client.mjs delete mode 100644 src/notifications/notification.service.mjs delete mode 100644 src/notifications/notifications.mjs delete mode 100644 src/relay/udp.remote.registrar.mjs delete mode 100644 src/sessions/session.cleanup.mjs delete mode 100644 src/sessions/session.client.mjs delete mode 100644 src/sessions/session.data.mjs delete mode 100644 src/sessions/session.repository.mjs delete mode 100644 src/sessions/session.service.mjs delete mode 100644 src/sessions/session.subjects.mjs delete mode 100644 src/sessions/sessions.mjs delete mode 100644 src/sessions/validators/require.session.game.mjs delete mode 100644 src/sessions/validators/require.session.mjs delete mode 100644 src/sessions/validators/require.session.user.mjs delete mode 100644 src/users/user.mjs delete mode 100644 src/users/user.repository.mjs delete mode 100644 src/users/users.mjs delete mode 100644 src/validators/extract.mapper.validator.mjs delete mode 100644 src/validators/require.body.mjs delete mode 100644 src/validators/require.header.mjs delete mode 100644 src/validators/require.schema.mjs delete mode 100644 src/validators/validator.mjs delete mode 100644 test/e2e/connection.test.mjs delete mode 100644 test/e2e/lobbies.test.mjs delete mode 100644 test/e2e/session.test.mjs delete mode 100644 test/mocking.mjs delete mode 100644 test/spec/connection/connection.attempt.processor.test.mjs delete mode 100644 test/spec/connection/connection.attempt.queue.test.mjs delete mode 100644 test/spec/games/games.test.mjs delete mode 100644 test/spec/lobbies/lobby.data.test.mjs delete mode 100644 test/spec/lobbies/lobby.service.test.mjs delete mode 100644 test/spec/relay/udp.remote.registrar.test.mjs delete mode 100644 test/spec/sessions/validators/require.session.game.test.mjs delete mode 100644 test/spec/sessions/validators/require.session.test.mjs delete mode 100644 test/spec/sessions/validators/require.session.user.test.mjs delete mode 100644 test/spec/validators/extract.mapper.validator.test.mjs delete mode 100644 test/spec/validators/require.body.test.mjs delete mode 100644 test/spec/validators/require.header.test.mjs delete mode 100644 test/spec/validators/require.schema.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd5964e..9da9746 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: CI env: node-version: 18.x - NODE_OPTIONS: --max_old_space_size=8192 on: push: diff --git a/README.md b/README.md index 66fc6e1..40728cb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,54 @@ -# natty +# noray -Online multiplayer orchestrator and potential game platform +A fork of [Natty](https://github.com/foxssake/natty) for open-source purposes. -## Docs +## Motivation -* [Connection orchestration](docs/connection-orchestration.md) -* [UDP relays](docs/udp-relays.md) -* [noray](docs/noray.md) +While Natty is open-source, its scope becomes quite large from v1 onwards - +managing users, supporting multiple different games, different orchestration +strategies, multiple sessions per user, lobbies, etc. This can make Natty an +unwieldy solution for situations where you just want to get something running +online, or if you don't plan on running a whole platform for some individual +multiplayer games. + +This is the niche noray intends to fill - a very simple server that manages +connectivity between players. Anything more than that is the responsibility of +the game or some other backend service unrelated to noray. + +Thankfully, at the point of writing, Natty implements most of the features +needed for noray, so it can start its life as a stripped-down fork. + +## Scope + +To ensure connection, noray will support *NAT punchthrough orchestration* and +*UDP relays*. + +A game would happen through the following flow: + +- The host connects to noray and sends a host request with its id + - This id can be whatever, as long as its unique + - It can be some random-generated string, a player name, a lobby name, etc. + - It is left to the game itself + - noray allocates a relay for the host +- Successive clients send a connect request to noray + - The request contains the host id +- noray sends a handshake message to both parties + - The host receives the client's external address + - The client receives the host's external address +- If the handshake succeeds, the client connects to the host +- If the handshake fails, the client sends a relay connect request to noray + - The client receives the host's relay address to connect to + - When the client sends data to the host, the client gets its own relay allocated + +## Protocol + +To keep things simple, data is transmitted through TCP as newline-separated +strings. Each line starts with a command, a space, and the rest of the line is +treated as data. Example: + +``` +connect-relay host-1 +``` + +The protocol has no concept of replies, threads, correspondences or anything +similar. Think of it as a dumbed-down RPC without return values. diff --git a/docs/connection-orchestration.md b/docs/connection-orchestration.md deleted file mode 100644 index 06ff88c..0000000 --- a/docs/connection-orchestration.md +++ /dev/null @@ -1,116 +0,0 @@ -# Connection orchestration - -Natty's primary purpose is to establish connectivity between participants in -online games. - -The unit of orchestration is a lobby - participants gather into a lobby, and at -a player-triggered point in time, Natty will coordinate connections between the -players. - -Supported games are assumed to work in a server-client model. Whenever possible, -Natty will try to designate one of the participants as server. Otherwise, Natty -will act as a UDP relay between players. - -## Hosting methods - -### Self-hosting - -*Self-hosting* is *only* viable if there's at least one player to whom everyone -can connect. If there's more than one such player, a selection process must be -used to determine who will host. - -### UDP relay - -*UDP relaying* is used when there's no player capable of hosting. Limits are -configured so the application won't be overloaded with too many concurrently -running relays. - -## Diagnostic process - -Before the lobby starts, Natty needs to figure out which hosting method will -work for the given lobby. - -1. When a new lobby is created - 1. Create a bookkeeping entry for the lobby - 1. Each entry stores a set of connection attempts - 1. The connection attempt has a state - `pending`, `running`, `done` - 1. The connection attempt has an outcome - success or fail - 1. The connection attempt has a pair of user id's -1. When someone joins a lobby - 1. Generate a new connection attempt for each missing link with a state of - `pending` - 1. If there's now n+1 players in the lobby, this step will result in n new - items - 1. Example: - 1. Lobby has players A, B and C - 1. Player D joins - 1. The new entries will be: - 1. [A-D, B-D, C-D, D-A, D-B, D-C] -1. When someone leaves a lobby - 1. Delete all connection attempts that affect the leaving player -1. When a new connection attempt is added - 1. Ask both participants to do a [handshake](./handshake.md) - 1. Once both participants ack'd, set attempt state to `running` - 1. Wait for both participants to respond - 1. Set attempt to success *only if* both participants respond with success - 1. Error: if either participant takes too long to ack, fail the attempt - -## Orchestration - -Once the lobby starts, Natty needs to designate a host and orchestrate -connection for everyone to the host. - -Firstly, the hosting method is determined: -1. If there's at least one player to whom anyone can connect, use self-hosting -1. Else if the config limit isn't reached, use UDP relay -1. Otherwise fail lobby - -### Self-hosting - -1. Designate a host - 1. If the lobby owner is viable, designate them - 1. Otherwise pick an arbitrary viable participant -1. Notify designated host, wait for them to ack - 1. The host should prepare for incoming connections and ack - 1. Error: if the host takes too long to ack, fail lobby -1. Wait for participants to report - 1. Each participant reports whether they managed to connect - 1. For each participant failing to connect - 1. If the config limit isn't reached, create a UDP relay - 1. Participant should report again - 1. Else fail the lobby - 1. If all participants succeeded, revel in success - 1. If there's participants that failed even with UDP relay, fail lobby - -### UDP relay - -1. Designate a host - 1. This will be the lobby owner -1. Notify designated host, wait for them to ack - 1. The host should prepare for incoming connections and ack - 1. The host is also required to register their remote address at this point - 1. See [UDP Relays doc](udp-relays.md) - 1. Error: if the host takes too long to ack, fail lobby -1. Create a UDP relay for each client -1. Wait for participants to report - 1. If a client fails, fail the lobby - 1. Rationale: we are already using UDP relays, if someone can't connect, - there's nothing more Natty can do - -## Failing the lobby - -By now, lobbies will need to have a state which can be one of the following: - -1. Gathering - 1. Lobby is open for players to join - 1. Diagnostic process is running in the background -1. Starting - 1. Lobby is closed for new joiners - 1. Natty is orchestrating connection -1. Active - 1. Lobby is closed for new joiners - 1. Connection has been successfully orchestrated - -If a lobby fails for whatever reason, the lobby state is returned to Gathering. -This is to make it more convenient for players to retry connection in case -something goes wrong in the process. diff --git a/docs/handshake.md b/docs/handshake.md deleted file mode 100644 index 3bab3fb..0000000 --- a/docs/handshake.md +++ /dev/null @@ -1,27 +0,0 @@ -# Handshake - -The handshake procedure is used to find out whether two peers can communicate -bidirectionally - that is, both of them can send and receive data from the -other. - -This is done by continuously sending packets to eachother, and change the -packet's content based on the handshake's state. After a given interval, the -handshake is either successful or times out. - -Each peer can be in one of the following states during handshake: - -1. Empty - 1. Nothing is known about the other peer -1. Received - 1. Data has been received from the other peer - 1. This means that we know their packets arrive -1. Bidirectional - 1. Data has been received from the other peer, indicating they can read us - 1. This means that we know their packets arrive - 1. This also means that our packets arrive -1. Waiting for handshake - 1. We already know that they can read us and we can read them - 1. We are waiting for the other peer to respond with a Bidirectional packet - 1. Receiving the bidirectional packet means that the handshake is complete, - since both peers indicated connectivity - diff --git a/docs/noray.md b/docs/noray.md deleted file mode 100644 index 2b67bf8..0000000 --- a/docs/noray.md +++ /dev/null @@ -1,54 +0,0 @@ -# noray - -A fork of Natty for open-source purposes. - -## Motivation - -While Natty is open-source, its scope becomes quite large from v1 onwards - -managing users, supporting multiple different games, different orchestration -strategies, multiple sessions per user, lobbies, etc. This can make Natty an -unwieldy solution for situations where you just want to get something running -online, or if you don't plan on running a whole platform for some individual -multiplayer games. - -This is the niche noray intends to fill - a very simple server that manages -connectivity between players. Anything more than that is the responsibility of -the game or some other backend service unrelated to noray. - -Thankfully, at the point of writing, Natty implements most of the features -needed for noray, so it can start its life as a stripped-down fork. - -## Scope - -To ensure connection, noray will support *NAT punchthrough orchestration* and -*UDP relays*. - -A game would happen through the following flow: - -- The host connects to noray and sends a host request with its id - - This id can be whatever, as long as its unique - - It can be some random-generated string, a player name, a lobby name, etc. - - It is left to the game itself - - noray allocates a relay for the host -- Successive clients send a connect request to noray - - The request contains the host id -- noray sends a handshake message to both parties - - The host receives the client's external address - - The client receives the host's external address -- If the handshake succeeds, the client connects to the host -- If the handshake fails, the client sends a relay connect request to noray - - The client receives the host's relay address to connect to - - When the client sends data to the host, the client gets its own relay allocated - -## Protocol - -To keep things simple, data is transmitted through TCP as newline-separated -strings. Each line starts with a command, a space, and the rest of the line is -treated as data. Example: - -``` -connect-relay host-1 -``` - -The protocol has no concept of replies, threads, correspondences or anything -similar. Think of it as a dumbed-down RPC without return values. diff --git a/docs/udp-relays.md b/docs/udp-relays.md deleted file mode 100644 index 3135f7e..0000000 --- a/docs/udp-relays.md +++ /dev/null @@ -1,188 +0,0 @@ -# UDP relays - -In case there's no player in a lobby that everyone can connect to, Natty will -jump in as a relay server. Doing this, Natty will send its own address and a -designated port to each player to connect to. Whenever data arrives on the -given port, it will be forwarded to the lobby's host. - -As simple as it sounds, there are multiple constraints that necessitate a -documented plan for this feature. - -## Constraints - -1. The relay must be **transparent** - 1. Neither of the connected nodes should be able to detect the relay - 1. This includes not modifying the data in any way -1. The relay must be **consistently addressed** - 1. For every host, the same client must always appear to have the same, - unique address - 1. For every client, the host must always appear to have the same address - 1. This address must be unique only in the context of a single lobby - 1. The address includes the IP address *and* port - -## Proposed solutions - -### Naive mapping - -The idea is to reserve a port for every relay link. - -Take an example of a lobby starting with 3 players: host, client 1 and client 2 - -1. Natty reserves the relay bindings: - 1. Port 10001 is reserved for Host - 1. Port 10002 is reserved for Client 1 - 1. Port 10003 is reserved for Client 2 -1. Natty instructs the clients to connect to the host - 1. Client 1 is instructed to connect to Natty:10001 - 1. Client 2 is instructed to connect to Natty:10001 -1. For any incoming traffic - 1. If it's on port 10001, forward it to Host - 1. If it's on port 10002, forward it to Client 1 - 1. If it's on port 10003, forward it to Client 2 - -This could leave us with something strongly resembling a NAT table: - -| Incoming port | Outgoing address | -| ------------- | ---------------- | -| 10001 | Host | -| 10002 | Client 1 | -| 10003 | Client 2 | - -Which works perfectly, because: - -1. Client 1 and Client 2 think the Host is at Natty:10001 - 1. Natty will forward any traffic on 10001 to the Host -1. Host thinks the Clients are at Natty:10002 and Natty:10003 - 1. Natty will forward any traffic on 10002 to Client 1 - 1. Natty will forward any traffic on 10003 to Client 2 - -**Verdict:** -* pro: Simple to implement and reason about -* con: Easy to run out of available ports - * The theoretical max is 65535 players on a single Natty server - * Ports 0-1023 are well-known ports - * Ports 1024-49151 are registered ports - * So this leaves us with at most 16384 to 64512 ports depending on other - things running on the server - -### Conservative mapping - -Based on the naive approach. However, to allocate ports slower, it uses the -fact that dedicated addresses must be unique *only* in the context of a lobby. - -Let's take an example with two lobbies starting, each with 1 host and 2 clients: -* Lobby 1: Host 1, Client 11, Client 12 -* Lobby 2: Host 2, Client 21, Client 22 - -When the lobbies start: -1. Natty reserves the relay bindings - 1. Port 10001 is reserved for Host 1 and Host 2 - 1. Port 10002 is reserved for Client 11 and Client 21 - 1. Port 10003 is reserved for Client 12 and Client 22 -1. Natty instructs the clients to connect to their hosts - 1. Client 11 is instructed to connect to Natty:10001 - 1. Client 12 is instructed to connect to Natty:10001 - 1. Client 21 is instructed to connect to Natty:10001 - 1. Client 22 is instructed to connect to Natty:10001 -1. For any incoming traffic - 1. If it's from Client 11 on port 10001, forward it to Host 1 - 1. If it's from Client 21 on port 10001, forward it to Host 2 - 1. If it's from Client 12 on port 10001, forward it to Host 1 - 1. If it's from Client 22 on port 10001, forward it to Host 2 - 1. If it's from Host 1 on port 10002, forward it to Client 11 - 1. If it's from Host 2 on port 10002, forward it to Client 21 - 1. If it's from Host 1 on port 10003, forward it to Client 12 - 1. If it's from Host 2 on port 10003, forward it to Client 22 - -This leaves us with the following translation table: - -| Incoming address | Incoming port | Outgoing address | -| ---------------- | ------------- | ---------------- | -| Client 11 | 10001 | Host 1 | -| Client 21 | 10001 | Host 2 | -| Client 12 | 10001 | Host 1 | -| Client 22 | 10001 | Host 2 | -| Host 1 | 10002 | Client 11 | -| Host 2 | 10002 | Client 21 | -| Host 1 | 10003 | Client 12 | -| Host 2 | 10003 | Client 22 | - -The above could raise concerns, for example: -* What happens if two clients are behind the same router? -* What happens if multiple games are hosted on the same server? -* What happens if multiple clients are joining from the same machine? - -These can be managed by expanding our translation entries to the following columns: -* Remote address - * The address originating the message - * e.g. Client 11's IP address: 87.148.31.109 -* Remote port - * The port originating the message - * e.g. Client 11's port 48735 -* Incoming port - * The port on which we've received the message - * e.g. 10001 as Client 11 is trying to talk to Host 1 -* Outgoing address -* Outgoing port - -While this approach might seem quite more complex than the naive mapping, in -essence we just track the allocated ports *per lobby* to add entries to the -translation table. The relaying part is the same - find entry with matching -data, relay traffic to entry's outgoing address. - -**Verdict:** -* pro: Way more efficient with ports -* pro: Boils down to simple table population similar to Naive mapping -* con: Slightly more complex - -## Final result - -While the conservative mapping is feasible in implementation and plausible in -resource usage, unfortunately it is not realistic due to client-side -constraints. - -Most importantly, it's framework support. Part of the target audience is games -using multiplayer frameworks, as we don't expect everyone to write their own -multiplayer solution from scratch ( like us, heh heh ). And frameworks *don't -always allow access to the underlying sockets*. In some cases, sockets per se -aren't even a concept in the framework. This also means that the client can't -use the framework's socket to communicate its port to Natty, so we wouldn't -know where to actually relay the incoming data. - -This is slightly different for the hosting application, as frameworks usually -allow to at least customize the listening port. This allows us to create a -custom socket to communicate the address and port to Natty, release the socket, -and instruct the framework to use that port for listening. - -### Dynamic naive mapping - -This solution is based on the following: - -1. We don't know the ports of the clients in advance -1. We can control the host's port -1. We have no way to associate traffic with session/lobby based on incoming data - 1. ( since we don't know in advance the clients ports ) - -So in essence, what we do is allocate a port for every player - for hosts in -advance, for clients on the go. This is combined with a port registration -mechanism for hosts, where they can notify Natty of their remote addresses in -advance. - -So to illustrate step by step: - -1. The starting sequence designates a host -1. The host reports its remote port - 1. This is done by the host sending some packets with its session ID to a - dedicated port - 1. Natty will store the remote address of the packet along with the session - ID -1. A relay is allocated for the host, along with a dedicated port - 1. Any traffic arriving on the port will be forwarded to the host -1. The game starts, clients will start sending traffic to the relay - 1. In case we already have a dedicated port for the sender, use that - 1. In case we don't - 1. Validate that the address belongs to a client in the lobby - 1. Allocate a port for the client and use that - -Aside from automatic cleanup after an interval of inactivity, relays are also -freed when the lobby closes. diff --git a/package.json b/package.json index 33205ac..2d4962b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@foxssake/natty", - "version": "0.13.1", + "name": "@foxssake/noray", + "version": "0.13.2", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/natty.mjs", "bin": { @@ -28,9 +28,6 @@ "utap": "^0.2.0" }, "dependencies": { - "@elementbound/nlon": "^1.2.1", - "@elementbound/nlon-socket": "^1.2.1", - "ajv": "^8.12.0", "dotenv": "^16.0.3", "nanoid": "^4.0.1", "pino": "^8.11.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8bac1e..69768eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,15 +1,6 @@ lockfileVersion: '6.0' dependencies: - '@elementbound/nlon': - specifier: ^1.2.1 - version: 1.2.1 - '@elementbound/nlon-socket': - specifier: ^1.2.1 - version: 1.2.1 - ajv: - specifier: ^8.12.0 - version: 8.12.0 dotenv: specifier: ^16.0.3 version: 16.0.3 @@ -78,20 +69,6 @@ packages: to-fast-properties: 2.0.0 dev: true - /@elementbound/nlon-socket@1.2.1: - resolution: {integrity: sha512-P8XeLlhqGsRIL5ywsAnbXBZ9orIJ5ssmfSYV+M6+ri+FyVVuQzxCiyCoBPCbXNVJyd5O97NNZt4PYpoUgSdbqg==} - dependencies: - '@elementbound/nlon': 1.2.1 - dev: false - - /@elementbound/nlon@1.2.1: - resolution: {integrity: sha512-X2JvWtMgzABgrhjVpVOV10RRBT+f0pVoCaj0AKW2QG+kvgc+/OXjueB8KUJz/0TbDvuG8BKW7tgRhh0FJOVZPA==} - dependencies: - nanoid: 4.0.1 - ndjson: 2.0.0 - pino: 8.11.0 - dev: false - /@eslint-community/eslint-utils@4.3.0(eslint@8.36.0): resolution: {integrity: sha512-v3oplH6FYCULtFuCeqyuTd9D2WKO937Dxdq+GmHOLL72TTRriLxz2VLlNfkZRsvj6PKnOPAtuT6dwrs/pA5DvA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -255,15 +232,6 @@ packages: uri-js: 4.4.1 dev: true - /ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} - dependencies: - fast-deep-equal: 3.1.3 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - uri-js: 4.4.1 - dev: false - /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -827,6 +795,7 @@ packages: /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: true /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -1055,6 +1024,7 @@ packages: /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true /internal-slot@1.0.5: resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} @@ -1235,18 +1205,10 @@ packages: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true - /json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - dev: false - /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} dev: true - /json-stringify-safe@5.0.1: - resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - dev: false - /json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -1350,6 +1312,7 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: true /mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} @@ -1371,18 +1334,6 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true - /ndjson@2.0.0: - resolution: {integrity: sha512-nGl7LRGrzugTtaFcJMhLbpzJM6XdivmbkdlaGcrk/LXg2KL/YBC6z1g70xh0/al+oFuVFP8N8kiWRucmeEH/qQ==} - engines: {node: '>=10'} - hasBin: true - dependencies: - json-stringify-safe: 5.0.1 - minimist: 1.2.8 - readable-stream: 3.6.2 - split2: 3.2.2 - through2: 4.0.2 - dev: false - /nise@5.1.4: resolution: {integrity: sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==} dependencies: @@ -1558,6 +1509,7 @@ packages: /punycode@2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} + dev: true /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -1574,6 +1526,7 @@ packages: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + dev: true /readable-stream@4.3.0: resolution: {integrity: sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ==} @@ -1603,11 +1556,6 @@ packages: engines: {node: '>=8'} dev: true - /require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - dev: false - /requizzle@0.2.4: resolution: {integrity: sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==} dependencies: @@ -1648,6 +1596,7 @@ packages: /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + dev: true /safe-regex-test@1.0.0: resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==} @@ -1715,12 +1664,6 @@ packages: dependencies: atomic-sleep: 1.0.0 - /split2@3.2.2: - resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} - dependencies: - readable-stream: 3.6.2 - dev: false - /split2@4.1.0: resolution: {integrity: sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==} engines: {node: '>= 10.x'} @@ -1754,6 +1697,7 @@ packages: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: safe-buffer: 5.2.1 + dev: true /strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} @@ -1794,12 +1738,6 @@ packages: real-require: 0.2.0 dev: false - /through2@4.0.2: - resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} - dependencies: - readable-stream: 3.6.2 - dev: false - /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -1860,6 +1798,7 @@ packages: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: punycode: 2.3.0 + dev: true /utap@0.2.0: resolution: {integrity: sha512-YbDY2Qb/+UHUc7Qhued83UzmTyxSozwczDpWxLs4Htees2H7Krj7WkmMbyJI4Pq2UCxQuPHrVv4/kozHz4WWYQ==} @@ -1871,6 +1810,7 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + dev: true /which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} diff --git a/src/ajv.mjs b/src/ajv.mjs deleted file mode 100644 index b4a9df3..0000000 --- a/src/ajv.mjs +++ /dev/null @@ -1,5 +0,0 @@ -import Ajv from 'ajv' - -export const ajv = new Ajv({ - allErrors: true -}) diff --git a/src/client.mjs b/src/client.mjs deleted file mode 100644 index a3769bf..0000000 --- a/src/client.mjs +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable */ -import { Peer } from '@elementbound/nlon' -/* eslint-enable */ -import { fail } from 'node:assert' - -/** -* Generic base class for Natty flow clients. -* -* A new client class should be created for every Natty feature ( e.g. sessions ) -* and implement calls related to it. -*/ -export class Client { - /** @type {object} */ - #context - - /** @type {Peer} */ - #peer - - /** - * Construct client - * @param {object} options - * @param {object} [options.context] Context - * @param {Peer} options.peer Peer - */ - constructor (options) { - this.#context = options.context ?? {} - this.#peer = options.peer ?? fail('Peer is required!') - } - - /** - * Client context - * @type {object} - */ - get context () { - return this.#context - } - - /** - * Peer - * @type {Peer} - */ - get peer () { - return this.#peer - } -} diff --git a/src/connection/connection.attempt.mjs b/src/connection/connection.attempt.mjs deleted file mode 100644 index 9132f50..0000000 --- a/src/connection/connection.attempt.mjs +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable */ -import { Peer } from '@elementbound/nlon' -/* eslint-enable */ -import { requireEnum } from '../assertions.mjs' - -export const ConnectionAttemptState = Object.freeze({ - Pending: 'pending', - Running: 'running', - Done: 'done' -}) - -/** -* Class describing a connection attempt. -* -* A connection attempt is basically a connectivity check between two players. -* The players will attempt to do a handshake with eachother and report back -* their results. Depending on the outcome, a hosting strategy will be selected -* ( e.g. designate one of the players for everyone to connect to, or just relay -* traffic ). -*/ -export class ConnectionAttempt { - /** - * Connection attempt state - * @type {string} - * @see ConnectionAttemptState - */ - state = ConnectionAttemptState.Pending - - /** - * Is the attempt successful? - * - * Note: If state isn't Done, this should remaing false - * @type {boolean} - */ - isSuccess = false - - /** - * Hosting peer - * @type {Peer} - */ - hostingPeer - - /** - * Joining peer - * @type {Peer} - */ - connectingPeer - - /** - * Construct instance. - * @param {ConnectionAttempt} options Options - */ - constructor (options) { - options && Object.assign(this, options) - requireEnum(this.state, ConnectionAttemptState) - } -} diff --git a/src/connection/connection.attempt.processor.mjs b/src/connection/connection.attempt.processor.mjs deleted file mode 100644 index c2a449d..0000000 --- a/src/connection/connection.attempt.processor.mjs +++ /dev/null @@ -1,75 +0,0 @@ -/* eslint-disable */ -import { ajv } from '../ajv.mjs' -import logger from '../logger.mjs' -import { requireSchema } from '../validators/require.schema.mjs' -import { ConnectionAttempt, ConnectionAttemptState } from './connection.attempt.mjs' -import { HandshakeRequestMessage } from './message.templates.mjs' -/* eslint-enable */ - -ajv.addSchema({ - type: 'object', - properties: { - target: { - type: 'object', - properties: { - address: { type: 'string' }, - port: { type: 'number' } - } - } - } -}, 'connection/handshake/request') - -ajv.addSchema({ - type: 'object', - properties: { - success: { type: 'boolean' }, - target: { - type: 'object', - properties: { - address: { type: 'string' }, - port: { type: 'number' } - } - } - } -}, 'connection/handshake/response') - -/** -* Process a connection attempt. -* @param {ConnectionAttempt} connectionAttempt Connection attempt -* @returns {Promise} Attempt success -*/ -export async function processConnectionAttempt (connectionAttempt) { - const { hostingPeer, connectingPeer } = connectionAttempt - const log = logger.child({ - name: 'processConnectionAttempt', - hostAddress: hostingPeer?.stream?.remoteAddress, - hostPort: hostingPeer?.stream?.remotePort, - clientAddress: connectingPeer?.stream?.remoteAddress, - clientPort: connectingPeer?.stream?.remotePort - }) - - connectionAttempt.state = ConnectionAttemptState.Running - connectionAttempt.isSuccess = false - log.trace('Processing connection attempt, state set to running') - - // Instruct peers to do a handshake, wait for reports - try { - log.trace('Sending handshake requests') - const results = await Promise.all([ - hostingPeer.send(HandshakeRequestMessage(connectingPeer)) - .next(requireSchema('connection/handshake/response')), - connectingPeer.send(HandshakeRequestMessage(hostingPeer)) - .next(requireSchema('connection/handshake/response')) - ]) - - // Check results - log.debug({ results }, 'Gathered handshake results') - connectionAttempt.isSuccess = results.every(result => result?.success === true) - return connectionAttempt.isSuccess - } catch (err) { - log.error({ err }, 'Failed to gather handshake results!') - throw err - } finally { - connectionAttempt.state = ConnectionAttemptState.Done - } -} diff --git a/src/connection/connection.attempt.queue.mjs b/src/connection/connection.attempt.queue.mjs deleted file mode 100644 index e87eb42..0000000 --- a/src/connection/connection.attempt.queue.mjs +++ /dev/null @@ -1,104 +0,0 @@ -/* eslint-disable */ -import { ConnectionAttempt, ConnectionAttemptState } from './connection.attempt.mjs' -/* eslint-enable */ -import logger from '../logger.mjs' -import { Timeout, withTimeout } from '../utils.mjs' -import { processConnectionAttempt } from './connection.attempt.processor.mjs' - -export class ConnectionAttemptQueue { - /** - * @type {Map} - */ - #attempts = new Map() - - /** - * @type {function(ConnectionAttempt): Promise} - */ - #processor - - /** - * Construct queue - * @param {function(ConnectionAttempt): Promise} [processor] Attempt processor - */ - constructor (processor) { - this.#processor = processor ?? processConnectionAttempt - } - - /* - * Add a connection attempt to the queue. - * - * The attempt will be immediately started and kept track of until it's - * complete. - * - * If the attempt times out, its state will be updated and outcome set to - * failure. - * @param {ConnectionAttempt} attempt - * @param {number} timeout - */ - enqueue (attempt, timeout) { - const log = logger.child({ - name: 'ConnectionAttemptQueue', - connectingPeer: attempt.connectingPeer.id, - hostingPeer: attempt.hostingPeer.id - }) - const id = this.#getAttemptId(attempt) - - // Check if attempt is duplicate - if (this.#attempts.has(id)) { - log.warn('Pushing duplicate connection attempt, ignoring') - return - } - - // Save attempt - this.#attempts.set(id, attempt) - - // Process it asap - withTimeout(this.#processAttempt(attempt), timeout) - .then(result => { - if (result === Timeout) { - log.warn('Connection attempt didn\'t complete in %d seconds, ignoring!', timeout) - attempt.state = ConnectionAttemptState.Done - attempt.isSuccess = false - } else { - log.debug('Connection attempt completed with result %s', result) - } - }) - .catch(err => { - log.error({ err }, 'Connection attempt failed!') - }) - .finally(() => { - // TODO: Consider during start sequence impl if we need to hold on to - // this longer - this.#attempts.delete(id) - }) - } - - /** - * Currently active connection attempts - * @type {ConnectionAttempt[]} - */ - get attempts () { - return [...this.#attempts.values()] - } - - /** - * Return id unique to connection attempt. - * - * If the id is known by #attempts, it means it's already known. - * @param {ConnectionAttempt} attempt Attempt id - * @returns {string} Attempt id - */ - #getAttemptId (attempt) { - return `${attempt.hostingPeer}:${attempt.connectingPeer}` - } - - /** - * @param {ConnectionAttempt} attempt - * @returns {Promise} - */ - #processAttempt (attempt) { - return this.#processor(attempt) - } -} - -export const connectionAttemptQueue = new ConnectionAttemptQueue() diff --git a/src/connection/connections.mjs b/src/connection/connections.mjs deleted file mode 100644 index f0874a0..0000000 --- a/src/connection/connections.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import { lobbyParticipantRepository, lobbyService } from '../lobbies/lobbies.mjs' -import logger from '../logger.mjs' -import { Natty } from '../natty.mjs' -import { sessionRepository } from '../sessions/session.repository.mjs' -import { combine } from '../utils.mjs' -import { ConnectionAttempt } from './connection.attempt.mjs' -import { connectionAttemptQueue } from './connection.attempt.queue.mjs' -import { config } from '../config.mjs' - -const log = logger.child({ name: 'Connections' }) - -Natty.hook(() => { - log.info('Wiring up connection diagnostics') - - lobbyService.on('join', (lobby, joiningUser) => { - log.trace( - { lobby: lobby.id, user: joiningUser.id }, - 'User joining lobby, adding new connection pairs to queue' - ) - const joinerSessions = sessionRepository.findSessionsByGameFor( - lobby.game, joiningUser.id - ) - - const currentSessions = lobbyParticipantRepository.getParticipantsOf(lobby.id) - .filter(id => id !== joiningUser.id) - .flatMap(id => sessionRepository.findSessionsByGameFor(lobby.game, id)) - - combine(joinerSessions, currentSessions) - .map(([connecting, hosting]) => new ConnectionAttempt({ - connectingPeer: connecting.peer, - hostingPeer: hosting.peer - })) - .forEach(attempt => { - connectionAttemptQueue.enqueue(attempt, config.connectionDiagnostics.timeout) - }) - }) -}) diff --git a/src/connection/message.templates.mjs b/src/connection/message.templates.mjs deleted file mode 100644 index 0e504b1..0000000 --- a/src/connection/message.templates.mjs +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable */ -import { Message, MessageHeader, MessageTypes, Peer } from '@elementbound/nlon' -import net from 'node:net' -/* eslint-enable */ - -const Subjects = Object.freeze({ - HandshakeRequest: 'connection/handshake' -}) - -/** -* Create a handshake request message. -* @param {Peer} target -*/ -export function HandshakeRequestMessage (target) { - /** @type {net.Socket} */ - const socket = target.stream - - return new Message({ - header: new MessageHeader({ - subject: Subjects.HandshakeRequest - }), - type: MessageTypes.Finish, - body: { - target: { - address: socket.remoteAddress, - port: socket.remotePort - } - } - }) -} diff --git a/src/games/game.data.mjs b/src/games/game.data.mjs deleted file mode 100644 index 2f24530..0000000 --- a/src/games/game.data.mjs +++ /dev/null @@ -1,24 +0,0 @@ -/** -* Game data. -*/ -export class GameData { - /** - * Game id. - * @type {string} - */ - id - - /** - * Game name. - * @type {string} - */ - name - - /** - * Construct instance. - * @param {GameData} [options] Options - */ - constructor (options) { - options && Object.assign(this, options) - } -} diff --git a/src/games/game.repository.mjs b/src/games/game.repository.mjs deleted file mode 100644 index 9e323de..0000000 --- a/src/games/game.repository.mjs +++ /dev/null @@ -1,17 +0,0 @@ -/* eslint-disable */ -import { GameData } from './game.data.mjs' -/* eslint-enable */ -import { Repository } from '../repository.mjs' - -/** -* Game repository. -* @extends {Repository} -*/ -export class GameRepository extends Repository { - constructor () { - super({ - idMapper: game => game.id, - itemMerger: (a, b) => Object.assign(a, b) - }) - } -} diff --git a/src/games/games.mjs b/src/games/games.mjs deleted file mode 100644 index 65fdcb7..0000000 --- a/src/games/games.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { config } from '../config.mjs' -import logger from '../logger.mjs' -import { Natty } from '../natty.mjs' -import { GameData } from './game.data.mjs' -import { GameRepository } from './game.repository.mjs' - -/** -* Parse all games described in text. -* -* Each game should have its own line in the text, in the form of -* ` `. -* -* Empty lines and lines not matching the format are ignored. -* -* @param {string} text Text -* @returns {GameData[]} Games -*/ -export function parseGamesConfig (text) { - const gameRegex = /(\S*)\s+(.+)/ - return text.split('\n') - .map(l => l.trim()) - .filter(l => gameRegex.test(l)) - .map(l => gameRegex.exec(l)) - .map(([_, id, name]) => new GameData({ id, name })) - .map(game => Object.freeze(game)) -} - -export const gameRepository = new GameRepository() - -Natty.hook(natty => { - const log = logger.child({ name: 'Games' }) - - log.info('Parsing games from config') - parseGamesConfig(config.games) - .forEach(game => { - log.info({ game }, 'Adding game') - gameRepository.add(game) - }) -}) diff --git a/src/games/validation.mjs b/src/games/validation.mjs deleted file mode 100644 index 39d9667..0000000 --- a/src/games/validation.mjs +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable */ -import { GameRepository } from './game.repository.mjs' -/* eslint-enable */ - -export class InvalidGameError extends Error { } - -/** -* Check if message has a valid game. -* -* Saves game in `context.game`. -* -* @param {GameRepository} gameRepository Game repository -* @returns {ReadHandler} -*/ -export function requireGame (gameRepository) { - return function (_body, header, context) { - context.game = gameRepository.find(header.game) - if (!context.game) { - throw new InvalidGameError('Invalid game specified!') - } - } -} diff --git a/src/lobbies/lobbies.client.mjs b/src/lobbies/lobbies.client.mjs deleted file mode 100644 index af98b45..0000000 --- a/src/lobbies/lobbies.client.mjs +++ /dev/null @@ -1,124 +0,0 @@ -import { Message, MessageHeader } from '@elementbound/nlon' -import { Client } from '../client.mjs' - -export class LobbiesClient extends Client { - /** - * Create a new lobby. - * - * Saves the lobby id in `context.lobbyId` - * @param {string} name Lobby name - * @param {boolean} [isPublic=true] Is public? - * @returns {Promise} Lobby id - */ - async create (name, isPublic) { - const corr = this.peer.send(new Message({ - header: new MessageHeader({ - subject: 'lobby/create', - authorization: this.context.authorization - }), - body: { - name, - public: isPublic ?? true - } - })) - - corr.finish() - - const response = await corr.next() - - this.context.lobbyId = response.lobby.id - return response.lobby.id - } - - /** - * Deleta a lobby. - * @param {string} lobbyId Lobby id - */ - async delete (lobbyId) { - const corr = this.peer.send(new Message({ - header: new MessageHeader({ - subject: 'lobby/delete', - authorization: this.context.authorization - }), - body: { - lobby: { - id: lobbyId - } - } - })) - - corr.finish() - await corr.next() - } - - /** - * Join a lobby. - * - * Saves the lobby id in `context.lobbyId` - * @param {string} lobbyId Lobby id - */ - async join (lobbyId) { - const corr = this.peer.send(new Message({ - header: new MessageHeader({ - subject: 'lobby/join', - authorization: this.context.authorization - }), - body: { - lobby: { - id: lobbyId - } - } - })) - - corr.finish() - await corr.next() - - this.context.lobbyId = lobbyId - } - - /** - * Leave the currently joined lobby. - * - * Resets the lobby id in `context.lobbyId` - */ - async leave () { - const corr = this.peer.send(new Message({ - header: new MessageHeader({ - subject: 'lobby/leave', - authorization: this.context.authorization - }) - })) - - corr.finish() - await corr.next() - - this.context.lobbyId = undefined - } - - /** - * List all lobbies. - * @returns {AsyncGenerator} Lobby id's - */ - async * list () { - const corr = this.peer.send(new Message({ - header: new MessageHeader({ - subject: 'lobby/list', - authorization: this.context.authorization - }) - })) - - for await (const chunk of corr.all()) { - for (const lobby of chunk.lobbies) { - yield lobby.id - } - } - } - - /** - * Currently joined lobby - * @type {string} - */ - get lobbyId () { - return this.context.lobbyId - } -} diff --git a/src/lobbies/lobbies.mjs b/src/lobbies/lobbies.mjs deleted file mode 100644 index 3f3b5aa..0000000 --- a/src/lobbies/lobbies.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import logger from '../logger.mjs' -import { Natty } from '../natty.mjs' -import { notificationService } from '../notifications/notifications.mjs' -import { LobbyParticipantRepository } from './lobby.participant.repository.mjs' -import { LobbyRepository } from './lobby.repository.mjs' -import { LobbyService } from './lobby.service.mjs' -import { createLobbySubject } from './subjects/create.lobby.mjs' -import { deleteLobbySubject } from './subjects/delete.lobby.mjs' -import { joinLobbySubject } from './subjects/join.lobby.mjs' -import { leaveLobbySubject } from './subjects/leave.lobby.mjs' -import { listLobbiesSubject } from './subjects/list.lobbies.mjs' - -const log = logger.child({ name: 'Lobbies' }) - -export const lobbyRepository = new LobbyRepository() -export const lobbyParticipantRepository = new LobbyParticipantRepository() -export const lobbyService = new LobbyService({ - lobbyRepository, - notificationService, - participantRepository: lobbyParticipantRepository -}) - -Natty.hook(natty => { - log.info('Registering lobby subjects') - - natty.nlons.configure(nlons => { - createLobbySubject(nlons) - deleteLobbySubject(nlons) - joinLobbySubject(nlons) - leaveLobbySubject(nlons) - listLobbiesSubject(nlons) - }) -}) diff --git a/src/lobbies/lobby.data.mjs b/src/lobbies/lobby.data.mjs deleted file mode 100644 index d21c47c..0000000 --- a/src/lobbies/lobby.data.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import { requireEnum } from '../assertions.mjs' - -/** -* Possible lobby states. -* @readonly -* @enum {string} -*/ -export const LobbyState = Object.freeze({ - Gathering: 'gathering', - Starting: 'starting', - Active: 'active' -}) - -/** -* Class representing a lobby. -*/ -export class LobbyData { - /** - * Lobby id - * @type {string} - */ - id - - /** - * Lobby name - * @type {string} - */ - name - - /** - * Owner user's id - * @type {string} - */ - owner - - /** - * Associated game's id - * @type {string} - */ - game - - /** - * Lobby state - * @type {string} - */ - state = LobbyState.Gathering - - /** - * Is the lobby public? - * @type {boolean} - */ - isPublic = true - - /** - * Is lobby locked? - * @type {boolean} - */ - get isLocked () { - return this.state !== LobbyState.Gathering - } - - /** - * Construct instance. - * @param {LobbyData} [options] Options - */ - constructor (options) { - options && Object.assign(this, options) - requireEnum(this.state, LobbyState) - } -} diff --git a/src/lobbies/lobby.participant.repository.mjs b/src/lobbies/lobby.participant.repository.mjs deleted file mode 100644 index e1634d0..0000000 --- a/src/lobbies/lobby.participant.repository.mjs +++ /dev/null @@ -1,94 +0,0 @@ -import { Repository } from '../repository.mjs' - -/** -* Data class linking a user to a lobby. -* -* **This is a 1:1 relation.** -*/ -export class LobbyParticipant { - /** - * User id - * @type {string} - */ - userId - - /** - * Lobby id - * @type {string} - */ - lobbyId - - /** - * Construct instance. - * @param {LobbyParticipant} [options] Options - */ - constructor (options) { - options && Object.assign(this, options) - } -} - -/** -* Repository managing which user is in which lobby. -* @extends {Repository} -*/ -export class LobbyParticipantRepository extends Repository { - constructor () { - super({ - idMapper: p => `${p.userId}::${p.lobbyId}` - }) - } - - /** - * Remove user from lobby. - * @param {string} userId User id - * @param {string} lobbyId Lobby id - * @returns {boolean} - */ - removeParticipantFrom (userId, lobbyId) { - return this.removeItem({ userId, lobbyId }) - } - - /** - * Check if user is in lobby. - * @param {string} userId User id - * @param {string} lobbyId Lobby id - * @returns {boolean} - */ - isParticipantOf (userId, lobbyId) { - return this.hasItem({ userId, lobbyId }) - } - - /** - * Find all participants of lobby. - * @param {string} lobbyId Lobby id - * @returns {string[]} List of user id's - */ - getParticipantsOf (lobbyId) { - return [...this.list()] - .filter(p => p.lobbyId === lobbyId) - .map(p => p.userId) - } - - /** - * Find all lobbies the user is participating in. - * @param {string} userId User id - * @returns {string[]} List of lobby id's - */ - getLobbiesOf (userId) { - return [...this.list()] - .filter(p => p.userId === userId) - .map(p => p.lobbyId) - } - - /** - * Delete a lobby by removing all participants from it. - * @param {string} lobbyId Lobby id - */ - deleteLobby (lobbyId) { - for (const p of this.list()) { - if (p.lobbyId === lobbyId) { - this.remove(p.userId) - } - } - } -} diff --git a/src/lobbies/lobby.repository.mjs b/src/lobbies/lobby.repository.mjs deleted file mode 100644 index 38b8c07..0000000 --- a/src/lobbies/lobby.repository.mjs +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable */ -import { LobbyData } from './lobby.data.mjs' -/* eslint-enable */ -import { Repository } from '../repository.mjs' - -/** -* Repository managing all active lobbies. -* @extends {Repository} -*/ -export class LobbyRepository extends Repository { - /** - * List lobbies by game. - * @param {string} gameId Game id - * @returns {LobbyData[]} Lobbies - */ - listByGame (gameId) { - return [...this.list()] - .filter(lobby => lobby.game === gameId) - } -} diff --git a/src/lobbies/lobby.service.mjs b/src/lobbies/lobby.service.mjs deleted file mode 100644 index c8aff82..0000000 --- a/src/lobbies/lobby.service.mjs +++ /dev/null @@ -1,251 +0,0 @@ -/* eslint-disable */ -import { User } from '../users/user.mjs' -import { LobbyRepository } from './lobby.repository.mjs' -import { LobbyParticipant, LobbyParticipantRepository } from './lobby.participant.repository.mjs' -import { GameData } from '../games/game.data.mjs' -import { NotificationService } from '../notifications/notification.service.mjs' -/* eslint-enable */ -import assert from 'node:assert' -import { EventEmitter } from 'node:events' -import logger from '../logger.mjs' -import { config } from '../config.mjs' -import { LobbyData, LobbyState } from './lobby.data.mjs' -import { nanoid } from 'nanoid' -import { requireParam } from '../assertions.mjs' -import { DeleteLobbyNotificationMessage, JoinLobbyNotificationMessage, LeaveLobbyNotificationMessage } from './message.templates.mjs' - -export class LobbyOwnerError extends Error { } - -export class AlreadyInLobbyError extends Error { } - -export class LobbyLockedError extends Error { } - -/** -*/ -export class LobbyService extends EventEmitter { - #log - /** @type {LobbyRepository} */ - #lobbyRepository - /** @type {LobbyParticipantRepository} */ - #participantRepository - /** @type {NotificationService} */ - #notificationService - - /** - * Construct service. - * @param {object} options Options - * @param {LobbyRepository} options.lobbyRepository Lobby repository - * @param {LobbyParticipantRepository} options.participantRepository - * Lobby participant repository - * @param {NotificationService} options.notificationService - * Notification service - */ - constructor (options) { - super() - this.#lobbyRepository = requireParam(options.lobbyRepository) - this.#participantRepository = requireParam(options.participantRepository) - this.#notificationService = requireParam(options.notificationService) - this.#log = logger.child({ name: 'LobbyService' }) - } - - /** - * Create a new lobby. - * @param {string} name Lobby name - * @param {User} owner Owning user - * @param {GameData} game Hosting game - * @param {boolean} isPublic Is public lobby? - * @returns {LobbyData} New lobby - * @fires LobbyService#create - */ - create (name, owner, game, isPublic) { - assert(name.length >= config.lobby.minNameLength, 'Lobby name too short!') - assert(name.length < config.lobby.maxNameLength, 'Lobby name too long!') - - this.#log.info( - { user: owner.id, game: game.id }, - 'Attempting to create lobby for user' - ) - - // Check if user is not already in a lobby - if (this.#isUserInLobby(owner.id, game.id)) { - this.#log.error( - { user: owner.id, game: game.id }, - 'Can\'t create lobby for user, they\'re already in a lobby' - ) - - throw new AlreadyInLobbyError('User is already in a lobby!') - } - - const lobby = this.#lobbyRepository.add(new LobbyData({ - id: nanoid(), - name, - owner: owner.id, - game: game.id, - isPublic - })) - - this.#log.info( - { user: owner.id, game: game.id, lobby: lobby.id }, - 'Created lobby for user' - ) - - this.emit('create', lobby) - - this.join(owner, lobby) - - return lobby - } - - /** - * Add user to lobby. - * @param {User} user Joining user - * @param {LobbyData} lobby Target lobby - * @fires LobbyService#join - */ - join (user, lobby) { - this.#log.info( - { user: user.id, lobby: lobby.id }, - 'Attempting to add user to lobby' - ) - - // Reject if user is already in a lobby in that game - if (this.#isUserInLobby(user.id, lobby.game)) { - this.#log.error( - { user: user.id, lobby: lobby.id }, - 'Can\'t add user to lobby, they\'re already in another' - ) - - throw new AlreadyInLobbyError('User is already in a lobby!') - } - - // Reject if lobby is locked - if (lobby.isLocked) { - this.#log.error( - { user: user.id, lobby: lobby.id }, - 'Can\'t add user to locked lobby!' - ) - - throw new LobbyLockedError('Lobby is locked!') - } - - // Add user to lobby - this.#participantRepository.add(new LobbyParticipant({ - userId: user.id, - lobbyId: lobby.id - })) - this.#log.info( - { user: user.id, lobby: lobby.id }, - 'Added user to lobby' - ) - - this.emit('join', lobby, user) - - // Notify participants, including joining user - this.#notificationService.send({ - message: JoinLobbyNotificationMessage(user), - userIds: this.#participantRepository.getParticipantsOf(lobby.id) - }) - } - - /** - * Remove user from lobby. - * @param {User} user Leaving user - * @param {LobbyData} lobby Target lobby - * @fires LobbyService#leave - */ - leave (user, lobby) { - // Do nothing if user is not part of the given lobby ( or any lobby ) - if (!this.#participantRepository.isParticipantOf(user.id, lobby.id)) { - return - } - // Deny if user is owner of lobby - if (lobby.owner === user.id) { - throw new LobbyOwnerError('Can\'t leave own lobby') - } - - // Remove user from lobby - this.#participantRepository.removeParticipantFrom(user.id, lobby.id) - this.emit('leave', lobby, user) - - // Notify participants, including leaving user - this.#notificationService.send({ - message: LeaveLobbyNotificationMessage(user), - userIds: [ - user.id, - ...this.#participantRepository.getParticipantsOf(lobby.id) - ] - }) - } - - /** - * Delete lobby. - * - * This will also remove all participants. - * @param {LobbyData} lobby Lobby - * @fires LobbyService#delete - */ - delete (lobby) { - // Delete lobby - const participants = this.#participantRepository.getParticipantsOf(lobby.id) - this.#participantRepository.deleteLobby(lobby.id) - this.#lobbyRepository.remove(lobby.id) - this.emit('delete', lobby) - - // Notify participants of lobby delete - this.#notificationService.send({ - message: DeleteLobbyNotificationMessage(lobby), - userIds: participants - }) - } - - /** - * List lobbies for game. - * - * This will only list lobbies that are publicly visible and not active. - * @param {GameData} game Game - * @returns {LobbyData[]} Lobbies - */ - list (game) { - return this.#lobbyRepository.listByGame(game.id) - .filter(lobby => lobby.isPublic) - .filter(lobby => lobby.state !== LobbyState.Active) - } - - /** - * Check if the user is already in a lobby for the given game. - * @param {string} userId User id - * @param {string} gameId Game id - * @returns {boolean} - */ - #isUserInLobby (userId, gameId) { - return this.#participantRepository.getLobbiesOf(userId) - .map(lobbyId => this.#lobbyRepository.find(lobbyId)) - .some(lobby => lobby?.game === gameId) - } -} - -/** -* Event fired when a new lobby is created. -* @event LobbyService#create -* @param {LobbyData} lobby Lobby -*/ - -/** -* Event fired when a user joins a lobby. -* @event LobbyService#join -* @param {LobbyData} lobby Lobby -* @param {User} user User -*/ - -/** -* Event fired when a user leaves a lobby. -* @event LobbyService#leave -* @param {LobbyData} lobby Lobby -* @param {User} user User -*/ - -/** -* Event fired when a lobby is deleted. -* @event LobbyService#delete -* @param {LobbyData} lobby Lobby -*/ diff --git a/src/lobbies/message.templates.mjs b/src/lobbies/message.templates.mjs deleted file mode 100644 index 16d7d87..0000000 --- a/src/lobbies/message.templates.mjs +++ /dev/null @@ -1,69 +0,0 @@ -/* eslint-disable */ -import { User } from '../users/user.mjs' -import { LobbyData } from './lobby.data.mjs' -/* eslint-enable */ -import { Message, MessageHeader, MessageTypes } from '@elementbound/nlon' - -const Subjects = Object.freeze({ - Join: 'lobby/notif/join', - Leave: 'lobby/notif/leave', - Delete: 'lobby/notif/delete' -}) - -/** -* Create a join lobby notification message. -* @param {User} user Joining user -* @returns {Message} -*/ -export function JoinLobbyNotificationMessage (user) { - return new Message({ - header: new MessageHeader({ - subject: Subjects.Join - }), - type: MessageTypes.Finish, // TODO: Use terminate - body: { - user: { - id: user.id, - name: user.name - } - } - }) -} - -/** -* Create a leave lobby notification message. -* @param {User} user Leaving user -* @returns {Message} -*/ -export function LeaveLobbyNotificationMessage (user) { - return new Message({ - header: new MessageHeader({ - subject: Subjects.Leave - }), - type: MessageTypes.Finish, // TODO: Use terminate - body: { - user: { - id: user.id - } - } - }) -} - -/** -* Create a lobby delete notification message. -* @param {LobbyData} lobby Lobby -* @returns {Message} -*/ -export function DeleteLobbyNotificationMessage (lobby) { - return new Message({ - header: new MessageHeader({ - subject: Subjects.Delete - }), - type: MessageTypes.Finish, // TODO: Use terminate - body: { - lobby: { - id: lobby.id - } - } - }) -} diff --git a/src/lobbies/subjects/create.lobby.mjs b/src/lobbies/subjects/create.lobby.mjs deleted file mode 100644 index 9e230b1..0000000 --- a/src/lobbies/subjects/create.lobby.mjs +++ /dev/null @@ -1,59 +0,0 @@ -/* eslint-disable */ -import { Server } from '@elementbound/nlon' -import { User } from '../../users/user.mjs' -import { GameData } from '../../games/game.data.mjs' -/* eslint-enable */ -import { ajv } from '../../ajv.mjs' -import { requireBody } from '../../validators/require.body.mjs' -import { requireAuthorization } from '../../validators/require.header.mjs' -import { requireSchema } from '../../validators/require.schema.mjs' -import { lobbyService } from '../lobbies.mjs' -import { requireSessionUser } from '../../sessions/validators/require.session.user.mjs' -import { requireSession } from '../../sessions/validators/require.session.mjs' -import { requireSessionGame } from '../../sessions/validators/require.session.game.mjs' - -function CreateLobbyResponse (lobby) { - return { - lobby: { - id: lobby.id - } - } -} - -/** -* Register create lobby subject handler. -* @param {Server} server nlon server -*/ -export function createLobbySubject (server) { - ajv.addSchema({ - type: 'object', - properties: { - name: { type: 'string' }, - public: { type: 'boolean' } - }, - required: ['name'] - }, 'lobby/create') - - server.handle('lobby/create', async (_peer, corr) => { - const request = await corr.next( - requireBody(), - requireSchema('lobby/create'), - requireAuthorization(), - requireSession(), - requireSessionUser(), - requireSessionGame() - ) - - /** @type {string} */ - const name = request.name - /** @type {User} */ - const user = corr.context.user - /** @type {GameData} */ - const game = corr.context.game - /** @type {boolean} */ - const isPublic = request.public ?? true - - const lobby = lobbyService.create(name, user, game, isPublic) - corr.finish(CreateLobbyResponse(lobby)) - }) -} diff --git a/src/lobbies/subjects/delete.lobby.mjs b/src/lobbies/subjects/delete.lobby.mjs deleted file mode 100644 index 89bf32b..0000000 --- a/src/lobbies/subjects/delete.lobby.mjs +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable */ -import { Server } from '@elementbound/nlon' -import { User } from '../../users/user.mjs' -import { LobbyData } from '../lobby.data.mjs' -/* eslint-enable */ -import { requireAuthorization } from '../../validators/require.header.mjs' -import { lobbyRepository, lobbyService } from '../lobbies.mjs' -import { ajv } from '../../ajv.mjs' -import { requireBody } from '../../validators/require.body.mjs' -import { requireSchema } from '../../validators/require.schema.mjs' -import { requireLobby } from '../validation.mjs' -import { requireSession } from '../../sessions/validators/require.session.mjs' -import { requireSessionUser } from '../../sessions/validators/require.session.user.mjs' - -// TODO: Standard error? Maybe in nlon? -class UnauthorizedError extends Error { } - -/** -* Register delete lobby subject handler. -* @param {Server} server nlon server -*/ -export function deleteLobbySubject (server) { - ajv.addSchema({ - type: 'object', - properties: { - lobby: { - type: 'object', - properties: { - id: { type: 'string' } - } - } - } - }, 'lobby/delete') - - server.handle('lobby/delete', async (_peer, corr) => { - await corr.next( - requireBody(), - requireSchema('lobby/delete'), - requireAuthorization(), - requireSession(), - requireSessionUser(), - requireLobby(lobbyRepository, body => body.lobby.id) - ) - - /** @type {User} */ - const user = corr.context.user - - /** @type {LobbyData} */ - const lobby = corr.context.lobby - - // Deny if not owner - if (lobby.owner !== user.id) { - throw new UnauthorizedError('User is not the owner of lobby') - } - - // Delete - lobbyService.delete(lobby) - corr.finish() - }) -} diff --git a/src/lobbies/subjects/join.lobby.mjs b/src/lobbies/subjects/join.lobby.mjs deleted file mode 100644 index 5ed1b29..0000000 --- a/src/lobbies/subjects/join.lobby.mjs +++ /dev/null @@ -1,52 +0,0 @@ -/* eslint-disable */ -import { Server } from '@elementbound/nlon' -import { User } from '../../users/user.mjs' -import { LobbyData } from '../lobby.data.mjs' -/* eslint-enable */ -import { ajv } from '../../ajv.mjs' -import { requireBody } from '../../validators/require.body.mjs' -import { requireAuthorization } from '../../validators/require.header.mjs' -import { requireSchema } from '../../validators/require.schema.mjs' -import { lobbyRepository, lobbyService } from '../lobbies.mjs' -import { requireLobby } from '../validation.mjs' -import { requireSession } from '../../sessions/validators/require.session.mjs' -import { requireSessionUser } from '../../sessions/validators/require.session.user.mjs' - -/** -* Register leave lobby subject handler. -* @param {Server} server nlon server -*/ -export function joinLobbySubject (server) { - ajv.addSchema({ - type: 'object', - properties: { - lobby: { - type: 'object', - properties: { - id: { type: 'string' } - } - } - } - }, 'lobby/join') - - server.handle('lobby/join', async (_peer, corr) => { - await corr.next( - requireBody(), - requireSchema('lobby/join'), - requireAuthorization(), - requireSession(), - requireSessionUser(), - requireLobby(lobbyRepository, body => body.lobby.id) - ) - - /** @type {User} */ - const user = corr.context.user - /** @type {LobbyData} */ - const lobby = corr.context.lobby - - // Join - lobbyService.join(user, lobby) - - corr.finish() - }) -} diff --git a/src/lobbies/subjects/leave.lobby.mjs b/src/lobbies/subjects/leave.lobby.mjs deleted file mode 100644 index 5ae5988..0000000 --- a/src/lobbies/subjects/leave.lobby.mjs +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-disable */ -import { Server } from '@elementbound/nlon' -import { User } from '../../users/user.mjs' -import { GameData } from '../../games/game.data.mjs' -/* eslint-enable */ -import { requireAuthorization } from '../../validators/require.header.mjs' -import { lobbyParticipantRepository, lobbyRepository, lobbyService } from '../lobbies.mjs' -import { requireSession } from '../../sessions/validators/require.session.mjs' -import { requireSessionUser } from '../../sessions/validators/require.session.user.mjs' -import { requireSessionGame } from '../../sessions/validators/require.session.game.mjs' -import logger from '../../logger.mjs' - -class NotInLobbyError extends Error { } - -/** -* Register leave lobby subject handler. -* @param {Server} server nlon server -*/ -export function leaveLobbySubject (server) { - server.handle('lobby/leave', async (_peer, corr) => { - await corr.next( - requireAuthorization(), - requireSession(), - requireSessionUser(), - requireSessionGame() - ) - - /** @type {User} */ - const user = corr.context.user - /** @type {GameData} */ - const game = corr.context.game - - const log = logger.child({ - name: 'leaveLobbySubject', - user: user.id, - game: game.id, - session: corr.context.session.id - }) - - log.info('User requesting to leave lobby') - - // Find lobby corresponding to session game - const lobbyId = lobbyParticipantRepository.getLobbiesOf(user.id) - .map(lobbyId => lobbyRepository.find(lobbyId)) - .find(lobby => lobby?.game === game.id) - ?.id - - if (!lobbyId) { - log.error('No lobby found for user!') - throw new NotInLobbyError('User is not in any lobby!') - } - - const lobby = lobbyRepository.find(lobbyId) - - // Leave - log.info({ lobby: lobby.id }, 'Removing user from lobby') - lobbyService.leave(user, lobby) - - corr.finish() - }) -} diff --git a/src/lobbies/subjects/list.lobbies.mjs b/src/lobbies/subjects/list.lobbies.mjs deleted file mode 100644 index 44262c3..0000000 --- a/src/lobbies/subjects/list.lobbies.mjs +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable */ -import { Server } from '@elementbound/nlon' -import { requireSessionGame } from '../../sessions/validators/require.session.game.mjs' -import { requireSession } from '../../sessions/validators/require.session.mjs' -/* eslint-enable */ -import { requireAuthorization } from '../../validators/require.header.mjs' -import { lobbyService } from '../lobbies.mjs' -import { chunks } from '../../utils.mjs' - -/** -* @param {LobbyData[]} lobbies -* @returns {object} -*/ -function ListLobbiesResponse (lobbies) { - return { - lobbies: lobbies.map(lobby => ({ id: lobby.id })) - } -} - -/** -* @param {Server} server nlon server -*/ -export function listLobbiesSubject (server) { - server.handle('lobby/list', async (_peer, corr) => { - await corr.next( - requireAuthorization(), - requireSession(), - requireSessionGame() - ) - - const game = corr.context.game - const chunkSize = 64 - - chunks(lobbyService.list(game), chunkSize) - .forEach(chunk => { - corr.write(ListLobbiesResponse(chunk)) - }) - - corr.finish() - }) -} diff --git a/src/lobbies/validation.mjs b/src/lobbies/validation.mjs deleted file mode 100644 index b683663..0000000 --- a/src/lobbies/validation.mjs +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -import { LobbyRepository } from './lobby.repository.mjs' -/* eslint-enable */ - -class InvalidLobbyError extends Error { } - -/** -* Check if message has a valid lobby specified. -* -* Saves lobby in `context.lobby`. -* -* @param {LobbyRepository} lobbyRepository Lobby repository -* @param {function(any, any):string} mapper Body+header to lobby id mapper -* @returns {ReadHandler} -*/ -export function requireLobby (lobbyRepository, mapper) { - return function (body, header, context) { - context.lobby = lobbyRepository.find(mapper(body, header)) - if (!context.lobby) { - throw new InvalidLobbyError('Invalid lobby!') - } - } -} diff --git a/src/natty.client.mjs b/src/natty.client.mjs deleted file mode 100644 index ae39109..0000000 --- a/src/natty.client.mjs +++ /dev/null @@ -1,35 +0,0 @@ -/* eslint-disable */ -import { Peer } from '@elementbound/nlon' -/* eslint-enable */ -import { Client } from './client.mjs' -import { LobbiesClient } from './lobbies/lobbies.client.mjs' -import { SessionClient } from './sessions/session.client.mjs' - -/** -* Natty client class. -* -* Contains multiple clients specific to a given feature, each with a shared -* context. -*/ -export class NattyClient extends Client { - /** @type {SessionClient} */ - session - - /** @type {LobbiesClient} */ - lobbies - - /** - * Construct client - * @param {Peer} peer Peer - */ - constructor (peer) { - super({ - peer - }) - - this.session = new SessionClient({ peer, context: this.context }) - this.lobbies = new LobbiesClient({ peer, context: this.context }) - - Object.freeze(this) - } -} diff --git a/src/natty.mjs b/src/natty.mjs index 191ab62..98e7dee 100644 --- a/src/natty.mjs +++ b/src/natty.mjs @@ -1,9 +1,5 @@ -/* eslint-disable */ -import * as nlon from '@elementbound/nlon' -/* eslint-enable */ import * as net from 'node:net' import { EventEmitter } from 'node:events' -import { wrapSocketServer } from '@elementbound/nlon-socket' import logger from './logger.mjs' import { config } from './config.mjs' @@ -21,9 +17,6 @@ export class Natty extends EventEmitter { /** @type {net.Server} */ #socket - /** @type {nlon.Server} */ - #nlons - #log = logger /** @@ -41,13 +34,7 @@ export class Natty extends EventEmitter { const socket = net.createServer() - /** @type {nlon.Server} */ - const nlons = wrapSocketServer(socket, { - logger: this.#log.child({ name: 'nlons' }) - }) - this.#socket = socket - this.#nlons = nlons // Import modules for hooks for (const m of modules) { @@ -77,8 +64,4 @@ export class Natty extends EventEmitter { this.#socket.close() } - - get nlons () { - return this.#nlons - } } diff --git a/src/notifications/notification.service.mjs b/src/notifications/notification.service.mjs deleted file mode 100644 index f13f45c..0000000 --- a/src/notifications/notification.service.mjs +++ /dev/null @@ -1,43 +0,0 @@ -/* eslint-disable */ -import { Correspondence, Message, Peer } from '@elementbound/nlon' -import { SessionRepository } from '../sessions/session.repository.mjs' -/* eslint-enable */ -import { requireParam } from '../assertions.mjs' - -export class NotificationService { - /** @type {SessionRepository} */ - #sessionRepository - - /** - * Construct service. - * @param {object} options Options - * @param {SessionRepository} options.sessionRepository Session repository - */ - constructor (options) { - this.#sessionRepository = requireParam(options.sessionRepository) - } - - /** - * Send a notification to a multitude of targets. - * @param {object} options Options - * @param {Message} options.message Message to send - * @param {string[]} [options.userIds=[]] Target user ids - * @param {Peer[]} [options.peers=[]] Target peers - * @returns {Correspondence[]} A list of correspondences - */ - send (options) { - requireParam(options.message) - - const peers = [ - ...(options.peers ?? []), - ...this.#userIdsToPeers(options.userIds) - ] - - return peers.map(peer => peer.send(options.message)) - } - - #userIdsToPeers (userIds) { - return this.#sessionRepository.findSessionsOf(...(userIds ?? [])) - .map(s => s.peer) - } -} diff --git a/src/notifications/notifications.mjs b/src/notifications/notifications.mjs deleted file mode 100644 index 3908f70..0000000 --- a/src/notifications/notifications.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { sessionRepository } from '../sessions/session.repository.mjs' -import { NotificationService } from './notification.service.mjs' - -export const notificationService = new NotificationService({ - sessionRepository -}) diff --git a/src/relay/udp.remote.registrar.mjs b/src/relay/udp.remote.registrar.mjs deleted file mode 100644 index b210f90..0000000 --- a/src/relay/udp.remote.registrar.mjs +++ /dev/null @@ -1,97 +0,0 @@ -/* eslint-disable */ -import { SessionRepository } from '../sessions/session.repository.mjs' -import { UDPRelayHandler } from './udp.relay.handler.mjs' -/* eslint-enable */ -import dgram from 'node:dgram' -import assert from 'node:assert' -import { RelayEntry } from './relay.entry.mjs' -import { NetAddress } from './net.address.mjs' -import { requireParam } from '../assertions.mjs' -import logger from '../logger.mjs' - -const log = logger.child({ name: 'UDPRemoteRegistrar' }) - -/** -* @summary Class for remote address registration over UDP. -* -* @description The UDP remote registrar will listen on a specific port for -* incoming session ID's. If the session ID is valid, it will create a new relay -* for that player and reply a packet saying 'OK'. -* -* Note that if the relay already exists, it will reply anyway, but will not -* create duplicate relays. This helps combatting UDP's unreliable nature - -* clients can just spam the request until they receive a reply. -*/ -export class UDPRemoteRegistrar { - /** @type {dgram.Socket} */ - #socket - - /** @type {SessionRepository} */ - #sessionRepository - - /** @type {UDPRelayHandler} */ - #udpRelayHandler - - /** - * Construct instance. - * @param {object} options Options - * @param {SessionRepository} options.sessionRepository Session repository - * @param {UDPRelayHandler} options.udpRelayHandler UDP relay handler - * @param {dgram.Socket} [options.socket] Socket - */ - constructor (options) { - this.#sessionRepository = requireParam(options.sessionRepository) - this.#udpRelayHandler = requireParam(options.udpRelayHandler) - this.#socket = options.socket ?? dgram.createSocket('udp4') - } - - /** - * Start listening for incoming requests. - * @param {number} [port=0] Port - * @param {string} [address='0.0.0.0'] Address - * @returns {Promise} - */ - listen (port, address) { - return new Promise(resolve => { - port ??= 0 - address ??= '0.0.0.0' - - this.#socket.on('message', (msg, rinfo) => this.#handle(msg, rinfo)) - this.#socket.bind(port, address, () => { - const address = this.#socket.address() - log.info('Listening on %s:%s', address.address, address.port) - resolve() - }) - }) - } - - /** - * Socket listening for requests. - * @type {dgram.Socket} - */ - get socket () { - return this.#socket - } - - /** - * @param {Buffer} msg - * @param {dgram.RemoteInfo} rinfo - */ - async #handle (msg, rinfo) { - try { - const sessionId = msg.toString('utf8') - log.debug({ sessionId, rinfo }, 'Received UDP relay request') - - const session = this.#sessionRepository.find(sessionId) - assert(session, 'Unknown session id!') - - await this.#udpRelayHandler.createRelay(new RelayEntry({ - address: NetAddress.fromRinfo(rinfo) - })) - - this.#socket.send('OK', rinfo.port, rinfo.address) - } catch (e) { - this.#socket.send(e.message ?? 'Error', rinfo.port, rinfo.address) - } - } -} diff --git a/src/sessions/session.cleanup.mjs b/src/sessions/session.cleanup.mjs deleted file mode 100644 index 607533c..0000000 --- a/src/sessions/session.cleanup.mjs +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-disable */ -import { SessionRepository } from './session.repository.mjs' -import { SessionService } from './session.service.mjs' -/* eslint-enable */ -import logger from '../logger.mjs' -import { formatDuration, time } from '../utils.mjs' - -const log = logger.child({ name: 'SessionCleanup' }) - -/** -* Cleanup expired sessions. -* @param {object} options Options -* @param {number} options.timeout Session timeout in s -* @param {number} options.interval Session cleanup interval in s -* @param {SessionRepository} options.sessionRepository Session repository -* @param {SessionService} options.sessionService Session service -*/ -export function cleanupSessions (options) { - const { timeout, sessionRepository, sessionService } = options - const currentTime = time() - - const expiredSessions = [] - let sessionCount = 0 - log.debug('Gathering expired sessions') - for (const session of sessionRepository.list()) { - ++sessionCount - log.debug( - { currentTime, session }, - 'Session id %s has age %s', - session.id, formatDuration(currentTime - session.lastMessage) - ) - if (currentTime - session.lastMessage > timeout) { - expiredSessions.push(session) - } - } - - log.debug('Cleaning up %d expired sessions of %d', - expiredSessions.length, sessionCount) - expiredSessions.forEach(session => sessionService.destroy(session.id)) -} - -/** -* Run a job to cleanup expired sessions. -* @param {object} options Options -* @param {number} options.timeout Session timeout in s -* @param {number} options.interval Session cleanup interval in s -* @param {SessionRepository} options.sessionRepository Session repository -* @param {SessionService} options.sessionService Session service -*/ -export function startCleanupJob (options) { - const interval = options.interval - - log.info('Launching cleanup job every %s', formatDuration(interval)) - return setInterval( - () => cleanupSessions(options), - interval * 1000 - ) -} diff --git a/src/sessions/session.client.mjs b/src/sessions/session.client.mjs deleted file mode 100644 index a9abe70..0000000 --- a/src/sessions/session.client.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import { fail } from 'node:assert' -import { Message, MessageHeader } from '@elementbound/nlon' -import { Client } from '../client.mjs' - -/** -* Stateful client for session-related subjects. -*/ -export class SessionClient extends Client { - /** - * Start session by logging in. - * @param {string} name Username - * @param {string} game Game id - * @returns {Promise} Session id - */ - async login (name, game) { - const corr = this.peer.send(new Message({ - header: new MessageHeader({ - subject: 'session/login', - game - }), - body: { - name - } - })) - - corr.finish() - const response = await corr.next() - const session = response.session ?? fail('No session in response!') - - this.context.authorization = session - - return session - } - - /** - * Terminate session by logging out. - */ - async logout () { - const corr = this.peer.send(new Message({ - header: new MessageHeader({ - subject: 'session/logout', - authorization: this.context.authorization - }) - })) - - corr.finish() - await corr.next() - - this.context.authorization = undefined - } -} diff --git a/src/sessions/session.data.mjs b/src/sessions/session.data.mjs deleted file mode 100644 index a429583..0000000 --- a/src/sessions/session.data.mjs +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable */ -import { Peer } from '@elementbound/nlon' -/* eslint-enable */ -import { time } from '../utils.mjs' - -/** -* Session data. -* @extends {DataObject} -*/ -export class SessionData { - /** - * Session id - * @type {string} - */ - id - - /** - * Associated user's id - * @type {string} - */ - userId - - /** - * Associated game's id - * @type {string} - */ - gameId - - /** - * Peer associated with session - * @type {Peer} - */ - peer - - /** - * Date of the last message received - * @type {number} - */ - lastMessage = time() - - /** - * Construct instance. - * @param {SessionData} [options] Options - */ - constructor (options) { - options && Object.assign(this, options) - } -} diff --git a/src/sessions/session.repository.mjs b/src/sessions/session.repository.mjs deleted file mode 100644 index da4faa2..0000000 --- a/src/sessions/session.repository.mjs +++ /dev/null @@ -1,34 +0,0 @@ -/* eslint-disable */ -import { SessionData } from './session.data.mjs' -/* eslint-enable */ -import { Repository } from '../repository.mjs' - -/** -* Repository to manage session data. -* @extends {Repository} -*/ -export class SessionRepository extends Repository { - /** - * Find all sessions of the given user(s). - * @param {...string} userIds User id's - * @returns {SessionData[]} Sessions - */ - findSessionsOf (...userIds) { - return [...this.list()] - .filter(s => userIds.includes(s.userId)) - } - - /** - * Find all sessions of the given user(s) for a game. - * @param {string} gameId Game id - * @param {...string} userIds User id's - * @returns {SessionData[]} Sessions - */ - findSessionsByGameFor (gameId, ...userIds) { - return [...this.list()] - .filter(s => s.gameId === gameId) - .filter(s => userIds.includes(s.userId)) - } -} - -export const sessionRepository = new SessionRepository() diff --git a/src/sessions/session.service.mjs b/src/sessions/session.service.mjs deleted file mode 100644 index 7a8d014..0000000 --- a/src/sessions/session.service.mjs +++ /dev/null @@ -1,104 +0,0 @@ -/* eslint-disable */ -import { Peer } from '@elementbound/nlon' -import { UserRepository } from '../users/user.repository.mjs' -import { sessionRepository, SessionRepository } from './session.repository.mjs' -import { GameData } from '../games/game.data.mjs' -/* eslint-enable */ -import { nanoid } from 'nanoid' -import { fail } from 'node:assert' -import logger from '../logger.mjs' -import { time } from '../utils.mjs' -import { SessionData } from './session.data.mjs' -import { User } from '../users/user.mjs' -import { userRepository } from '../users/users.mjs' - -/** -* Service for managing sessions. -*/ -export class SessionService { - #log - /** @type {SessionRepository} */ - #sessionRepository - /** @type {UserRepository} */ - #userRepository - - /** - * Construct service. - * @param {object} options Options - * @param {SessionRepository} options.sessionRepository Session repository - * @param {UserRepository} options.userRepository User repository - */ - constructor (options) { - this.#sessionRepository = options.sessionRepository ?? fail('Session repository is required!') - this.#userRepository = options.userRepository ?? fail('User repository is required!') - this.#log = logger.child({ name: 'SessionService' }) - } - - /** - * Create a session. - * @param {string} username Username - * @param {GameData} game Game - * @param {Peer} peer Peer initiating session - * @returns {string} Session id - */ - create (username, game, peer) { - const user = this.#userRepository.add(new User({ - id: nanoid(), - name: username - })) - - const session = this.#sessionRepository.add(new SessionData({ - id: nanoid(), - userId: user.id, - gameId: game.id, - peer - })) - - peer.on('disconnect', () => { - this.#log.info({ session: session.id }, - 'Peer disconnected, releasing session') - this.destroy(session.id) - }) - - peer.on('correspondence', () => { - this.#log.debug({ session: session.id }, - 'Refreshing session due to new correspondence') - session.lastMessage = time() - }) - - this.#log.info({ user, session }, 'Created session for user') - return session.id - } - - /** - * Validate a session by id. - * @param {string} id Session id - * @returns {SessionData | undefined} Session data or undefined - */ - validate (id) { - const session = this.#sessionRepository.find(id) - return session - } - - /** - * Destroy session by id. - * @param {string} id Session id - */ - destroy (id) { - const session = this.#sessionRepository.find(id) - - if (!session) { - return - } - - this.#sessionRepository.remove(id) - this.#userRepository.remove(session.userId) - - this.#log.info({ sessionId: id, userId: session.userId }, 'Destroyed session') - } -} - -export const sessionService = new SessionService({ - userRepository, - sessionRepository -}) diff --git a/src/sessions/session.subjects.mjs b/src/sessions/session.subjects.mjs deleted file mode 100644 index 2291419..0000000 --- a/src/sessions/session.subjects.mjs +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable */ -import { Server } from '@elementbound/nlon' -import { GameData } from '../games/game.data.mjs' -/* eslint-enable */ -import { ajv } from '../ajv.mjs' -import { gameRepository } from '../games/games.mjs' -import { requireGame } from '../games/validation.mjs' -import { requireBody } from '../validators/require.body.mjs' -import { requireAuthorization } from '../validators/require.header.mjs' -import { requireSchema } from '../validators/require.schema.mjs' -import { requireSession } from './validators/require.session.mjs' -import { sessionService } from './session.service.mjs' -import { sessionRepository } from './session.repository.mjs' - -function registerSchemas (ajv) { - ajv.addSchema({ - type: 'object', - properties: { - name: { type: 'string' } - } - }, 'session/login') -} - -/** -* Configure subjects for session handling. -* @param {Server} server nlon server -*/ -export function sessionSubjects (server) { - registerSchemas(ajv) - - server.handle('session/login', async (peer, corr) => { - const request = await corr.next( - requireBody(), - requireSchema('session/login'), - requireGame(gameRepository) - ) - - /** @type {string} */ - const { name } = request - /** @type {GameData} */ - const game = corr.context.game - - const session = sessionService.create(name, game, peer) - corr.finish({ session }) - }) - - server.handle('session/logout', async (_p, corr) => { - await corr.next( - requireAuthorization(), - requireSession(sessionRepository, sessionService) - ) - const { session } = corr.context - - sessionService.destroy(session) - corr.finish() - }) -} diff --git a/src/sessions/sessions.mjs b/src/sessions/sessions.mjs deleted file mode 100644 index 52eeee3..0000000 --- a/src/sessions/sessions.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import { sessionRepository } from './session.repository.mjs' -import { sessionSubjects } from './session.subjects.mjs' -import { startCleanupJob } from './session.cleanup.mjs' -import { Natty } from '../natty.mjs' -import { config } from '../config.mjs' -import logger from '../logger.mjs' -import { sessionService } from './session.service.mjs' - -const log = logger.child({ name: 'Sessions' }) - -Natty.hook(natty => { - log.info('Registering session subjects') - natty.nlons.configure(sessionSubjects) - - log.info('Starting session cleanup job') - const cleanupJob = startCleanupJob({ - timeout: config.session.timeout, - interval: config.session.cleanupInterval, - sessionRepository, - sessionService - }) - - natty.on('close', () => { - log.info('Natty shutting down, cancelling session cleanup job') - clearInterval(cleanupJob) - }) -}) diff --git a/src/sessions/validators/require.session.game.mjs b/src/sessions/validators/require.session.game.mjs deleted file mode 100644 index 8bcb582..0000000 --- a/src/sessions/validators/require.session.game.mjs +++ /dev/null @@ -1,43 +0,0 @@ -/* eslint-disable */ -import { GameRepository } from '../../games/game.repository.mjs' -/* eslint-enable */ -import { requireParam } from '../../assertions.mjs' -import { gameRepository } from '../../games/games.mjs' -import { asSingletonFactory } from '../../utils.mjs' -import { ExtractMapperValidator } from '../../validators/extract.mapper.validator.mjs' -import { InvalidSessionError } from './require.session.mjs' - -/** -* Extract the game from session into context. -* -* Needs `session` to be in context, by calling `requireSession` first. -* -* Saves user in `context.game` -* -* @returns {ReadHandler} -*/ -export class SessionGameIdValidator extends ExtractMapperValidator { - /** - * Construct validator. - * @param {object} options Options - * @param {GameRepository} options.gameRepository Game repository - */ - constructor (options) { - requireParam(options.gameRepository) - - super({ - extractor: (_b, _h, context) => context.session.gameId, - mapper: id => options.gameRepository.find(id), - writer: (context, game) => { context.game = game }, - thrower: () => { - throw new InvalidSessionError('No game found for session!') - } - }) - } -} - -export const requireSessionGame = asSingletonFactory(() => - new SessionGameIdValidator({ - gameRepository - }).asFunction() -) diff --git a/src/sessions/validators/require.session.mjs b/src/sessions/validators/require.session.mjs deleted file mode 100644 index d29ce6c..0000000 --- a/src/sessions/validators/require.session.mjs +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable */ -import { sessionService, SessionService } from '../session.service.mjs' -/* eslint-enable */ -import { requireParam } from '../../assertions.mjs' -import { asSingletonFactory } from '../../utils.mjs' -import { Validator } from '../../validators/validator.mjs' - -export class InvalidSessionError extends Error { } - -export class SessionPresenceValidator extends Validator { - /** @type {SessionService} */ - #sessionService - - /** - * Construct validator. - * @param {object} options Options - * @param {SessionService} options.sessionService Session service - */ - constructor (options) { - super() - this.#sessionService = requireParam(options.sessionService) - } - - validate (_body, header, context) { - const sessionId = header.authorization - const session = this.#sessionService.validate(sessionId) - - if (session === undefined) { - throw new InvalidSessionError(`Invalid session: ${sessionId}`) - } - - context.session = session - } -} -/** -* Check if message has a valid session. -* -* Saves session in `context.session`. -* -* @param {SessionRepository} sessionRepository Session repository -* @param {SessionService} sessionService Session service -* @returns {ReadHandler} -*/ -export const requireSession = asSingletonFactory(() => - new SessionPresenceValidator({ - sessionService - }).asFunction() -) diff --git a/src/sessions/validators/require.session.user.mjs b/src/sessions/validators/require.session.user.mjs deleted file mode 100644 index 1010fed..0000000 --- a/src/sessions/validators/require.session.user.mjs +++ /dev/null @@ -1,43 +0,0 @@ -/* eslint-disable */ -import { UserRepository } from '../../users/user.repository.mjs' -/* eslint-enable */ -import { requireParam } from '../../assertions.mjs' -import { InvalidSessionError } from './require.session.mjs' -import { ExtractMapperValidator } from '../../validators/extract.mapper.validator.mjs' -import { userRepository } from '../../users/users.mjs' -import { asSingletonFactory } from '../../utils.mjs' - -export class SessionUserIdValidator extends ExtractMapperValidator { - /** - * Construct validator. - * @param {object} options Options - * @param {UserRepository} options.userRepository User repository - */ - constructor (options) { - requireParam(options.userRepository) - - super({ - extractor: (_b, _h, context) => context.session.userId, - mapper: id => options.userRepository.find(id), - writer: (context, user) => { context.user = user }, - thrower: () => { - throw new InvalidSessionError('No user found for session!') - } - }) - } -} - -/** -* Extract the user from session into context. -* -* Needs `session` to be in context, by calling `requireSession` first. -* -* Saves user in `context.user` -* -* @returns {ReadHandler} -*/ -export const requireSessionUser = asSingletonFactory(() => - new SessionUserIdValidator({ - userRepository - }).asFunction() -) diff --git a/src/users/user.mjs b/src/users/user.mjs deleted file mode 100644 index ba3acdd..0000000 --- a/src/users/user.mjs +++ /dev/null @@ -1,25 +0,0 @@ -/** -* Class representing a User. -* @extends {DataObject} -*/ -export class User { - /** - * User's unique id - * @type {string} - */ - id - - /** - * User's nickname - * @type {string} - */ - name - - /** - * Construct instance. - * @param {User} [options] Options - */ - constructor (options) { - options && Object.assign(this, options) - } -} diff --git a/src/users/user.repository.mjs b/src/users/user.repository.mjs deleted file mode 100644 index a64e9b9..0000000 --- a/src/users/user.repository.mjs +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable */ -import { User } from './user.mjs' -/* eslint-enable */ -import { Repository } from '../repository.mjs' - -/** -* Repository managing all known users. -* @extends {Repository} -*/ -export class UserRepository extends Repository { } diff --git a/src/users/users.mjs b/src/users/users.mjs deleted file mode 100644 index e085bc9..0000000 --- a/src/users/users.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import { UserRepository } from './user.repository.mjs' - -export const userRepository = new UserRepository() diff --git a/src/validators/extract.mapper.validator.mjs b/src/validators/extract.mapper.validator.mjs deleted file mode 100644 index 8a22f3b..0000000 --- a/src/validators/extract.mapper.validator.mjs +++ /dev/null @@ -1,44 +0,0 @@ -/* eslint-disable */ -import { MessageHeader } from '@elementbound/nlon' -/* eslint-enable */ -import { requireParam } from '../assertions.mjs' -import { Validator } from './validator.mjs' - -/** -* Configurable validator for extract-map validators. -* -* An extract-map validator does exactly what it implies: extracts some data, -* then maps that. If the extraction or mapping fails, an exception is thrown. -* -* @template T, U -*/ -export class ExtractMapperValidator extends Validator { - #extractor - #mapper - #writer - #thrower - - /** - * Construct validator. - * @param {object} options Options - * @param {function(any,MessageHeader,object): T} options.extractor Extractor - * @param {function(T): U} options.mapper Mapper - * @param {function(object, U)} options.writer Writer - * @param {function(T,U)} options.thrower Thrower - */ - constructor (options) { - super() - this.#extractor = requireParam(options.extractor) - this.#mapper = requireParam(options.mapper) - this.#writer = requireParam(options.writer) - this.#thrower = requireParam(options.thrower) - } - - validate (body, header, context) { - const extract = this.#extractor(body, header, context) ?? - this.#thrower(undefined, undefined) - const mapped = this.#mapper(extract) ?? - this.#thrower(extract, undefined) - this.#writer(context, mapped) - } -} diff --git a/src/validators/require.body.mjs b/src/validators/require.body.mjs deleted file mode 100644 index c6fbf58..0000000 --- a/src/validators/require.body.mjs +++ /dev/null @@ -1,21 +0,0 @@ -import { Correspondence } from '@elementbound/nlon' -import { asSingletonFactory } from '../utils.mjs' -import { Validator } from './validator.mjs' - -export class MissingBodyError extends Error { } - -export class BodyPresenceValidator extends Validator { - validate (body, _header, _context) { - if (body === undefined || body === null || body === Correspondence.End) { - throw new MissingBodyError('Missing message body!') - } - } -} - -/** -* Validate that there is a message body present. -* @returns {ReadHandler} -*/ -export const requireBody = asSingletonFactory(() => - new BodyPresenceValidator().asFunction() -) diff --git a/src/validators/require.header.mjs b/src/validators/require.header.mjs deleted file mode 100644 index 073903b..0000000 --- a/src/validators/require.header.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import { requireParam } from '../assertions.mjs' -import { asSingletonFactory } from '../utils.mjs' -import { Validator } from './validator.mjs' - -export class MissingHeaderError extends Error { } - -export class HeaderValidator extends Validator { - #header - - /** - * Construct validator - * @param {string} header Header - */ - constructor (header) { - super() - this.#header = requireParam(header) - } - - validate (_body, header, _context) { - if (header[this.#header] === undefined) { - // TODO: Throw standardized nlon error? - throw (this.#header === 'authorization') - ? new MissingHeaderError('Missing authorization header') - : new MissingHeaderError(`Missing header: ${this.#header}`) - } - } -} - -/** -* Validate that header is present. -* @param {string} name Header name -* @returns {ReadHandler} -*/ -export function requireHeader (name) { - return new HeaderValidator(name).asFunction() -} - -/** -* Validate that an authorization header is present. -* @returns {ReadHandler} -*/ -export const requireAuthorization = asSingletonFactory(() => - new HeaderValidator('authorization').asFunction() -) diff --git a/src/validators/require.schema.mjs b/src/validators/require.schema.mjs deleted file mode 100644 index b30dda0..0000000 --- a/src/validators/require.schema.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import { ajv } from '../ajv.mjs' -import { requireParam } from '../assertions.mjs' -import { Validator } from './validator.mjs' - -export class SchemaValidationError extends Error { } - -export class SchemaValidator extends Validator { - #ajv - #schema - - /** - * Construct validator - * @param {object} options Options - * @param {ajv} options.ajv ajv - * @param {string} options.schema Schema name - */ - constructor (options) { - super() - this.#ajv = requireParam(options.ajv) - this.#schema = requireParam(options.schema) - } - - validate (body, _header, _context) { - if (!this.#ajv.validate(this.#schema, body)) { - throw new SchemaValidationError('Body does not match schema!') - } - } -} - -/** -* Validates that the message body fits the given schema. -* @param {string} schema Schema name -* @returns {ReadHandler} -*/ -export function requireSchema (schema) { - return new SchemaValidator({ ajv, schema }).asFunction() -} diff --git a/src/validators/validator.mjs b/src/validators/validator.mjs deleted file mode 100644 index 6111eb9..0000000 --- a/src/validators/validator.mjs +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable */ -import { MessageHeader } from '@elementbound/nlon' -/* eslint-enable */ - -/** -* Base class for validators. -*/ -export class Validator { - /** - * Validate message. - * @param {any} body Message body - * @param {MessageHeader} header Message header - * @param {object} context Message context - */ - validate (body, header, context) { - // validate - } - - asFunction () { - return this.validate.bind(this) - } -} diff --git a/test/e2e/connection.test.mjs b/test/e2e/connection.test.mjs deleted file mode 100644 index be45d44..0000000 --- a/test/e2e/connection.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, after, before } from 'node:test' -import { End2EndContext } from './context.mjs' -import { config } from '../../src/config.mjs' -import { requireSchema } from '../../src/validators/require.schema.mjs' -import { sleep } from '../../src/utils.mjs' - -describe('Connections', { concurrency: false }, () => { - const context = new End2EndContext() - - before(async () => { - config.games = 'test001 Test' - await context.startup() - }) - - it('should send connection diagnostics on join', { skip: true }, async () => { - // Given - context.log.info('Creating clients') - const host = context.connect() - const client = context.connect() - - context.log.info('Logging in clients') - await host.session.login('host', 'test001') - await client.session.login('client', 'test001') - - context.log.info('Creating lobby') - const lobby = await host.lobbies.create('test') - - // When - context.log.info('Joining lobby with client') - await client.lobbies.join(lobby) - - // Then - context.log.info('Waiting for incoming requests') - await sleep(config.connectionDiagnostics.timeout + 0.1) - ;(await host.peer.receive()).next(requireSchema('connection/handshake/request')) - ;(await client.peer.receive()).next(requireSchema('connection/handshake/request')) - }) - - after(() => context.shutdown()) -}) diff --git a/test/e2e/context.mjs b/test/e2e/context.mjs index 5f95b95..be5ee79 100644 --- a/test/e2e/context.mjs +++ b/test/e2e/context.mjs @@ -1,14 +1,8 @@ -import { createSocketPeer } from '@elementbound/nlon-socket' import logger from '../../src/logger.mjs' -import { config } from '../../src/config.mjs' -import { NattyClient } from '../../src/natty.client.mjs' import { Natty } from '../../src/natty.mjs' import { promiseEvent } from '../../src/utils.mjs' export class End2EndContext { - /** @type {NattyClient[]} */ - clients = [] - /** @type {Natty} */ natty @@ -26,30 +20,7 @@ export class End2EndContext { this.log.info('Startup done, ready for testing') } - /** - * Create a client and connect it to Natty. - * @returns {NattyClient} - */ - connect () { - this.log.info('Creating client') - const peer = createSocketPeer({ - host: 'localhost', - port: config.socket.port - }) - - const client = new NattyClient(peer) - this.clients.push(client) - - return client - } - shutdown () { - this.log.info('Disconnecting peer streams') - this.clients.forEach(client => client.peer.stream.destroy()) - - this.log.info('Shutting down peers') - this.clients.forEach(client => client.peer.disconnect()) - this.log.info('Terminating Natty') this.natty.shutdown() } diff --git a/test/e2e/lobbies.test.mjs b/test/e2e/lobbies.test.mjs deleted file mode 100644 index 821c19d..0000000 --- a/test/e2e/lobbies.test.mjs +++ /dev/null @@ -1,172 +0,0 @@ -/* eslint-disable */ -import { NattyClient } from '../../src/natty.client.mjs' -/* eslint-enable */ - -import { describe, it, after, before } from 'node:test' -import assert from 'node:assert' -import { End2EndContext } from './context.mjs' -import { config } from '../../src/config.mjs' - -describe('Lobbies', { concurrency: false }, async () => { - const context = new End2EndContext() - - /** @type {NattyClient} */ - let client - - /** @type {NattyClient} */ - let unauthClient - - /** @type {string} */ - let lobbyId - - before(async () => { - config.games = 'test001 Test' - await context.startup() - - context.log.info('Creating session') - client = context.connect() - await client.session.login('foo', 'test001') - - unauthClient = context.connect() - }) - - describe('create', () => { - it('should reject without auth', () => { - assert.rejects(() => unauthClient.lobbies.create('Test lobby')) - }) - - it('should create lobby', async () => { - context.log.info('Creating lobby') - lobbyId = await client.lobbies.create('Test lobby') - assert(lobbyId) - }) - }) - - describe('join', () => { - before(async () => { - // Create own client that has no lobby - client = context.connect() - await client.session.login('bar', 'test001') - }) - - it('should reject without auth', () => { - assert.rejects(() => - unauthClient.lobbies.join(lobbyId) - ) - }) - - it('should reject invalid lobby', () => { - assert.rejects(() => - client.lobbies.join('invalid-id') - ) - }) - - it('should join lobby', () => { - assert.doesNotReject(() => - client.lobbies.join(lobbyId) - ) - }) - - it('should reject if already in lobby', () => { - // We're in a lobby now, so the same join call should reject - assert.rejects(() => - client.lobbies.join(lobbyId) - ) - }) - }) - - describe('leave', () => { - before(async () => { - // Create own client that has no lobby - client = context.connect() - await client.session.login('bar', 'test001') - - // Join to previously created lobby so we can leave - await client.lobbies.join(lobbyId) - }) - - it('should reject without auth', () => { - assert.rejects(() => - unauthClient.lobbies.leave() - ) - }) - - it('should leave lobby', () => { - assert.doesNotReject(() => - client.lobbies.leave() - ) - }) - - it('should reject if not in any lobby', () => { - // Already left the lobby, should reject - assert.rejects(() => - client.lobbies.leave() - ) - }) - }) - - describe('delete', () => { - before(async () => { - // Create own client that has no lobby - client = context.connect() - await client.session.login('quix', 'test001') - }) - - it('should reject without auth', () => { - assert.rejects( - () => unauthClient.lobbies.delete(lobbyId) - ) - }) - - it('should reject if not own lobby', () => { - assert.rejects( - () => client.lobbies.delete(lobbyId) - ) - }) - - it('should delete lobby', async () => { - const ownLobbyId = await client.lobbies.create('test002') - - assert.doesNotReject( - () => client.lobbies.delete(ownLobbyId) - ) - }) - }) - - describe('list', () => { - it('should reject without auth', async () => { - assert.rejects( - () => unauthClient.lobbies.list().next() - ) - }) - - it('should list lobbies', async () => { - // Given - const lobbies = [] - - // When - for await (const lobby of client.lobbies.list()) { - lobbies.push(lobby) - } - - // Then - assert(lobbies.length) - }) - - it('should not list private lobbies', async () => { - // Given - const privateLobby = await client.lobbies.create('Private Lobby', false) - const listedLobbies = [] - - // When - for await (const lobby of client.lobbies.list()) { - listedLobbies.push(lobby) - } - - // Then - assert(!listedLobbies.includes(privateLobby), 'Private lobby is in the list!') - }) - }) - - after(() => context.shutdown()) -}) diff --git a/test/e2e/session.test.mjs b/test/e2e/session.test.mjs deleted file mode 100644 index c44bb78..0000000 --- a/test/e2e/session.test.mjs +++ /dev/null @@ -1,76 +0,0 @@ -import { createSocketPeer } from '@elementbound/nlon-socket' -import { describe, it, after, before } from 'node:test' -import { ok, rejects, throws } from 'node:assert' -import logger from '../../src/logger.mjs' -import { NattyClient } from '../../src/natty.client.mjs' -import { Natty } from '../../src/natty.mjs' -import { config } from '../../src/config.mjs' -import { promiseEvent, sleep } from '../../src/utils.mjs' -import { GameData } from '../../src/games/game.data.mjs' -import { End2EndContext } from './context.mjs' - -describe('Sessions', { concurrency: false }, async () => { - const log = logger.child({ name: 'test' }) - const context = new End2EndContext() - - /** @type {NattyClient} */ - let client - - /** @type {Natty} */ - let natty - - const game = new GameData({ - id: 'test001', - name: 'Test' - }) - - before(async () => { - log.info('Starting app') - config.session.timeout = 0.050 - config.session.cleanupInterval = 0.010 - config.games = `${game.id} ${game.name}` - - await context.startup() - - natty = context.natty - client = context.connect() - }) - - it('should start new session', async () => { - // Given - const username = 'foo' - - // When - const session = await client.session.login(username, game.id) - - // Then - ok(session) - }) - - it('should logout', async () => { - await client.session.logout() - }) - - it('should reject logout without auth', async () => { - rejects(() => client.session.logout()) - }) - - it('should cleanup session', async () => { - await client.session.login('foo', game.id) - - await sleep( - config.session.timeout + - config.session.cleanupInterval - ) - - rejects(() => client.session.logout()) - }) - - after(() => { - context.shutdown() - - // Reset session cleanup settings - config.session.timeout = 300 - config.session.cleanupInterval = 300 - }) -}) diff --git a/test/e2e/udp.relay.test.mjs b/test/e2e/udp.relay.test.mjs index cba2d85..c245d74 100644 --- a/test/e2e/udp.relay.test.mjs +++ b/test/e2e/udp.relay.test.mjs @@ -8,7 +8,7 @@ import { NetAddress } from '../../src/relay/net.address.mjs' import { sleep } from '../../src/utils.mjs' import { UDPSocketPool } from '../../src/relay/udp.socket.pool.mjs' -describe('UDP Relay', { concurrency: false }, async () => { +describe('UDP Relay', { concurrency: false, skip: true }, async () => { const context = new End2EndContext() /** @type {dgram.Socket} */ diff --git a/test/mocking.mjs b/test/mocking.mjs deleted file mode 100644 index b9d53bd..0000000 --- a/test/mocking.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import { mock } from 'node:test' - -/** -* Extract method names from class string. -* @param {string} str -* @returns {string[]} Potential method names -*/ -function extractMethods (str) { - const pattern = /^\s*([\w\d$_]+)\s*\([\w\d$_,\s]*\)\s*\{/mg - - return [...str.matchAll(pattern)] - .map(match => match[1]) - .filter(method => method !== 'constructor') -} - -/** -* Create a mock instance of a class without instantiating the class itself. -* @param {Function...} classes Classes -* @returns {object} Object with mocked methods -*/ -export function mockClass (...classes) { - const methods = classes.flatMap(c => extractMethods(c.toString())) - - return Object.fromEntries( - methods.map(name => ([name, mock.fn(() => {})])) - ) -} diff --git a/test/spec/connection/connection.attempt.processor.test.mjs b/test/spec/connection/connection.attempt.processor.test.mjs deleted file mode 100644 index 7042655..0000000 --- a/test/spec/connection/connection.attempt.processor.test.mjs +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert' -import sinon from 'sinon' -import { ConnectionAttempt, ConnectionAttemptState } from '../../../src/connection/connection.attempt.mjs' -import { Correspondence, Peer } from '@elementbound/nlon' -import { processConnectionAttempt } from '../../../src/connection/connection.attempt.processor.mjs' - -describe('processConnectionAttempt', () => { - it('should return success', async () => { - // Given - const connectingReport = { - success: true, - target: { - address: '0.0.0.1', - port: 1 - } - } - - const hostingReport = { - success: true, - target: { - address: '0.0.0.2', - port: 2 - } - } - - const connectingCorr = sinon.createStubInstance(Correspondence) - const hostingCorr = sinon.createStubInstance(Correspondence) - - const connectingPeer = sinon.createStubInstance(Peer) - const hostingPeer = sinon.createStubInstance(Peer) - - const connectionAttempt = new ConnectionAttempt({ - connectingPeer, - hostingPeer - }) - - connectingCorr.next.resolves(connectingReport) - hostingCorr.next.resolves(hostingReport) - connectingPeer.send.returns(connectingCorr) - hostingPeer.send.returns(hostingCorr) - sinon.stub(connectingPeer, 'stream').value({ remoteAddress: '0.0.0.2', remotePort: 2 }) - sinon.stub(hostingPeer, 'stream').value({ remoteAddress: '0.0.0.1', remotePort: 1 }) - - // When - const result = await processConnectionAttempt(connectionAttempt) - - // Then - assert(result) - assert.equal(connectionAttempt.state, ConnectionAttemptState.Done) - assert.equal(connectionAttempt.isSuccess, true) - }) - - it('should return failure', async () => { - // Given - const connectingReport = { - success: false, - target: { - address: '0.0.0.1', - port: 1 - } - } - - const hostingReport = { - success: true, - target: { - address: '0.0.0.2', - port: 2 - } - } - - const connectingCorr = sinon.createStubInstance(Correspondence) - const hostingCorr = sinon.createStubInstance(Correspondence) - - const connectingPeer = sinon.createStubInstance(Peer) - const hostingPeer = sinon.createStubInstance(Peer) - - const connectionAttempt = new ConnectionAttempt({ - connectingPeer, - hostingPeer - }) - - connectingCorr.next.resolves(connectingReport) - hostingCorr.next.resolves(hostingReport) - connectingPeer.send.returns(connectingCorr) - hostingPeer.send.returns(hostingCorr) - sinon.stub(connectingPeer, 'stream').value({ remoteAddress: '0.0.0.2', remotePort: 2 }) - sinon.stub(hostingPeer, 'stream').value({ remoteAddress: '0.0.0.1', remotePort: 1 }) - - // When - const result = await processConnectionAttempt(connectionAttempt) - - // Then - assert(!result) - assert.equal(connectionAttempt.state, ConnectionAttemptState.Done) - assert.equal(connectionAttempt.isSuccess, false) - }) - it('should throw if report fails', () => { - // Given - const connectingReport = { - success: false, - target: { - address: '0.0.0.1', - port: 1 - } - } - - const connectingCorr = sinon.createStubInstance(Correspondence) - const hostingCorr = sinon.createStubInstance(Correspondence) - - const connectingPeer = sinon.createStubInstance(Peer) - const hostingPeer = sinon.createStubInstance(Peer) - - const connectionAttempt = new ConnectionAttempt({ - connectingPeer, - hostingPeer - }) - - const expected = new Error('Report failed!') - - connectingCorr.next.resolves(connectingReport) - hostingCorr.next.throws(expected) - connectingPeer.send.returns(connectingCorr) - hostingPeer.send.returns(hostingCorr) - sinon.stub(connectingPeer, 'stream').value({ remoteAddress: '0.0.0.2', remotePort: 2 }) - sinon.stub(hostingPeer, 'stream').value({ remoteAddress: '0.0.0.1', remotePort: 1 }) - - // When + Then - assert.rejects( - () => processConnectionAttempt(connectionAttempt), - expected - ) - assert.equal(connectionAttempt.state, ConnectionAttemptState.Done) - assert.equal(connectionAttempt.isSuccess, false) - }) -}) diff --git a/test/spec/connection/connection.attempt.queue.test.mjs b/test/spec/connection/connection.attempt.queue.test.mjs deleted file mode 100644 index c446e08..0000000 --- a/test/spec/connection/connection.attempt.queue.test.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, beforeEach } from 'node:test' -import assert from 'node:assert' -import sinon from 'sinon' -import { ConnectionAttemptQueue } from '../../../src/connection/connection.attempt.queue.mjs' -import { ConnectionAttempt, ConnectionAttemptState } from '../../../src/connection/connection.attempt.mjs' -import { Peer } from '@elementbound/nlon' -import { sleep } from '../../../src/utils.mjs' - -describe('ConnectionAttemptQueue', () => { - let queue = new ConnectionAttemptQueue() - let processor = sinon.stub() - let connectingPeer = sinon.createStubInstance(Peer) - let hostingPeer = sinon.createStubInstance(Peer) - - describe('enqueue', () => { - beforeEach(() => { - processor = sinon.stub() - queue = new ConnectionAttemptQueue(processor) - connectingPeer = sinon.createStubInstance(Peer) - hostingPeer = sinon.createStubInstance(Peer) - - sinon.stub(connectingPeer, 'id').value('p001') - sinon.stub(hostingPeer, 'id').value('p002') - }) - - it('should run attempt', () => { - // Given - processor.resolves(true) - const attempt = new ConnectionAttempt({ connectingPeer, hostingPeer}) - - // When - queue.enqueue(attempt, 60) - - // Then - processor.calledOnceWith(attempt) - }) - - it('should fail attempt on timeout', async () => { - // Given - processor.returns(sleep(0.05)) - const attempt = new ConnectionAttempt({ connectingPeer, hostingPeer}) - - // When - queue.enqueue(attempt, 0.01) - - // Then - await sleep(0.02) - processor.calledOnceWith(attempt) - assert.equal(attempt.state, ConnectionAttemptState.Done) - assert.equal(attempt.isSuccess, false) - }) - }) -}) diff --git a/test/spec/games/games.test.mjs b/test/spec/games/games.test.mjs deleted file mode 100644 index 348e2b6..0000000 --- a/test/spec/games/games.test.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it } from 'node:test' -import { deepEqual } from 'node:assert' -import { GameData } from '../../../src/games/game.data.mjs' -import { parseGamesConfig } from '../../../src/games/games.mjs' - -describe('parseGamesConfig', () => { - it('should return expected', () => { - // Given - const text = [ - '', // Empty line => ignore - ' ', // Pure spaces => ignore - 'id0 ', // Missing game name => ignore - 'id0 foo ', // Correct format => parse - 'id0 with space ' // Name with spaces => parse - ].join('\n') - - const expected = [ - new GameData({ id: 'id0', name: 'foo' }), - new GameData({ id: 'id0', name: 'with space' }) - ] - - // When - const actual = parseGamesConfig(text) - - // Then - deepEqual(actual, expected) - }) -}) diff --git a/test/spec/lobbies/lobby.data.test.mjs b/test/spec/lobbies/lobby.data.test.mjs deleted file mode 100644 index 3339f3e..0000000 --- a/test/spec/lobbies/lobby.data.test.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import assert from 'node:assert' -import { describe, it } from 'node:test' -import { LobbyData, LobbyState } from '../../../src/lobbies/lobby.data.mjs' - -describe('LobbyData', () => { - it('should reject invalid state', () => { - assert.throws( - () => new LobbyData({ state: '@$invalid$@' }) - ) - }) - - it('should be unlocked if gathering', () => { - // Given - const lobby = new LobbyData({ - state: LobbyState.Gathering - }) - - // When + Then - assert.equal(lobby.isLocked, false) - }) - - it('should be locked if starting', () => { - // Given - const lobby = new LobbyData({ - state: LobbyState.Starting - }) - - // When + Then - assert.equal(lobby.isLocked, true) - }) - - it('should be locked if active', () => { - // Given - const lobby = new LobbyData({ - state: LobbyState.Active - }) - - // When + Then - assert.equal(lobby.isLocked, true) - }) -}) diff --git a/test/spec/lobbies/lobby.service.test.mjs b/test/spec/lobbies/lobby.service.test.mjs deleted file mode 100644 index b45e289..0000000 --- a/test/spec/lobbies/lobby.service.test.mjs +++ /dev/null @@ -1,466 +0,0 @@ -import { beforeEach, describe, it, mock } from 'node:test' -import assert from 'node:assert' -import { LobbyOwnerError, LobbyService } from '../../../src/lobbies/lobby.service.mjs' -import { mockClass } from '../../mocking.mjs' -import { LobbyRepository } from '../../../src/lobbies/lobby.repository.mjs' -import { LobbyParticipantRepository } from '../../../src/lobbies/lobby.participant.repository.mjs' -import { NotificationService } from '../../../src/notifications/notification.service.mjs' -import { User } from '../../../src/users/user.mjs' -import { Repository } from '../../../src/repository.mjs' -import { GameData } from '../../../src/games/game.data.mjs' -import { LobbyData, LobbyState } from '../../../src/lobbies/lobby.data.mjs' - -describe('LobbyService', () => { - /** @type {LobbyRepository} */ - let lobbyRepository - /** @type {LobbyParticipantRepository} */ - let participantRepository - /** @type {NotificationService} */ - let notificationService - - /** @type {LobbyService} */ - let lobbyService - - function setup () { - lobbyRepository = mockClass(LobbyRepository, Repository) - participantRepository = mockClass(LobbyParticipantRepository, Repository) - notificationService = mockClass(NotificationService) - - participantRepository.getLobbiesOf = mock.fn(() => []) - participantRepository.add = mock.fn(item => item) - lobbyRepository.add = mock.fn(item => item) - notificationService.send = mock.fn(() => []) - - lobbyService = new LobbyService({ - nameConfig: { - minNameLength: 4, - maxNameLength: 16 - }, - lobbyRepository, - participantRepository, - notificationService - }) - } - - describe('create', () => { - beforeEach(setup) - - it('should create lobby', () => { - // Given - const lobbyName = 'test' - const ownerUser = new User({ id: 'a', name: 'b' }) - const game = new GameData({ id: 'foo', name: 'Foo game' }) - const handler = mock.fn(() => {}) - lobbyService.on('create', handler) - - // When - const actual = lobbyService.create(lobbyName, ownerUser, game) - - // Then - assert(actual) - assert.equal(actual.game, game.id) - assert.equal(actual.name, lobbyName) - assert.equal(actual.owner, ownerUser.id) - - assert.equal(handler.mock.callCount(), 1) - assert.deepEqual(handler.mock.calls[0].arguments, [actual]) - }) - - it('should add owner to new lobby', () => { - // Given - const lobbyName = 'test' - const ownerUser = new User({ id: 'a', name: 'b' }) - const game = new GameData({ id: 'foo', name: 'Foo game' }) - - participantRepository.getParticipantsOf = mock.fn(lobby => [ownerUser.id]) - - // When - lobbyService.create(lobbyName, ownerUser, game) - - // Then - assert.equal(notificationService.send.mock.callCount(), 1) - assert.deepEqual( - notificationService.send.mock.calls[0].arguments[0].userIds, - [ownerUser.id] - ) - }) - - it('should reject short name', () => { - // Given - const lobbyName = '.' - const ownerUser = new User({ id: 'a', name: 'b' }) - const game = new GameData({ id: 'foo', name: 'Foo game' }) - - // When + then - assert.throws( - () => lobbyService.create(lobbyName, ownerUser, game), - e => e.message === 'Lobby name too short!' - ) - }) - - it('should reject long name', () => { - // Given - const lobbyName = '.'.repeat(128) - const ownerUser = new User({ id: 'a', name: 'b' }) - const game = new GameData({ id: 'foo', name: 'Foo game' }) - - // When + then - assert.throws( - () => lobbyService.create(lobbyName, ownerUser, game), - e => e.message === 'Lobby name too long!' - ) - }) - - it('should reject when already in lobby', () => { - // Given - const lobbyName = 'test' - const ownerUser = new User({ id: 'usr-reject', name: 'b' }) - const game = new GameData({ id: 'foo', name: 'Foo game' }) - const currentLobby = new LobbyData({ - id: '0xtest', - game: game.id, - name: 'Current lobby', - owner: ownerUser.id - }) - - participantRepository.getLobbiesOf = mock.fn(() => [currentLobby.id]) - lobbyRepository.find = mock.fn(() => currentLobby) - - // When + then - assert.throws( - () => lobbyService.create(lobbyName, ownerUser, game), - e => e.message === 'User is already in a lobby!' - ) - }) - }) - - describe('join', () => { - beforeEach(setup) - - it('should join lobby', () => { - // Given - const owner = new User({ - id: 'usr-owner', - name: 'Owner user' - }) - - const user = new User({ - id: 'usr-join', - name: 'Joining user' - }) - - const lobby = new LobbyData({ - id: 'l001', - game: 'g001', - name: 'Target lobby', - owner: owner.id - }) - - const handler = mock.fn(() => {}) - lobbyService.on('join', handler) - - participantRepository.getParticipantsOf = mock.fn( - () => [owner.id, user.id] - ) - - // When - lobbyService.join(user, lobby) - - // Then - assert.equal(notificationService.send.mock.callCount(), 1) - assert.deepEqual( - notificationService.send.mock.calls[0].arguments[0].userIds, - [owner.id, user.id] - ) - assert.equal(handler.mock.callCount(), 1) - assert.deepEqual(handler.mock.calls[0].arguments, [lobby, user]) - }) - - it('should reject if lobby is locked', () => { - // Given - const user = new User({ - id: 'usr-join', - name: 'Joining user' - }) - - const lobby = new LobbyData({ - id: 'l001', - game: 'g001', - name: 'Target lobby', - owner: 'usr-owner', - state: LobbyState.Active - }) - - participantRepository.getLobbiesOf = mock.fn(() => []) - - // When + then - assert.throws( - () => lobbyService.join(user, lobby), - e => e.message === 'Lobby is locked!' - ) - }) - - it('should reject if already in lobby', () => { - // Given - const owner = new User({ - id: 'usr-owner', - name: 'Owner user' - }) - - const user = new User({ - id: 'usr-join', - name: 'Joining user' - }) - - const currentLobby = new LobbyData({ - id: 'l002', - game: 'g001', - name: 'Current lobby', - owner: user.id - }) - - const lobby = new LobbyData({ - id: 'l001', - game: 'g001', - name: 'Target lobby', - owner: owner.id - }) - - participantRepository.getLobbiesOf = mock.fn(() => [currentLobby.id]) - lobbyRepository.find = mock.fn(() => currentLobby) - - // When + then - assert.throws( - () => lobbyService.join(user, lobby), - e => e.message === 'User is already in a lobby!' - ) - }) - }) - - describe('leave', () => { - beforeEach(setup) - - it('should remove user', () => { - // Given - const user = new User({ - id: 'usr-leave', - name: 'Leaving user' - }) - - const lobby = new LobbyData({ - id: 'l001', - game: 'g001', - name: 'Target lobby', - owner: 'usr-owner' - }) - - const handler = mock.fn(() => {}) - lobbyService.on('leave', handler) - - participantRepository.isParticipantOf = mock.fn(() => true) - participantRepository.getParticipantsOf = () => ['usr01', 'usr02'] - - // When - lobbyService.leave(user, lobby) - - // Then - assert.equal( - participantRepository.removeParticipantFrom.mock.callCount(), - 1 - ) - assert.deepEqual( - participantRepository.removeParticipantFrom.mock.calls[0].arguments, - [user.id, lobby.id] - ) - - assert.equal(notificationService.send.mock.callCount(), 1) - assert.deepEqual( - notificationService.send.mock.calls[0].arguments[0].userIds, - [user.id, 'usr01', 'usr02'] - ) - assert.equal(handler.mock.callCount(), 1) - assert.deepEqual(handler.mock.calls[0].arguments, [lobby, user]) - }) - - it('should do nothing if not in lobby', () => { - // Given - const user = new User({ - id: 'usr-leave', - name: 'Leaving user' - }) - - const lobby = new LobbyData({ - id: 'l001', - game: 'g001', - name: 'Target lobby', - owner: 'usr-owner' - }) - - participantRepository.isParticipantOf = mock.fn(() => false) - - // When - lobbyService.leave(user, lobby) - - // Then - assert.equal( - participantRepository.removeParticipantFrom.mock.callCount(), - 0 - ) - assert.equal(notificationService.send.mock.callCount(), 0) - }) - - it('should reject if owner is leaving', () => { - // Given - const user = new User({ - id: 'usr-leave', - name: 'Leaving user' - }) - - const lobby = new LobbyData({ - id: 'l001', - game: 'g001', - name: 'Target lobby', - owner: user.id - }) - - participantRepository.isParticipantOf = mock.fn(() => true) - - // When - assert.throws( - () => lobbyService.leave(user, lobby), - LobbyOwnerError - ) - }) - }) - - describe('delete', () => { - beforeEach(setup) - - it('should delete lobby', () => { - // Given - const lobby = new LobbyData({ - id: 'l001', - game: 'g001', - name: 'Target lobby', - owner: 'usr002' - }) - - participantRepository.getParticipantsOf = mock.fn( - () => ['usr002', 'usr003'] - ) - - notificationService.send = mock.fn(() => []) - - const handler = mock.fn(() => {}) - lobbyService.on('delete', handler) - - // When - lobbyService.delete(lobby) - - // Then - assert.equal( - participantRepository.getParticipantsOf.mock.callCount(), - 1 - ) - assert.deepEqual( - participantRepository.getParticipantsOf.mock.calls[0].arguments, - [lobby.id] - ) - - assert.equal( - participantRepository.deleteLobby.mock.callCount(), - 1 - ) - assert.deepEqual( - participantRepository.deleteLobby.mock.calls[0].arguments, - [lobby.id] - ) - - assert.equal(lobbyRepository.remove.mock.callCount(), 1) - assert.deepEqual( - lobbyRepository.remove.mock.calls[0].arguments, - [lobby.id] - ) - - assert.equal(notificationService.send.mock.callCount(), 1) - assert.deepEqual( - notificationService.send.mock.calls[0].arguments[0].userIds, - ['usr002', 'usr003'] - ) - - assert.equal(handler.mock.callCount(), 1) - assert.deepEqual(handler.mock.calls[0].arguments, [lobby]) - }) - }) - - describe('list', () => { - beforeEach(setup) - - it('should list all public lobbies', () => { - // Given - const game = new GameData({ - id: 'g001', - name: 'Test game' - }) - - const lobbies = [ - new LobbyData({ - id: 'l001', - isPublic: true - }), - new LobbyData({ - id: 'l002', - isPublic: true - }), - new LobbyData({ - id: 'l003', - isPublic: false - }) - ] - - lobbyRepository.listByGame = mock.fn(() => lobbies) - - const expected = lobbies.slice(0, 2) - - // When - const actual = lobbyService.list(game) - - // Then - assert.deepEqual(actual, expected) - }) - - it('should not list active lobbies', () => { - // Given - const game = new GameData({ - id: 'g001', - name: 'Test game' - }) - - const lobbies = [ - new LobbyData({ - id: 'l001', - isPublic: true - }), - new LobbyData({ - id: 'l002', - isPublic: true - }), - new LobbyData({ - id: 'l003', - isPublic: true, - state: LobbyState.Active - }) - ] - - lobbyRepository.listByGame = mock.fn(() => lobbies) - - const expected = lobbies.slice(0, 2) - - // When - const actual = lobbyService.list(game) - - // Then - assert.deepEqual(actual, expected) - }) - }) -}) diff --git a/test/spec/relay/udp.remote.registrar.test.mjs b/test/spec/relay/udp.remote.registrar.test.mjs deleted file mode 100644 index 51dadc5..0000000 --- a/test/spec/relay/udp.remote.registrar.test.mjs +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, beforeEach } from 'node:test' -import assert from 'node:assert' -import sinon from 'sinon' -import dgram from 'node:dgram' -import { SessionRepository } from '../../../src/sessions/session.repository.mjs' -import { UDPRelayHandler } from '../../../src/relay/udp.relay.handler.mjs' -import { UDPRemoteRegistrar } from '../../../src/relay/udp.remote.registrar.mjs' -import { SessionData } from '../../../src/sessions/session.data.mjs' -import { NetAddress } from '../../../src/relay/net.address.mjs' -import { RelayEntry } from '../../../src/relay/relay.entry.mjs' - -describe('UDPRemoteRegistrar', () => { - /** @type {sinon.SinonStubbedInstance} */ - let sessionRepository - /** @type {sinon.SinonStubbedInstance} */ - let relayHandler - /** @type {sinon.SinonStubbedInstance} */ - let socket - - /** @type {UDPRemoteRegistrar} */ - let remoteRegistrar - - const session = new SessionData({ - id: 's0001' - }) - - beforeEach(() => { - sessionRepository = sinon.createStubInstance(SessionRepository) - relayHandler = sinon.createStubInstance(UDPRelayHandler) - socket = sinon.createStubInstance(dgram.Socket) - - sessionRepository.find.returns(session) - socket.bind.callsArg(2) // Instantly resolve on bind - - remoteRegistrar = new UDPRemoteRegistrar({ - sessionRepository, udpRelayHandler: relayHandler, socket - }) - }) - - it('should succeed', async () => { - // Given - const msg = Buffer.from(session.id) - const rinfo = { address: '88.57.0.3', port: 32745 } - - await remoteRegistrar.listen() - const messageHandler = socket.on.lastCall.callback - - // When - await messageHandler(msg, rinfo) - - // Then - assert.deepEqual( - relayHandler.createRelay.lastCall.args[0], - new RelayEntry({ address: NetAddress.fromRinfo(rinfo) }) - ) - assert.deepEqual( - socket.send.lastCall?.args, - ['OK', rinfo.port, rinfo.address] - ) - }) - it('should fail on unknown session', async () => { - // Given - const msg = Buffer.from(session.id) - const rinfo = { address: '88.57.0.3', port: 32745 } - - await remoteRegistrar.listen() - const messageHandler = socket.on.lastCall.callback - - sessionRepository.find.returns(undefined) - - // When - await messageHandler(msg, rinfo) - - // Then - assert(relayHandler.createRelay.notCalled, 'A relay was created!') - assert.deepEqual( - socket.send.lastCall?.args, - ['Unknown session id!', rinfo.port, rinfo.address] - ) - }) - - it('should fail throw', async () => { - // Given - const msg = Buffer.from(session.id) - const rinfo = { address: '88.57.0.3', port: 32745 } - - await remoteRegistrar.listen() - const messageHandler = socket.on.lastCall.callback - - relayHandler.createRelay.throws('Error', 'Test') - - // When - await messageHandler(msg, rinfo) - - // Then - assert.deepEqual( - socket.send.lastCall?.args, - ['Test', rinfo.port, rinfo.address] - ) - }) -}) diff --git a/test/spec/sessions/validators/require.session.game.test.mjs b/test/spec/sessions/validators/require.session.game.test.mjs deleted file mode 100644 index f366621..0000000 --- a/test/spec/sessions/validators/require.session.game.test.mjs +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it, beforeEach, mock } from 'node:test' -import assert from 'node:assert' -import { mockClass } from '../../../mocking.mjs' -import { Repository } from '../../../../src/repository.mjs' -import { SessionData } from '../../../../src/sessions/session.data.mjs' -import { GameRepository } from '../../../../src/games/game.repository.mjs' -import { SessionGameIdValidator } from '../../../../src/sessions/validators/require.session.game.mjs' -import { GameData } from '../../../../src/games/game.data.mjs' - -describe('SessionGameIdValidator', () => { - /** @type {GameRepository} */ - let gameRepository - /** @type {SessionGameIdValidator} */ - let validator - - const game = new GameData({ - id: 'g001', - name: 'Test' - }) - - beforeEach(() => { - gameRepository = mockClass(Repository, GameRepository) - gameRepository.find = mock.fn( - id => id === 'g001' ? game : undefined - ) - - validator = new SessionGameIdValidator({ - gameRepository - }) - }) - - it('should extract', () => { - // Given - const session = new SessionData({ - id: 's001', - gameId: 'g001', - userId: 'usr001', - peer: {}, - lastMessage: 8 - }) - - const body = {} - const header = {} - const context = { session } - - // When - validator.validate(body, header, context) - - // Then - assert.deepEqual(context.game, game) - }) - - it('should fail on missing session', () => { - // Given - const body = {} - const header = {} - const context = {} - - // When + Then - assert.throws(() => - validator.validate(body, header, context) - ) - }) - - it('should fail on unknown game', () => { - // Given - const session = new SessionData({ - id: 's001', - gameId: 'unknown', - userId: 'usr001', - peer: {}, - lastMessage: 8 - }) - - const body = {} - const header = {} - const context = { session } - - // When + Then - assert.throws(() => - validator.validate(body, header, context) - ) - }) -}) diff --git a/test/spec/sessions/validators/require.session.test.mjs b/test/spec/sessions/validators/require.session.test.mjs deleted file mode 100644 index 119e747..0000000 --- a/test/spec/sessions/validators/require.session.test.mjs +++ /dev/null @@ -1,81 +0,0 @@ -import { beforeEach, describe, it, mock } from 'node:test' -import assert from 'node:assert' -import { SessionData } from '../../../../src/sessions/session.data.mjs' -import { SessionService } from '../../../../src/sessions/session.service.mjs' -import { InvalidSessionError, SessionPresenceValidator } from '../../../../src/sessions/validators/require.session.mjs' -import { mockClass } from '../../../mocking.mjs' - -describe('SessionPresenceValidator', () => { - /** @type {SessionPresenceValidator} */ - let validator - /** @type {SessionService} */ - let sessionService - - beforeEach(() => { - sessionService = mockClass(SessionService) - validator = new SessionPresenceValidator({ - sessionService - }) - }) - - it('should pass', () => { - // Given - const expected = new SessionData({ - id: 'foo', - gameId: 'g001', - userId: 'usr001', - peer: {}, - lastMessage: 0 - }) - - const body = {} - const header = { - authorization: expected.id - } - const context = {} - - sessionService.validate = mock.fn( - id => id === expected.id - ? expected - : undefined - ) - - // When - validator.validate(body, header, context) - - // Then - assert.deepEqual(context.session, expected) - assert.equal(sessionService.validate.mock.callCount(), 1) - }) - - it('should throw on missing session id', () => { - // Given - const body = {} - const header = {} - const context = {} - - // When + then - assert.throws( - () => validator.validate(body, header, context), - InvalidSessionError - ) - assert(!context.session) - }) - - it('should throw on invalid session id', () => { - // Given - const body = {} - const header = { - authorization: 'invalid' - } - const context = {} - - // When + then - assert.throws( - () => validator.validate(body, header, context), - InvalidSessionError - ) - assert.equal(sessionService.validate.mock.callCount(), 1) - assert(!context.session) - }) -}) diff --git a/test/spec/sessions/validators/require.session.user.test.mjs b/test/spec/sessions/validators/require.session.user.test.mjs deleted file mode 100644 index d3c71f2..0000000 --- a/test/spec/sessions/validators/require.session.user.test.mjs +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it, beforeEach, mock } from 'node:test' -import assert from 'node:assert' -import { mockClass } from '../../../mocking.mjs' -import { Repository } from '../../../../src/repository.mjs' -import { SessionUserIdValidator } from '../../../../src/sessions/validators/require.session.user.mjs' -import { SessionData } from '../../../../src/sessions/session.data.mjs' -import { User } from '../../../../src/users/user.mjs' -import { UserRepository } from '../../../../src/users/user.repository.mjs' - -describe('SessionUserIdValidator', () => { - /** @type {UserRepository} */ - let userRepository - /** @type {SessionUserIdValidator} */ - let validator - - const user = new User({ - id: 'usr001', - name: 'Foo' - }) - - beforeEach(() => { - userRepository = mockClass(Repository, UserRepository) - userRepository.find = mock.fn( - id => id === 'usr001' ? user : undefined - ) - - validator = new SessionUserIdValidator({ - userRepository - }) - }) - - it('should extract', () => { - // Given - const session = new SessionData({ - id: 's001', - gameId: 'g001', - userId: 'usr001', - peer: {}, - lastMessage: 8 - }) - - const body = {} - const header = {} - const context = { session } - - // When - validator.validate(body, header, context) - - // Then - assert.deepEqual(context.user, user) - }) - - it('should fail on missing session', () => { - // Given - const body = {} - const header = {} - const context = {} - - // When + Then - assert.throws(() => - validator.validate(body, header, context) - ) - }) - - it('should fail on unknown user', () => { - // Given - const session = new SessionData({ - id: 's001', - gameId: 'g001', - userId: 'unknown', - peer: {}, - lastMessage: 8 - }) - - const body = {} - const header = {} - const context = { session } - - // When + Then - assert.throws(() => - validator.validate(body, header, context) - ) - }) -}) diff --git a/test/spec/validators/extract.mapper.validator.test.mjs b/test/spec/validators/extract.mapper.validator.test.mjs deleted file mode 100644 index 0e3a079..0000000 --- a/test/spec/validators/extract.mapper.validator.test.mjs +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, it, mock } from 'node:test' -import assert from 'node:assert' -import { ExtractMapperValidator } from '../../../src/validators/extract.mapper.validator.mjs' - -describe('ExtractMapperValidator', () => { - it('should pass', () => { - // Given - const expected = 'value' - - const options = { - extractor: mock.fn(body => body.id), - mapper: mock.fn(() => expected), - writer: mock.fn((context, value) => { context.result = value }), - thrower: mock.fn(() => { throw new Error() }) - } - - const validator = new ExtractMapperValidator(options) - - const body = { id: 2 } - const header = {} - const context = {} - - // When - validator.validate(body, header, context) - - // Then - assert.equal(context.result, expected) - - assert.equal(options.extractor.mock.callCount(), 1) - assert.deepEqual( - options.extractor.mock.calls[0].arguments, - [body, header, context] - ) - - assert.equal(options.mapper.mock.callCount(), 1) - assert.deepEqual( - options.mapper.mock.calls[0].arguments, - [body.id] - ) - - assert.equal(options.writer.mock.callCount(), 1) - assert.deepEqual( - options.writer.mock.calls[0].arguments, - [context, 'value'] - ) - - assert.equal(options.thrower.mock.callCount(), 0) - }) - - it('should throw on failed extract', () => { - // Given - const options = { - extractor: mock.fn(body => undefined), - mapper: mock.fn(() => {}), - writer: mock.fn((context, value) => {}), - thrower: mock.fn(() => { throw new Error() }) - } - - const validator = new ExtractMapperValidator(options) - - const body = { id: 2 } - const header = {} - const context = {} - - // When + Then - assert.throws(() => - validator.validate(body, header, context) - ) - - assert.equal(context.result, undefined) - - assert.equal(options.extractor.mock.callCount(), 1) - assert.deepEqual( - options.extractor.mock.calls[0].arguments, - [body, header, context] - ) - - assert.equal(options.mapper.mock.callCount(), 0) - assert.equal(options.writer.mock.callCount(), 0) - - assert.equal(options.thrower.mock.callCount(), 1) - assert.deepEqual( - options.thrower.mock.calls[0].arguments, - [undefined, undefined] - ) - }) - - it('should throw on failed mapping', () => { - // Given - const options = { - extractor: mock.fn(body => 2), - mapper: mock.fn(() => undefined), - writer: mock.fn((context, value) => {}), - thrower: mock.fn(() => { throw new Error() }) - } - - const validator = new ExtractMapperValidator(options) - - const body = { id: 2 } - const header = {} - const context = {} - - // When + Then - assert.throws(() => - validator.validate(body, header, context) - ) - - assert.equal(context.result, undefined) - - assert.equal(options.extractor.mock.callCount(), 1) - assert.deepEqual( - options.extractor.mock.calls[0].arguments, - [body, header, context] - ) - - assert.equal(options.mapper.mock.callCount(), 1) - assert.deepEqual( - options.mapper.mock.calls[0].arguments, - [body.id] - ) - - assert.equal(options.writer.mock.callCount(), 0) - - assert.equal(options.thrower.mock.callCount(), 1) - assert.deepEqual( - options.thrower.mock.calls[0].arguments, - [body.id, undefined] - ) - }) -}) diff --git a/test/spec/validators/require.body.test.mjs b/test/spec/validators/require.body.test.mjs deleted file mode 100644 index 213643d..0000000 --- a/test/spec/validators/require.body.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert' -import { BodyPresenceValidator } from '../../../src/validators/require.body.mjs' -import { Correspondence } from '@elementbound/nlon' - -const failureCases = [ - { name: 'should fail undefined', input: undefined }, - { name: 'should fail null', input: null }, - { name: 'should fail End', input: Correspondence.End } -] - -describe('BodyPresenceValidator', () => { - it('should pass', () => { - // Given - const validator = new BodyPresenceValidator() - const body = 'hello' - const header = {} - const context = {} - - // When - validator.validate(body, header, context) - - // Then pass - }) - - failureCases.forEach(kase => { - it(kase.name, () => { - // Given - const validator = new BodyPresenceValidator() - const body = kase.input - const header = {} - const context = {} - - // When + then - assert.throws(() => - validator.validate(body, header, context) - ) - }) - }) -}) diff --git a/test/spec/validators/require.header.test.mjs b/test/spec/validators/require.header.test.mjs deleted file mode 100644 index 419f660..0000000 --- a/test/spec/validators/require.header.test.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert' -import { HeaderValidator } from '../../../src/validators/require.header.mjs' - -describe('HeaderValidator', () => { - it('should pass', () => { - // Given - const body = {} - const header = { foo: 'bar' } - const context = {} - - const validator = new HeaderValidator('foo') - - // When - validator.validate(body, header, context) - - // Then pass - }) - - it('should fail', () => { - // Given - const body = {} - const header = { bar: 'foo' } - const context = {} - - const validator = new HeaderValidator('foo') - - // When + then - assert.throws(() => - validator.validate(body, header, context) - ) - }) -}) diff --git a/test/spec/validators/require.schema.test.mjs b/test/spec/validators/require.schema.test.mjs deleted file mode 100644 index 0442d71..0000000 --- a/test/spec/validators/require.schema.test.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, mock } from 'node:test' -import assert from 'node:assert' -import { SchemaValidationError, SchemaValidator } from '../../../src/validators/require.schema.mjs' - -describe('SchemaValidator', () => { - it('should delegate to ajv', () => { - // Given - const ajv = { - validate: mock.fn(() => true) - } - const schema = 'test/schema' - const body = 'test' - const header = {} - const context = {} - - const validator = new SchemaValidator({ ajv, schema }) - - // When - validator.validate(body, header, context) - console.log({ - actual: ajv.validate.mock.calls, - expected: [[schema, body]] - }) - - // Then - assert.equal(ajv.validate.mock.callCount(), 1) - assert.deepEqual(ajv.validate.mock.calls[0].arguments, [schema, body]) - }) - - it('should throw if ajv throws', () => { - // Given - const ajv = { - validate: () => { - throw new SchemaValidationError() - } - } - const schema = 'test/schema' - const body = 'test' - const header = {} - const context = {} - - const validator = new SchemaValidator({ ajv, schema }) - - // When + then - assert.throws( - () => validator.validate(body, header, context), - SchemaValidationError - ) - }) -}) From 48d7e62c697398c06d2cf1e0ca24e22cdf85ace9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 29 May 2023 23:48:27 +0200 Subject: [PATCH 02/34] chore: Rebrand (#4) Closes #2 --- .env.example | 56 +++++++-------------------------- README.md | 8 ++--- bin/natty.mjs | 4 --- bin/noray.mjs | 4 +++ package.json | 8 ++--- src/config.mjs | 45 +++++++++----------------- src/logger.mjs | 4 +-- src/{natty.mjs => noray.mjs} | 12 +++---- src/relay/relay.mjs | 21 +++---------- src/relay/udp.relay.handler.mjs | 2 +- test/e2e/context.mjs | 18 +++++------ 11 files changed, 57 insertions(+), 125 deletions(-) delete mode 100644 bin/natty.mjs create mode 100644 bin/noray.mjs rename src/{natty.mjs => noray.mjs} (80%) diff --git a/.env.example b/.env.example index f226c48..5bd0018 100644 --- a/.env.example +++ b/.env.example @@ -1,73 +1,39 @@ # Socket ====================================================================== # TCP hostname to listen on -NATTY_SOCKET_HOST=::1 +NORAY_SOCKET_HOST=::1 # TCP port to listen on -NATTY_SOCKET_PORT=8808 - -# Session ===================================================================== -# Session timeout -# -# After this amount of no communication, sessions are terminated -NATTY_SESSION_TIMEOUT=1hr - -# Session cleanup interval -# -# Specifies the interval between checks for expired sessions ( see above ) -NATTY_SESSION_CLEANUP_INTERVAL=10m - -# Lobby ======================================================================= -# Minimum length for a lobby name -NATTY_LOBBY_MIN_NAME_LENGTH=3 -# Maximum length for a lobby name -NATTY_LOBBY_MAX_NAME_LENGTH=128 - -# Timeout for connection diagnostics, i.e. how much to wait for peers to report -# connectivity to eachoter -NATTY_CONNECTION_DIAGNOSTICS_TIMEOUT=8s +NORAY_SOCKET_PORT=8890 # UDP Relays ================================================================== # Maximum number of active relay slots -NATTY_UDP_RELAY_MAX_SLOTS=16384 +NORAY_UDP_RELAY_MAX_SLOTS=16384 # Seconds of inactivity before a relay is freed -NATTY_UDP_RELAY_TIMEOUT=30s +NORAY_UDP_RELAY_TIMEOUT=30s # Interval at which the UDP relay cleanup is run in seconds -NATTY_UDP_RELAY_CLEANUP_INTERVAL=30s - -# Port where Natty listens for UDP relay requests from hosts -NATTY_UDP_REGISTRAR_PORT=8809 +NORAY_UDP_RELAY_CLEANUP_INTERVAL=30s # Maximum traffic per relay, in bytes / sec -NATTY_UDP_RELAY_MAX_INDIVIDUAL_TRAFFIC=128kb +NORAY_UDP_RELAY_MAX_INDIVIDUAL_TRAFFIC=128kb # Maximum traffic for relaying, globally, in bytes / sec -NATTY_UDP_RELAY_MAX_GLOBAL_TRAFFIC=1Gb +NORAY_UDP_RELAY_MAX_GLOBAL_TRAFFIC=1Gb # Traffic measurement interval # This is the timeslice used to limit traffic, i.e. 2 sec means we'll track the # traffic for 2 seconds, reset our counter and start again. -NATTY_UDP_RELAY_TRAFFIC_INTERVAL=100ms +NORAY_UDP_RELAY_TRAFFIC_INTERVAL=100ms # Maximum relay lifetime # Relays will be blocked after being active for this duration -NATTY_UDP_RELAY_MAX_LIFETIME_DURATION=4hr +NORAY_UDP_RELAY_MAX_LIFETIME_DURATION=4hr # Maximum relay traffic # Relays will be blocked after throughputting this amount of data -NATTY_UDP_RELAY_MAX_LIFETIME_TRAFFIC=4Gb +NORAY_UDP_RELAY_MAX_LIFETIME_TRAFFIC=4Gb # Other ======================================================================= -# Known games -# -# Each game should reside in its own line, with its id followed by a whitespace -# and its name. -# -# Spaces are trimmed from the ends of the lines -NATTY_GAMES=" - q5jMbqNLKQSy0FxhTCHZ9 Game 1 - Yf8cBD_EmJa26xRr_2hoX Game 2 -" # Logging level - silent, trace, debug, info, warn, error, fatal -NATTY_LOGLEVEL=info +NORAY_LOGLEVEL=info diff --git a/README.md b/README.md index 40728cb..7b1b939 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # noray -A fork of [Natty](https://github.com/foxssake/natty) for open-source purposes. +A fork of [Noray](https://github.com/foxssake/noray) for open-source purposes. ## Motivation -While Natty is open-source, its scope becomes quite large from v1 onwards - +While Noray is open-source, its scope becomes quite large from v1 onwards - managing users, supporting multiple different games, different orchestration -strategies, multiple sessions per user, lobbies, etc. This can make Natty an +strategies, multiple sessions per user, lobbies, etc. This can make Noray an unwieldy solution for situations where you just want to get something running online, or if you don't plan on running a whole platform for some individual multiplayer games. @@ -15,7 +15,7 @@ This is the niche noray intends to fill - a very simple server that manages connectivity between players. Anything more than that is the responsibility of the game or some other backend service unrelated to noray. -Thankfully, at the point of writing, Natty implements most of the features +Thankfully, at the point of writing, Noray implements most of the features needed for noray, so it can start its life as a stripped-down fork. ## Scope diff --git a/bin/natty.mjs b/bin/natty.mjs deleted file mode 100644 index a64aea7..0000000 --- a/bin/natty.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import { Natty } from '../src/natty.mjs' - -const natty = new Natty() -natty.start() diff --git a/bin/noray.mjs b/bin/noray.mjs new file mode 100644 index 0000000..5a58e0f --- /dev/null +++ b/bin/noray.mjs @@ -0,0 +1,4 @@ +import { Noray } from '../src/noray.mjs' + +const noray = new Noray() +noray.start() diff --git a/package.json b/package.json index 2d4962b..7fa4900 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "name": "@foxssake/noray", - "version": "0.13.2", + "version": "0.13.3", "description": "Online multiplayer orchestrator and potential game platform", - "main": "src/natty.mjs", + "main": "src/noray.mjs", "bin": { - "natty": "bin/natty.mjs" + "noray": "bin/noray.mjs" }, "scripts": { "lint": "eslint --ext .mjs src", "doc": "jsdoc -c .jsdoc.js src", "test": "node --test test/spec/ | utap", "test:e2e": "node --test test/e2e/ | node scripts/taplog.mjs utap \"pino-pretty -c\"", - "start": "node bin/natty.mjs | pino-pretty" + "start": "node bin/noray.mjs | pino-pretty" }, "keywords": [], "author": "Tamas Galffy", diff --git a/src/config.mjs b/src/config.mjs index 82238ad..2a37734 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -7,45 +7,28 @@ dotenv.config() const env = process.env /** -* Natty configuration type. +* Noray configuration type. */ -export class NattyConfig { +export class NorayConfig { socket = { - host: env.NATTY_SOCKET_HOST ?? '::1', - port: integer(env.NATTY_SOCKET_PORT) ?? 8808 - } - - session = { - timeout: duration(env.NATTY_SESSION_TIMEOUT ?? '1hr'), - cleanupInterval: duration(env.NATTY_SESSION_CLEANUP_INTERVAL ?? '10m') - } - - lobby = { - minNameLength: number(env.NATTY_LOBBY_MIN_NAME_LENGTH) ?? 3, - maxNameLength: number(env.NATTY_LOBBY_MAX_NAME_LENGTH) ?? 128 - } - - connectionDiagnostics = { - timeout: duration(env.NATTY_CONNECTION_DIAGNOSTICS_TIMEOUT ?? '8s') + host: env.NORAY_SOCKET_HOST ?? '::1', + port: integer(env.NORAY_SOCKET_PORT) ?? 8890 } udpRelay = { - maxSlots: number(env.NATTY_UDP_RELAY_MAX_SLOTS) ?? 16384, - timeout: duration(env.NATTY_UDP_RELAY_TIMEOUT ?? '30s'), - cleanupInterval: duration(env.NATTY_UDP_RELAY_CLEANUP_INTERVAL ?? '30s'), - registrarPort: number(env.NATTY_UDP_REGISTRAR_PORT) ?? 8809, - - maxIndividualTraffic: byteSize(env.NATTY_UDP_RELAY_MAX_INDIVIDUAL_TRAFFIC ?? '128kb'), - maxGlobalTraffic: byteSize(env.NATTY_UDP_RELAY_MAX_GLOBAL_TRAFFIC ?? '1gb'), - trafficInterval: duration(env.NATTY_UDP_RELAY_TRAFFIC_INTERVAL ?? '100ms'), - maxLifetimeDuration: duration(env.NATTY_UDP_RELAY_MAX_LIFETIME_DURATION ?? '4hr'), - maxLifetimeTraffic: byteSize(env.NATTY_UDP_RELAY_MAX_LIFETIME_TRAFFIC ?? '4gb') + maxSlots: number(env.NORAY_UDP_RELAY_MAX_SLOTS) ?? 16384, + timeout: duration(env.NORAY_UDP_RELAY_TIMEOUT ?? '30s'), + cleanupInterval: duration(env.NORAY_UDP_RELAY_CLEANUP_INTERVAL ?? '30s'), + + maxIndividualTraffic: byteSize(env.NORAY_UDP_RELAY_MAX_INDIVIDUAL_TRAFFIC ?? '128kb'), + maxGlobalTraffic: byteSize(env.NORAY_UDP_RELAY_MAX_GLOBAL_TRAFFIC ?? '1gb'), + trafficInterval: duration(env.NORAY_UDP_RELAY_TRAFFIC_INTERVAL ?? '100ms'), + maxLifetimeDuration: duration(env.NORAY_UDP_RELAY_MAX_LIFETIME_DURATION ?? '4hr'), + maxLifetimeTraffic: byteSize(env.NORAY_UDP_RELAY_MAX_LIFETIME_TRAFFIC ?? '4gb') } - games = env.NATTY_GAMES ?? '' - loglevel = getLogLevel() } -export const config = new NattyConfig() +export const config = new NorayConfig() logger.info({ config }, 'Loaded application config') diff --git a/src/logger.mjs b/src/logger.mjs index 4625a07..4ce5806 100644 --- a/src/logger.mjs +++ b/src/logger.mjs @@ -9,11 +9,11 @@ export const loglevels = Object.freeze([ dotenv.config() export function getLogLevel () { - return enumerated(process.env.NATTY_LOGLEVEL, loglevels) ?? 'info' + return enumerated(process.env.NORAY_LOGLEVEL, loglevels) ?? 'info' } const logger = pino({ - name: 'natty', + name: 'noray', level: getLogLevel() }) diff --git a/src/natty.mjs b/src/noray.mjs similarity index 80% rename from src/natty.mjs rename to src/noray.mjs index 98e7dee..87e975f 100644 --- a/src/natty.mjs +++ b/src/noray.mjs @@ -4,24 +4,20 @@ import logger from './logger.mjs' import { config } from './config.mjs' const defaultModules = [ - 'sessions/sessions.mjs', - 'games/games.mjs', - 'lobbies/lobbies.mjs', - 'connection/connections.mjs', 'relay/relay.mjs' ] const hooks = [] -export class Natty extends EventEmitter { +export class Noray extends EventEmitter { /** @type {net.Server} */ #socket #log = logger /** - * Register a Natty configuration hook. - * @param {function(Natty)} h Hook + * Register a Noray configuration hook. + * @param {function(Noray)} h Hook */ static hook (h) { hooks.push(h) @@ -30,7 +26,7 @@ export class Natty extends EventEmitter { async start (modules) { modules ??= defaultModules - this.#log.info('Starting Natty') + this.#log.info('Starting Noray') const socket = net.createServer() diff --git a/src/relay/relay.mjs b/src/relay/relay.mjs index ea28c56..fc97f7c 100644 --- a/src/relay/relay.mjs +++ b/src/relay/relay.mjs @@ -1,24 +1,17 @@ import { config } from '../config.mjs' import { constrainGlobalBandwidth, constrainIndividualBandwidth, constrainLifetime, constrainRelayTableSize, constrainTraffic } from './constraints.mjs' import { UDPRelayHandler } from './udp.relay.handler.mjs' -import { Natty } from '../natty.mjs' +import { Noray } from '../noray.mjs' import { cleanupUdpRelayTable } from './udp.relay.cleanup.mjs' import logger from '../logger.mjs' -import { UDPRemoteRegistrar } from './udp.remote.registrar.mjs' -import { sessionRepository } from '../sessions/session.repository.mjs' import { formatByteSize, formatDuration } from '../utils.mjs' export const udpRelayHandler = new UDPRelayHandler() constrainRelayTableSize(udpRelayHandler, config.udpRelay.maxSlots) -export const udpRemoteRegistrar = new UDPRemoteRegistrar({ - sessionRepository, - udpRelayHandler -}) - const log = logger.child({ name: 'Relays' }) -Natty.hook(natty => { +Noray.hook(noray => { log.info( 'Starting periodic UDP relay cleanup job, running every %s', formatDuration(config.udpRelay.cleanupInterval) @@ -28,9 +21,6 @@ Natty.hook(natty => { config.udpRelay.cleanupInterval * 1000 ) - log.info('Listening on port %d for UDP remote registrars', config.udpRelay.registrarPort) - udpRemoteRegistrar.listen(config.udpRelay.registrarPort, config.socket.host) - log.info( 'Limiting relay bandwidth to %s/s and global bandwidth to %s/s', formatByteSize(config.udpRelay.maxIndividualTraffic), @@ -54,11 +44,8 @@ Natty.hook(natty => { constrainTraffic(udpRelayHandler, config.udpRelay.maxLifetimeTraffic) log.info('Adding shutdown hooks') - natty.on('close', () => { - log.info('Natty shutting down, cancelling UDP relay cleanup job') + noray.on('close', () => { + log.info('Noray shutting down, cancelling UDP relay cleanup job') clearInterval(cleanupJob) - - log.info('Closing UDP remote registrar socket') - udpRemoteRegistrar.socket.close() }) }) diff --git a/src/relay/udp.relay.handler.mjs b/src/relay/udp.relay.handler.mjs index ce8e19a..0d244b4 100644 --- a/src/relay/udp.relay.handler.mjs +++ b/src/relay/udp.relay.handler.mjs @@ -23,7 +23,7 @@ const log = logger.child({ name: 'UDPRelayHandler' }) * * Example: Port 1 is allocated for Host, port 2 is allocated for Client. When * we get a packet targeting port 1 from Client, we use port 2 to relay the data -* to Host. This way, Client will always appear as Natty:2 to the Host. +* to Host. This way, Client will always appear as Noray:2 to the Host. */ export class UDPRelayHandler extends EventEmitter { /** @type {UDPSocketPool} */ diff --git a/test/e2e/context.mjs b/test/e2e/context.mjs index be5ee79..b699e3d 100644 --- a/test/e2e/context.mjs +++ b/test/e2e/context.mjs @@ -1,27 +1,27 @@ import logger from '../../src/logger.mjs' -import { Natty } from '../../src/natty.mjs' +import { Noray } from '../../src/noray.mjs' import { promiseEvent } from '../../src/utils.mjs' export class End2EndContext { - /** @type {Natty} */ - natty + /** @type {Noray} */ + noray log = logger.child({ name: 'test' }) async startup () { this.log.info('Starting app') - this.natty = new Natty() - await this.natty.start() + this.noray = new Noray() + await this.noray.start() - this.log.info('Waiting for Natty ot start listening') - await promiseEvent(this.natty, 'listening') + this.log.info('Waiting for Noray ot start listening') + await promiseEvent(this.noray, 'listening') this.log.info('Startup done, ready for testing') } shutdown () { - this.log.info('Terminating Natty') - this.natty.shutdown() + this.log.info('Terminating Noray') + this.noray.shutdown() } } From d901e6e2647081750c8e02d89f5aa0457ab0c3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 3 Jun 2023 14:05:24 +0200 Subject: [PATCH 03/34] chore: Fix branding --- README.md | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7b1b939..40728cb 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # noray -A fork of [Noray](https://github.com/foxssake/noray) for open-source purposes. +A fork of [Natty](https://github.com/foxssake/natty) for open-source purposes. ## Motivation -While Noray is open-source, its scope becomes quite large from v1 onwards - +While Natty is open-source, its scope becomes quite large from v1 onwards - managing users, supporting multiple different games, different orchestration -strategies, multiple sessions per user, lobbies, etc. This can make Noray an +strategies, multiple sessions per user, lobbies, etc. This can make Natty an unwieldy solution for situations where you just want to get something running online, or if you don't plan on running a whole platform for some individual multiplayer games. @@ -15,7 +15,7 @@ This is the niche noray intends to fill - a very simple server that manages connectivity between players. Anything more than that is the responsibility of the game or some other backend service unrelated to noray. -Thankfully, at the point of writing, Noray implements most of the features +Thankfully, at the point of writing, Natty implements most of the features needed for noray, so it can start its life as a stripped-down fork. ## Scope diff --git a/package.json b/package.json index 7fa4900..e986a39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.13.3", + "version": "0.13.4", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { From 1549b1f2d44bff86093b355b883a84bb1832b520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 3 Jun 2023 14:07:10 +0200 Subject: [PATCH 04/34] chore: Remove CI --- .github/actions/setup.node/action.yml | 40 --------------------- .github/workflows/ci.yml | 50 --------------------------- package.json | 2 +- 3 files changed, 1 insertion(+), 91 deletions(-) delete mode 100644 .github/actions/setup.node/action.yml delete mode 100644 .github/workflows/ci.yml diff --git a/.github/actions/setup.node/action.yml b/.github/actions/setup.node/action.yml deleted file mode 100644 index 61cc306..0000000 --- a/.github/actions/setup.node/action.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Setup node.js environment -description: Setup node, pnpm and dependencies -inputs: - node-version: - description: 'node.js version' - required: false - default: 18.x - pnpm-version: - description: 'pnpm version' - required: false - default: '7' -runs: - using: composite - steps: - - uses: actions/checkout@v3 - - name: Install Node.js - uses: actions/setup-node@v3 - with: - node-version: ${{inputs.node-version}} - - name: Install pnpm - uses: pnpm/action-setup@v2 - id: pnpm-install - with: - version: ${{inputs.pnpm-version}} - run_install: false - - name: Get pnpm store directory - id: pnpm-cache - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 - name: Setup pnpm cache - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - name: Install dependencies - shell: bash - run: pnpm install diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 9da9746..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: CI - -env: - node-version: 18.x - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Setup - uses: ./.github/actions/setup.node - - name: Lint - run: pnpm lint - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Setup - uses: ./.github/actions/setup.node - - name: Generate docs - run: pnpm doc - test: - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@v3 - - name: Setup - uses: ./.github/actions/setup.node - - name: Env info - run: | - echo "Node $(node -v)" - echo "pnpm v$(pnpm -v)" - - name: Test - run: pnpm test - e2e: - runs-on: ubuntu-latest - needs: test - steps: - - uses: actions/checkout@v3 - - name: Setup - uses: ./.github/actions/setup.node - - name: End to end tests - run: pnpm test:e2e diff --git a/package.json b/package.json index e986a39..ef032b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.13.4", + "version": "0.13.5", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { From 41aaf31ab8e5d5fbdb91816cba6f73474c63b4b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 3 Jun 2023 15:29:11 +0200 Subject: [PATCH 05/34] chore: Add CI --- .github/actions/setup.node/action.yml | 40 +++++++++++++++++++++ .github/workflows/ci.yml | 50 +++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 .github/actions/setup.node/action.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/actions/setup.node/action.yml b/.github/actions/setup.node/action.yml new file mode 100644 index 0000000..61cc306 --- /dev/null +++ b/.github/actions/setup.node/action.yml @@ -0,0 +1,40 @@ +name: Setup node.js environment +description: Setup node, pnpm and dependencies +inputs: + node-version: + description: 'node.js version' + required: false + default: 18.x + pnpm-version: + description: 'pnpm version' + required: false + default: '7' +runs: + using: composite + steps: + - uses: actions/checkout@v3 + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{inputs.node-version}} + - name: Install pnpm + uses: pnpm/action-setup@v2 + id: pnpm-install + with: + version: ${{inputs.pnpm-version}} + run_install: false + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install dependencies + shell: bash + run: pnpm install diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9da9746 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +env: + node-version: 18.x + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup + uses: ./.github/actions/setup.node + - name: Lint + run: pnpm lint + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup + uses: ./.github/actions/setup.node + - name: Generate docs + run: pnpm doc + test: + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v3 + - name: Setup + uses: ./.github/actions/setup.node + - name: Env info + run: | + echo "Node $(node -v)" + echo "pnpm v$(pnpm -v)" + - name: Test + run: pnpm test + e2e: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v3 + - name: Setup + uses: ./.github/actions/setup.node + - name: End to end tests + run: pnpm test:e2e diff --git a/package.json b/package.json index ef032b3..493f686 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.13.5", + "version": "0.13.6", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { From 525952be2b0af56d9935d0d3de3a885b372ccb12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 3 Jun 2023 15:37:40 +0200 Subject: [PATCH 06/34] feat: Implement protocol Closes #5 --- package.json | 2 +- src/echo/echo.mjs | 13 ++++ src/noray.mjs | 18 +++++- src/protocol/protocol.server.mjs | 59 +++++++++++++++++ test/spec/protocol/protocol.server.test.mjs | 72 +++++++++++++++++++++ 5 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 src/echo/echo.mjs create mode 100644 src/protocol/protocol.server.mjs create mode 100644 test/spec/protocol/protocol.server.test.mjs diff --git a/package.json b/package.json index 493f686..1065306 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.13.6", + "version": "0.14.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/echo/echo.mjs b/src/echo/echo.mjs new file mode 100644 index 0000000..df15080 --- /dev/null +++ b/src/echo/echo.mjs @@ -0,0 +1,13 @@ +import { Noray } from '../noray.mjs' +import logger from '../logger.mjs' + +const log = logger.child({ name: 'Echo' }) + +Noray.hook(noray => { + log.info('Adding echo command') + + noray.protocolServer.on('echo', (data, socket) => { + socket.write(`echo ${data}\n`) + log.info('Echoing: %s', data) + }) +}) diff --git a/src/noray.mjs b/src/noray.mjs index 87e975f..03bad33 100644 --- a/src/noray.mjs +++ b/src/noray.mjs @@ -2,9 +2,11 @@ import * as net from 'node:net' import { EventEmitter } from 'node:events' import logger from './logger.mjs' import { config } from './config.mjs' +import { ProtocolServer } from './protocol/protocol.server.mjs' const defaultModules = [ - 'relay/relay.mjs' + 'relay/relay.mjs', + 'echo/echo.mjs' ] const hooks = [] @@ -13,6 +15,9 @@ export class Noray extends EventEmitter { /** @type {net.Server} */ #socket + /** @type {ProtocolServer} */ + #protocolServer + #log = logger /** @@ -31,6 +36,7 @@ export class Noray extends EventEmitter { const socket = net.createServer() this.#socket = socket + this.#protocolServer = new ProtocolServer() // Import modules for hooks for (const m of modules) { @@ -49,6 +55,11 @@ export class Noray extends EventEmitter { config.socket.host, config.socket.port ) + socket.on('connection', conn => { + this.#protocolServer.attach(conn) + conn.on('close', () => this.#protocolServer.detach(conn)) + }) + this.emit('listening', config.socket.port, config.socket.host) }) } @@ -57,7 +68,10 @@ export class Noray extends EventEmitter { this.#log.info('Shutting down') this.emit('close') - this.#socket.close() } + + get protocolServer () { + return this.#protocolServer + } } diff --git a/src/protocol/protocol.server.mjs b/src/protocol/protocol.server.mjs new file mode 100644 index 0000000..7e7e918 --- /dev/null +++ b/src/protocol/protocol.server.mjs @@ -0,0 +1,59 @@ +/* eslint-disable */ +import * as net from 'node:net' +/* eslint-enable */ +import * as readline from 'node:readline' +import * as events from 'node:events' + +/** +* Protocol implementation. +* +* The "protocol" itself is as follows: +* +* ``` +* \n +* \n +* ``` +* +* If the incoming data fits either of the above formats, an event with the +* command's name is emitted. The data can be an arbitrary string. The same +* applies to the command, with the exception that it can't contain spaces. +*/ +export class ProtocolServer extends events.EventEmitter { + #readers = new Map() + + /** + * Attach socket to server. + * @param {net.Socket} socket + */ + attach (socket) { + const rl = readline.createInterface({ + input: socket + }) + + rl.on('line', line => this.#handleLine(socket, line)) + this.#readers.set(socket, rl) + } + + /** + * Detach socket from server. + * @param {net.Socket} socket + */ + detach (socket) { + this.#readers.get(socket)?.close() + this.#readers.delete(socket) + } + + /** + * @param {net.Socket} socket + * @param {string} line + */ + #handleLine (socket, line) { + const idx = line.indexOf(' ') + + const [command, data] = idx >= 0 + ? [line.slice(0, idx), line.slice(idx + 1)] + : [line, ''] + + this.emit(command, data, socket) + } +} diff --git a/test/spec/protocol/protocol.server.test.mjs b/test/spec/protocol/protocol.server.test.mjs new file mode 100644 index 0000000..e92c4ec --- /dev/null +++ b/test/spec/protocol/protocol.server.test.mjs @@ -0,0 +1,72 @@ +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert' +import sinon from 'sinon' +import * as net from 'node:net' +import { ProtocolServer } from '../../../src/protocol/protocol.server.mjs' +import { promiseEvent, sleep } from '../../../src/utils.mjs' + +describe('ProtocolServer', () => { + /** @type {net.Socket} */ + let socket + + /** @type {ProtocolServer} */ + let server + + /** @type {net.Server} */ + let host + + beforeEach(async () => { + server = new ProtocolServer() + + host = net.createServer(conn => server.attach(conn)) + host.listen() + await promiseEvent(host, 'listening') + + socket = net.createConnection(host.address().port) + await promiseEvent(socket, 'connect') + }) + + it('should emit event with data', async () => { + // Given + const handler = sinon.mock() + server.on('command', handler) + + // When + socket.write('command data\n') + await sleep(0.05) + + // Then + assert.equal(handler.args[0][0], 'data') + }) + + it('should emit event without data', async () => { + // Given + const handler = sinon.mock() + server.on('command', handler) + + // When + socket.write('command\n') + await sleep(0.05) + + // Then + assert.equal(handler.args[0][0], '') + }) + + it('should not emit without nl', async () => { + // Given + const handler = sinon.mock() + server.on('command', handler) + + // When + socket.write('command') + await sleep(0.05) + + // Then + assert(handler.notCalled) + }) + + afterEach(() => { + socket.destroy() + host.close() + }) +}) From 48da100e9dc5ee8414674ff251e52fcdf1296a6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 3 Jun 2023 20:33:58 +0200 Subject: [PATCH 07/34] feat: Implement hosts Closes #6 --- package.json | 2 +- src/echo/echo.mjs | 2 +- src/hosts/host.commands.mjs | 40 ++++ src/hosts/host.entity.mjs | 40 ++++ src/hosts/host.mjs | 13 ++ src/hosts/host.repository.mjs | 17 ++ src/noray.mjs | 3 +- src/protocol/protocol.server.mjs | 28 +++ src/relay/relay.mjs | 2 +- test/e2e/context.mjs | 19 ++ test/e2e/host.mjs | 39 ++++ test/spec/protocol/protocol.server.test.mjs | 194 ++++++++++++++------ 12 files changed, 339 insertions(+), 60 deletions(-) create mode 100644 src/hosts/host.commands.mjs create mode 100644 src/hosts/host.entity.mjs create mode 100644 src/hosts/host.mjs create mode 100644 src/hosts/host.repository.mjs create mode 100644 test/e2e/host.mjs diff --git a/package.json b/package.json index 1065306..966c722 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.14.0", + "version": "0.15.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/echo/echo.mjs b/src/echo/echo.mjs index df15080..97902ec 100644 --- a/src/echo/echo.mjs +++ b/src/echo/echo.mjs @@ -1,7 +1,7 @@ import { Noray } from '../noray.mjs' import logger from '../logger.mjs' -const log = logger.child({ name: 'Echo' }) +const log = logger.child({ name: 'mod:echo' }) Noray.hook(noray => { log.info('Adding echo command') diff --git a/src/hosts/host.commands.mjs b/src/hosts/host.commands.mjs new file mode 100644 index 0000000..f3f99ce --- /dev/null +++ b/src/hosts/host.commands.mjs @@ -0,0 +1,40 @@ +/* eslint-disable */ +import * as net from 'node:net' +import { HostRepository } from './host.repository.mjs' +/* eslint-enable */ +import { HostEntity } from './host.entity.mjs' +import logger from '../logger.mjs' + +/** +* @param {HostRepository} hostRepository +*/ +export function handleRegisterHost (hostRepository) { + /** + * @param {ProtocolServer} server + */ + return function (server) { + server.on('register-host', (_data, socket) => { + const log = logger.child({ name: 'cmd:register-host' }) + + const host = new HostEntity({ socket }) + hostRepository.add(host) + + server.send(socket, 'set-oid', host.oid) + server.send(socket, 'set-pid', host.pid) + + log.info( + { oid: host.oid, pid: host.pid }, + 'Registered host from address %s:%d', + socket.address().address, socket.address().port + ) + + socket.on('close', () => { + log.info( + { oid: host.oid, pid: host.pid }, + 'Host disconnected, removing from repository' + ) + hostRepository.removeItem(host) + }) + }) + } +} diff --git a/src/hosts/host.entity.mjs b/src/hosts/host.entity.mjs new file mode 100644 index 0000000..457d068 --- /dev/null +++ b/src/hosts/host.entity.mjs @@ -0,0 +1,40 @@ +/* eslint-disable */ +import * as net from 'node:net' +/* eslint-enable */ +import { nanoid } from 'nanoid' + +/** +* Host entity. +* +* Hosts register in advance for other players to connect to them. +*/ +export class HostEntity { + /** + * Open id. + * @type {string} + */ + oid + + /** + * Private id. + * @type {string} + */ + pid + + /** + * Socket. + * @type {net.Socket} + */ + socket + + /** + * Construct entity. + * @param {HostEntity} options Options + */ + constructor (options) { + options && Object.assign(this, options) + + this.oid ??= nanoid() + this.pid ??= nanoid(128) + } +} diff --git a/src/hosts/host.mjs b/src/hosts/host.mjs new file mode 100644 index 0000000..c2f1d97 --- /dev/null +++ b/src/hosts/host.mjs @@ -0,0 +1,13 @@ +import { Noray } from '../noray.mjs' +import logger from '../logger.mjs' +import { handleRegisterHost } from './host.commands.mjs' +import { HostRepository } from './host.repository.mjs' + +const log = logger.child({ name: 'mod:host' }) + +export const hostRepository = new HostRepository() + +Noray.hook(noray => { + log.info('Registering host commands') + noray.protocolServer.configure(handleRegisterHost(hostRepository)) +}) diff --git a/src/hosts/host.repository.mjs b/src/hosts/host.repository.mjs new file mode 100644 index 0000000..2c7f65f --- /dev/null +++ b/src/hosts/host.repository.mjs @@ -0,0 +1,17 @@ +/* eslint-disable */ +import { HostEntity } from './host.entity.mjs' +/* eslint-enable */ +import { Repository, fieldIdMapper } from '../repository.mjs' + +/** +* Repository for tracking hosts. +* +* @extends {Repository} +*/ +export class HostRepository extends Repository { + constructor () { + super({ + idMapper: fieldIdMapper('oid') + }) + } +} diff --git a/src/noray.mjs b/src/noray.mjs index 03bad33..6cfe9be 100644 --- a/src/noray.mjs +++ b/src/noray.mjs @@ -6,7 +6,8 @@ import { ProtocolServer } from './protocol/protocol.server.mjs' const defaultModules = [ 'relay/relay.mjs', - 'echo/echo.mjs' + 'echo/echo.mjs', + 'hosts/host.mjs' ] const hooks = [] diff --git a/src/protocol/protocol.server.mjs b/src/protocol/protocol.server.mjs index 7e7e918..da9f4b5 100644 --- a/src/protocol/protocol.server.mjs +++ b/src/protocol/protocol.server.mjs @@ -3,6 +3,7 @@ import * as net from 'node:net' /* eslint-enable */ import * as readline from 'node:readline' import * as events from 'node:events' +import assert from 'node:assert' /** * Protocol implementation. @@ -43,6 +44,33 @@ export class ProtocolServer extends events.EventEmitter { this.#readers.delete(socket) } + /** + * Configure server using callback. + * @param {function(ProtocolServer)} cb Callback + * @returns {ProtocolServer} Server + */ + configure (cb) { + cb.apply(this) + return this + } + + /** + * Send a command through socket. + * @param {net.Socket} socket Socket + * @param {string} command Command + * @param {string} [data] Data + */ + send (socket, command, data) { + assert(!command.includes(' '), 'Command can\'t contain spaces!') + assert(!command.includes('\n'), 'Command can\'t contain newlines!') + assert(!data || !data.includes('\n'), 'Data can\'t contain newlines!') + + socket.write(data + ? `${command} ${data}\n` + : `${command}\n` + ) + } + /** * @param {net.Socket} socket * @param {string} line diff --git a/src/relay/relay.mjs b/src/relay/relay.mjs index fc97f7c..597c794 100644 --- a/src/relay/relay.mjs +++ b/src/relay/relay.mjs @@ -9,7 +9,7 @@ import { formatByteSize, formatDuration } from '../utils.mjs' export const udpRelayHandler = new UDPRelayHandler() constrainRelayTableSize(udpRelayHandler, config.udpRelay.maxSlots) -const log = logger.child({ name: 'Relays' }) +const log = logger.child({ name: 'mod:relay' }) Noray.hook(noray => { log.info( diff --git a/test/e2e/context.mjs b/test/e2e/context.mjs index b699e3d..c01c0a1 100644 --- a/test/e2e/context.mjs +++ b/test/e2e/context.mjs @@ -1,8 +1,12 @@ +import * as net from 'node:net' import logger from '../../src/logger.mjs' import { Noray } from '../../src/noray.mjs' import { promiseEvent } from '../../src/utils.mjs' +import { config } from '../../src/config.mjs' export class End2EndContext { + #clients = [] + /** @type {Noray} */ noray @@ -20,7 +24,22 @@ export class End2EndContext { this.log.info('Startup done, ready for testing') } + async connect () { + const socket = net.createConnection({ + host: config.socket.host, + port: config.socket.port + }) + socket.setEncoding('utf8') + + await promiseEvent(socket, 'connect') + this.#clients.push(socket) + return socket + } + shutdown () { + this.log.info('Closing %d connections', this.#clients.length) + this.#clients.forEach(c => c.destroy()) + this.log.info('Terminating Noray') this.noray.shutdown() } diff --git a/test/e2e/host.mjs b/test/e2e/host.mjs new file mode 100644 index 0000000..ea01dff --- /dev/null +++ b/test/e2e/host.mjs @@ -0,0 +1,39 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert' +import * as net from 'node:net' +import { End2EndContext } from './context.mjs' +import { sleep } from '../../src/utils.mjs' + +describe('Hosts', () => { + const context = new End2EndContext() + + before(async () => { + await context.startup() + }) + + describe('register', () => { + it('should respond with oid/pid', async () => { + const client = await context.connect() + + client.write('register-host\n') + + // Wait a bit for response + await sleep(0.25) + + // Read response + const lines = [] + for (let line = ''; line != null; line = client.read()) { + lines.push(line) + } + const response = lines.join('\n') + + // Check if we got both id's + assert(response.includes('set-oid '), 'Missing open id!') + assert(response.includes('set-pid '), 'Missing private id!') + }) + }) + + after(() => { + context.shutdown() + }) +}) diff --git a/test/spec/protocol/protocol.server.test.mjs b/test/spec/protocol/protocol.server.test.mjs index e92c4ec..de39145 100644 --- a/test/spec/protocol/protocol.server.test.mjs +++ b/test/spec/protocol/protocol.server.test.mjs @@ -6,67 +6,149 @@ import { ProtocolServer } from '../../../src/protocol/protocol.server.mjs' import { promiseEvent, sleep } from '../../../src/utils.mjs' describe('ProtocolServer', () => { - /** @type {net.Socket} */ - let socket - - /** @type {ProtocolServer} */ - let server - - /** @type {net.Server} */ - let host - - beforeEach(async () => { - server = new ProtocolServer() - - host = net.createServer(conn => server.attach(conn)) - host.listen() - await promiseEvent(host, 'listening') - - socket = net.createConnection(host.address().port) - await promiseEvent(socket, 'connect') + describe('handle', () => { + /** @type {net.Socket} */ + let socket + + /** @type {ProtocolServer} */ + let server + + /** @type {net.Server} */ + let host + + beforeEach(async () => { + server = new ProtocolServer() + + host = net.createServer(conn => server.attach(conn)) + host.listen() + await promiseEvent(host, 'listening') + + socket = net.createConnection(host.address().port) + await promiseEvent(socket, 'connect') + }) + + it('should emit event with data', async () => { + // Given + const handler = sinon.mock() + server.on('command', handler) + + // When + socket.write('command data\n') + await sleep(0.05) + + // Then + assert.equal(handler.args[0][0], 'data') + }) + + it('should emit event without data', async () => { + // Given + const handler = sinon.mock() + server.on('command', handler) + + // When + socket.write('command\n') + await sleep(0.05) + + // Then + assert.equal(handler.args[0][0], '') + }) + + it('should not emit without nl', async () => { + // Given + const handler = sinon.mock() + server.on('command', handler) + + // When + socket.write('command') + await sleep(0.05) + + // Then + assert(handler.notCalled) + }) + + afterEach(() => { + socket.destroy() + host.close() + }) }) - it('should emit event with data', async () => { - // Given - const handler = sinon.mock() - server.on('command', handler) - - // When - socket.write('command data\n') - await sleep(0.05) - - // Then - assert.equal(handler.args[0][0], 'data') + describe('send', () => { + it('should send with data', () => { + // Given + const server = new ProtocolServer() + const socket = sinon.createStubInstance(net.Socket) + + const command = 'command' + const data = 'data' + const expected = `command data\n` + + // When + server.send(socket, command, data) + + // Then + assert(socket.write.calledWith(expected)) + }) + + it('should send without data', () => { + // Given + const server = new ProtocolServer() + const socket = sinon.createStubInstance(net.Socket) + + const command = 'command' + const expected = `command\n` + + // When + server.send(socket, command) + + // Then + assert(socket.write.calledWith(expected)) + }) + + it('should throw on command with whitespace', () => { + // Given + const server = new ProtocolServer() + const socket = sinon.createStubInstance(net.Socket) + + // When + then + assert.throws(() => + server.send(socket, 'com mand', 'data') + ) + }) + + it('should throw on command with newline', () => { + // Given + const server = new ProtocolServer() + const socket = sinon.createStubInstance(net.Socket) + + // When + then + assert.throws(() => + server.send(socket, 'com\nmand', 'data') + ) + }) + + it('should throw on data with newline', () => { + // Given + const server = new ProtocolServer() + const socket = sinon.createStubInstance(net.Socket) + + // When + then + assert.throws(() => + server.send(socket, 'command', 'da\nta') + ) + }) }) - it('should emit event without data', async () => { - // Given - const handler = sinon.mock() - server.on('command', handler) + describe ('configure', () => { + it('should call callback', () => { + // Given + const server = new ProtocolServer() + const callback = sinon.spy() - // When - socket.write('command\n') - await sleep(0.05) - - // Then - assert.equal(handler.args[0][0], '') - }) - - it('should not emit without nl', async () => { - // Given - const handler = sinon.mock() - server.on('command', handler) - - // When - socket.write('command') - await sleep(0.05) - - // Then - assert(handler.notCalled) - }) + // When + server.configure(callback) - afterEach(() => { - socket.destroy() - host.close() + // Then + assert(callback.calledWith(server)) + }) }) }) From 81f60ad3c0f960fa13adff42793a6a6363c0f0d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 4 Jun 2023 14:15:35 +0200 Subject: [PATCH 08/34] feat: UDP port registrar (#14) Closes #7 --- .env.example | 3 + package.json | 2 +- src/config.mjs | 1 + src/hosts/host.repository.mjs | 9 ++ src/protocol/protocol.server.mjs | 6 +- src/relay/relay.mjs | 9 ++ src/relay/udp.remote.registrar.mjs | 97 +++++++++++++++ test/spec/relay/udp.remote.registrar.test.mjs | 115 ++++++++++++++++++ 8 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 src/relay/udp.remote.registrar.mjs create mode 100644 test/spec/relay/udp.remote.registrar.test.mjs diff --git a/.env.example b/.env.example index 5bd0018..b046c8f 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,9 @@ NORAY_UDP_RELAY_TIMEOUT=30s # Interval at which the UDP relay cleanup is run in seconds NORAY_UDP_RELAY_CLEANUP_INTERVAL=30s +# Port where noray listens for UDP relay requests from hosts +NORAY_UDP_REGISTRAR_PORT=8809 + # Maximum traffic per relay, in bytes / sec NORAY_UDP_RELAY_MAX_INDIVIDUAL_TRAFFIC=128kb diff --git a/package.json b/package.json index 966c722..38729fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.15.0", + "version": "0.16.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/config.mjs b/src/config.mjs index 2a37734..0ef4ece 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -19,6 +19,7 @@ export class NorayConfig { maxSlots: number(env.NORAY_UDP_RELAY_MAX_SLOTS) ?? 16384, timeout: duration(env.NORAY_UDP_RELAY_TIMEOUT ?? '30s'), cleanupInterval: duration(env.NORAY_UDP_RELAY_CLEANUP_INTERVAL ?? '30s'), + registrarPort: number(env.NORAY_UDP_REGISTRAR_PORT) ?? 8809, maxIndividualTraffic: byteSize(env.NORAY_UDP_RELAY_MAX_INDIVIDUAL_TRAFFIC ?? '128kb'), maxGlobalTraffic: byteSize(env.NORAY_UDP_RELAY_MAX_GLOBAL_TRAFFIC ?? '1gb'), diff --git a/src/hosts/host.repository.mjs b/src/hosts/host.repository.mjs index 2c7f65f..3301411 100644 --- a/src/hosts/host.repository.mjs +++ b/src/hosts/host.repository.mjs @@ -14,4 +14,13 @@ export class HostRepository extends Repository { idMapper: fieldIdMapper('oid') }) } + + /** + * Find host by private id. + * @param {string} pid Private id + * @returns {HostEntity|undefined} Host + */ + findByPid (pid) { + return [...this.list()].find(host => host.pid === pid) + } } diff --git a/src/protocol/protocol.server.mjs b/src/protocol/protocol.server.mjs index da9f4b5..aa2ad3e 100644 --- a/src/protocol/protocol.server.mjs +++ b/src/protocol/protocol.server.mjs @@ -46,11 +46,11 @@ export class ProtocolServer extends events.EventEmitter { /** * Configure server using callback. - * @param {function(ProtocolServer)} cb Callback + * @param {function(ProtocolServer)} configurer Callback * @returns {ProtocolServer} Server */ - configure (cb) { - cb.apply(this) + configure (configurer) { + configurer(this) return this } diff --git a/src/relay/relay.mjs b/src/relay/relay.mjs index 597c794..f0b8dfb 100644 --- a/src/relay/relay.mjs +++ b/src/relay/relay.mjs @@ -5,10 +5,16 @@ import { Noray } from '../noray.mjs' import { cleanupUdpRelayTable } from './udp.relay.cleanup.mjs' import logger from '../logger.mjs' import { formatByteSize, formatDuration } from '../utils.mjs' +import { UDPRemoteRegistrar } from './udp.remote.registrar.mjs' +import { hostRepository } from '../hosts/host.mjs' export const udpRelayHandler = new UDPRelayHandler() constrainRelayTableSize(udpRelayHandler, config.udpRelay.maxSlots) +export const udpRemoteRegistrar = new UDPRemoteRegistrar({ + hostRepository, + udpRelayHandler +}) const log = logger.child({ name: 'mod:relay' }) Noray.hook(noray => { @@ -21,6 +27,9 @@ Noray.hook(noray => { config.udpRelay.cleanupInterval * 1000 ) + log.info('Listening on port %d for UDP remote registrars', config.udpRelay.registrarPort) + udpRemoteRegistrar.listen(config.udpRelay.registrarPort, config.socket.host) + log.info( 'Limiting relay bandwidth to %s/s and global bandwidth to %s/s', formatByteSize(config.udpRelay.maxIndividualTraffic), diff --git a/src/relay/udp.remote.registrar.mjs b/src/relay/udp.remote.registrar.mjs new file mode 100644 index 0000000..f68c9a2 --- /dev/null +++ b/src/relay/udp.remote.registrar.mjs @@ -0,0 +1,97 @@ +/* eslint-disable */ +import { UDPRelayHandler } from './udp.relay.handler.mjs' +import { HostRepository } from '../hosts/host.repository.mjs' +/* eslint-enable */ +import dgram from 'node:dgram' +import assert from 'node:assert' +import { RelayEntry } from './relay.entry.mjs' +import { NetAddress } from './net.address.mjs' +import { requireParam } from '../assertions.mjs' +import logger from '../logger.mjs' + +const log = logger.child({ name: 'UDPRemoteRegistrar' }) + +/** +* @summary Class for remote address registration over UDP. +* +* @description The UDP remote registrar will listen on a specific port for +* incoming host ID's. If the host ID is valid, it will create a new relay +* for that player and reply a packet saying 'OK'. +* +* Note that if the relay already exists, it will reply anyway, but will not +* create duplicate relays. This helps combatting UDP's unreliable nature - +* clients can just spam the request until they receive a reply. +*/ +export class UDPRemoteRegistrar { + /** @type {dgram.Socket} */ + #socket + + /** @type {HostRepository} */ + #hostRepository + + /** @type {UDPRelayHandler} */ + #udpRelayHandler + + /** + * Construct instance. + * @param {object} options Options + * @param {HostRepository} options.hostRepository Host repository + * @param {UDPRelayHandler} options.udpRelayHandler UDP relay handler + * @param {dgram.Socket} [options.socket] Socket + */ + constructor (options) { + this.#hostRepository = requireParam(options.hostRepository) + this.#udpRelayHandler = requireParam(options.udpRelayHandler) + this.#socket = options.socket ?? dgram.createSocket('udp4') + } + + /** + * Start listening for incoming requests. + * @param {number} [port=0] Port + * @param {string} [address='0.0.0.0'] Address + * @returns {Promise} + */ + listen (port, address) { + return new Promise(resolve => { + port ??= 0 + address ??= '0.0.0.0' + + this.#socket.on('message', (msg, rinfo) => this.#handle(msg, rinfo)) + this.#socket.bind(port, address, () => { + const address = this.#socket.address() + log.info('Listening on %s:%s', address.address, address.port) + resolve() + }) + }) + } + + /** + * Socket listening for requests. + * @type {dgram.Socket} + */ + get socket () { + return this.#socket + } + + /** + * @param {Buffer} msg + * @param {dgram.RemoteInfo} rinfo + */ + async #handle (msg, rinfo) { + try { + const pid = msg.toString('utf8') + log.debug({ pid, rinfo }, 'Received UDP relay request') + + const host = this.#hostRepository.findByPid(pid) + assert(host, 'Unknown host pid!') + + await this.#udpRelayHandler.createRelay(new RelayEntry({ + address: NetAddress.fromRinfo(rinfo) + })) + + this.#socket.send('OK', rinfo.port, rinfo.address) + } catch (e) { + this.#socket.send(e.message ?? 'Error', rinfo.port, rinfo.address) + } + } +} diff --git a/test/spec/relay/udp.remote.registrar.test.mjs b/test/spec/relay/udp.remote.registrar.test.mjs new file mode 100644 index 0000000..1dd5638 --- /dev/null +++ b/test/spec/relay/udp.remote.registrar.test.mjs @@ -0,0 +1,115 @@ +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert' +import sinon from 'sinon' +import dgram from 'node:dgram' +import { UDPRelayHandler } from '../../../src/relay/udp.relay.handler.mjs' +import { UDPRemoteRegistrar } from '../../../src/relay/udp.remote.registrar.mjs' +import { NetAddress } from '../../../src/relay/net.address.mjs' +import { RelayEntry } from '../../../src/relay/relay.entry.mjs' +import { HostRepository } from '../../../src/hosts/host.repository.mjs' +import { HostEntity } from '../../../src/hosts/host.entity.mjs' + +describe('UDPRemoteRegistrar', () => { + /** @type {sinon.SinonFakeTimers} */ + let clock + + /** @type {sinon.SinonStubbedInstance} */ + let hostRepository + /** @type {sinon.SinonStubbedInstance} */ + let relayHandler + /** @type {sinon.SinonStubbedInstance} */ + let socket + + /** @type {UDPRemoteRegistrar} */ + let remoteRegistrar + + const host = new HostEntity({ + oid: 'h0001', + pid: 'p0001' + }) + + beforeEach(() => { + clock = sinon.useFakeTimers() + + hostRepository = sinon.createStubInstance(HostRepository) + relayHandler = sinon.createStubInstance(UDPRelayHandler) + socket = sinon.createStubInstance(dgram.Socket) + + hostRepository.findByPid.withArgs(host.pid).returns(host) + socket.bind.callsArg(2) // Instantly resolve on bind + socket.address.returns({ + address: '127.0.0.1', + port: 32768 + }) + + remoteRegistrar = new UDPRemoteRegistrar({ + hostRepository, udpRelayHandler: relayHandler, socket + }) + }) + + it('should succeed', async () => { + // Given + const msg = Buffer.from(host.pid) + const rinfo = { address: '88.57.0.3', port: 32745 } + + await remoteRegistrar.listen() + const messageHandler = socket.on.lastCall.callback + + // When + await messageHandler(msg, rinfo) + + // Then + assert.deepEqual( + relayHandler.createRelay.lastCall.args[0], + new RelayEntry({ address: NetAddress.fromRinfo(rinfo) }) + ) + assert.deepEqual( + socket.send.lastCall?.args, + ['OK', rinfo.port, rinfo.address] + ) + }) + it('should fail on unknown pid', async () => { + // Given + const msg = Buffer.from(host.pid) + const rinfo = { address: '88.57.0.3', port: 32745 } + + await remoteRegistrar.listen() + const messageHandler = socket.on.lastCall.callback + + hostRepository.findByPid.withArgs(host.pid).returns(undefined) + + // When + await messageHandler(msg, rinfo) + + // Then + assert(relayHandler.createRelay.notCalled, 'A relay was created!') + assert.deepEqual( + socket.send.lastCall?.args, + ['Unknown host pid!', rinfo.port, rinfo.address] + ) + }) + + it('should fail on throw', async () => { + // Given + const msg = Buffer.from(host.pid) + const rinfo = { address: '88.57.0.3', port: 32745 } + + await remoteRegistrar.listen() + const messageHandler = socket.on.lastCall.callback + + relayHandler.createRelay.throws('Error', 'Test') + + // When + await messageHandler(msg, rinfo) + + // Then + assert.deepEqual( + socket.send.lastCall?.args, + ['Test', rinfo.port, rinfo.address] + ) + }) + + afterEach(() => { + clock.restore() + }) +}) From ea853330cbb475176f08d84e449ca6f9caf290cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 4 Jun 2023 15:27:05 +0200 Subject: [PATCH 09/34] feat: Connection request handler (#15) Closes #8 --- package.json | 2 +- src/connection/connection.commands.mjs | 40 +++++++++++++++++++++++ src/connection/connection.mjs | 11 +++++++ src/hosts/host.commands.mjs | 1 - src/noray.mjs | 3 +- test/e2e/connection.test.mjs | 45 ++++++++++++++++++++++++++ test/e2e/context.mjs | 16 ++++++++- test/e2e/{host.mjs => host.test.mjs} | 15 ++------- 8 files changed, 117 insertions(+), 16 deletions(-) create mode 100644 src/connection/connection.commands.mjs create mode 100644 src/connection/connection.mjs create mode 100644 test/e2e/connection.test.mjs rename test/e2e/{host.mjs => host.test.mjs} (56%) diff --git a/package.json b/package.json index 38729fd..5d30da2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.16.0", + "version": "0.17.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/connection/connection.commands.mjs b/src/connection/connection.commands.mjs new file mode 100644 index 0000000..827e72d --- /dev/null +++ b/src/connection/connection.commands.mjs @@ -0,0 +1,40 @@ +/* eslint-disable */ +import { HostRepository } from '../hosts/host.repository.mjs' +/* eslint-enable */ +import assert from 'node:assert' +import logger from '../logger.mjs' + +/** +* @param {HostRepository} hostRepository +*/ +export function handleConnect (hostRepository) { + /** + * @param {ProtocolServer} server + */ + return function (server) { + server.on('connect', (data, socket) => { + const log = logger.child({ name: 'cmd:connect' }) + + const oid = data + const host = hostRepository.find(oid) + log.debug( + { oid, client: socket.address() }, + 'Client attempting to connect to host' + ) + assert(host, 'Unknown host oid: ' + oid) + + const hostAddress = stringifyAddress(host.socket.address()) + const clientAddress = stringifyAddress(socket.address()) + server.send(socket, 'connect', hostAddress) + server.send(host.socket, 'connect', clientAddress) + log.debug( + { client: clientAddress, host: hostAddress, oid }, + 'Connected client to host' + ) + }) + } +} + +function stringifyAddress (address) { + return `${address.address}:${address.port}` +} diff --git a/src/connection/connection.mjs b/src/connection/connection.mjs new file mode 100644 index 0000000..b1e12f5 --- /dev/null +++ b/src/connection/connection.mjs @@ -0,0 +1,11 @@ +import { Noray } from '../noray.mjs' +import logger from '../logger.mjs' +import { handleConnect } from './connection.commands.mjs' +import { hostRepository } from '../hosts/host.mjs' + +const log = logger.child({ name: 'mod:connection' }) + +Noray.hook(noray => { + log.info('Registering host commands') + noray.protocolServer.configure(handleConnect(hostRepository)) +}) diff --git a/src/hosts/host.commands.mjs b/src/hosts/host.commands.mjs index f3f99ce..926c80f 100644 --- a/src/hosts/host.commands.mjs +++ b/src/hosts/host.commands.mjs @@ -1,5 +1,4 @@ /* eslint-disable */ -import * as net from 'node:net' import { HostRepository } from './host.repository.mjs' /* eslint-enable */ import { HostEntity } from './host.entity.mjs' diff --git a/src/noray.mjs b/src/noray.mjs index 6cfe9be..892e96a 100644 --- a/src/noray.mjs +++ b/src/noray.mjs @@ -7,7 +7,8 @@ import { ProtocolServer } from './protocol/protocol.server.mjs' const defaultModules = [ 'relay/relay.mjs', 'echo/echo.mjs', - 'hosts/host.mjs' + 'hosts/host.mjs', + 'connection/connection.mjs' ] const hooks = [] diff --git a/test/e2e/connection.test.mjs b/test/e2e/connection.test.mjs new file mode 100644 index 0000000..3897605 --- /dev/null +++ b/test/e2e/connection.test.mjs @@ -0,0 +1,45 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert' +import { End2EndContext } from './context.mjs' + +describe('Connection', () => { + const context = new End2EndContext() + + before(async () => { + await context.startup() + }) + + describe('connect', () => { + it('should respond with external address', async () => { + const host = await context.connect() + const client = await context.connect() + + host.write('register-host\n') + + // Grab oid from response + const oid = (await context.read(host)) + .filter(cmd => cmd.startsWith('set-oid ')) + .map(cmd => cmd.split(' ')[1]) + .at(0) ?? assert.fail('No oid received!') + + // Send connect request + client.write(`connect ${oid}\n`) + + // Assert responses + assert( + (await context.read(client)).find(cmd => cmd.startsWith('connect ')), + 'No handshake received by client!' + ) + + assert( + (await context.read(host)).find(cmd => cmd.startsWith('connect ')), + 'No handshake received by host!' + ) + }) + }) + + after(() => { + context.shutdown() + }) +}) + diff --git a/test/e2e/context.mjs b/test/e2e/context.mjs index c01c0a1..81839c9 100644 --- a/test/e2e/context.mjs +++ b/test/e2e/context.mjs @@ -1,9 +1,11 @@ import * as net from 'node:net' import logger from '../../src/logger.mjs' import { Noray } from '../../src/noray.mjs' -import { promiseEvent } from '../../src/utils.mjs' +import { promiseEvent, sleep } from '../../src/utils.mjs' import { config } from '../../src/config.mjs' +const READ_WAIT = 0.05 + export class End2EndContext { #clients = [] @@ -36,6 +38,18 @@ export class End2EndContext { return socket } + async read (socket) { + await sleep(READ_WAIT) + + const lines = [] + for (let line = ''; line != null; line = socket.read()) { + lines.push(line) + } + + console.log('Got response', lines.join('')) + return lines.join('').split('\n') + } + shutdown () { this.log.info('Closing %d connections', this.#clients.length) this.#clients.forEach(c => c.destroy()) diff --git a/test/e2e/host.mjs b/test/e2e/host.test.mjs similarity index 56% rename from test/e2e/host.mjs rename to test/e2e/host.test.mjs index ea01dff..ca8ab13 100644 --- a/test/e2e/host.mjs +++ b/test/e2e/host.test.mjs @@ -1,8 +1,6 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert' -import * as net from 'node:net' import { End2EndContext } from './context.mjs' -import { sleep } from '../../src/utils.mjs' describe('Hosts', () => { const context = new End2EndContext() @@ -17,19 +15,12 @@ describe('Hosts', () => { client.write('register-host\n') - // Wait a bit for response - await sleep(0.25) - // Read response - const lines = [] - for (let line = ''; line != null; line = client.read()) { - lines.push(line) - } - const response = lines.join('\n') + const response = await context.read(client) // Check if we got both id's - assert(response.includes('set-oid '), 'Missing open id!') - assert(response.includes('set-pid '), 'Missing private id!') + assert(response.find(cmd => cmd.startsWith('set-oid')), 'Missing open id!') + assert(response.find(cmd => cmd.startsWith('set-pid')), 'Missing private id!') }) }) From aa2664c8aedc958506cbef9627e1b3d52e0d3a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 4 Jun 2023 16:39:11 +0200 Subject: [PATCH 10/34] feat: Dynamic relays (#17) Closes #16 --- package.json | 2 +- src/relay/dynamic.relaying.mjs | 83 ++++++++++++++ src/relay/relay.mjs | 4 + src/relay/udp.relay.handler.mjs | 17 ++- test/spec/relay/dynamic.relaying.test.mjs | 125 +++++++++++++++++++++ test/spec/relay/udp.relay.handler.test.mjs | 4 + 6 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 src/relay/dynamic.relaying.mjs create mode 100644 test/spec/relay/dynamic.relaying.test.mjs diff --git a/package.json b/package.json index 5d30da2..ac67eff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.17.0", + "version": "0.18.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/relay/dynamic.relaying.mjs b/src/relay/dynamic.relaying.mjs new file mode 100644 index 0000000..b8223c1 --- /dev/null +++ b/src/relay/dynamic.relaying.mjs @@ -0,0 +1,83 @@ +/* eslint-disable */ +import { NetAddress } from './net.address.mjs' +import { UDPRelayHandler } from './udp.relay.handler.mjs' +/* eslint-enable */ +import logger from '../logger.mjs' +import { RelayEntry } from './relay.entry.mjs' + +const log = logger.child({ name: 'DynamicRelaying' }) + +/** +* Implementation for dynamic relaying. +* +* Whenever an unknown client tries to send data to a known host through its +* relay address, dynamic relaying will create a new relay. +* +* While it's waiting for the relay to be created, it will buffer any incoming +* data and send it all once the relay is created. +*/ +export class DynamicRelaying { + /** @type {Map} */ + #buffers = new Map() + + /** + * Apply dynamic relay creation to relay handler. + * @param {UDPRelayHandler} relayHandler Relay handler + */ + apply (relayHandler) { + relayHandler.on('drop', + (senderRelay, targetRelay, senderAddress, targetPort, message) => + this.#handle(relayHandler, senderRelay, targetRelay, senderAddress, targetPort, message) + ) + } + + /** + * @param {UDPRelayHandler} relayHandler + * @param {RelayEntry} senderRelay + * @param {RelayEntry} targetRelay + * @param {NetAddress} senderAddress + * @param {number} targetPort + * @param {Buffer} message + */ + async #handle (relayHandler, senderRelay, targetRelay, senderAddress, targetPort, message) { + // Unknown host or client already has relay, ignore + if (senderRelay || !targetRelay) { + return + } + + const key = senderAddress.toString() + '>' + targetPort + + // We're already buffering for client, save data end return + if (this.#buffers.has(key)) { + this.#buffers.get(key).push(message) + return + } + + // No buffer for client yet, start buffering and create relay + log.info( + { from: senderAddress, to: targetRelay.address }, + 'Creating dynamic relay' + ) + this.#buffers.set(key, [message]) + const relay = new RelayEntry({ + address: senderAddress + }) + await relayHandler.createRelay(relay) + + log.info( + 'Relay created, sending %d packets', + this.#buffers.get(key)?.length ?? 0 + ) + this.#buffers.get(key).forEach(msg => + relayHandler.relay(msg, senderAddress, targetPort) + ) + } +} + +/** +* Apply dynamic relaying to relay handler. +* @param {UDPRelayHandler} relayHandler Relay handler +*/ +export function useDynamicRelay (relayHandler) { + new DynamicRelaying().apply(relayHandler) +} diff --git a/src/relay/relay.mjs b/src/relay/relay.mjs index f0b8dfb..d6a3ba0 100644 --- a/src/relay/relay.mjs +++ b/src/relay/relay.mjs @@ -7,6 +7,7 @@ import logger from '../logger.mjs' import { formatByteSize, formatDuration } from '../utils.mjs' import { UDPRemoteRegistrar } from './udp.remote.registrar.mjs' import { hostRepository } from '../hosts/host.mjs' +import { useDynamicRelay } from './dynamic.relaying.mjs' export const udpRelayHandler = new UDPRelayHandler() constrainRelayTableSize(udpRelayHandler, config.udpRelay.maxSlots) @@ -52,6 +53,9 @@ Noray.hook(noray => { constrainLifetime(udpRelayHandler, config.udpRelay.maxLifetimeDuration) constrainTraffic(udpRelayHandler, config.udpRelay.maxLifetimeTraffic) + log.info('Applying dynamic relaying') + useDynamicRelay(udpRelayHandler) + log.info('Adding shutdown hooks') noray.on('close', () => { log.info('Noray shutting down, cancelling UDP relay cleanup job') diff --git a/src/relay/udp.relay.handler.mjs b/src/relay/udp.relay.handler.mjs index 0d244b4..853b2a5 100644 --- a/src/relay/udp.relay.handler.mjs +++ b/src/relay/udp.relay.handler.mjs @@ -114,6 +114,7 @@ export class UDPRelayHandler extends EventEmitter { * @param {number} target Target port * @returns {Promise} True on success * @fires UDPRelayHandler#transmit + * @fires UDPRelayHandler#drop */ relay (msg, sender, target) { const senderRelay = this.#relayTable.find(r => @@ -123,6 +124,7 @@ export class UDPRelayHandler extends EventEmitter { if (!senderRelay || !targetRelay) { // We don't have a relay for the sender, target, or both + this.emit('drop', senderRelay, targetRelay, sender, target, msg) return false } @@ -132,7 +134,7 @@ export class UDPRelayHandler extends EventEmitter { return false } - this.emit('transmit', senderRelay, targetRelay) + this.emit('transmit', senderRelay, targetRelay, msg) socket.send(msg, targetRelay.address.port, targetRelay.address.address) @@ -196,3 +198,16 @@ export class UDPRelayHandler extends EventEmitter { * @event UDPRelayHandler#destroy * @param {RelayEntry} relay Relay being freed. */ + +/** +* Relay drop event. +* +* This event is emitted when a packet arrives for relay that we can't transfer +* - usually because of an unknown node ( either sender or target). +* @event UDPRelayHandler#drop +* @param {RelayEntry} sourceRelay Source relay +* @param {RelayEntry} targetRelay Target relay +* @param {NetAddress} sourceAddress Source address +* @param {number} targetPort Target port +* @param {Buffer} message Message +*/ diff --git a/test/spec/relay/dynamic.relaying.test.mjs b/test/spec/relay/dynamic.relaying.test.mjs new file mode 100644 index 0000000..3e3cbac --- /dev/null +++ b/test/spec/relay/dynamic.relaying.test.mjs @@ -0,0 +1,125 @@ +import { beforeEach, afterEach, describe, it } from 'node:test' +import assert from 'node:assert' +import sinon from 'sinon' +import { UDPRelayHandler } from '../../../src/relay/udp.relay.handler.mjs' +import { sleep } from '../../../src/utils.mjs' +import { useDynamicRelay } from '../../../src/relay/dynamic.relaying.mjs' +import { RelayEntry } from '../../../src/relay/relay.entry.mjs' +import { NetAddress } from '../../../src/relay/net.address.mjs' + +describe('DynamicRelaying', () => { + let clock + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + it('should create relay', async () => { + // Given + const relayHandler = sinon.createStubInstance(UDPRelayHandler) + relayHandler.on.callThrough() + relayHandler.emit.callThrough() + + relayHandler.createRelay.resolves(true) + useDynamicRelay(relayHandler) + + const senderRelay = undefined + const targetRelay = new RelayEntry({ + address: new NetAddress({ address: '87.54.0.16', port: 16752 }), + port: 10007 + }) + const senderAddress = new NetAddress({ address: '97.32.4.16', port: 32775 }) + const targetPort = targetRelay.port + const messages = [ + 'hello', 'world', 'use', 'noray' + ].map(message => Buffer.from(message)) + + // When + messages.forEach(message => + relayHandler.emit('drop', senderRelay, targetRelay, senderAddress, targetPort, message) + ) + clock.restore() + await sleep(0.05) // Wait for relay to be created + clock = sinon.useFakeTimers() + + // Then + assert( + relayHandler.createRelay.calledWith(new RelayEntry({ address: senderAddress })), + 'Relay was not created!' + ) + + const sent = relayHandler.relay.getCalls().map(call => call.args[0]?.toString()) + messages.forEach(message => + assert( + sent.includes(message.toString()), + `Message "${message.toString()}" was not sent!` + ) + ) + }) + + it('should ignore known sender', async () => { + // Given + const relayHandler = sinon.createStubInstance(UDPRelayHandler) + relayHandler.on.callThrough() + relayHandler.emit.callThrough() + + useDynamicRelay(relayHandler) + + const senderRelay = new RelayEntry({ + address: new NetAddress({ address: '87.54.0.16', port: 16752 }), + port: 10007 + }) + + const targetRelay = undefined + const senderAddress = new NetAddress(senderRelay.address) + const targetPort = 10057 + const messages = [ + 'hello', 'world', 'use', 'noray' + ].map(message => Buffer.from(message)) + + // When + messages.forEach(message => + relayHandler.emit('drop', senderRelay, targetRelay, senderAddress, targetPort, message) + ) + clock.restore() + await sleep(0.05) // Wait for relay to be created + clock = sinon.useFakeTimers() + + // Then + assert(relayHandler.createRelay.notCalled) + assert(relayHandler.relay.notCalled) + }) + + it('should ignore unknown target', async () => { + // Given + const relayHandler = sinon.createStubInstance(UDPRelayHandler) + relayHandler.on.callThrough() + relayHandler.emit.callThrough() + + useDynamicRelay(relayHandler) + + const senderRelay = undefined + const targetRelay = undefined + const senderAddress = new NetAddress({ address: '87.54.0.16', port: 16752 }) + const targetPort = 10057 + const messages = [ + 'hello', 'world', 'use', 'noray' + ].map(message => Buffer.from(message)) + + // When + messages.forEach(message => + relayHandler.emit('drop', senderRelay, targetRelay, senderAddress, targetPort, message) + ) + clock.restore() + await sleep(0.05) // Wait for relay to be created + clock = sinon.useFakeTimers() + + // Then + assert(relayHandler.createRelay.notCalled) + assert(relayHandler.relay.notCalled) + }) + + afterEach(() => { + clock.restore() + }) +}) diff --git a/test/spec/relay/udp.relay.handler.test.mjs b/test/spec/relay/udp.relay.handler.test.mjs index 0094699..e2874d6 100644 --- a/test/spec/relay/udp.relay.handler.test.mjs +++ b/test/spec/relay/udp.relay.handler.test.mjs @@ -186,6 +186,8 @@ describe('UDPRelayHandler', () => { const socketPool = sinon.createStubInstance(UDPSocketPool) socketPool.getSocket.returns(socket) + const dropHandler = sinon.spy() + const relayHandler = new UDPRelayHandler({ socketPool }) @@ -197,6 +199,7 @@ describe('UDPRelayHandler', () => { port: 32279 }) })) + relayHandler.on('drop', dropHandler) socketPool.getSocket.resetHistory() // When @@ -209,6 +212,7 @@ describe('UDPRelayHandler', () => { assert(!success, 'Relay succeeded?') assert(socketPool.getSocket.notCalled, 'Socket queried!') assert(socket.send.notCalled, 'Message sent?') + assert(dropHandler.calledOnce, 'Drop event not emitted!') }) it('should ignore on missing socket', async () => { // Given From 1954a0f4a4d5f8802162cd9fa7dd9aa11147bf22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 4 Jun 2023 22:57:12 +0200 Subject: [PATCH 11/34] feat: Implement relay connection request handler (#19) Closes #18 --- package.json | 2 +- src/connection/connection.commands.mjs | 32 ++++++ src/connection/connection.mjs | 6 +- src/hosts/host.entity.mjs | 6 ++ src/protocol/protocol.server.mjs | 17 ++- src/relay/dynamic.relaying.mjs | 7 +- src/relay/relay.mjs | 8 +- src/relay/udp.remote.registrar.mjs | 16 ++- test/e2e/connect.relay.test.mjs | 139 +++++++++++++++++++++++++ test/e2e/connection.test.mjs | 1 - 10 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 test/e2e/connect.relay.test.mjs diff --git a/package.json b/package.json index ac67eff..8d35d4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.18.0", + "version": "0.19.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/connection/connection.commands.mjs b/src/connection/connection.commands.mjs index 827e72d..0ca72b9 100644 --- a/src/connection/connection.commands.mjs +++ b/src/connection/connection.commands.mjs @@ -1,4 +1,5 @@ /* eslint-disable */ +import { ProtocolServer } from '../protocol/protocol.server.mjs' import { HostRepository } from '../hosts/host.repository.mjs' /* eslint-enable */ import assert from 'node:assert' @@ -35,6 +36,37 @@ export function handleConnect (hostRepository) { } } +/** +* @param {HostRepository} hostRepository +*/ +export function handleConnectRelay (hostRepository) { + /** + * @param {ProtocolServer} server + */ + return function (server) { + server.on('connect-relay', (data, socket) => { + const log = logger.child({ name: 'cmd:connect-relay' }) + + const oid = data + const host = hostRepository.find(oid) + log.debug( + { oid, client: socket.address() }, + 'Client attempting to connect to host' + ) + assert(host, 'Unknown host oid: ' + oid) + log.debug({ relay: host.relay }, 'Asserting relay') + assert(host.relay, 'Host has no relay!') + + log.debug({ relay: host.relay }, 'Replying with relay') + server.send(socket, 'connect-relay', host.relay) + log.debug( + { client: stringifyAddress(socket.address()), relay: host.relay, oid }, + 'Connected client to host' + ) + }) + } +} + function stringifyAddress (address) { return `${address.address}:${address.port}` } diff --git a/src/connection/connection.mjs b/src/connection/connection.mjs index b1e12f5..ab5393a 100644 --- a/src/connection/connection.mjs +++ b/src/connection/connection.mjs @@ -1,11 +1,13 @@ import { Noray } from '../noray.mjs' import logger from '../logger.mjs' -import { handleConnect } from './connection.commands.mjs' +import { handleConnect, handleConnectRelay } from './connection.commands.mjs' import { hostRepository } from '../hosts/host.mjs' const log = logger.child({ name: 'mod:connection' }) Noray.hook(noray => { log.info('Registering host commands') - noray.protocolServer.configure(handleConnect(hostRepository)) + noray.protocolServer + .configure(handleConnect(hostRepository)) + .configure(handleConnectRelay(hostRepository)) }) diff --git a/src/hosts/host.entity.mjs b/src/hosts/host.entity.mjs index 457d068..8b7f497 100644 --- a/src/hosts/host.entity.mjs +++ b/src/hosts/host.entity.mjs @@ -27,6 +27,12 @@ export class HostEntity { */ socket + /** + * Relay port. + * @type {number} + */ + relay + /** * Construct entity. * @param {HostEntity} options Options diff --git a/src/protocol/protocol.server.mjs b/src/protocol/protocol.server.mjs index aa2ad3e..b3dad83 100644 --- a/src/protocol/protocol.server.mjs +++ b/src/protocol/protocol.server.mjs @@ -4,6 +4,9 @@ import * as net from 'node:net' import * as readline from 'node:readline' import * as events from 'node:events' import assert from 'node:assert' +import logger from '../logger.mjs' + +const log = logger.child({ name: 'ProtocolServer' }) /** * Protocol implementation. @@ -58,12 +61,13 @@ export class ProtocolServer extends events.EventEmitter { * Send a command through socket. * @param {net.Socket} socket Socket * @param {string} command Command - * @param {string} [data] Data + * @param {any} [data] Data */ send (socket, command, data) { + data = data.toString() assert(!command.includes(' '), 'Command can\'t contain spaces!') assert(!command.includes('\n'), 'Command can\'t contain newlines!') - assert(!data || !data.includes('\n'), 'Data can\'t contain newlines!') + assert(!data || !data?.includes('\n'), 'Data can\'t contain newlines!') socket.write(data ? `${command} ${data}\n` @@ -82,6 +86,13 @@ export class ProtocolServer extends events.EventEmitter { ? [line.slice(0, idx), line.slice(idx + 1)] : [line, ''] - this.emit(command, data, socket) + try { + this.emit(command, data, socket) + } catch (err) { + log.warn( + { line, err }, + 'Error handling line' + ) + } } } diff --git a/src/relay/dynamic.relaying.mjs b/src/relay/dynamic.relaying.mjs index b8223c1..34227a0 100644 --- a/src/relay/dynamic.relaying.mjs +++ b/src/relay/dynamic.relaying.mjs @@ -59,18 +59,23 @@ export class DynamicRelaying { 'Creating dynamic relay' ) this.#buffers.set(key, [message]) + const port = await relayHandler.socketPool.allocatePort() const relay = new RelayEntry({ - address: senderAddress + address: senderAddress, + port }) await relayHandler.createRelay(relay) log.info( + { relay }, 'Relay created, sending %d packets', this.#buffers.get(key)?.length ?? 0 ) this.#buffers.get(key).forEach(msg => relayHandler.relay(msg, senderAddress, targetPort) ) + + this.#buffers.delete(key) } } diff --git a/src/relay/relay.mjs b/src/relay/relay.mjs index d6a3ba0..a2fde3d 100644 --- a/src/relay/relay.mjs +++ b/src/relay/relay.mjs @@ -29,7 +29,7 @@ Noray.hook(noray => { ) log.info('Listening on port %d for UDP remote registrars', config.udpRelay.registrarPort) - udpRemoteRegistrar.listen(config.udpRelay.registrarPort, config.socket.host) + udpRemoteRegistrar.listen(config.udpRelay.registrarPort) log.info( 'Limiting relay bandwidth to %s/s and global bandwidth to %s/s', @@ -60,5 +60,11 @@ Noray.hook(noray => { noray.on('close', () => { log.info('Noray shutting down, cancelling UDP relay cleanup job') clearInterval(cleanupJob) + + log.info('Closing UDP remote registrar socket') + udpRemoteRegistrar.socket.close() + + log.info('Closing relay handler') + udpRelayHandler.clear() }) }) diff --git a/src/relay/udp.remote.registrar.mjs b/src/relay/udp.remote.registrar.mjs index f68c9a2..4ccf02d 100644 --- a/src/relay/udp.remote.registrar.mjs +++ b/src/relay/udp.remote.registrar.mjs @@ -85,10 +85,24 @@ export class UDPRemoteRegistrar { const host = this.#hostRepository.findByPid(pid) assert(host, 'Unknown host pid!') + if (host.relay) { + // Host has already a relay + this.#socket.send('OK', rinfo.port, rinfo.address) + return + } + + const port = await this.#udpRelayHandler.socketPool.allocatePort() + host.relay = port await this.#udpRelayHandler.createRelay(new RelayEntry({ - address: NetAddress.fromRinfo(rinfo) + address: NetAddress.fromRinfo(rinfo), + port })) + log.info( + { host, port }, + 'Created relay for host' + ) + this.#socket.send('OK', rinfo.port, rinfo.address) } catch (e) { this.#socket.send(e.message ?? 'Error', rinfo.port, rinfo.address) diff --git a/test/e2e/connect.relay.test.mjs b/test/e2e/connect.relay.test.mjs new file mode 100644 index 0000000..da93019 --- /dev/null +++ b/test/e2e/connect.relay.test.mjs @@ -0,0 +1,139 @@ +import * as net from 'node:net' +import * as dgram from 'node:dgram' +import { describe, it, before, after } from 'node:test' +import assert, { fail } from 'node:assert' +import { End2EndContext } from './context.mjs' +import { promiseEvent, sleep } from '../../src/utils.mjs' +import { config } from '../../src/config.mjs' + +describe('Connection', () => { + const context = new End2EndContext() + + const host = { + /** @type {net.Socket} */ + tcp: undefined, + + /** @type {dgram.Socket} */ + udp: undefined, + + oid: '', + pid: '' + } + + const client = { + /** @type {net.Socket} */ + tcp: undefined, + + /** @type {dgram.Socket} */ + udp: undefined, + + targetRelay: undefined + } + + before(async () => { + await context.startup() + + context.log.info('Connecting to noray') + host.tcp = await context.connect() + client.tcp = await context.connect() + + context.log.info('Creating UDP sockets') + host.udp = dgram.createSocket('udp4') + client.udp = dgram.createSocket('udp4') + + context.log.info('Binding UDP') + host.udp.bind() + client.udp.bind() + + context.log.info('Waiting for UDP sockets to start listening') + await Promise.all([ + promiseEvent(host.udp, 'listening'), + promiseEvent(client.udp, 'listening') + ]) + + context.log.info('Startup done') + }) + + it('should register host', async () => { + // Register host + host.tcp.write('register-host\n') + const response = await context.read(host.tcp) + + host.oid = response + .filter(cmd => cmd.startsWith('set-oid ')) + .map(cmd => cmd.split(' ')[1]) + .at(0) ?? fail('Failed to get oid!') + + host.pid = response + .filter(cmd => cmd.startsWith('set-pid ')) + .map(cmd => cmd.split(' ')[1]) + .at(0) ?? fail('Failed to get pid!') + + // Register UDP port + let relayStatus = undefined + host.udp.on('message', (msg, _rinfo) => { + context.log.info('Received message: %s', msg.toString()) + relayStatus = msg.toString('utf8') + }) + + for (let i = 0; i < 64; ++i) { + context.log.info( + 'Attempt #%d sending pid to registrar port %d', + i + 1, config.udpRelay.registrarPort + ) + host.udp.send(host.pid, config.udpRelay.registrarPort) + await sleep(0.05) + + if (relayStatus !== undefined) { + break + } + } + host.udp.removeAllListeners() + + assert.equal(relayStatus, 'OK') + }) + + it('should reply with relay port', async () => { + // Request to connect + client.tcp.write(`connect-relay ${host.oid}\n`) + + client.targetRelay = (await context.read(client.tcp)) + .filter(cmd => cmd.startsWith('connect-relay')) + .map(cmd => cmd.split(' ')[1]) + .at(0) ?? fail('Failed to get relay port!') + + context.log.info('Client received relay port %d', client.targetRelay) + }) + + it('should relay data', async () => { + // Since it's all running on localhost, let's assume the data gets through + const message = 'Hello from client!' + const response = 'Hello from host!' + + const hostReceived = [] + const clientReceived = [] + + host.udp.on('message', (msg, rinfo) => { + hostReceived.push(msg.toString()) + host.udp.send(response, rinfo.port, rinfo.address) + }) + + client.udp.on('message', (msg, _rinfo) => { + clientReceived.push(msg.toString()) + }) + + client.udp.send(message, client.targetRelay) + + // Check if data went through both ways + await sleep(0.1) + assert.equal(hostReceived.join(''), message) + assert.equal(clientReceived.join(''), response) + }) + + after(() => { + host.udp.close() + client.udp.close() + + context.shutdown() + }) +}) diff --git a/test/e2e/connection.test.mjs b/test/e2e/connection.test.mjs index 3897605..e692a36 100644 --- a/test/e2e/connection.test.mjs +++ b/test/e2e/connection.test.mjs @@ -42,4 +42,3 @@ describe('Connection', () => { context.shutdown() }) }) - From 6f1a25cd8bc6905cd96945838dc2e7347148e721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 15 Jun 2023 16:16:13 +0200 Subject: [PATCH 12/34] fix: Use external address for handshakes Closes #20 --- README.md | 18 +++++++++------ package.json | 2 +- src/connection/connection.commands.mjs | 8 +++++-- src/hosts/host.entity.mjs | 7 ++++++ src/hosts/host.repository.mjs | 10 +++++++++ src/relay/udp.remote.registrar.mjs | 1 + test/e2e/connection.test.mjs | 26 +++++++++++++++++++-- test/e2e/context.mjs | 31 ++++++++++++++++++++++++++ 8 files changed, 91 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 40728cb..fb23e54 100644 --- a/README.md +++ b/README.md @@ -25,20 +25,24 @@ To ensure connection, noray will support *NAT punchthrough orchestration* and A game would happen through the following flow: -- The host connects to noray and sends a host request with its id - - This id can be whatever, as long as its unique - - It can be some random-generated string, a player name, a lobby name, etc. - - It is left to the game itself +- The host connects to noray and sends a host request + - noray replies with the host's OpenID and PrivateID +- The host sends its PID to noray's UDP remote registrar port + - noray saves the host's external address for UDP comms - noray allocates a relay for the host -- Successive clients send a connect request to noray - - The request contains the host id +- Clients connect to noray and send a register host request1 + - noray replies with the client's OpenID and PrivateID +- Clients send their PIDs to noray's UDP remote registrar port + - noray saves the external addresses and allocates relays2 +- Clients send a connect request to noray with the host's OID - noray sends a handshake message to both parties - The host receives the client's external address - The client receives the host's external address - If the handshake succeeds, the client connects to the host - If the handshake fails, the client sends a relay connect request to noray - The client receives the host's relay address to connect to - - When the client sends data to the host, the client gets its own relay allocated + - The host receives the client's relay address to connect to + - noray will relay the traffic ## Protocol diff --git a/package.json b/package.json index 8d35d4c..4d91764 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.19.0", + "version": "0.19.1", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/connection/connection.commands.mjs b/src/connection/connection.commands.mjs index 0ca72b9..a2d3569 100644 --- a/src/connection/connection.commands.mjs +++ b/src/connection/connection.commands.mjs @@ -18,14 +18,18 @@ export function handleConnect (hostRepository) { const oid = data const host = hostRepository.find(oid) + const client = hostRepository.findBySocket(socket) log.debug( { oid, client: socket.address() }, 'Client attempting to connect to host' ) assert(host, 'Unknown host oid: ' + oid) + assert(host.rinfo, 'Host has no remote info registered!') + assert(client, 'Unknown client from address') + assert(client.rinfo, 'Client has no remote info registered!') - const hostAddress = stringifyAddress(host.socket.address()) - const clientAddress = stringifyAddress(socket.address()) + const hostAddress = stringifyAddress(host.rinfo) + const clientAddress = stringifyAddress(client.rinfo) server.send(socket, 'connect', hostAddress) server.send(host.socket, 'connect', clientAddress) log.debug( diff --git a/src/hosts/host.entity.mjs b/src/hosts/host.entity.mjs index 8b7f497..18e3fa7 100644 --- a/src/hosts/host.entity.mjs +++ b/src/hosts/host.entity.mjs @@ -1,5 +1,6 @@ /* eslint-disable */ import * as net from 'node:net' +import * as dgram from 'node:dgram' /* eslint-enable */ import { nanoid } from 'nanoid' @@ -33,6 +34,12 @@ export class HostEntity { */ relay + /** + * Host remote info. + * @type {dgram.RemoteInfo} + */ + rinfo + /** * Construct entity. * @param {HostEntity} options Options diff --git a/src/hosts/host.repository.mjs b/src/hosts/host.repository.mjs index 3301411..71d00a5 100644 --- a/src/hosts/host.repository.mjs +++ b/src/hosts/host.repository.mjs @@ -1,4 +1,5 @@ /* eslint-disable */ +import * as net from 'node:net' import { HostEntity } from './host.entity.mjs' /* eslint-enable */ import { Repository, fieldIdMapper } from '../repository.mjs' @@ -23,4 +24,13 @@ export class HostRepository extends Repository { findByPid (pid) { return [...this.list()].find(host => host.pid === pid) } + + /** + * Find host by socket. + * @param {net.Socket} socket Socket + * @returns {HostEntity|undefined} Host + */ + findBySocket (socket) { + return [...this.list()].find(host => host.socket === socket) + } } diff --git a/src/relay/udp.remote.registrar.mjs b/src/relay/udp.remote.registrar.mjs index 4ccf02d..f02c913 100644 --- a/src/relay/udp.remote.registrar.mjs +++ b/src/relay/udp.remote.registrar.mjs @@ -91,6 +91,7 @@ export class UDPRemoteRegistrar { return } + host.rinfo = rinfo const port = await this.#udpRelayHandler.socketPool.allocatePort() host.relay = port await this.#udpRelayHandler.createRelay(new RelayEntry({ diff --git a/test/e2e/connection.test.mjs b/test/e2e/connection.test.mjs index e692a36..137651a 100644 --- a/test/e2e/connection.test.mjs +++ b/test/e2e/connection.test.mjs @@ -14,14 +14,36 @@ describe('Connection', () => { const host = await context.connect() const client = await context.connect() + context.log.info('Registering parties') host.write('register-host\n') + client.write('register-host\n') - // Grab oid from response - const oid = (await context.read(host)) + // Grab data from responses + const hostResult = await context.read(host) + const clientResult = await context.read(client) + + const oid = hostResult .filter(cmd => cmd.startsWith('set-oid ')) .map(cmd => cmd.split(' ')[1]) .at(0) ?? assert.fail('No oid received!') + const pid = hostResult + .filter(cmd => cmd.startsWith('set-pid ')) + .map(cmd => cmd.split(' ')[1]) + .at(0) ?? assert.fail('No oid received!') + + const clientPid = clientResult + .filter(cmd => cmd.startsWith('set-pid ')) + .map(cmd => cmd.split(' ')[1]) + .at(0) ?? assert.fail('No oid received!') + + // Register external addresses + context.log.info('Registering external addresses') + await Promise.all([ + context.registerExternal(pid), + context.registerExternal(clientPid) + ]) + // Send connect request client.write(`connect ${oid}\n`) diff --git a/test/e2e/context.mjs b/test/e2e/context.mjs index 81839c9..f5e7fa3 100644 --- a/test/e2e/context.mjs +++ b/test/e2e/context.mjs @@ -1,4 +1,5 @@ import * as net from 'node:net' +import * as dgram from 'node:dgram' import logger from '../../src/logger.mjs' import { Noray } from '../../src/noray.mjs' import { promiseEvent, sleep } from '../../src/utils.mjs' @@ -50,6 +51,36 @@ export class End2EndContext { return lines.join('').split('\n') } + /** + * @param {string} pid + */ + async registerExternal (pid) { + const udp = dgram.createSocket('udp4') + udp.bind() + await promiseEvent(udp, 'listening') + + let done = false + let error + udp.on('message', (buf, _rinfo) => { + const msg = buf.toString('utf8') + done = true + error = msg !== 'OK' && msg + }) + + while (!done) { + udp.send(pid, config.udpRelay.registrarPort) + await sleep(0.1) + } + + if (error) { + throw new Error(error) + } + + const result = udp.address().port + udp.close() + return result + } + shutdown () { this.log.info('Closing %d connections', this.#clients.length) this.#clients.forEach(c => c.destroy()) From 22fc7024eeb645707af0b8c765b60415f28808d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 22 Jun 2023 23:39:43 +0200 Subject: [PATCH 13/34] feat: Two-way relay connection orchestration (#24) Closes #21 --- package.json | 4 +- src/connection/connection.commands.mjs | 9 ++- src/hosts/host.commands.mjs | 2 +- src/protocol/protocol.server.mjs | 5 +- test/e2e/connect.relay.test.mjs | 57 ++++++++----------- test/e2e/connection.test.mjs | 30 +++------- test/e2e/context.mjs | 48 ++++++++++++---- test/spec/relay/dynamic.relaying.test.mjs | 13 +++-- test/spec/relay/udp.remote.registrar.test.mjs | 23 ++++++-- 9 files changed, 106 insertions(+), 85 deletions(-) diff --git a/package.json b/package.json index 4d91764..fa315d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.19.1", + "version": "0.20.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { @@ -9,7 +9,7 @@ "scripts": { "lint": "eslint --ext .mjs src", "doc": "jsdoc -c .jsdoc.js src", - "test": "node --test test/spec/ | utap", + "test": "node --test test/spec/", "test:e2e": "node --test test/e2e/ | node scripts/taplog.mjs utap \"pino-pretty -c\"", "start": "node bin/noray.mjs | pino-pretty" }, diff --git a/src/connection/connection.commands.mjs b/src/connection/connection.commands.mjs index a2d3569..9813b07 100644 --- a/src/connection/connection.commands.mjs +++ b/src/connection/connection.commands.mjs @@ -53,18 +53,21 @@ export function handleConnectRelay (hostRepository) { const oid = data const host = hostRepository.find(oid) + const client = hostRepository.findBySocket(socket) log.debug( - { oid, client: socket.address() }, + { oid, client: `${socket.remoteAddress}:${socket.remotePort}` }, 'Client attempting to connect to host' ) assert(host, 'Unknown host oid: ' + oid) - log.debug({ relay: host.relay }, 'Asserting relay') assert(host.relay, 'Host has no relay!') + assert(client, 'Unknown client from address') + assert(client.relay, 'Client has no relay!') log.debug({ relay: host.relay }, 'Replying with relay') server.send(socket, 'connect-relay', host.relay) + server.send(host.socket, 'connect-relay', client.relay) log.debug( - { client: stringifyAddress(socket.address()), relay: host.relay, oid }, + { client: `${socket.remoteAddress}:${socket.remotePort}`, relay: host.relay, oid }, 'Connected client to host' ) }) diff --git a/src/hosts/host.commands.mjs b/src/hosts/host.commands.mjs index 926c80f..05caa7f 100644 --- a/src/hosts/host.commands.mjs +++ b/src/hosts/host.commands.mjs @@ -24,7 +24,7 @@ export function handleRegisterHost (hostRepository) { log.info( { oid: host.oid, pid: host.pid }, 'Registered host from address %s:%d', - socket.address().address, socket.address().port + socket.remoteAddress, socket.remotePort ) socket.on('close', () => { diff --git a/src/protocol/protocol.server.mjs b/src/protocol/protocol.server.mjs index b3dad83..74d9bf2 100644 --- a/src/protocol/protocol.server.mjs +++ b/src/protocol/protocol.server.mjs @@ -64,13 +64,12 @@ export class ProtocolServer extends events.EventEmitter { * @param {any} [data] Data */ send (socket, command, data) { - data = data.toString() assert(!command.includes(' '), 'Command can\'t contain spaces!') assert(!command.includes('\n'), 'Command can\'t contain newlines!') - assert(!data || !data?.includes('\n'), 'Data can\'t contain newlines!') + assert(!data || !data?.toString()?.includes('\n'), 'Data can\'t contain newlines!') socket.write(data - ? `${command} ${data}\n` + ? `${command} ${data.toString()}\n` : `${command}\n` ) } diff --git a/test/e2e/connect.relay.test.mjs b/test/e2e/connect.relay.test.mjs index da93019..a9243bf 100644 --- a/test/e2e/connect.relay.test.mjs +++ b/test/e2e/connect.relay.test.mjs @@ -27,7 +27,10 @@ describe('Connection', () => { /** @type {dgram.Socket} */ udp: undefined, - targetRelay: undefined + targetRelay: undefined, + + oid: '', + pid: '' } before(async () => { @@ -51,50 +54,32 @@ describe('Connection', () => { promiseEvent(client.udp, 'listening') ]) + context.log.info('Host bound to UDP port %d', host.udp.address().port) + context.log.info('Client bound to UDP port %d', client.udp.address().port) + context.log.info('Startup done') }) it('should register host', async () => { - // Register host - host.tcp.write('register-host\n') - const response = await context.read(host.tcp) + // Register hosts + [host.oid, host.pid] = await context.registerHost(host.tcp) + assert(host.oid, 'Failed to get host oid!') + assert(host.pid, 'Failed to get host pid!') - host.oid = response - .filter(cmd => cmd.startsWith('set-oid ')) - .map(cmd => cmd.split(' ')[1]) - .at(0) ?? fail('Failed to get oid!') - - host.pid = response - .filter(cmd => cmd.startsWith('set-pid ')) - .map(cmd => cmd.split(' ')[1]) - .at(0) ?? fail('Failed to get pid!') + ;[client.oid, client.pid] = await context.registerHost(client.tcp) + assert(client.oid, 'Failed to get client oid!') + assert(client.pid, 'Failed to get client pid!') // Register UDP port - let relayStatus = undefined - host.udp.on('message', (msg, _rinfo) => { - context.log.info('Received message: %s', msg.toString()) - relayStatus = msg.toString('utf8') - }) - - for (let i = 0; i < 64; ++i) { - context.log.info( - 'Attempt #%d sending pid to registrar port %d', - i + 1, config.udpRelay.registrarPort - ) - host.udp.send(host.pid, config.udpRelay.registrarPort) - await sleep(0.05) - - if (relayStatus !== undefined) { - break - } - } - host.udp.removeAllListeners() - - assert.equal(relayStatus, 'OK') + context.log.info('Registering host external address') + await context.registerExternal(host.udp, host.pid) + context.log.info('Registering client external address') + await context.registerExternal(client.udp, client.pid) }) it('should reply with relay port', async () => { // Request to connect + context.log.info('Connecting over relay') client.tcp.write(`connect-relay ${host.oid}\n`) client.targetRelay = (await context.read(client.tcp)) @@ -115,6 +100,7 @@ describe('Connection', () => { host.udp.on('message', (msg, rinfo) => { hostReceived.push(msg.toString()) + console.log('Host got message', JSON.stringify({ msg: msg.toString(), rinfo })) host.udp.send(response, rinfo.port, rinfo.address) }) @@ -122,10 +108,13 @@ describe('Connection', () => { clientReceived.push(msg.toString()) }) + console.log('Sending initial packet to port ', client.targetRelay) client.udp.send(message, client.targetRelay) // Check if data went through both ways + context.log.info('Waiting for messages to go through') await sleep(0.1) + // TODO: Why tf data not getting through?? assert.equal(hostReceived.join(''), message) assert.equal(clientReceived.join(''), response) }) diff --git a/test/e2e/connection.test.mjs b/test/e2e/connection.test.mjs index 137651a..0cb5dda 100644 --- a/test/e2e/connection.test.mjs +++ b/test/e2e/connection.test.mjs @@ -14,34 +14,20 @@ describe('Connection', () => { const host = await context.connect() const client = await context.connect() - context.log.info('Registering parties') - host.write('register-host\n') - client.write('register-host\n') - // Grab data from responses - const hostResult = await context.read(host) - const clientResult = await context.read(client) - - const oid = hostResult - .filter(cmd => cmd.startsWith('set-oid ')) - .map(cmd => cmd.split(' ')[1]) - .at(0) ?? assert.fail('No oid received!') - - const pid = hostResult - .filter(cmd => cmd.startsWith('set-pid ')) - .map(cmd => cmd.split(' ')[1]) - .at(0) ?? assert.fail('No oid received!') + context.log.info('Registering parties') + const [oid, pid] = await context.registerHost(host) + const [_, clientPid] = await context.registerHost(client) - const clientPid = clientResult - .filter(cmd => cmd.startsWith('set-pid ')) - .map(cmd => cmd.split(' ')[1]) - .at(0) ?? assert.fail('No oid received!') + assert(oid, 'No oid received!') + assert(pid, 'No pid received!') + assert(clientPid, 'No client pid received!') // Register external addresses context.log.info('Registering external addresses') await Promise.all([ - context.registerExternal(pid), - context.registerExternal(clientPid) + context.registerExternal(undefined, pid), + context.registerExternal(undefined, clientPid) ]) // Send connect request diff --git a/test/e2e/context.mjs b/test/e2e/context.mjs index f5e7fa3..e471569 100644 --- a/test/e2e/context.mjs +++ b/test/e2e/context.mjs @@ -47,40 +47,68 @@ export class End2EndContext { lines.push(line) } - console.log('Got response', lines.join('')) return lines.join('').split('\n') } /** + * @param {dgram.Socket} udp * @param {string} pid */ - async registerExternal (pid) { - const udp = dgram.createSocket('udp4') - udp.bind() - await promiseEvent(udp, 'listening') - + async registerExternal (udp, pid) { let done = false let error - udp.on('message', (buf, _rinfo) => { + const throwaway = udp === undefined + + if (udp === undefined) { + udp = dgram.createSocket('udp4') + udp.bind() + await promiseEvent(udp, 'listening') + } + + udp.once('message', (buf, _rinfo) => { const msg = buf.toString('utf8') done = true error = msg !== 'OK' && msg }) - while (!done) { + for (let i = 0; i < 128 && !done; ++i) { udp.send(pid, config.udpRelay.registrarPort) + this.log.debug('Sending remote registrar attempt #%d', i+1) await sleep(0.1) } - if (error) { + if (!done) { + throw new Error('Registrar timed out!') + } else if (error) { throw new Error(error) } const result = udp.address().port - udp.close() + if (throwaway) { + udp.close() + } + return result } + async registerHost (socket) { + socket.write('register-host\n') + + const data = await this.read(socket) + + const oid = data + .filter(cmd => cmd.startsWith('set-oid ')) + .map(cmd => cmd.split(' ')[1]) + .at(0) + + const pid = data + .filter(cmd => cmd.startsWith('set-pid ')) + .map(cmd => cmd.split(' ')[1]) + .at(0) + + return [oid, pid] + } + shutdown () { this.log.info('Closing %d connections', this.#clients.length) this.#clients.forEach(c => c.destroy()) diff --git a/test/spec/relay/dynamic.relaying.test.mjs b/test/spec/relay/dynamic.relaying.test.mjs index 3e3cbac..d5c6b36 100644 --- a/test/spec/relay/dynamic.relaying.test.mjs +++ b/test/spec/relay/dynamic.relaying.test.mjs @@ -6,6 +6,7 @@ import { sleep } from '../../../src/utils.mjs' import { useDynamicRelay } from '../../../src/relay/dynamic.relaying.mjs' import { RelayEntry } from '../../../src/relay/relay.entry.mjs' import { NetAddress } from '../../../src/relay/net.address.mjs' +import { UDPSocketPool } from '../../../src/relay/udp.socket.pool.mjs' describe('DynamicRelaying', () => { let clock @@ -16,9 +17,13 @@ describe('DynamicRelaying', () => { it('should create relay', async () => { // Given + const socketPool = sinon.createStubInstance(UDPSocketPool) + socketPool.allocatePort.resolves(10000) + const relayHandler = sinon.createStubInstance(UDPRelayHandler) relayHandler.on.callThrough() relayHandler.emit.callThrough() + sinon.stub(relayHandler, 'socketPool').value(socketPool) relayHandler.createRelay.resolves(true) useDynamicRelay(relayHandler) @@ -43,10 +48,10 @@ describe('DynamicRelaying', () => { clock = sinon.useFakeTimers() // Then - assert( - relayHandler.createRelay.calledWith(new RelayEntry({ address: senderAddress })), - 'Relay was not created!' - ) + const createdRelay = relayHandler.createRelay.lastCall.args[0] + assert(createdRelay, 'Relay was not created!') + assert.equal(createdRelay.address, senderAddress) + assert.equal(createdRelay.port, 10000) const sent = relayHandler.relay.getCalls().map(call => call.args[0]?.toString()) messages.forEach(message => diff --git a/test/spec/relay/udp.remote.registrar.test.mjs b/test/spec/relay/udp.remote.registrar.test.mjs index 1dd5638..7f40549 100644 --- a/test/spec/relay/udp.remote.registrar.test.mjs +++ b/test/spec/relay/udp.remote.registrar.test.mjs @@ -8,6 +8,7 @@ import { NetAddress } from '../../../src/relay/net.address.mjs' import { RelayEntry } from '../../../src/relay/relay.entry.mjs' import { HostRepository } from '../../../src/hosts/host.repository.mjs' import { HostEntity } from '../../../src/hosts/host.entity.mjs' +import { UDPSocketPool } from '../../../src/relay/udp.socket.pool.mjs' describe('UDPRemoteRegistrar', () => { /** @type {sinon.SinonFakeTimers} */ @@ -23,18 +24,27 @@ describe('UDPRemoteRegistrar', () => { /** @type {UDPRemoteRegistrar} */ let remoteRegistrar - const host = new HostEntity({ - oid: 'h0001', - pid: 'p0001' - }) + const allocatedPort = 10002 + + /** @type {HostEntity} */ + let host beforeEach(() => { + host = new HostEntity({ + oid: 'h0001', + pid: 'p0001' + }) clock = sinon.useFakeTimers() hostRepository = sinon.createStubInstance(HostRepository) relayHandler = sinon.createStubInstance(UDPRelayHandler) socket = sinon.createStubInstance(dgram.Socket) + const socketPool = sinon.createStubInstance(UDPSocketPool) + socketPool.allocatePort.resolves(allocatedPort) + + sinon.stub(relayHandler, 'socketPool').value(socketPool) + hostRepository.findByPid.withArgs(host.pid).returns(host) socket.bind.callsArg(2) // Instantly resolve on bind socket.address.returns({ @@ -60,14 +70,15 @@ describe('UDPRemoteRegistrar', () => { // Then assert.deepEqual( - relayHandler.createRelay.lastCall.args[0], - new RelayEntry({ address: NetAddress.fromRinfo(rinfo) }) + relayHandler.createRelay.lastCall?.args?.at(0), + new RelayEntry({ address: NetAddress.fromRinfo(rinfo), port: allocatedPort }) ) assert.deepEqual( socket.send.lastCall?.args, ['OK', rinfo.port, rinfo.address] ) }) + it('should fail on unknown pid', async () => { // Given const msg = Buffer.from(host.pid) From 2b9028f04552149da867fc1b5364b62fc124bfeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 22 Jun 2023 23:48:57 +0200 Subject: [PATCH 14/34] chore: Add start:prod script (#25) --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index fa315d9..cfb0aec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.20.0", + "version": "0.20.1", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { @@ -11,7 +11,8 @@ "doc": "jsdoc -c .jsdoc.js src", "test": "node --test test/spec/", "test:e2e": "node --test test/e2e/ | node scripts/taplog.mjs utap \"pino-pretty -c\"", - "start": "node bin/noray.mjs | pino-pretty" + "start": "node bin/noray.mjs | pino-pretty", + "start:prod": "NODE_ENV=production node bin/noray.mjs" }, "keywords": [], "author": "Tamas Galffy", From e65104e36bdfd58744acd76890c6957fac670b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 2 Jul 2023 23:23:23 +0200 Subject: [PATCH 15/34] feat: Port ranges (#29) Closes #22 --- .env.example | 9 ++- package.json | 2 +- src/config.mjs | 4 +- src/config.parsers.mjs | 68 +++++++++++++--- src/relay/constraints.mjs | 12 --- src/relay/relay.mjs | 24 +++++- src/relay/udp.relay.handler.mjs | 27 ++++--- src/relay/udp.remote.registrar.mjs | 10 +-- src/relay/udp.socket.pool.mjs | 79 ++++++++++++++++--- test/e2e/connect.relay.test.mjs | 1 - test/spec/config.parsers.test.mjs | 18 ++++- test/spec/relay/constraints.test.mjs | 45 +---------- test/spec/relay/udp.relay.handler.test.mjs | 33 ++++---- test/spec/relay/udp.remote.registrar.test.mjs | 15 ++-- test/spec/relay/udp.socket.pool.test.mjs | 62 ++++++++++++++- 15 files changed, 277 insertions(+), 132 deletions(-) diff --git a/.env.example b/.env.example index b046c8f..5837f16 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,13 @@ NORAY_SOCKET_HOST=::1 NORAY_SOCKET_PORT=8890 # UDP Relays ================================================================== -# Maximum number of active relay slots -NORAY_UDP_RELAY_MAX_SLOTS=16384 +# Ports reserved for relays - this also determines the number of relay slots +# Valid forms include: +# Literal ports: 2048, 2049, 2050, 2051 +# Port ranges: 2048-2051 +# Offset ranges: 2048+3 +# The above forms can be freely combined, separated by commas +NORAY_UDP_RELAY_PORTS=49152-51200 # Seconds of inactivity before a relay is freed NORAY_UDP_RELAY_TIMEOUT=30s diff --git a/package.json b/package.json index cfb0aec..b46965d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.20.1", + "version": "0.21.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/config.mjs b/src/config.mjs index 0ef4ece..78ac8f7 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -1,5 +1,5 @@ import * as dotenv from 'dotenv' -import { byteSize, duration, integer, number } from './config.parsers.mjs' +import { byteSize, duration, integer, number, ports } from './config.parsers.mjs' import logger, { getLogLevel } from './logger.mjs' dotenv.config() @@ -16,7 +16,7 @@ export class NorayConfig { } udpRelay = { - maxSlots: number(env.NORAY_UDP_RELAY_MAX_SLOTS) ?? 16384, + ports: ports(env.NORAY_UDP_RELAY_PORTS ?? '49152-51200'), timeout: duration(env.NORAY_UDP_RELAY_TIMEOUT ?? '30s'), cleanupInterval: duration(env.NORAY_UDP_RELAY_CLEANUP_INTERVAL ?? '30s'), registrarPort: number(env.NORAY_UDP_REGISTRAR_PORT) ?? 8809, diff --git a/src/config.parsers.mjs b/src/config.parsers.mjs index dc7bf97..d619964 100644 --- a/src/config.parsers.mjs +++ b/src/config.parsers.mjs @@ -22,6 +22,19 @@ export function number (value) { return isNaN(result) ? undefined : result } +/** + * Parse config value as enum. + * + * @param {any} value Value + * @param {Array} values Allowed values + * @returns {any?} Allowed value or undefined + */ +export function enumerated (value, values) { + return values.includes(value) + ? value + : undefined +} + /** * Split an input into nominator and unit * @param {string} value @@ -89,14 +102,49 @@ export function duration (value) { } /** - * Parse config value as enum. - * - * @param {any} value Value - * @param {Array} values Allowed values - * @returns {any?} Allowed value or undefined - */ -export function enumerated (value, values) { - return values.includes(value) - ? value - : undefined +* Parse config value as port ranges. +* +* Three kinds of port ranges are parsed: +* 1. literal - e.g. '1007' becomes [1007] +* 1. absolute - e.g. '1024-1026' becomes [1024, 1025, 1026] +* 1. relative - e.g. '1024+2' becomes [1024, 1025, 1026] +* +* These ranges are separated by a comma, e.g.: +* `1007, 1024-1026, 2048+2` +* +* @param {string} value Value +* @returns {number[]} Ports +*/ +export function ports (value) { + if (value === undefined) { + return undefined + } + + const ranges = value.split(',').map(r => r.trim()) + + const literals = ranges + .filter(p => /^\d+$/.test(p)) + .map(integer) + .filter(p => !!p) + .map(p => [p, p]) + + const absolutes = ranges + .filter(r => r.includes('-')) + .map(r => r.split('-').map(integer)) + .filter(r => !r.includes(undefined)) + + const relatives = ranges + .filter(r => r.includes('+')) + .map(r => r.split('+').map(integer)) + .filter(r => !r.includes(undefined)) + .map(([from, offset]) => [from, from + offset]) + + const result = [...literals, ...absolutes, ...relatives] + .flatMap(([from, to]) => + [...new Array(to - from + 1)].map((_, i) => from + i) + ) + .sort() + .filter((v, i, a) => i === 0 || v !== a[i - 1]) // ensure every port is unique + + return result.length > 0 ? result : undefined } diff --git a/src/relay/constraints.mjs b/src/relay/constraints.mjs index fb3cb6c..6849bc1 100644 --- a/src/relay/constraints.mjs +++ b/src/relay/constraints.mjs @@ -5,18 +5,6 @@ import { UDPRelayHandler } from './udp.relay.handler.mjs' import assert from 'node:assert' import { time } from '../utils.mjs' -/** -* Limit the relay table size to a given maximum. This ensures that we won't -* allocate too many relays. -* @param {UDPRelayHandler} relayHandler Relay handler -* @param {number} maxSize Maximum relay table size -*/ -export function constrainRelayTableSize (relayHandler, maxSize) { - relayHandler.on('create', () => { - assert(relayHandler.relayTable.length <= maxSize, 'Relay table size limit reached!') - }) -} - /** * Limit the bandwidth on every relay individually. * @param {UDPRelayHandler} relayHandler Relay handler diff --git a/src/relay/relay.mjs b/src/relay/relay.mjs index a2fde3d..f594ee7 100644 --- a/src/relay/relay.mjs +++ b/src/relay/relay.mjs @@ -1,5 +1,5 @@ import { config } from '../config.mjs' -import { constrainGlobalBandwidth, constrainIndividualBandwidth, constrainLifetime, constrainRelayTableSize, constrainTraffic } from './constraints.mjs' +import { constrainGlobalBandwidth, constrainIndividualBandwidth, constrainLifetime, constrainTraffic } from './constraints.mjs' import { UDPRelayHandler } from './udp.relay.handler.mjs' import { Noray } from '../noray.mjs' import { cleanupUdpRelayTable } from './udp.relay.cleanup.mjs' @@ -8,9 +8,11 @@ import { formatByteSize, formatDuration } from '../utils.mjs' import { UDPRemoteRegistrar } from './udp.remote.registrar.mjs' import { hostRepository } from '../hosts/host.mjs' import { useDynamicRelay } from './dynamic.relaying.mjs' +import { UDPSocketPool } from './udp.socket.pool.mjs' -export const udpRelayHandler = new UDPRelayHandler() -constrainRelayTableSize(udpRelayHandler, config.udpRelay.maxSlots) +export const udpSocketPool = new UDPSocketPool() + +export const udpRelayHandler = new UDPRelayHandler({ socketPool: udpSocketPool }) export const udpRemoteRegistrar = new UDPRemoteRegistrar({ hostRepository, @@ -18,7 +20,7 @@ export const udpRemoteRegistrar = new UDPRemoteRegistrar({ }) const log = logger.child({ name: 'mod:relay' }) -Noray.hook(noray => { +Noray.hook(async noray => { log.info( 'Starting periodic UDP relay cleanup job, running every %s', formatDuration(config.udpRelay.cleanupInterval) @@ -31,6 +33,17 @@ Noray.hook(noray => { log.info('Listening on port %d for UDP remote registrars', config.udpRelay.registrarPort) udpRemoteRegistrar.listen(config.udpRelay.registrarPort) + log.info('Binding %d ports for relaying', config.udpRelay.ports.length) + + for (const port of config.udpRelay.ports) { + log.debug('Binding port %d for relay', port) + try { + await udpSocketPool.allocatePort(port) + } catch (err) { + log.warn({ err }, 'Failed to bind port %d, ignoring', port) + } + } + log.info( 'Limiting relay bandwidth to %s/s and global bandwidth to %s/s', formatByteSize(config.udpRelay.maxIndividualTraffic), @@ -64,6 +77,9 @@ Noray.hook(noray => { log.info('Closing UDP remote registrar socket') udpRemoteRegistrar.socket.close() + log.info('Closing socket pool') + udpSocketPool.clear() + log.info('Closing relay handler') udpRelayHandler.clear() }) diff --git a/src/relay/udp.relay.handler.mjs b/src/relay/udp.relay.handler.mjs index 853b2a5..304a8d1 100644 --- a/src/relay/udp.relay.handler.mjs +++ b/src/relay/udp.relay.handler.mjs @@ -45,9 +45,11 @@ export class UDPRelayHandler extends EventEmitter { /** * Create a relay entry. + * + * If there's already a relay for the address, returns that. + * NOTE: This modifies the incoming relay and returns the same instance. * @param {RelayEntry} relay Relay - * @return {Promise} True if the entry was created, false if it - * already exists + * @return {Promise} Resulting relay * @fires UDPRelayHandler#create */ async createRelay (relay) { @@ -55,25 +57,30 @@ export class UDPRelayHandler extends EventEmitter { if (this.hasRelay(relay)) { // We already have this relay entry log.trace({ relay }, 'Relay already exists, ignoring') - return false + return this.#relayTable.find(e => e.equals(relay)) } + relay.port = this.#socketPool.getPort() this.emit('create', relay) - const socket = await this.#ensurePort(relay.port) - socket.on('message', (msg, rinfo) => { - this.relay(msg, NetAddress.fromRinfo(rinfo), relay.port) - }) + const socket = this.#socketPool.getSocket(relay.port) + socket.removeAllListeners('message') + .on('message', (msg, rinfo) => { + this.relay(msg, NetAddress.fromRinfo(rinfo), relay.port) + }) + relay.lastReceived = time() relay.created = time() this.#relayTable.push(relay) log.trace({ relay }, 'Relay created') - return true + return relay } /** * Check if relay already exists in the table. + * + * NOTE: This only compares the addresses, not the allocated port. * @param {RelayEntry} relay Relay * @returns {boolean} True if relay already exists */ @@ -95,13 +102,13 @@ export class UDPRelayHandler extends EventEmitter { this.emit('destroy', relay) - this.#socketPool.freePort(relay.port) + this.#socketPool.returnPort(relay.port) this.#relayTable = this.#relayTable.filter((_, i) => i !== idx) return true } /** - * Free all relay entries, and by extension, sockets in the pool. + * Free all relay entries. */ clear () { this.relayTable.forEach(entry => this.freeRelay(entry)) diff --git a/src/relay/udp.remote.registrar.mjs b/src/relay/udp.remote.registrar.mjs index f02c913..21fda34 100644 --- a/src/relay/udp.remote.registrar.mjs +++ b/src/relay/udp.remote.registrar.mjs @@ -92,15 +92,13 @@ export class UDPRemoteRegistrar { } host.rinfo = rinfo - const port = await this.#udpRelayHandler.socketPool.allocatePort() - host.relay = port - await this.#udpRelayHandler.createRelay(new RelayEntry({ - address: NetAddress.fromRinfo(rinfo), - port + const relay = await this.#udpRelayHandler.createRelay(new RelayEntry({ + address: NetAddress.fromRinfo(rinfo) })) + host.relay = relay.port log.info( - { host, port }, + { host, port: relay.port }, 'Created relay for host' ) diff --git a/src/relay/udp.socket.pool.mjs b/src/relay/udp.socket.pool.mjs index 6735e94..c5ddd86 100644 --- a/src/relay/udp.socket.pool.mjs +++ b/src/relay/udp.socket.pool.mjs @@ -1,3 +1,4 @@ +import assert from 'node:assert' import dgram from 'node:dgram' /** @@ -17,23 +18,52 @@ export class UDPSocketPool { */ #sockets = new Map() + /** + * Free ports + * @type {number[]} + */ + #freePorts = [] + /** * Allocate a new port for relaying. * * If port is unset or 0, a random port will be picked by the OS. * @param {number} [port=0] Port to allocate * @returns {Promise} Allocated port + * @throws if allocation fails */ allocatePort (port) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const socket = dgram.createSocket('udp4') - socket.bind(port, () => { + socket.once('error', reject) + socket.bind(port ?? 0, () => { port = this.addSocket(socket) resolve(port) }) }) } + /** + * Close the socket associated with the given port. + * + * Does nothing if the port is not managed by the relay. + * @param {number} port Port + */ + deallocatePort (port) { + this.#sockets.get(port)?.close() + this.#sockets.delete(port) + this.#freePorts = this.#freePorts.filter(p => p !== port) + } + + /** + * Get socket listening on port. + * @param {number} port Port + * @returns {dgram.Socket|undefined} Socket + */ + getSocket (port) { + return this.#sockets.get(port) + } + /** * Add an already listening socket to use for relaying. * @param {dgram.Socket} socket Socket @@ -42,28 +72,51 @@ export class UDPSocketPool { addSocket (socket) { const port = socket.address().port this.#sockets.set(port, socket) + this.#freePorts.push(port) return port } /** - * Close the socket associated with the given port. + * Get a free port to use for relaying. * - * Does nothing if the port is not managed by the relay. - * @param {number} port Port + * The resulting port can be converted into a socket using `getSocket`. + * @returns {number} + * @throws if no free ports are available */ - freePort (port) { - this.#sockets.get(port)?.close() - this.#sockets.delete(port) + getPort () { + assert(this.#freePorts.length > 0, 'No more free ports!') + return this.#freePorts.pop() } /** - * Get socket listening on port. - * @param {number} port Port - * @returns {dgram.Socket|undefined} Socket + * Returns a port to the pool. + * + * After this call, the port can be reused. Does nothing if the port is not + * managed by the relay. @param {number} port Port */ - getSocket (port) { - return this.#sockets.get(port) + returnPort (port) { + if (!this.#sockets.has(port)) { + return + } + + this.#freePorts.push(port) + } + + /** + * Check if there are any free ports in the pool. @returns {boolean} True if + * there's a free port, false otherwise + */ + hasFreePort () { + return this.#freePorts.length > 0 + } + + /** + * Close all sockets managed by this pool, freeing up all associated system + * resources. + */ + clear () { + [...this.#sockets.keys()].forEach(port => this.deallocatePort(port)) } /** diff --git a/test/e2e/connect.relay.test.mjs b/test/e2e/connect.relay.test.mjs index a9243bf..d890ebb 100644 --- a/test/e2e/connect.relay.test.mjs +++ b/test/e2e/connect.relay.test.mjs @@ -114,7 +114,6 @@ describe('Connection', () => { // Check if data went through both ways context.log.info('Waiting for messages to go through') await sleep(0.1) - // TODO: Why tf data not getting through?? assert.equal(hostReceived.join(''), message) assert.equal(clientReceived.join(''), response) }) diff --git a/test/spec/config.parsers.test.mjs b/test/spec/config.parsers.test.mjs index 5e58073..ebe94ae 100644 --- a/test/spec/config.parsers.test.mjs +++ b/test/spec/config.parsers.test.mjs @@ -1,6 +1,6 @@ import { describe, it } from 'node:test' import assert from 'node:assert' -import { byteSize, duration, enumerated, integer, number } from '../../src/config.parsers.mjs' +import { byteSize, duration, enumerated, integer, number, ports } from '../../src/config.parsers.mjs' function Case(name, input, expected) { return { name, input, expected } @@ -117,3 +117,19 @@ describe('duration', () => { )) ) }) + +describe('ports', () => { + const cases = [ + Case('should parse literal', '1024', [1024]), + Case('should parse absolute', '1024-1026', [1024, 1025, 1026]), + Case('should parse relative', '2048+3', [2048, 2049, 2050, 2051]), + Case('should parse single absolute', '1024-1024', [1024]), + Case('should parse single relative', '1024+0', [1024]), + Case('should return sorted', '2048+1, 1024-1025', [1024, 1025, 2048, 2049]), + Case('should return unique', '1-4, 2, 2-6', [1, 2, 3, 4, 5, 6]) + ] + + cases.forEach(kase => + it(kase.name, () => assert.deepEqual(ports(kase.input), kase.expected)) + ) +}) diff --git a/test/spec/relay/constraints.test.mjs b/test/spec/relay/constraints.test.mjs index bb75c94..200cc73 100644 --- a/test/spec/relay/constraints.test.mjs +++ b/test/spec/relay/constraints.test.mjs @@ -5,52 +5,9 @@ import { RelayEntry } from '../../../src/relay/relay.entry.mjs' import { NetAddress } from '../../../src/relay/net.address.mjs' import { UDPRelayHandler } from '../../../src/relay/udp.relay.handler.mjs' import { time } from '../../../src/utils.mjs' -import { constrainGlobalBandwidth, constrainIndividualBandwidth, constrainLifetime, constrainRelayTableSize, constrainTraffic } from '../../../src/relay/constraints.mjs' +import { constrainGlobalBandwidth, constrainIndividualBandwidth, constrainLifetime, constrainTraffic } from '../../../src/relay/constraints.mjs' describe('Relay constraints', () => { - describe('constrainRelayTableSize', () => { - const relayTable = [ - new RelayEntry({ - address: new NetAddress({ address: '37.89.0.5', port: 32467 }), - port: 10001 - }), - - new RelayEntry({ - address: new NetAddress({ address: '57.13.0.9', port: 45357 }), - port: 10002 - }), - ] - - it('should allow', () => { - // Given - const relayHandler = sinon.createStubInstance(UDPRelayHandler) - sinon.stub(relayHandler, 'relayTable').value(relayTable) - relayHandler.on.callThrough() - relayHandler.emit.callThrough() - - constrainRelayTableSize(relayHandler, 2) - - // When + Then - assert.doesNotThrow( - () => relayHandler.emit('create', relayTable[1]) - ) - }) - - it('should throw', () => { - const relayHandler = sinon.createStubInstance(UDPRelayHandler) - sinon.stub(relayHandler, 'relayTable').value(relayTable) - relayHandler.on.callThrough() - relayHandler.emit.callThrough() - - constrainRelayTableSize(relayHandler, 1) - - // When + Then - assert.throws( - () => relayHandler.emit('create', relayTable[1]) - ) - }) - }) - describe('constrainIndividualBandwidth', () => { it('should pass', () => { // Given diff --git a/test/spec/relay/udp.relay.handler.test.mjs b/test/spec/relay/udp.relay.handler.test.mjs index e2874d6..e2c5bdc 100644 --- a/test/spec/relay/udp.relay.handler.test.mjs +++ b/test/spec/relay/udp.relay.handler.test.mjs @@ -14,10 +14,11 @@ describe('UDPRelayHandler', () => { const handler = sinon.stub() const socket = sinon.createStubInstance(dgram.Socket) const socketPool = sinon.createStubInstance(UDPSocketPool) + socketPool.getPort.returns(10001) socketPool.getSocket.returns(socket) + socket.removeAllListeners.returnsThis() const relay = new RelayEntry({ - port: 10001, address: new NetAddress({ address: '88.57.0.107', port: '32279' @@ -31,10 +32,11 @@ describe('UDPRelayHandler', () => { relayHandler.on('create', handler) // When - await relayHandler.createRelay(relay) + const result = await relayHandler.createRelay(relay) // Then assert.deepEqual(relayHandler.relayTable, [relay]) + assert(relay.port, 'No port assigned to relay!') assert(handler.calledWith(relay), 'Create event not emitted!') }) @@ -43,9 +45,9 @@ describe('UDPRelayHandler', () => { const socket = sinon.createStubInstance(dgram.Socket) const socketPool = sinon.createStubInstance(UDPSocketPool) socketPool.getSocket.returns(socket) + socket.removeAllListeners.returnsThis() const relay = new RelayEntry({ - port: 10001, address: new NetAddress({ address: '88.57.0.107', port: '32279' @@ -61,7 +63,7 @@ describe('UDPRelayHandler', () => { const result = await relayHandler.createRelay(relay) // When - assert.equal(result, false) + assert.equal(result, relay) assert.deepEqual(relayHandler.relayTable, [relay]) }) }) @@ -72,10 +74,11 @@ describe('UDPRelayHandler', () => { const handler = sinon.stub() const socket = sinon.createStubInstance(dgram.Socket) const socketPool = sinon.createStubInstance(UDPSocketPool) + socketPool.getPort.returns(10001) socketPool.getSocket.returns(socket) + socket.removeAllListeners.returnsThis() const relay = new RelayEntry({ - port: 10001, address: new NetAddress({ address: '88.57.0.107', port: '32279' @@ -93,7 +96,7 @@ describe('UDPRelayHandler', () => { // When assert.equal(result, true) - assert(socketPool.freePort.calledOnceWith(10001)) + assert(socketPool.returnPort.calledOnceWith(10001)) assert.deepEqual(relayHandler.relayTable, []) assert(handler.calledWith(relay), 'Destroy event not emitted!') }) @@ -103,9 +106,9 @@ describe('UDPRelayHandler', () => { const socket = sinon.createStubInstance(dgram.Socket) const socketPool = sinon.createStubInstance(UDPSocketPool) socketPool.getSocket.returns(socket) + socket.removeAllListeners.returnsThis() const relay = new RelayEntry({ - port: 10001, address: new NetAddress({ address: '88.57.0.107', port: 32279 @@ -113,7 +116,6 @@ describe('UDPRelayHandler', () => { }) const unknownRelay = new RelayEntry({ - port: 1002, address: new NetAddress({ address: '89.45.0.109', port: 32279 @@ -130,7 +132,7 @@ describe('UDPRelayHandler', () => { // When assert.equal(result, false) - assert(socketPool.freePort.notCalled) + assert(socketPool.deallocatePort.notCalled) assert.deepEqual(relayHandler.relayTable, [relay]) }) }) @@ -141,7 +143,10 @@ describe('UDPRelayHandler', () => { const message = Buffer.from('Hello!', 'utf-8') const socket = sinon.createStubInstance(dgram.Socket) const socketPool = sinon.createStubInstance(UDPSocketPool) + socketPool.getPort.onFirstCall().returns(10001) + socketPool.getPort.onSecondCall().returns(10002) socketPool.getSocket.returns(socket) + socket.removeAllListeners.returnsThis() const handler = sinon.stub() const relayHandler = new UDPRelayHandler({ @@ -150,7 +155,6 @@ describe('UDPRelayHandler', () => { relayHandler.on('transmit', handler) await relayHandler.createRelay(new RelayEntry({ - port: 10001, address: new NetAddress({ address: '88.57.0.17', port: 32279 @@ -158,7 +162,6 @@ describe('UDPRelayHandler', () => { })) await relayHandler.createRelay(new RelayEntry({ - port: 10002, address: new NetAddress({ address: '88.59.62.107', port: 65227 @@ -184,7 +187,9 @@ describe('UDPRelayHandler', () => { const message = Buffer.from('Hello!', 'utf-8') const socket = sinon.createStubInstance(dgram.Socket) const socketPool = sinon.createStubInstance(UDPSocketPool) + socketPool.getPort.onFirstCall().returns(10001) socketPool.getSocket.returns(socket) + socket.removeAllListeners.returnsThis() const dropHandler = sinon.spy() @@ -193,7 +198,6 @@ describe('UDPRelayHandler', () => { }) await relayHandler.createRelay(new RelayEntry({ - port: 10001, address: new NetAddress({ address: '88.57.0.17', port: 32279 @@ -219,14 +223,16 @@ describe('UDPRelayHandler', () => { const message = Buffer.from('Hello!', 'utf-8') const socket = sinon.createStubInstance(dgram.Socket) const socketPool = sinon.createStubInstance(UDPSocketPool) + socketPool.getPort.onFirstCall().returns(10001) + socketPool.getPort.onSecondCall().returns(10002) socketPool.getSocket.returns(socket) + socket.removeAllListeners.returnsThis() const relayHandler = new UDPRelayHandler({ socketPool }) await relayHandler.createRelay(new RelayEntry({ - port: 10001, address: new NetAddress({ address: '88.57.0.17', port: 32279 @@ -234,7 +240,6 @@ describe('UDPRelayHandler', () => { })) await relayHandler.createRelay(new RelayEntry({ - port: 10002, address: new NetAddress({ address: '88.59.62.107', port: 65227 diff --git a/test/spec/relay/udp.remote.registrar.test.mjs b/test/spec/relay/udp.remote.registrar.test.mjs index 7f40549..133b49f 100644 --- a/test/spec/relay/udp.remote.registrar.test.mjs +++ b/test/spec/relay/udp.remote.registrar.test.mjs @@ -24,8 +24,6 @@ describe('UDPRemoteRegistrar', () => { /** @type {UDPRemoteRegistrar} */ let remoteRegistrar - const allocatedPort = 10002 - /** @type {HostEntity} */ let host @@ -40,11 +38,6 @@ describe('UDPRemoteRegistrar', () => { relayHandler = sinon.createStubInstance(UDPRelayHandler) socket = sinon.createStubInstance(dgram.Socket) - const socketPool = sinon.createStubInstance(UDPSocketPool) - socketPool.allocatePort.resolves(allocatedPort) - - sinon.stub(relayHandler, 'socketPool').value(socketPool) - hostRepository.findByPid.withArgs(host.pid).returns(host) socket.bind.callsArg(2) // Instantly resolve on bind socket.address.returns({ @@ -65,18 +58,24 @@ describe('UDPRemoteRegistrar', () => { await remoteRegistrar.listen() const messageHandler = socket.on.lastCall.callback + relayHandler.createRelay.resolves({ + address: NetAddress.fromRinfo(rinfo), + port: 32768 + }) + // When await messageHandler(msg, rinfo) // Then assert.deepEqual( relayHandler.createRelay.lastCall?.args?.at(0), - new RelayEntry({ address: NetAddress.fromRinfo(rinfo), port: allocatedPort }) + new RelayEntry({ address: NetAddress.fromRinfo(rinfo) }) ) assert.deepEqual( socket.send.lastCall?.args, ['OK', rinfo.port, rinfo.address] ) + assert.equal(host.relay, 32768) }) it('should fail on unknown pid', async () => { diff --git a/test/spec/relay/udp.socket.pool.test.mjs b/test/spec/relay/udp.socket.pool.test.mjs index d3cf22d..59b1a9b 100644 --- a/test/spec/relay/udp.socket.pool.test.mjs +++ b/test/spec/relay/udp.socket.pool.test.mjs @@ -14,7 +14,7 @@ describe('UDPSocketPool', () => { const port = await pool.allocatePort() // Finally - pool.freePort(port) + pool.deallocatePort(port) }) }) @@ -34,10 +34,11 @@ describe('UDPSocketPool', () => { // Then assert.deepEqual(pool.ports, [10001]) + assert.equal(pool.getSocket(10001), socket) }) }) - describe('freePort', () => { + describe('deallocatePort', () => { it('should call close', () => { // Given const socket = sinon.createStubInstance(dgram.Socket) @@ -51,7 +52,7 @@ describe('UDPSocketPool', () => { pool.addSocket(socket) // When - pool.freePort(7879) + pool.deallocatePort(7879) // Then assert(socket.close.calledOnce) @@ -71,10 +72,63 @@ describe('UDPSocketPool', () => { pool.addSocket(socket) // When - pool.freePort(7876) + pool.deallocatePort(7876) // Then assert(socket.close.notCalled) }) }) + + describe ('getPort', () => { + it('should return allocated', async () => { + // Given + const pool = new UDPSocketPool() + const expected = await pool.allocatePort() + + // When + const actual = pool.getPort() + + // Then + assert(!pool.hasFreePort()) + assert.equal(actual, expected) + + // Finally + pool.deallocatePort(expected) + }) + + it('should throw if none available', () => { + // Given + const pool = new UDPSocketPool() + + // When + Then + assert(!pool.hasFreePort()) + assert.throws(() => pool.getPort()) + }) + }) + + describe('returnPort', () => { + it('should make port available', async () => { + // Given + const pool = new UDPSocketPool() + await pool.allocatePort() + const port = pool.getPort() + + // When + pool.returnPort(port) + + // Then + assert(pool.hasFreePort()) + + // Finally + pool.deallocatePort(port) + }) + + it('should ignore unknown', async () => { + // Given + const pool = new UDPSocketPool() + + // When + then + assert.doesNotThrow(() => pool.returnPort(65575)) + }) + }) }) From d428424f0be83c420c9823f2f044ac0570c0642e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Fri, 7 Jul 2023 00:03:44 +0200 Subject: [PATCH 16/34] feat: On-demand relay allocation (#30) Closes #26 --- package.json | 2 +- src/connection/connection.commands.mjs | 27 ++++++++++++++++--- src/relay/udp.remote.registrar.mjs | 24 +++-------------- test/spec/relay/udp.remote.registrar.test.mjs | 23 +++------------- 4 files changed, 30 insertions(+), 46 deletions(-) diff --git a/package.json b/package.json index b46965d..125f5fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.21.0", + "version": "0.22.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/connection/connection.commands.mjs b/src/connection/connection.commands.mjs index 9813b07..b0c43a3 100644 --- a/src/connection/connection.commands.mjs +++ b/src/connection/connection.commands.mjs @@ -4,6 +4,9 @@ import { HostRepository } from '../hosts/host.repository.mjs' /* eslint-enable */ import assert from 'node:assert' import logger from '../logger.mjs' +import { udpRelayHandler } from '../relay/relay.mjs' +import { RelayEntry } from '../relay/relay.entry.mjs' +import { NetAddress } from '../relay/net.address.mjs' /** * @param {HostRepository} hostRepository @@ -48,7 +51,7 @@ export function handleConnectRelay (hostRepository) { * @param {ProtocolServer} server */ return function (server) { - server.on('connect-relay', (data, socket) => { + server.on('connect-relay', async (data, socket) => { const log = logger.child({ name: 'cmd:connect-relay' }) const oid = data @@ -59,11 +62,13 @@ export function handleConnectRelay (hostRepository) { 'Client attempting to connect to host' ) assert(host, 'Unknown host oid: ' + oid) - assert(host.relay, 'Host has no relay!') assert(client, 'Unknown client from address') - assert(client.relay, 'Client has no relay!') - log.debug({ relay: host.relay }, 'Replying with relay') + log.debug('Ensuring relay for both parties') + host.relay = await getRelay(host.rinfo) + client.relay = await getRelay(client.rinfo) + + log.debug({ host: host.relay, client: client.relay }, 'Replying with relay') server.send(socket, 'connect-relay', host.relay) server.send(host.socket, 'connect-relay', client.relay) log.debug( @@ -77,3 +82,17 @@ export function handleConnectRelay (hostRepository) { function stringifyAddress (address) { return `${address.address}:${address.port}` } + +async function getRelay (rinfo) { + // Attempt to create new relay on each connect + // If there's a relay already, UDPRelayHandler will return that + // If there's no relay, or it has expired, a new one will be created + const log = logger.child({ name: 'getRelay' }) + log.trace({ rinfo }, 'Ensuring relay for remote') + const relayEntry = await udpRelayHandler.createRelay( + new RelayEntry({ address: NetAddress.fromRinfo(rinfo) }) + ) + + log.trace({ relayEntry }, 'Created relay, returning with port %d', relayEntry.port) + return relayEntry.port +} diff --git a/src/relay/udp.remote.registrar.mjs b/src/relay/udp.remote.registrar.mjs index 21fda34..b05801f 100644 --- a/src/relay/udp.remote.registrar.mjs +++ b/src/relay/udp.remote.registrar.mjs @@ -1,13 +1,10 @@ /* eslint-disable */ -import { UDPRelayHandler } from './udp.relay.handler.mjs' import { HostRepository } from '../hosts/host.repository.mjs' /* eslint-enable */ import dgram from 'node:dgram' import assert from 'node:assert' -import { RelayEntry } from './relay.entry.mjs' -import { NetAddress } from './net.address.mjs' -import { requireParam } from '../assertions.mjs' import logger from '../logger.mjs' +import { requireParam } from '../assertions.mjs' const log = logger.child({ name: 'UDPRemoteRegistrar' }) @@ -29,19 +26,14 @@ export class UDPRemoteRegistrar { /** @type {HostRepository} */ #hostRepository - /** @type {UDPRelayHandler} */ - #udpRelayHandler - /** * Construct instance. * @param {object} options Options * @param {HostRepository} options.hostRepository Host repository - * @param {UDPRelayHandler} options.udpRelayHandler UDP relay handler * @param {dgram.Socket} [options.socket] Socket */ constructor (options) { this.#hostRepository = requireParam(options.hostRepository) - this.#udpRelayHandler = requireParam(options.udpRelayHandler) this.#socket = options.socket ?? dgram.createSocket('udp4') } @@ -85,23 +77,13 @@ export class UDPRemoteRegistrar { const host = this.#hostRepository.findByPid(pid) assert(host, 'Unknown host pid!') - if (host.relay) { - // Host has already a relay + if (host.rinfo) { + // Host has already remote info registered this.#socket.send('OK', rinfo.port, rinfo.address) return } host.rinfo = rinfo - const relay = await this.#udpRelayHandler.createRelay(new RelayEntry({ - address: NetAddress.fromRinfo(rinfo) - })) - host.relay = relay.port - - log.info( - { host, port: relay.port }, - 'Created relay for host' - ) - this.#socket.send('OK', rinfo.port, rinfo.address) } catch (e) { this.#socket.send(e.message ?? 'Error', rinfo.port, rinfo.address) diff --git a/test/spec/relay/udp.remote.registrar.test.mjs b/test/spec/relay/udp.remote.registrar.test.mjs index 133b49f..10963ad 100644 --- a/test/spec/relay/udp.remote.registrar.test.mjs +++ b/test/spec/relay/udp.remote.registrar.test.mjs @@ -2,13 +2,9 @@ import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert' import sinon from 'sinon' import dgram from 'node:dgram' -import { UDPRelayHandler } from '../../../src/relay/udp.relay.handler.mjs' import { UDPRemoteRegistrar } from '../../../src/relay/udp.remote.registrar.mjs' -import { NetAddress } from '../../../src/relay/net.address.mjs' -import { RelayEntry } from '../../../src/relay/relay.entry.mjs' import { HostRepository } from '../../../src/hosts/host.repository.mjs' import { HostEntity } from '../../../src/hosts/host.entity.mjs' -import { UDPSocketPool } from '../../../src/relay/udp.socket.pool.mjs' describe('UDPRemoteRegistrar', () => { /** @type {sinon.SinonFakeTimers} */ @@ -16,8 +12,6 @@ describe('UDPRemoteRegistrar', () => { /** @type {sinon.SinonStubbedInstance} */ let hostRepository - /** @type {sinon.SinonStubbedInstance} */ - let relayHandler /** @type {sinon.SinonStubbedInstance} */ let socket @@ -35,7 +29,6 @@ describe('UDPRemoteRegistrar', () => { clock = sinon.useFakeTimers() hostRepository = sinon.createStubInstance(HostRepository) - relayHandler = sinon.createStubInstance(UDPRelayHandler) socket = sinon.createStubInstance(dgram.Socket) hostRepository.findByPid.withArgs(host.pid).returns(host) @@ -46,7 +39,7 @@ describe('UDPRemoteRegistrar', () => { }) remoteRegistrar = new UDPRemoteRegistrar({ - hostRepository, udpRelayHandler: relayHandler, socket + hostRepository, socket }) }) @@ -58,24 +51,15 @@ describe('UDPRemoteRegistrar', () => { await remoteRegistrar.listen() const messageHandler = socket.on.lastCall.callback - relayHandler.createRelay.resolves({ - address: NetAddress.fromRinfo(rinfo), - port: 32768 - }) - // When await messageHandler(msg, rinfo) // Then - assert.deepEqual( - relayHandler.createRelay.lastCall?.args?.at(0), - new RelayEntry({ address: NetAddress.fromRinfo(rinfo) }) - ) assert.deepEqual( socket.send.lastCall?.args, ['OK', rinfo.port, rinfo.address] ) - assert.equal(host.relay, 32768) + assert.equal(host.rinfo, rinfo) }) it('should fail on unknown pid', async () => { @@ -92,7 +76,6 @@ describe('UDPRemoteRegistrar', () => { await messageHandler(msg, rinfo) // Then - assert(relayHandler.createRelay.notCalled, 'A relay was created!') assert.deepEqual( socket.send.lastCall?.args, ['Unknown host pid!', rinfo.port, rinfo.address] @@ -107,7 +90,7 @@ describe('UDPRemoteRegistrar', () => { await remoteRegistrar.listen() const messageHandler = socket.on.lastCall.callback - relayHandler.createRelay.throws('Error', 'Test') + socket.send.onFirstCall().throws(new Error('Test')) // When await messageHandler(msg, rinfo) From 73ff4be050927149806f87ecb3eff471222e8f1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Fri, 7 Jul 2023 23:20:51 +0200 Subject: [PATCH 17/34] chore: Remove dynamic relaying (#31) Closes #27 --- package.json | 2 +- src/relay/dynamic.relaying.mjs | 88 --------------- src/relay/relay.mjs | 4 - test/spec/relay/dynamic.relaying.test.mjs | 130 ---------------------- 4 files changed, 1 insertion(+), 223 deletions(-) delete mode 100644 src/relay/dynamic.relaying.mjs delete mode 100644 test/spec/relay/dynamic.relaying.test.mjs diff --git a/package.json b/package.json index 125f5fb..0dd571f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.22.0", + "version": "0.22.1", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/relay/dynamic.relaying.mjs b/src/relay/dynamic.relaying.mjs deleted file mode 100644 index 34227a0..0000000 --- a/src/relay/dynamic.relaying.mjs +++ /dev/null @@ -1,88 +0,0 @@ -/* eslint-disable */ -import { NetAddress } from './net.address.mjs' -import { UDPRelayHandler } from './udp.relay.handler.mjs' -/* eslint-enable */ -import logger from '../logger.mjs' -import { RelayEntry } from './relay.entry.mjs' - -const log = logger.child({ name: 'DynamicRelaying' }) - -/** -* Implementation for dynamic relaying. -* -* Whenever an unknown client tries to send data to a known host through its -* relay address, dynamic relaying will create a new relay. -* -* While it's waiting for the relay to be created, it will buffer any incoming -* data and send it all once the relay is created. -*/ -export class DynamicRelaying { - /** @type {Map} */ - #buffers = new Map() - - /** - * Apply dynamic relay creation to relay handler. - * @param {UDPRelayHandler} relayHandler Relay handler - */ - apply (relayHandler) { - relayHandler.on('drop', - (senderRelay, targetRelay, senderAddress, targetPort, message) => - this.#handle(relayHandler, senderRelay, targetRelay, senderAddress, targetPort, message) - ) - } - - /** - * @param {UDPRelayHandler} relayHandler - * @param {RelayEntry} senderRelay - * @param {RelayEntry} targetRelay - * @param {NetAddress} senderAddress - * @param {number} targetPort - * @param {Buffer} message - */ - async #handle (relayHandler, senderRelay, targetRelay, senderAddress, targetPort, message) { - // Unknown host or client already has relay, ignore - if (senderRelay || !targetRelay) { - return - } - - const key = senderAddress.toString() + '>' + targetPort - - // We're already buffering for client, save data end return - if (this.#buffers.has(key)) { - this.#buffers.get(key).push(message) - return - } - - // No buffer for client yet, start buffering and create relay - log.info( - { from: senderAddress, to: targetRelay.address }, - 'Creating dynamic relay' - ) - this.#buffers.set(key, [message]) - const port = await relayHandler.socketPool.allocatePort() - const relay = new RelayEntry({ - address: senderAddress, - port - }) - await relayHandler.createRelay(relay) - - log.info( - { relay }, - 'Relay created, sending %d packets', - this.#buffers.get(key)?.length ?? 0 - ) - this.#buffers.get(key).forEach(msg => - relayHandler.relay(msg, senderAddress, targetPort) - ) - - this.#buffers.delete(key) - } -} - -/** -* Apply dynamic relaying to relay handler. -* @param {UDPRelayHandler} relayHandler Relay handler -*/ -export function useDynamicRelay (relayHandler) { - new DynamicRelaying().apply(relayHandler) -} diff --git a/src/relay/relay.mjs b/src/relay/relay.mjs index f594ee7..d9d3e41 100644 --- a/src/relay/relay.mjs +++ b/src/relay/relay.mjs @@ -7,7 +7,6 @@ import logger from '../logger.mjs' import { formatByteSize, formatDuration } from '../utils.mjs' import { UDPRemoteRegistrar } from './udp.remote.registrar.mjs' import { hostRepository } from '../hosts/host.mjs' -import { useDynamicRelay } from './dynamic.relaying.mjs' import { UDPSocketPool } from './udp.socket.pool.mjs' export const udpSocketPool = new UDPSocketPool() @@ -66,9 +65,6 @@ Noray.hook(async noray => { constrainLifetime(udpRelayHandler, config.udpRelay.maxLifetimeDuration) constrainTraffic(udpRelayHandler, config.udpRelay.maxLifetimeTraffic) - log.info('Applying dynamic relaying') - useDynamicRelay(udpRelayHandler) - log.info('Adding shutdown hooks') noray.on('close', () => { log.info('Noray shutting down, cancelling UDP relay cleanup job') diff --git a/test/spec/relay/dynamic.relaying.test.mjs b/test/spec/relay/dynamic.relaying.test.mjs deleted file mode 100644 index d5c6b36..0000000 --- a/test/spec/relay/dynamic.relaying.test.mjs +++ /dev/null @@ -1,130 +0,0 @@ -import { beforeEach, afterEach, describe, it } from 'node:test' -import assert from 'node:assert' -import sinon from 'sinon' -import { UDPRelayHandler } from '../../../src/relay/udp.relay.handler.mjs' -import { sleep } from '../../../src/utils.mjs' -import { useDynamicRelay } from '../../../src/relay/dynamic.relaying.mjs' -import { RelayEntry } from '../../../src/relay/relay.entry.mjs' -import { NetAddress } from '../../../src/relay/net.address.mjs' -import { UDPSocketPool } from '../../../src/relay/udp.socket.pool.mjs' - -describe('DynamicRelaying', () => { - let clock - - beforeEach(() => { - clock = sinon.useFakeTimers() - }) - - it('should create relay', async () => { - // Given - const socketPool = sinon.createStubInstance(UDPSocketPool) - socketPool.allocatePort.resolves(10000) - - const relayHandler = sinon.createStubInstance(UDPRelayHandler) - relayHandler.on.callThrough() - relayHandler.emit.callThrough() - sinon.stub(relayHandler, 'socketPool').value(socketPool) - - relayHandler.createRelay.resolves(true) - useDynamicRelay(relayHandler) - - const senderRelay = undefined - const targetRelay = new RelayEntry({ - address: new NetAddress({ address: '87.54.0.16', port: 16752 }), - port: 10007 - }) - const senderAddress = new NetAddress({ address: '97.32.4.16', port: 32775 }) - const targetPort = targetRelay.port - const messages = [ - 'hello', 'world', 'use', 'noray' - ].map(message => Buffer.from(message)) - - // When - messages.forEach(message => - relayHandler.emit('drop', senderRelay, targetRelay, senderAddress, targetPort, message) - ) - clock.restore() - await sleep(0.05) // Wait for relay to be created - clock = sinon.useFakeTimers() - - // Then - const createdRelay = relayHandler.createRelay.lastCall.args[0] - assert(createdRelay, 'Relay was not created!') - assert.equal(createdRelay.address, senderAddress) - assert.equal(createdRelay.port, 10000) - - const sent = relayHandler.relay.getCalls().map(call => call.args[0]?.toString()) - messages.forEach(message => - assert( - sent.includes(message.toString()), - `Message "${message.toString()}" was not sent!` - ) - ) - }) - - it('should ignore known sender', async () => { - // Given - const relayHandler = sinon.createStubInstance(UDPRelayHandler) - relayHandler.on.callThrough() - relayHandler.emit.callThrough() - - useDynamicRelay(relayHandler) - - const senderRelay = new RelayEntry({ - address: new NetAddress({ address: '87.54.0.16', port: 16752 }), - port: 10007 - }) - - const targetRelay = undefined - const senderAddress = new NetAddress(senderRelay.address) - const targetPort = 10057 - const messages = [ - 'hello', 'world', 'use', 'noray' - ].map(message => Buffer.from(message)) - - // When - messages.forEach(message => - relayHandler.emit('drop', senderRelay, targetRelay, senderAddress, targetPort, message) - ) - clock.restore() - await sleep(0.05) // Wait for relay to be created - clock = sinon.useFakeTimers() - - // Then - assert(relayHandler.createRelay.notCalled) - assert(relayHandler.relay.notCalled) - }) - - it('should ignore unknown target', async () => { - // Given - const relayHandler = sinon.createStubInstance(UDPRelayHandler) - relayHandler.on.callThrough() - relayHandler.emit.callThrough() - - useDynamicRelay(relayHandler) - - const senderRelay = undefined - const targetRelay = undefined - const senderAddress = new NetAddress({ address: '87.54.0.16', port: 16752 }) - const targetPort = 10057 - const messages = [ - 'hello', 'world', 'use', 'noray' - ].map(message => Buffer.from(message)) - - // When - messages.forEach(message => - relayHandler.emit('drop', senderRelay, targetRelay, senderAddress, targetPort, message) - ) - clock.restore() - await sleep(0.05) // Wait for relay to be created - clock = sinon.useFakeTimers() - - // Then - assert(relayHandler.createRelay.notCalled) - assert(relayHandler.relay.notCalled) - }) - - afterEach(() => { - clock.restore() - }) -}) From 1f1467d843850bdb3ae064c72fd0b112ab2f0cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 8 Jul 2023 20:49:45 +0200 Subject: [PATCH 18/34] doc: Update README (#32) --- LICENSE | 18 +++++ README.md | 191 +++++++++++++++++++++++++++++++++++++++------------ package.json | 2 +- 3 files changed, 165 insertions(+), 46 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d6238dc --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +Copyright 2023 Gálffy Tamás + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index fb23e54..520df90 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,75 @@ # noray -A fork of [Natty](https://github.com/foxssake/natty) for open-source purposes. - -## Motivation - -While Natty is open-source, its scope becomes quite large from v1 onwards - -managing users, supporting multiple different games, different orchestration -strategies, multiple sessions per user, lobbies, etc. This can make Natty an -unwieldy solution for situations where you just want to get something running -online, or if you don't plan on running a whole platform for some individual -multiplayer games. - -This is the niche noray intends to fill - a very simple server that manages -connectivity between players. Anything more than that is the responsibility of -the game or some other backend service unrelated to noray. - -Thankfully, at the point of writing, Natty implements most of the features -needed for noray, so it can start its life as a stripped-down fork. - -## Scope - -To ensure connection, noray will support *NAT punchthrough orchestration* and -*UDP relays*. - -A game would happen through the following flow: - -- The host connects to noray and sends a host request - - noray replies with the host's OpenID and PrivateID -- The host sends its PID to noray's UDP remote registrar port - - noray saves the host's external address for UDP comms - - noray allocates a relay for the host -- Clients connect to noray and send a register host request1 - - noray replies with the client's OpenID and PrivateID -- Clients send their PIDs to noray's UDP remote registrar port - - noray saves the external addresses and allocates relays2 -- Clients send a connect request to noray with the host's OID -- noray sends a handshake message to both parties - - The host receives the client's external address - - The client receives the host's external address -- If the handshake succeeds, the client connects to the host -- If the handshake fails, the client sends a relay connect request to noray - - The client receives the host's relay address to connect to - - The host receives the client's relay address to connect to - - noray will relay the traffic - -## Protocol +A simple connection orchestrator and relay to bulletproof connectivity for your +online multiplayer games. + +Forked from [Natty](https://github.com/foxssake/natty) that aims to cover an +extended scope. + +## Why orchestration + +If you're already familiar with the topic, noray can help with NAT punchthrough. + +If you're not familiar with the issue, I'd highly recommend reading Keith +Johnston's [article] on the topic - it's very easy to follow and sums up the +topic really well. + +But to give you a short summary: + +* Most PC's online are behind a router +* Routers will only allow traffic to your PC if it's in response to something + * i.e. Google can't just send you traffic out of nowhere, but your router + will allow traffic from Google if you've already sent an HTTP request +* Similarly, if you host an online game, people won't be able to connect to + your PC +* NAT punchthrough is the process of both parties sending traffic to eachother + * The first packets will fail, as the router doesn't see it as response to + something + * The next packets should succeed, as the router sees that your PC is already + trying to connect to the other part + +*noray* helps by orchestrating the NAT punchthrough process 🔥 + +[article]: https://keithjohnston.wordpress.com/2014/02/17/nat-punch-through-for-multiplayer-games/ + +## Why relaying + +Unfortunately, even NAT punchthrough is not always a viable solution, depending +on your players' NAT setup. + +To make sure that your players can always connect to eachother, *noray* can act +as a relay 🔥 + +In essence, *noray* will dedicate a specific port to each player, at which +others can send data to them. Any data incoming on this dedicated port will be +transmitted as-is to the appropriate player. + +*NOTE:* Relaying only supports UDP traffic. + +## Dependencies + +* [node](https://nodejs.org/en/download) v18.16 or newer + * *NOTE:* Older versions may work, but are not explicitly supported +* [pnpm](https://pnpm.io/installation) + +## Installation + +After cloning the repository, run `pnpm install` to install all required packages. + +## Configuration + +*noray* can be configured through environment variables or a `.env` file. For available configuration keys and their purpose, please see the [example configuration](.env.example). + +## Usage + +To run *noray*, use `pnpm start` or `pnpm start:prod` for production use. + +Upon startup, the application will allocate all the configured ports and start +listening for incoming connections. Logs are written to `stdout`. + +## Documentation + +### Protocol To keep things simple, data is transmitted through TCP as newline-separated strings. Each line starts with a command, a space, and the rest of the line is @@ -56,3 +81,79 @@ connect-relay host-1 The protocol has no concept of replies, threads, correspondences or anything similar. Think of it as a dumbed-down RPC without return values. + +### Flows + +#### Host registration + +At first, each player has to register as host ( even clients ). This is done by +sending the following message to *noray* over TCP: + +``` +register-host +``` + +*noray* will reply with the host's OpenID and PrivateID ( oid and pid ): + +``` +set-oid [openid] +set-pid [privateid] +``` + +These ID's are needed for any subsequent exchanges with *noray*. + +> Don't forget to end your messages with a newline character! + +#### Remote address registration + +To orchestrate connections, *noray* will need to know each host's external +address. This is done by creating a UDP socket and using that to send the +host's PrivateID. This operation is idempotent, so you're free to send multiple +packets until you receive a reply. + +Upon successful registration, the reply will be `OK`, otherwise it will be an +error message. + +#### Connecting + +Connecting can be attempted either via NAT punchthrough or relay. Since *noray* +has a limited amount of ports to dedicate to relays, it makes sense to prefer +NAT punchthrough whenever possible. + +Regardless of which approach is taken, you'll need to host's OpenID. At the +moment, sharing OpenID is not taken care of, you'll need a manual solution for +that. + +Once you have the target's OpenID, you need to send one of the following +commands, depending on the approach being taken: + +``` +connect [openid] +``` + +``` +connect-relay [openid] +``` + +The server will reply with the same command in both cases. For NAT +punchthrough, it will reply with the target address and port ( e.g. +`87.53.78.15:55759` ). For relaying, it will reply with the target port, since +the target machine will be the *noray* server itself. + +Example responses: + +``` +connect 87.53.78.15:55759 +``` + +``` +connect-relay 49178 +``` + +Note that both parties will receive the appropriate connect command. When this +happens, the parties should attempt a UDP handshake with eachother. + +## License + +*noray* is licensed under the [MIT license](LICENSE). + diff --git a/package.json b/package.json index 0dd571f..25a5fb8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.22.1", + "version": "0.22.2", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { From ad9ebfce62a66693cea377d9023ac9873606e938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 8 Jul 2023 20:58:26 +0200 Subject: [PATCH 19/34] chore: Update package.json (#33) --- package.json | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 25a5fb8..f4a0d60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.22.2", + "version": "0.22.3", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { @@ -14,9 +14,14 @@ "start": "node bin/noray.mjs | pino-pretty", "start:prod": "NODE_ENV=production node bin/noray.mjs" }, - "keywords": [], + "keywords": ["online", "multiplayer", "orchestrator", "relay", "nat"], "author": "Tamas Galffy", "license": "MIT", + "repository": "github:foxssake/noray", + "homepage": "https://github.com/foxssake/noray", + "bugs": { + "url": "https://github.com/foxssake/noray/issues" + }, "devDependencies": { "eslint": "^8.36.0", "eslint-config-standard": "^17.0.0", @@ -32,5 +37,9 @@ "dotenv": "^16.0.3", "nanoid": "^4.0.1", "pino": "^8.11.0" - } + }, + "files": [ + "src/*", + "bin/*" + ] } From 6f52f5484a89a7904fdca029f14682e9811f1f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 8 Jul 2023 20:58:47 +0200 Subject: [PATCH 20/34] =?UTF-8?q?chore:=20Bump=20to=201.0.0=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f4a0d60..efb5a0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "0.22.3", + "version": "1.0.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { From dcf8fb2a633f8b246ca5b10ea60baa83cf88cd79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 8 Jul 2023 22:16:15 +0200 Subject: [PATCH 21/34] feat: Integrate Prometheus for metrics (#35) Closes #34 --- .env.example | 6 ++++ .github/actions/setup.node/action.yml | 2 +- package.json | 13 +++++++-- pnpm-lock.yaml | 24 ++++++++++++++++ src/config.mjs | 5 ++++ src/hosts/host.commands.mjs | 10 +++++++ src/metrics/metrics.mjs | 40 +++++++++++++++++++++++++++ src/noray.mjs | 3 +- 8 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 src/metrics/metrics.mjs diff --git a/.env.example b/.env.example index 5837f16..f987d51 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,12 @@ NORAY_SOCKET_HOST=::1 # TCP port to listen on NORAY_SOCKET_PORT=8890 +# HTTP ======================================================================== +# HTTP hostname to listen on +NORAY_HTTP_HOST=::1 +# HTTP port to listen on +NORAY_HTTP_PORT=8891 + # UDP Relays ================================================================== # Ports reserved for relays - this also determines the number of relay slots # Valid forms include: diff --git a/.github/actions/setup.node/action.yml b/.github/actions/setup.node/action.yml index 61cc306..92ac2c4 100644 --- a/.github/actions/setup.node/action.yml +++ b/.github/actions/setup.node/action.yml @@ -8,7 +8,7 @@ inputs: pnpm-version: description: 'pnpm version' required: false - default: '7' + default: '8' runs: using: composite steps: diff --git a/package.json b/package.json index efb5a0f..02d759e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "1.0.0", + "version": "1.1.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { @@ -14,7 +14,13 @@ "start": "node bin/noray.mjs | pino-pretty", "start:prod": "NODE_ENV=production node bin/noray.mjs" }, - "keywords": ["online", "multiplayer", "orchestrator", "relay", "nat"], + "keywords": [ + "online", + "multiplayer", + "orchestrator", + "relay", + "nat" + ], "author": "Tamas Galffy", "license": "MIT", "repository": "github:foxssake/noray", @@ -36,7 +42,8 @@ "dependencies": { "dotenv": "^16.0.3", "nanoid": "^4.0.1", - "pino": "^8.11.0" + "pino": "^8.11.0", + "prom-client": "^14.2.0" }, "files": [ "src/*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69768eb..e6f2f88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + dependencies: dotenv: specifier: ^16.0.3 @@ -10,6 +14,9 @@ dependencies: pino: specifier: ^8.11.0 version: 8.11.0 + prom-client: + specifier: ^14.2.0 + version: 14.2.0 devDependencies: eslint: @@ -302,6 +309,10 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + /bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + dev: false + /bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} dev: true @@ -1499,6 +1510,13 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + /prom-client@14.2.0: + resolution: {integrity: sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==} + engines: {node: '>=10'} + dependencies: + tdigest: 0.1.2 + dev: false + /pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} dependencies: @@ -1728,6 +1746,12 @@ packages: engines: {node: '>= 0.4'} dev: true + /tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + dependencies: + bintrees: 1.0.2 + dev: false + /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true diff --git a/src/config.mjs b/src/config.mjs index 78ac8f7..550426d 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -15,6 +15,11 @@ export class NorayConfig { port: integer(env.NORAY_SOCKET_PORT) ?? 8890 } + http = { + host: env.NORAY_HTTP_HOST ?? '::1', + port: integer(env.NORAY_HTTP_PORT) ?? 8891 + } + udpRelay = { ports: ports(env.NORAY_UDP_RELAY_PORTS ?? '49152-51200'), timeout: duration(env.NORAY_UDP_RELAY_TIMEOUT ?? '30s'), diff --git a/src/hosts/host.commands.mjs b/src/hosts/host.commands.mjs index 05caa7f..c637ce5 100644 --- a/src/hosts/host.commands.mjs +++ b/src/hosts/host.commands.mjs @@ -3,6 +3,14 @@ import { HostRepository } from './host.repository.mjs' /* eslint-enable */ import { HostEntity } from './host.entity.mjs' import logger from '../logger.mjs' +import * as prometheus from 'prom-client' +import { metricsRegistry } from '../metrics/metrics.mjs' + +const activeHostsGauge = new prometheus.Gauge({ + name: 'noray_active_hosts', + help: 'Number of currently active hosts', + registers: [metricsRegistry] +}) /** * @param {HostRepository} hostRepository @@ -14,6 +22,7 @@ export function handleRegisterHost (hostRepository) { return function (server) { server.on('register-host', (_data, socket) => { const log = logger.child({ name: 'cmd:register-host' }) + activeHostsGauge.inc() const host = new HostEntity({ socket }) hostRepository.add(host) @@ -33,6 +42,7 @@ export function handleRegisterHost (hostRepository) { 'Host disconnected, removing from repository' ) hostRepository.removeItem(host) + activeHostsGauge.dec() }) }) } diff --git a/src/metrics/metrics.mjs b/src/metrics/metrics.mjs new file mode 100644 index 0000000..250d037 --- /dev/null +++ b/src/metrics/metrics.mjs @@ -0,0 +1,40 @@ +import * as http from 'node:http' +import { Noray } from '../noray.mjs' +import logger from '../logger.mjs' +import * as prometheus from 'prom-client' +import { config } from '../config.mjs' + +const log = logger.child({ name: 'mod:metrics' }) + +export const metricsRegistry = new prometheus.Registry() + +Noray.hook(noray => { + log.info('Collecting default metrics') + prometheus.collectDefaultMetrics({ + register: metricsRegistry + }) + + log.info('Starting HTTP server to serve metrics') + + const httpServer = new http.Server() + httpServer.on('request', async (req, res) => { + if (req.url !== '/metrics') { + res.statusCode = 404 + res.end() + return + } + + res.write(await metricsRegistry.metrics()) + res.end() + }) + + httpServer.listen(config.http.port, config.http.host, + () => log.info('Serving metrics over HTTP on port %s:%d', config.http.host, config.http.port) + ) + + noray.on('close', () => { + log.info('noray closing, shutting down HTTP server') + httpServer.close() + httpServer.closeAllConnections() + }) +}) diff --git a/src/noray.mjs b/src/noray.mjs index 892e96a..e366e3a 100644 --- a/src/noray.mjs +++ b/src/noray.mjs @@ -8,7 +8,8 @@ const defaultModules = [ 'relay/relay.mjs', 'echo/echo.mjs', 'hosts/host.mjs', - 'connection/connection.mjs' + 'connection/connection.mjs', + 'metrics/metrics.mjs' ] const hooks = [] From 44ccedd2f482733f2d51478a1b74d28c93abb5dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 8 Jul 2023 23:16:53 +0200 Subject: [PATCH 22/34] feat: Report protocol metrics (#38) Closes #36 --- package.json | 2 +- src/hosts/host.commands.mjs | 2 +- src/metrics/metrics.mjs | 3 +-- src/metrics/metrics.registry.mjs | 3 +++ src/noray.mjs | 4 ++-- src/protocol/protocol.server.mjs | 36 ++++++++++++++++++++++++++++-- src/utils.mjs | 27 +++++++++++++++++++++++ test/spec/utils.test.mjs | 38 +++++++++++++++++++++++++++++++- 8 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 src/metrics/metrics.registry.mjs diff --git a/package.json b/package.json index 02d759e..3cc4ad0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "1.1.0", + "version": "1.2.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/hosts/host.commands.mjs b/src/hosts/host.commands.mjs index c637ce5..3e707d7 100644 --- a/src/hosts/host.commands.mjs +++ b/src/hosts/host.commands.mjs @@ -4,7 +4,7 @@ import { HostRepository } from './host.repository.mjs' import { HostEntity } from './host.entity.mjs' import logger from '../logger.mjs' import * as prometheus from 'prom-client' -import { metricsRegistry } from '../metrics/metrics.mjs' +import { metricsRegistry } from '../metrics/metrics.registry.mjs' const activeHostsGauge = new prometheus.Gauge({ name: 'noray_active_hosts', diff --git a/src/metrics/metrics.mjs b/src/metrics/metrics.mjs index 250d037..213e36b 100644 --- a/src/metrics/metrics.mjs +++ b/src/metrics/metrics.mjs @@ -3,11 +3,10 @@ import { Noray } from '../noray.mjs' import logger from '../logger.mjs' import * as prometheus from 'prom-client' import { config } from '../config.mjs' +import { metricsRegistry } from './metrics.registry.mjs' const log = logger.child({ name: 'mod:metrics' }) -export const metricsRegistry = new prometheus.Registry() - Noray.hook(noray => { log.info('Collecting default metrics') prometheus.collectDefaultMetrics({ diff --git a/src/metrics/metrics.registry.mjs b/src/metrics/metrics.registry.mjs new file mode 100644 index 0000000..dee25ba --- /dev/null +++ b/src/metrics/metrics.registry.mjs @@ -0,0 +1,3 @@ +import * as prometheus from 'prom-client' + +export const metricsRegistry = new prometheus.Registry() diff --git a/src/noray.mjs b/src/noray.mjs index e366e3a..2d6a1ec 100644 --- a/src/noray.mjs +++ b/src/noray.mjs @@ -5,11 +5,11 @@ import { config } from './config.mjs' import { ProtocolServer } from './protocol/protocol.server.mjs' const defaultModules = [ + 'metrics/metrics.mjs', 'relay/relay.mjs', 'echo/echo.mjs', 'hosts/host.mjs', - 'connection/connection.mjs', - 'metrics/metrics.mjs' + 'connection/connection.mjs' ] const hooks = [] diff --git a/src/protocol/protocol.server.mjs b/src/protocol/protocol.server.mjs index 74d9bf2..d49832b 100644 --- a/src/protocol/protocol.server.mjs +++ b/src/protocol/protocol.server.mjs @@ -5,9 +5,31 @@ import * as readline from 'node:readline' import * as events from 'node:events' import assert from 'node:assert' import logger from '../logger.mjs' +import * as prometheus from 'prom-client' +import { metricsRegistry } from '../metrics/metrics.registry.mjs' const log = logger.child({ name: 'ProtocolServer' }) +const durationHistogram = new prometheus.Histogram({ + name: 'noray_command_duration', + help: 'Duration of each command', + labelNames: ['command'], + registers: [metricsRegistry] +}) + +const exceptionsCounter = new prometheus.Counter({ + name: 'noray_command_exception', + help: 'Exceptions encountered processing command', + labelNames: ['command'], + registers: [metricsRegistry] +}) + +const activeConnectionGauge = new prometheus.Gauge({ + name: 'noray_tcp_connections', + help: 'Number of currently active TCP connections', + registers: [metricsRegistry] +}) + /** * Protocol implementation. * @@ -36,6 +58,8 @@ export class ProtocolServer extends events.EventEmitter { rl.on('line', line => this.#handleLine(socket, line)) this.#readers.set(socket, rl) + + activeConnectionGauge.inc() } /** @@ -45,6 +69,8 @@ export class ProtocolServer extends events.EventEmitter { detach (socket) { this.#readers.get(socket)?.close() this.#readers.delete(socket) + + activeConnectionGauge.dec() } /** @@ -78,20 +104,26 @@ export class ProtocolServer extends events.EventEmitter { * @param {net.Socket} socket * @param {string} line */ - #handleLine (socket, line) { + async #handleLine (socket, line) { const idx = line.indexOf(' ') const [command, data] = idx >= 0 ? [line.slice(0, idx), line.slice(idx + 1)] : [line, ''] + const measure = durationHistogram.startTimer({ command }) try { - this.emit(command, data, socket) + await Promise.all( + this.listeners(command).map(l => l(data, socket)) + ) } catch (err) { log.warn( { line, err }, 'Error handling line' ) + exceptionsCounter.inc({ command }) + } finally { + measure() } } } diff --git a/src/utils.mjs b/src/utils.mjs index 84e4c08..2372755 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -46,6 +46,33 @@ export function asSingletonFactory (f) { return () => value } +/** +* Memoize function. +* +* That is, for every set of input arguments, remember the result. The next time +* the same arguments are used, instead of calculating the result again, it will +* be recovered from cache. +* +* **NOTE** that the cache is not limited in any way, use only in cases where +* the possible number of parameters is limited. +* +* @param {function(): T} f Function +* @returns {function(): T} Memoized function +* @template T +*/ +export function memoize (f) { + const cache = new Map() + return function () { + const key = JSON.stringify(arguments) + + if (!cache.has(key)) { + cache.set(key, f(...arguments)) + } + + return cache.get(key) + } +} + /** * Maps an input array into chunks of a given size. The last chunk might be * smaller than the requested size. diff --git a/test/spec/utils.test.mjs b/test/spec/utils.test.mjs index 5d4bb5e..a9db373 100644 --- a/test/spec/utils.test.mjs +++ b/test/spec/utils.test.mjs @@ -1,9 +1,45 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert' import sinon from 'sinon' -import { Timeout, combine, formatByteSize, formatDuration, range, sleep, withTimeout } from '../../src/utils.mjs' +import { Timeout, combine, formatByteSize, formatDuration, memoize, range, sleep, withTimeout } from '../../src/utils.mjs' describe('utils', () => { + describe('memoized', () => { + it('should not call again with same params', () => { + // Given + const expected = 4 + const fn = sinon.mock() + fn.returns(expected) + + const mfn = memoize(fn) + + // When + mfn(16) + const actual = mfn(16) + + // Then + assert.equal(actual, expected) + assert(fn.calledOnce) + assert(fn.calledOnceWith(16)) + }) + + it('should call through on unknown', () => { + // Given + const fn = sinon.mock() + fn.twice().returns() + const mfn = memoize(fn) + + // When + mfn(16) + mfn(32) + + // Then + assert(fn.calledTwice) + assert(fn.calledWith(16)) + assert(fn.calledWith(32)) + }) + }) + describe('withTimeout', () => { /** @type {sinon.SinonFakeTimers} */ let clock From b4e93e0434bba2f13c5f37e225038bcb1d0db370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 8 Jul 2023 23:34:22 +0200 Subject: [PATCH 23/34] feat: Report relay metrics (#39) Closes #37 --- package.json | 2 +- src/relay/udp.relay.cleanup.mjs | 13 +++++++-- src/relay/udp.relay.handler.mjs | 42 ++++++++++++++++++++++++++++++ src/relay/udp.remote.registrar.mjs | 23 ++++++++++++++++ 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 3cc4ad0..5086d50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "1.2.0", + "version": "1.3.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/relay/udp.relay.cleanup.mjs b/src/relay/udp.relay.cleanup.mjs index 452f778..a4c1d54 100644 --- a/src/relay/udp.relay.cleanup.mjs +++ b/src/relay/udp.relay.cleanup.mjs @@ -2,6 +2,14 @@ import { UDPRelayHandler } from './udp.relay.handler.mjs' /* eslint-enable */ import { time } from '../utils.mjs' +import * as prometheus from 'prom-client' +import { metricsRegistry } from '../metrics/metrics.registry.mjs' + +const expiredRelayCounter = new prometheus.Counter({ + name: 'noray_relay_expired', + help: 'Count of expired relays', + registers: [metricsRegistry] +}) /** * Remove idle relays. @@ -14,7 +22,8 @@ export function cleanupUdpRelayTable (relayHandler, timeout) { relayHandler.relayTable .map(relay => [relay, Math.max(relay.lastSent, relay.lastReceived)]) .filter(([_, lastTraffic]) => lastTraffic <= timeCutoff) - .forEach(([relay, _]) => + .forEach(([relay, _]) => { relayHandler.freeRelay(relay) - ) + expiredRelayCounter.inc() + }) } diff --git a/src/relay/udp.relay.handler.mjs b/src/relay/udp.relay.handler.mjs index 304a8d1..d54a5f5 100644 --- a/src/relay/udp.relay.handler.mjs +++ b/src/relay/udp.relay.handler.mjs @@ -6,9 +6,35 @@ import { UDPSocketPool } from './udp.socket.pool.mjs' import { time } from '../utils.mjs' import { EventEmitter } from 'node:events' import logger from '../logger.mjs' +import * as prometheus from 'prom-client' +import { metricsRegistry } from '../metrics/metrics.registry.mjs' const log = logger.child({ name: 'UDPRelayHandler' }) +const relayDurationHistogram = new prometheus.Histogram({ + name: 'noray_relay_duration', + help: 'Time it takes to relay a packet', + registers: [metricsRegistry] +}) + +const relaySizeHistorgram = new prometheus.Histogram({ + name: 'noray_relay_size', + help: 'Size of the packet being relayed', + registers: [metricsRegistry] +}) + +const relayDropCounter = new prometheus.Counter({ + name: 'noray_relay_drop_count', + help: 'Number of relay packets dropped', + registers: [metricsRegistry] +}) + +const activeRelayGauge = new prometheus.Gauge({ + name: 'noray_relay_count', + help: 'Count of currently active relays', + registers: [metricsRegistry] +}) + /** * Class implementing the actual relay logic. * @@ -74,6 +100,8 @@ export class UDPRelayHandler extends EventEmitter { this.#relayTable.push(relay) log.trace({ relay }, 'Relay created') + activeRelayGauge.inc() + return relay } @@ -104,6 +132,9 @@ export class UDPRelayHandler extends EventEmitter { this.#socketPool.returnPort(relay.port) this.#relayTable = this.#relayTable.filter((_, i) => i !== idx) + + activeRelayGauge.dec() + return true } @@ -112,6 +143,8 @@ export class UDPRelayHandler extends EventEmitter { */ clear () { this.relayTable.forEach(entry => this.freeRelay(entry)) + + activeRelayGauge.reset() } /** @@ -124,6 +157,8 @@ export class UDPRelayHandler extends EventEmitter { * @fires UDPRelayHandler#drop */ relay (msg, sender, target) { + const measure = relayDurationHistogram.startTimer() + const senderRelay = this.#relayTable.find(r => r.address.port === sender.port && r.address.address === sender.address ) @@ -132,6 +167,10 @@ export class UDPRelayHandler extends EventEmitter { if (!senderRelay || !targetRelay) { // We don't have a relay for the sender, target, or both this.emit('drop', senderRelay, targetRelay, sender, target, msg) + + relayDropCounter.inc() + measure() + return false } @@ -149,6 +188,9 @@ export class UDPRelayHandler extends EventEmitter { senderRelay.lastReceived = time() targetRelay.lastSent = time() + relaySizeHistorgram.observe(msg?.byteLength ?? 0) + measure() + return true } diff --git a/src/relay/udp.remote.registrar.mjs b/src/relay/udp.remote.registrar.mjs index b05801f..d606500 100644 --- a/src/relay/udp.remote.registrar.mjs +++ b/src/relay/udp.remote.registrar.mjs @@ -5,9 +5,29 @@ import dgram from 'node:dgram' import assert from 'node:assert' import logger from '../logger.mjs' import { requireParam } from '../assertions.mjs' +import * as prometheus from 'prom-client' +import { metricsRegistry } from '../metrics/metrics.registry.mjs' const log = logger.child({ name: 'UDPRemoteRegistrar' }) +const registerSuccessCounter = new prometheus.Counter({ + name: 'noray_remote_registrar_success', + help: 'Number of successful remote address registrations', + registers: [metricsRegistry] +}) + +const registerFailCounter = new prometheus.Counter({ + name: 'noray_remote_registrar_fail', + help: 'Number of failed remote address registrations', + registers: [metricsRegistry] +}) + +const registerRepatCounter = new prometheus.Counter({ + name: 'noray_remote_registrar_repeat', + help: 'Number of redundant remote address registrations', + registers: [metricsRegistry] +}) + /** * @summary Class for remote address registration over UDP. * @@ -80,12 +100,15 @@ export class UDPRemoteRegistrar { if (host.rinfo) { // Host has already remote info registered this.#socket.send('OK', rinfo.port, rinfo.address) + registerRepatCounter.inc() return } host.rinfo = rinfo this.#socket.send('OK', rinfo.port, rinfo.address) + registerSuccessCounter.inc() } catch (e) { + registerFailCounter.inc() this.#socket.send(e.message ?? 'Error', rinfo.port, rinfo.address) } } From 4047174cd53476e9cc8cbfba26811dcff66259dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 9 Jul 2023 12:03:06 +0200 Subject: [PATCH 24/34] fix: UDP relaying (#41) Removing dynamic relaying broke UDP relaying for clients. After the handshake, generally the original UDP socket is released, and the multiplayer framework will create a new one. This will have the same external address, but a completely different port, meaning noray won't recognize it and won't relay any data. Fixes #40 --- package.json | 2 +- src/relay/dynamic.relaying.mjs | 88 +++++++++++++++ src/relay/relay.mjs | 4 + test/spec/relay/dynamic.relaying.test.mjs | 130 ++++++++++++++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 src/relay/dynamic.relaying.mjs create mode 100644 test/spec/relay/dynamic.relaying.test.mjs diff --git a/package.json b/package.json index 5086d50..ecb98c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "1.3.0", + "version": "1.3.1", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/relay/dynamic.relaying.mjs b/src/relay/dynamic.relaying.mjs new file mode 100644 index 0000000..34227a0 --- /dev/null +++ b/src/relay/dynamic.relaying.mjs @@ -0,0 +1,88 @@ +/* eslint-disable */ +import { NetAddress } from './net.address.mjs' +import { UDPRelayHandler } from './udp.relay.handler.mjs' +/* eslint-enable */ +import logger from '../logger.mjs' +import { RelayEntry } from './relay.entry.mjs' + +const log = logger.child({ name: 'DynamicRelaying' }) + +/** +* Implementation for dynamic relaying. +* +* Whenever an unknown client tries to send data to a known host through its +* relay address, dynamic relaying will create a new relay. +* +* While it's waiting for the relay to be created, it will buffer any incoming +* data and send it all once the relay is created. +*/ +export class DynamicRelaying { + /** @type {Map} */ + #buffers = new Map() + + /** + * Apply dynamic relay creation to relay handler. + * @param {UDPRelayHandler} relayHandler Relay handler + */ + apply (relayHandler) { + relayHandler.on('drop', + (senderRelay, targetRelay, senderAddress, targetPort, message) => + this.#handle(relayHandler, senderRelay, targetRelay, senderAddress, targetPort, message) + ) + } + + /** + * @param {UDPRelayHandler} relayHandler + * @param {RelayEntry} senderRelay + * @param {RelayEntry} targetRelay + * @param {NetAddress} senderAddress + * @param {number} targetPort + * @param {Buffer} message + */ + async #handle (relayHandler, senderRelay, targetRelay, senderAddress, targetPort, message) { + // Unknown host or client already has relay, ignore + if (senderRelay || !targetRelay) { + return + } + + const key = senderAddress.toString() + '>' + targetPort + + // We're already buffering for client, save data end return + if (this.#buffers.has(key)) { + this.#buffers.get(key).push(message) + return + } + + // No buffer for client yet, start buffering and create relay + log.info( + { from: senderAddress, to: targetRelay.address }, + 'Creating dynamic relay' + ) + this.#buffers.set(key, [message]) + const port = await relayHandler.socketPool.allocatePort() + const relay = new RelayEntry({ + address: senderAddress, + port + }) + await relayHandler.createRelay(relay) + + log.info( + { relay }, + 'Relay created, sending %d packets', + this.#buffers.get(key)?.length ?? 0 + ) + this.#buffers.get(key).forEach(msg => + relayHandler.relay(msg, senderAddress, targetPort) + ) + + this.#buffers.delete(key) + } +} + +/** +* Apply dynamic relaying to relay handler. +* @param {UDPRelayHandler} relayHandler Relay handler +*/ +export function useDynamicRelay (relayHandler) { + new DynamicRelaying().apply(relayHandler) +} diff --git a/src/relay/relay.mjs b/src/relay/relay.mjs index d9d3e41..f594ee7 100644 --- a/src/relay/relay.mjs +++ b/src/relay/relay.mjs @@ -7,6 +7,7 @@ import logger from '../logger.mjs' import { formatByteSize, formatDuration } from '../utils.mjs' import { UDPRemoteRegistrar } from './udp.remote.registrar.mjs' import { hostRepository } from '../hosts/host.mjs' +import { useDynamicRelay } from './dynamic.relaying.mjs' import { UDPSocketPool } from './udp.socket.pool.mjs' export const udpSocketPool = new UDPSocketPool() @@ -65,6 +66,9 @@ Noray.hook(async noray => { constrainLifetime(udpRelayHandler, config.udpRelay.maxLifetimeDuration) constrainTraffic(udpRelayHandler, config.udpRelay.maxLifetimeTraffic) + log.info('Applying dynamic relaying') + useDynamicRelay(udpRelayHandler) + log.info('Adding shutdown hooks') noray.on('close', () => { log.info('Noray shutting down, cancelling UDP relay cleanup job') diff --git a/test/spec/relay/dynamic.relaying.test.mjs b/test/spec/relay/dynamic.relaying.test.mjs new file mode 100644 index 0000000..d5c6b36 --- /dev/null +++ b/test/spec/relay/dynamic.relaying.test.mjs @@ -0,0 +1,130 @@ +import { beforeEach, afterEach, describe, it } from 'node:test' +import assert from 'node:assert' +import sinon from 'sinon' +import { UDPRelayHandler } from '../../../src/relay/udp.relay.handler.mjs' +import { sleep } from '../../../src/utils.mjs' +import { useDynamicRelay } from '../../../src/relay/dynamic.relaying.mjs' +import { RelayEntry } from '../../../src/relay/relay.entry.mjs' +import { NetAddress } from '../../../src/relay/net.address.mjs' +import { UDPSocketPool } from '../../../src/relay/udp.socket.pool.mjs' + +describe('DynamicRelaying', () => { + let clock + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + it('should create relay', async () => { + // Given + const socketPool = sinon.createStubInstance(UDPSocketPool) + socketPool.allocatePort.resolves(10000) + + const relayHandler = sinon.createStubInstance(UDPRelayHandler) + relayHandler.on.callThrough() + relayHandler.emit.callThrough() + sinon.stub(relayHandler, 'socketPool').value(socketPool) + + relayHandler.createRelay.resolves(true) + useDynamicRelay(relayHandler) + + const senderRelay = undefined + const targetRelay = new RelayEntry({ + address: new NetAddress({ address: '87.54.0.16', port: 16752 }), + port: 10007 + }) + const senderAddress = new NetAddress({ address: '97.32.4.16', port: 32775 }) + const targetPort = targetRelay.port + const messages = [ + 'hello', 'world', 'use', 'noray' + ].map(message => Buffer.from(message)) + + // When + messages.forEach(message => + relayHandler.emit('drop', senderRelay, targetRelay, senderAddress, targetPort, message) + ) + clock.restore() + await sleep(0.05) // Wait for relay to be created + clock = sinon.useFakeTimers() + + // Then + const createdRelay = relayHandler.createRelay.lastCall.args[0] + assert(createdRelay, 'Relay was not created!') + assert.equal(createdRelay.address, senderAddress) + assert.equal(createdRelay.port, 10000) + + const sent = relayHandler.relay.getCalls().map(call => call.args[0]?.toString()) + messages.forEach(message => + assert( + sent.includes(message.toString()), + `Message "${message.toString()}" was not sent!` + ) + ) + }) + + it('should ignore known sender', async () => { + // Given + const relayHandler = sinon.createStubInstance(UDPRelayHandler) + relayHandler.on.callThrough() + relayHandler.emit.callThrough() + + useDynamicRelay(relayHandler) + + const senderRelay = new RelayEntry({ + address: new NetAddress({ address: '87.54.0.16', port: 16752 }), + port: 10007 + }) + + const targetRelay = undefined + const senderAddress = new NetAddress(senderRelay.address) + const targetPort = 10057 + const messages = [ + 'hello', 'world', 'use', 'noray' + ].map(message => Buffer.from(message)) + + // When + messages.forEach(message => + relayHandler.emit('drop', senderRelay, targetRelay, senderAddress, targetPort, message) + ) + clock.restore() + await sleep(0.05) // Wait for relay to be created + clock = sinon.useFakeTimers() + + // Then + assert(relayHandler.createRelay.notCalled) + assert(relayHandler.relay.notCalled) + }) + + it('should ignore unknown target', async () => { + // Given + const relayHandler = sinon.createStubInstance(UDPRelayHandler) + relayHandler.on.callThrough() + relayHandler.emit.callThrough() + + useDynamicRelay(relayHandler) + + const senderRelay = undefined + const targetRelay = undefined + const senderAddress = new NetAddress({ address: '87.54.0.16', port: 16752 }) + const targetPort = 10057 + const messages = [ + 'hello', 'world', 'use', 'noray' + ].map(message => Buffer.from(message)) + + // When + messages.forEach(message => + relayHandler.emit('drop', senderRelay, targetRelay, senderAddress, targetPort, message) + ) + clock.restore() + await sleep(0.05) // Wait for relay to be created + clock = sinon.useFakeTimers() + + // Then + assert(relayHandler.createRelay.notCalled) + assert(relayHandler.relay.notCalled) + }) + + afterEach(() => { + clock.restore() + }) +}) From bb80e47e776c9a8c0fe98098dedc41083a858eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 12 Aug 2023 15:55:39 +0200 Subject: [PATCH 25/34] fix: Duplicate relay creation (#43) Closes #42 --- package.json | 2 +- src/relay/relay.entry.mjs | 2 +- src/relay/udp.relay.handler.mjs | 8 --- test/spec/relay/relay.entry.test.mjs | 39 +++++++++++++ test/spec/relay/udp.relay.handler.test.mjs | 65 ++++++++++++++++++++++ 5 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 test/spec/relay/relay.entry.test.mjs diff --git a/package.json b/package.json index ecb98c4..587853d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "1.3.1", + "version": "1.3.2", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/relay/relay.entry.mjs b/src/relay/relay.entry.mjs index 1cb19c0..7bb6ee6 100644 --- a/src/relay/relay.entry.mjs +++ b/src/relay/relay.entry.mjs @@ -51,7 +51,7 @@ export class RelayEntry { * @returns {boolean} True if equal */ equals (other) { - return this.address.equals(other.address) && this.port === other.port + return this.address.equals(other.address) } /** diff --git a/src/relay/udp.relay.handler.mjs b/src/relay/udp.relay.handler.mjs index d54a5f5..e3533bf 100644 --- a/src/relay/udp.relay.handler.mjs +++ b/src/relay/udp.relay.handler.mjs @@ -209,14 +209,6 @@ export class UDPRelayHandler extends EventEmitter { get relayTable () { return [...this.#relayTable] } - - async #ensurePort (port) { - if (!this.#socketPool.getSocket(port)) { - await this.#socketPool.allocatePort(port) - } - - return this.#socketPool.getSocket(port) - } } /** diff --git a/test/spec/relay/relay.entry.test.mjs b/test/spec/relay/relay.entry.test.mjs new file mode 100644 index 0000000..2a6c12b --- /dev/null +++ b/test/spec/relay/relay.entry.test.mjs @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert' +import { RelayEntry } from '../../../src/relay/relay.entry.mjs' +import { NetAddress } from '../../../src/relay/net.address.mjs' + +describe('RelayEntry', () => { + describe('equals', () => { + const cases = [ + [ + 'same should equal', + new RelayEntry({ address: new NetAddress({ address: "host1", port: 1000 }), port: 2000 }), + new RelayEntry({ address: new NetAddress({ address: "host1", port: 1000 }), port: 2000 }), + true + ], + [ + 'different port should equal', + new RelayEntry({ address: new NetAddress({ address: "host1", port: 1000 }), port: 2000 }), + new RelayEntry({ address: new NetAddress({ address: "host1", port: 1000 }), port: 2010 }), + true + ], + [ + 'different address host should not equal', + new RelayEntry({ address: new NetAddress({ address: "host1", port: 1000 }), port: 2000 }), + new RelayEntry({ address: new NetAddress({ address: "host2", port: 1000 }), port: 2000 }), + false + ], + [ + 'different address port should not equal', + new RelayEntry({ address: new NetAddress({ address: "host1", port: 1000 }), port: 2000 }), + new RelayEntry({ address: new NetAddress({ address: "host1", port: 1020 }), port: 2000 }), + false + ], + ] + + cases.forEach(([name, a, b, expected]) => { + it(name, () => { assert.equal(a.equals(b), expected) }) + }) + }) +}) diff --git a/test/spec/relay/udp.relay.handler.test.mjs b/test/spec/relay/udp.relay.handler.test.mjs index e2c5bdc..7a01ee8 100644 --- a/test/spec/relay/udp.relay.handler.test.mjs +++ b/test/spec/relay/udp.relay.handler.test.mjs @@ -260,4 +260,69 @@ describe('UDPRelayHandler', () => { assert(socket.send.notCalled, 'Message sent?') }) }) + describe('hasRelay', () => { + it('should not have relay', async () => { + // Given + const socket = sinon.createStubInstance(dgram.Socket) + const socketPool = sinon.createStubInstance(UDPSocketPool) + socketPool.getPort.returns(10001) + socketPool.getPort.returns(10002) + socketPool.getSocket.returns(socket) + socket.removeAllListeners.returnsThis() + + const createdRelay = new RelayEntry({ + address: new NetAddress({ + address: '88.57.0.107', + port: 32279 + }) + }) + + const testRelay = new RelayEntry({ + address: new NetAddress({ + address: '88.57.0.107', + port: 49152 + }) + }) + + const relayHandler = new UDPRelayHandler({ + socketPool + }) + + await relayHandler.createRelay(createdRelay) + + // When + Then + assert(!relayHandler.hasRelay(testRelay)) + }) + it('should have relay', async () => { + // Given + const socket = sinon.createStubInstance(dgram.Socket) + const socketPool = sinon.createStubInstance(UDPSocketPool) + socketPool.getPort.onFirstCall().returns(10001) + socketPool.getPort.onSecondCall().returns(10002) + socketPool.getSocket.returns(socket) + socket.removeAllListeners.returnsThis() + + const createdRelay = new RelayEntry({ + address: new NetAddress({ + address: '88.57.0.107', + port: 32279 + }) + }) + + const testRelay = new RelayEntry({ + address: new NetAddress({ + address: '88.57.0.107', + port: 32279 + }) + }) + const relayHandler = new UDPRelayHandler({ + socketPool + }) + + await relayHandler.createRelay(createdRelay) + + // When + Then + assert(relayHandler.hasRelay(testRelay)) + }) + }) }) From 822deca37435efa81c8c2d3591a94b2481fc5e98 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Tue, 28 Nov 2023 17:45:22 +0100 Subject: [PATCH 26/34] chore: Dockerize application (#47) Closes #46 --- .dockerignore | 7 ++++++ .github/workflows/docker-publish.yml | 35 ++++++++++++++++++++++++++++ Dockerfile | 23 ++++++++++++++++++ README.md | 16 +++++++++++++ package.json | 2 +- 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-publish.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1c54827 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.github +test/ +.editorconfig +.env.example +.eslintrc.js +.jsdoc.js +node_modules/ diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..a5a2760 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,35 @@ +name: Docker Publish + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +# From https://docs.github.com/en/actions/publishing-packages/publishing-docker-images +jobs: + docker-publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v3 + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + - name: Build and push ( if on main ) Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8349186 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:18-alpine +# From https://github.com/pnpm/pnpm/issues/4837 + +EXPOSE 8890/tcp +EXPOSE 8891/udp + +COPY . noray + +WORKDIR noray + +RUN npm i -g npm@latest; \ + # Install pnpm + npm install -g pnpm; \ + pnpm --version; \ + pnpm setup; \ + mkdir -p /usr/local/share/pnpm &&\ + export PNPM_HOME="/usr/local/share/pnpm" &&\ + export PATH="$PNPM_HOME:$PATH"; \ + pnpm bin -g &&\ + # Install dependencies + pnpm install + +CMD pnpm start:prod diff --git a/README.md b/README.md index 520df90..516eb67 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,22 @@ To run *noray*, use `pnpm start` or `pnpm start:prod` for production use. Upon startup, the application will allocate all the configured ports and start listening for incoming connections. Logs are written to `stdout`. +### Usage with Docker + +Create `.env` file from `.env.example`. + +Build and run docker: + +``` +docker build . -t noray +docker run -p 8090:8090 -p 8091:8091 --env-file=.env -t noray +``` + +Or run prebuilt docker: +``` +docker run -p 8090:8090 -p 8091:8091 --env-file=.env -t ghcr.io/foxssake/noray:main +``` + ## Documentation ### Protocol diff --git a/package.json b/package.json index 587853d..d4277a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "1.3.2", + "version": "1.3.3", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { From 97188b93b273a714e1786319e10f411512ef7a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 28 Nov 2023 18:15:35 +0100 Subject: [PATCH 27/34] doc: Update example config to use IPv4 (#49) --- README.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 516eb67..90b9c5e 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,14 @@ Or run prebuilt docker: docker run -p 8090:8090 -p 8091:8091 --env-file=.env -t ghcr.io/foxssake/noray:main ``` +#### EADDRNOTAVAIL + +If you get an `EADDRNOTAVAIL` error when trying to listen on an IPv6 address, +you either need to [enable IPv6 in Docker], or choose an IPv4 host address to +listen on, e.g. '0.0.0.0' or 'localhost'. + +[enable IPv6 in Docker]: https://docs.docker.com/config/daemon/ipv6/ + ## Documentation ### Protocol diff --git a/package.json b/package.json index d4277a6..4a62e6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "1.3.3", + "version": "1.3.4", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { From e01f8e763ed97dce9f2a9a326159f5798e11a339 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Tue, 28 Nov 2023 22:42:05 +0100 Subject: [PATCH 28/34] Generate id's with custom length and letters (#48) --- .env.example | 10 ++++++++++ package.json | 2 +- src/config.mjs | 11 +++++++++++ src/hosts/host.entity.mjs | 11 +++++++---- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index f987d51..56c1eff 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,13 @@ +# Id generation =========================================================== +# If you change this, check the collision probability https://zelark.github.io/nano-id-cc/ +NORAY_OID_LENGTH=21 # For 10 id/hour, 15 trillion years +# NORAY_OID_LENGTH=4 # For 10 id/hour, 2 days +# NORAY_OID_LENGTH=5 # For 10 id/hour, 19 days +# NORAY_OID_LENGTH=6 # For 10 id/hour, 155 days +NORAY_OID_CHARSET=useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict +NORAY_PID_LENGTH=128 +NORAY_PID_CHARSET=useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict + # Socket ====================================================================== # TCP hostname to listen on NORAY_SOCKET_HOST=::1 diff --git a/package.json b/package.json index 4a62e6b..7e8d26f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "1.3.4", + "version": "1.4.0", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { diff --git a/src/config.mjs b/src/config.mjs index 550426d..6c4c9ef 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -1,6 +1,7 @@ import * as dotenv from 'dotenv' import { byteSize, duration, integer, number, ports } from './config.parsers.mjs' import logger, { getLogLevel } from './logger.mjs' +import { urlAlphabet } from 'nanoid' dotenv.config() @@ -10,6 +11,16 @@ const env = process.env * Noray configuration type. */ export class NorayConfig { + oid = { + length: integer(env.NORAY_OID_LENGTH) ?? 21, + charset: env.NORAY_OID_CHARSET ?? urlAlphabet + } + + pid = { + length: integer(env.NORAY_PID_LENGTH) ?? 128, + charset: env.NORAY_PID_CHARSET ?? urlAlphabet + } + socket = { host: env.NORAY_SOCKET_HOST ?? '::1', port: integer(env.NORAY_SOCKET_PORT) ?? 8890 diff --git a/src/hosts/host.entity.mjs b/src/hosts/host.entity.mjs index 18e3fa7..406b422 100644 --- a/src/hosts/host.entity.mjs +++ b/src/hosts/host.entity.mjs @@ -2,7 +2,11 @@ import * as net from 'node:net' import * as dgram from 'node:dgram' /* eslint-enable */ -import { nanoid } from 'nanoid' +import * as nanoid from 'nanoid' +import { config } from '../config.mjs' + +const generateOID = nanoid.customAlphabet(config.oid.charset, config.oid.length) +const generatePID = nanoid.customAlphabet(config.pid.charset, config.pid.length) /** * Host entity. @@ -46,8 +50,7 @@ export class HostEntity { */ constructor (options) { options && Object.assign(this, options) - - this.oid ??= nanoid() - this.pid ??= nanoid(128) + this.oid ??= generateOID() + this.pid ??= generatePID() } } From ed756d16a9a6c9cfb84ca544fc2625068fc8ad58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 29 Nov 2023 16:21:10 +0100 Subject: [PATCH 29/34] fix: Docker expose protocol (#52) --- Dockerfile | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8349186..91dba03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ FROM node:18-alpine # From https://github.com/pnpm/pnpm/issues/4837 -EXPOSE 8890/tcp -EXPOSE 8891/udp +EXPOSE 8890/udp +EXPOSE 8891/tcp COPY . noray diff --git a/package.json b/package.json index 7e8d26f..49fe6b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "1.4.0", + "version": "1.4.1", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { From 0db49adb4be3fcbebcfb885d7823fd56279626cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 17 Dec 2023 17:00:23 +0100 Subject: [PATCH 30/34] fix: Dockerfile port protocol (#53) --- Dockerfile | 8 ++++++-- package.json | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 91dba03..0a8270f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,11 @@ FROM node:18-alpine # From https://github.com/pnpm/pnpm/issues/4837 -EXPOSE 8890/udp +# UDP host for remote address registration +EXPOSE 8809/udp +# TCP host for commands +EXPOSE 8890/tcp +# HTTP host for Prometheus metrics EXPOSE 8891/tcp COPY . noray @@ -16,7 +20,7 @@ RUN npm i -g npm@latest; \ mkdir -p /usr/local/share/pnpm &&\ export PNPM_HOME="/usr/local/share/pnpm" &&\ export PATH="$PNPM_HOME:$PATH"; \ - pnpm bin -g &&\ + pnpm bin -g && \ # Install dependencies pnpm install diff --git a/package.json b/package.json index 49fe6b9..173ee11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/noray", - "version": "1.4.1", + "version": "1.4.2", "description": "Online multiplayer orchestrator and potential game platform", "main": "src/noray.mjs", "bin": { From 16fe708d063f7d9c0ab1058a70161f07d9fde28e Mon Sep 17 00:00:00 2001 From: Mondanzo <14519085+Mondanzo@users.noreply.github.com> Date: Sun, 4 Aug 2024 05:57:49 +0200 Subject: [PATCH 31/34] Update .env.example to have udp and tcp share port Reduces confusion of ports. --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 56c1eff..92f8459 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,7 @@ NORAY_UDP_RELAY_TIMEOUT=30s NORAY_UDP_RELAY_CLEANUP_INTERVAL=30s # Port where noray listens for UDP relay requests from hosts -NORAY_UDP_REGISTRAR_PORT=8809 +NORAY_UDP_REGISTRAR_PORT=8890 # Maximum traffic per relay, in bytes / sec NORAY_UDP_RELAY_MAX_INDIVIDUAL_TRAFFIC=128kb From 2dc74808638dcd18d09510307135188d177fc350 Mon Sep 17 00:00:00 2001 From: Mondanzo Date: Tue, 6 Aug 2024 19:04:08 +0200 Subject: [PATCH 32/34] Updated error handling --- src/noray.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/noray.mjs b/src/noray.mjs index 2d6a1ec..d80ace8 100644 --- a/src/noray.mjs +++ b/src/noray.mjs @@ -58,9 +58,14 @@ export class Noray extends EventEmitter { config.socket.host, config.socket.port ) + socket.on('error', err => { + this.#log.error(err) + }) + socket.on('connection', conn => { this.#protocolServer.attach(conn) conn.on('close', () => this.#protocolServer.detach(conn)) + conn.on('error', err => this.#log.error(err)) }) this.emit('listening', config.socket.port, config.socket.host) From fd4a9701c7f7e598a3f021f430b19fe0895bc767 Mon Sep 17 00:00:00 2001 From: Mondanzo Date: Tue, 6 Aug 2024 19:42:48 +0200 Subject: [PATCH 33/34] Updated error handling --- src/hosts/host.commands.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hosts/host.commands.mjs b/src/hosts/host.commands.mjs index 3e707d7..37af2f7 100644 --- a/src/hosts/host.commands.mjs +++ b/src/hosts/host.commands.mjs @@ -36,6 +36,8 @@ export function handleRegisterHost (hostRepository) { socket.remoteAddress, socket.remotePort ) + socket.on('error', err => {}) + socket.on('close', () => { log.info( { oid: host.oid, pid: host.pid }, From 22cc80f4bba312701a0f826359227e4d7e47ef11 Mon Sep 17 00:00:00 2001 From: Mondanzo Date: Tue, 6 Aug 2024 20:01:48 +0200 Subject: [PATCH 34/34] Updated error handling --- src/protocol/protocol.server.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/protocol/protocol.server.mjs b/src/protocol/protocol.server.mjs index d49832b..7f8601d 100644 --- a/src/protocol/protocol.server.mjs +++ b/src/protocol/protocol.server.mjs @@ -57,6 +57,10 @@ export class ProtocolServer extends events.EventEmitter { }) rl.on('line', line => this.#handleLine(socket, line)) + rl.on('error', err => { + this.detach(socket) + log.error("Socket connection abruptly lost!") + }) this.#readers.set(socket, rl) activeConnectionGauge.inc()