puter.peer: room names, relayed guest grants, servers that come back
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

A peer server could only be reached by the invite code the signaller
minted for it, which is unknowable ahead of time and dies with the
server's socket. Any app that wanted a link to share in advance or reuse
had to run a registry of its own that maps a stable code to whatever
invite is live — and keep it live, since the signaller socket can drop
without the library saying a word (`onclose` was an empty function),
leaving a host serving an invite nobody can dial.

`serve({ name })` serves under a room name of the caller's choosing;
`connect(name)` dials it. A name is held by whoever serves it and free
once they stop; serving a held name from another identity rejects with
`name_in_use`, from the same identity it takes over. A connection to a
room nobody serves fails with `no_host`, so a lobby has a definite answer
to poll on instead of guessing between "not yet" and "gone".

`serve({ guestGrant })` and `server.setGuestGrant()` leave a guest grant
with the signaller, which hands it to every anonymous guest in the connect
reply; the connection redeems it before making its offer, so a guest with
no session gets relays without the app publishing the grant anywhere.

A server whose signaller socket drops now re-registers on its own with
backoff — under the same name, or a fresh code announced by the new
`reconnect` event — and pings the socket to keep idle proxies from cutting
it. It fires `close` (`replaced`, `name_in_use`) when it has to stand
down for good. Existing connections are peer-to-peer and never affected.

Signaller refusals reach the connection's `error` event as an Error with
a `code` next to the message. Keepalive replies and stray frames no longer
throw in the message handlers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jelveh
2026-08-25 13:24:45 -07:00
co-authored by Claude Fable 5
parent 2a45a10854
commit e006b19773
8 changed files with 878 additions and 76 deletions
+5 -1
View File
@@ -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
+18 -2
View File
@@ -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
+3 -1
View File
@@ -10,10 +10,12 @@ Use the Peer API to build peer-to-peer applications without the need for a serve
<div class="info">
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/).
</div>
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
+8 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+19
View File
@@ -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 364 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
+425 -69
View File
@@ -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, 364 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,
* 364 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<string>}
*/
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<string>}
*/
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<void>}
*/
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 hosts 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<RTCIceServer[]>}
*/
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<PuterPeerServer>}
*/
async serve (options) {
if ( options?.name !== undefined && ! isRoomName(options.name) ) {
throw new TypeError('Room names are 364 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]
+399
View File
@@ -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 signallers 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 signallers 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);
});
});