mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-05 19:31:56 +00:00
feat: guest turn for peer (#3640)
This commit is contained in:
1 parent
6f1548e26a
commit
3c866c645b
14 files changed
+1736
-28
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user