diff --git a/src/docs/src/Objects/puterpeerconnection.md b/src/docs/src/Objects/puterpeerconnection.md index 5009c4259..95156b5ff 100644 --- a/src/docs/src/Objects/puterpeerconnection.md +++ b/src/docs/src/Objects/puterpeerconnection.md @@ -13,6 +13,10 @@ The `PuterPeerConnection` object representing a WebRTC data-channel connection t Information about the user who created the server, with `username` and `uuid`. +#### `room` (String) + +The room name this connection was made in, when the server was reached by name (see the `name` option of [`puter.peer.serve()`](/Peer/serve/)). `undefined` for a connection made on an invite code. + #### `connected` (Boolean) Whether the data channel is currently open. @@ -51,7 +55,7 @@ Fired when the connection closes. `event.reason` holds the reason, if one was gi #### `error` -Fired when a connection error occurs. `event.error` holds the error. +Fired when a connection error occurs. `event.error` holds the error. When the signaller refused the connection it is an `Error` whose `code` says why — `no_host` (a room nobody is serving right now), `invalid_invite` (an invite code that is not live) or `invalid_auth` — and `close` follows. ## Example diff --git a/src/docs/src/Objects/puterpeerserver.md b/src/docs/src/Objects/puterpeerserver.md index 5203dfa8d..47bcfbde3 100644 --- a/src/docs/src/Objects/puterpeerserver.md +++ b/src/docs/src/Objects/puterpeerserver.md @@ -11,7 +11,7 @@ The `PuterPeerServer` object returned by [`puter.peer.serve()`](/Peer/serve/). I #### `inviteCode` (String) -The code to share with other clients so they can connect with [`puter.peer.connect()`](/Peer/connect/). +The code to share with other clients so they can connect with [`puter.peer.connect()`](/Peer/connect/). For a server started with a `name`, this is the name. For one on a generated code, it can change if the server has to re-register with the signaller — see the `reconnect` event. #### `connections` (Map) @@ -21,7 +21,11 @@ A `Map` of every connected client, keyed by connection id. The values are [`Pute #### `close()` -Closes every client connection and the signalling connection. The invite code stops working. +Closes every client connection and the signalling connection. The invite code stops working; a room name is free for someone else to serve. + +#### `setGuestGrant(grant)` + +Replaces the guest grant handed to clients that connect from now on (see the `guestGrant` option of [`puter.peer.serve()`](/Peer/serve/)). Grants expire, so a long-running host issues a fresh one with [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/) before the old one lapses and passes it here. Pass `null` to stop handing one out. ## Events @@ -32,6 +36,18 @@ Fired when a client connects. The event has the following attributes: - `conn` ([`PuterPeerConnection`](/Objects/puterpeerconnection/)) - The connection to the client. - `user` (Object) - Metadata about the connecting user, with `username` and `uuid` (if available). +#### `reconnect` + +Fired when the server has re-registered with the signaller after losing its connection to it. Nothing about existing client connections changes; this only concerns clients yet to connect. The event has: + +- `inviteCode` (String) - The invite code in force now. Unchanged for a server with a `name`; a server on a generated code gets a fresh one, since the old one died with the connection — share the new one. + +#### `close` + +Fired when the server has stopped accepting clients without `close()` having been called. Existing connections stay open; the invite code no longer works. The event has: + +- `reason` (String) - `replaced` when a newer server of yours took the same room name over (the same account, or the same `anonToken`, called `serve()` again with that name), or `name_in_use` when the name is held by someone else and could not be reclaimed after the connection was lost. + ## Example ```js diff --git a/src/docs/src/Peer.md b/src/docs/src/Peer.md index 125b3aa22..8d1dced67 100644 --- a/src/docs/src/Peer.md +++ b/src/docs/src/Peer.md @@ -10,10 +10,12 @@ Use the Peer API to build peer-to-peer applications without the need for a serve
-Hosting a session requires authentication — on websites, Puter.js will prompt the user if needed. Guests can join without an account: pass `anonToken`, plus a `turnGrant` from the host so the connection can still use Puter's relays. See [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/). +Hosting a session requires authentication — on websites, Puter.js will prompt the user if needed. Guests can join without an account: pass `anonToken`, plus a `turnGrant` from the host so the connection can still use Puter's relays — or let the host serve with a `guestGrant`, which reaches guests on its own. See [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/).
+A server is reached either by the invite code it was handed — good for as long as it serves — or by a **room name** of your choosing (`puter.peer.serve({ name: 'friday-standup' })`), which anyone can dial with `puter.peer.connect('friday-standup')` for as long as someone serves it. Room names are how you make a link that can be shared ahead of time and reused; see [`puter.peer.serve()`](/Peer/serve/#room-names). + ## Features #### Create a peer server and exchange messages diff --git a/src/docs/src/Peer/connect.md b/src/docs/src/Peer/connect.md index 84e251066..c0aec1f9d 100644 --- a/src/docs/src/Peer/connect.md +++ b/src/docs/src/Peer/connect.md @@ -24,7 +24,7 @@ const conn = await puter.peer.connect(inviteCode, options); #### `inviteCode` (required) -A string invite code created by `puter.peer.serve()`. +The invite code a `puter.peer.serve()` call was given, or the **room name** it was started with (`serve({ name })`). The two are told apart by shape — generated codes are uppercase (`NJ-7F3A9C`), room names lowercase — so pass whichever you were handed. #### `options` (optional) @@ -33,12 +33,18 @@ A string invite code created by `puter.peer.serve()`. - `iceServers` (`RTCIceServer[]`) Custom ICE servers (STUN/TURN) to use instead of the Puter-managed relays. - `forceRelay` (`boolean`) Whether to force connections to route through a relay instead of attempting peer-to-peer (default). Metering charges may apply. - `anonToken` (`String`) Join without a Puter session. Any uuid — it identifies this guest for the duration of the session, and no sign-in prompt is shown. The host sees the guest as `anonymous`, so anything you want to call them is yours to send over the connection. -- `turnGrant` (`String`) A grant from [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/). Lets a guest use the Puter-managed relays on the host's account. Without one, a guest connects only where a direct connection is possible; with `forceRelay`, a guest needs one. +- `turnGrant` (`String`) A grant from [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/). Lets a guest use the Puter-managed relays on the host's account. Without one, a guest still gets relays when the host serves with a `guestGrant` — that grant reaches the guest through the signaller. Otherwise a guest connects only where a direct connection is possible; with `forceRelay`, a guest needs a grant one way or the other. ## Return value A `Promise` that resolves to a [`PuterPeerConnection`](/Objects/puterpeerconnection/) instance, which carries `send()` and `close()` methods and the `open`, `message`, `close`, and `error` events. +The promise resolves once the connection has been requested, not once it is open — wait for `open` before sending. If the signaller refuses the connection, the instance fires `error` with an `Error` whose `code` says why, then `close`: + +- `no_host` — the room name is valid but nobody is serving it right now. Try again in a few seconds; the host may be on their way. +- `invalid_invite` — the invite code is not live: it was mistyped, or the server that issued it is gone. +- `invalid_auth` — the session token or `anonToken` was not accepted. + ## Example ```html diff --git a/src/docs/src/Peer/createGuestGrant.md b/src/docs/src/Peer/createGuestGrant.md index bdac1b9ec..22996757e 100644 --- a/src/docs/src/Peer/createGuestGrant.md +++ b/src/docs/src/Peer/createGuestGrant.md @@ -5,7 +5,7 @@ platforms: [websites, apps] --- -Creates a **guest grant**: a short-lived token that lets people without a Puter session use the Puter-managed TURN relays. Hand it to the people you invite alongside the invite code, and they pass it to [`puter.peer.connect()`](/Peer/connect/) as `turnGrant`. +Creates a **guest grant**: a short-lived token that lets people without a Puter session use the Puter-managed TURN relays. Either hand it to the people you invite alongside the invite code, and they pass it to [`puter.peer.connect()`](/Peer/connect/) as `turnGrant` — or pass it to [`puter.peer.serve()`](/Peer/serve/) as `guestGrant`, and every guest that connects without a grant of their own receives it through the signaller. Without a grant, a guest can still join a session — but only over direct connections. Relay credentials are what make a connection work when one side is behind a NAT or firewall that blocks direct traffic, and minting them requires an account. The grant is how your account vouches for the guest. diff --git a/src/docs/src/Peer/serve.md b/src/docs/src/Peer/serve.md index 4e177d829..e06f8de7f 100644 --- a/src/docs/src/Peer/serve.md +++ b/src/docs/src/Peer/serve.md @@ -29,6 +29,8 @@ const server = await puter.peer.serve(options); - `iceServers` (`RTCIceServer[]`) Custom ICE servers (STUN/TURN) to use instead of the Puter-managed relays. - `forceRelay` (`boolean`) Whether to force connections to route through a relay instead of attempting peer-to-peer (default). Metering charges will increase. - `anonToken` (`String`) Host without a Puter session. Any uuid; no sign-in prompt is shown. An anonymous host has no account to attribute relay usage to, so it cannot issue guest grants and gets no relays of its own. +- `name` (`String`) Serve under a **room name** of your choosing instead of a generated invite code. Clients connect with the same string: `puter.peer.connect(name)`. See [Room names](#room-names) below. +- `guestGrant` (`String`) A grant from [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/) to hand to guests. Every client that connects with `anonToken` and no `turnGrant` of its own receives it through the signaller and uses the relays on your account, so you never have to deliver the grant some other way. Renew it with [`server.setGuestGrant()`](/Objects/puterpeerserver/#setguestgrant-grant) before it expires. To let people join your session without accounts of their own, keep hosting authenticated and give them a grant — see [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/). @@ -36,6 +38,23 @@ To let people join your session without accounts of their own, keep hosting auth A `Promise` that resolves to a [`PuterPeerServer`](/Objects/puterpeerserver/) instance, which carries the `inviteCode` to share, the `connections` map of connected clients, and a `connection` event fired as each client joins. +Rejects with an `Error` whose `code` is `name_in_use` when `name` is currently being served by someone else, and with a `TypeError` when `name` is not a valid room name. + +## Room names + +A generated invite code (`NJ-7F3A9C`) is minted when you call `serve()` and stops working when the server goes away — good for a one-off session, useless for a link you want to share ahead of time or reuse. A room name is an address you pick, and it is the same every time you serve it: + +```js +const server = await puter.peer.serve({ name: 'friday-standup' }); +server.inviteCode; // 'friday-standup' +``` + +- Names are 3–64 characters of lowercase letters, digits and hyphens, not starting or ending with a hyphen. +- A name is held by whoever is serving it right now, first come, and is free again the moment that server stops. While it is held, `serve()` from **another** identity rejects with `name_in_use`. From the **same** identity — the same account, or the same `anonToken` — the newer server takes the name over and the older one fires `close` with reason `replaced`, so a host whose connection dropped can come straight back, and a user who opens the same room twice ends up with the newest tab serving it. +- A client that connects to a room nobody is serving gets a definite answer — an `error` event whose `code` is `no_host` — so a lobby can simply try again in a few seconds until the host arrives. + +A server stays reachable on its own: if its connection to the signaller drops, it re-registers under the same name (or, for a generated code, under a fresh one, announced by the `reconnect` event). Existing connections are never affected by this — they are peer-to-peer. + ## Example ```html diff --git a/src/puter-js/src/modules/Peer.js b/src/puter-js/src/modules/Peer.js index 92cbfe30c..927ef2996 100644 --- a/src/puter-js/src/modules/Peer.js +++ b/src/puter-js/src/modules/Peer.js @@ -12,6 +12,14 @@ import { PuterModule } from '../lib/PuterModule.js'; * guest for the duration of the session and skips the sign-in prompt. * @property {string} [turnGrant] A grant from `puter.peer.createGuestGrant()`, letting a guest with * no session use the Puter-managed relays on the granting account's allowance. + * @property {string} [name] `serve()` only: serve under a room name of your choosing instead of a + * generated invite code. Clients reach the server with `connect(name)`. Lowercase letters, digits + * and hyphens, 3–64 characters. The name is yours while you serve it, and free again once you stop; + * serving a name that is currently held fails with `name_in_use` — unless the holder is you, in + * which case the newer server takes over and the older one is closed. + * @property {string} [guestGrant] `serve()` only: a grant from `puter.peer.createGuestGrant()` + * handed to every guest that connects without a `turnGrant` of its own, so they reach the relays + * without you having to deliver the grant some other way. Renew it with `server.setGuestGrant()`. */ /** @@ -26,6 +34,72 @@ import { PuterModule } from '../lib/PuterModule.js'; /** @typedef {RTCSessionDescription | RTCSessionDescriptionInit} PuterPeerDescription */ /** @typedef {RTCIceCandidate | RTCIceCandidateInit} PuterPeerIceCandidate */ +/** + * Room names the signaller accepts: lowercase letters, digits and hyphens, + * 3–64 characters, no leading or trailing hyphen. + */ +const ROOM_NAME_RE = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/; + +/** + * The shape of a generated invite code (`NJ-7F3A9C`): up to four characters + * of the username, a dash, six hex digits. Uppercase throughout, so a room + * name — lowercase by rule — can never be mistaken for one. + */ +const INVITE_CODE_RE = /^[A-Z0-9]{0,4}-[0-9A-F]{6}$/; + +/** + * Whether a string is a room name (as opposed to a generated invite code). + * + * @param {string} value + * @returns {boolean} + */ +export function isRoomName (value) { + return typeof value === 'string' && ROOM_NAME_RE.test(value) && !INVITE_CODE_RE.test(value); +} + +/** Signaller keepalive: the signaller answers `{"ping":1}` with `{"pong":1}`. */ +const PING = '{"ping":1}'; +const PING_INTERVAL_MS = 30_000; + +/** The signaller closes a server with this code when a newer one took its name over. */ +const CLOSE_REPLACED = 4001; + +/** Reconnect backoff for a server whose signaller socket died. */ +const RECONNECT_BASE_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; +/** How many times to retry a name someone else appears to be holding before giving up. */ +const NAME_IN_USE_ATTEMPTS = 6; + +const CREATE_TIMEOUT_MS = 15_000; + +/** + * The signaller URL to open for a room, or the plain one for invite codes. + * + * @param {string} signallerUrl + * @param {string | undefined} room + * @returns {string} + */ +function signallerUrlFor (signallerUrl, room) { + if ( ! room ) return signallerUrl; + const url = new URL(signallerUrl); + url.searchParams.set('room', room); + return url.toString(); +} + +/** + * An error carrying the signaller's machine-readable code (`no_host`, + * `name_in_use`, `invalid_invite`, …) next to its message. + * + * @param {string} message + * @param {string} [code] + * @returns {Error & { code?: string }} + */ +function signallerError (message, code) { + const error = /** @type {Error & { code?: string }} */ (new Error(message)); + if ( code ) error.code = code; + return error; +} + /** * Dispatched by `PuterPeerServer` for the `'connection'` event when a client * connects. @@ -56,6 +130,50 @@ export class PuterPeerServerConnectionEvent extends Event { } } +/** + * Dispatched by `PuterPeerServer` for the `'reconnect'` event once it has + * re-registered with the signaller after losing its socket. Existing + * connections are unaffected; this only concerns clients yet to connect. + */ +export class PuterPeerServerReconnectEvent extends Event { + /** + * The invite code in force now. Unchanged for a server with a `name`; a + * server on a generated code gets a new one, since the old one died with + * the socket. + * + * @type {string} + */ + inviteCode; + + /** @param {string} inviteCode */ + constructor (inviteCode) { + super('reconnect'); + this.inviteCode = inviteCode; + } +} + +/** + * Dispatched by `PuterPeerServer` for the `'close'` event when the server has + * stopped accepting clients for good without `close()` having been called: + * its name was taken over by a newer server of yours (`replaced`), or is + * held by someone else and could not be reclaimed (`name_in_use`). Existing + * connections stay open; the invite code no longer works. + */ +export class PuterPeerServerCloseEvent extends Event { + /** + * Why the server stopped: `'replaced'` or `'name_in_use'`. + * + * @type {string} + */ + reason; + + /** @param {string} reason */ + constructor (reason) { + super('close'); + this.reason = reason; + } +} + /** * Dispatched by `PuterPeerConnection` for the `'message'` event when a message * is received. @@ -109,10 +227,16 @@ export class PuterPeerConnectionCloseEvent extends Event { * error occurs. */ export class PuterPeerConnectionErrorEvent extends Event { - /** @type {string} */ + /** + * The error. When the signaller refused the connection this is an `Error` + * whose `code` names why: `no_host` (a room nobody is serving right now), + * `invalid_invite` (an invite code that is not live), `invalid_auth`. + * + * @type {Error & { code?: string } | string} + */ error; - /** @param {string} error */ + /** @param {Error & { code?: string } | string} error */ constructor (error) { super('error'); this.error = error; @@ -120,23 +244,33 @@ export class PuterPeerConnectionErrorEvent extends Event { } export class PuterPeerServer extends EventTarget { - #wsconn; - #oncreateresolve; + #wsconn = null; + #oncreateresolve = null; + #peerConfig; + /** @type {PuterPeerOptions} */ + #options = {}; + /** True once the first registration has succeeded. */ + #registered = false; + /** True once the server is done for good — close() called, or given up. */ + #closed = false; + #reconnectTimer = null; + #reconnectAttempts = 0; + #nameInUseAttempts = 0; + #pingTimer = null; connections = new Map(); /** - * The invite code to share with other clients so they can connect. + * The invite code to share with other clients so they can connect. For a + * server started with a `name`, this is the name. * * @type {string | undefined} */ inviteCode; - #peerConfig; constructor (peerConfig) { super(); this.#peerConfig = peerConfig; - this.#wsconn = new WebSocket(peerConfig.signallerUrl); } /** @@ -147,61 +281,202 @@ export class PuterPeerServer extends EventTarget { * @param {PuterPeerOptions} [options] * @returns {Promise} */ - async start(options = {}) { + async start (options = {}) { + this.#options = options; + const inviteCode = await this.#register(); + this.#registered = true; + return inviteCode; + } + + /** + * Replaces the guest grant handed to clients that connect from now on. + * Pass a fresh grant before the previous one expires so guests joining + * hours into a session still get relays. + * + * @param {string | null} grant + * @returns {void} + */ + setGuestGrant (grant) { + this.#options = { ...this.#options, guestGrant: grant || undefined }; + if ( this.#registered && this.#wsconn?.readyState === 1 ) { + this.#wsconn.send(JSON.stringify({ server: { grant: { grant: grant || null } } })); + } + } + + /** + * Open a socket to the signaller and register; resolves to the invite + * code. Used for the first registration and for every reconnect. + * + * @returns {Promise} + */ + async #register () { + const ws = new WebSocket(signallerUrlFor(this.#peerConfig.signallerUrl, this.#options.name)); + this.#wsconn = ws; await new Promise((resolve, reject) => { - this.#wsconn.onopen = resolve; - this.#wsconn.onerror = reject; - this.#wsconn.onclose = () => { + ws.onopen = resolve; + ws.onerror = () => reject(new Error('Could not reach the signaller')); + ws.onclose = () => { reject(new Error('Connection closed unexpectedly')); }; }); - this.#wsconn.onmessage = (event) => { - let data = JSON.parse(event.data); + ws.onmessage = (event) => { + let data; + try { + data = JSON.parse(event.data); + } catch { + return; // keepalive replies and anything else that isn't ours + } return this.#message(data); }; - this.#wsconn.onclose = () => { - // what should we do here? + ws.onclose = (event) => { + if ( this.#wsconn !== ws ) return; // a socket we already replaced + this.#onSignallerLost(event); }; + ws.onerror = null; - this.#wsconn.send( + ws.send( JSON.stringify({ server: { create: { authToken: this.#peerConfig.authToken, - anonToken: options.anonToken, - port: options.port, + anonToken: this.#options.anonToken, + port: this.#options.port, + name: this.#options.name, + grant: this.#options.guestGrant, }, }, }), ); - const { inviteCode } = await new Promise((resolve, reject) => { - this.#oncreateresolve = (data) => { - if ( data.success ) { - resolve({ - inviteCode: data.invitecode, - }); + const inviteCode = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => { this.#oncreateresolve = null; - this.inviteCode = data.invitecode; + reject(new Error('Server creation timed out')); + }, + CREATE_TIMEOUT_MS, + ); + this.#oncreateresolve = (data) => { + clearTimeout(timer); + this.#oncreateresolve = null; + if ( data.success ) { + // A server on a port has no code; keep whatever it had. + resolve(data.invitecode ?? this.inviteCode); } else { - reject(new Error(data.error)); + reject(signallerError(data.error, data.code)); } }; - setTimeout( - () => reject(new Error('Server creation timed out')), - 15000, - ); + }).catch((error) => { + // A failed registration leaves nothing to reconnect. + ws.onclose = null; + try { + ws.close(); + } catch { + /* noop */ + } + if ( this.#wsconn === ws ) this.#wsconn = null; + throw error; }); + this.inviteCode = inviteCode; + this.#startPing(ws); return inviteCode; } + #startPing (ws) { + this.#stopPing(); + this.#pingTimer = setInterval(() => { + if ( ws.readyState === 1 ) { + try { + ws.send(PING); + } catch { + /* the close handler takes it from here */ + } + } + }, PING_INTERVAL_MS); + } + + #stopPing () { + if ( this.#pingTimer ) { + clearInterval(this.#pingTimer); + this.#pingTimer = null; + } + } + + /** + * The signaller socket closed under a registered server. Existing + * connections are WebRTC and unaffected; what is lost is the ability to + * accept new ones — so get it back, unless we were told not to. + * + * @param {CloseEvent} event + */ + #onSignallerLost (event) { + this.#stopPing(); + this.#wsconn = null; + if ( this.#closed || ! this.#registered ) return; + if ( event?.code === CLOSE_REPLACED ) { + // A newer server of ours holds the name now. Retrying would only + // take it back from that one, and so on forever. + this.#giveUp('replaced'); + return; + } + this.#scheduleReconnect(); + } + + #scheduleReconnect () { + if ( this.#closed || this.#reconnectTimer ) return; + const attempt = this.#reconnectAttempts++; + const backoff = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt); + const delay = backoff / 2 + Math.random() * (backoff / 2); + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = null; + this.#reconnect(); + }, delay); + } + + async #reconnect () { + if ( this.#closed ) return; + let inviteCode; + try { + inviteCode = await this.#register(); + } catch (error) { + if ( this.#closed ) return; + if ( error?.code === 'name_in_use' ) { + // Someone else is serving our name. Briefly, that may be our + // own dead socket the signaller hasn't noticed yet — which + // the same identity would take over — so a few tries are + // worth it; past that, the name is genuinely theirs. + if ( ++this.#nameInUseAttempts >= NAME_IN_USE_ATTEMPTS ) { + this.#giveUp('name_in_use'); + return; + } + } + this.#scheduleReconnect(); + return; + } + this.#reconnectAttempts = 0; + this.#nameInUseAttempts = 0; + this.dispatchEvent(new PuterPeerServerReconnectEvent(inviteCode)); + } + + /** Stop for good without touching live connections; tell the app why. */ + #giveUp (reason) { + if ( this.#closed ) return; + this.#closed = true; + this.#stopPing(); + if ( this.#reconnectTimer ) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } + this.dispatchEvent(new PuterPeerServerCloseEvent(reason)); + } + async #message (data) { - if ( ! data.server ) return; + if ( ! data || ! data.server ) return; if ( data.server.create ) { - this.#oncreateresolve(data.server.create); + this.#oncreateresolve?.(data.server.create); return; } @@ -209,9 +484,10 @@ export class PuterPeerServer extends EventTarget { let uuid = data.server.connect.id; let connection = new PuterPeerConnection(this.#peerConfig); this.connections.set(uuid, connection); + const ws = this.#wsconn; connection.peerconnection.onicecandidate = (e) => { - if ( e.candidate ) { - this.#wsconn.send( + if ( e.candidate && ws?.readyState === 1 ) { + ws.send( JSON.stringify({ server: { candidate: { @@ -251,16 +527,18 @@ export class PuterPeerServer extends EventTarget { new RTCSessionDescription(data.server.offer.offer), ); const answer = await connection.createAnswer(); - this.#wsconn.send( - JSON.stringify({ - server: { - answer: { - id: uuid, - answer, + if ( this.#wsconn?.readyState === 1 ) { + this.#wsconn.send( + JSON.stringify({ + server: { + answer: { + id: uuid, + answer, + }, }, - }, - }), - ); + }), + ); + } } } @@ -270,11 +548,24 @@ export class PuterPeerServer extends EventTarget { * @returns {void} */ close () { + this.#closed = true; + this.#stopPing(); + if ( this.#reconnectTimer ) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } for ( const [uuid, connection] of this.connections ) { connection.close(); } - this.#wsconn.onclose = null; - this.#wsconn.close(); + if ( this.#wsconn ) { + this.#wsconn.onclose = null; + try { + this.#wsconn.close(); + } catch { + /* noop */ + } + this.#wsconn = null; + } } } @@ -292,6 +583,14 @@ export class PuterPeerConnection extends EventTarget { * @type {PuterPeerUser | undefined} */ owner; + + /** + * The room name this connection was made in, when the server was reached + * by name. + * + * @type {string | undefined} + */ + room; #peerConfig; #datachannel; connected = false; @@ -301,7 +600,7 @@ export class PuterPeerConnection extends EventTarget { super(); this.#peerConfig = peerConfig; this.peerconnection = new RTCPeerConnection({ - iceTransportPolicy: peerConfig.forceRelay ? "relay" : "all", + iceTransportPolicy: peerConfig.forceRelay ? 'relay' : 'all', iceServers: peerConfig.iceServers, }); this.#datachannel = this.peerconnection.createDataChannel('channel-1', { negotiated: true, id: 2 }); @@ -334,15 +633,19 @@ export class PuterPeerConnection extends EventTarget { } /** - * Connects to the server that issued `invitecode`, resolving once the - * offer has been exchanged. `puter.peer.connect()` calls this. + * Connects to the server that issued `invitecode` — or serves the room of + * that name — resolving once the offer has been exchanged. + * `puter.peer.connect()` calls this. * * @param {string} invitecode * @param {PuterPeerOptions} [options] * @returns {Promise} */ - async connect(invitecode, options = {}) { - this.#wsconn = new WebSocket(this.#peerConfig.signallerUrl); + async connect (invitecode, options = {}) { + // A room name is dialled on the room's own signaller connection; an + // invite code (or a loopback port) on the shared one. + const room = ! options.port && isRoomName(invitecode) ? invitecode : undefined; + this.#wsconn = new WebSocket(signallerUrlFor(this.#peerConfig.signallerUrl, room)); await new Promise((resolve, reject) => { this.#wsconn.onopen = resolve; this.#wsconn.onerror = reject; @@ -371,6 +674,7 @@ export class PuterPeerConnection extends EventTarget { ); this.peerconnection.onicecandidate = (evt) => { + if ( this.#wsconn?.readyState !== 1 ) return; this.#wsconn.send( JSON.stringify({ client: { @@ -383,7 +687,12 @@ export class PuterPeerConnection extends EventTarget { }; this.#wsconn.onmessage = async (evt) => { - let msg = JSON.parse(evt.data).client; + let msg; + try { + msg = JSON.parse(evt.data).client; + } catch { + return; // keepalive replies and anything else that isn't ours + } if ( ! msg ) return; if ( msg.answer ) { this.setRemoteDescription(msg.answer.answer); @@ -394,7 +703,11 @@ export class PuterPeerConnection extends EventTarget { if ( msg.connect ) { if ( msg.connect.success ) { this.owner = msg.connect.owner; + this.room = msg.connect.room; + await this.#adoptRelayedGrant(msg.connect.grant, options); + if ( this.closed ) return; const offer = await this.createOffer(); + if ( this.#wsconn?.readyState !== 1 ) return; this.#wsconn.send( JSON.stringify({ client: { @@ -405,7 +718,7 @@ export class PuterPeerConnection extends EventTarget { }), ); } else { - this.#doclose(undefined, new Error(msg.connect.error)); + this.#doclose(undefined, signallerError(msg.connect.error, msg.connect.code)); } } if ( msg.disconnect && !this.connected ) { @@ -414,6 +727,29 @@ export class PuterPeerConnection extends EventTarget { }; } + /** + * The server left a guest grant with the signaller. A guest with no + * relays of its own — no session, no grant, no ICE servers of its own — + * redeems it now, before the offer, so its candidates include relays. + * + * @param {string | undefined} grant + * @param {PuterPeerOptions} options + */ + async #adoptRelayedGrant (grant, options) { + if ( ! grant || ! options.anonToken || options.turnGrant || options.iceServers ) return; + if ( typeof this.#peerConfig.iceServersFor !== 'function' ) return; + try { + const iceServers = await this.#peerConfig.iceServersFor({ turnGrant: grant }); + if ( this.closed || ! iceServers ) return; + this.peerconnection.setConfiguration({ + iceTransportPolicy: this.#peerConfig.forceRelay ? 'relay' : 'all', + iceServers, + }); + } catch (error) { + console.warn('Unable to use the host’s relays. Some connections may fail.', error); + } + } + #doclose (reason, error) { if ( this.closed ) return; this.closed = true; @@ -507,6 +843,10 @@ export class PuterPeerConnection extends EventTarget { * account by passing `anonToken`, and reach the Puter-managed relays with a * `turnGrant` the host issued via `createGuestGrant()` — relay usage is * charged to the host that issued it. + * + * A server is reached either by the invite code it was handed, good for as + * long as it serves, or by a room name it chose (`serve({ name })`), which + * anyone can dial as `connect(name)` for as long as someone serves it. */ export class PeerModule extends PuterModule { #signallerUrl; @@ -622,36 +962,50 @@ export class PeerModule extends PuterModule { } } + /** + * The ICE servers a connection should use: the caller's own, else the + * Puter-managed relays (on a grant where one is given), else the fallback. + * + * @param {PuterPeerOptions} [options] + * @returns {Promise} + */ + async #iceServersFor (options) { + if ( options?.iceServers ) return options.iceServers; + await this.ensureTurnRelays(options ?? {}); + if ( this.#turnServers ) return this.#turnServers; + console.warn('Unable to use TURN relays. Some connections may fail.'); + return this.#fallbackIceServers; + } + async #resolvePeerConfig (options) { await this.#loadMetadata(); - let iceServers; - if ( options?.iceServers ) { - iceServers = options.iceServers; - } else { - await this.ensureTurnRelays(options); - if ( this.#turnServers ) { - iceServers = this.#turnServers; - } else { - iceServers = this.#fallbackIceServers; - console.warn('Unable to use TURN relays. Some connections may fail.'); - } - } + const iceServers = await this.#iceServersFor(options); return { authToken: this.authToken, iceServers, signallerUrl: this.#signallerUrl, - forceRelay: options?.forceRelay + forceRelay: options?.forceRelay, + // Lets a guest connection redeem a grant the server left with the + // signaller, once it learns of it. + iceServersFor: (opts) => this.#iceServersFor(opts), }; } /** * Creates a peer server and starts it, resolving to the server once it has * an invite code. Requires authentication, unless `anonToken` is supplied. * + * With `name`, the server is reached by that room name instead of a + * generated code — the same name every time it serves, so the address can + * be shared ahead of time and reused. + * * @param {PuterPeerOptions} [options] * @returns {Promise} */ async serve (options) { + if ( options?.name !== undefined && ! isRoomName(options.name) ) { + throw new TypeError('Room names are 3–64 lowercase letters, digits and hyphens, not starting or ending with a hyphen.'); + } if ( !options?.anonToken ) await this.#authenticateForPeerAction('create a server'); const peerConfig = await this.#resolvePeerConfig(options); const server = new PuterPeerServer(peerConfig); @@ -660,10 +1014,12 @@ export class PeerModule extends PuterModule { } /** - * Connects to a peer server using an invite code from `serve()`, resolving - * once the offer has been exchanged. Requires authentication, unless - * `anonToken` is supplied to join without a session — pair it with a - * `turnGrant` from the host so the connection can still use relays. + * Connects to a peer server — by the invite code from `serve()`, or by + * the room name it serves under — resolving once the offer has been + * exchanged. Requires authentication, unless `anonToken` is supplied to + * join without a session; pair it with a `turnGrant` from the host so the + * connection can still use relays, or leave that to the host, whose + * `guestGrant` reaches the guest through the signaller. * * @param {string} invitecode * @param {PuterPeerOptions} [options] diff --git a/src/puter-js/src/modules/Peer.rooms.test.js b/src/puter-js/src/modules/Peer.rooms.test.js new file mode 100644 index 000000000..d819ce840 --- /dev/null +++ b/src/puter-js/src/modules/Peer.rooms.test.js @@ -0,0 +1,399 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PuterPeerConnection, PuterPeerServer, isRoomName } from './Peer.js'; + +/* + * Room names and the signaller-side plumbing around them: a server that + * claims a name, dials on the room's own signaller connection, hands its + * guest grant over, comes back after losing its socket, and stands down when + * a newer server of its own took the name; a connection that dials a name + * the same way and redeems the grant the server left. + */ + +class FakeWebSocket { + static instances = []; + static get latest () { + return FakeWebSocket.instances.at(-1) ?? null; + } + sent = []; + onopen = null; + onmessage = null; + onerror = null; + onclose = null; + readyState = 1; + closedBy = null; + + constructor (url) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + send (data) { + this.sent.push(data); + } + + close (code, reason) { + this.readyState = 3; + this.closedBy = { code, reason }; + } + + /** Messages sent so far, parsed. */ + get messages () { + return this.sent.map((raw) => { + try { + return JSON.parse(raw); + } catch { + return raw; + } + }); + } + + async deliver (message) { + await this.onmessage({ data: JSON.stringify(message) }); + } +} + +class FakeRTCPeerConnection { + static instances = []; + constructor (config) { + this.config = config; + this.configurations = []; + this.onicecandidate = null; + FakeRTCPeerConnection.instances.push(this); + } + createDataChannel () { + return { onmessage: null, onopen: null, onclose: null, onerror: null, close () {}, send () {} }; + } + setConfiguration (config) { + this.configurations.push(config); + } + async createOffer () { + return { type: 'offer', sdp: 'v=0' }; + } + async setLocalDescription () {} + async setRemoteDescription () {} + async addIceCandidate () {} + close () {} +} + +const origWebSocket = globalThis.WebSocket; +const origRTC = globalThis.RTCPeerConnection; + +beforeEach(() => { + FakeWebSocket.instances = []; + FakeRTCPeerConnection.instances = []; + globalThis.WebSocket = FakeWebSocket; + globalThis.RTCPeerConnection = FakeRTCPeerConnection; +}); + +afterEach(() => { + globalThis.WebSocket = origWebSocket; + globalThis.RTCPeerConnection = origRTC; + vi.useRealTimers(); +}); + +const flush = async (n = 4) => { + for ( let i = 0; i < n; i++ ) await Promise.resolve(); +}; + +const SIGNALLER = 'wss://signaller.test/'; + +/** Open the latest fake socket and let the code under test install its handlers. */ +const open = async () => { + FakeWebSocket.latest.onopen(); + await flush(); + return FakeWebSocket.latest; +}; + +/** start() a server and answer its create; resolves to [server, ws]. */ +const startServer = async (options = {}, reply = { success: true, invitecode: options.name ?? 'AB-123456' }) => { + const server = new PuterPeerServer({ signallerUrl: SIGNALLER, authToken: 'token' }); + const started = server.start(options); + const ws = await open(); + await ws.deliver({ server: { create: reply } }); + await started; + return [server, ws]; +}; + +describe('isRoomName', () => { + it('accepts lowercase names of letters, digits and hyphens', () => { + expect(isRoomName('abc-defg-hij')).toBe(true); + expect(isRoomName('room1')).toBe(true); + expect(isRoomName('a1-')).toBe(false); + expect(isRoomName('-ab')).toBe(false); + expect(isRoomName('ab')).toBe(false); + expect(isRoomName('a'.repeat(65))).toBe(false); + expect(isRoomName('Has-Caps')).toBe(false); + expect(isRoomName('has space')).toBe(false); + expect(isRoomName('')).toBe(false); + expect(isRoomName(null)).toBe(false); + }); + + it('never mistakes a generated invite code for a name', () => { + expect(isRoomName('NJ-7F3A9C')).toBe(false); + expect(isRoomName('-7F3A9C')).toBe(false); + // All-digit codes are the one shape both grammars admit; codes win. + expect(isRoomName('1234-123456')).toBe(false); + }); +}); + +describe('PuterPeerServer with a room name', () => { + it('dials the room connection, asks for the name, and takes it as the invite code', async () => { + const [server, ws] = await startServer({ name: 'abc-defg-hij', guestGrant: 'pg1.grant', anonToken: 'u-1' }); + expect(new URL(ws.url).searchParams.get('room')).toBe('abc-defg-hij'); + const create = ws.messages[0].server.create; + expect(create.name).toBe('abc-defg-hij'); + expect(create.grant).toBe('pg1.grant'); + expect(create.anonToken).toBe('u-1'); + expect(server.inviteCode).toBe('abc-defg-hij'); + }); + + it('dials the plain connection for a generated code', async () => { + const [server, ws] = await startServer(); + expect(new URL(ws.url).searchParams.has('room')).toBe(false); + expect(ws.messages[0].server.create.name).toBeUndefined(); + expect(server.inviteCode).toBe('AB-123456'); + }); + + it('rejects start() with the signaller’s code when the name is held', async () => { + const server = new PuterPeerServer({ signallerUrl: SIGNALLER, authToken: 'token' }); + const started = server.start({ name: 'abc-defg-hij' }); + const ws = await open(); + await ws.deliver({ server: { create: { success: false, error: 'Name in use', code: 'name_in_use' } } }); + await expect(started).rejects.toMatchObject({ message: 'Name in use', code: 'name_in_use' }); + expect(ws.closedBy).not.toBeNull(); + // A registration that never succeeded has nothing to reconnect. + expect(FakeWebSocket.instances).toHaveLength(1); + }); + + it('sends a renewed guest grant to the signaller', async () => { + const [server, ws] = await startServer({ name: 'abc-defg-hij', guestGrant: 'pg1.one' }); + server.setGuestGrant('pg1.two'); + expect(ws.messages.at(-1)).toEqual({ server: { grant: { grant: 'pg1.two' } } }); + server.setGuestGrant(null); + expect(ws.messages.at(-1)).toEqual({ server: { grant: { grant: null } } }); + }); + + it('keeps the socket warm with pings', async () => { + vi.useFakeTimers(); + const [, ws] = await startServer({ name: 'abc-defg-hij' }); + const before = ws.sent.length; + vi.advanceTimersByTime(30_000); + expect(ws.sent.slice(before)).toEqual(['{"ping":1}']); + // The reply is not a protocol message; it must be ignored, not thrown on. + await expect(ws.onmessage({ data: '{"pong":1}' })).resolves.toBeUndefined(); + }); +}); + +describe('PuterPeerServer losing its signaller socket', () => { + it('re-registers under the same name and says so', async () => { + vi.useFakeTimers(); + const [server, ws] = await startServer({ name: 'abc-defg-hij', guestGrant: 'pg1.grant' }); + const events = []; + server.addEventListener('reconnect', (e) => events.push(['reconnect', e.inviteCode])); + server.addEventListener('close', (e) => events.push(['close', e.reason])); + + ws.onclose({ code: 1006 }); + expect(FakeWebSocket.instances).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1_000); + expect(FakeWebSocket.instances).toHaveLength(2); + const ws2 = await open(); + expect(new URL(ws2.url).searchParams.get('room')).toBe('abc-defg-hij'); + const create = ws2.messages[0].server.create; + expect(create.name).toBe('abc-defg-hij'); + expect(create.grant).toBe('pg1.grant'); + await ws2.deliver({ server: { create: { success: true, invitecode: 'abc-defg-hij' } } }); + await flush(); + expect(events).toEqual([['reconnect', 'abc-defg-hij']]); + expect(server.inviteCode).toBe('abc-defg-hij'); + }); + + it('carries the renewed grant, not the original, into the re-registration', async () => { + vi.useFakeTimers(); + const [server, ws] = await startServer({ name: 'abc-defg-hij', guestGrant: 'pg1.one' }); + server.setGuestGrant('pg1.two'); + ws.onclose({ code: 1006 }); + await vi.advanceTimersByTimeAsync(1_000); + const ws2 = await open(); + expect(ws2.messages[0].server.create.grant).toBe('pg1.two'); + }); + + it('takes the new code a re-registration hands a codeless server', async () => { + vi.useFakeTimers(); + const [server, ws] = await startServer(); + const codes = []; + server.addEventListener('reconnect', (e) => codes.push(e.inviteCode)); + ws.onclose({ code: 1006 }); + await vi.advanceTimersByTimeAsync(1_000); + const ws2 = await open(); + await ws2.deliver({ server: { create: { success: true, invitecode: 'AB-654321' } } }); + await flush(); + expect(codes).toEqual(['AB-654321']); + expect(server.inviteCode).toBe('AB-654321'); + }); + + it('backs off and keeps trying while the signaller is unreachable', async () => { + vi.useFakeTimers(); + const [, ws] = await startServer({ name: 'abc-defg-hij' }); + ws.onclose({ code: 1006 }); + await vi.advanceTimersByTimeAsync(1_000); + expect(FakeWebSocket.instances).toHaveLength(2); + // The attempt fails outright: no reconnect for a while, then another. + FakeWebSocket.latest.onclose({ code: 1006 }); + await flush(); + await vi.advanceTimersByTimeAsync(900); + expect(FakeWebSocket.instances).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1_200); + expect(FakeWebSocket.instances).toHaveLength(3); + }); + + it('stands down without retrying when a newer server of ours took the name', async () => { + vi.useFakeTimers(); + const [server, ws] = await startServer({ name: 'abc-defg-hij' }); + const events = []; + server.addEventListener('close', (e) => events.push(e.reason)); + ws.onclose({ code: 4001, reason: 'Replaced by a newer server for this room' }); + await vi.advanceTimersByTimeAsync(60_000); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(events).toEqual(['replaced']); + }); + + it('gives the name up after someone else keeps holding it', async () => { + vi.useFakeTimers(); + const [server, ws] = await startServer({ name: 'abc-defg-hij' }); + const events = []; + server.addEventListener('close', (e) => events.push(e.reason)); + ws.onclose({ code: 1006 }); + for ( let attempt = 0; attempt < 6; attempt++ ) { + await vi.advanceTimersByTimeAsync(30_000); + const next = await open(); + await next.deliver({ server: { create: { success: false, error: 'Name in use', code: 'name_in_use' } } }); + await flush(); + } + expect(events).toEqual(['name_in_use']); + const sockets = FakeWebSocket.instances.length; + await vi.advanceTimersByTimeAsync(60_000); + expect(FakeWebSocket.instances).toHaveLength(sockets); + }); + + it('does not reconnect after close()', async () => { + vi.useFakeTimers(); + const [server, ws] = await startServer({ name: 'abc-defg-hij' }); + server.close(); + expect(ws.closedBy).not.toBeNull(); + ws.onclose?.({ code: 1000 }); + await vi.advanceTimersByTimeAsync(60_000); + expect(FakeWebSocket.instances).toHaveLength(1); + }); +}); + +describe('PuterPeerConnection dialling', () => { + const config = (extra = {}) => ({ + signallerUrl: SIGNALLER, + authToken: undefined, + iceServers: [{ urls: 'stun:fallback' }], + ...extra, + }); + + it('dials a room name on the room connection', async () => { + const conn = new PuterPeerConnection(config()); + const connecting = conn.connect('abc-defg-hij', { anonToken: 'u-1' }); + const ws = await open(); + await connecting; + expect(new URL(ws.url).searchParams.get('room')).toBe('abc-defg-hij'); + expect(ws.messages[0].client.connect).toMatchObject({ anonToken: 'u-1', invitecode: 'abc-defg-hij' }); + }); + + it('dials an invite code on the plain connection', async () => { + const conn = new PuterPeerConnection(config()); + const connecting = conn.connect('NJ-7F3A9C', { anonToken: 'u-1' }); + const ws = await open(); + await connecting; + expect(new URL(ws.url).searchParams.has('room')).toBe(false); + }); + + it('redeems the grant the server left before making its offer', async () => { + const relays = [{ urls: 'turn:relay.test', username: 'u', credential: 'c' }]; + const iceServersFor = vi.fn(async () => relays); + const conn = new PuterPeerConnection(config({ iceServersFor })); + const connecting = conn.connect('abc-defg-hij', { anonToken: 'u-1' }); + const ws = await open(); + await connecting; + await ws.deliver({ client: { connect: { success: true, owner: { username: 'host', uuid: 'h' }, grant: 'pg1.grant', room: 'abc-defg-hij' } } }); + await flush(8); + expect(iceServersFor).toHaveBeenCalledWith({ turnGrant: 'pg1.grant' }); + const pc = FakeRTCPeerConnection.instances.at(-1); + expect(pc.configurations).toEqual([{ iceTransportPolicy: 'all', iceServers: relays }]); + expect(ws.messages.at(-1).client.offer).toBeDefined(); + expect(conn.owner).toEqual({ username: 'host', uuid: 'h' }); + expect(conn.room).toBe('abc-defg-hij'); + }); + + it('leaves relays alone for a guest that brought its own grant', async () => { + const iceServersFor = vi.fn(async () => []); + const conn = new PuterPeerConnection(config({ iceServersFor })); + const connecting = conn.connect('abc-defg-hij', { anonToken: 'u-1', turnGrant: 'pg1.mine' }); + const ws = await open(); + await connecting; + await ws.deliver({ client: { connect: { success: true, owner: {}, grant: 'pg1.theirs' } } }); + await flush(8); + expect(iceServersFor).not.toHaveBeenCalled(); + expect(ws.messages.at(-1).client.offer).toBeDefined(); + }); + + it('leaves relays alone for a signed-in caller, who has its own', async () => { + const iceServersFor = vi.fn(async () => []); + const conn = new PuterPeerConnection(config({ iceServersFor, authToken: 'token' })); + const connecting = conn.connect('abc-defg-hij'); + const ws = await open(); + await connecting; + await ws.deliver({ client: { connect: { success: true, owner: {}, grant: 'pg1.theirs' } } }); + await flush(8); + expect(iceServersFor).not.toHaveBeenCalled(); + }); + + it('still offers when the grant cannot be redeemed', async () => { + const iceServersFor = vi.fn(async () => { + throw new Error('relay service down'); + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const conn = new PuterPeerConnection(config({ iceServersFor })); + const connecting = conn.connect('abc-defg-hij', { anonToken: 'u-1' }); + const ws = await open(); + await connecting; + await ws.deliver({ client: { connect: { success: true, owner: {}, grant: 'pg1.grant' } } }); + await flush(8); + expect(ws.messages.at(-1).client.offer).toBeDefined(); + warn.mockRestore(); + }); + + it('surfaces the signaller’s refusal with its code', async () => { + const conn = new PuterPeerConnection(config()); + const errors = []; + const closes = []; + conn.addEventListener('error', (e) => errors.push(e.error)); + conn.addEventListener('close', (e) => closes.push(e.reason)); + const connecting = conn.connect('abc-defg-hij', { anonToken: 'u-1' }); + const ws = await open(); + await connecting; + await ws.deliver({ client: { connect: { success: false, error: 'No host', code: 'no_host' } } }); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(Error); + expect(errors[0].message).toBe('No host'); + expect(errors[0].code).toBe('no_host'); + expect(closes).toHaveLength(1); + expect(conn.closed).toBe(true); + // The signaller closes the socket after refusing; that must not fire a second close. + ws.onclose?.({ code: 1000 }); + expect(closes).toHaveLength(1); + }); + + it('ignores keepalive replies on the way', async () => { + const conn = new PuterPeerConnection(config()); + const connecting = conn.connect('abc-defg-hij', { anonToken: 'u-1' }); + const ws = await open(); + await connecting; + await expect(ws.onmessage({ data: '{"pong":1}' })).resolves.toBeUndefined(); + await expect(ws.onmessage({ data: 'not json' })).resolves.toBeUndefined(); + expect(conn.closed).toBe(false); + }); +});