UNPKG

@wordpress/sync

Version:
859 lines (823 loc) 21.8 kB
// File copied as is from the y-webrtc package with only exports // added to the following vars/functions: signalingConns,rooms, publishSignalingMessage, log. /* eslint-disable eslint-comments/disable-enable-pair */ /* eslint-disable eslint-comments/no-unlimited-disable */ /* eslint-disable */ // @ts-nocheck import * as ws from 'lib0/websocket'; import * as map from 'lib0/map'; import * as error from 'lib0/error'; import * as random from 'lib0/random'; import * as encoding from 'lib0/encoding'; import * as decoding from 'lib0/decoding'; import { Observable } from 'lib0/observable'; import * as logging from 'lib0/logging'; import * as promise from 'lib0/promise'; import * as bc from 'lib0/broadcastchannel'; import * as buffer from 'lib0/buffer'; import * as math from 'lib0/math'; import { createMutex } from 'lib0/mutex'; import * as Y from 'yjs'; // eslint-disable-line import Peer from 'simple-peer/simplepeer.min.js'; import * as syncProtocol from 'y-protocols/sync'; import * as awarenessProtocol from 'y-protocols/awareness'; import * as cryptoutils from './crypto.js'; export const log = logging.createModuleLogger( 'y-webrtc' ); const messageSync = 0; const messageQueryAwareness = 3; const messageAwareness = 1; const messageBcPeerId = 4; /** * @type {Map<string, SignalingConn>} */ export const signalingConns = new Map(); /** * @type {Map<string,Room>} */ export const rooms = new Map(); /** * @param {Room} room */ const checkIsSynced = ( room ) => { let synced = true; room.webrtcConns.forEach( ( peer ) => { if ( ! peer.synced ) { synced = false; } } ); if ( ( ! synced && room.synced ) || ( synced && ! room.synced ) ) { room.synced = synced; room.provider.emit( 'synced', [ { synced } ] ); log( 'synced ', logging.BOLD, room.name, logging.UNBOLD, ' with all peers' ); } }; /** * @param {Room} room * @param {Uint8Array} buf * @param {function} syncedCallback * @return {encoding.Encoder?} */ const readMessage = ( room, buf, syncedCallback ) => { const decoder = decoding.createDecoder( buf ); const encoder = encoding.createEncoder(); const messageType = decoding.readVarUint( decoder ); if ( room === undefined ) { return null; } const awareness = room.awareness; const doc = room.doc; let sendReply = false; switch ( messageType ) { case messageSync: { encoding.writeVarUint( encoder, messageSync ); const syncMessageType = syncProtocol.readSyncMessage( decoder, encoder, doc, room ); if ( syncMessageType === syncProtocol.messageYjsSyncStep2 && ! room.synced ) { syncedCallback(); } if ( syncMessageType === syncProtocol.messageYjsSyncStep1 ) { sendReply = true; } break; } case messageQueryAwareness: encoding.writeVarUint( encoder, messageAwareness ); encoding.writeVarUint8Array( encoder, awarenessProtocol.encodeAwarenessUpdate( awareness, Array.from( awareness.getStates().keys() ) ) ); sendReply = true; break; case messageAwareness: awarenessProtocol.applyAwarenessUpdate( awareness, decoding.readVarUint8Array( decoder ), room ); break; case messageBcPeerId: { const add = decoding.readUint8( decoder ) === 1; const peerName = decoding.readVarString( decoder ); if ( peerName !== room.peerId && ( ( room.bcConns.has( peerName ) && ! add ) || ( ! room.bcConns.has( peerName ) && add ) ) ) { const removed = []; const added = []; if ( add ) { room.bcConns.add( peerName ); added.push( peerName ); } else { room.bcConns.delete( peerName ); removed.push( peerName ); } room.provider.emit( 'peers', [ { added, removed, webrtcPeers: Array.from( room.webrtcConns.keys() ), bcPeers: Array.from( room.bcConns ), }, ] ); broadcastBcPeerId( room ); } break; } default: console.error( 'Unable to compute message' ); return encoder; } if ( ! sendReply ) { // nothing has been written, no answer created return null; } return encoder; }; /** * @param {WebrtcConn} peerConn * @param {Uint8Array} buf * @return {encoding.Encoder?} */ const readPeerMessage = ( peerConn, buf ) => { const room = peerConn.room; log( 'received message from ', logging.BOLD, peerConn.remotePeerId, logging.GREY, ' (', room.name, ')', logging.UNBOLD, logging.UNCOLOR ); return readMessage( room, buf, () => { peerConn.synced = true; log( 'synced ', logging.BOLD, room.name, logging.UNBOLD, ' with ', logging.BOLD, peerConn.remotePeerId ); checkIsSynced( room ); } ); }; /** * @param {WebrtcConn} webrtcConn * @param {encoding.Encoder} encoder */ const sendWebrtcConn = ( webrtcConn, encoder ) => { log( 'send message to ', logging.BOLD, webrtcConn.remotePeerId, logging.UNBOLD, logging.GREY, ' (', webrtcConn.room.name, ')', logging.UNCOLOR ); try { webrtcConn.peer.send( encoding.toUint8Array( encoder ) ); } catch ( e ) {} }; /** * @param {Room} room * @param {Uint8Array} m */ const broadcastWebrtcConn = ( room, m ) => { log( 'broadcast message in ', logging.BOLD, room.name, logging.UNBOLD ); room.webrtcConns.forEach( ( conn ) => { try { conn.peer.send( m ); } catch ( e ) {} } ); }; export class WebrtcConn { /** * @param {SignalingConn} signalingConn * @param {boolean} initiator * @param {string} remotePeerId * @param {Room} room */ constructor( signalingConn, initiator, remotePeerId, room ) { log( 'establishing connection to ', logging.BOLD, remotePeerId ); this.room = room; this.remotePeerId = remotePeerId; this.glareToken = undefined; this.closed = false; this.connected = false; this.synced = false; /** * @type {any} */ this.peer = new Peer( { initiator, ...room.provider.peerOpts } ); this.peer.on( 'signal', ( signal ) => { if ( this.glareToken === undefined ) { // add some randomness to the timestamp of the offer this.glareToken = Date.now() + Math.random(); } publishSignalingMessage( signalingConn, room, { to: remotePeerId, from: room.peerId, type: 'signal', token: this.glareToken, signal, } ); } ); this.peer.on( 'connect', () => { log( 'connected to ', logging.BOLD, remotePeerId ); this.connected = true; // send sync step 1 const provider = room.provider; const doc = provider.doc; const awareness = room.awareness; const encoder = encoding.createEncoder(); encoding.writeVarUint( encoder, messageSync ); syncProtocol.writeSyncStep1( encoder, doc ); sendWebrtcConn( this, encoder ); const awarenessStates = awareness.getStates(); if ( awarenessStates.size > 0 ) { const encoder = encoding.createEncoder(); encoding.writeVarUint( encoder, messageAwareness ); encoding.writeVarUint8Array( encoder, awarenessProtocol.encodeAwarenessUpdate( awareness, Array.from( awarenessStates.keys() ) ) ); sendWebrtcConn( this, encoder ); } } ); this.peer.on( 'close', () => { this.connected = false; this.closed = true; if ( room.webrtcConns.has( this.remotePeerId ) ) { room.webrtcConns.delete( this.remotePeerId ); room.provider.emit( 'peers', [ { removed: [ this.remotePeerId ], added: [], webrtcPeers: Array.from( room.webrtcConns.keys() ), bcPeers: Array.from( room.bcConns ), }, ] ); } checkIsSynced( room ); this.peer.destroy(); log( 'closed connection to ', logging.BOLD, remotePeerId ); announceSignalingInfo( room ); } ); this.peer.on( 'error', ( err ) => { log( 'Error in connection to ', logging.BOLD, remotePeerId, ': ', err ); announceSignalingInfo( room ); } ); this.peer.on( 'data', ( data ) => { const answer = readPeerMessage( this, data ); if ( answer !== null ) { sendWebrtcConn( this, answer ); } } ); } destroy() { this.peer.destroy(); } } /** * @param {Room} room * @param {Uint8Array} m */ const broadcastBcMessage = ( room, m ) => cryptoutils .encrypt( m, room.key ) .then( ( data ) => room.mux( () => bc.publish( room.name, data ) ) ); /** * @param {Room} room * @param {Uint8Array} m */ const broadcastRoomMessage = ( room, m ) => { if ( room.bcconnected ) { broadcastBcMessage( room, m ); } broadcastWebrtcConn( room, m ); }; /** * @param {Room} room */ const announceSignalingInfo = ( room ) => { signalingConns.forEach( ( conn ) => { // only subscribe if connection is established, otherwise the conn automatically subscribes to all rooms if ( conn.connected ) { conn.send( { type: 'subscribe', topics: [ room.name ] } ); if ( room.webrtcConns.size < room.provider.maxConns ) { publishSignalingMessage( conn, room, { type: 'announce', from: room.peerId, } ); } } } ); }; /** * @param {Room} room */ const broadcastBcPeerId = ( room ) => { if ( room.provider.filterBcConns ) { // broadcast peerId via broadcastchannel const encoderPeerIdBc = encoding.createEncoder(); encoding.writeVarUint( encoderPeerIdBc, messageBcPeerId ); encoding.writeUint8( encoderPeerIdBc, 1 ); encoding.writeVarString( encoderPeerIdBc, room.peerId ); broadcastBcMessage( room, encoding.toUint8Array( encoderPeerIdBc ) ); } }; export class Room { /** * @param {Y.Doc} doc * @param {WebrtcProvider} provider * @param {string} name * @param {CryptoKey|null} key */ constructor( doc, provider, name, key ) { /** * Do not assume that peerId is unique. This is only meant for sending signaling messages. * * @type {string} */ this.peerId = random.uuidv4(); this.doc = doc; /** * @type {awarenessProtocol.Awareness} */ this.awareness = provider.awareness; this.provider = provider; this.synced = false; this.name = name; // @todo make key secret by scoping this.key = key; /** * @type {Map<string, WebrtcConn>} */ this.webrtcConns = new Map(); /** * @type {Set<string>} */ this.bcConns = new Set(); this.mux = createMutex(); this.bcconnected = false; /** * @param {ArrayBuffer} data */ this._bcSubscriber = ( data ) => cryptoutils.decrypt( new Uint8Array( data ), key ).then( ( m ) => this.mux( () => { const reply = readMessage( this, m, () => {} ); if ( reply ) { broadcastBcMessage( this, encoding.toUint8Array( reply ) ); } } ) ); /** * Listens to Yjs updates and sends them to remote peers * * @param {Uint8Array} update * @param {any} origin */ this._docUpdateHandler = ( update, origin ) => { const encoder = encoding.createEncoder(); encoding.writeVarUint( encoder, messageSync ); syncProtocol.writeUpdate( encoder, update ); broadcastRoomMessage( this, encoding.toUint8Array( encoder ) ); }; /** * Listens to Awareness updates and sends them to remote peers * * @param {any} changed * @param {any} origin */ this._awarenessUpdateHandler = ( { added, updated, removed }, origin ) => { const changedClients = added.concat( updated ).concat( removed ); const encoderAwareness = encoding.createEncoder(); encoding.writeVarUint( encoderAwareness, messageAwareness ); encoding.writeVarUint8Array( encoderAwareness, awarenessProtocol.encodeAwarenessUpdate( this.awareness, changedClients ) ); broadcastRoomMessage( this, encoding.toUint8Array( encoderAwareness ) ); }; this._beforeUnloadHandler = () => { awarenessProtocol.removeAwarenessStates( this.awareness, [ doc.clientID ], 'window unload' ); rooms.forEach( ( room ) => { room.disconnect(); } ); }; if ( typeof window !== 'undefined' ) { window.addEventListener( 'beforeunload', this._beforeUnloadHandler ); } else if ( typeof process !== 'undefined' ) { process.on( 'exit', this._beforeUnloadHandler ); } } connect() { this.doc.on( 'update', this._docUpdateHandler ); this.awareness.on( 'update', this._awarenessUpdateHandler ); // signal through all available signaling connections announceSignalingInfo( this ); const roomName = this.name; bc.subscribe( roomName, this._bcSubscriber ); this.bcconnected = true; // broadcast peerId via broadcastchannel broadcastBcPeerId( this ); // write sync step 1 const encoderSync = encoding.createEncoder(); encoding.writeVarUint( encoderSync, messageSync ); syncProtocol.writeSyncStep1( encoderSync, this.doc ); broadcastBcMessage( this, encoding.toUint8Array( encoderSync ) ); // broadcast local state const encoderState = encoding.createEncoder(); encoding.writeVarUint( encoderState, messageSync ); syncProtocol.writeSyncStep2( encoderState, this.doc ); broadcastBcMessage( this, encoding.toUint8Array( encoderState ) ); // write queryAwareness const encoderAwarenessQuery = encoding.createEncoder(); encoding.writeVarUint( encoderAwarenessQuery, messageQueryAwareness ); broadcastBcMessage( this, encoding.toUint8Array( encoderAwarenessQuery ) ); // broadcast local awareness state const encoderAwarenessState = encoding.createEncoder(); encoding.writeVarUint( encoderAwarenessState, messageAwareness ); encoding.writeVarUint8Array( encoderAwarenessState, awarenessProtocol.encodeAwarenessUpdate( this.awareness, [ this.doc.clientID, ] ) ); broadcastBcMessage( this, encoding.toUint8Array( encoderAwarenessState ) ); } disconnect() { // signal through all available signaling connections signalingConns.forEach( ( conn ) => { if ( conn.connected ) { conn.send( { type: 'unsubscribe', topics: [ this.name ] } ); } } ); awarenessProtocol.removeAwarenessStates( this.awareness, [ this.doc.clientID ], 'disconnect' ); // broadcast peerId removal via broadcastchannel const encoderPeerIdBc = encoding.createEncoder(); encoding.writeVarUint( encoderPeerIdBc, messageBcPeerId ); encoding.writeUint8( encoderPeerIdBc, 0 ); // remove peerId from other bc peers encoding.writeVarString( encoderPeerIdBc, this.peerId ); broadcastBcMessage( this, encoding.toUint8Array( encoderPeerIdBc ) ); bc.unsubscribe( this.name, this._bcSubscriber ); this.bcconnected = false; this.doc.off( 'update', this._docUpdateHandler ); this.awareness.off( 'update', this._awarenessUpdateHandler ); this.webrtcConns.forEach( ( conn ) => conn.destroy() ); } destroy() { this.disconnect(); if ( typeof window !== 'undefined' ) { window.removeEventListener( 'beforeunload', this._beforeUnloadHandler ); } else if ( typeof process !== 'undefined' ) { process.off( 'exit', this._beforeUnloadHandler ); } } } /** * @param {Y.Doc} doc * @param {WebrtcProvider} provider * @param {string} name * @param {CryptoKey|null} key * @return {Room} */ const openRoom = ( doc, provider, name, key ) => { // there must only be one room if ( rooms.has( name ) ) { throw error.create( `A Yjs Doc connected to room "${ name }" already exists!` ); } const room = new Room( doc, provider, name, key ); rooms.set( name, /** @type {Room} */ ( room ) ); return room; }; /** * @param {SignalingConn} conn * @param {Room} room * @param {any} data */ export const publishSignalingMessage = ( conn, room, data ) => { if ( room.key ) { cryptoutils.encryptJson( data, room.key ).then( ( data ) => { conn.send( { type: 'publish', topic: room.name, data: buffer.toBase64( data ), } ); } ); } else { conn.send( { type: 'publish', topic: room.name, data } ); } }; export class SignalingConn extends ws.WebsocketClient { constructor( url ) { super( url ); /** * @type {Set<WebrtcProvider>} */ this.providers = new Set(); this.on( 'connect', () => { log( `connected (${ url })` ); const topics = Array.from( rooms.keys() ); this.send( { type: 'subscribe', topics } ); rooms.forEach( ( room ) => publishSignalingMessage( this, room, { type: 'announce', from: room.peerId, } ) ); } ); this.on( 'message', ( m ) => { switch ( m.type ) { case 'publish': { const roomName = m.topic; const room = rooms.get( roomName ); if ( room == null || typeof roomName !== 'string' ) { return; } const execMessage = ( data ) => { const webrtcConns = room.webrtcConns; const peerId = room.peerId; if ( data == null || data.from === peerId || ( data.to !== undefined && data.to !== peerId ) || room.bcConns.has( data.from ) ) { // ignore messages that are not addressed to this conn, or from clients that are connected via broadcastchannel return; } const emitPeerChange = webrtcConns.has( data.from ) ? () => {} : () => room.provider.emit( 'peers', [ { removed: [], added: [ data.from ], webrtcPeers: Array.from( room.webrtcConns.keys() ), bcPeers: Array.from( room.bcConns ), }, ] ); switch ( data.type ) { case 'announce': if ( webrtcConns.size < room.provider.maxConns ) { map.setIfUndefined( webrtcConns, data.from, () => new WebrtcConn( this, true, data.from, room ) ); emitPeerChange(); } break; case 'signal': if ( data.signal.type === 'offer' ) { const existingConn = webrtcConns.get( data.from ); if ( existingConn ) { const remoteToken = data.token; const localToken = existingConn.glareToken; if ( localToken && localToken > remoteToken ) { log( 'offer rejected: ', data.from ); return; } // if we don't reject the offer, we will be accepting it and answering it existingConn.glareToken = undefined; } } if ( data.signal.type === 'answer' ) { log( 'offer answered by: ', data.from ); const existingConn = webrtcConns.get( data.from ); existingConn.glareToken = undefined; } if ( data.to === peerId ) { map.setIfUndefined( webrtcConns, data.from, () => new WebrtcConn( this, false, data.from, room ) ).peer.signal( data.signal ); emitPeerChange(); } break; } }; if ( room.key ) { if ( typeof m.data === 'string' ) { cryptoutils .decryptJson( buffer.fromBase64( m.data ), room.key ) .then( execMessage ); } } else { execMessage( m.data ); } } } } ); this.on( 'disconnect', () => log( `disconnect (${ url })` ) ); } } /** * @typedef {Object} ProviderOptions * @property {Array<string>} [signaling] * @property {string} [password] * @property {awarenessProtocol.Awareness} [awareness] * @property {number} [maxConns] * @property {boolean} [filterBcConns] * @property {any} [peerOpts] */ /** * @extends Observable<string> */ export class WebrtcProvider extends Observable { /** * @param {string} roomName * @param {Y.Doc} doc * @param {ProviderOptions?} opts */ constructor( roomName, doc, { signaling = [ 'wss://y-webrtc-eu.fly.dev' ], password = null, awareness = new awarenessProtocol.Awareness( doc ), maxConns = 20 + math.floor( random.rand() * 15 ), // the random factor reduces the chance that n clients form a cluster filterBcConns = true, peerOpts = {}, // simple-peer options. See https://github.com/feross/simple-peer#peer--new-peeropts } = {} ) { super(); this.roomName = roomName; this.doc = doc; this.filterBcConns = filterBcConns; /** * @type {awarenessProtocol.Awareness} */ this.awareness = awareness; this.shouldConnect = false; this.signalingUrls = signaling; this.signalingConns = []; this.maxConns = maxConns; this.peerOpts = peerOpts; /** * @type {PromiseLike<CryptoKey | null>} */ this.key = password ? cryptoutils.deriveKey( password, roomName ) : /** @type {PromiseLike<null>} */ ( promise.resolve( null ) ); /** * @type {Room|null} */ this.room = null; this.key.then( ( key ) => { this.room = openRoom( doc, this, roomName, key ); if ( this.shouldConnect ) { this.room.connect(); } else { this.room.disconnect(); } } ); this.connect(); this.destroy = this.destroy.bind( this ); doc.on( 'destroy', this.destroy ); } /** * @type {boolean} */ get connected() { return this.room !== null && this.shouldConnect; } connect() { this.shouldConnect = true; this.signalingUrls.forEach( ( url ) => { const signalingConn = map.setIfUndefined( signalingConns, url, () => new SignalingConn( url ) ); this.signalingConns.push( signalingConn ); signalingConn.providers.add( this ); } ); if ( this.room ) { this.room.connect(); } } disconnect() { this.shouldConnect = false; this.signalingConns.forEach( ( conn ) => { conn.providers.delete( this ); if ( conn.providers.size === 0 ) { conn.destroy(); signalingConns.delete( conn.url ); } } ); if ( this.room ) { this.room.disconnect(); } } destroy() { this.doc.off( 'destroy', this.destroy ); // need to wait for key before deleting room this.key.then( () => { /** @type {Room} */ ( this.room ).destroy(); rooms.delete( this.roomName ); } ); super.destroy(); } }