From 8d38e07e989914e7bd436157a18ffc2b69a27205 Mon Sep 17 00:00:00 2001 From: velzie Date: Wed, 18 Mar 2026 02:08:44 -0400 Subject: [PATCH] feat: add puter.peer to sdk, create PeerService in backend (#2664) * feat: add puter.peer to sdk, create PeerService in backend * cloudflare turn, typedefs, fallback ice, bugfixes * minr tweak * restrict peerservice to api.puter.com, make customIndentifier more detailed --------- Co-authored-by: ProgrammerIn-wonderland <30693865+ProgrammerIn-wonderland@users.noreply.github.com> --- src/backend/src/CoreModule.js | 3 + src/backend/src/services/PeerService.js | 77 ++++ src/puter-js/index.d.ts | 4 + src/puter-js/src/index.js | 2 + src/puter-js/src/modules/Peer.js | 467 ++++++++++++++++++++++++ src/puter-js/types/modules/peer.d.ts | 98 +++++ src/puter-js/types/puter.d.ts | 2 + 7 files changed, 653 insertions(+) create mode 100644 src/backend/src/services/PeerService.js create mode 100644 src/puter-js/src/modules/Peer.js create mode 100644 src/puter-js/types/modules/peer.d.ts diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js index 8d017d4f1..26f89e136 100644 --- a/src/backend/src/CoreModule.js +++ b/src/backend/src/CoreModule.js @@ -381,6 +381,9 @@ const install = async ({ context, services, app, useapi, modapi }) => { const { PermissionShortcutService } = require('./services/auth/PermissionShortcutService'); services.registerService('permission-shortcut', PermissionShortcutService); + const { PeerService } = require('./services/PeerService'); + services.registerService('peer', PeerService); + }; const install_legacy = async ({ services }) => { diff --git a/src/backend/src/services/PeerService.js b/src/backend/src/services/PeerService.js new file mode 100644 index 000000000..84f799d5b --- /dev/null +++ b/src/backend/src/services/PeerService.js @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import configurable_auth from '../middleware/configurable_auth.js'; +import { Endpoint } from '../util/expressutil.js'; +import BaseService from './BaseService.js'; + +export class PeerService extends BaseService { + '__on_install.routes' (_, { app }) { + Endpoint({ + route: '/peer/signaller-info', + methods: ['GET'], + subdomain: "api", + handler: async (req, res) => { + res.json({ + url: this.config.signaller_url, + fallbackIce: this.config.fallback_ice, + }); + }, + }).attach(app); + + Endpoint({ + route: '/peer/generate-turn', + methods: ['POST'], + mw: [configurable_auth()], + subdomain: "api", + handler: async (req, res) => { + if ( ! this.config.cloudflare_turn ) { + res.status(500).send({ error: 'TURN is not configured' }); + return; + } + let response = await fetch( + `https://rtc.live.cloudflare.com/v1/turn/keys/${this.config.cloudflare_turn.turn_key_id}/credentials/generate-ice-servers`, + { + headers: { + Authorization: `Bearer ${this.config.cloudflare_turn.turn_key_api_token}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify({ + ttl: this.config.cloudflare_turn.ttl_ms, + customIdentifier: `${req.actor.type.user.uuid}:${req.actor.type?.app?.uid ?? 'global'}`, + }), + }, + ); + + if ( ! response.ok ) { + res.status(500).send({ error: 'Failed to generate TURN credentials' }); + return; + } + + const { iceServers } = await response.json(); + + res.json({ + ttl: this.config.cloudflare_turn.ttl_ms, + iceServers, + }); + }, + }).attach(app); + } +} diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index e56099523..81305a798 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -11,6 +11,7 @@ import type { KV, KVIncrementPath, KVPair } from './types/modules/kv.d.ts'; import type { Networking, PSocket, PTLSSocket } from './types/modules/networking.d.ts'; import type { OS } from './types/modules/os.d.ts'; import type { Perms } from './types/modules/perms.d.ts'; +import type Peer, { PuterPeerConnection, PuterPeerServer } from './types/modules/peer.d.ts'; import type { AlertButton, AppConnection, AppConnectionCloseEvent, CancelAwarePromise, ContextMenuItem, ContextMenuOptions, DirectoryPickerOptions, FilePickerOptions, LaunchAppOptions, MenuItem, MenubarOptions, ThemeData, UI, WindowOptions } from './types/modules/ui.d.ts'; import type Util, { UtilRPC } from './types/modules/util.d.ts'; import type { WorkerDeployment, WorkerInfo, WorkersHandler } from './types/modules/workers.d.ts'; @@ -77,8 +78,11 @@ export type { OS, PaginatedResult, PaginationOptions, + Peer, Perms, PSocket, + PuterPeerConnection, + PuterPeerServer, PTLSSocket, Puter, PuterEnvironment, diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index e8f57dfc8..1287e2d78 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -21,6 +21,7 @@ import Perms from './modules/Perms.js'; import UI from './modules/UI.js'; import Util from './modules/Util.js'; import { WorkersHandler } from './modules/Workers.js'; +import Peer from './modules/Peer.js'; class SimpleLogger { constructor (fields = {}) { @@ -172,6 +173,7 @@ const puterInit = (function () { this.registerModule('perms', Perms); this.registerModule('drivers', Drivers); this.registerModule('debug', Debug); + this.registerModule('peer', Peer); // Path this.path = path; diff --git a/src/puter-js/src/modules/Peer.js b/src/puter-js/src/modules/Peer.js new file mode 100644 index 000000000..327e2b22c --- /dev/null +++ b/src/puter-js/src/modules/Peer.js @@ -0,0 +1,467 @@ +class PuterPeerServerConnectionEvent extends Event { + conn; + user; + constructor (connection, user) { + super('connection'); + this.conn = connection; + this.user = user; + } +} + +class PuterPeerConnectionMessageEvent extends Event { + data; + constructor (message) { + super('message'); + this.data = message; + } +} + +class PuterPeerConnectionOpenEvent extends Event { + constructor () { + super('open'); + } +} + +class PuterPeerConnectionCloseEvent extends Event { + reason; + constructor (reason = undefined) { + super('close'); + this.reason = reason; + } +} + +class PuterPeerConnectionErrorEvent extends Event { + error; + constructor (error) { + super('error'); + this.error = error; + } +} + +class PuterPeerServer extends EventTarget { + #wsconn; + #oncreateresolve; + + /** @type {Map} */ + #connections = new Map(); + invitecode; + #peerConfig; + + constructor (peerConfig) { + super(); + this.#peerConfig = peerConfig; + this.#wsconn = new WebSocket(peerConfig.signallerUrl); + } + + async start () { + await new Promise((resolve, reject) => { + this.#wsconn.onopen = resolve; + this.#wsconn.onerror = reject; + this.#wsconn.onclose = () => { + reject(new Error('Connection closed unexpectedly')); + }; + }); + + this.#wsconn.onmessage = (event) => { + let data = JSON.parse(event.data); + this.#message(data); + }; + + this.#wsconn.onclose = () => { + // what should we do here? + }; + + this.#wsconn.send( + JSON.stringify({ + server: { + create: { + authToken: this.#peerConfig.authToken, + }, + }, + }), + ); + + const { invitecode } = await new Promise((resolve, reject) => { + this.#oncreateresolve = (data) => { + if ( data.success ) { + resolve({ + invitecode: data.invitecode, + }); + this.#oncreateresolve = null; + this.invitecode = data.invitecode; + } else { + reject(new Error(data.error)); + } + }; + setTimeout( + () => reject(new Error('Server creation timed out')), + 15000, + ); + }); + + return invitecode; + } + + async #message (data) { + if ( ! data.server ) return; + if ( data.server.create ) { + this.#oncreateresolve(data.server.create); + return; + } + + if ( data.server.connect ) { + let uuid = data.server.connect.id; + let connection = new PuterPeerConnection(this.#peerConfig); + this.#connections.set(uuid, connection); + connection.peerconnection.onicecandidate = (e) => { + if ( e.candidate ) { + this.#wsconn.send( + JSON.stringify({ + server: { + candidate: { + id: uuid, + candidate: e.candidate, + }, + }, + }), + ); + } + }; + this.dispatchEvent( + new PuterPeerServerConnectionEvent( + connection, + data.server.connect.user, + ), + ); + } + + if ( data.server.candidate ) { + let uuid = data.server.candidate.id; + let connection = this.#connections.get(uuid); + if ( connection ) { + await connection.addIceCandidate( + data.server.candidate.candidate, + ); + } + } + + if ( data.server.offer ) { + let uuid = data.server.offer.id; + let connection = this.#connections.get(uuid); + if ( connection ) { + await connection.setRemoteDescription( + new RTCSessionDescription(data.server.offer.offer), + ); + } + + const answer = await connection.createAnswer(); + this.#wsconn.send( + JSON.stringify({ + server: { + answer: { + id: uuid, + answer, + }, + }, + }), + ); + } + } + + close () { + for ( const [uuid, connection] of this.#connections ) { + connection.close(); + } + this.#wsconn.onclose = null; + this.#wsconn.close(); + } +} + +class PuterPeerConnection extends EventTarget { + #wsconn; + peerconnection; + #peerConfig; + #datachannel; + connected = false; + closed = false; + #bufferedMessages = []; + constructor (peerConfig) { + super(); + this.#peerConfig = peerConfig; + this.peerconnection = new RTCPeerConnection({ + iceServers: peerConfig.iceServers, + }); + this.#datachannel = this.peerconnection.createDataChannel('channel-1', { negotiated: true, id: 2 }); + this.#datachannel.onmessage = (evt) => { + this.dispatchEvent(new PuterPeerConnectionMessageEvent(evt.data)); + }; + this.#datachannel.onopen = () => { + this.connected = true; + for ( const message of this.#bufferedMessages ) { + this.send(message); + } + this.#bufferedMessages = []; + this.dispatchEvent(new PuterPeerConnectionOpenEvent()); + this.#closews(); + }; + this.#datachannel.onclose = () => { + this.#doclose(undefined, undefined); + }; + this.#datachannel.onerror = (evt) => { + this.#doclose(undefined, evt.error); + }; + } + + #closews () { + if ( this.#wsconn ) { + this.#wsconn.onclose = null; + this.#wsconn.close(); + this.#wsconn = null; + } + } + + async connect (invitecode) { + this.#wsconn = new WebSocket(this.#peerConfig.signallerUrl); + await new Promise((resolve, reject) => { + this.#wsconn.onopen = resolve; + this.#wsconn.onerror = reject; + this.#wsconn.onclose = () => { + reject(new Error('Connection closed unexpectedly')); + }; + }); + this.#wsconn.onopen = null; + this.#wsconn.onerror = null; + // post initial connect close + this.#wsconn.onclose = () => { + this.#doclose(undefined, new Error('Connection closed unexpectedly before peer offer was sent')); + }; + + this.#wsconn.send( + JSON.stringify({ + client: { + connect: { + authToken: this.#peerConfig.authToken, + invitecode, + }, + }, + }), + ); + + this.peerconnection.onicecandidate = (evt) => { + this.#wsconn.send( + JSON.stringify({ + client: { + candidate: { + candidate: evt.candidate, + }, + }, + }), + ); + }; + + this.#wsconn.onmessage = async (evt) => { + let msg = JSON.parse(evt.data).client; + if ( ! msg ) return; + if ( msg.answer ) { + this.setRemoteDescription(msg.answer.answer); + } + if ( msg.candidate ) { + this.addIceCandidate(msg.candidate.candidate); + } + if ( msg.connect ) { + if ( msg.connect.success ) { + const offer = await this.createOffer(); + this.#wsconn.send( + JSON.stringify({ + client: { + offer: { + offer, + }, + }, + }), + ); + } else { + this.#doclose(undefined, new Error(msg.connect.error)); + } + } + if ( msg.disconnect && !this.connected ) { + this.#doclose(msg.disconnect.reason); + } + }; + } + + #doclose (reason, error) { + if ( this.closed ) return; + this.closed = true; + this.connected = false; + if ( this.#wsconn ) this.#closews(); + if ( this.#datachannel ) { + this.#datachannel.onclose = null; + this.#datachannel.close(); + } + if ( this.peerconnection ) { + this.peerconnection.close(); + } + if ( error ) this.dispatchEvent(new PuterPeerConnectionErrorEvent(error)); + this.dispatchEvent(new PuterPeerConnectionCloseEvent(reason)); + } + + close (reason) { + this.#doclose(reason, undefined); + } + + async createOffer () { + const offer = await this.peerconnection.createOffer(); + await this.peerconnection.setLocalDescription(offer); + return offer; + } + + async createAnswer () { + const answer = await this.peerconnection.createAnswer(); + await this.peerconnection.setLocalDescription(answer); + return answer; + } + + async setRemoteDescription (description) { + await this.peerconnection.setRemoteDescription(description); + } + + async addIceCandidate (candidate) { + await this.peerconnection.addIceCandidate(candidate); + } + + send ( message ) { + if ( ! this.connected ) { + this.#bufferedMessages.push(message); + return; + } + this.#datachannel.send(message); + } +} + +class Peer { + #signallerUrl; + #turnServers; + #fallbackIceServers; + #turnTTL; + #turnStartedAt; + #turnFailed; + /** + * Creates a new instance with the given authentication token, API origin, and app ID, + * + * @class + * @param {string} authToken - Token used to authenticate the user. + * @param {string} APIOrigin - Origin of the API server. Used to build the API endpoint URLs. + * @param {string} appID - ID of the app to use. + */ + constructor (puter) { + this.puter = puter; + this.authToken = puter.authToken; + this.APIOrigin = puter.APIOrigin; + this.appID = puter.appID; + } + + /** + * Sets a new authentication token. + * + * @param {string} authToken - The new authentication token. + * @memberof [OS] + * @returns {void} + */ + setAuthToken (authToken) { + this.authToken = authToken; + } + + /** + * Sets the API origin. + * + * @param {string} APIOrigin - The new API origin. + * @memberof [Apps] + * @returns {void} + */ + setAPIOrigin (APIOrigin) { + this.APIOrigin = APIOrigin; + } + + async ensureTurnRelays () { + if ( this.#turnFailed ) return; + if ( this.#turnServers && Date.now() - this.#turnStartedAt < this.#turnTTL * 1000 ) return; + + const response = await fetch(`${this.APIOrigin}/peer/generate-turn`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.authToken}`, + }, + }); + + if ( ! response.ok ) { + this.#turnFailed = true; + return; + } + + const { iceServers, ttl, fallbackIce } = await response.json(); + this.#fallbackIceServers = fallbackIce; + this.#turnServers = iceServers; + this.#turnTTL = ttl; + this.#turnStartedAt = Date.now(); + } + + async #loadMetadata () { + if ( this.#signallerUrl ) return; + const response = await fetch(`${this.APIOrigin}/peer/signaller-info`); + if ( ! response.ok ) { + throw new Error('Failed to get signaller info from Puter.'); + } + const { url } = await response.json(); + this.#signallerUrl = url; + } + + async #authenticateForPeerAction (action) { + if ( this.authToken || this.puter.env !== 'web' ) return; + try { + await this.puter.ui.authenticateWithPuter(); + } catch (e) { + throw new Error(`Need authentication to ${action} but failed to authenticate with Puter.`); + } + } + + async #resolvePeerConfig (options) { + await this.#loadMetadata(); + let iceServers; + if ( options?.iceServers ) { + iceServers = options.iceServers; + } else { + await this.ensureTurnRelays(); + if ( this.#turnServers ) { + iceServers = this.#turnServers; + } else { + iceServers = this.#fallbackIceServers; + console.warn('Unable to use TURN relays. Some connections may fail.'); + } + } + + return { + authToken: this.authToken, + iceServers, + signallerUrl: this.#signallerUrl, + }; + } + async serve (options) { + await this.#authenticateForPeerAction('create a server'); + const peerConfig = await this.#resolvePeerConfig(options); + const server = new PuterPeerServer(peerConfig); + await server.start(); + return server; + } + + async connect (invitecode, options) { + await this.#authenticateForPeerAction('connect to a server'); + const peerConfig = await this.#resolvePeerConfig(options); + const conn = new PuterPeerConnection(peerConfig); + await conn.connect(invitecode); + return conn; + } +} + +export default Peer; diff --git a/src/puter-js/types/modules/peer.d.ts b/src/puter-js/types/modules/peer.d.ts new file mode 100644 index 000000000..f02b2132a --- /dev/null +++ b/src/puter-js/types/modules/peer.d.ts @@ -0,0 +1,98 @@ +export interface PuterPeerOptions { + iceServers?: RTCIceServer[]; +} + +export interface PuterPeerUser extends Record {} + +export type PuterPeerMessage = string | Blob | ArrayBuffer | ArrayBufferView; +export type PuterPeerDescription = RTCSessionDescription | RTCSessionDescriptionInit; +export type PuterPeerIceCandidate = RTCIceCandidate | RTCIceCandidateInit; + +export class PuterPeerServerConnectionEvent extends Event { + readonly conn: PuterPeerConnection; + readonly user: PuterPeerUser; +} + +export class PuterPeerConnectionMessageEvent extends Event { + readonly data: unknown; +} + +export class PuterPeerConnectionOpenEvent extends Event {} + +export class PuterPeerConnectionCloseEvent extends Event { + readonly reason?: unknown; +} + +export class PuterPeerConnectionErrorEvent extends Event { + readonly error: unknown; +} + +export interface PuterPeerServerEventMap { + connection: PuterPeerServerConnectionEvent; +} + +export interface PuterPeerConnectionEventMap { + open: PuterPeerConnectionOpenEvent; + message: PuterPeerConnectionMessageEvent; + close: PuterPeerConnectionCloseEvent; + error: PuterPeerConnectionErrorEvent; +} + +export class PuterPeerServer extends EventTarget { + invitecode?: string; + + start (): Promise; + message (data: unknown): Promise; + + addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void; + addEventListener( + type: K, + listener: (this: PuterPeerServer, ev: PuterPeerServerEventMap[K]) => unknown, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions): void; + removeEventListener( + type: K, + listener: (this: PuterPeerServer, ev: PuterPeerServerEventMap[K]) => unknown, + options?: boolean | EventListenerOptions, + ): void; +} + +export class PuterPeerConnection extends EventTarget { + peerconnection: RTCPeerConnection; + connected: boolean; + closed: boolean; + + connect (invitecode: string): Promise; + close (reason?: unknown): void; + createOffer (): Promise; + createAnswer (): Promise; + setRemoteDescription (description: PuterPeerDescription): void; + addIceCandidate (candidate: PuterPeerIceCandidate): void; + send (message: PuterPeerMessage): void; + + addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void; + addEventListener( + type: K, + listener: (this: PuterPeerConnection, ev: PuterPeerConnectionEventMap[K]) => unknown, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions): void; + removeEventListener( + type: K, + listener: (this: PuterPeerConnection, ev: PuterPeerConnectionEventMap[K]) => unknown, + options?: boolean | EventListenerOptions, + ): void; +} + +export default class Peer { + authToken?: string | null; + APIOrigin: string; + appID?: string; + + setAuthToken (authToken: string): void; + setAPIOrigin (APIOrigin: string): void; + ensureTurnRelays (): Promise; + serve (options?: PuterPeerOptions): Promise; + connect (invitecode: string, options?: PuterPeerOptions): Promise; +} diff --git a/src/puter-js/types/puter.d.ts b/src/puter-js/types/puter.d.ts index f879e4785..61939ea4f 100644 --- a/src/puter-js/types/puter.d.ts +++ b/src/puter-js/types/puter.d.ts @@ -9,6 +9,7 @@ import type { Hosting } from './modules/hosting.d.ts'; import type { KV } from './modules/kv.d.ts'; import type { Networking } from './modules/networking.d.ts'; import type { OS } from './modules/os.d.ts'; +import type Peer from './modules/peer.d.ts'; import type { Perms } from './modules/perms.d.ts'; import type { UI } from './modules/ui.d.ts'; import type Util from './modules/util.d.ts'; @@ -55,6 +56,7 @@ export class Puter { perms: Perms; drivers: Drivers; debug: Debug; + peer: Peer | null; path: { join: (...parts: string[]) => string; dirname: (p: string) => string;