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/.env.example b/.env.example index f226c48..940fe3d 100644 --- a/.env.example +++ b/.env.example @@ -1,73 +1,67 @@ +# Id generation =========================================================== +# If you change this, check the collision probability https://zelark.github.io/nano-id-cc/ +# For 10 id/hour, 15 trillion years +NORAY_OID_LENGTH=21 +# For 10 id/hour, 2 days +# NORAY_OID_LENGTH=4 +# For 10 id/hour, 19 days +# NORAY_OID_LENGTH=5 +# For 10 id/hour, 155 days +# NORAY_OID_LENGTH=6 +NORAY_OID_CHARSET=useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict +NORAY_PID_LENGTH=128 +NORAY_PID_CHARSET=useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict + # Socket ====================================================================== # TCP hostname to listen on -NATTY_SOCKET_HOST=::1 +NORAY_SOCKET_HOST=0.0.0.0 # 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 +NORAY_SOCKET_PORT=8890 -# Timeout for connection diagnostics, i.e. how much to wait for peers to report -# connectivity to eachoter -NATTY_CONNECTION_DIAGNOSTICS_TIMEOUT=8s +# HTTP ======================================================================== +# HTTP hostname to listen on +NORAY_HTTP_HOST=0.0.0.0 +# HTTP port to listen on +NORAY_HTTP_PORT=8891 # UDP Relays ================================================================== -# Maximum number of active relay slots -NATTY_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 -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 +NORAY_UDP_RELAY_CLEANUP_INTERVAL=30s -# Port where Natty listens for UDP relay requests from hosts -NATTY_UDP_REGISTRAR_PORT=8809 +# Port where noray listens for UDP relay requests from hosts +NORAY_UDP_REGISTRAR_PORT=8809 # 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/.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/.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/.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..0a8270f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM node:18-alpine +# From https://github.com/pnpm/pnpm/issues/4837 + +# 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 + +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/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 66fc6e1..5496a8d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,204 @@ -# natty +# noray -Online multiplayer orchestrator and potential game platform +A simple connection orchestrator and relay to bulletproof connectivity for your +online multiplayer games. -## Docs +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`. + +### Usage with Docker + +Create `.env` file based on `.env.example`. + +Build and run docker: + +``` +docker build . -t noray +docker run -p 8890:8890 -p 8891:8891 -p 8809:8809/udp -p 49152-51200:49152-51200/udp --env-file=.env -t noray +``` + +Or run prebuilt docker: +``` +docker run -p 8890:8890 -p 8891:8891 -p 8809:8809/udp -p 49152-51200:49152-51200/udp --env-file=.env -t ghcr.io/foxssake/noray:main +``` + +The above will expose the following ports: + +* Port 8890 for clients to register and request connections +* Port 8891 to expose metrics over HTTP +* Port 8809 for the remote port registrar +* Ports 49152 to 51200 for relays + * Make sure these are the same ports as configured in `.env`! + +Note that exposing a lot of relay ports can severely impact deploy time. + +In case of relays not working - i.e. clients can register and request +connections, but the handshake process fails -, Docker might be mapping ports +as data arrives from outside of the container. In these cases, try running +noray using the [host network]: + +``` +docker run --network host --env-file=.env -t noray +``` + +[host network]: https://docs.docker.com/engine/network/tutorials/host/ + +#### 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 + +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. + +### 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). -* [Connection orchestration](docs/connection-orchestration.md) -* [UDP relays](docs/udp-relays.md) -* [noray](docs/noray.md) 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..649a320 --- /dev/null +++ b/bin/noray.mjs @@ -0,0 +1,7 @@ +import { Noray } from '../src/noray.mjs' + +const noray = new Noray() +noray.start() + +process.on('exit', () => noray.shutdown()) +process.on('SIGINT', () => noray.shutdown()) 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..9349d1b 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,33 @@ { - "name": "@foxssake/natty", - "version": "0.13.1", + "name": "@foxssake/noray", + "version": "1.4.5", "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": "node --test test/spec/", "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", + "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", @@ -28,11 +40,15 @@ "utap": "^0.2.0" }, "dependencies": { - "@elementbound/nlon": "^1.2.1", - "@elementbound/nlon-socket": "^1.2.1", - "ajv": "^8.12.0", + "@foxssake/trimsock-js": "link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-js", + "@foxssake/trimsock-node": "link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-node", "dotenv": "^16.0.3", "nanoid": "^4.0.1", - "pino": "^8.11.0" - } + "pino": "^8.11.0", + "prom-client": "^14.2.0" + }, + "files": [ + "src/*", + "bin/*" + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8bac1e..5e75bb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,601 +1,414 @@ -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 - nanoid: - specifier: ^4.0.1 - version: 4.0.1 - pino: - specifier: ^8.11.0 - version: 8.11.0 - -devDependencies: - eslint: - specifier: ^8.36.0 - version: 8.36.0 - eslint-config-standard: - specifier: ^17.0.0 - version: 17.0.0(eslint-plugin-import@2.27.5)(eslint-plugin-n@15.6.1)(eslint-plugin-promise@6.1.1)(eslint@8.36.0) - eslint-plugin-import: - specifier: ^2.25.2 - version: 2.27.5(eslint@8.36.0) - eslint-plugin-n: - specifier: ^15.0.0 - version: 15.6.1(eslint@8.36.0) - eslint-plugin-promise: - specifier: ^6.0.0 - version: 6.1.1(eslint@8.36.0) - jsdoc: - specifier: ^4.0.2 - version: 4.0.2 - pino-pretty: - specifier: ^10.0.0 - version: 10.0.0 - sinon: - specifier: ^15.0.4 - version: 15.0.4 - utap: - specifier: ^0.2.0 - version: 0.2.0 +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@foxssake/trimsock-js': link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-js + trimsock-node: link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-node + '@foxssake/trimsock-node': link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-node + +importers: + + .: + dependencies: + '@foxssake/trimsock-js': + specifier: link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-js + version: link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-js + '@foxssake/trimsock-node': + specifier: link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-node + version: link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-node + dotenv: + specifier: ^16.0.3 + version: 16.5.0 + nanoid: + specifier: ^4.0.1 + version: 4.0.2 + pino: + specifier: ^8.11.0 + version: 8.21.0 + prom-client: + specifier: ^14.2.0 + version: 14.2.0 + devDependencies: + eslint: + specifier: ^8.36.0 + version: 8.57.1 + eslint-config-standard: + specifier: ^17.0.0 + version: 17.1.0(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: + specifier: ^2.25.2 + version: 2.31.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.0.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.0.0 + version: 6.6.0(eslint@8.57.1) + jsdoc: + specifier: ^4.0.2 + version: 4.0.4 + pino-pretty: + specifier: ^10.0.0 + version: 10.3.1 + sinon: + specifier: ^15.0.4 + version: 15.2.0 + utap: + specifier: ^0.2.0 + version: 0.2.0 packages: - /@babel/helper-string-parser@7.19.4: - resolution: {integrity: sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - dev: true - /@babel/helper-validator-identifier@7.19.1: - resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} - dev: true - /@babel/parser@7.21.3: - resolution: {integrity: sha512-lobG0d7aOfQRXh8AyklEAgZGvA4FShxo6xQbUrrT/cNBPUdIDojlokwJsQyCC/eKia7ifqM0yP+2DRZ4WKw2RQ==} + '@babel/parser@7.27.4': + resolution: {integrity: sha512-BRmLHGwpUqLFR2jzx9orBuX/ABDkj2jLKOXrHDTN2aOKL+jFDDKaRNo9nyYsIl9h/UE/7lMKdDjKQQyxKKDZ7g==} engines: {node: '>=6.0.0'} hasBin: true - dependencies: - '@babel/types': 7.21.3 - dev: true - /@babel/types@7.21.3: - resolution: {integrity: sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg==} + '@babel/types@7.27.3': + resolution: {integrity: sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.19.4 - '@babel/helper-validator-identifier': 7.19.1 - 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==} + '@eslint-community/eslint-utils@4.7.0': + resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - dependencies: - eslint: 8.36.0 - eslint-visitor-keys: 3.3.0 - dev: true - /@eslint-community/regexpp@4.4.0: - resolution: {integrity: sha512-A9983Q0LnDGdLPjxyXQ00sbV+K+O+ko2Dr+CZigbHWtX9pNfxlaBkMR8X1CztI73zuEyEBXTVjx7CE+/VSwDiQ==} + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - dev: true - /@eslint/eslintrc@2.0.1: - resolution: {integrity: sha512-eFRmABvW2E5Ho6f5fHLqgena46rOj7r7OKHYfLElqcBfGFHHpjBhivyi5+jOEQuSpdc/1phIZJlbC2te+tZNIw==} + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - ajv: 6.12.6 - debug: 4.3.4 - espree: 9.5.0 - globals: 13.20.0 - ignore: 5.2.4 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - dev: true - /@eslint/js@8.36.0: - resolution: {integrity: sha512-lxJ9R5ygVm8ZWgYdUweoq5ownDlJ4upvoWmO4eLxBYHdMo+vZ/Rx0EN6MbKWDJOSUGrqJy2Gt+Dyv/VKml0fjg==} + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true - /@humanwhocodes/config-array@0.11.8: - resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==} + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} - dependencies: - '@humanwhocodes/object-schema': 1.2.1 - debug: 4.3.4 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - dev: true + deprecated: Use @eslint/config-array instead - /@humanwhocodes/module-importer@1.0.1: + '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - dev: true - /@humanwhocodes/object-schema@1.2.1: - resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} - dev: true + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead - /@jsdoc/salty@0.2.5: - resolution: {integrity: sha512-TfRP53RqunNe2HBobVBJ0VLhK1HbfvBYeTC1ahnN64PWvyYyGebmMiPkuwvD9fpw2ZbkoPb8Q7mwy0aR8Z9rvw==} + '@jsdoc/salty@0.2.9': + resolution: {integrity: sha512-yYxMVH7Dqw6nO0d5NIV8OQWnitU8k6vXH8NtgqAfIa/IUqRMxRv/NUJJ08VEKbAakwxlgBl5PJdrU0dMPStsnw==} engines: {node: '>=v12.0.0'} - dependencies: - lodash: 4.17.21 - dev: true - /@nodelib/fs.scandir@2.1.5: + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - dev: true - /@nodelib/fs.stat@2.0.5: + '@nodelib/fs.stat@2.0.5': resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} - dev: true - /@nodelib/fs.walk@1.2.8: + '@nodelib/fs.walk@1.2.8': resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.15.0 - dev: true - /@sinonjs/commons@2.0.0: - resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} - dependencies: - type-detect: 4.0.8 - dev: true + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - /@sinonjs/commons@3.0.0: - resolution: {integrity: sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==} - dependencies: - type-detect: 4.0.8 - dev: true + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - /@sinonjs/fake-timers@10.0.2: - resolution: {integrity: sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==} - dependencies: - '@sinonjs/commons': 2.0.0 - dev: true + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - /@sinonjs/samsam@8.0.0: - resolution: {integrity: sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==} - dependencies: - '@sinonjs/commons': 2.0.0 - lodash.get: 4.4.2 - type-detect: 4.0.8 - dev: true + '@sinonjs/fake-timers@11.3.1': + resolution: {integrity: sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==} - /@sinonjs/text-encoding@0.7.2: - resolution: {integrity: sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==} - dev: true + '@sinonjs/samsam@8.0.2': + resolution: {integrity: sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==} - /@types/json5@0.0.29: + '@sinonjs/text-encoding@0.7.3': + resolution: {integrity: sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==} + + '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - dev: true - /@types/linkify-it@3.0.2: - resolution: {integrity: sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==} - dev: true + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} - /@types/markdown-it@12.2.3: - resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} - dependencies: - '@types/linkify-it': 3.0.2 - '@types/mdurl': 1.0.2 - dev: true + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} - /@types/mdurl@1.0.2: - resolution: {integrity: sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==} - dev: true + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} - /abort-controller@3.0.0: + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} - dependencies: - event-target-shim: 5.0.1 - /acorn-jsx@5.3.2(acorn@8.8.2): + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - acorn: 8.8.2 - dev: true - /acorn@8.8.2: - resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} + acorn@8.14.1: + resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} engines: {node: '>=0.4.0'} hasBin: true - dev: true - /ajv@6.12.6: + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - 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: + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - dev: true - /ansi-styles@4.3.0: + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - dependencies: - color-convert: 2.0.1 - dev: true - /argparse@2.0.1: + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true - /array-buffer-byte-length@1.0.0: - resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} - dependencies: - call-bind: 1.0.2 - is-array-buffer: 3.0.2 - dev: true + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} - /array-includes@3.1.6: - resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==} + array-includes@3.1.8: + resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 - get-intrinsic: 1.2.0 - is-string: 1.0.7 - dev: true - /array.prototype.flat@1.3.1: - resolution: {integrity: sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==} + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 - es-shim-unscopables: 1.0.0 - dev: true - /array.prototype.flatmap@1.3.1: - resolution: {integrity: sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==} + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 - es-shim-unscopables: 1.0.0 - dev: true - /atomic-sleep@1.0.0: + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - /available-typed-arrays@1.0.5: - resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - dev: true - /balanced-match@1.0.2: + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - dev: true - /base64-js@1.5.1: + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - /bluebird@3.7.2: + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - dev: true - /brace-expansion@1.1.11: + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - dev: true - - /brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} - dependencies: - balanced-match: 1.0.2 - dev: true - /buffer@6.0.3: + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - /builtins@5.0.1: - resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==} - dependencies: - semver: 7.3.8 - dev: true + builtins@5.1.0: + resolution: {integrity: sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==} - /call-bind@1.0.2: - resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} - dependencies: - function-bind: 1.1.1 - get-intrinsic: 1.2.0 - dev: true + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} - /callsites@3.1.0: + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - dev: true - /catharsis@0.9.0: + catharsis@0.9.0: resolution: {integrity: sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==} engines: {node: '>= 10'} - dependencies: - lodash: 4.17.21 - dev: true - /chalk@4.1.2: + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - dev: true - /chalk@5.2.0: - resolution: {integrity: sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==} + chalk@5.4.1: + resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - dev: true - /color-convert@2.0.1: + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} - dependencies: - color-name: 1.1.4 - dev: true - /color-name@1.1.4: + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - dev: true - /colorette@2.0.19: - resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} - dev: true + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - /concat-map@0.0.1: + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - dev: true - /cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - dev: true - /dateformat@4.6.3: + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - dev: true - /debug@3.2.7: + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: supports-color: '*' peerDependenciesMeta: supports-color: optional: true - dependencies: - ms: 2.1.2 - dev: true - /debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' peerDependenciesMeta: supports-color: optional: true - dependencies: - ms: 2.1.2 - dev: true - /deep-is@0.1.4: + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dev: true - /define-properties@1.2.0: - resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} - dependencies: - has-property-descriptors: 1.0.0 - object-keys: 1.1.1 - dev: true - /diff@5.1.0: - resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==} + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + diff@5.2.0: + resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} - dev: true - /doctrine@2.1.0: + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dependencies: - esutils: 2.0.3 - dev: true - /doctrine@3.0.0: + doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} - dependencies: - esutils: 2.0.3 - dev: true - /dotenv@16.0.3: - resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} + dotenv@16.5.0: + resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} engines: {node: '>=12'} - dev: false - /end-of-stream@1.4.4: + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - dependencies: - once: 1.4.0 - dev: true - - /entities@2.1.0: - resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==} - dev: true - - /es-abstract@1.21.2: - resolution: {integrity: sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg==} - engines: {node: '>= 0.4'} - dependencies: - array-buffer-byte-length: 1.0.0 - available-typed-arrays: 1.0.5 - call-bind: 1.0.2 - es-set-tostringtag: 2.0.1 - es-to-primitive: 1.2.1 - function.prototype.name: 1.1.5 - get-intrinsic: 1.2.0 - get-symbol-description: 1.0.0 - globalthis: 1.0.3 - gopd: 1.0.1 - has: 1.0.3 - has-property-descriptors: 1.0.0 - has-proto: 1.0.1 - has-symbols: 1.0.3 - internal-slot: 1.0.5 - is-array-buffer: 3.0.2 - is-callable: 1.2.7 - is-negative-zero: 2.0.2 - is-regex: 1.1.4 - is-shared-array-buffer: 1.0.2 - is-string: 1.0.7 - is-typed-array: 1.1.10 - is-weakref: 1.0.2 - object-inspect: 1.12.3 - object-keys: 1.1.1 - object.assign: 4.1.4 - regexp.prototype.flags: 1.4.3 - safe-regex-test: 1.0.0 - string.prototype.trim: 1.2.7 - string.prototype.trimend: 1.0.6 - string.prototype.trimstart: 1.0.6 - typed-array-length: 1.0.4 - unbox-primitive: 1.0.2 - which-typed-array: 1.1.9 - dev: true - /es-set-tostringtag@2.0.1: - resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} - dependencies: - get-intrinsic: 1.2.0 - has: 1.0.3 - has-tostringtag: 1.0.0 - dev: true - /es-shim-unscopables@1.0.0: - resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} - dependencies: - has: 1.0.3 - dev: true + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} - /es-to-primitive@1.2.1: - resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - dependencies: - is-callable: 1.2.7 - is-date-object: 1.0.5 - is-symbol: 1.0.4 - dev: true - /escape-string-regexp@2.0.0: + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} - dev: true - /escape-string-regexp@4.0.0: + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - dev: true - /eslint-config-standard@17.0.0(eslint-plugin-import@2.27.5)(eslint-plugin-n@15.6.1)(eslint-plugin-promise@6.1.1)(eslint@8.36.0): - resolution: {integrity: sha512-/2ks1GKyqSOkH7JFvXJicu0iMpoojkwB+f5Du/1SC0PtBL+s8v30k9njRZ21pm2drKYm2342jFnGWzttxPmZVg==} + eslint-config-standard@17.1.0: + resolution: {integrity: sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==} + engines: {node: '>=12.0.0'} peerDependencies: eslint: ^8.0.1 eslint-plugin-import: ^2.25.2 - eslint-plugin-n: ^15.0.0 + eslint-plugin-n: '^15.0.0 || ^16.0.0 ' eslint-plugin-promise: ^6.0.0 - dependencies: - eslint: 8.36.0 - eslint-plugin-import: 2.27.5(eslint@8.36.0) - eslint-plugin-n: 15.6.1(eslint@8.36.0) - eslint-plugin-promise: 6.1.1(eslint@8.36.0) - dev: true - /eslint-import-resolver-node@0.3.7: - resolution: {integrity: sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==} - dependencies: - debug: 3.2.7 - is-core-module: 2.11.0 - resolve: 1.22.1 - transitivePeerDependencies: - - supports-color - dev: true + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - /eslint-module-utils@2.7.4(eslint-import-resolver-node@0.3.7)(eslint@8.36.0): - resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==} + eslint-module-utils@2.12.0: + resolution: {integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -614,1312 +427,2231 @@ packages: optional: true eslint-import-resolver-webpack: optional: true - dependencies: - debug: 3.2.7 - eslint: 8.36.0 - eslint-import-resolver-node: 0.3.7 - transitivePeerDependencies: - - supports-color - dev: true - /eslint-plugin-es@4.1.0(eslint@8.36.0): + eslint-plugin-es@4.1.0: resolution: {integrity: sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ==} engines: {node: '>=8.10.0'} peerDependencies: eslint: '>=4.19.1' - dependencies: - eslint: 8.36.0 - eslint-utils: 2.1.0 - regexpp: 3.2.0 - dev: true - /eslint-plugin-import@2.27.5(eslint@8.36.0): - resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==} + eslint-plugin-import@2.31.0: + resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 peerDependenciesMeta: '@typescript-eslint/parser': optional: true - dependencies: - array-includes: 3.1.6 - array.prototype.flat: 1.3.1 - array.prototype.flatmap: 1.3.1 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 8.36.0 - eslint-import-resolver-node: 0.3.7 - eslint-module-utils: 2.7.4(eslint-import-resolver-node@0.3.7)(eslint@8.36.0) - has: 1.0.3 - is-core-module: 2.11.0 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.values: 1.1.6 - resolve: 1.22.1 - semver: 6.3.0 - tsconfig-paths: 3.14.2 - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - dev: true - /eslint-plugin-n@15.6.1(eslint@8.36.0): - resolution: {integrity: sha512-R9xw9OtCRxxaxaszTQmQAlPgM+RdGjaL1akWuY/Fv9fRAi8Wj4CUKc6iYVG8QNRjRuo8/BqVYIpfqberJUEacA==} + eslint-plugin-n@15.7.0: + resolution: {integrity: sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==} engines: {node: '>=12.22.0'} peerDependencies: eslint: '>=7.0.0' - dependencies: - builtins: 5.0.1 - eslint: 8.36.0 - eslint-plugin-es: 4.1.0(eslint@8.36.0) - eslint-utils: 3.0.0(eslint@8.36.0) - ignore: 5.2.4 - is-core-module: 2.11.0 - minimatch: 3.1.2 - resolve: 1.22.1 - semver: 7.3.8 - dev: true - /eslint-plugin-promise@6.1.1(eslint@8.36.0): - resolution: {integrity: sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==} + eslint-plugin-promise@6.6.0: + resolution: {integrity: sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - dependencies: - eslint: 8.36.0 - dev: true + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 - /eslint-scope@7.1.1: - resolution: {integrity: sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==} + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - dev: true - /eslint-utils@2.1.0: + eslint-utils@2.1.0: resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} engines: {node: '>=6'} - dependencies: - eslint-visitor-keys: 1.3.0 - dev: true - /eslint-utils@3.0.0(eslint@8.36.0): + eslint-utils@3.0.0: resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} peerDependencies: eslint: '>=5' - dependencies: - eslint: 8.36.0 - eslint-visitor-keys: 2.1.0 - dev: true - /eslint-visitor-keys@1.3.0: + eslint-visitor-keys@1.3.0: resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} engines: {node: '>=4'} - dev: true - /eslint-visitor-keys@2.1.0: + eslint-visitor-keys@2.1.0: resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} engines: {node: '>=10'} - dev: true - /eslint-visitor-keys@3.3.0: - resolution: {integrity: sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==} + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true - /eslint@8.36.0: - resolution: {integrity: sha512-Y956lmS7vDqomxlaaQAHVmeb4tNMp2FWIvU/RnU5BD3IKMD/MJPr76xdyr68P8tV1iNMvN2mRK0yy3c+UjL+bw==} + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true - dependencies: - '@eslint-community/eslint-utils': 4.3.0(eslint@8.36.0) - '@eslint-community/regexpp': 4.4.0 - '@eslint/eslintrc': 2.0.1 - '@eslint/js': 8.36.0 - '@humanwhocodes/config-array': 0.11.8 - '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.3 - debug: 4.3.4 - doctrine: 3.0.0 - escape-string-regexp: 4.0.0 - eslint-scope: 7.1.1 - eslint-visitor-keys: 3.3.0 - espree: 9.5.0 - esquery: 1.5.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 - find-up: 5.0.0 - glob-parent: 6.0.2 - globals: 13.20.0 - grapheme-splitter: 1.0.4 - ignore: 5.2.4 - import-fresh: 3.3.0 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-sdsl: 4.3.0 - js-yaml: 4.1.0 - json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.1 - strip-ansi: 6.0.1 - strip-json-comments: 3.1.1 - text-table: 0.2.0 - transitivePeerDependencies: - - supports-color - dev: true - /espree@9.5.0: - resolution: {integrity: sha512-JPbJGhKc47++oo4JkEoTe2wjy4fmMwvFpgJT9cQzmfXKp22Dr6Hf1tdCteLz1h0P3t+mGvWZ+4Uankvh8+c6zw==} + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - acorn: 8.8.2 - acorn-jsx: 5.3.2(acorn@8.8.2) - eslint-visitor-keys: 3.3.0 - dev: true - /esquery@1.5.0: - resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} - dependencies: - estraverse: 5.3.0 - dev: true - /esrecurse@4.3.0: + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - dependencies: - estraverse: 5.3.0 - dev: true - /estraverse@5.3.0: + estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - dev: true - /esutils@2.0.3: + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - dev: true - /event-target-shim@5.0.1: + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - /events@3.3.0: + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - /fast-copy@3.0.1: - resolution: {integrity: sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==} - dev: true + fast-copy@3.0.2: + resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} - /fast-deep-equal@3.1.3: + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - /fast-json-stable-stringify@2.1.0: + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - dev: true - /fast-levenshtein@2.0.6: + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - dev: true - /fast-redact@3.1.2: - resolution: {integrity: sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw==} + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} - dev: false - /fast-safe-stringify@2.1.1: + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - dev: true - /fastq@1.15.0: - resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} - dependencies: - reusify: 1.0.4 - dev: true + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - /file-entry-cache@6.0.1: + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} - dependencies: - flat-cache: 3.0.4 - dev: true - /find-up@5.0.0: + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - dev: true - /flat-cache@3.0.4: - resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} - dependencies: - flatted: 3.2.7 - rimraf: 3.0.2 - dev: true - /flatted@3.2.7: - resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} - dev: true + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - /for-each@0.3.3: - resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} - dependencies: - is-callable: 1.2.7 - dev: true + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} - /fs.realpath@1.0.0: + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - dev: true - /function-bind@1.1.1: - resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} - dev: true + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - /function.prototype.name@1.1.5: - resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==} + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 - functions-have-names: 1.2.3 - dev: true - /functions-have-names@1.2.3: + functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - dev: true - /get-intrinsic@1.2.0: - resolution: {integrity: sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==} - dependencies: - function-bind: 1.1.1 - has: 1.0.3 - has-symbols: 1.0.3 - dev: true + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} - /get-symbol-description@1.0.0: - resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.0 - dev: true - /glob-parent@6.0.2: + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - dependencies: - is-glob: 4.0.3 - dev: true - /glob@7.2.3: + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - dev: true - - /glob@8.1.0: - resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} - engines: {node: '>=12'} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 5.1.6 - once: 1.4.0 - dev: true + deprecated: Glob versions prior to v9 are no longer supported - /globals@13.20.0: - resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} - dependencies: - type-fest: 0.20.2 - dev: true - /globalthis@1.0.3: - resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==} + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} - dependencies: - define-properties: 1.2.0 - dev: true - /gopd@1.0.1: - resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} - dependencies: - get-intrinsic: 1.2.0 - dev: true + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} - /graceful-fs@4.2.11: + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - dev: true - /grapheme-splitter@1.0.4: - resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} - dev: true + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - /has-bigints@1.0.2: - resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} - dev: true + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} - /has-flag@4.0.0: + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - dev: true - /has-property-descriptors@1.0.0: - resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} - dependencies: - get-intrinsic: 1.2.0 - dev: true + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - /has-proto@1.0.1: - resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} engines: {node: '>= 0.4'} - dev: true - /has-symbols@1.0.3: - resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - dev: true - /has-tostringtag@1.0.0: - resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - dependencies: - has-symbols: 1.0.3 - dev: true - /has@1.0.3: - resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} - engines: {node: '>= 0.4.0'} - dependencies: - function-bind: 1.1.1 - dev: true + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} - /help-me@4.2.0: - resolution: {integrity: sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==} - dependencies: - glob: 8.1.0 - readable-stream: 3.6.2 - dev: true + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - /ieee754@1.2.1: + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - /ignore@5.2.4: - resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - dev: true - /import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - dev: true - /imurmurhash@0.1.4: + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - dev: true - /inflight@1.0.6: + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - dev: true + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - /inherits@2.0.4: + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - /internal-slot@1.0.5: - resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - dependencies: - get-intrinsic: 1.2.0 - has: 1.0.3 - side-channel: 1.0.4 - dev: true - /is-array-buffer@3.0.2: - resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} - dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.0 - is-typed-array: 1.1.10 - dev: true + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} - /is-bigint@1.0.4: - resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} - dependencies: - has-bigints: 1.0.2 - dev: true + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} - /is-boolean-object@1.1.2: - resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - has-tostringtag: 1.0.0 - dev: true - /is-callable@1.2.7: + is-callable@1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - dev: true - /is-core-module@2.11.0: - resolution: {integrity: sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==} - dependencies: - has: 1.0.3 - dev: true + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} - /is-date-object@1.0.5: - resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} - dependencies: - has-tostringtag: 1.0.0 - dev: true - /is-extglob@2.1.1: + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - dev: true - /is-glob@4.0.3: + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.0: + resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - dependencies: - is-extglob: 2.1.1 - dev: true - /is-negative-zero@2.0.2: - resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} - dev: true - /is-number-object@1.0.7: - resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} - dependencies: - has-tostringtag: 1.0.0 - dev: true - /is-path-inside@3.0.3: + is-path-inside@3.0.3: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} - dev: true - /is-regex@1.1.4: - resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - has-tostringtag: 1.0.0 - dev: true - /is-shared-array-buffer@1.0.2: - resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} - dependencies: - call-bind: 1.0.2 - dev: true + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} - /is-string@1.0.7: - resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} - dependencies: - has-tostringtag: 1.0.0 - dev: true - /is-symbol@1.0.4: - resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} - dependencies: - has-symbols: 1.0.3 - dev: true - /is-typed-array@1.1.10: - resolution: {integrity: sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==} + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} engines: {node: '>= 0.4'} - dependencies: - available-typed-arrays: 1.0.5 - call-bind: 1.0.2 - for-each: 0.3.3 - gopd: 1.0.1 - has-tostringtag: 1.0.0 - dev: true - /is-weakref@1.0.2: - resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} - dependencies: - call-bind: 1.0.2 - dev: true + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} - /isarray@0.0.1: - resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} - dev: true + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - /isexe@2.0.0: + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - dev: true - /joycon@3.1.1: + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - dev: true - - /js-sdsl@4.3.0: - resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==} - dev: true - /js-yaml@4.1.0: + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true - dependencies: - argparse: 2.0.1 - dev: true - /js2xmlparser@4.0.2: + js2xmlparser@4.0.2: resolution: {integrity: sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==} - dependencies: - xmlcreate: 2.0.4 - dev: true - /jsdoc@4.0.2: - resolution: {integrity: sha512-e8cIg2z62InH7azBBi3EsSEqrKx+nUtAS5bBcYTSpZFA+vhNPyhv8PTFZ0WsjOPDj04/dOLlm08EDcQJDqaGQg==} + jsdoc@4.0.4: + resolution: {integrity: sha512-zeFezwyXeG4syyYHbvh1A967IAqq/67yXtXvuL5wnqCkFZe8I0vKfm+EO+YEvLguo6w9CDUbrAXVtJSHh2E8rw==} engines: {node: '>=12.0.0'} hasBin: true - dependencies: - '@babel/parser': 7.21.3 - '@jsdoc/salty': 0.2.5 - '@types/markdown-it': 12.2.3 - bluebird: 3.7.2 - catharsis: 0.9.0 - escape-string-regexp: 2.0.0 - js2xmlparser: 4.0.2 - klaw: 3.0.0 - markdown-it: 12.3.2 - markdown-it-anchor: 8.6.7(@types/markdown-it@12.2.3)(markdown-it@12.3.2) - marked: 4.2.12 - mkdirp: 1.0.4 - requizzle: 0.2.4 - strip-json-comments: 3.1.1 - underscore: 1.13.6 - dev: true - /json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - dev: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - /json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - dev: false + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - /json-stable-stringify-without-jsonify@1.0.1: + 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: + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true - dependencies: - minimist: 1.2.8 - dev: true - /just-extend@4.2.1: - resolution: {integrity: sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==} - dev: true + just-extend@6.2.0: + resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} - /klaw@3.0.0: + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + klaw@3.0.0: resolution: {integrity: sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==} - dependencies: - graceful-fs: 4.2.11 - dev: true - /levn@0.4.1: + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - dev: true - /linkify-it@3.0.3: - resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==} - dependencies: - uc.micro: 1.0.6 - dev: true + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} - /locate-path@6.0.0: + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - dependencies: - p-locate: 5.0.0 - dev: true - /lodash.get@4.4.2: + lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} - dev: true + deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. - /lodash.merge@4.6.2: + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - dev: true - /lodash@4.17.21: + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: true - - /lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - dependencies: - yallist: 4.0.0 - dev: true - /markdown-it-anchor@8.6.7(@types/markdown-it@12.2.3)(markdown-it@12.3.2): + markdown-it-anchor@8.6.7: resolution: {integrity: sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==} peerDependencies: '@types/markdown-it': '*' markdown-it: '*' - dependencies: - '@types/markdown-it': 12.2.3 - markdown-it: 12.3.2 - dev: true - /markdown-it@12.3.2: - resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==} + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true - dependencies: - argparse: 2.0.1 - entities: 2.1.0 - linkify-it: 3.0.3 - mdurl: 1.0.1 - uc.micro: 1.0.6 - dev: true - - /marked@4.2.12: - resolution: {integrity: sha512-yr8hSKa3Fv4D3jdZmtMMPghgVt6TWbk86WQaWhDloQjRSQhMMYCAro7jP7VDJrjjdV8pxVxMssXS8B8Y5DZ5aw==} + + marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} engines: {node: '>= 12'} hasBin: true - dev: true - /mdurl@1.0.1: - resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} - dev: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} - /minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - dependencies: - brace-expansion: 1.1.11 - dev: true + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} - /minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - dependencies: - brace-expansion: 2.0.1 - dev: true + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - /minimist@1.2.8: + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - /mkdirp@1.0.4: + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} hasBin: true - dev: true - /ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - /nanoid@4.0.1: - resolution: {integrity: sha512-udKGtCCUafD3nQtJg9wBhRP3KMbPglUsgV5JVsXhvyBs/oefqb4sqMEhKBBgqZncYowu58p1prsZQBYvAj/Gww==} + nanoid@4.0.2: + resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} engines: {node: ^14 || ^16 || >=18} hasBin: true - dev: false - /natural-compare@1.4.0: + natural-compare@1.4.0: 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: - '@sinonjs/commons': 2.0.0 - '@sinonjs/fake-timers': 10.0.2 - '@sinonjs/text-encoding': 0.7.2 - just-extend: 4.2.1 - path-to-regexp: 1.8.0 - dev: true - - /object-inspect@1.12.3: - resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} - dev: true - - /object-keys@1.1.1: + nise@5.1.9: + resolution: {integrity: sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} - dev: true - /object.assign@4.1.4: - resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==} + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - has-symbols: 1.0.3 - object-keys: 1.1.1 - dev: true - /object.values@1.1.6: - resolution: {integrity: sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==} + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 - dev: true - /on-exit-leak-free@2.1.0: - resolution: {integrity: sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} - /once@1.4.0: + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - dependencies: - wrappy: 1.0.2 - dev: true - /optionator@0.9.1: - resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.3 - dev: true - /p-limit@3.1.0: + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - dependencies: - yocto-queue: 0.1.0 - dev: true - /p-locate@5.0.0: + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - dependencies: - p-limit: 3.1.0 - dev: true - /parent-module@1.0.1: + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - dependencies: - callsites: 3.1.0 - dev: true - /path-exists@4.0.0: + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - dev: true - /path-is-absolute@1.0.1: + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} - dev: true - /path-key@3.1.1: + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - dev: true - /path-parse@1.0.7: + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - dev: true - /path-to-regexp@1.8.0: - resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==} - dependencies: - isarray: 0.0.1 - dev: true + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - /pino-abstract-transport@1.0.0: - resolution: {integrity: sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA==} - dependencies: - readable-stream: 4.3.0 - split2: 4.1.0 + pino-abstract-transport@1.2.0: + resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==} - /pino-pretty@10.0.0: - resolution: {integrity: sha512-zKFjYXBzLaLTEAN1ayKpHXtL5UeRQC7R3lvhKe7fWs7hIVEjKGG/qIXwQt9HmeUp71ogUd/YcW+LmMwRp4KT6Q==} + pino-pretty@10.3.1: + resolution: {integrity: sha512-az8JbIYeN/1iLj2t0jR9DV48/LQ3RC6hZPpapKPkb84Q+yTidMCpgWxIT3N0flnBDilyBQ1luWNpOeJptjdp/g==} hasBin: true - dependencies: - colorette: 2.0.19 - dateformat: 4.6.3 - fast-copy: 3.0.1 - fast-safe-stringify: 2.1.1 - help-me: 4.2.0 - joycon: 3.1.1 - minimist: 1.2.8 - on-exit-leak-free: 2.1.0 - pino-abstract-transport: 1.0.0 - pump: 3.0.0 - readable-stream: 4.3.0 - secure-json-parse: 2.7.0 - sonic-boom: 3.3.0 - strip-json-comments: 3.1.1 - dev: true - /pino-std-serializers@6.1.0: - resolution: {integrity: sha512-KO0m2f1HkrPe9S0ldjx7za9BJjeHqBku5Ch8JyxETxT8dEFGz1PwgrHaOQupVYitpzbFSYm7nnljxD8dik2c+g==} - dev: false + pino-std-serializers@6.2.2: + resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} - /pino@8.11.0: - resolution: {integrity: sha512-Z2eKSvlrl2rH8p5eveNUnTdd4AjJk8tAsLkHYZQKGHP4WTh2Gi1cOSOs3eWPqaj+niS3gj4UkoreoaWgF3ZWYg==} + pino@8.21.0: + resolution: {integrity: sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==} hasBin: true - dependencies: - atomic-sleep: 1.0.0 - fast-redact: 3.1.2 - on-exit-leak-free: 2.1.0 - pino-abstract-transport: 1.0.0 - pino-std-serializers: 6.1.0 - process-warning: 2.1.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.4.3 - sonic-boom: 3.3.0 - thread-stream: 2.3.0 - dev: false - /prelude-ls@1.2.1: + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - dev: true - /process-warning@2.1.0: - resolution: {integrity: sha512-9C20RLxrZU/rFnxWncDkuF6O999NdIf3E1ws4B0ZeY3sRVPzWBMsYDE2lxjxhiXxg464cQTgKUGm8/i6y2YGXg==} - dev: false + process-warning@3.0.0: + resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} - /process@0.11.10: + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} - /pump@3.0.0: - resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} - dependencies: - end-of-stream: 1.4.4 - once: 1.4.0 - dev: true + prom-client@14.2.0: + resolution: {integrity: sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==} + engines: {node: '>=10'} + + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} - /punycode@2.3.0: - resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - /queue-microtask@1.2.3: + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true - /quick-format-unescaped@4.0.4: + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - dev: false - - /readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - /readable-stream@4.3.0: - resolution: {integrity: sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ==} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - abort-controller: 3.0.0 - buffer: 6.0.3 - events: 3.3.0 - process: 0.11.10 - /real-require@0.2.0: + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} - dev: false - /regexp.prototype.flags@1.4.3: - resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - functions-have-names: 1.2.3 - dev: true - /regexpp@3.2.0: + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpp@3.2.0: resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} 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: + requizzle@0.2.4: resolution: {integrity: sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==} - dependencies: - lodash: 4.17.21 - dev: true - /resolve-from@4.0.0: + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - dev: true - /resolve@1.22.1: - resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==} + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} hasBin: true - dependencies: - is-core-module: 2.11.0 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - dev: true - /reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true - /rimraf@3.0.2: + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - dependencies: - glob: 7.2.3 - dev: true - /run-parallel@1.2.0: + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - dependencies: - queue-microtask: 1.2.3 - dev: true - /safe-buffer@5.2.1: + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - /safe-regex-test@1.0.0: - resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==} - dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.0 - is-regex: 1.1.4 - dev: true + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} - /safe-stable-stringify@2.4.3: - resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} - dev: false - /secure-json-parse@2.7.0: + secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} - dev: true - /semver@6.3.0: - resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - dev: true - /semver@7.3.8: - resolution: {integrity: sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==} + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} hasBin: true - dependencies: - lru-cache: 6.0.0 - dev: true - /shebang-command@2.0.0: + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} - dependencies: - shebang-regex: 3.0.0 - dev: true - /shebang-regex@3.0.0: + shebang-regex@3.0.0: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - dev: true - /side-channel@1.0.4: - resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} - dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.0 - object-inspect: 1.12.3 - dev: true + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} - /sinon@15.0.4: - resolution: {integrity: sha512-uzmfN6zx3GQaria1kwgWGeKiXSSbShBbue6Dcj0SI8fiCNFbiUDqKl57WFlY5lyhxZVUKmXvzgG2pilRQCBwWg==} - dependencies: - '@sinonjs/commons': 3.0.0 - '@sinonjs/fake-timers': 10.0.2 - '@sinonjs/samsam': 8.0.0 - diff: 5.1.0 - nise: 5.1.4 - supports-color: 7.2.0 - dev: true + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} - /sonic-boom@3.3.0: - resolution: {integrity: sha512-LYxp34KlZ1a2Jb8ZQgFCK3niIHzibdwtwNUWKg0qQRzsDoJ3Gfgkf8KdBTFU3SkejDEIlWwnSnpVdOZIhFMl/g==} - dependencies: - atomic-sleep: 1.0.0 + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} - /split2@3.2.2: - resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} - dependencies: - readable-stream: 3.6.2 - dev: false + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + sinon@15.2.0: + resolution: {integrity: sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==} + deprecated: 16.1.1 + + sonic-boom@3.8.1: + resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} - /split2@4.1.0: - resolution: {integrity: sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - /string.prototype.trim@1.2.7: - resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 - dev: true - /string.prototype.trimend@1.0.6: - resolution: {integrity: sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 - dev: true + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} - /string.prototype.trimstart@1.0.6: - resolution: {integrity: sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 - dev: true + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} - /string_decoder@1.3.0: + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - dependencies: - safe-buffer: 5.2.1 - /strip-ansi@6.0.1: + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - dependencies: - ansi-regex: 5.0.1 - dev: true - /strip-bom@3.0.0: + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - dev: true - /strip-json-comments@3.1.1: + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - dev: true - /supports-color@7.2.0: + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - dependencies: - has-flag: 4.0.0 - dev: true - /supports-preserve-symlinks-flag@1.0.0: + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - dev: true - - /text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - dev: true - /thread-stream@2.3.0: - resolution: {integrity: sha512-kaDqm1DET9pp3NXwR8382WHbnpXnRkN9xGN9dQt3B2+dmXiW8X1SOwmFOxAErEQ47ObhZ96J6yhZNXuyCOL7KA==} - dependencies: - real-require: 0.2.0 - dev: false + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} - /through2@4.0.2: - resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} - dependencies: - readable-stream: 3.6.2 - dev: false + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - /to-fast-properties@2.0.0: - resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} - engines: {node: '>=4'} - dev: true + thread-stream@2.7.0: + resolution: {integrity: sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==} - /tsconfig-paths@3.14.2: - resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} - dependencies: - '@types/json5': 0.0.29 - json5: 1.0.2 - minimist: 1.2.8 - strip-bom: 3.0.0 - dev: true + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - /type-check@0.4.0: + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - dependencies: - prelude-ls: 1.2.1 - dev: true - /type-detect@4.0.8: + type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} - dev: true - /type-fest@0.20.2: + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} - dev: true - /typed-array-length@1.0.4: - resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} - dependencies: - call-bind: 1.0.2 - for-each: 0.3.3 - is-typed-array: 1.1.10 - dev: true + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} - /uc.micro@1.0.6: - resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} - dev: true + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} - /unbox-primitive@1.0.2: - resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} - dependencies: - call-bind: 1.0.2 - has-bigints: 1.0.2 - has-symbols: 1.0.3 - which-boxed-primitive: 1.0.2 - dev: true + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} - /underscore@1.13.6: - resolution: {integrity: sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==} - dev: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - /uri-js@4.4.1: + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + underscore@1.13.7: + resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} + + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - dependencies: - punycode: 2.3.0 - /utap@0.2.0: + utap@0.2.0: resolution: {integrity: sha512-YbDY2Qb/+UHUc7Qhued83UzmTyxSozwczDpWxLs4Htees2H7Krj7WkmMbyJI4Pq2UCxQuPHrVv4/kozHz4WWYQ==} engines: {node: '>=18'} hasBin: true - dependencies: - chalk: 5.2.0 - dev: true - /util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} - /which-boxed-primitive@1.0.2: - resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} - dependencies: - is-bigint: 1.0.4 - is-boolean-object: 1.1.2 - is-number-object: 1.0.7 - is-string: 1.0.7 - is-symbol: 1.0.4 - dev: true + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} - /which-typed-array@1.1.9: - resolution: {integrity: sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==} + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} - dependencies: - available-typed-arrays: 1.0.5 - call-bind: 1.0.2 - for-each: 0.3.3 - gopd: 1.0.1 - has-tostringtag: 1.0.0 - is-typed-array: 1.1.10 - dev: true - /which@2.0.2: + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true - dependencies: - isexe: 2.0.0 - dev: true - /word-wrap@1.2.3: - resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - dev: true - /wrappy@1.0.2: + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - dev: true - /xmlcreate@2.0.4: + xmlcreate@2.0.4: resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==} - dev: true - - /yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - dev: true - /yocto-queue@0.1.0: + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - dev: true + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/parser@7.27.4': + dependencies: + '@babel/types': 7.27.3 + + '@babel/types@7.27.3': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@eslint-community/eslint-utils@4.7.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.1 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.1 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@jsdoc/salty@0.2.9': + dependencies: + lodash: 4.17.21 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@rtsao/scc@1.1.0': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/fake-timers@11.3.1': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/samsam@8.0.2': + dependencies: + '@sinonjs/commons': 3.0.1 + lodash.get: 4.4.2 + type-detect: 4.1.0 + + '@sinonjs/text-encoding@0.7.3': {} + + '@types/json5@0.0.29': {} + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + + '@ungap/structured-clone@1.3.0': {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.14.1): + dependencies: + acorn: 8.14.1 + + acorn@8.14.1: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + async-function@1.0.0: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + bintrees@1.0.2: {} + + bluebird@3.7.2: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtins@5.1.0: + dependencies: + semver: 7.7.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + catharsis@0.9.0: + dependencies: + lodash: 4.17.21 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.4.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + dateformat@4.6.3: {} + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + diff@5.2.0: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dotenv@16.5.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + + entities@4.5.0: {} + + es-abstract@1.24.0: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-plugin-import: 2.31.0(eslint@8.57.1) + eslint-plugin-n: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: 6.6.0(eslint@8.57.1) + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.10 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.0(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): + dependencies: + debug: 3.2.7 + optionalDependencies: + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + + eslint-plugin-es@4.1.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-utils: 2.1.0 + regexpp: 3.2.0 + + eslint-plugin-import@2.31.0(eslint@8.57.1): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.0(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-n@15.7.0(eslint@8.57.1): + dependencies: + builtins: 5.1.0 + eslint: 8.57.1 + eslint-plugin-es: 4.1.0(eslint@8.57.1) + eslint-utils: 3.0.0(eslint@8.57.1) + ignore: 5.3.2 + is-core-module: 2.16.1 + minimatch: 3.1.2 + resolve: 1.22.10 + semver: 7.7.2 + + eslint-plugin-promise@6.6.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-utils@2.1.0: + dependencies: + eslint-visitor-keys: 1.3.0 + + eslint-utils@3.0.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 2.1.0 + + eslint-visitor-keys@1.3.0: {} + + eslint-visitor-keys@2.1.0: {} + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.1 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.14.1 + acorn-jsx: 5.3.2(acorn@8.14.1) + eslint-visitor-keys: 3.4.3 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + event-target-shim@5.0.1: {} + + events@3.3.0: {} + + fast-copy@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-redact@3.5.0: {} + + fast-safe-stringify@2.1.1: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fs.realpath@1.0.0: {} + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + help-me@5.0.0: {} + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.0: + dependencies: + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-path-inside@3.0.3: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + joycon@3.1.1: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + js2xmlparser@4.0.2: + dependencies: + xmlcreate: 2.0.4 + + jsdoc@4.0.4: + dependencies: + '@babel/parser': 7.27.4 + '@jsdoc/salty': 0.2.9 + '@types/markdown-it': 14.1.2 + bluebird: 3.7.2 + catharsis: 0.9.0 + escape-string-regexp: 2.0.0 + js2xmlparser: 4.0.2 + klaw: 3.0.0 + markdown-it: 14.1.0 + markdown-it-anchor: 8.6.7(@types/markdown-it@14.1.2)(markdown-it@14.1.0) + marked: 4.3.0 + mkdirp: 1.0.4 + requizzle: 0.2.4 + strip-json-comments: 3.1.1 + underscore: 1.13.7 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + just-extend@6.2.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + klaw@3.0.0: + dependencies: + graceful-fs: 4.2.11 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.get@4.4.2: {} + + lodash.merge@4.6.2: {} + + lodash@4.17.21: {} + + markdown-it-anchor@8.6.7(@types/markdown-it@14.1.2)(markdown-it@14.1.0): + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.0 + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + marked@4.3.0: {} + + math-intrinsics@1.1.0: {} + + mdurl@2.0.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimist@1.2.8: {} + + mkdirp@1.0.4: {} + + ms@2.1.3: {} + + nanoid@4.0.2: {} + + natural-compare@1.4.0: {} + + nise@5.1.9: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 11.3.1 + '@sinonjs/text-encoding': 0.7.3 + just-extend: 6.2.0 + path-to-regexp: 6.3.0 + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-to-regexp@6.3.0: {} + + pino-abstract-transport@1.2.0: + dependencies: + readable-stream: 4.7.0 + split2: 4.2.0 + + pino-pretty@10.3.1: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 3.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 1.2.0 + pump: 3.0.2 + readable-stream: 4.7.0 + secure-json-parse: 2.7.0 + sonic-boom: 3.8.1 + strip-json-comments: 3.1.1 + + pino-std-serializers@6.2.2: {} + + pino@8.21.0: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 1.2.0 + pino-std-serializers: 6.2.2 + process-warning: 3.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 3.8.1 + thread-stream: 2.7.0 + + possible-typed-array-names@1.1.0: {} + + prelude-ls@1.2.1: {} + + process-warning@3.0.0: {} + + process@0.11.10: {} + + prom-client@14.2.0: + dependencies: + tdigest: 0.1.2 + + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + real-require@0.2.0: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpp@3.2.0: {} + + requizzle@0.2.4: + dependencies: + lodash: 4.17.21 + + resolve-from@4.0.0: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@2.7.0: {} + + semver@6.3.1: {} + + semver@7.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + sinon@15.2.0: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 10.3.0 + '@sinonjs/samsam': 8.0.2 + diff: 5.2.0 + nise: 5.1.9 + supports-color: 7.2.0 + + sonic-boom@3.8.1: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + + text-table@0.2.0: {} + + thread-stream@2.7.0: + dependencies: + real-require: 0.2.0 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-detect@4.1.0: {} + + type-fest@0.20.2: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + uc.micro@2.1.0: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + underscore@1.13.7: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + utap@0.2.0: + dependencies: + chalk: 5.4.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.0 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + xmlcreate@2.0.4: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..bdf5d70 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +overrides: + '@foxssake/trimsock-js': link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-js + '@foxssake/trimsock-node': link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-node + trimsock-node: link:../../../../../home/adminus/.local/share/pnpm/global/5/node_modules/@foxssake/trimsock-node 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/config.mjs b/src/config.mjs index 82238ad..a4e957a 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -1,51 +1,51 @@ 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' +import { urlAlphabet } from 'nanoid' dotenv.config() const env = process.env /** -* Natty configuration type. +* Noray configuration type. */ -export class NattyConfig { - socket = { - host: env.NATTY_SOCKET_HOST ?? '::1', - port: integer(env.NATTY_SOCKET_PORT) ?? 8808 +export class NorayConfig { + oid = { + length: integer(env.NORAY_OID_LENGTH) ?? 21, + charset: env.NORAY_OID_CHARSET ?? urlAlphabet } - session = { - timeout: duration(env.NATTY_SESSION_TIMEOUT ?? '1hr'), - cleanupInterval: duration(env.NATTY_SESSION_CLEANUP_INTERVAL ?? '10m') + pid = { + length: integer(env.NORAY_PID_LENGTH) ?? 128, + charset: env.NORAY_PID_CHARSET ?? urlAlphabet } - lobby = { - minNameLength: number(env.NATTY_LOBBY_MIN_NAME_LENGTH) ?? 3, - maxNameLength: number(env.NATTY_LOBBY_MAX_NAME_LENGTH) ?? 128 + socket = { + host: env.NORAY_SOCKET_HOST ?? '0.0.0.0', + port: integer(env.NORAY_SOCKET_PORT) ?? 8890 } - connectionDiagnostics = { - timeout: duration(env.NATTY_CONNECTION_DIAGNOSTICS_TIMEOUT ?? '8s') + http = { + host: env.NORAY_HTTP_HOST ?? '0.0.0.0', + port: integer(env.NORAY_HTTP_PORT) ?? 8891 } 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') + 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, + + 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/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/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/connection.commands.mjs b/src/connection/connection.commands.mjs new file mode 100644 index 0000000..7fbfe81 --- /dev/null +++ b/src/connection/connection.commands.mjs @@ -0,0 +1,106 @@ +/* eslint-disable */ +import { ProtocolServer } from '../protocol/protocol.server.mjs' +import { HostRepository } from '../hosts/host.repository.mjs' +import { NodeSocketReactor } from '@foxssake/trimsock-node' +/* 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 +*/ +export function handleConnect (hostRepository) { + /** + * @param {NodeSocketReactor} server + */ + return function (server) { + server.on('connect', (command, exchange) => { + const log = logger.child({ name: 'cmd:connect' }) + + const socket = exchange.source + const oid = command.requireText() + 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.rinfo) + const clientAddress = stringifyAddress(client.rinfo) + + server.send(socket, { name: 'connect', data: hostAddress }) + server.send(host.socket, { name: 'connect', data: clientAddress }) + + log.debug( + { client: clientAddress, host: hostAddress, oid }, + 'Connected client to host' + ) + }) + } +} + +/** +* @param {HostRepository} hostRepository +*/ +export function handleConnectRelay (hostRepository) { + /** + * @param {NodeSocketReactor} server + */ + return function (server) { + server.on('connect-relay', async (command, exchange) => { + const log = logger.child({ name: 'cmd:connect-relay' }) + + const socket = exchange.source + const oid = command.requireText() + const host = hostRepository.find(oid) + const client = hostRepository.findBySocket(socket) + + log.debug( + { oid, client: `${socket.remoteAddress}:${socket.remotePort}` }, + 'Client attempting to connect to host' + ) + assert(host, 'Unknown host oid: ' + oid) + assert(client, 'Unknown client from address') + + 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, { name: 'connect-relay', data: host.relay }) + server.send(host.socket, { name: 'connect-relay', data: client.relay }) + log.debug( + { client: `${socket.remoteAddress}:${socket.remotePort}`, relay: host.relay, oid }, + 'Connected client to host' + ) + }) + } +} + +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/connection/connection.mjs b/src/connection/connection.mjs new file mode 100644 index 0000000..bdff227 --- /dev/null +++ b/src/connection/connection.mjs @@ -0,0 +1,14 @@ +import { Noray } from '../noray.mjs' +import logger from '../logger.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 connection commands') + + // TODO: Add `.configure()` to trimsock reactor? + handleConnect(hostRepository)(noray.reactor) + handleConnectRelay(hostRepository)(noray.reactor) +}) 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/echo/echo.mjs b/src/echo/echo.mjs new file mode 100644 index 0000000..97902ec --- /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: 'mod: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/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/hosts/host.commands.mjs b/src/hosts/host.commands.mjs new file mode 100644 index 0000000..65ff5b2 --- /dev/null +++ b/src/hosts/host.commands.mjs @@ -0,0 +1,55 @@ +/* eslint-disable */ +import { HostRepository } from './host.repository.mjs' +import { NodeSocketReactor } from '@foxssake/trimsock-node' +/* eslint-enable */ +import { HostEntity } from './host.entity.mjs' +import logger from '../logger.mjs' +import * as prometheus from 'prom-client' +import { metricsRegistry } from '../metrics/metrics.registry.mjs' + +const activeHostsGauge = new prometheus.Gauge({ + name: 'noray_active_hosts', + help: 'Number of currently active hosts', + registers: [metricsRegistry] +}) + +/** +* @param {HostRepository} hostRepository +*/ +export function handleRegisterHost (hostRepository) { + /** + * @param {NodeSocketReactor} server + */ + return function (server) { + server.on('register-host', (__, exchange) => { + const log = logger.child({ name: 'cmd:register-host' }) + activeHostsGauge.inc() + + const socket = exchange.source + const host = new HostEntity({ socket }) + hostRepository.add(host) + + exchange.send({ name: 'set-oid', data: host.oid }) + exchange.send({ name: 'set-pid', data: host.pid }) + + log.info( + { oid: host.oid, pid: host.pid }, + 'Registered host from address %s:%d', + socket.remoteAddress, socket.remotePort + ) + + socket.on('error', err => { + log.error(err) + }) + + socket.on('close', () => { + log.info( + { oid: host.oid, pid: host.pid }, + 'Host disconnected, removing from repository' + ) + hostRepository.removeItem(host) + activeHostsGauge.dec() + }) + }) + } +} diff --git a/src/hosts/host.entity.mjs b/src/hosts/host.entity.mjs new file mode 100644 index 0000000..406b422 --- /dev/null +++ b/src/hosts/host.entity.mjs @@ -0,0 +1,56 @@ +/* eslint-disable */ +import * as net from 'node:net' +import * as dgram from 'node:dgram' +/* eslint-enable */ +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. +* +* 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 + + /** + * Relay port. + * @type {number} + */ + relay + + /** + * Host remote info. + * @type {dgram.RemoteInfo} + */ + rinfo + + /** + * Construct entity. + * @param {HostEntity} options Options + */ + constructor (options) { + options && Object.assign(this, options) + this.oid ??= generateOID() + this.pid ??= generatePID() + } +} diff --git a/src/hosts/host.mjs b/src/hosts/host.mjs new file mode 100644 index 0000000..6a81373 --- /dev/null +++ b/src/hosts/host.mjs @@ -0,0 +1,14 @@ +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') + + handleRegisterHost(hostRepository)(noray.reactor) +}) diff --git a/src/hosts/host.repository.mjs b/src/hosts/host.repository.mjs new file mode 100644 index 0000000..71d00a5 --- /dev/null +++ b/src/hosts/host.repository.mjs @@ -0,0 +1,36 @@ +/* eslint-disable */ +import * as net from 'node:net' +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') + }) + } + + /** + * Find host by private id. + * @param {string} pid Private id + * @returns {HostEntity|undefined} Host + */ + 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/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/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/metrics/metrics.mjs b/src/metrics/metrics.mjs new file mode 100644 index 0000000..213e36b --- /dev/null +++ b/src/metrics/metrics.mjs @@ -0,0 +1,39 @@ +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' +import { metricsRegistry } from './metrics.registry.mjs' + +const log = logger.child({ name: 'mod:metrics' }) + +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/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/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 deleted file mode 100644 index 191ab62..0000000 --- a/src/natty.mjs +++ /dev/null @@ -1,84 +0,0 @@ -/* 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' - -const defaultModules = [ - 'sessions/sessions.mjs', - 'games/games.mjs', - 'lobbies/lobbies.mjs', - 'connection/connections.mjs', - 'relay/relay.mjs' -] - -const hooks = [] - -export class Natty extends EventEmitter { - /** @type {net.Server} */ - #socket - - /** @type {nlon.Server} */ - #nlons - - #log = logger - - /** - * Register a Natty configuration hook. - * @param {function(Natty)} h Hook - */ - static hook (h) { - hooks.push(h) - } - - async start (modules) { - modules ??= defaultModules - - this.#log.info('Starting Natty') - - 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) { - this.#log.info('Pulling module %s for hooks', m) - await import(`../src/${m}`) - } - - // Run hooks - this.#log.info('Running %d hooks', hooks.length) - hooks.forEach(h => h(this)) - this.#log.info('Hooks done') - - socket.listen(config.socket.port, config.socket.host, () => { - this.#log.info( - 'Listening on %s:%s', - config.socket.host, config.socket.port - ) - - this.emit('listening', config.socket.port, config.socket.host) - }) - } - - shutdown () { - this.#log.info('Shutting down') - - this.emit('close') - - this.#socket.close() - } - - get nlons () { - return this.#nlons - } -} diff --git a/src/noray.mjs b/src/noray.mjs new file mode 100644 index 0000000..ff294ae --- /dev/null +++ b/src/noray.mjs @@ -0,0 +1,86 @@ +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' +import { NodeSocketReactor } from '@foxssake/trimsock-node' + +const defaultModules = [ + 'metrics/metrics.mjs', + 'relay/relay.mjs', + 'echo/echo.mjs', + 'hosts/host.mjs', + 'connection/connection.mjs' +] + +const hooks = [] + +export class Noray extends EventEmitter { + /** @type {net.Server} */ + #server + + /** @type {ProtocolServer} */ + #protocolServer + + /** @type {NodeSocketReactor} */ + #reactor + + #log = logger + + /** + * Register a Noray configuration hook. + * @param {function(Noray)} h Hook + */ + static hook (h) { + hooks.push(h) + } + + async start (modules) { + modules ??= defaultModules + + this.#log.info('Starting Noray') + + this.#protocolServer = new ProtocolServer() + this.#reactor = new NodeSocketReactor() + .onError((command, exchange, error) => { + exchange.failOrSend({ name: command.name, data: '' + error }) + }) + + // Import modules for hooks + for (const m of modules) { + this.#log.info('Pulling module %s for hooks', m) + await import(`../src/${m}`) + } + + // Run hooks + this.#log.info('Running %d hooks', hooks.length) + hooks.forEach(h => h(this)) + this.#log.info('Hooks done') + + // Start server + this.#log.info('Starting TCP server') + this.#server = this.#reactor.serve().listen(config.socket.port, config.socket.host, () => { + this.#log.info( + 'Listening on %s:%s', + config.socket.host, config.socket.port + ) + + this.emit('listening', config.socket.port, config.socket.host) + }) + } + + shutdown () { + this.#log.info('Shutting down') + + this.emit('close') + this.#server.close() + } + + get protocolServer () { + return this.#protocolServer + } + + get reactor () { + return this.#reactor + } +} 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/protocol/protocol.server.mjs b/src/protocol/protocol.server.mjs new file mode 100644 index 0000000..8ee6ac4 --- /dev/null +++ b/src/protocol/protocol.server.mjs @@ -0,0 +1,140 @@ +/* eslint-disable */ +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' +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. +* +* 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)) + rl.on('error', err => { + this.detach(socket) + log.error('Socket connection abruptly lost!') + log.error(err) + }) + this.#readers.set(socket, rl) + + activeConnectionGauge.inc() + } + + /** + * Detach socket from server. + * @param {net.Socket} socket + */ + detach (socket) { + this.#readers.get(socket)?.close() + this.#readers.delete(socket) + + activeConnectionGauge.dec() + } + + /** + * Configure server using callback. + * @param {function(ProtocolServer)} configurer Callback + * @returns {ProtocolServer} Server + */ + configure (configurer) { + configurer(this) + return this + } + + /** + * Send a command through socket. + * @param {net.Socket} socket Socket + * @param {string} command Command + * @param {any} [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?.toString()?.includes('\n'), 'Data can\'t contain newlines!') + + socket.write(data + ? `${command} ${data.toString()}\n` + : `${command}\n`, + err => { + if (err) { + log.error('Failed sending command "%s"(%s)', command, data) + log.error(err) + } + } + ) + } + + /** + * @param {net.Socket} socket + * @param {string} 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 { + 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/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/dynamic.relaying.mjs b/src/relay/dynamic.relaying.mjs new file mode 100644 index 0000000..abf9d49 --- /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 = relayHandler.socketPool.getPort() + 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.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/relay.mjs b/src/relay/relay.mjs index ea28c56..f594ee7 100644 --- a/src/relay/relay.mjs +++ b/src/relay/relay.mjs @@ -1,24 +1,26 @@ 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 { 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' +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() -export const udpRelayHandler = new UDPRelayHandler() -constrainRelayTableSize(udpRelayHandler, config.udpRelay.maxSlots) +export const udpRelayHandler = new UDPRelayHandler({ socketPool: udpSocketPool }) export const udpRemoteRegistrar = new UDPRemoteRegistrar({ - sessionRepository, + hostRepository, udpRelayHandler }) +const log = logger.child({ name: 'mod:relay' }) -const log = logger.child({ name: 'Relays' }) - -Natty.hook(natty => { +Noray.hook(async noray => { log.info( 'Starting periodic UDP relay cleanup job, running every %s', formatDuration(config.udpRelay.cleanupInterval) @@ -29,7 +31,18 @@ Natty.hook(natty => { ) 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('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', @@ -53,12 +66,21 @@ Natty.hook(natty => { constrainLifetime(udpRelayHandler, config.udpRelay.maxLifetimeDuration) constrainTraffic(udpRelayHandler, config.udpRelay.maxLifetimeTraffic) + log.info('Applying dynamic relaying') + useDynamicRelay(udpRelayHandler) + 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() + + log.info('Closing socket pool') + udpSocketPool.clear() + + log.info('Closing relay handler') + udpRelayHandler.clear() }) }) 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 ce8e19a..e3533bf 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. * @@ -23,7 +49,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} */ @@ -45,9 +71,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 +83,32 @@ 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 + activeRelayGauge.inc() + + 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,16 +130,21 @@ 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) + + activeRelayGauge.dec() + 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)) + + activeRelayGauge.reset() } /** @@ -114,8 +154,11 @@ 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 measure = relayDurationHistogram.startTimer() + const senderRelay = this.#relayTable.find(r => r.address.port === sender.port && r.address.address === sender.address ) @@ -123,6 +166,11 @@ 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 } @@ -132,7 +180,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) @@ -140,6 +188,9 @@ export class UDPRelayHandler extends EventEmitter { senderRelay.lastReceived = time() targetRelay.lastSent = time() + relaySizeHistorgram.observe(msg?.byteLength ?? 0) + measure() + return true } @@ -158,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) - } } /** @@ -196,3 +239,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/src/relay/udp.remote.registrar.mjs b/src/relay/udp.remote.registrar.mjs index b210f90..d606500 100644 --- a/src/relay/udp.remote.registrar.mjs +++ b/src/relay/udp.remote.registrar.mjs @@ -1,21 +1,38 @@ /* eslint-disable */ -import { SessionRepository } from '../sessions/session.repository.mjs' -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' +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. * * @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 +* 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 @@ -26,22 +43,17 @@ export class UDPRemoteRegistrar { /** @type {dgram.Socket} */ #socket - /** @type {SessionRepository} */ - #sessionRepository - - /** @type {UDPRelayHandler} */ - #udpRelayHandler + /** @type {HostRepository} */ + #hostRepository /** * Construct instance. * @param {object} options Options - * @param {SessionRepository} options.sessionRepository Session repository - * @param {UDPRelayHandler} options.udpRelayHandler UDP relay handler + * @param {HostRepository} options.hostRepository Host repository * @param {dgram.Socket} [options.socket] Socket */ constructor (options) { - this.#sessionRepository = requireParam(options.sessionRepository) - this.#udpRelayHandler = requireParam(options.udpRelayHandler) + this.#hostRepository = requireParam(options.hostRepository) this.#socket = options.socket ?? dgram.createSocket('udp4') } @@ -79,18 +91,24 @@ export class UDPRemoteRegistrar { */ async #handle (msg, rinfo) { try { - const sessionId = msg.toString('utf8') - log.debug({ sessionId, rinfo }, 'Received UDP relay request') + const pid = msg.toString('utf8') + log.debug({ pid, rinfo }, 'Received UDP relay request') - const session = this.#sessionRepository.find(sessionId) - assert(session, 'Unknown session id!') + const host = this.#hostRepository.findByPid(pid) + assert(host, 'Unknown host pid!') - await this.#udpRelayHandler.createRelay(new RelayEntry({ - address: NetAddress.fromRinfo(rinfo) - })) + 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) } } 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/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/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/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/connect.relay.test.mjs b/test/e2e/connect.relay.test.mjs new file mode 100644 index 0000000..d890ebb --- /dev/null +++ b/test/e2e/connect.relay.test.mjs @@ -0,0 +1,127 @@ +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, + + oid: '', + pid: '' + } + + 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('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 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!') + + ;[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 + 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)) + .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()) + console.log('Host got message', JSON.stringify({ msg: msg.toString(), rinfo })) + host.udp.send(response, rinfo.port, rinfo.address) + }) + + client.udp.on('message', (msg, _rinfo) => { + 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) + 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 be45d44..0cb5dda 100644 --- a/test/e2e/connection.test.mjs +++ b/test/e2e/connection.test.mjs @@ -1,40 +1,52 @@ -import { describe, it, after, before } from 'node:test' +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert' 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 }, () => { +describe('Connection', () => { 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')) + describe('connect', () => { + it('should respond with external address', async () => { + const host = await context.connect() + const client = await context.connect() + + // Grab data from responses + context.log.info('Registering parties') + const [oid, pid] = await context.registerHost(host) + const [_, clientPid] = await context.registerHost(client) + + 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(undefined, pid), + context.registerExternal(undefined, clientPid) + ]) + + // 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()) + after(() => { + context.shutdown() + }) }) diff --git a/test/e2e/context.mjs b/test/e2e/context.mjs index 5f95b95..e471569 100644 --- a/test/e2e/context.mjs +++ b/test/e2e/context.mjs @@ -1,56 +1,119 @@ -import { createSocketPeer } from '@elementbound/nlon-socket' +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' 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' + +const READ_WAIT = 0.05 export class End2EndContext { - /** @type {NattyClient[]} */ - clients = [] + #clients = [] - /** @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') } + 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 + } + + async read (socket) { + await sleep(READ_WAIT) + + const lines = [] + for (let line = ''; line != null; line = socket.read()) { + lines.push(line) + } + + return lines.join('').split('\n') + } + /** - * Create a client and connect it to Natty. - * @returns {NattyClient} + * @param {dgram.Socket} udp + * @param {string} pid */ - connect () { - this.log.info('Creating client') - const peer = createSocketPeer({ - host: 'localhost', - port: config.socket.port + async registerExternal (udp, pid) { + let done = false + let error + 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 }) - const client = new NattyClient(peer) - this.clients.push(client) + 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 (!done) { + throw new Error('Registrar timed out!') + } else if (error) { + throw new Error(error) + } - return client + const result = udp.address().port + if (throwaway) { + udp.close() + } + + return result } - shutdown () { - this.log.info('Disconnecting peer streams') - this.clients.forEach(client => client.peer.stream.destroy()) + async registerHost (socket) { + socket.write('register-host\n') - this.log.info('Shutting down peers') - this.clients.forEach(client => client.peer.disconnect()) + 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()) - this.log.info('Terminating Natty') - this.natty.shutdown() + this.log.info('Terminating Noray') + this.noray.shutdown() } } diff --git a/test/e2e/host.test.mjs b/test/e2e/host.test.mjs new file mode 100644 index 0000000..ca8ab13 --- /dev/null +++ b/test/e2e/host.test.mjs @@ -0,0 +1,30 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert' +import { End2EndContext } from './context.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') + + // Read response + const response = await context.read(client) + + // Check if we got both id's + assert(response.find(cmd => cmd.startsWith('set-oid')), 'Missing open id!') + assert(response.find(cmd => cmd.startsWith('set-pid')), 'Missing private id!') + }) + }) + + after(() => { + context.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/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/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/protocol/protocol.server.test.mjs b/test/spec/protocol/protocol.server.test.mjs new file mode 100644 index 0000000..de39145 --- /dev/null +++ b/test/spec/protocol/protocol.server.test.mjs @@ -0,0 +1,154 @@ +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', () => { + 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() + }) + }) + + 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') + ) + }) + }) + + describe ('configure', () => { + it('should call callback', () => { + // Given + const server = new ProtocolServer() + const callback = sinon.spy() + + // When + server.configure(callback) + + // Then + assert(callback.calledWith(server)) + }) + }) +}) 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/dynamic.relaying.test.mjs b/test/spec/relay/dynamic.relaying.test.mjs new file mode 100644 index 0000000..9b049af --- /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.getPort.returns(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() + }) +}) 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 0094699..7a01ee8 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,19 +187,23 @@ 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() const relayHandler = new UDPRelayHandler({ socketPool }) await relayHandler.createRelay(new RelayEntry({ - port: 10001, address: new NetAddress({ address: '88.57.0.17', port: 32279 }) })) + relayHandler.on('drop', dropHandler) socketPool.getSocket.resetHistory() // When @@ -209,20 +216,23 @@ 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 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 @@ -230,7 +240,6 @@ describe('UDPRelayHandler', () => { })) await relayHandler.createRelay(new RelayEntry({ - port: 10002, address: new NetAddress({ address: '88.59.62.107', port: 65227 @@ -251,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)) + }) + }) }) diff --git a/test/spec/relay/udp.remote.registrar.test.mjs b/test/spec/relay/udp.remote.registrar.test.mjs index 51dadc5..10963ad 100644 --- a/test/spec/relay/udp.remote.registrar.test.mjs +++ b/test/spec/relay/udp.remote.registrar.test.mjs @@ -1,45 +1,51 @@ -import { describe, it, beforeEach } from 'node:test' +import { describe, it, beforeEach, afterEach } 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' +import { HostRepository } from '../../../src/hosts/host.repository.mjs' +import { HostEntity } from '../../../src/hosts/host.entity.mjs' describe('UDPRemoteRegistrar', () => { - /** @type {sinon.SinonStubbedInstance} */ - let sessionRepository - /** @type {sinon.SinonStubbedInstance} */ - let relayHandler + /** @type {sinon.SinonFakeTimers} */ + let clock + + /** @type {sinon.SinonStubbedInstance} */ + let hostRepository /** @type {sinon.SinonStubbedInstance} */ let socket /** @type {UDPRemoteRegistrar} */ let remoteRegistrar - const session = new SessionData({ - id: 's0001' - }) + /** @type {HostEntity} */ + let host beforeEach(() => { - sessionRepository = sinon.createStubInstance(SessionRepository) - relayHandler = sinon.createStubInstance(UDPRelayHandler) + host = new HostEntity({ + oid: 'h0001', + pid: 'p0001' + }) + clock = sinon.useFakeTimers() + + hostRepository = sinon.createStubInstance(HostRepository) socket = sinon.createStubInstance(dgram.Socket) - sessionRepository.find.returns(session) + 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({ - sessionRepository, udpRelayHandler: relayHandler, socket + hostRepository, socket }) }) it('should succeed', async () => { // Given - const msg = Buffer.from(session.id) + const msg = Buffer.from(host.pid) const rinfo = { address: '88.57.0.3', port: 32745 } await remoteRegistrar.listen() @@ -49,45 +55,42 @@ describe('UDPRemoteRegistrar', () => { 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] ) + assert.equal(host.rinfo, rinfo) }) - it('should fail on unknown session', async () => { + + it('should fail on unknown pid', async () => { // Given - const msg = Buffer.from(session.id) + const msg = Buffer.from(host.pid) const rinfo = { address: '88.57.0.3', port: 32745 } await remoteRegistrar.listen() const messageHandler = socket.on.lastCall.callback - sessionRepository.find.returns(undefined) + 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 session id!', rinfo.port, rinfo.address] + ['Unknown host pid!', rinfo.port, rinfo.address] ) }) - it('should fail throw', async () => { + it('should fail on throw', async () => { // Given - const msg = Buffer.from(session.id) + 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') + socket.send.onFirstCall().throws(new Error('Test')) // When await messageHandler(msg, rinfo) @@ -98,4 +101,8 @@ describe('UDPRemoteRegistrar', () => { ['Test', rinfo.port, rinfo.address] ) }) + + afterEach(() => { + clock.restore() + }) }) 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)) + }) + }) }) 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/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 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 - ) - }) -})