UNPKG

tahi

Version:

A shared peer-to-peer state for multiple users connected by WebRTC.

393 lines (350 loc) 11.3 kB
const Peer = require('simple-peer'); const cuid = require('cuid'); const log = (...messages) => { if (process.env.NODE_ENV === 'development') { console.log('tahi:', ...messages); } }; const autoMesh = { invitesRequested: new Set(), invitesAwaitingAnswer: {}, answersRequested: new Set(), }; export function createStore(reducer, preloadedState, enhancer) { if ( (typeof preloadedState === 'function' && typeof enhancer === 'function') || (typeof enhancer === 'function' && typeof arguments[3] === 'function') ) { throw new Error( 'It looks like you are passing several store enhancers to ' + 'createStore(). This is not supported. Instead, compose them ' + 'together to a single function', ); } if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') { enhancer = preloadedState; preloadedState = undefined; } if (typeof enhancer !== 'undefined') { if (typeof enhancer !== 'function') { throw new Error('Expected the enhancer to be a function.'); } return enhancer(createStore)(reducer, preloadedState); } if (typeof reducer !== 'function') { throw new Error('Expected the reducer to be a function.'); } const peerId = cuid(); let initialState = reducer(preloadedState, { type: '@@INIT' }); let currentState = initialState; let currentListeners = []; let nextListeners = currentListeners; let isDispatching = false; let messages = []; let currentMessageIndex = -1; let peers = {}; function ensureCanMutateNextListeners() { if (nextListeners === currentListeners) { nextListeners = currentListeners.slice(); } } function asyncDispatch(message) { const { timestamp, id, action } = message; try { isDispatching = true; const { timestamp: mostRecent = 0 } = messages[currentMessageIndex] || {}; if (timestamp > mostRecent) { messages.push(message); currentState = reducer(Object.assign({}, currentState), action); } else { let isDuplicate = false; const insertIndex = messages.findIndex(({ timestamp: t, id: i }) => { if (timestamp < t) { return true; } else if (timestamp === t) { if (id === i) { // This is a duplicate action, ignore it isDuplicate = true; return true; } else if (id < i) { return true; } } return false; }); if (!isDuplicate) { messages.splice(insertIndex, 0, message); currentState = messages.reduce( (nextState, { action }) => reducer(nextState, action), initialState, ); } } } finally { isDispatching = false; currentMessageIndex++; } const listeners = (currentListeners = nextListeners); listeners.forEach((listener) => { listener(); }); } function handlePeerConnect( peer, localPeerId, handlePeerRemoved, peerConnected, ) { log('Awaiting peer connect...'); peer.on('error', (err) => { // TODO log('TODO: handle error:', err); if (/Ice connection failed/.test(err)) { delete peers[localPeerId]; handlePeerRemoved(localPeerId, err); log('Ice connection failed', err); } }); peer.on('connect', () => { log('Peer connected'); peerConnected(); const send = (message) => { log('Sending message to peer:', message); peer.send(JSON.stringify(message)); }; peer.on('data', (encodedMessage) => { const message = new TextDecoder('utf-8').decode(encodedMessage); const parsedMessage = JSON.parse(message); log('Peer message:', parsedMessage); switch (parsedMessage.type) { // Invite to auto-connect new peer case 'AUTO_MESH_REQUEST_INVITE': { // If "new" peer is already a peer or still waiting for a response, ignore this request if ( peers[parsedMessage.peerInviting] == null && autoMesh.invitesAwaitingAnswer[parsedMessage.peerInviting] == null ) { invitePeer() .then(({ inviteCode, completeInvitation }) => { autoMesh.invitesAwaitingAnswer[ parsedMessage.peerInviting ] = completeInvitation; // Reply back with the invite code peers[parsedMessage.bridgePeer].send({ type: 'AUTO_MESH_RESPOND_INVITE', inviteCode, respondPeer: peerId, peerInviting: parsedMessage.peerInviting, }); }) .catch((err) => { // TODO console.log('tahi:', 'Auto-mesh invite error:', err); }); } break; } case 'AUTO_MESH_RESPOND_INVITE': { // Only continue if we requested this invite if ( autoMesh.invitesRequested.has( `${parsedMessage.respondPeer}-${parsedMessage.peerInviting}`, ) ) { autoMesh.invitesRequested.delete( `${parsedMessage.respondPeer}-${parsedMessage.peerInviting}`, ); autoMesh.answersRequested.add(parsedMessage.peerInviting); peers[parsedMessage.peerInviting].send({ type: 'AUTO_MESH_REQUEST_ANSWER', invitingPeer: parsedMessage.respondPeer, inviteCode: parsedMessage.inviteCode, bridgePeer: peerId, }); } break; } case 'AUTO_MESH_REQUEST_ANSWER': { // Only continue if not already a peer if (peers[parsedMessage.invitingPeer] == null) { autoMesh.answersRequested.add(parsedMessage.peerInviting); joinPeer(parsedMessage.inviteCode) .then((answer) => { peers[parsedMessage.bridgePeer].send({ type: 'AUTO_MESH_RESPOND_ANSWER', answer, invitingPeer: parsedMessage.invitingPeer, answeringPeer: peerId, }); }) .catch((err) => { // TODO console.log('tahi:', 'Auto-mesh join error:', err); }); } break; } case 'AUTO_MESH_RESPOND_ANSWER': { // Only continue if expecting an answer if (autoMesh.answersRequested.has(parsedMessage.answeringPeer)) { autoMesh.answersRequested.delete(parsedMessage.answeringPeer); peers[parsedMessage.invitingPeer].send({ type: 'AUTO_MESH_COMPLETE_INVITE', answeringPeer: parsedMessage.answeringPeer, answer: parsedMessage.answer, }); } break; } case 'AUTO_MESH_COMPLETE_INVITE': { // Only continue if expecting an answer if (autoMesh.invitesAwaitingAnswer[parsedMessage.answeringPeer]) { autoMesh.invitesAwaitingAnswer[parsedMessage.answeringPeer]( parsedMessage.answer, () => { // TODO: handle peer remove }, ); delete autoMesh.invitesAwaitingAnswer[ parsedMessage.answeringPeer ]; } break; } default: asyncDispatch(parsedMessage); } }); peer.on('close', () => { log('Peer connection closed. PeerId:', localPeerId); delete peers[localPeerId]; handlePeerRemoved(localPeerId); peer.destroy(); }); // Message all already connected peers asking for invite to connect new peer Object.entries(peers).forEach(([id, { send }]) => { autoMesh.invitesRequested.add(`${id}-${localPeerId}`); send({ type: 'AUTO_MESH_REQUEST_INVITE', bridgePeer: peerId, peerInviting: localPeerId, }); }); peers[localPeerId] = { send, }; log('Update new peer with my state'); messages.forEach(send); }); } function invitePeer(callback) { log('Inviting...'); const peer = new Peer({ initiator: true }); log('Invite peer'); peer.on('signal', (data) => { log('Host peer signal:', data); log( 'Resolving, invite code:', btoa( JSON.stringify({ ...data, peerId, }), ), ); callback(null, { inviteCode: btoa( JSON.stringify({ ...data, peerId, }), ), completeInvitation: (response, handlePeerRemoved, callback) => { try { const { peerId: localPeerId, ...answer } = JSON.parse( atob(response), ); handlePeerConnect(peer, localPeerId, handlePeerRemoved, () => { callback(null); }); peer.signal(answer); } catch (e) { callback(e); } }, }); }); } function joinPeer(response, handlePeerRemoved, callback) { const peer = new Peer(); peer.on('signal', (data) => { log('Guest peer signal:', data); callback( null, btoa( JSON.stringify({ ...data, peerId, }), ), ); }); try { const { peerId: localPeerId, ...offer } = JSON.parse(atob(response)); handlePeerConnect(peer, localPeerId, handlePeerRemoved, () => { callback(null, null, true); }); peer.signal(offer); } catch (e) { callback(e); } } return { getState: () => Object.assign({}, currentState), subscribe: (listener) => { if (typeof listener !== 'function') { throw new Error('Expected the listener to be a function.'); } if (isDispatching) { throw new Error( 'You may not call store.subscribe() while the reducer is executing.', ); } let isSubscribed = true; ensureCanMutateNextListeners(); nextListeners.push(listener); return () => { if (!isSubscribed) { return; } if (isDispatching) { throw new Error( 'You may not unsubscribe while the reducer is executing.', ); } isSubscribed = false; ensureCanMutateNextListeners(); const index = nextListeners.indexOf(listener); nextListeners.splice(index, 1); }; }, dispatch: (action) => { const message = { timestamp: Date.now(), id: peerId, action, }; asyncDispatch(message); Object.values(peers).forEach(({ send }) => { send(message); }); return action; }, removePeer: (id) => { delete peers[id]; }, getId: () => peerId, invitePeer, joinPeer, }; }