diff --git a/README.md b/README.md index 40c662c6d..4eabf03a7 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,11 @@ only passes the connection setup between them and never sees the game; they then each browser first asking outpost, NESER's own STUN server (`stun:outpost.rmstdope.eu:3478`), for its public address, so players in different homes can play together and each can see the other's internet address. Networks that pick a new public port for every destination (common on mobile, hotspot and -work networks) cannot be connected this way; after about 10 seconds both players are told so. With -outpost down, only computers on the same network (or VPN) connect. liaison accepts pages served from +work networks) cannot be connected directly; for those, liaison hands both browsers the room's TURN +relay on outpost, in Sweden, and the game goes through it, which the window and the top-bar chip +("Relayed") say. Players who can connect directly still do. When even the relay cannot connect them, +or liaison had no relay to give, both players are told so after about 10 seconds. With outpost down, +only computers on the same network (or VPN) connect. liaison accepts pages served from https://neser.rmstdope.eu and from `localhost`/`127.0.0.1` only, so a self-hosted copy reached by another address (a LAN IP, say) cannot start a network game. The two must run the same NESER build, and a connection slower than 120 ms round trip is refused. Once connected the host presses **Start game**: diff --git a/architecture.md b/architecture.md index 72c312e6a..62386f0d4 100644 --- a/architecture.md +++ b/architecture.md @@ -567,7 +567,7 @@ The web frontend is bundled with **Vite** (config at `vite.config.ts`, root: `we | `web/src/save-state/` | Save state persistence using IndexedDB (`save_state_storage.ts`, `save_state_controller.ts`, `save_state_context.ts`), offered where the console table says so: NES, Game Boy and SNES games (`save_state_support.ts`), and the Load button's preview of the saved state, a picture of its frame and when it was saved, kept in the IndexedDB database `neser-save-previews` (`save_preview.ts`). | | `web/src/debugger/` | Browser-based debugger panels — disassembly, OAM viewer, watch expressions, PPU viewer layout/scroll — docked beside the picture, and whether the top bar shows its button (`debugger_dock.ts`, kept in `localStorage` under `neser.debuggerButton` and set from the settings panel's Developer tab; F5 opens it either way). | | `web/src/shortcuts/` | Keyboard shortcut actions, and which key targets they leave alone (`keyboard_target.ts`: editable fields and open dialogs). | -| `web/src/netplay/` | Connecting two browsers for a network game (nr-ayk5.3) and sending the host's game (nr-ayk5.4): `game_package.ts` (the ROM, any SNES firmware and the host's save state as one `NSRG` byte string, read back as untrusted: anything that does not describe itself exactly is refused, never thrown over; `crc32`, since `crypto.subtle` is missing on a plain-http page), `build_id.ts` (the NESER version and short commit Vite stamps in as `__NESER_VERSION__`/`__NESER_COMMIT__`; two browsers connect only on the same build), `room_code.ts` (the six-character room codes from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, shown as `ABC-DEF`, and tidying what is typed into one), `liaison.ts` (nr-z4nw: the only `WebSocket` user, a client for the liaison meeting service at `wss://liaison.rmstdope.eu/ws`, github.com/rmstdope/liaison: `create`, `join`, `signal`, and its answers read back as untrusted; liaison's TURN relay is not used), `response_time.ts` (Great under 50 ms, Playable to 120 ms, Too slow above), `peer_link.ts` (the only `RTCPeerConnection` user: one data channel from a non-trickle offer and answer; each browser asks outpost, NESER's own STUN server `stun:outpost.rmstdope.eu:3478`, for its public address (nr-ri8n), gathering for at most 3 s, and no TURN relay is used, so the two must reach each other directly) and `session.ts` (the host's and joiner's steps as a DOM-free state machine over injectable signaling and links: the host opens a liaison room and shows its code, the joiner joins by it, and the offer and answer cross as `signal {offer}` / `signal {answer}`, after which each side leaves liaison; a room that ran out, or whose joiner left before the swap, is replaced by itself; the joiner keeps one liaison connection across its tries so liaison's wrong-code limit holds; a 10 s connect limit from the joiner being in the room (nr-ri8n) and five-ping check that sends `ready` or `refuse`, live round trips once a second, `bye`; then sending the game: `game` announces it, 16 KiB binary chunks led by the transfer id follow, the joiner acknowledges each with `got` so at most 256 KiB is in flight, checks what came through an injected `checkGame` and answers `arrived` or `broken`; `stop` is either side's Cancel, ten silent seconds are a broken send, `arrived` carrying how long the joiner's test load took; then the host confirms it still runs the game (`confirmStart`) and says `start`, on which the joiner closes its own game and sets the received one up, while the host waits half the round trip measured before the send plus that time, so both counts begin together). `peer_link.ts` carries bytes as well as text. `app.ts` test-loads a received game on a scratch core before the joiner's own game is touched, and never keeps one (no library entry, picture or save state; it goes when the joiner leaves the ended game for the library). Playing together (nr-ayk5.5): `start` carries the input delay the host chose from its response time (`inputDelayFor`, 2-6 frames); lockstep packets ride the same link as bytes led by transfer id 0, beside the play messages `resume` and `reset`; `end` says the other player ended it, where `bye` says they left. `play.ts` is the DOM-free play controller each side owns over `WasmLockstep`, an injected core, link, clock and count: every local controller's buttons drive this side's player; a frame runs only once both pads are known, on ports 1 and 2; pause (with which window paused it) and reset travel in the pad's bits 12-15 so both games act after the same frame, and once both are stopped there, resuming and resetting are messages; it reports waiting (held half a second) and catching up (behind half a second, running two frames a tick). Resyncing (nr-ayk5.6): after every 60th frame each side hands the rules a CRC of its whole save state; a side whose rules find the two differ puts a resync command in its next pad, so both stop after the same frame; the host then sends its save state (`state`, then 16 KiB pieces led by transfer id `0xFFFFFFFF`, unacknowledged), the joiner loads it and says `resynced`, and both count "Resynced"; a failed load, a state that does not add up, or 15 s without getting back in step ends playing together on both sides with `unsynced`. Keeping full speed (nr-ayk5.7): `speed.ts` measures, per animation frame, how many frames a second this computer manages (what a frame costs is the wait until the next animation frame), and wants 95 % of it after three seconds below 90 % of the speed played at (a second whose frames took under 250 ms was mostly held and counts for neither), or full speed again after three at 97 %; `play.ts` sends what it wants as `speed`, both games run at the slower of the two sides' (the page's frame limiter follows `PlayView.speed`, and the sound is then never pitched down), and the first slowdown of a play together sets `slowNote` once, while playing, for the note worded for each side; a `speed` at or above full speed, or not a whole number of at least 1, asks nothing. Small clock differences need nothing more: lockstep evens them out one frame at a time, which the sound's lead absorbs. | +| `web/src/netplay/` | Connecting two browsers for a network game (nr-ayk5.3) and sending the host's game (nr-ayk5.4): `game_package.ts` (the ROM, any SNES firmware and the host's save state as one `NSRG` byte string, read back as untrusted: anything that does not describe itself exactly is refused, never thrown over; `crc32`, since `crypto.subtle` is missing on a plain-http page), `build_id.ts` (the NESER version and short commit Vite stamps in as `__NESER_VERSION__`/`__NESER_COMMIT__`; two browsers connect only on the same build), `room_code.ts` (the six-character room codes from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, shown as `ABC-DEF`, and tidying what is typed into one), `liaison.ts` (nr-z4nw: the only `WebSocket` user, a client for the liaison meeting service at `wss://liaison.rmstdope.eu/ws`, github.com/rmstdope/liaison: `create`, `join`, `signal`, and its answers read back as untrusted, including the TURN relay (`iceServers`) liaison sends with `created` and `joined` (nr-1wbx), kept only when it is exactly TURN servers with credentials), `response_time.ts` (Great under 50 ms, Playable to 120 ms, Too slow above), `peer_link.ts` (the only `RTCPeerConnection` user: one data channel from a non-trickle offer and answer; each browser asks outpost, NESER's own STUN server `stun:outpost.rmstdope.eu:3478`, for its public address (nr-ri8n), gathering for at most 3 s, and is also given the room's TURN relay when liaison sent one (nr-1wbx); WebRTC prefers a direct path, and once the channel opens `isRelayed` reads the selected candidate pair from `getStats` so the link says whether it went through the relay, the same on both sides) and `session.ts` (the host's and joiner's steps as a DOM-free state machine over injectable signaling and links: the host opens a liaison room and shows its code, the joiner joins by it, and the offer and answer cross as `signal {offer}` / `signal {answer}`, after which each side leaves liaison; a room that ran out, or whose joiner left before the swap, is replaced by itself; the joiner keeps one liaison connection across its tries so liaison's wrong-code limit holds; a 10 s connect limit from the joiner being in the room (nr-ri8n) and five-ping check that sends `ready` or `refuse`, live round trips once a second, `bye`; then sending the game: `game` announces it, 16 KiB binary chunks led by the transfer id follow, the joiner acknowledges each with `got` so at most 256 KiB is in flight, checks what came through an injected `checkGame` and answers `arrived` or `broken`; `stop` is either side's Cancel, ten silent seconds are a broken send, `arrived` carrying how long the joiner's test load took; then the host confirms it still runs the game (`confirmStart`) and says `start`, on which the joiner closes its own game and sets the received one up, while the host waits half the round trip measured before the send plus that time, so both counts begin together). `peer_link.ts` carries bytes as well as text. `app.ts` test-loads a received game on a scratch core before the joiner's own game is touched, and never keeps one (no library entry, picture or save state; it goes when the joiner leaves the ended game for the library). Playing together (nr-ayk5.5): `start` carries the input delay the host chose from its response time (`inputDelayFor`, 2-6 frames); lockstep packets ride the same link as bytes led by transfer id 0, beside the play messages `resume` and `reset`; `end` says the other player ended it, where `bye` says they left. `play.ts` is the DOM-free play controller each side owns over `WasmLockstep`, an injected core, link, clock and count: every local controller's buttons drive this side's player; a frame runs only once both pads are known, on ports 1 and 2; pause (with which window paused it) and reset travel in the pad's bits 12-15 so both games act after the same frame, and once both are stopped there, resuming and resetting are messages; it reports waiting (held half a second) and catching up (behind half a second, running two frames a tick). Resyncing (nr-ayk5.6): after every 60th frame each side hands the rules a CRC of its whole save state; a side whose rules find the two differ puts a resync command in its next pad, so both stop after the same frame; the host then sends its save state (`state`, then 16 KiB pieces led by transfer id `0xFFFFFFFF`, unacknowledged), the joiner loads it and says `resynced`, and both count "Resynced"; a failed load, a state that does not add up, or 15 s without getting back in step ends playing together on both sides with `unsynced`. Keeping full speed (nr-ayk5.7): `speed.ts` measures, per animation frame, how many frames a second this computer manages (what a frame costs is the wait until the next animation frame), and wants 95 % of it after three seconds below 90 % of the speed played at (a second whose frames took under 250 ms was mostly held and counts for neither), or full speed again after three at 97 %; `play.ts` sends what it wants as `speed`, both games run at the slower of the two sides' (the page's frame limiter follows `PlayView.speed`, and the sound is then never pitched down), and the first slowdown of a play together sets `slowNote` once, while playing, for the note worded for each side; a `speed` at or above full speed, or not a whole number of at least 1, asks nothing. Small clock differences need nothing more: lockstep evens them out one frame at a time, which the sound's lead absorbs. | | `web/src/ui/` | Toast overlays (`toast_overlay.ts`), gamepad init toast, the control bar's volume (`volume.ts`, key `neser.volume`) and game title (`game_title.ts`) helpers, the settings panel (`settings_panel.ts`), the short lists Filter, Palette, Colours and Reset open (`choice_menu.ts`), the Play together window and its top-bar status chip (`play_together.ts`, drawing `netplay/session.ts`'s view one step at a time, Start game and the send's progress included), the 3-2-1 counts before and while playing together (`countdown.ts`), the note once both games run slower because one computer can't keep full speed (`slow_note.ts`, nr-ayk5.7: five seconds, once per play together, gone as soon as the game stops), the paused, waiting, catching-up, resyncing and ended screens over the shared game and the "End playing together?" question (`play_screen.ts`), the Keys and controls panel H and the top bar's ? open (`help_panel.ts`: the shortcut rows and a controller drawn for the running console's keys), where an error shows and its library title (`errors.ts`: a failed pick in the library, a stopped game over the picture), and the library (`library.ts`: grouping, search, system filter, and the release-news wave). | | `web/integration/` | Playwright-based end-to-end integration tests for the web frontend. | diff --git a/docs/retrospectives/nr-1wbx.md b/docs/retrospectives/nr-1wbx.md new file mode 100644 index 000000000..6a8646e9e --- /dev/null +++ b/docs/retrospectives/nr-1wbx.md @@ -0,0 +1,21 @@ +# nr-1wbx — retrospective + +- **Implementer:** Cyclops +- **Date:** 2026-10-30 +- **PR:** #3401 + +## A Playwright test offering a dead TURN relay passed locally and timed out tearing down on CI + +**What happened.** The new play-together spec "Given liaison offers a relay, … then they connect directly and nothing says Relayed" had liaison offer `turn:127.0.0.1:9?transport=udp`, a relay nobody answers on. Locally it passed in 18 s. In CI (run 41091902968, job `web-integration (2/2)`) the test body passed, then failed twice with `Tearing down "context" exceeded the test timeout of 45000ms.` +**Why.** Chrome keeps retrying a TURN allocation over UDP to a port that never answers, and closing the browser context waits for that. Over `transport=tcp` the closed port refuses the connection at once. Locally the test then took 9 s instead of 18 s, and CI went green on the next head. Why the UDP wait outlasted 45 s only on the runner was not established: the runner may not get ICMP "port unreachable" back as quickly as macOS does. +**Cost.** One CI cycle (about 20 minutes) and one of the bead's three fix attempts. +**Prevent by.** A test that hands the browser an ICE server that must fail uses `transport=tcp` to a closed port, never UDP. The comment above `liaison.relay` in `web/integration/specs/play-together.integration.spec.ts` says why. That rule would fit as a line under the Playwright notes in `web/integration/` or in `architecture.md`'s testing strategy. +**Seen before.** none found. + +## An unrelated app-shell test failed on both CI attempts and passed 5 of 5 locally + +**What happened.** On head 95e79b8, `web-integration (1/2)` failed with `app-shell.integration.spec.ts:304` "Given a DSP game whose firmware dialog is cancelled, …". The `Not started: … needs the DSP-1 firmware` toast was not visible within 5 s, on the first attempt and on the retry (16–23 s each). Locally, `--repeat-each 5 --retries 0` passed every time in about 4 s. The same test failed the same way on main at 402e55af (run 41080130065). It passed on main's later runs, and on this PR's next head. +**Why.** Not established. The toast is checked with the default 5 s `expect` timeout after the library reappears, and each CI run took 4–5 times longer than local. The toast may come and go before the check sees it, or come later than 5 s, on a slow runner. +**Cost.** About 10 minutes of reading logs and reproducing locally. The CI run that followed was needed anyway for another fix. +**Prevent by.** A bead to make this test robust on slow runners. It could check that the toast was shown at all (for example via a toast log the page keeps), rather than that it is visible within 5 s. Start by reading the trace in that run's Playwright artifact. +**Seen before.** none found in `docs/retrospectives/`. It is on main's CI history at 402e55af. diff --git a/web/integration/specs/play-together.integration.spec.ts b/web/integration/specs/play-together.integration.spec.ts index 6de7deb96..28251f867 100644 --- a/web/integration/specs/play-together.integration.spec.ts +++ b/web/integration/specs/play-together.integration.spec.ts @@ -27,6 +27,7 @@ const CONNECT_TIMEOUT_MS = 20_000; * Liaison, the meeting service (nr-z4nw), played by the test so CI never reaches the real one: it * opens rooms named K7Q-M4X, T9W-2HB, …, puts a joiner in by its code, passes signals between the * two, and tells the one left when the other's connection closes, as liaison's README describes. + * Given `relay`, it sends it with `created` and `joined` as the room's TURN relay (nr-1wbx). */ type Room = { code: string; host: WebSocketRoute; joiner: WebSocketRoute | null }; const ROOM_CODES = ["K7Q-M4X", "T9W-2HB", "P3R-8ZD", "H6N-5LQ"]; @@ -34,11 +35,16 @@ const ROOM_CODES = ["K7Q-M4X", "T9W-2HB", "P3R-8ZD", "H6N-5LQ"]; class FakeLiaison { private rooms = new Map(); private opened = 0; + relay: object[] | null = null; async serve(page: Page) { await page.routeWebSocket(/liaison\.rmstdope\.eu/, (ws) => this.accept(ws)); } + private relayField() { + return this.relay ? { iceServers: this.relay } : {}; + } + private accept(ws: WebSocketRoute) { let room: Room | null = null; const tell = (to: WebSocketRoute, message: object) => to.send(JSON.stringify(message)); @@ -47,14 +53,14 @@ class FakeLiaison { if (message.type === "create") { room = { code: ROOM_CODES[this.opened++ % ROOM_CODES.length], host: ws, joiner: null }; this.rooms.set(room.code, room); - tell(ws, { type: "created", room: room.code }); + tell(ws, { type: "created", room: room.code, ...this.relayField() }); } else if (message.type === "join") { const wanted = this.rooms.get(message.room); if (!wanted) return tell(ws, { type: "error", reason: "room-not-found" }); if (wanted.joiner) return tell(ws, { type: "error", reason: "room-full" }); wanted.joiner = ws; room = wanted; - tell(ws, { type: "joined" }); + tell(ws, { type: "joined", ...this.relayField() }); tell(wanted.host, { type: "peer-joined" }); } else if (message.type === "signal" && room) { const other = ws === room.host ? room.joiner : room.host; @@ -465,6 +471,29 @@ test.describe("Play together", () => { await waitForRunningState(host); }); + test("Given liaison offers a relay, when two players who can reach each other directly connect, then they connect directly and nothing says Relayed", async ({ + page: host, + browser + }) => { + // A relay nobody answers on: the two must not need it. Over TCP to a closed port it is refused at + // once; over UDP Chrome keeps retrying the allocation, and closing the page then outlasts the + // test's time on CI. + liaison.relay = [{ urls: ["turn:127.0.0.1:9?transport=tcp"], username: "u", credential: "c" }]; + await openApp(host); + await loadRomFromFileInput(host); + await waitForRunningState(host); + const joiner = await secondBrowser(browser); + await openApp(joiner); + + await connect(host, joiner); + for (const page of [host, joiner]) { + await expect(panel(page).locator(".netplay-pill")).toHaveText(/^Response time \d+ ms · /); + await expect(panel(page).locator(".netplay-relay")).toHaveCount(0); + await page.locator("#netplay-close").click(); + await expect(page.locator("#netplay-open")).toHaveText(/^Connected · \d+ ms$/); + } + }); + test("Given a Game Boy Advance game, when Play together opens, then hosting is greyed out with its reason", async ({ page }) => { await openApp(page); await loadGbaRomFromFileInput(page); diff --git a/web/main.css b/web/main.css index 829002031..098cdd855 100644 --- a/web/main.css +++ b/web/main.css @@ -2543,6 +2543,13 @@ body.handheld:not(.handheld-portrait).touch-running .screen-wrap canvas { color: var(--steel-hi); } +/* A relayed game (nr-1wbx): one blue line beneath the response time; plain text that wraps. */ +.netplay-body p.netplay-relay { + margin: 6px 0 0; + font-size: 13px; + color: var(--steel-hi); +} + .netplay-pill { display: inline-flex; margin: 6px 0; diff --git a/web/src/netplay/liaison.test.ts b/web/src/netplay/liaison.test.ts index d96670a35..500f0fbfc 100644 --- a/web/src/netplay/liaison.test.ts +++ b/web/src/netplay/liaison.test.ts @@ -66,11 +66,43 @@ describe("reading what liaison says", () => { }); }); - it("ignores the relay liaison may offer, which this build does not use", () => { - expect(parseServerMessage('{"type":"created","room":"K7Q-M4X","iceServers":[{"urls":["turn:x"]}]}')).toEqual({ + it("reads the relay liaison offers with created and joined", () => { + const relay = { urls: ["turn:outpost.rmstdope.eu:3478?transport=udp", "turn:outpost.rmstdope.eu:3478?transport=tcp"], username: "u", credential: "c" }; + const servers = JSON.stringify([relay]); + expect(parseServerMessage(`{"type":"created","room":"K7Q-M4X","iceServers":${servers}}`)).toEqual({ type: "created", room: "K7Q-M4X", + iceServers: [relay], }); + expect(parseServerMessage(`{"type":"joined","iceServers":${servers}}`)).toEqual({ type: "joined", iceServers: [relay] }); + expect(parseServerMessage('{"type":"joined","iceServers":[{"urls":"turns:r.example:443","username":"u","credential":"c"}]}')).toEqual({ + type: "joined", + iceServers: [{ urls: "turns:r.example:443", username: "u", credential: "c" }], + }); + }); + + it("drops a relay that is not exactly TURN servers, and still reads the message", () => { + for (const servers of [ + "7", + "{}", + "[]", + '["turn:x"]', + '[{"urls":["turn:x"]}]', + '[{"urls":["turn:x"],"username":"u"}]', + '[{"urls":["turn:x"],"username":"u","credential":5}]', + '[{"urls":["stun:x"],"username":"u","credential":"c"}]', + '[{"urls":["https://x"],"username":"u","credential":"c"}]', + '[{"urls":[],"username":"u","credential":"c"}]', + '[{"urls":[7],"username":"u","credential":"c"}]', + JSON.stringify(Array.from({ length: 5 }, () => ({ urls: "turn:x", username: "u", credential: "c" }))), + JSON.stringify([{ urls: Array.from({ length: 9 }, () => "turn:x"), username: "u", credential: "c" }]), + ]) { + expect(parseServerMessage(`{"type":"created","room":"K7Q-M4X","iceServers":${servers}}`), servers).toEqual({ + type: "created", + room: "K7Q-M4X", + }); + expect(parseServerMessage(`{"type":"joined","iceServers":${servers}}`), servers).toEqual({ type: "joined" }); + } }); it("takes nothing that is not exactly a message", () => { diff --git a/web/src/netplay/liaison.ts b/web/src/netplay/liaison.ts index 3d837631f..29df9219a 100644 --- a/web/src/netplay/liaison.ts +++ b/web/src/netplay/liaison.ts @@ -3,8 +3,9 @@ import type { BuildId } from "./build_id"; /** * The meeting service for network play (nr-z4nw): liaison, github.com/rmstdope/liaison. Two * browsers meet in a room named by a short code and swap, through it, the offer and answer that - * open their direct connection; liaison never sees the game. This is the only file that touches - * `WebSocket`; the session drives it through `Signaling`. + * open their connection, and get the TURN relay outpost gives the room (nr-1wbx); liaison never + * sees the game. This is the only file that touches `WebSocket`; the session drives it through + * `Signaling`. */ export const LIAISON_URL = "wss://liaison.rmstdope.eu/ws"; @@ -30,12 +31,13 @@ export type ErrorReason = | "room-expired"; /** - * What liaison says. The TURN relay it may offer (`iceServers`) is left out: this build connects - * the two browsers directly only. + * What liaison says. `created` and `joined` carry the room's TURN relay (`iceServers`, nr-1wbx) + * when outpost gave liaison one; both browsers get the same, and use it only when no direct path + * works. */ export type ServerMessage = - | { type: "created"; room: string } - | { type: "joined" } + | { type: "created"; room: string; iceServers?: RTCIceServer[] } + | { type: "joined"; iceServers?: RTCIceServer[] } | { type: "peer-joined" } | { type: "peer-left" } | { type: "signal"; data: unknown } @@ -61,6 +63,38 @@ function isBuild(value: unknown): value is BuildId { return isObject(value) && typeof value.version === "string" && typeof value.commit === "string"; } +/** The most relay servers, and URLs per server, taken from liaison: far more than it sends. */ +const MAX_RELAYS = 4; +const MAX_RELAY_URLS = 8; + +const isTurnUrl = (value: unknown): value is string => typeof value === "string" && /^turns?:/.test(value); + +/** + * The relay liaison offers, read as untrusted: TURN servers with their credentials, exactly, or + * undefined. A relay that is not one is dropped and the room works for direct connections only. + */ +export function parseIceServers(value: unknown): RTCIceServer[] | undefined { + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_RELAYS) return undefined; + const servers: RTCIceServer[] = []; + for (const entry of value) { + if (!isObject(entry) || typeof entry.username !== "string" || typeof entry.credential !== "string") return undefined; + const { urls, username, credential } = entry; + if (Array.isArray(urls)) { + if (urls.length === 0 || urls.length > MAX_RELAY_URLS || !urls.every(isTurnUrl)) return undefined; + servers.push({ urls: [...urls], username, credential }); + } else if (isTurnUrl(urls)) { + servers.push({ urls, username, credential }); + } else return undefined; + } + return servers; +} + +/** `message` with the relay liaison offered, when it is one. */ +function withRelay(message: T, value: unknown): T & { iceServers?: RTCIceServer[] } { + const iceServers = parseIceServers(value); + return iceServers ? { ...message, iceServers } : message; +} + /** A message from liaison, read as untrusted: anything that is not exactly one is null. */ export function parseServerMessage(text: string): ServerMessage | null { let message: unknown; @@ -72,8 +106,9 @@ export function parseServerMessage(text: string): ServerMessage | null { if (!isObject(message)) return null; switch (message.type) { case "created": - return typeof message.room === "string" ? { type: "created", room: message.room } : null; + return typeof message.room === "string" ? withRelay({ type: "created" as const, room: message.room }, message.iceServers) : null; case "joined": + return withRelay({ type: "joined" as const }, message.iceServers); case "peer-joined": case "peer-left": return { type: message.type }; diff --git a/web/src/netplay/peer_link.test.ts b/web/src/netplay/peer_link.test.ts index 438f584d2..c63f0229a 100644 --- a/web/src/netplay/peer_link.test.ts +++ b/web/src/netplay/peer_link.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { webRtcLinks, wrapChannel } from "./peer_link"; +import { isRelayed, webRtcLinks, wrapChannel } from "./peer_link"; /** Just enough of a data channel and its connection for the link wrapper. */ function fakeChannel() { @@ -28,6 +28,32 @@ describe("the link to the other browser", () => { expect(got[1]).toBe("hello"); }); + it("keeps what arrives before anyone listens, for the first listener (nr-1wbx: the link opens after reading its stats)", () => { + const { link, receive } = fakeChannel(); + receive("ping"); + receive(new Uint8Array([4]).buffer); + const got: (string | Uint8Array)[] = []; + link.onMessage((data) => got.push(data)); + receive("later"); + expect(got.map((data) => (typeof data === "string" ? data : Array.from(data)))).toEqual(["ping", [4], "later"]); + }); + + it("tells a listener that comes late that the channel has already closed", () => { + const { channel, link } = fakeChannel(); + channel.dispatchEvent(new Event("close")); + const closed = vi.fn(); + link.onClosed(closed); + expect(closed).toHaveBeenCalledOnce(); + }); + + it("does not tell of a close this side made", () => { + const { link } = fakeChannel(); + link.close(); + const closed = vi.fn(); + link.onClosed(closed); + expect(closed).not.toHaveBeenCalled(); + }); + it("sends bytes as bytes and text as text", () => { const { channel, link } = fakeChannel(); const bytes = new Uint8Array([9, 8]); @@ -38,33 +64,111 @@ describe("the link to the other browser", () => { }); }); +const RELAY: RTCIceServer = { urls: ["turn:outpost.rmstdope.eu:3478?transport=udp"], username: "u", credential: "c" }; + +/** Just enough of a connection to start either side; `stats` is what `getStats` reports. */ +function fakeConnections(stats: () => Promise>> = async () => new Map()) { + const configs: RTCConfiguration[] = []; + const made: EventTarget[] = []; + class FakeConnection extends EventTarget { + iceGatheringState = "complete"; + connectionState = "new"; + localDescription: { sdp: string } | null = null; + constructor(config: RTCConfiguration) { + super(); + configs.push(config); + made.push(this); + } + createDataChannel() { + return new EventTarget(); + } + async setLocalDescription() { + this.localDescription = { sdp: "local" }; + } + async setRemoteDescription() {} + getStats() { + return stats(); + } + close() {} + } + return { FakeConnection, configs, made }; +} + +/** The selected pair between a local and a remote candidate of these types. */ +function pairStats(local: string, remote: string, how: "transport" | "nominated" = "transport") { + const report = new Map>(); + report.set("L", { id: "L", type: "local-candidate", candidateType: local }); + report.set("R", { id: "R", type: "remote-candidate", candidateType: remote }); + report.set("P0", { id: "P0", type: "candidate-pair", localCandidateId: "R", remoteCandidateId: "R", state: "failed" }); + report.set("P", { id: "P", type: "candidate-pair", localCandidateId: "L", remoteCandidateId: "R", state: "succeeded", nominated: how === "nominated" }); + if (how === "transport") report.set("T", { id: "T", type: "transport", selectedCandidatePairId: "P" }); + return report; +} + describe("connecting across the internet", () => { it("asks outpost, NESER's own STUN server, for each browser's public address", async () => { - const configs: RTCConfiguration[] = []; - class FakeConnection extends EventTarget { - iceGatheringState = "complete"; - localDescription: { sdp: string } | null = null; - constructor(config: RTCConfiguration) { - super(); - configs.push(config); - } - createDataChannel() { - return new EventTarget(); - } - async setLocalDescription() { - this.localDescription = { sdp: "local" }; - } - async setRemoteDescription() {} - close() {} - } + const { FakeConnection, configs } = fakeConnections(); vi.stubGlobal("RTCPeerConnection", FakeConnection); try { - (await webRtcLinks.startHost()).cancel(); - (await webRtcLinks.startJoin("offer")).cancel(); + (await webRtcLinks.startHost([])).cancel(); + (await webRtcLinks.startJoin("offer", [])).cancel(); } finally { vi.unstubAllGlobals(); } const outpost = { iceServers: [{ urls: "stun:outpost.rmstdope.eu:3478" }] }; expect(configs).toEqual([outpost, outpost]); }); + + it("offers the room's relay beside outpost, for when no direct path works", async () => { + const { FakeConnection, configs } = fakeConnections(); + vi.stubGlobal("RTCPeerConnection", FakeConnection); + try { + (await webRtcLinks.startHost([RELAY])).cancel(); + (await webRtcLinks.startJoin("offer", [RELAY])).cancel(); + } finally { + vi.unstubAllGlobals(); + } + const both = { iceServers: [{ urls: "stun:outpost.rmstdope.eu:3478" }, RELAY] }; + expect(configs).toEqual([both, both]); + }); +}); + +describe("telling a relayed connection from a direct one", () => { + it("is relayed when either end of the chosen pair is the relay", () => { + expect(isRelayed(pairStats("relay", "srflx") as unknown as RTCStatsReport)).toBe(true); + expect(isRelayed(pairStats("host", "relay") as unknown as RTCStatsReport)).toBe(true); + expect(isRelayed(pairStats("relay", "relay", "nominated") as unknown as RTCStatsReport)).toBe(true); + }); + + it("is direct when neither end is", () => { + expect(isRelayed(pairStats("host", "host") as unknown as RTCStatsReport)).toBe(false); + expect(isRelayed(pairStats("srflx", "prflx", "nominated") as unknown as RTCStatsReport)).toBe(false); + expect(isRelayed(new Map() as unknown as RTCStatsReport)).toBe(false); + }); + + it("says so on the link once the channel opens", async () => { + const { FakeConnection, made } = fakeConnections(async () => pairStats("relay", "srflx")); + vi.stubGlobal("RTCPeerConnection", FakeConnection); + try { + const start = await webRtcLinks.startJoin("offer", [RELAY]); + const channel = Object.assign(new EventTarget(), { readyState: "open", binaryType: "blob", close: vi.fn() }); + made[0].dispatchEvent(Object.assign(new Event("datachannel"), { channel })); + expect((await start.link).relayed).toBe(true); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("counts a connection whose stats cannot be read as direct", async () => { + const { FakeConnection, made } = fakeConnections(() => Promise.reject(new Error("closed"))); + vi.stubGlobal("RTCPeerConnection", FakeConnection); + try { + const start = await webRtcLinks.startJoin("offer", []); + const channel = Object.assign(new EventTarget(), { readyState: "open", binaryType: "blob", close: vi.fn() }); + made[0].dispatchEvent(Object.assign(new Event("datachannel"), { channel })); + expect((await start.link).relayed).toBe(false); + } finally { + vi.unstubAllGlobals(); + } + }); }); diff --git a/web/src/netplay/peer_link.ts b/web/src/netplay/peer_link.ts index ff3b960a0..3b7d9f112 100644 --- a/web/src/netplay/peer_link.ts +++ b/web/src/netplay/peer_link.ts @@ -2,12 +2,13 @@ * The direct connection between two browsers (nr-ayk5.3): one WebRTC data channel, set up from * an offer and an answer the two browsers swap through liaison (nr-z4nw). Each browser asks * outpost, NESER's own STUN server, for the public address its router gives it (nr-ri8n), so two - * players in different homes can reach each other directly; no TURN relay is used (liaison's is - * left for nr-1wbx), so a pair whose networks block a direct connection cannot connect. This is - * the only file that touches `RTCPeerConnection`; the session drives it through `PeerLinks`. + * players in different homes can reach each other directly. Beside it each is given the room's TURN + * relay, outpost in Sweden, when liaison has one (nr-1wbx): WebRTC still prefers a direct path and + * goes through the relay only when none works, and the open link says which it took. This is the + * only file that touches `RTCPeerConnection`; the session drives it through `PeerLinks`. */ -/** outpost answers STUN only; it never carries game data. Without it, same-network play still works. */ +/** outpost's STUN, which never carries game data. Without it, same-network play still works. */ export const ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:outpost.rmstdope.eu:3478" }]; /** @@ -15,8 +16,12 @@ export const ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:outpost.rmstdope.eu:34 * being sent, nr-ayk5.4). `onClosed` is told once, however the channel ends. */ export interface PeerLink { + /** Whether the two connected through the relay, as found when the channel opened; it never changes. */ + readonly relayed: boolean; send(data: string | Uint8Array): void; + /** One listener: a later call replaces it. What arrived before the first is handed to it. */ onMessage(listener: (data: string | Uint8Array) => void): void; + /** One listener: a later call replaces it. A close before it was set is told to it at once. */ onClosed(listener: () => void): void; close(): void; } @@ -35,9 +40,10 @@ export interface JoinStart { cancel(): void; } +/** `relay` is the room's TURN relay from liaison, or none. */ export interface PeerLinks { - startHost(): Promise; - startJoin(hostSdp: string): Promise; + startHost(relay: RTCIceServer[]): Promise; + startJoin(hostSdp: string, relay: RTCIceServer[]): Promise; } /** @@ -60,31 +66,72 @@ function gathered(pc: RTCPeerConnection): Promise { }); } +/** + * Whether the candidate pair the connection chose has the relay at either end: the transport's + * selected pair, or else the nominated pair that succeeded. Either end, so both browsers agree. + */ +export function isRelayed(report: RTCStatsReport): boolean { + const stats = new Map>(); + report.forEach((stat: Record) => stats.set(String(stat.id), stat)); + const all = [...stats.values()]; + const selectedId = all.find((stat) => stat.type === "transport" && typeof stat.selectedCandidatePairId === "string")?.selectedCandidatePairId; + const pair = + (typeof selectedId === "string" ? stats.get(selectedId) : undefined) ?? + all.find((stat) => stat.type === "candidate-pair" && stat.nominated === true && stat.state === "succeeded"); + if (!pair) return false; + return [pair.localCandidateId, pair.remoteCandidateId].some((id) => stats.get(String(id))?.candidateType === "relay"); +} + +/** Whether `pc` is connected through the relay; a connection whose stats cannot be read counts as direct. */ +async function readRelayed(pc: RTCPeerConnection): Promise { + try { + return isRelayed(await pc.getStats()); + } catch { + return false; + } +} + export function wrapChannel(pc: RTCPeerConnection, channel: RTCDataChannel): PeerLink { channel.binaryType = "arraybuffer"; - let closedListener: () => void = () => {}; + // The link is made as the channel opens, before anyone listens (the stats are read first), so + // what arrives meanwhile is kept for the first listener, and a close is told to it late. + let closedListener: (() => void) | null = null; + let messageListener: ((data: string | Uint8Array) => void) | null = null; + const early: (string | Uint8Array)[] = []; let ended = false; + /** The channel ended from the other side, or the network, before anyone listened. */ + let endedUntold = false; const end = () => { if (ended) return; ended = true; - closedListener(); + if (closedListener) closedListener(); + else endedUntold = true; }; channel.addEventListener("close", end); + channel.addEventListener("message", (event) => { + const data = event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : String(event.data); + if (messageListener) messageListener(data); + else early.push(data); + }); pc.addEventListener("connectionstatechange", () => { if (pc.connectionState === "failed" || pc.connectionState === "closed") end(); }); return { + relayed: false, send: (data) => { if (channel.readyState !== "open") return; if (typeof data === "string") channel.send(data); else channel.send(data as Uint8Array); }, - onMessage: (listener) => - channel.addEventListener("message", (event) => - listener(event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : String(event.data)) - ), + onMessage: (listener) => { + messageListener = listener; + for (const data of early.splice(0)) listener(data); + }, onClosed: (listener) => { closedListener = listener; + if (!endedUntold) return; + endedUntold = false; + listener(); }, // Closing the connection itself would abort the channel and drop what is still queued (a // refusal, a goodbye), so the channel closes first and takes the connection with it. @@ -104,15 +151,20 @@ function opened(pc: RTCPeerConnection, channel: Promise): Promis pc.addEventListener("connectionstatechange", () => { if (pc.connectionState === "failed" || pc.connectionState === "closed") reject(new Error("unreachable")); }); + // Wrapped at once, so nothing that arrives while the stats are read is lost. + const open = (ch: RTCDataChannel) => { + const link = wrapChannel(pc, ch); + readRelayed(pc).then((relayed) => resolve({ ...link, relayed })); + }; channel.then((ch) => { - if (ch.readyState === "open") resolve(wrapChannel(pc, ch)); - else ch.addEventListener("open", () => resolve(wrapChannel(pc, ch)), { once: true }); + if (ch.readyState === "open") open(ch); + else ch.addEventListener("open", () => open(ch), { once: true }); }); }); } -async function startHost(): Promise { - const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); +async function startHost(relay: RTCIceServer[]): Promise { + const pc = new RTCPeerConnection({ iceServers: [...ICE_SERVERS, ...relay] }); const channel = pc.createDataChannel("neser"); try { await pc.setLocalDescription(); @@ -131,8 +183,8 @@ async function startHost(): Promise { }; } -async function startJoin(hostSdp: string): Promise { - const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); +async function startJoin(hostSdp: string, relay: RTCIceServer[]): Promise { + const pc = new RTCPeerConnection({ iceServers: [...ICE_SERVERS, ...relay] }); const channel = new Promise((resolve) => { pc.addEventListener("datachannel", (event) => resolve(event.channel), { once: true }); }); diff --git a/web/src/netplay/session.test.ts b/web/src/netplay/session.test.ts index 90d32120a..9b7eae556 100644 --- a/web/src/netplay/session.test.ts +++ b/web/src/netplay/session.test.ts @@ -8,6 +8,7 @@ const THEIRS = { version: "1.3.0", commit: "b81e07d" }; /** The other browser at the end of a fake link: it answers pings after `rtt` ms, and logs the rest. */ class FakeLink implements PeerLink { + relayed = false; sent: Record[] = []; closed = false; rtt: number; @@ -56,19 +57,20 @@ function fakeLinks() { const accepted = deferred(); const joined = deferred(); let offers = 0; + /** The relay each start was given, in order. */ + const hostRelays: RTCIceServer[][] = []; + const joinRelays: RTCIceServer[][] = []; const links: PeerLinks = { - startHost: async (): Promise => ({ - sdp: `offer-sdp-${++offers}`, - accept: vi.fn(() => accepted.promise), - cancel: hostCancel, - }), - startJoin: async (hostSdp: string): Promise => ({ - sdp: `answer-to-${hostSdp}`, - link: joined.promise, - cancel: joinCancel, - }), + startHost: async (relay): Promise => { + hostRelays.push(relay); + return { sdp: `offer-sdp-${++offers}`, accept: vi.fn(() => accepted.promise), cancel: hostCancel }; + }, + startJoin: async (hostSdp: string, relay): Promise => { + joinRelays.push(relay); + return { sdp: `answer-to-${hostSdp}`, link: joined.promise, cancel: joinCancel }; + }, }; - return { links, accepted, joined, hostCancel, joinCancel }; + return { links, accepted, joined, hostCancel, joinCancel, hostRelays, joinRelays }; } /** This browser's connection to liaison, which the test speaks for. */ @@ -472,6 +474,130 @@ describe("joining", () => { }); }); +const RELAY: RTCIceServer = { urls: ["turn:outpost.rmstdope.eu:3478?transport=udp"], username: "u", credential: "c" }; + +describe("the relay (nr-1wbx)", () => { + it("gives the host's connection the relay liaison sent with the room", async () => { + session.host(); + await settle(); + const socket = liaison.last(); + socket.says({ type: "created", room: "K7Q-M4X", iceServers: [RELAY] }); + expect(session.view().relayTried).toBe(false); + socket.says({ type: "peer-joined" }); + await settle(); + expect(fake.hostRelays).toEqual([[RELAY]]); + // Tried once both sides have started with it: the joiner answered. + expect(session.view().relayTried).toBe(false); + socket.says({ type: "signal", data: { answer: "answer-sdp" } }); + await settle(); + expect(session.view().relayTried).toBe(true); + }); + + it("gives the joiner's connection the relay liaison sent on joining", async () => { + const socket = await joinTried(); + socket.says({ type: "joined", iceServers: [RELAY] }); + expect(session.view().relayTried).toBe(false); + socket.says({ type: "signal", data: { offer: "offer-sdp" } }); + await settle(); + expect(fake.joinRelays).toEqual([[RELAY]]); + expect(session.view().relayTried).toBe(true); + }); + + it("connects directly only when liaison sent no relay", async () => { + await hostSwapped(); + await joinToWaiting(); + expect(fake.hostRelays).toEqual([[]]); + expect(fake.joinRelays).toEqual([[]]); + expect(session.view().relayTried).toBe(false); + }); + + it("uses the relay of the room it has now, when a new room replaced the old", async () => { + session.host(); + await settle(); + liaison.last().says({ type: "created", room: "K7Q-M4X", iceServers: [RELAY] }); + liaison.last().says({ type: "error", reason: "room-expired" }); + await settle(); + const socket = liaison.last(); + socket.says({ type: "created", room: "ABC-DEF" }); + socket.says({ type: "peer-joined" }); + await settle(); + expect(fake.hostRelays).toEqual([[]]); + expect(session.view().relayTried).toBe(false); + }); + + it("says the connection is relayed when its link went through the relay, on both sides", async () => { + await hostSwapped(); + const link = new FakeLink(20); + link.relayed = true; + fake.accepted.resolve(link); + await settle(); + await vi.advanceTimersByTimeAsync(2000); + expect(session.view()).toMatchObject({ phase: "connected", relayed: true }); + + session.cancel(); + fake = fakeLinks(); + session = createNetplaySession({ signaling: liaison.signaling, links: fake.links, build: OURS }); + await joinToWaiting(); + const joinLink = new FakeLink(20); + joinLink.relayed = true; + fake.joined.resolve(joinLink); + await settle(); + joinLink.deliver({ t: "ready", rtt: 90 }); + expect(session.view()).toMatchObject({ phase: "connected", relayed: true }); + }); + + it("says a direct connection is not relayed", async () => { + await hostToConnect(20); + await vi.advanceTimersByTimeAsync(2000); + expect(session.view()).toMatchObject({ phase: "connected", relayed: false }); + }); + + it("keeps saying a refused connection was relayed, for its advice", async () => { + await joinToWaiting(); + const link = new FakeLink(200); + link.relayed = true; + fake.joined.resolve(link); + await settle(); + link.deliver({ t: "refuse", rtt: 210 }); + expect(session.view()).toMatchObject({ phase: "failed", failure: "too-slow", relayed: true }); + }); + + it("says the relay was tried when the two could not connect even with it", async () => { + const socket = await joinTried(); + socket.says({ type: "joined", iceServers: [RELAY] }); + socket.says({ type: "signal", data: { offer: "offer-sdp" } }); + await vi.advanceTimersByTimeAsync(10_100); + expect(session.view()).toMatchObject({ phase: "failed", failure: "unreachable", relayTried: true }); + session.startAgain(); + expect(session.view()).toMatchObject({ relayTried: false, relayed: false }); + }); + + it("does not say the relay was tried when either browser could not start a connection, so both say the same", async () => { + fake.links.startHost = () => Promise.reject(new Error("no WebRTC")); + session.host(); + await settle(); + liaison.last().says({ type: "created", room: "K7Q-M4X", iceServers: [RELAY] }); + liaison.last().says({ type: "peer-joined" }); + await settle(); + expect(session.view()).toMatchObject({ phase: "failed", failure: "unreachable", relayTried: false }); + + fake.links.startJoin = () => Promise.reject(new Error("no WebRTC")); + const socket = await joinTried(); + socket.says({ type: "joined", iceServers: [RELAY] }); + socket.says({ type: "signal", data: { offer: "offer-sdp" } }); + await settle(); + expect(session.view()).toMatchObject({ phase: "failed", failure: "unreachable", relayTried: false }); + }); + + it("does not say the relay was tried when liaison ends a room nobody connected through", async () => { + session.host(); + await settle(); + liaison.last().says({ type: "created", room: "K7Q-M4X", iceServers: [RELAY] }); + liaison.last().says({ type: "error", reason: "bad-message" }); + expect(session.view()).toMatchObject({ phase: "failed", failure: "unreachable", relayTried: false }); + }); +}); + describe("once connected", () => { async function connected(rtt: number) { const result = await hostToConnect(rtt); diff --git a/web/src/netplay/session.ts b/web/src/netplay/session.ts index c86652fed..906606e71 100644 --- a/web/src/netplay/session.ts +++ b/web/src/netplay/session.ts @@ -19,7 +19,9 @@ import { ROOM_CODE_LENGTH, showRoomCode, tidyRoomCode } from "./room_code"; * other side does once its link is open, and the link itself says whether the two connected. A * host whose room ran out, or whose joiner left before the swap, gets a fresh room by itself. The * joiner keeps one connection to liaison across its tries at a code, so liaison's limit on wrong - * codes holds; Start again begins with a fresh one. + * codes holds; Start again begins with a fresh one. liaison may give the room a TURN relay with + * `created` and `joined` (nr-1wbx); both connections are offered it, the view says whether one was + * tried (which can't-connect message to show) and whether the link went through it. * * The two browsers talk in JSON text over the link: each answers `ping` with `pong`; the host * measures `CHECK_SAMPLES` round trips on connecting and sends `ready` or `refuse` with their @@ -136,6 +138,14 @@ export interface NetplayView { notice: SendingNotice | null; /** How the other player went, once they have (failure "peer-left"). */ leaving: Leaving | null; + /** + * Both browsers started their connections with the room's relay (nr-1wbx): which message says + * the two couldn't connect. Set by the host on the joiner's answer and by the joiner once it has + * answered, so both say the same; a failure before then, or with no relay, says none was available. + */ + relayTried: boolean; + /** The two connected through the relay; kept by a failure, so a too-slow refusal can say so. */ + relayed: boolean; } export interface NetplaySession { @@ -251,6 +261,8 @@ const IDLE: NetplayView = { sending: null, notice: null, leaving: null, + relayTried: false, + relayed: false, }; const isCount = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0; @@ -295,6 +307,8 @@ export function createNetplaySession({ let socket: SignalingSocket | null = null; /** The offer and answer have been swapped: the other side leaving the room is no longer news. */ let swapped = false; + /** The TURN relay liaison gave the room of the attempt under way, or none. */ + let relay: RTCIceServer[] = []; let hostStart: HostStart | null = null; let joinStart: JoinStart | null = null; let live: PeerLink | null = null; @@ -344,6 +358,7 @@ export function createNetplaySession({ live = null; closeSocket(); swapped = false; + relay = []; samples = []; unanswered = new Map(); } @@ -554,6 +569,7 @@ export function createNetplaySession({ /** Listens on a fresh link: pongs feed `onSample`, and the other player's leaving ends it. */ function adopt(link: PeerLink, mine: number, onSample: (rtt: number) => void, onMessage: (m: Message) => void) { live = link; + set({ relayed: link.relayed }); link.onMessage((data) => { if (mine !== attempt) return; if (typeof data !== "string") { @@ -659,11 +675,12 @@ export function createNetplaySession({ function hostHears(m: ServerMessage, mine: number) { if (m.type === "created" && state.phase === "opening") { + relay = m.iceServers ?? []; set({ phase: "waiting", room: showRoomCode(tidyRoomCode(m.room)) }); } else if (m.type === "peer-joined" && state.phase === "waiting") { set({ phase: "connecting" }); startConnectLimit(mine); - links.startHost().then( + links.startHost(relay).then( (start) => { if (mine !== attempt) return start.cancel(); hostStart = start; @@ -676,6 +693,8 @@ export function createNetplaySession({ const answer = sdpIn(m.data, "answer"); if (answer === null) return; swapped = true; + // Both browsers have now started with the room's relay: from here, it has been tried. + set({ relayTried: relay.length > 0 }); hostStart.accept(answer).then( (link) => { if (mine !== attempt) return link.close(); @@ -706,15 +725,18 @@ export function createNetplaySession({ function joinerHears(m: ServerMessage, mine: number) { if (state.phase !== "connecting") return; if (m.type === "joined") { + relay = m.iceServers ?? []; startConnectLimit(mine); } else if (m.type === "signal" && !joinStart && !swapped) { const offer = sdpIn(m.data, "offer"); if (offer === null) return; swapped = true; - links.startJoin(offer).then( + links.startJoin(offer, relay).then( (start) => { if (mine !== attempt) return start.cancel(); joinStart = start; + // Both browsers have now started with the room's relay: from here, it has been tried. + set({ relayTried: relay.length > 0 }); socket?.send({ type: "signal", data: { answer: start.sdp } }); start.link.then( (link) => { @@ -749,7 +771,7 @@ export function createNetplaySession({ const chars = tidyRoomCode(code); if (state.role !== "join" || state.phase !== "idle" || chars.length !== ROOM_CODE_LENGTH) return; const mine = attempt; - set({ phase: "connecting", joinProblem: null }); + set({ phase: "connecting", joinProblem: null, relayTried: false }); const ask = (s: SignalingSocket) => s.send({ type: "join", room: showRoomCode(chars), build }); if (socket) return ask(socket); openSocket( diff --git a/web/src/netplay/session_sending.test.ts b/web/src/netplay/session_sending.test.ts index f3993f472..5e4762a08 100644 --- a/web/src/netplay/session_sending.test.ts +++ b/web/src/netplay/session_sending.test.ts @@ -24,6 +24,7 @@ type Data = string | Uint8Array; /** One end of the pair; what it sends reaches the other end `ONE_WAY_MS` later, unless held or dropped. */ class End implements PeerLink { + relayed = false; other!: End; listener: (data: Data) => void = () => {}; closedListener: () => void = () => {}; diff --git a/web/src/ui/play_together.test.ts b/web/src/ui/play_together.test.ts index 8abfcbe11..8256130da 100644 --- a/web/src/ui/play_together.test.ts +++ b/web/src/ui/play_together.test.ts @@ -19,6 +19,8 @@ const IDLE: NetplayView = { sending: null, notice: null, leaving: null, + relayTried: false, + relayed: false, }; class FakeSession implements NetplaySession { @@ -50,6 +52,10 @@ class FakeSession implements NetplaySession { } const BUILD = { version: "1.3.0", commit: "4f2a9c1" }; +const NO_RELAY = + "Couldn't connect your two computers. Your networks don't allow a direct connection, and NESER's relay can't be reached right now. Try again in a while, or with both of you on a home network."; +const RELAY_TRIED = + "Couldn't connect your two computers. Your networks blocked every way of connecting them, even through NESER's relay. This can happen on strict work or school networks — try again from another network."; let session: FakeSession; let game: CurrentGame | null; const PACKAGE: GamePackage = { kind: "nes", name: "smb3.nes", rom: new Uint8Array([1]), firmware: null, state: new Uint8Array([2]) }; @@ -89,6 +95,15 @@ function open() { function alerts() { return [...body().querySelectorAll(".netplay-alert")].map((a) => [a.dataset.tone, a.textContent]); } +function hostStep1() { + open(); + body().querySelector("[data-choice=host]")!.click(); +} +function joinStep1() { + game = null; + open(); + body().querySelector("[data-choice=join]")!.click(); +} beforeEach(() => { document.body.innerHTML = ` @@ -161,7 +176,7 @@ describe("the choice", () => { }); const ADDRESS_LINE = - "ⓘ Playing together connects your computer directly to the other player's, so each of you can see the other's internet address."; + "ⓘ Playing together connects your computer to the other player's — directly, or through NESER's relay when it has to — so each of you can see the other's internet address."; it.each([ ["a NES game", { kind: "nes", title: "Super Mario Bros. 3" }], @@ -193,10 +208,6 @@ describe("the choice", () => { }); describe("hosting", () => { - function hostStep1() { - open(); - body().querySelector("[data-choice=host]")!.click(); - } function room() { return body().querySelector(".netplay-room")?.textContent; } @@ -312,10 +323,10 @@ describe("hosting", () => { expect(buttonNames()).toEqual(["Close", "Try again"]); }); - it("says when the two computers could not be connected, offering to start again (nr-ri8n)", () => { + it("says when the two computers could not be connected and no relay was available, offering to start again (nr-1wbx)", () => { hostStep1(); - session.emit({ phase: "failed", failure: "unreachable" }); - expect(alerts()).toEqual([["err", "Couldn't connect your two computers. Your networks don't allow a direct connection between them. This often happens on mobile, hotspot or work networks — try again with both of you on a home network."]]); + session.emit({ phase: "failed", failure: "unreachable", relayTried: false }); + expect(alerts()).toEqual([["err", NO_RELAY]]); expect(buttonNames()).toEqual(["Close", "Start again"]); expect(document.activeElement).toBe(button("Start again")); button("Start again")!.click(); @@ -387,11 +398,6 @@ describe("hosting", () => { }); describe("joining", () => { - function joinStep1() { - game = null; - open(); - body().querySelector("[data-choice=join]")!.click(); - } function field() { return body().querySelector("input.netplay-room-field")!; } @@ -550,11 +556,11 @@ describe("joining", () => { expect(buttonNames()).toEqual(["Cancel", "Join"]); }); - it("says when the two computers could not be connected, offering to start again (nr-ri8n)", () => { + it("says when the two computers could not be connected and no relay was available, offering to start again (nr-1wbx)", () => { joinStep1(); type("K7QM4X"); - session.emit({ phase: "failed", failure: "unreachable" }); - expect(alerts()).toEqual([["err", "Couldn't connect your two computers. Your networks don't allow a direct connection between them. This often happens on mobile, hotspot or work networks — try again with both of you on a home network."]]); + session.emit({ phase: "failed", failure: "unreachable", relayTried: false }); + expect(alerts()).toEqual([["err", NO_RELAY]]); expect(buttonNames()).toEqual(["Close", "Start again"]); expect(document.activeElement).toBe(button("Start again")); }); @@ -577,6 +583,115 @@ describe("joining", () => { }); }); +describe("playing through the relay (nr-1wbx)", () => { + const RELAY_LINE = + "↪ Connected through NESER's relay in Sweden, because your networks don't allow a direct connection. This can add a little delay."; + const relayLines = () => [...body().querySelectorAll(".netplay-relay")].map((line) => line.textContent); + + it.each([ + ["host", hostStep1], + ["joiner", joinStep1], + ] as const)("says the %s is connected through the relay, beneath the response time", (_, toStep1) => { + toStep1(); + session.emit({ step: 2, phase: "connected", rtt: 18, relayed: true }); + expect(relayLines()).toEqual([RELAY_LINE]); + const live = body().querySelector(".netplay-live")!; + expect([...live.children].map((child) => child.className)).toEqual(["netplay-pill", "netplay-relay"]); + expect(body().querySelector(".netplay-relay")!.matches("button, a, [tabindex]")).toBe(false); + }); + + it("puts the line above the joiner's wait for the host", () => { + joinStep1(); + session.emit({ step: 2, phase: "connected", rtt: 18, relayed: true }); + expect(text().indexOf(RELAY_LINE)).toBeLessThan(text().indexOf("⟳ Waiting for the host to start the game…")); + }); + + it("says nothing of a relay on a direct connection", () => { + hostStep1(); + session.emit({ step: 2, phase: "connected", rtt: 18, relayed: false }); + expect(relayLines()).toEqual([]); + expect(text()).not.toContain("relay"); + }); + + it("keeps the line above the alert when the relayed connection becomes too slow, with the relay's advice", () => { + hostStep1(); + session.emit({ step: 2, phase: "connected", rtt: 18, relayed: true }); + session.emit({ rtt: 190 }); + const live = body().querySelector(".netplay-live")!; + expect([...live.children].map((child) => child.className)).toEqual(["netplay-pill", "netplay-relay", "netplay-alert"]); + expect(alerts()).toEqual([ + [ + "err", + "The connection through NESER's relay has become too slow to play. It may recover by itself — if not, try again with both of you on a home network, ideally wired.", + ], + ]); + expect(buttonNames()).toEqual(["Disconnect", "Start game"]); + }); + + it("gives the relay's advice when a relayed connection is too slow at connect time", () => { + hostStep1(); + session.emit({ phase: "failed", failure: "too-slow", rtt: 210, relayed: true }); + expect(text()).toContain("Too slow to play"); + expect(alerts()).toEqual([ + [ + "err", + "The connection through NESER's relay is too slow for the game to keep up. A direct connection is usually faster — try again with both of you on a home network, ideally wired.", + ], + ]); + expect(buttonNames()).toEqual(["Close", "Try again"]); + }); + + it.each([ + ["host", hostStep1], + ["joiner", joinStep1], + ] as const)("tells the %s the relay was tried and still could not connect them", (_, toStep1) => { + toStep1(); + session.emit({ phase: "failed", failure: "unreachable", relayTried: true }); + expect(alerts()).toEqual([["err", RELAY_TRIED]]); + expect(buttonNames()).toEqual(["Close", "Start again"]); + expect(document.activeElement).toBe(button("Start again")); + }); + + it("says Relayed on the chip, and only the arrow on a narrow window, while the screen reader hears Relayed", () => { + open(); + session.emit({ role: "host", step: 2, phase: "connected", rtt: 18, relayed: true }); + q("#netplay-close")!.click(); + expect(chip().querySelector(".netplay-chip-long")!.textContent).toBe("Connected · Relayed · "); + expect(chip().querySelector(".netplay-chip-short")!.textContent).toBe("↪ "); + expect(chip().querySelector(".netplay-chip-short")!.getAttribute("aria-hidden")).toBe("true"); + expect(chip().getAttribute("aria-label")).toBe("Connected · Relayed · 18 ms"); + expect(chip().dataset.tone).toBe("ok"); + session.emit({ rtt: 110 }); + expect(chip().dataset.tone).toBe("warn"); + expect(chip().getAttribute("aria-label")).toBe("Connected · Relayed · 110 ms"); + session.emit({ rtt: 160 }); + expect(chip().dataset.tone).toBe("err"); + }); + + it("says neither Relayed nor the arrow while waiting or once disconnected", () => { + open(); + session.emit({ role: "host", step: 2, phase: "connected", rtt: 84, relayed: true }); + q("#netplay-close")!.click(); + waiting = true; + window_.refresh(); + expect(chip().textContent).toBe("Waiting…"); + expect(chip().hasAttribute("aria-label")).toBe(false); + waiting = false; + session.emit({ phase: "failed", failure: "peer-left" }); + expect(chip().textContent).toBe("Disconnected"); + expect(chip().hasAttribute("aria-label")).toBe(false); + }); + + it("leaves a direct game's chip as it was", () => { + open(); + session.emit({ role: "host", step: 2, phase: "connected", rtt: 18, relayed: false }); + q("#netplay-close")!.click(); + expect(chip().textContent).toBe("Connected · 18 ms"); + expect(chip().querySelector(".netplay-chip-short")).toBeNull(); + expect(chip().hasAttribute("aria-label")).toBe(false); + }); +}); + describe("closing the window", () => { it("before connecting cancels the attempt and resumes", () => { open(); diff --git a/web/src/ui/play_together.ts b/web/src/ui/play_together.ts index ce1f2865a..65d96c185 100644 --- a/web/src/ui/play_together.ts +++ b/web/src/ui/play_together.ts @@ -40,9 +40,38 @@ export function inviteMessage(gameTitle: string, room: string): string { ); } -/** Always beneath the two cards (nr-ri8n, docs/ui/nr-ri8n-internet-play.html): plain information, nothing to accept. */ +/** + * Always beneath the two cards (nr-ri8n, docs/ui/nr-ri8n-internet-play.html; worded for the relay + * in nr-1wbx): plain information, nothing to accept. + */ const ADDRESS_LINE = - "ⓘ Playing together connects your computer directly to the other player's, so each of you can see the other's internet address."; + "ⓘ Playing together connects your computer to the other player's — directly, or through NESER's relay when it has to — so each of you can see the other's internet address."; + +/** + * A game relayed through outpost (nr-1wbx, docs/ui/nr-1wbx-relay.html): this line beneath the + * response time, "Relayed" on the chip, and its own advice when it is too slow. + */ +const RELAY_LINE = + "↪ Connected through NESER's relay in Sweden, because your networks don't allow a direct connection. This can add a little delay."; +const TOO_SLOW_TO_START = { + direct: "The connection between the two computers is too slow for the game to keep up. Try a wired connection or being closer to the router — or play on the same network.", + relayed: + "The connection through NESER's relay is too slow for the game to keep up. A direct connection is usually faster — try again with both of you on a home network, ideally wired.", +}; +const BECAME_TOO_SLOW = { + direct: "The connection has become too slow to play. It may recover by itself — if not, try a wired connection or being closer to the router.", + relayed: + "The connection through NESER's relay has become too slow to play. It may recover by itself — if not, try again with both of you on a home network, ideally wired.", +}; +/** Couldn't connect, by whether the connection was given a relay to try. */ +const COULD_NOT_CONNECT = { + noRelay: + "Your networks don't allow a direct connection, and NESER's relay can't be reached right now. Try again in a while, or with both of you on a home network.", + relayTried: + "Your networks blocked every way of connecting them, even through NESER's relay. This can happen on strict work or school networks — try again from another network.", +}; + +const relayWay = (view: NetplayView) => (view.relayed ? "relayed" : "direct"); const OTHER: Record = { host: "the other player", join: "the host" }; @@ -181,6 +210,8 @@ export function createPlayTogether({ } function drawButton(view: NetplayView) { + // Only the relayed chip names itself, so a screen reader hears "Relayed" on a narrow window too. + openButton.removeAttribute("aria-label"); const lost = view.phase === "failed" && view.failure === "peer-left"; if (view.phase === "connected" && view.sending) { const { direction, done, total } = view.sending; @@ -208,7 +239,14 @@ export function createPlayTogether({ const tone = responseTimeRating(view.rtt).tone; openButton.removeAttribute("data-icon"); openButton.dataset.tone = tone; - openButton.replaceChildren(dot(tone), el("span", "netplay-chip-long", "Connected · "), `${view.rtt} ms`); + if (view.relayed) { + const arrow = el("span", "netplay-chip-short", "↪ "); + arrow.setAttribute("aria-hidden", "true"); + openButton.replaceChildren(dot(tone), el("span", "netplay-chip-long", "Connected · Relayed · "), arrow, `${view.rtt} ms`); + openButton.setAttribute("aria-label", `Connected · Relayed · ${view.rtt} ms`); + } else { + openButton.replaceChildren(dot(tone), el("span", "netplay-chip-long", "Connected · "), `${view.rtt} ms`); + } } else if (lost || (endedHere && view.phase === "idle")) { openButton.removeAttribute("data-icon"); openButton.dataset.tone = "err"; @@ -408,11 +446,7 @@ export function createPlayTogether({ if (view.failure === "too-many-attempts") { return alert("err", "Too many wrong codes.", "Check the code with the host, then press Start again."); } - return alert( - "err", - "Couldn't connect your two computers.", - "Your networks don't allow a direct connection between them. This often happens on mobile, hotspot or work networks — try again with both of you on a home network." - ); + return alert("err", "Couldn't connect your two computers.", view.relayTried ? COULD_NOT_CONNECT.relayTried : COULD_NOT_CONNECT.noRelay); } function drawRefused(view: NetplayView) { @@ -421,11 +455,7 @@ export function createPlayTogether({ body.append(status); if (view.rtt !== null) body.append(pill(view.rtt)); body.append( - alert( - "err", - "", - "The connection between the two computers is too slow for the game to keep up. Try a wired connection or being closer to the router — or play on the same network." - ), + alert("err", "", TOO_SLOW_TO_START[relayWay(view)]), actions(btn("Close", close), btn("Try again", () => session.startAgain(), true)) ); body.querySelector(".netplay-actions button")?.focus(); @@ -449,16 +479,11 @@ export function createPlayTogether({ const rating = responseTimeRating(v.rtt); status.replaceChildren(dot(rating.tone), " ", el("b", "", "Connected"), ` to ${OTHER[role]}.`); live.replaceChildren(pill(v.rtt)); + if (v.relayed) live.append(el("p", "netplay-relay", RELAY_LINE)); if (rating.tone === "warn") { live.append(alert("warn", "", "Your button presses will take a moment longer to show up. Fine for most games, harder for fast ones.")); } else if (rating.tone === "err") { - live.append( - alert( - "err", - "", - "The connection has become too slow to play. It may recover by itself — if not, try a wired connection or being closer to the router." - ) - ); + live.append(alert("err", "", BECAME_TOO_SLOW[relayWay(v)])); } }; update(view);