feat: guest turn for peer (#3640)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-08-25 07:51:02 -07:00
1 parent 6f1548e26a
commit 3c866c645b
14 files changed
+1736 -28

No files matched your search

+78 -13
View File
@@ -8,7 +8,10 @@ import { PuterModule } from '../lib/PuterModule.js';
* @property {RTCIceServer[]} [iceServers] Custom ICE servers (STUN/TURN) to use instead of the
* Puter-managed relays.
* @property {boolean} [forceRelay] Route every candidate through a TURN relay.
* @property {string} [anonToken] Connect without a Puter session, using a token the server issued.
* @property {string} [anonToken] Take part without a Puter session. Any uuid; it identifies this
* 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.
*/
/**
@@ -498,7 +501,12 @@ export class PuterPeerConnection extends EventTarget {
/**
* The `puter.peer` API. Provides WebRTC data channels with built-in signaling
* and TURN relays for connecting clients directly without your own signaling
* server. Peer connections require authentication.
* server.
*
* Hosting a session requires authentication. Guests can join one without an
* 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.
*/
export class PeerModule extends PuterModule {
#signallerUrl;
@@ -507,6 +515,36 @@ export class PeerModule extends PuterModule {
#turnTTL;
#turnStartedAt;
#turnFailed;
#turnSource;
/**
* Creates a grant that lets guests without a Puter session use the
* Puter-managed relays. Requires authentication.
*
* Hand the grant to the people you invite — alongside the invite code —
* and they pass it to `connect()` as `turnGrant`. Their relay usage counts
* against this account, so treat the grant as something that spends your
* allowance: share it with the session you meant to host, and let it
* expire rather than reusing one indefinitely.
*
* @returns {Promise<{ grant: string, expiresAt: number }>} The grant, and
* when it stops being accepted (seconds since the epoch).
*/
async createGuestGrant () {
const response = await fetchUrl(`${this.APIOrigin}/peer/turn-grant`, {
method: 'POST',
includePuterAuth: true,
headers: {
'Content-Type': 'application/json',
},
});
if ( ! response.ok ) {
throw new Error('Failed to create a guest grant.');
}
return await response.json();
}
/**
* Fetches TURN relay credentials ahead of time so connections start
@@ -514,19 +552,44 @@ export class PeerModule extends PuterModule {
* it resolves either way: if relays can't be loaded, connecting falls back
* to the default ICE servers.
*
* With `turnGrant`, credentials are minted against the granting account
* instead of the caller's own session, which is how a guest gets relays
* without signing in.
*
* @param {Object} [options]
* @param {string} [options.turnGrant] A grant from `createGuestGrant()`.
* @returns {Promise<void>}
*/
async ensureTurnRelays () {
async ensureTurnRelays (options = {}) {
// Credentials are tied to whoever is paying for them, so a change of
// source invalidates both the cached servers and a previous failure —
// otherwise a guest who tried before holding a grant would be stuck
// with the fallback for the rest of the page's life.
const source = options.turnGrant ? `grant:${options.turnGrant}` : 'session';
if ( source !== this.#turnSource ) {
this.#turnSource = source;
this.#turnServers = undefined;
this.#turnFailed = false;
}
if ( this.#turnFailed ) return;
if ( this.#turnServers && Date.now() - this.#turnStartedAt < this.#turnTTL * 1000 ) return;
const response = await fetchUrl(`${this.APIOrigin}/peer/generate-turn`, {
method: 'POST',
includePuterAuth: true,
headers: {
'Content-Type': 'application/json',
},
});
const response = options.turnGrant
? await fetchUrl(`${this.APIOrigin}/peer/guest-turn`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ grant: options.turnGrant }),
})
: await fetchUrl(`${this.APIOrigin}/peer/generate-turn`, {
method: 'POST',
includePuterAuth: true,
headers: {
'Content-Type': 'application/json',
},
});
if ( ! response.ok ) {
this.#turnFailed = true;
@@ -565,7 +628,7 @@ export class PeerModule extends PuterModule {
if ( options?.iceServers ) {
iceServers = options.iceServers;
} else {
await this.ensureTurnRelays();
await this.ensureTurnRelays(options);
if ( this.#turnServers ) {
iceServers = this.#turnServers;
} else {
@@ -583,7 +646,7 @@ export class PeerModule extends PuterModule {
}
/**
* Creates a peer server and starts it, resolving to the server once it has
* an invite code. Requires authentication.
* an invite code. Requires authentication, unless `anonToken` is supplied.
*
* @param {PuterPeerOptions} [options]
* @returns {Promise<PuterPeerServer>}
@@ -598,7 +661,9 @@ 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.
* 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.
*
* @param {string} invitecode
* @param {PuterPeerOptions} [options]
+373
View File
@@ -0,0 +1,373 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Relay-credential plumbing for `puter.peer`: an authenticated caller mints
* its own, a guest redeems a host's grant. `fetchUrl` is the HTTP boundary, so
* that is what's stubbed; everything above it is the real module.
*/
const { fetchUrlMock } = vi.hoisted(() => ({ fetchUrlMock: vi.fn() }));
vi.mock('../lib/networkUtils.js', () => ({ fetchUrl: fetchUrlMock }));
const { PeerModule } = await import('./Peer.js');
const API_ORIGIN = 'https://api.test';
/** A `fetchUrl` response stub. */
const respond = (body, ok = true) => ({ ok, json: async () => body });
/** Routes stubbed responses by URL, so tests declare intent, not call order. */
const routeFetch = (routes) => {
fetchUrlMock.mockImplementation(async (url, opts) => {
for (const [fragment, responder] of Object.entries(routes)) {
if (url.includes(fragment)) {
return typeof responder === 'function'
? responder(opts)
: responder;
}
}
throw new Error(`unexpected request to ${url}`);
});
};
/** The options `fetchUrl` was called with for the first URL that matches. */
const callTo = (fragment) =>
fetchUrlMock.mock.calls.find(([url]) => url.includes(fragment));
const makePeer = ({ authToken = null, env = 'web' } = {}) => {
const puter = {
authToken,
APIOrigin: API_ORIGIN,
env,
ui: { authenticateWithPuter: vi.fn(async () => {}) },
};
return { peer: new PeerModule(puter), puter };
};
const HOST_SERVERS = [{ urls: 'turn:host.test' }];
const GUEST_SERVERS = [{ urls: 'turn:guest.test' }];
beforeEach(() => {
fetchUrlMock.mockReset();
});
describe('createGuestGrant', () => {
it('mints a grant against the caller session', async () => {
routeFetch({
'/peer/turn-grant': respond({
grant: 'pg1.payload.sig',
expiresAt: 1_700_000_900,
}),
});
const { peer } = makePeer({ authToken: 'host-token' });
await expect(peer.createGuestGrant()).resolves.toEqual({
grant: 'pg1.payload.sig',
expiresAt: 1_700_000_900,
});
const [url, opts] = callTo('/peer/turn-grant');
expect(url).toBe(`${API_ORIGIN}/peer/turn-grant`);
expect(opts.method).toBe('POST');
expect(opts.includePuterAuth).toBe(true);
});
it('throws when the grant is refused', async () => {
routeFetch({ '/peer/turn-grant': respond({}, false) });
const { peer } = makePeer({ authToken: 'host-token' });
await expect(peer.createGuestGrant()).rejects.toThrow(
'Failed to create a guest grant.',
);
});
});
describe('ensureTurnRelays', () => {
it('uses the authenticated endpoint when no grant is given', async () => {
routeFetch({
'/peer/generate-turn': respond({
iceServers: HOST_SERVERS,
ttl: 3600,
}),
});
const { peer } = makePeer({ authToken: 'host-token' });
await peer.ensureTurnRelays();
const [, opts] = callTo('/peer/generate-turn');
expect(opts.includePuterAuth).toBe(true);
expect(opts.body).toBeUndefined();
expect(callTo('/peer/guest-turn')).toBeUndefined();
});
it('redeems a grant at the guest endpoint, without sending a session', async () => {
routeFetch({
'/peer/guest-turn': respond({
iceServers: GUEST_SERVERS,
ttl: 600,
}),
});
const { peer } = makePeer();
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
const [url, opts] = callTo('/peer/guest-turn');
expect(url).toBe(`${API_ORIGIN}/peer/guest-turn`);
expect(opts.method).toBe('POST');
expect(opts.includePuterAuth).toBeUndefined();
expect(JSON.parse(opts.body)).toEqual({ grant: 'grant-1' });
expect(callTo('/peer/generate-turn')).toBeUndefined();
});
it('reuses credentials within their ttl', async () => {
routeFetch({
'/peer/guest-turn': respond({
iceServers: GUEST_SERVERS,
ttl: 600,
}),
});
const { peer } = makePeer();
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
expect(fetchUrlMock).toHaveBeenCalledTimes(1);
});
it('re-mints once the ttl has passed', async () => {
routeFetch({
'/peer/guest-turn': respond({
iceServers: GUEST_SERVERS,
ttl: 600,
}),
});
const { peer } = makePeer();
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000);
try {
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
now.mockReturnValue(1_000_000 + 601_000);
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
} finally {
now.mockRestore();
}
expect(fetchUrlMock).toHaveBeenCalledTimes(2);
});
it('does not throw when relays are unavailable', async () => {
routeFetch({ '/peer/guest-turn': respond({}, false) });
const { peer } = makePeer();
await expect(
peer.ensureTurnRelays({ turnGrant: 'grant-1' }),
).resolves.toBeUndefined();
});
it('stops asking after a failure for the same source', async () => {
routeFetch({ '/peer/guest-turn': respond({}, false) });
const { peer } = makePeer();
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
expect(fetchUrlMock).toHaveBeenCalledTimes(1);
});
it('retries once a grant arrives after an unauthenticated failure', async () => {
// The guest case: the first attempt has no session and no grant, so it
// fails; holding a grant has to be a fresh start, not a cached refusal.
routeFetch({
'/peer/generate-turn': respond({}, false),
'/peer/guest-turn': respond({
iceServers: GUEST_SERVERS,
ttl: 600,
}),
});
const { peer } = makePeer();
await peer.ensureTurnRelays();
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
expect(callTo('/peer/generate-turn')).toBeDefined();
expect(callTo('/peer/guest-turn')).toBeDefined();
});
it('re-mints when the grant changes', async () => {
routeFetch({
'/peer/guest-turn': respond({
iceServers: GUEST_SERVERS,
ttl: 600,
}),
});
const { peer } = makePeer();
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
await peer.ensureTurnRelays({ turnGrant: 'grant-2' });
expect(fetchUrlMock).toHaveBeenCalledTimes(2);
expect(
JSON.parse(fetchUrlMock.mock.calls.at(-1)[1].body),
).toEqual({ grant: 'grant-2' });
});
});
// -- Guest join, end to end through connect() --------------------------
class FakeWebSocket {
static latest = null;
sent = [];
onopen = null;
onmessage = null;
onerror = null;
onclose = null;
constructor () {
FakeWebSocket.latest = this;
// Open on the next tick, the way a real socket resolves the handshake
// after the caller has installed its handlers.
queueMicrotask(() => this.onopen?.());
}
send (data) {
this.sent.push(data);
}
close () {}
}
class FakeRTCPeerConnection {
static latest = null;
constructor (config) {
this.config = config;
FakeRTCPeerConnection.latest = this;
}
createDataChannel () {
return {
onmessage: null,
onopen: null,
onclose: null,
onerror: null,
send () {},
close () {},
};
}
async createOffer () {
return { type: 'offer', sdp: 'v=0' };
}
async setLocalDescription () {}
async setRemoteDescription () {}
async addIceCandidate () {}
close () {}
}
describe('connect as a guest', () => {
const origWebSocket = globalThis.WebSocket;
const origRTC = globalThis.RTCPeerConnection;
beforeEach(() => {
FakeWebSocket.latest = null;
FakeRTCPeerConnection.latest = null;
globalThis.WebSocket = FakeWebSocket;
globalThis.RTCPeerConnection = FakeRTCPeerConnection;
});
afterEach(() => {
globalThis.WebSocket = origWebSocket;
globalThis.RTCPeerConnection = origRTC;
});
const signallerInfo = respond({
url: 'ws://signaller.test/',
fallbackIce: [{ urls: 'stun:fallback.test' }],
});
it('joins with a grant and no session, on the granted relays', async () => {
routeFetch({
'/peer/signaller-info': signallerInfo,
'/peer/guest-turn': respond({
iceServers: GUEST_SERVERS,
ttl: 600,
}),
});
const { peer, puter } = makePeer();
await peer.connect('HOST-1234', {
anonToken: '11111111-2222-3333-4444-555555555555',
turnGrant: 'grant-1',
});
// No sign-in prompt, and the relays came from the host's grant.
expect(puter.ui.authenticateWithPuter).not.toHaveBeenCalled();
expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual(
GUEST_SERVERS,
);
const sent = JSON.parse(FakeWebSocket.latest.sent[0]);
expect(sent.client.connect).toMatchObject({
anonToken: '11111111-2222-3333-4444-555555555555',
invitecode: 'HOST-1234',
});
// Nothing to authenticate with; the anon token is the identity.
expect(sent.client.connect.authToken ?? null).toBeNull();
});
it('falls back to the public ICE servers when the grant is refused', async () => {
routeFetch({
'/peer/signaller-info': signallerInfo,
'/peer/guest-turn': respond({}, false),
});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { peer } = makePeer();
try {
await peer.connect('HOST-1234', {
anonToken: '11111111-2222-3333-4444-555555555555',
turnGrant: 'expired-grant',
});
} finally {
warn.mockRestore();
}
expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual([
{ urls: 'stun:fallback.test' },
]);
});
it('honors caller-supplied ICE servers without redeeming a grant', async () => {
routeFetch({ '/peer/signaller-info': signallerInfo });
const { peer } = makePeer();
await peer.connect('HOST-1234', {
anonToken: '11111111-2222-3333-4444-555555555555',
iceServers: [{ urls: 'turn:mine.test' }],
});
expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual([
{ urls: 'turn:mine.test' },
]);
expect(callTo('/peer/guest-turn')).toBeUndefined();
});
it('still mints against the session for an authenticated caller', async () => {
routeFetch({
'/peer/signaller-info': signallerInfo,
'/peer/generate-turn': respond({
iceServers: HOST_SERVERS,
ttl: 3600,
}),
});
const { peer } = makePeer({ authToken: 'user-token' });
await peer.connect('HOST-1234');
expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual(
HOST_SERVERS,
);
const sent = JSON.parse(FakeWebSocket.latest.sent[0]);
expect(sent.client.connect.authToken).toBe('user-token');
expect(callTo('/peer/guest-turn')).toBeUndefined();
});
});