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);
+ });
+});