├── .env.example ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── index.js ├── lib ├── buffer-proxy.js ├── convert-to-protobuf-compatible-buffer.js ├── editor-proxy-metadata.js ├── editor-proxy.js ├── errors.js ├── follow-state.js ├── null-editor-proxy-delegate.js ├── null-portal-delegate.js ├── peer-connection.js ├── peer-pool.js ├── portal.js ├── pub-sub-signaling-provider.js ├── pusher-pub-sub-gateway.js ├── rest-gateway.js ├── router.js ├── star-overlay-network.js ├── teletype-client.js ├── teletype-client_pb.js └── teletype-crdt_pb.js ├── package-lock.json ├── package.json ├── teletype-client.proto ├── teletype-crdt.proto └── test ├── helpers ├── build-star-network.js ├── condition.js ├── fake-buffer-delegate.js ├── fake-editor-delegate.js ├── fake-portal-delegate.js ├── peer-pools.js ├── set-equal.js └── timeout.js ├── integration.test.js ├── peer-pool.test.js ├── portal.test.js ├── rest-gateway.test.js ├── router.test.js ├── setup.js ├── star-overlay-network.test.js └── teletype-client.test.js /.env.example: -------------------------------------------------------------------------------- 1 | TEST_DATABASE_URL=postgres://localhost:5432/teletype-server-test 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | 5 | notifications: 6 | email: 7 | on_success: never 8 | on_failure: change 9 | 10 | services: 11 | - postgresql 12 | 13 | env: 14 | global: 15 | - DISPLAY=:99.0 16 | 17 | before_install: 18 | - "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16" 19 | 20 | before_script: 21 | - 'createdb teletype-server-test' 22 | - 'cp .env.example .env' 23 | 24 | git: 25 | depth: 10 26 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [atom@github.com](mailto:atom@github.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 GitHub 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ##### Atom and all repositories under Atom will be archived on December 15, 2022. Learn more in our [official announcement](https://github.blog/2022-06-08-sunsetting-atom/) 2 | # teletype-client 3 | 4 | The editor-agnostic library managing the interaction with other clients to support peer-to-peer collaborative editing in [Teletype for Atom](https://github.com/atom/teletype). 5 | 6 | ## Hacking 7 | 8 | ### Dependencies 9 | 10 | To run teletype-client tests locally, you'll first need to have: 11 | 12 | - Node 7+ 13 | - PostgreSQL 9.x 14 | 15 | ### Running locally 16 | 17 | 1. Clone and bootstrap 18 | 19 | ``` 20 | git clone https://github.com/atom/teletype-client.git 21 | cd teletype-client 22 | cp .env.example .env 23 | createdb teletype-server-test 24 | npm install 25 | ``` 26 | 27 | 2. Run the tests 28 | 29 | ``` 30 | npm test 31 | ``` 32 | 33 | ## TODO 34 | 35 | * [ ] Document APIs 36 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const TeletypeClient = require('./lib/teletype-client') 2 | const FollowState = require('./lib/follow-state') 3 | const Errors = require('./lib/errors') 4 | 5 | module.exports = {TeletypeClient, FollowState, Errors} 6 | -------------------------------------------------------------------------------- /lib/buffer-proxy.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const {CompositeDisposable, Emitter} = require('event-kit') 3 | const {Document, serializeOperation, deserializeOperation} = require('@atom/teletype-crdt') 4 | const Messages = require('./teletype-client_pb') 5 | 6 | function doNothing () {} 7 | 8 | module.exports = 9 | class BufferProxy { 10 | static deserialize (message, props) { 11 | const id = message.getId() 12 | const uri = message.getUri() 13 | const operations = message.getOperationsList().map(deserializeOperation) 14 | return new BufferProxy(Object.assign({id, uri, operations}, props)) 15 | } 16 | 17 | constructor ({id, uri, text, history, operations, router, hostPeerId, siteId, didDispose}) { 18 | this.id = id 19 | this.hostPeerId = hostPeerId 20 | this.siteId = siteId 21 | this.isHost = (this.siteId === 1) 22 | this.uri = uri 23 | this.router = router 24 | this.emitDidDispose = didDispose || doNothing 25 | this.document = new Document({siteId, text, history}) 26 | this.nextMarkerLayerId = 1 27 | this.emitter = new Emitter() 28 | this.subscriptions = new CompositeDisposable() 29 | this.subscriptions.add( 30 | this.router.onNotification(`/buffers/${id}`, this.receiveUpdate.bind(this)) 31 | ) 32 | if (this.isHost) { 33 | this.subscriptions.add( 34 | this.router.onRequest(`/buffers/${id}`, this.receiveFetch.bind(this)), 35 | this.router.onNotification(`/buffers/${id}/save`, this.receiveSave.bind(this)) 36 | ) 37 | } else { 38 | this.subscriptions.add( 39 | this.router.onNotification(`/buffers/${id}/disposal`, this.dispose.bind(this)) 40 | ) 41 | } 42 | 43 | if (operations) this.integrateOperations(operations) 44 | } 45 | 46 | dispose () { 47 | this.subscriptions.dispose() 48 | if (this.delegate) this.delegate.dispose() 49 | if (this.isHost) this.router.notify({channelId: `/buffers/${this.id}/disposal`}) 50 | this.emitDidDispose() 51 | } 52 | 53 | serialize () { 54 | const bufferProxyMessage = new Messages.BufferProxy() 55 | bufferProxyMessage.setId(this.id) 56 | bufferProxyMessage.setUri(this.uri) 57 | bufferProxyMessage.setOperationsList(this.document.getOperations().map(serializeOperation)) 58 | return bufferProxyMessage 59 | } 60 | 61 | setDelegate (delegate) { 62 | this.delegate = delegate 63 | if (this.siteId !== 1 && this.delegate) { 64 | this.delegate.setText(this.document.getText()) 65 | } 66 | } 67 | 68 | getNextMarkerLayerId () { 69 | return this.nextMarkerLayerId++ 70 | } 71 | 72 | setTextInRange (oldStart, oldEnd, newText) { 73 | const operations = this.document.setTextInRange(oldStart, oldEnd, newText) 74 | this.broadcastOperations(operations) 75 | this.emitter.emit('did-update-text', {remote: false}) 76 | } 77 | 78 | setURI (uri) { 79 | assert(this.isHost, 'Only hosts can change the URI') 80 | this.uri = uri 81 | this.broadcastURIChange(uri) 82 | } 83 | 84 | getMarkers () { 85 | return this.document.getMarkers() 86 | } 87 | 88 | updateMarkers (markerUpdatesByLayerId, broadcastOperations = true) { 89 | const operations = this.document.updateMarkers(markerUpdatesByLayerId) 90 | if (broadcastOperations) this.broadcastOperations(operations) 91 | return operations 92 | } 93 | 94 | onDidUpdateMarkers (listener) { 95 | return this.emitter.on('did-update-markers', listener) 96 | } 97 | 98 | onDidUpdateText (listener) { 99 | return this.emitter.on('did-update-text', listener) 100 | } 101 | 102 | undo () { 103 | const undoEntry = this.document.undo() 104 | if (undoEntry) { 105 | const {operations, textUpdates, markers} = undoEntry 106 | this.broadcastOperations(operations) 107 | if (textUpdates.length > 0) { 108 | this.emitter.emit('did-update-text', {remote: false}) 109 | } 110 | return {textUpdates, markers} 111 | } else { 112 | return null 113 | } 114 | } 115 | 116 | redo () { 117 | const redoEntry = this.document.redo() 118 | if (redoEntry) { 119 | const {operations, textUpdates, markers} = redoEntry 120 | this.broadcastOperations(operations) 121 | if (textUpdates.length > 0) { 122 | this.emitter.emit('did-update-text', {remote: false}) 123 | } 124 | return {textUpdates, markers} 125 | } else { 126 | return null 127 | } 128 | } 129 | 130 | createCheckpoint (options) { 131 | return this.document.createCheckpoint(options) 132 | } 133 | 134 | getChangesSinceCheckpoint (checkpoint) { 135 | return this.document.getChangesSinceCheckpoint(checkpoint) 136 | } 137 | 138 | groupChangesSinceCheckpoint (checkpoint, options) { 139 | return this.document.groupChangesSinceCheckpoint(checkpoint, options) 140 | } 141 | 142 | groupLastChanges () { 143 | return this.document.groupLastChanges() 144 | } 145 | 146 | revertToCheckpoint (checkpoint, options) { 147 | const result = this.document.revertToCheckpoint(checkpoint, options) 148 | if (result) { 149 | const {operations, textUpdates, markers} = result 150 | this.broadcastOperations(operations) 151 | if (textUpdates.length > 0) { 152 | this.emitter.emit('did-update-text', {remote: false}) 153 | } 154 | return {textUpdates, markers} 155 | } else { 156 | return false 157 | } 158 | } 159 | 160 | applyGroupingInterval (groupingInterval) { 161 | this.document.applyGroupingInterval(groupingInterval) 162 | } 163 | 164 | getHistory (maxEntries) { 165 | return this.document.getHistory(maxEntries) 166 | } 167 | 168 | requestSave () { 169 | assert(!this.isHost, 'Only guests can request a save') 170 | this.router.notify({recipientId: this.hostPeerId, channelId: `/buffers/${this.id}/save`}) 171 | } 172 | 173 | receiveFetch ({requestId}) { 174 | this.router.respond({requestId, body: this.serialize().serializeBinary()}) 175 | } 176 | 177 | receiveUpdate ({body}) { 178 | const updateMessage = Messages.BufferProxyUpdate.deserializeBinary(body) 179 | if (updateMessage.hasOperationsUpdate()) { 180 | this.receiveOperationsUpdate(updateMessage.getOperationsUpdate()) 181 | } else if (updateMessage.hasUriUpdate()) { 182 | this.receiveURIUpdate(updateMessage.getUriUpdate()) 183 | } else { 184 | throw new Error('Received unknown update message') 185 | } 186 | } 187 | 188 | receiveOperationsUpdate (operationsUpdateMessage) { 189 | const operations = operationsUpdateMessage.getOperationsList().map(deserializeOperation) 190 | this.integrateOperations(operations) 191 | } 192 | 193 | receiveURIUpdate (uriUpdateMessage) { 194 | this.uri = uriUpdateMessage.getUri() 195 | this.delegate.didChangeURI(this.uri) 196 | } 197 | 198 | receiveSave () { 199 | this.delegate.save() 200 | } 201 | 202 | broadcastOperations (operations) { 203 | const operationsUpdateMessage = new Messages.BufferProxyUpdate.OperationsUpdate() 204 | operationsUpdateMessage.setOperationsList(operations.map(serializeOperation)) 205 | const updateMessage = new Messages.BufferProxyUpdate() 206 | updateMessage.setOperationsUpdate(operationsUpdateMessage) 207 | 208 | this.broadcastUpdate(updateMessage) 209 | } 210 | 211 | integrateOperations (operations) { 212 | const {textUpdates, markerUpdates} = this.document.integrateOperations(operations) 213 | if (this.delegate) this.delegate.updateText(textUpdates) 214 | this.emitter.emit('did-update-markers', markerUpdates) 215 | if (textUpdates.length > 0) { 216 | this.emitter.emit('did-update-text', {remote: true}) 217 | } 218 | } 219 | 220 | broadcastURIChange (uri) { 221 | const uriUpdateMessage = new Messages.BufferProxyUpdate.URIUpdate() 222 | uriUpdateMessage.setUri(uri) 223 | const updateMessage = new Messages.BufferProxyUpdate() 224 | updateMessage.setUriUpdate(uriUpdateMessage) 225 | 226 | this.broadcastUpdate(updateMessage) 227 | } 228 | 229 | broadcastUpdate (updateMessage) { 230 | this.router.notify({channelId: `/buffers/${this.id}`, body: updateMessage.serializeBinary()}) 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /lib/convert-to-protobuf-compatible-buffer.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | function convertToProtobufCompatibleBuffer (data) { 3 | if (data == null) return data 4 | 5 | if (!(data instanceof Buffer)) { 6 | data = Buffer.from(data) 7 | } 8 | // Hack to convince protocol buffers that this Buffer really *is* a Uint8Array 9 | data.constructor = Uint8Array 10 | return data 11 | } 12 | -------------------------------------------------------------------------------- /lib/editor-proxy-metadata.js: -------------------------------------------------------------------------------- 1 | const Messages = require('./teletype-client_pb') 2 | const {CompositeDisposable} = require('event-kit') 3 | const NOOP = () => {} 4 | 5 | module.exports = 6 | class EditorProxyMetadata { 7 | static deserialize (message, props) { 8 | return new EditorProxyMetadata(Object.assign({ 9 | id: message.getId(), 10 | bufferProxyId: message.getBufferProxyId(), 11 | bufferProxyURI: message.getBufferProxyUri() 12 | }, props)) 13 | } 14 | 15 | constructor ({id, bufferProxyId, bufferProxyURI, siteId, router, didDispose}) { 16 | this.id = id 17 | this.bufferProxyId = bufferProxyId 18 | this.bufferProxyURI = bufferProxyURI 19 | this.subscriptions = new CompositeDisposable() 20 | this.didDispose = didDispose || NOOP 21 | if (didDispose) { 22 | this.subscriptions.add( 23 | router.onNotification(`/buffers/${id}`, this.receiveBufferUpdate.bind(this)) 24 | ) 25 | this.subscriptions.add( 26 | router.onNotification(`/editors/${id}/disposal`, this.dispose.bind(this)) 27 | ) 28 | } 29 | } 30 | 31 | dispose () { 32 | this.subscriptions.dispose() 33 | this.didDispose() 34 | } 35 | 36 | serialize () { 37 | const message = new Messages.EditorProxyMetadata() 38 | message.setId(this.id) 39 | message.setBufferProxyId(this.bufferProxyId) 40 | message.setBufferProxyUri(this.bufferProxyURI) 41 | return message 42 | } 43 | 44 | receiveBufferUpdate ({body}) { 45 | const updateMessage = Messages.BufferProxyUpdate.deserializeBinary(body) 46 | if (updateMessage.hasUriUpdate()) { 47 | this.bufferProxyURI = updateMessage.getUriUpdate().getUri() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/editor-proxy.js: -------------------------------------------------------------------------------- 1 | const {CompositeDisposable, Emitter} = require('event-kit') 2 | const {serializeRemotePosition, deserializeRemotePosition} = require('@atom/teletype-crdt') 3 | const Messages = require('./teletype-client_pb') 4 | const FollowState = require('./follow-state') 5 | const NullEditorProxyDelegate = require('./null-editor-proxy-delegate') 6 | const EditorProxyMetadata = require('./editor-proxy-metadata') 7 | 8 | function doNothing () {} 9 | 10 | module.exports = 11 | class EditorProxy { 12 | static deserialize (message, props) { 13 | const id = message.getId() 14 | const bufferProxyId = message.getBufferProxyId() 15 | const bufferProxy = props.bufferProxiesById.get(bufferProxyId) 16 | 17 | const selectionLayerIdsBySiteId = new Map() 18 | message.getSelectionLayerIdsBySiteIdMap().forEach((layerId, siteId) => { 19 | selectionLayerIdsBySiteId.set(siteId, layerId) 20 | }) 21 | 22 | return new EditorProxy(Object.assign({id, bufferProxy, selectionLayerIdsBySiteId}, props)) 23 | } 24 | 25 | constructor ({id, bufferProxy, selectionLayerIdsBySiteId, selections, router, siteId, didDispose, portal}) { 26 | this.id = id 27 | this.siteId = siteId 28 | this.isHost = (this.siteId === 1) 29 | this.bufferProxy = bufferProxy 30 | this.router = router 31 | this.emitDidDispose = didDispose || doNothing 32 | this.selectionLayerIdsBySiteId = selectionLayerIdsBySiteId || new Map() 33 | this.localHiddenSelectionsLayerId = bufferProxy.getNextMarkerLayerId() 34 | this.delegate = new NullEditorProxyDelegate() 35 | this.emitter = new Emitter() 36 | this.selectionsVisible = true 37 | this.portal = portal 38 | this.createLocalSelectionsLayer(selections) 39 | 40 | this.subscriptions = new CompositeDisposable() 41 | this.subscriptions.add( 42 | this.bufferProxy.onDidUpdateMarkers(this.bufferProxyDidUpdateMarkers.bind(this)) 43 | ) 44 | this.subscriptions.add( 45 | this.router.onNotification(`/editors/${id}/updates`, this.receiveUpdate.bind(this)) 46 | ) 47 | if (this.isHost) { 48 | this.subscriptions.add( 49 | this.router.onRequest(`/editors/${id}`, this.receiveFetch.bind(this)) 50 | ) 51 | } else { 52 | this.subscriptions.add( 53 | this.router.onNotification(`/editors/${id}/disposal`, this.dispose.bind(this)) 54 | ) 55 | } 56 | } 57 | 58 | dispose () { 59 | this.subscriptions.dispose() 60 | this.delegate.dispose() 61 | if (this.isHost) this.router.notify({channelId: `/editors/${this.id}/disposal`}) 62 | this.emitDidDispose() 63 | } 64 | 65 | serialize () { 66 | const editorMessage = new Messages.EditorProxy() 67 | editorMessage.setId(this.id) 68 | editorMessage.setBufferProxyId(this.bufferProxy.id) 69 | 70 | const selectionLayerIdsBySiteIdMessage = editorMessage.getSelectionLayerIdsBySiteIdMap() 71 | this.selectionLayerIdsBySiteId.forEach((layerId, siteId) => { 72 | selectionLayerIdsBySiteIdMessage.set(siteId, layerId) 73 | }) 74 | 75 | return editorMessage 76 | } 77 | 78 | getMetadata () { 79 | return new EditorProxyMetadata({ 80 | id: this.id, 81 | bufferProxyId: this.bufferProxy.id, 82 | bufferProxyURI: this.bufferProxy.uri 83 | }) 84 | } 85 | 86 | setDelegate (delegate) { 87 | this.delegate = delegate || new NullEditorProxyDelegate() 88 | this.bufferProxyDidUpdateMarkers(this.bufferProxy.getMarkers(), {initialUpdate: true}) 89 | } 90 | 91 | createLocalSelectionsLayer (selections) { 92 | const localSelectionsLayerId = this.bufferProxy.getNextMarkerLayerId() 93 | this.selectionLayerIdsBySiteId.set(this.siteId, localSelectionsLayerId) 94 | 95 | const selectionsUpdateMessage = new Messages.EditorProxyUpdate.SelectionsUpdate() 96 | selectionsUpdateMessage.getSelectionLayerIdsBySiteIdMap().set(this.siteId, localSelectionsLayerId) 97 | const editorProxyUpdateMessage = new Messages.EditorProxyUpdate() 98 | editorProxyUpdateMessage.setSelectionsUpdate(selectionsUpdateMessage) 99 | 100 | this.router.notify({channelId: `/editors/${this.id}/updates`, body: editorProxyUpdateMessage.serializeBinary()}) 101 | 102 | if (selections) this.updateSelections(selections, {initialUpdate: true}) 103 | } 104 | 105 | updateSelections (selections = {}, options = {}) { 106 | this.bufferProxy.updateMarkers({ 107 | [this.localHiddenSelectionsLayerId]: selections 108 | }, false) 109 | 110 | if (this.selectionsVisible) { 111 | const localSelectionsLayerId = this.selectionLayerIdsBySiteId.get(this.siteId) 112 | this.bufferProxy.updateMarkers({ 113 | [localSelectionsLayerId]: selections 114 | }) 115 | } 116 | 117 | this.emitter.emit('did-update-local-selections', options) 118 | } 119 | 120 | bufferProxyDidUpdateMarkers (markerUpdates, options = {}) { 121 | const selectionLayerIdsBySiteId = new Map() 122 | 123 | for (let siteId in markerUpdates) { 124 | siteId = parseInt(siteId) 125 | if (siteId !== this.siteId) { 126 | const layersById = markerUpdates[siteId] 127 | for (let layerId in layersById) { 128 | layerId = parseInt(layerId) 129 | if (this.selectionLayerIdsBySiteId.get(siteId) === layerId) { 130 | const selections = layersById[layerId] 131 | this.delegate.updateSelectionsForSiteId(siteId, selections) 132 | selectionLayerIdsBySiteId.set(siteId, layerId) 133 | } 134 | } 135 | } 136 | } 137 | 138 | this.emitter.emit('did-update-remote-selections', {selectionLayerIdsBySiteId, initialUpdate: options.initialUpdate}) 139 | } 140 | 141 | didScroll (callback) { 142 | this.emitter.emit('did-scroll') 143 | } 144 | 145 | onDidScroll (callback) { 146 | return this.emitter.on('did-scroll', callback) 147 | } 148 | 149 | onDidUpdateLocalSelections (callback) { 150 | return this.emitter.on('did-update-local-selections', callback) 151 | } 152 | 153 | onDidUpdateRemoteSelections (callback) { 154 | return this.emitter.on('did-update-remote-selections', callback) 155 | } 156 | 157 | cursorPositionForSiteId (siteId) { 158 | let selections 159 | 160 | if (siteId === this.siteId) { 161 | selections = this.getLocalHiddenSelections() 162 | } else if (this.selectionLayerIdsBySiteId.has(siteId)) { 163 | const layers = this.bufferProxy.getMarkers()[siteId] 164 | const selectionLayerId = this.selectionLayerIdsBySiteId.get(siteId) 165 | selections = layers ? layers[selectionLayerId] : {} 166 | } else { 167 | selections = {} 168 | } 169 | 170 | const selectionIds = Object.keys(selections).map((key) => parseInt(key)) 171 | if (selectionIds.length > 0) { 172 | const lastSelection = selections[Math.max(...selectionIds)] 173 | return lastSelection.reversed ? lastSelection.range.start : lastSelection.range.end 174 | } 175 | } 176 | 177 | isScrollNeededToViewPosition (position) { 178 | return this.delegate.isScrollNeededToViewPosition(position) 179 | } 180 | 181 | hideSelections () { 182 | const localSelectionsLayerId = this.selectionLayerIdsBySiteId.get(this.siteId) 183 | if (this.selectionsVisible && localSelectionsLayerId) { 184 | const selectionsUpdate = {} 185 | for (const selectionId in this.getLocalHiddenSelections()) { 186 | selectionsUpdate[selectionId] = null 187 | } 188 | 189 | this.bufferProxy.updateMarkers({ 190 | [localSelectionsLayerId]: selectionsUpdate 191 | }) 192 | } 193 | 194 | this.selectionsVisible = false 195 | } 196 | 197 | showSelections () { 198 | const localSelectionsLayerId = this.selectionLayerIdsBySiteId.get(this.siteId) 199 | if (!this.selectionsVisible && localSelectionsLayerId) { 200 | this.bufferProxy.updateMarkers({ 201 | [localSelectionsLayerId]: this.getLocalHiddenSelections() 202 | }) 203 | } 204 | 205 | this.selectionsVisible = true 206 | } 207 | 208 | getLocalHiddenSelections () { 209 | const localLayers = this.bufferProxy.getMarkers()[this.siteId] 210 | const localHiddenSelectionsLayer = localLayers ? localLayers[this.localHiddenSelectionsLayerId] : null 211 | return localHiddenSelectionsLayer || {} 212 | } 213 | 214 | hostDidDisconnect () { 215 | this.selectionLayerIdsBySiteId.forEach((_, siteId) => { 216 | this.siteDidDisconnect(siteId) 217 | }) 218 | } 219 | 220 | siteDidDisconnect (siteId) { 221 | this.selectionLayerIdsBySiteId.delete(siteId) 222 | this.delegate.clearSelectionsForSiteId(siteId) 223 | } 224 | 225 | receiveFetch ({requestId}) { 226 | this.router.respond({requestId, body: this.serialize().serializeBinary()}) 227 | } 228 | 229 | receiveUpdate ({body}) { 230 | const updateMessage = Messages.EditorProxyUpdate.deserializeBinary(body) 231 | 232 | if (updateMessage.hasSelectionsUpdate()) { 233 | this.receiveSelectionsUpdate(updateMessage.getSelectionsUpdate()) 234 | } 235 | } 236 | 237 | receiveSelectionsUpdate (selectionsUpdate) { 238 | selectionsUpdate.getSelectionLayerIdsBySiteIdMap().forEach((layerId, siteId) => { 239 | this.selectionLayerIdsBySiteId.set(siteId, layerId) 240 | }) 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | class ClientOutOfDateError extends Error { 2 | constructor () { 3 | super(...arguments) 4 | } 5 | } 6 | 7 | class HTTPRequestError extends Error { 8 | constructor () { 9 | super(...arguments) 10 | } 11 | } 12 | 13 | class NetworkConnectionError extends Error { 14 | constructor () { 15 | super(...arguments) 16 | } 17 | } 18 | 19 | class InvalidAuthenticationTokenError extends Error { 20 | constructor () { 21 | super(...arguments) 22 | } 23 | } 24 | 25 | class UnexpectedAuthenticationError extends Error { 26 | constructor () { 27 | super(...arguments) 28 | } 29 | } 30 | 31 | class PeerConnectionError extends Error { 32 | constructor () { 33 | super(...arguments) 34 | } 35 | } 36 | 37 | class PortalCreationError extends Error { 38 | constructor () { 39 | super(...arguments) 40 | } 41 | } 42 | 43 | class PortalJoinError extends Error { 44 | constructor () { 45 | super(...arguments) 46 | } 47 | } 48 | 49 | class PortalNotFoundError extends Error { 50 | constructor () { 51 | super(...arguments) 52 | } 53 | } 54 | 55 | class PubSubConnectionError extends Error { 56 | constructor () { 57 | super(...arguments) 58 | } 59 | } 60 | 61 | module.exports = { 62 | ClientOutOfDateError, 63 | HTTPRequestError, 64 | NetworkConnectionError, 65 | InvalidAuthenticationTokenError, 66 | UnexpectedAuthenticationError, 67 | PeerConnectionError, 68 | PortalCreationError, 69 | PortalJoinError, 70 | PortalNotFoundError, 71 | PubSubConnectionError 72 | } 73 | -------------------------------------------------------------------------------- /lib/follow-state.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | DISCONNECTED: 0, 3 | RETRACTED: 1, 4 | EXTENDED: 2 5 | } 6 | -------------------------------------------------------------------------------- /lib/null-editor-proxy-delegate.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | class NullEditorProxyDelegate { 3 | constructor () {} 4 | 5 | dispose () {} 6 | 7 | isScrollNeededToViewPosition () {} 8 | 9 | updateActivePositions () {} 10 | 11 | updateSelectionsForSiteId () {} 12 | 13 | clearSelectionsForSiteId () {} 14 | 15 | updateTether () {} 16 | } 17 | -------------------------------------------------------------------------------- /lib/null-portal-delegate.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | class NullPortalDelegate { 3 | dispose () {} 4 | 5 | updateTether () {} 6 | 7 | updateActivePositions () {} 8 | 9 | hostDidLoseConnection () {} 10 | 11 | hostDidClosePortal () {} 12 | 13 | siteDidLeave () {} 14 | 15 | siteDidJoin () {} 16 | 17 | didChangeEditorProxies () {} 18 | } 19 | -------------------------------------------------------------------------------- /lib/peer-connection.js: -------------------------------------------------------------------------------- 1 | require('webrtc-adapter') 2 | 3 | const MAX_SEND_RETRY_COUNT = 5 4 | const MULTIPART_MASK = 0b00000001 5 | const DISCONNECT_MASK = 0b00000010 6 | 7 | const convertToProtobufCompatibleBuffer = require('./convert-to-protobuf-compatible-buffer') 8 | const Errors = require('./errors') 9 | 10 | module.exports = 11 | class PeerConnection { 12 | constructor (props) { 13 | const { 14 | localPeerId, remotePeerId, fragmentSize, iceServers, connectionTimeout, 15 | signalingProvider, didReceiveMessage, didDisconnect, didError 16 | } = props 17 | 18 | this.localPeerId = localPeerId 19 | this.remotePeerId = remotePeerId 20 | this.fragmentSize = fragmentSize 21 | this.iceServers = iceServers 22 | this.connectionTimeout = connectionTimeout 23 | this.signalingProvider = signalingProvider 24 | this.didReceiveMessage = didReceiveMessage 25 | this.didDisconnect = didDisconnect 26 | this.didError = didError 27 | 28 | this.receivedSignals = {} 29 | this.incomingSignalSequenceNumber = 0 30 | this.outgoingSignalSequenceNumber = 0 31 | 32 | this.state = 'initial' 33 | this.initiator = false 34 | 35 | this.signalingProvider.receive = this.receiveSignal.bind(this) 36 | 37 | this.rtcPeerConnection = new RTCPeerConnection({iceServers}) 38 | this.rtcPeerConnection.oniceconnectionstatechange = this.handleConnectionStateChange.bind(this) 39 | this.rtcPeerConnection.onicecandidate = this.handleICECandidate.bind(this) 40 | this.rtcPeerConnection.ondatachannel = this.handleDataChannel.bind(this) 41 | this.rtcPeerConnection.onerror = this.handleError.bind(this) 42 | 43 | this.connectedPromise = new Promise((resolve, reject) => { 44 | this.resolveConnectedPromise = resolve 45 | this.rejectConnectedPromise = reject 46 | }) 47 | this.disconnectedPromise = new Promise((resolve) => this.resolveDisconnectedPromise = resolve) 48 | } 49 | 50 | connect () { 51 | if (this.state === 'initial') { 52 | this.state = 'connecting' 53 | this.initiator = true 54 | this.rtcPeerConnection.onnegotiationneeded = this.handleNegotiationNeeded.bind(this) 55 | this.negotiationNeeded = true 56 | const channel = this.rtcPeerConnection.createDataChannel(null, {ordered: true}) 57 | this.handleDataChannel({channel}) 58 | const timeoutError = new Errors.PeerConnectionError('Connecting to peer timed out') 59 | setTimeout(() => { 60 | if (this.state === 'connecting') { 61 | this.disconnect() 62 | this.rejectConnectedPromise(timeoutError) 63 | } 64 | }, this.connectionTimeout) 65 | } 66 | 67 | return this.getConnectedPromise() 68 | } 69 | 70 | getConnectedPromise () { 71 | return this.connectedPromise 72 | } 73 | 74 | getDisconnectedPromise () { 75 | return this.disconnectedPromise 76 | } 77 | 78 | disconnect () { 79 | if (this.state === 'disconnected') return 80 | this.state = 'disconnected' 81 | 82 | this.didDisconnect(this.remotePeerId) 83 | this.resolveDisconnectedPromise() 84 | 85 | // Give channel a chance to flush. This helps avoid flaky tests where 86 | // the a star network hub disconnects all its peers and needs to 87 | // inform the remaining peers of the disconnection as each peer leaves. 88 | process.nextTick(() => { 89 | if (this.channel) { 90 | try { 91 | this.channel.send(Buffer.alloc(1, DISCONNECT_MASK)) 92 | } catch (e) { 93 | // Ignore the exception since the connection is about to be closed. 94 | } finally { 95 | this.channel.close() 96 | } 97 | } 98 | 99 | if (this.rtcPeerConnection.signalingState !== 'closed') { 100 | this.rtcPeerConnection.close() 101 | } 102 | }) 103 | } 104 | 105 | async handleNegotiationNeeded () { 106 | if (!this.negotiationNeeded || this.state === 'disconnected') return 107 | this.negotiationNeeded = false 108 | const offer = await this.rtcPeerConnection.createOffer() 109 | await this.rtcPeerConnection.setLocalDescription(offer) 110 | try { 111 | await this.sendSignal({offer, senderId: this.localPeerId}) 112 | } catch (error) { 113 | this.disconnect() 114 | this.rejectConnectedPromise(error) 115 | } 116 | } 117 | 118 | async handleICECandidate ({candidate}) { 119 | try { 120 | await this.sendSignal({candidate}) 121 | } catch (error) { 122 | this.disconnect() 123 | if (this.initiator) { 124 | this.rejectConnectedPromise(error) 125 | } else { 126 | this.handleError(error) 127 | } 128 | } 129 | } 130 | 131 | handleDataChannel ({channel}) { 132 | this.channel = channel 133 | this.channel.binaryType = 'arraybuffer' 134 | this.channel.onerror = this.handleError.bind(this) 135 | this.channel.onmessage = ({data}) => this.receive(Buffer.from(data)) 136 | this.channel.onclose = () => this.disconnect() 137 | 138 | if (this.channel.readyState === 'open') { 139 | this.handleConnectionStateChange() 140 | } else { 141 | this.channel.onopen = this.handleConnectionStateChange.bind(this) 142 | } 143 | } 144 | 145 | handleConnectionStateChange () { 146 | if (this.isConnectionOpen() && this.state !== 'connected') { 147 | this.state = 'connected' 148 | this.resolveConnectedPromise() 149 | } else if (this.isConnectionClosed() && this.state !== 'disconnected') { 150 | this.disconnect() 151 | } 152 | } 153 | 154 | isConnectionOpen () { 155 | const {iceConnectionState} = this.rtcPeerConnection 156 | return ( 157 | (iceConnectionState === 'connected' || iceConnectionState === 'completed') && 158 | this.channel && this.channel.readyState === 'open' 159 | ) 160 | } 161 | 162 | isConnectionClosed () { 163 | const {iceConnectionState, signalingState} = this.rtcPeerConnection 164 | return ( 165 | iceConnectionState === 'closed' || 166 | iceConnectionState === 'failed' || 167 | (iceConnectionState === 'disconnected' && signalingState === 'stable') 168 | ) 169 | } 170 | 171 | handleError (event) { 172 | this.didError({peerId: this.remotePeerId, event}) 173 | } 174 | 175 | sendSignal (signal) { 176 | if (this.state !== 'disconnected') { 177 | return this.signalingProvider.send(signal) 178 | } 179 | } 180 | 181 | async receiveSignal (signal) { 182 | if (this.state === 'disconnected') return 183 | 184 | if (signal.offer) { 185 | await this.rtcPeerConnection.setRemoteDescription(signal.offer) 186 | const answer = await this.rtcPeerConnection.createAnswer() 187 | await this.rtcPeerConnection.setLocalDescription(answer) 188 | try { 189 | await this.sendSignal({answer}) 190 | } catch (error) { 191 | this.disconnect() 192 | this.handleError(error) 193 | } 194 | } else if (signal.answer) { 195 | await this.rtcPeerConnection.setRemoteDescription(signal.answer) 196 | } else if (signal.candidate) { 197 | this.rtcPeerConnection.addIceCandidate(signal.candidate) 198 | } 199 | } 200 | 201 | send (message) { 202 | if (this.state !== 'connected') throw new Error('Must be connected to send') 203 | 204 | message = convertToProtobufCompatibleBuffer(message) 205 | 206 | let multiPartByte = 0 207 | let envelopeSize = message.length + 1 208 | if (envelopeSize > this.fragmentSize) { 209 | multiPartByte = 1 210 | envelopeSize += 4 211 | } 212 | 213 | const envelope = Buffer.alloc(envelopeSize) 214 | let offset = 0 215 | envelope.writeUInt8(multiPartByte, offset) 216 | offset++ 217 | if (envelopeSize > this.fragmentSize) { 218 | envelope.writeUInt32BE(envelopeSize, offset) 219 | offset += 4 220 | } 221 | message.copy(envelope, offset) 222 | 223 | if (envelopeSize > this.fragmentSize) { 224 | let messageOffset = 0 225 | while (messageOffset < envelopeSize) { 226 | this.channel.send(envelope.slice(messageOffset, messageOffset + this.fragmentSize)) 227 | messageOffset += this.fragmentSize 228 | } 229 | } else { 230 | let retryCount = 0 231 | while (true) { 232 | // Calling send on the data channel sometimes throws in tests 233 | // even when the channel's readyState is 'open'. It always seems 234 | // to work on the first retry, but we can retry a few times. 235 | try { 236 | this.channel.send(envelope) 237 | break 238 | } catch (error) { 239 | if (retryCount++ === MAX_SEND_RETRY_COUNT) throw error 240 | } 241 | } 242 | } 243 | } 244 | 245 | receive (data) { 246 | if (this.state !== 'connected') return 247 | 248 | if (this.incomingMultipartEnvelope) { 249 | data.copy(this.incomingMultipartEnvelope, this.incomingMultipartEnvelopeOffset) 250 | this.incomingMultipartEnvelopeOffset += data.length 251 | if (this.incomingMultipartEnvelopeOffset === this.incomingMultipartEnvelope.length) { 252 | this.finishReceiving(this.incomingMultipartEnvelope) 253 | this.incomingMultipartEnvelope = null 254 | this.incomingMultipartEnvelopeOffset = 0 255 | } 256 | } else { 257 | const metadataByte = data.readUInt8(0) 258 | 259 | if (metadataByte & MULTIPART_MASK) { 260 | const envelopeSize = data.readUInt32BE(1) 261 | this.incomingMultipartEnvelope = Buffer.alloc(envelopeSize) 262 | data.copy(this.incomingMultipartEnvelope, 0) 263 | this.incomingMultipartEnvelopeOffset = data.length 264 | } else if (metadataByte & DISCONNECT_MASK) { 265 | this.disconnect() 266 | } else { 267 | this.finishReceiving(data) 268 | } 269 | } 270 | } 271 | 272 | finishReceiving (envelope) { 273 | const multiPartByte = envelope.readUInt8(0) 274 | const startOffset = (multiPartByte & 1) ? 5 : 1 275 | const message = convertToProtobufCompatibleBuffer(envelope.slice(startOffset)) 276 | this.didReceiveMessage({senderId: this.remotePeerId, message}) 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /lib/peer-pool.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const {CompositeDisposable, Disposable, Emitter} = require('event-kit') 3 | const PeerConnection = require('./peer-connection') 4 | const PubSubSignalingProvider = require('./pub-sub-signaling-provider') 5 | const Errors = require('./errors') 6 | 7 | module.exports = 8 | class PeerPool { 9 | constructor ({peerId, peerIdentity, restGateway, pubSubGateway, fragmentSize, connectionTimeout, testEpoch}) { 10 | this.peerId = peerId 11 | this.restGateway = restGateway 12 | this.pubSubGateway = pubSubGateway 13 | this.fragmentSize = fragmentSize 14 | this.connectionTimeout = connectionTimeout || 5000 15 | this.testEpoch = testEpoch 16 | this.emitter = new Emitter() 17 | this.subscriptions = new CompositeDisposable() 18 | this.peerConnectionsById = new Map() 19 | this.peerIdentitiesById = new Map([ 20 | [peerId, peerIdentity] 21 | ]) 22 | this.disposed = false 23 | this.listenersCount = 0 24 | } 25 | 26 | async initialize () { 27 | await this.fetchICEServers() 28 | } 29 | 30 | async listen () { 31 | if (!this.listenPromise) { 32 | const timeoutError = new Errors.PubSubConnectionError('Timed out while subscribing to incoming signals') 33 | this.listenPromise = new Promise(async (resolve, reject) => { 34 | let rejected = false 35 | const timeoutId = window.setTimeout(() => { 36 | this.listenPromise = null 37 | reject(timeoutError) 38 | rejected = true 39 | }, this.connectionTimeout) 40 | 41 | const subscription = await this.pubSubGateway.subscribe( 42 | `/peers/${this.peerId}`, 43 | 'signal', 44 | this.didReceiveSignal.bind(this) 45 | ) 46 | if (rejected) { 47 | subscription.dispose() 48 | } else { 49 | window.clearTimeout(timeoutId) 50 | this.subscriptions.add(subscription) 51 | resolve(subscription) 52 | } 53 | }) 54 | } 55 | 56 | this.listenersCount++ 57 | const subscription = await this.listenPromise 58 | return new Disposable(() => { 59 | this.listenersCount-- 60 | if (this.listenersCount === 0) { 61 | this.listenPromise = null 62 | subscription.dispose() 63 | } 64 | }) 65 | } 66 | 67 | dispose () { 68 | this.disposed = true 69 | this.subscriptions.dispose() 70 | this.peerIdentitiesById.clear() 71 | this.disconnect() 72 | } 73 | 74 | async fetchICEServers () { 75 | const {body: iceServers, ok} = await this.restGateway.get('/ice-servers') 76 | assert(Array.isArray(iceServers), 'ICE servers must be an Array') 77 | this.iceServers = iceServers 78 | } 79 | 80 | getLocalPeerIdentity () { 81 | return this.peerIdentitiesById.get(this.peerId) 82 | } 83 | 84 | async connectTo (peerId) { 85 | if (this.peerId === peerId) { 86 | throw new Errors.PeerConnectionError('Sorry. You can\'t connect to yourself this way. Maybe try meditation or a walk in the woods instead?') 87 | } 88 | 89 | const peerConnection = this.getPeerConnection(peerId) 90 | 91 | try { 92 | await peerConnection.connect() 93 | } catch (error) { 94 | this.peerConnectionsById.delete(peerId) 95 | throw error 96 | } 97 | } 98 | 99 | getConnectedPromise (peerId) { 100 | return this.getPeerConnection(peerId).getConnectedPromise() 101 | } 102 | 103 | getDisconnectedPromise (peerId) { 104 | if (this.peerConnectionsById.has(peerId)) { 105 | return this.peerConnectionsById.get(peerId).getDisconnectedPromise() 106 | } else { 107 | return Promise.resolve() 108 | } 109 | } 110 | 111 | disconnect () { 112 | this.peerConnectionsById.forEach((peerConnection) => { 113 | peerConnection.disconnect() 114 | }) 115 | this.peerConnectionsById.clear() 116 | } 117 | 118 | send (peerId, message) { 119 | const peerConnection = this.peerConnectionsById.get(peerId) 120 | if (peerConnection) { 121 | peerConnection.send(message) 122 | } else { 123 | throw new Error('No connection to peer') 124 | } 125 | } 126 | 127 | onDisconnection (callback) { 128 | return this.emitter.on('disconnection', callback) 129 | } 130 | 131 | onReceive (callback) { 132 | return this.emitter.on('receive', callback) 133 | } 134 | 135 | onError (callback) { 136 | return this.emitter.on('error', callback) 137 | } 138 | 139 | isConnectedToPeer (peerId) { 140 | const peerConnection = this.peerConnectionsById.get(peerId) 141 | return peerConnection ? (peerConnection.state === 'connected') : false 142 | } 143 | 144 | getPeerIdentity (peerId) { 145 | return this.peerIdentitiesById.get(peerId) 146 | } 147 | 148 | didReceiveSignal (message) { 149 | const {senderId, senderIdentity} = message 150 | if (senderIdentity) this.peerIdentitiesById.set(senderId, senderIdentity) 151 | const peerConnection = this.getPeerConnection(senderId) 152 | peerConnection.signalingProvider.receiveMessage(message) 153 | } 154 | 155 | didDisconnect (peerId) { 156 | this.peerConnectionsById.delete(peerId) 157 | this.emitter.emit('disconnection', {peerId}) 158 | } 159 | 160 | didReceiveMessage (event) { 161 | this.emitter.emit('receive', event) 162 | } 163 | 164 | peerConnectionDidError ({peerId, event}) { 165 | this.didDisconnect(peerId) 166 | this.emitter.emit('error', event) 167 | } 168 | 169 | getPeerConnection (peerId) { 170 | let peerConnection = this.peerConnectionsById.get(peerId) 171 | if (!peerConnection) { 172 | peerConnection = new PeerConnection({ 173 | localPeerId: this.peerId, 174 | remotePeerId: peerId, 175 | fragmentSize: this.fragmentSize, 176 | iceServers: this.iceServers, 177 | connectionTimeout: this.connectionTimeout, 178 | didReceiveMessage: this.didReceiveMessage.bind(this), 179 | didDisconnect: this.didDisconnect.bind(this), 180 | didError: this.peerConnectionDidError.bind(this), 181 | signalingProvider: new PubSubSignalingProvider({ 182 | localPeerId: this.peerId, 183 | remotePeerId: peerId, 184 | restGateway: this.restGateway, 185 | testEpoch: this.testEpoch 186 | }) 187 | }) 188 | this.peerConnectionsById.set(peerId, peerConnection) 189 | } 190 | return peerConnection 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /lib/portal.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const {CompositeDisposable} = require('event-kit') 3 | const Router = require('./router') 4 | const BufferProxy = require('./buffer-proxy') 5 | const EditorProxy = require('./editor-proxy') 6 | const EditorProxyMetadata = require('./editor-proxy-metadata') 7 | const StarOverlayNetwork = require('./star-overlay-network') 8 | const Messages = require('./teletype-client_pb') 9 | const NullPortalDelegate = require('./null-portal-delegate') 10 | const FollowState = require('./follow-state') 11 | 12 | module.exports = 13 | class Portal { 14 | constructor ({id, hostPeerId, siteId, peerPool, connectionTimeout, tetherDisconnectWindow}) { 15 | this.id = id 16 | this.hostPeerId = hostPeerId 17 | this.siteId = siteId 18 | this.tetherDisconnectWindow = tetherDisconnectWindow 19 | this.isHost = isHostSiteId(this.siteId) 20 | this.siteIdsByPeerId = new Map() 21 | this.peerIdsBySiteId = new Map() 22 | this.editorProxiesById = new Map() 23 | this.bufferProxiesById = new Map() 24 | this.activeEditorProxiesBySiteId = new Map() 25 | this.activeEditorProxySubscriptions = new CompositeDisposable() 26 | this.tethersByFollowerId = new Map() 27 | this.disposables = new CompositeDisposable() 28 | this.disposed = false 29 | this.delegate = new NullPortalDelegate() 30 | 31 | this.peerPool = peerPool 32 | this.network = new StarOverlayNetwork({id, isHub: this.isHost, peerPool, connectionTimeout}) 33 | this.router = new Router(this.network) 34 | 35 | if (this.isHost) { 36 | this.bindPeerIdToSiteId(this.network.getPeerId(), this.siteId) 37 | this.nextSiteId = 2 38 | this.nextBufferId = 1 39 | this.nextEditorId = 1 40 | this.disposables.add( 41 | this.router.onRequest(`/portals/${id}`, this.receiveSubscription.bind(this)) 42 | ) 43 | } else { 44 | this.editorProxiesMetadataById = new Map() 45 | } 46 | 47 | this.disposables.add( 48 | this.router.onNotification(`/portals/${id}`, this.receiveUpdate.bind(this)), 49 | this.network.onMemberLeave(this.siteDidLeave.bind(this)) 50 | ) 51 | } 52 | 53 | getLocalSiteId () { 54 | return this.siteId 55 | } 56 | 57 | dispose () { 58 | this.editorProxiesById.forEach((editorProxy) => { 59 | editorProxy.dispose() 60 | }) 61 | 62 | this.bufferProxiesById.forEach((bufferProxy) => { 63 | bufferProxy.dispose() 64 | }) 65 | 66 | this.disposables.dispose() 67 | this.router.dispose() 68 | this.network.dispose() 69 | 70 | this.delegate.dispose() 71 | this.disposed = true 72 | } 73 | 74 | async setDelegate (delegate) { 75 | this.delegate = delegate || new NullPortalDelegate() 76 | 77 | await this.delegate.updateTether( 78 | this.resolveFollowState(), 79 | this.getLocalActiveEditorProxy(), 80 | this.resolveLeaderPosition() 81 | ) 82 | } 83 | 84 | async initialize () { 85 | try { 86 | this.disposables.add(await this.peerPool.listen()) 87 | } catch (error) { 88 | this.dispose() 89 | throw error 90 | } 91 | } 92 | 93 | async join () { 94 | try { 95 | this.networkConnectionPromise = this.network.connectTo(this.hostPeerId) 96 | await this.networkConnectionPromise 97 | } catch (error) { 98 | this.dispose() 99 | throw error 100 | } 101 | const rawResponse = await this.router.request({recipientId: this.hostPeerId, channelId: `/portals/${this.id}`}) 102 | const response = Messages.PortalSubscriptionResponse.deserializeBinary(rawResponse.body) 103 | 104 | response.getSiteIdsByPeerIdMap().forEach((siteId, peerId) => { 105 | this.bindPeerIdToSiteId(peerId, siteId) 106 | }) 107 | this.siteId = this.siteIdsByPeerId.get(this.network.getPeerId()) 108 | 109 | response.getEditorProxiesMetadataList().forEach((editorProxyMetadataMessage) => { 110 | this.deserializeEditorProxyMetadata(editorProxyMetadataMessage) 111 | }) 112 | 113 | const tethers = response.getTethersList() 114 | for (let i = 0; i < tethers.length; i++) { 115 | const tether = tethers[i] 116 | this.tethersByFollowerId.set(tether.getFollowerSiteId(), { 117 | leaderId: tether.getLeaderSiteId(), 118 | state: tether.getState() 119 | }) 120 | } 121 | 122 | const activeBufferProxies = response.getActiveBufferProxiesList() 123 | for (let i = 0; i < activeBufferProxies.length; i++) { 124 | const bufferProxyMessage = activeBufferProxies[i] 125 | this.deserializeBufferProxy(bufferProxyMessage) 126 | } 127 | 128 | const activeEditorProxies = response.getActiveEditorProxiesList() 129 | for (let i = 0; i < activeEditorProxies.length; i++) { 130 | const editorProxyMessage = activeEditorProxies[i] 131 | this.deserializeEditorProxy(editorProxyMessage) 132 | } 133 | 134 | response.getActiveEditorProxyIdsBySiteIdMap().forEach((editorProxyId, siteId) => { 135 | const editorProxy = this.editorProxiesById.get(editorProxyId) 136 | this.activeEditorProxiesBySiteId.set(siteId, editorProxy) 137 | }) 138 | 139 | this.follow(1) 140 | } 141 | 142 | createBufferProxy (props) { 143 | const id = this.nextBufferId++ 144 | const bufferProxy = new BufferProxy(Object.assign({ 145 | id, 146 | hostPeerId: this.hostPeerId, 147 | siteId: this.siteId, 148 | router: this.router, 149 | didDispose: () => this.bufferProxiesById.delete(id) 150 | }, props)) 151 | this.bufferProxiesById.set(id, bufferProxy) 152 | return bufferProxy 153 | } 154 | 155 | async findOrFetchBufferProxy (id) { 156 | if (id == null) return 157 | 158 | let bufferProxy = this.bufferProxiesById.get(id) 159 | if (!bufferProxy && !this.isHost) { 160 | bufferProxy = await this.fetchBufferProxy(id) 161 | } 162 | 163 | return bufferProxy 164 | } 165 | 166 | async fetchBufferProxy (id) { 167 | const response = await this.router.request({recipientId: this.hostPeerId, channelId: `/buffers/${id}`}) 168 | if (response.ok) { 169 | const bufferProxyMessage = Messages.BufferProxy.deserializeBinary(response.body) 170 | return this.deserializeBufferProxy(bufferProxyMessage) 171 | } 172 | } 173 | 174 | deserializeBufferProxy (message) { 175 | const bufferProxy = BufferProxy.deserialize(message, { 176 | router: this.router, 177 | hostPeerId: this.hostPeerId, 178 | siteId: this.siteId, 179 | didDispose: () => this.bufferProxiesById.delete(bufferProxy.id) 180 | }) 181 | this.bufferProxiesById.set(bufferProxy.id, bufferProxy) 182 | return bufferProxy 183 | } 184 | 185 | createEditorProxy (props) { 186 | const id = this.nextEditorId++ 187 | const editorProxy = new EditorProxy(Object.assign({ 188 | id, 189 | siteId: this.siteId, 190 | router: this.router, 191 | tetherDisconnectWindow: this.tetherDisconnectWindow, 192 | didDispose: () => this.editorProxiesById.delete(id), 193 | portal: this 194 | }, props)) 195 | this.editorProxiesById.set(id, editorProxy) 196 | this.broadcastEditorProxyCreation(editorProxy) 197 | 198 | return editorProxy 199 | } 200 | 201 | async findOrFetchEditorProxy (id) { 202 | let editorProxy = this.editorProxiesById.get(id) 203 | if (!editorProxy && !this.isHost) { 204 | editorProxy = await this.fetchEditorProxy(id) 205 | } 206 | 207 | return editorProxy 208 | } 209 | 210 | async fetchEditorProxy (id) { 211 | if (id == null) return 212 | 213 | const response = await this.router.request({recipientId: this.hostPeerId, channelId: `/editors/${id}`}) 214 | if (!response.ok) return 215 | 216 | const editorProxyMessage = Messages.EditorProxy.deserializeBinary(response.body) 217 | const bufferProxy = await this.findOrFetchBufferProxy(editorProxyMessage.getBufferProxyId()) 218 | if (bufferProxy) { 219 | const editorProxy = this.deserializeEditorProxy(editorProxyMessage) 220 | editorProxy.hideSelections() 221 | return editorProxy 222 | } 223 | } 224 | 225 | deserializeEditorProxy (message) { 226 | const editorProxy = EditorProxy.deserialize(message, { 227 | router: this.router, 228 | siteId: this.siteId, 229 | bufferProxiesById: this.bufferProxiesById, 230 | didDispose: () => { 231 | this.delegate.didChangeEditorProxies() 232 | this.editorProxiesById.delete(editorProxy.id) 233 | }, 234 | portal: this 235 | }) 236 | this.editorProxiesById.set(editorProxy.id, editorProxy) 237 | 238 | const editorProxyMetadata = this.editorProxiesMetadataById.get(editorProxy.id) 239 | if (editorProxyMetadata) editorProxyMetadata.dispose() 240 | 241 | return editorProxy 242 | } 243 | 244 | deserializeEditorProxyMetadata (message) { 245 | const editorProxyMetadata = EditorProxyMetadata.deserialize(message, { 246 | siteId: this.siteId, 247 | router: this.router, 248 | didDispose: () => { 249 | if (!this.editorProxiesById.has(editorProxyMetadata.id)) { 250 | this.delegate.didChangeEditorProxies() 251 | } 252 | 253 | this.editorProxiesMetadataById.delete(editorProxyMetadata.id) 254 | } 255 | }) 256 | this.editorProxiesMetadataById.set(editorProxyMetadata.id, editorProxyMetadata) 257 | return editorProxyMetadata 258 | } 259 | 260 | activateEditorProxy (newEditorProxy) { 261 | const oldEditorProxy = this.getLocalActiveEditorProxy() 262 | if (newEditorProxy != oldEditorProxy) { 263 | this.unfollow() 264 | if (oldEditorProxy) oldEditorProxy.hideSelections() 265 | if (newEditorProxy) newEditorProxy.showSelections() 266 | 267 | this.activeEditorProxiesBySiteId.set(this.siteId, newEditorProxy) 268 | this.subscribeToEditorProxyChanges(newEditorProxy) 269 | this.broadcastEditorProxySwitch(newEditorProxy) 270 | 271 | this.updateActivePositions() 272 | } 273 | } 274 | 275 | getLocalActiveEditorProxy () { 276 | return this.activeEditorProxyForSiteId(this.siteId) 277 | } 278 | 279 | activeEditorProxyForSiteId (siteId) { 280 | const leaderId = this.resolveLeaderSiteId(siteId) 281 | const followState = this.resolveFollowState(siteId) 282 | if (followState === FollowState.RETRACTED) { 283 | return this.activeEditorProxiesBySiteId.get(leaderId) 284 | } else { 285 | return this.activeEditorProxiesBySiteId.get(siteId) 286 | } 287 | } 288 | 289 | getEditorProxiesMetadata () { 290 | const editorProxiesMetadata = [] 291 | 292 | this.editorProxiesMetadataById.forEach((editorProxyMetadata) => { 293 | editorProxiesMetadata.push(editorProxyMetadata) 294 | }) 295 | 296 | this.editorProxiesById.forEach((editorProxy) => { 297 | editorProxiesMetadata.push(editorProxy.getMetadata()) 298 | }) 299 | 300 | return editorProxiesMetadata 301 | } 302 | 303 | getEditorProxyMetadata (editorProxyId) { 304 | const editorProxy = this.editorProxiesById.get(editorProxyId) 305 | if (editorProxy) { 306 | return editorProxy.getMetadata() 307 | } else { 308 | return this.editorProxiesMetadataById.get(editorProxyId) 309 | } 310 | } 311 | 312 | getActiveSiteIds () { 313 | const connectedMemberIds = this.network.getMemberIds() 314 | const activeSiteIds = [] 315 | for (let i = 0; i < connectedMemberIds.length; i++) { 316 | const memberId = connectedMemberIds[i] 317 | const siteId = this.siteIdsByPeerId.get(memberId) 318 | if (siteId != null) activeSiteIds.push(siteId) 319 | } 320 | return activeSiteIds 321 | } 322 | 323 | getSiteIdentity (siteId) { 324 | const peerId = this.peerIdsBySiteId.get(siteId) 325 | return this.network.getMemberIdentity(peerId) 326 | } 327 | 328 | siteDidLeave ({peerId, connectionLost}) { 329 | const siteId = this.siteIdsByPeerId.get(peerId) 330 | 331 | this.editorProxiesById.forEach((editorProxy) => { 332 | if (isHostSiteId(siteId)) { 333 | editorProxy.hostDidDisconnect() 334 | } else { 335 | editorProxy.siteDidDisconnect(siteId) 336 | } 337 | }) 338 | 339 | if (isHostSiteId(siteId)) { 340 | if (connectionLost) { 341 | this.delegate.hostDidLoseConnection() 342 | } else { 343 | this.delegate.hostDidClosePortal() 344 | } 345 | 346 | this.dispose() 347 | } else { 348 | this.delegate.siteDidLeave(siteId) 349 | } 350 | 351 | const tether = this.tethersByFollowerId.get(this.siteId) 352 | if (tether && siteId === tether.leaderId) { 353 | this.unfollow() 354 | } 355 | this.tethersByFollowerId.delete(siteId) 356 | this.activeEditorProxiesBySiteId.delete(siteId) 357 | this.updateActivePositions() 358 | } 359 | 360 | receiveSubscription ({senderId, requestId}) { 361 | this.assignNewSiteId(senderId) 362 | this.sendSubscriptionResponse(requestId) 363 | this.delegate.siteDidJoin(this.siteIdsByPeerId.get(senderId)) 364 | this.updateActivePositions() 365 | } 366 | 367 | assignNewSiteId (peerId) { 368 | const siteId = this.nextSiteId++ 369 | this.bindPeerIdToSiteId(peerId, siteId) 370 | 371 | const siteAssignmentMessage = new Messages.PortalUpdate.SiteAssignment() 372 | siteAssignmentMessage.setPeerId(peerId) 373 | siteAssignmentMessage.setSiteId(siteId) 374 | const updateMessage = new Messages.PortalUpdate() 375 | updateMessage.setSiteAssignment(siteAssignmentMessage) 376 | 377 | this.router.notify({channelId: `/portals/${this.id}`, body: updateMessage.serializeBinary()}) 378 | } 379 | 380 | sendSubscriptionResponse (requestId) { 381 | const response = new Messages.PortalSubscriptionResponse() 382 | 383 | this.siteIdsByPeerId.forEach((siteId, peerId) => { 384 | response.getSiteIdsByPeerIdMap().set(peerId, siteId) 385 | }) 386 | 387 | const editorProxiesMetadata = [] 388 | this.editorProxiesById.forEach((editorProxy) => { 389 | editorProxiesMetadata.push(editorProxy.getMetadata().serialize()) 390 | }) 391 | response.setEditorProxiesMetadataList(editorProxiesMetadata) 392 | 393 | const activeBufferProxiesById = new Map() 394 | const activeEditorProxiesById = new Map() 395 | this.activeEditorProxiesBySiteId.forEach((editorProxy, siteId) => { 396 | if (editorProxy) { 397 | const {bufferProxy} = editorProxy 398 | if (!activeBufferProxiesById.has(bufferProxy.id)) { 399 | activeBufferProxiesById.set(bufferProxy.id, bufferProxy.serialize()) 400 | } 401 | 402 | if (!activeEditorProxiesById.has(editorProxy.id)) { 403 | activeEditorProxiesById.set(editorProxy.id, editorProxy.serialize()) 404 | } 405 | 406 | response.getActiveEditorProxyIdsBySiteIdMap().set(siteId, editorProxy.id) 407 | } 408 | }) 409 | response.setActiveBufferProxiesList(Array.from(activeBufferProxiesById.values())) 410 | response.setActiveEditorProxiesList(Array.from(activeEditorProxiesById.values())) 411 | 412 | const tethers = [] 413 | this.tethersByFollowerId.forEach((tether, followerId) => { 414 | const tetherMessage = new Messages.Tether() 415 | tetherMessage.setFollowerSiteId(followerId) 416 | tetherMessage.setLeaderSiteId(tether.leaderId) 417 | tetherMessage.setState(tether.state) 418 | tethers.push(tetherMessage) 419 | }) 420 | response.setTethersList(tethers) 421 | 422 | this.router.respond({requestId, body: response.serializeBinary()}) 423 | } 424 | 425 | async receiveUpdate ({senderId, body}) { 426 | const updateMessage = Messages.PortalUpdate.deserializeBinary(body) 427 | 428 | if (updateMessage.hasEditorProxyCreation()) { 429 | this.receiveEditorProxyCreation(updateMessage.getEditorProxyCreation()) 430 | } else if (updateMessage.hasEditorProxySwitch()) { 431 | const senderSiteId = this.siteIdsByPeerId.get(senderId) 432 | await this.receiveEditorProxySwitch(senderSiteId, updateMessage.getEditorProxySwitch()) 433 | } else if (updateMessage.hasSiteAssignment()) { 434 | this.receiveSiteAssignment(updateMessage.getSiteAssignment()) 435 | } else if (updateMessage.hasTetherUpdate()) { 436 | this.receiveTetherUpdate(updateMessage.getTetherUpdate()) 437 | } else { 438 | throw new Error('Received unknown update message') 439 | } 440 | } 441 | 442 | receiveEditorProxyCreation (editorProxyCreationMessage) { 443 | this.deserializeEditorProxyMetadata(editorProxyCreationMessage.getEditorProxyMetadata()) 444 | this.delegate.didChangeEditorProxies() 445 | } 446 | 447 | async receiveEditorProxySwitch (senderSiteId, editorProxySwitch) { 448 | const editorProxyId = editorProxySwitch.getEditorProxyId() 449 | const editorProxy = await this.findOrFetchEditorProxy(editorProxyId) 450 | this.activeEditorProxiesBySiteId.set(senderSiteId, editorProxy) 451 | 452 | if (senderSiteId === this.resolveLeaderSiteId()) this.leaderDidUpdate() 453 | this.updateActivePositions() 454 | } 455 | 456 | receiveSiteAssignment (siteAssignment) { 457 | const siteId = siteAssignment.getSiteId() 458 | const peerId = siteAssignment.getPeerId() 459 | this.bindPeerIdToSiteId(peerId, siteId) 460 | if (this.network.getPeerId() !== peerId) { 461 | this.delegate.siteDidJoin(siteId) 462 | } 463 | } 464 | 465 | receiveTetherUpdate (tetherUpdate) { 466 | const oldResolvedLeaderId = this.resolveLeaderSiteId() 467 | const oldResolvedState = this.resolveFollowState() 468 | 469 | const followerSiteId = tetherUpdate.getFollowerSiteId() 470 | const leaderSiteId = tetherUpdate.getLeaderSiteId() 471 | const tetherState = tetherUpdate.getState() 472 | this.tethersByFollowerId.set(followerSiteId, {leaderId: leaderSiteId, state: tetherState}) 473 | 474 | const newResolvedLeaderId = this.resolveLeaderSiteId() 475 | const newResolvedState = this.resolveFollowState() 476 | this.didChangeTetherState({oldResolvedState, oldResolvedLeaderId, newResolvedState, newResolvedLeaderId}) 477 | } 478 | 479 | bindPeerIdToSiteId (peerId, siteId) { 480 | this.siteIdsByPeerId.set(peerId, siteId) 481 | this.peerIdsBySiteId.set(siteId, peerId) 482 | } 483 | 484 | subscribeToEditorProxyChanges (editorProxy) { 485 | this.activeEditorProxySubscriptions.dispose() 486 | this.activeEditorProxySubscriptions = new CompositeDisposable() 487 | if (editorProxy) { 488 | this.activeEditorProxySubscriptions.add(editorProxy.onDidScroll(this.activeEditorDidScroll.bind(this))) 489 | this.activeEditorProxySubscriptions.add(editorProxy.onDidUpdateLocalSelections(this.activeEditorDidUpdateLocalSelections.bind(this))) 490 | this.activeEditorProxySubscriptions.add(editorProxy.onDidUpdateRemoteSelections(this.activeEditorDidUpdateRemoteSelections.bind(this))) 491 | this.activeEditorProxySubscriptions.add(editorProxy.bufferProxy.onDidUpdateText(this.activeEditorDidUpdateText.bind(this))) 492 | } 493 | } 494 | 495 | activeEditorDidUpdateLocalSelections ({initialUpdate}) { 496 | this.lastLocalUpdateAt = Date.now() 497 | if (!initialUpdate && this.resolveFollowState() === FollowState.RETRACTED) { 498 | const localCursorPosition = this.getLocalActiveEditorProxy().cursorPositionForSiteId(this.siteId) 499 | const leaderPosition = this.resolveLeaderPosition() 500 | if (localCursorPosition && leaderPosition && !pointsEqual(localCursorPosition, leaderPosition)) { 501 | this.extendTether() 502 | } 503 | } 504 | 505 | this.updateActivePositions() 506 | } 507 | 508 | activeEditorDidUpdateRemoteSelections ({selectionLayerIdsBySiteId, initialUpdate}) { 509 | const leaderDidChangeSelections = selectionLayerIdsBySiteId.has(this.resolveLeaderSiteId()) 510 | if (!initialUpdate && leaderDidChangeSelections) { 511 | this.leaderDidUpdate() 512 | } 513 | 514 | this.updateActivePositions() 515 | } 516 | 517 | activeEditorDidUpdateText ({remote}) { 518 | if (this.resolveFollowState() === FollowState.RETRACTED) { 519 | if (remote) { 520 | this.delegate.updateTether(FollowState.RETRACTED, this.getLocalActiveEditorProxy(), this.resolveLeaderPosition()) 521 | } else { 522 | this.lastLocalUpdateAt = Date.now() 523 | this.extendTether() 524 | } 525 | } 526 | 527 | this.updateActivePositions() 528 | } 529 | 530 | activeEditorDidScroll () { 531 | const leaderPosition = this.resolveLeaderPosition() 532 | if (leaderPosition && this.getLocalActiveEditorProxy().isScrollNeededToViewPosition(leaderPosition)) { 533 | this.unfollow() 534 | } 535 | } 536 | 537 | follow (leaderSiteId) { 538 | this.setFollowState(FollowState.RETRACTED, leaderSiteId) 539 | } 540 | 541 | unfollow () { 542 | this.setFollowState(FollowState.DISCONNECTED) 543 | } 544 | 545 | extendTether () { 546 | this.setFollowState(FollowState.EXTENDED) 547 | } 548 | 549 | retractTether () { 550 | this.setFollowState(FollowState.RETRACTED) 551 | } 552 | 553 | getFollowedSiteId () { 554 | if (this.resolveFollowState() === FollowState.DISCONNECTED) { 555 | return null 556 | } else { 557 | return this.tethersByFollowerId.get(this.siteId).leaderId 558 | } 559 | } 560 | 561 | // Private 562 | leaderDidUpdate () { 563 | switch (this.resolveFollowState()) { 564 | case FollowState.RETRACTED: 565 | const editorProxy = this.getLocalActiveEditorProxy() 566 | this.subscribeToEditorProxyChanges(editorProxy) 567 | this.delegate.updateTether(FollowState.RETRACTED, editorProxy, this.resolveLeaderPosition()) 568 | break 569 | case FollowState.EXTENDED: 570 | this.retractOrDisconnectTether() 571 | break 572 | } 573 | } 574 | 575 | // Private 576 | retractOrDisconnectTether () { 577 | const leaderPosition = this.resolveLeaderPosition() 578 | const leaderSiteId = this.resolveLeaderSiteId() 579 | const localActiveEditorProxy = this.getLocalActiveEditorProxy() 580 | const leaderActiveEditorProxy = this.activeEditorProxyForSiteId(leaderSiteId) 581 | const leaderPositionIsInvisible = ( 582 | localActiveEditorProxy !== leaderActiveEditorProxy || 583 | (leaderPosition && localActiveEditorProxy.isScrollNeededToViewPosition(leaderPosition)) 584 | ) 585 | 586 | const hasRecentlyPerformedLocalUpdate = (Date.now() - this.lastLocalUpdateAt) <= this.tetherDisconnectWindow 587 | if (leaderPositionIsInvisible) { 588 | if (hasRecentlyPerformedLocalUpdate) { 589 | this.unfollow() 590 | } else { 591 | this.retractTether() 592 | } 593 | } 594 | } 595 | 596 | // Private 597 | setFollowState (newState, newLeaderId) { 598 | const tether = this.tethersByFollowerId.get(this.siteId) 599 | const oldState = tether ? tether.state : null 600 | const oldResolvedState = this.resolveFollowState() 601 | const oldResolvedLeaderId = this.resolveLeaderSiteId() 602 | const oldLeaderId = tether ? tether.leaderId : null 603 | newLeaderId = newLeaderId == null ? oldLeaderId : newLeaderId 604 | if (newLeaderId == null) return 605 | 606 | this.tethersByFollowerId.set(this.siteId, {leaderId: newLeaderId, state: newState}) 607 | 608 | const newResolvedState = this.resolveFollowState() 609 | const newResolvedLeaderId = this.resolveLeaderSiteId() 610 | this.didChangeTetherState({oldResolvedState, oldResolvedLeaderId, newResolvedState, newResolvedLeaderId}) 611 | 612 | if (oldState !== newState || oldLeaderId !== newLeaderId) { 613 | const tetherMessage = new Messages.Tether() 614 | tetherMessage.setFollowerSiteId(this.siteId) 615 | tetherMessage.setLeaderSiteId(newLeaderId) 616 | tetherMessage.setState(newState) 617 | const updateMessage = new Messages.PortalUpdate() 618 | updateMessage.setTetherUpdate(tetherMessage) 619 | 620 | this.router.notify({channelId: `/portals/${this.id}`, body: updateMessage.serializeBinary()}) 621 | } 622 | } 623 | 624 | didChangeTetherState ({oldResolvedState, oldResolvedLeaderId, newResolvedState, newResolvedLeaderId}) { 625 | const oldLeaderActiveEditorProxy = this.activeEditorProxiesBySiteId.get(oldResolvedLeaderId) 626 | const newLeaderActiveEditorProxy = this.activeEditorProxiesBySiteId.get(newResolvedLeaderId) 627 | 628 | if (newResolvedState === FollowState.RETRACTED) { 629 | this.editorProxiesById.forEach((editorProxy) => { 630 | editorProxy.hideSelections() 631 | }) 632 | 633 | this.subscribeToEditorProxyChanges(newLeaderActiveEditorProxy) 634 | } else if (oldResolvedState === FollowState.RETRACTED) { 635 | this.activeEditorProxiesBySiteId.set(this.siteId, oldLeaderActiveEditorProxy) 636 | this.broadcastEditorProxySwitch(oldLeaderActiveEditorProxy) 637 | if (oldLeaderActiveEditorProxy) oldLeaderActiveEditorProxy.showSelections() 638 | } 639 | 640 | this.delegate.updateTether(newResolvedState, this.getLocalActiveEditorProxy(), this.resolveLeaderPosition()) 641 | this.updateActivePositions() 642 | } 643 | 644 | updateActivePositions () { 645 | const activePositions = {} 646 | const activeSiteIds = this.getActiveSiteIds() 647 | for (let i = 0; i < activeSiteIds.length; i++) { 648 | const siteId = activeSiteIds[i] 649 | const editorProxy = this.activeEditorProxyForSiteId(siteId) 650 | if (editorProxy) { 651 | let position 652 | const followState = this.resolveFollowState(siteId) 653 | if (followState === FollowState.RETRACTED) { 654 | const leaderId = this.resolveLeaderSiteId(siteId) 655 | position = editorProxy.cursorPositionForSiteId(leaderId) 656 | } else { 657 | position = editorProxy.cursorPositionForSiteId(siteId) 658 | } 659 | 660 | activePositions[siteId] = {editorProxy, position, followState} 661 | } else { 662 | activePositions[siteId] = {editorProxy: null, position: null, followState: null} 663 | } 664 | } 665 | 666 | this.delegate.updateActivePositions(activePositions) 667 | } 668 | 669 | broadcastEditorProxyCreation (editorProxy) { 670 | const editorProxyCreationMessage = new Messages.PortalUpdate.EditorProxyCreation() 671 | editorProxyCreationMessage.setEditorProxyMetadata(editorProxy.getMetadata().serialize()) 672 | const updateMessage = new Messages.PortalUpdate() 673 | updateMessage.setEditorProxyCreation(editorProxyCreationMessage) 674 | this.router.notify({channelId: `/portals/${this.id}`, body: updateMessage.serializeBinary()}) 675 | } 676 | 677 | broadcastEditorProxySwitch (editorProxy) { 678 | const editorProxySwitchMessage = new Messages.PortalUpdate.EditorProxySwitch() 679 | if (editorProxy) editorProxySwitchMessage.setEditorProxyId(editorProxy.id) 680 | const updateMessage = new Messages.PortalUpdate() 681 | updateMessage.setEditorProxySwitch(editorProxySwitchMessage) 682 | this.router.notify({channelId: `/portals/${this.id}`, body: updateMessage.serializeBinary()}) 683 | } 684 | 685 | resolveLeaderPosition (followerId = this.siteId) { 686 | const leaderId = this.resolveLeaderSiteId(followerId) 687 | const editorProxy = this.getLocalActiveEditorProxy() 688 | return editorProxy ? editorProxy.cursorPositionForSiteId(leaderId) : null 689 | } 690 | 691 | resolveFollowState (followerId = this.siteId) { 692 | const leaderId = this.resolveLeaderSiteId(followerId) 693 | if (followerId === leaderId) { 694 | return FollowState.DISCONNECTED 695 | } else { 696 | return this.tethersByFollowerId.get(followerId).state 697 | } 698 | } 699 | 700 | resolveLeaderSiteId (followerId = this.siteId) { 701 | const tether = this.tethersByFollowerId.get(followerId) 702 | if (!tether) return followerId 703 | 704 | const visitedSiteIds = new Set([followerId]) 705 | let leaderId = tether.leaderId 706 | 707 | let nextTether = this.tethersByFollowerId.get(leaderId) 708 | while (nextTether && nextTether.state === FollowState.RETRACTED) { 709 | if (visitedSiteIds.has(leaderId)) { 710 | leaderId = Math.min(...Array.from(visitedSiteIds)) 711 | break 712 | } else { 713 | visitedSiteIds.add(leaderId) 714 | leaderId = nextTether.leaderId 715 | nextTether = this.tethersByFollowerId.get(leaderId) 716 | } 717 | } 718 | 719 | return leaderId 720 | } 721 | } 722 | 723 | function isHostSiteId (siteId) { 724 | return siteId === 1 725 | } 726 | 727 | function pointsEqual (a, b) { 728 | return a.row === b.row && a.column === b.column 729 | } 730 | -------------------------------------------------------------------------------- /lib/pub-sub-signaling-provider.js: -------------------------------------------------------------------------------- 1 | const Errors = require('./errors') 2 | 3 | module.exports = 4 | class PubSubSignalingProvider { 5 | constructor ({localPeerId, remotePeerId, restGateway, testEpoch}) { 6 | this.localPeerId = localPeerId 7 | this.remotePeerId = remotePeerId 8 | this.restGateway = restGateway 9 | this.testEpoch = testEpoch 10 | this.incomingSequenceNumber = 0 11 | this.outgoingSequenceNumber = 0 12 | this.incomingSignals = {} 13 | } 14 | 15 | async send (signal) { 16 | const request = { 17 | senderId: this.localPeerId, 18 | sequenceNumber: this.outgoingSequenceNumber++, 19 | signal 20 | } 21 | if (this.testEpoch != null) request.testEpoch = this.testEpoch 22 | 23 | const {ok, status, body} = await this.restGateway.post(`/peers/${this.remotePeerId}/signals`, request) 24 | if (status === 401) { 25 | throw new Errors.InvalidAuthenticationTokenError('The provided authentication token is invalid') 26 | } else if (!ok) { 27 | throw new Errors.PubSubConnectionError('Error signalling peer: ' + body.message) 28 | } 29 | } 30 | 31 | async receiveMessage ({testEpoch, sequenceNumber, signal}) { 32 | if (this.testEpoch && this.testEpoch !== testEpoch) return 33 | 34 | this.incomingSignals[sequenceNumber] = signal 35 | 36 | if (!this.receivingSignals) { 37 | this.receivingSignals = true 38 | while (true) { 39 | const signal = this.incomingSignals[this.incomingSequenceNumber] 40 | if (signal) { 41 | delete this.incomingSignals[this.incomingSequenceNumber] 42 | await this.receive(signal) 43 | this.incomingSequenceNumber++ 44 | } else { 45 | break 46 | } 47 | } 48 | this.receivingSignals = false 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/pusher-pub-sub-gateway.js: -------------------------------------------------------------------------------- 1 | const Pusher = require('pusher-js/dist/web/pusher') 2 | const {Disposable} = require('event-kit') 3 | const Errors = require('./errors') 4 | 5 | module.exports = 6 | class PusherPubSubGateway { 7 | constructor ({key, options}) { 8 | this.channelsByName = new Map() 9 | this.subscriptionsCount = 0 10 | this.pusherClient = createDisconnectedPusherClient(key, options) 11 | } 12 | 13 | async subscribe (channelName, eventName, callback) { 14 | if (this.subscriptionsCount === 0) await this.connect() 15 | 16 | channelName = channelName.replace(/\//g, '.') 17 | let channel = this.channelsByName.get(channelName) 18 | if (!channel) { 19 | channel = this.pusherClient.subscribe(channelName) 20 | await new Promise((resolve, reject) => { 21 | channel.bind('pusher:subscription_succeeded', resolve) 22 | channel.bind('pusher:subscription_error', reject) 23 | }) 24 | this.channelsByName.set(channelName, channel) 25 | } 26 | 27 | channel.bind(eventName, callback) 28 | this.subscriptionsCount++ 29 | 30 | return new Disposable(() => { 31 | channel.unbind(eventName, callback) 32 | 33 | this.subscriptionsCount-- 34 | if (this.subscriptionsCount === 0) this.disconnect() 35 | }) 36 | } 37 | 38 | connect () { 39 | const error = new Errors.PubSubConnectionError('Error establishing web socket connection to signaling server') 40 | this.pusherClient.connect() 41 | return new Promise((resolve, reject) => { 42 | const handleConnection = () => { 43 | this.pusherClient.connection.unbind('connected', handleConnection) 44 | this.pusherClient.connection.unbind('error', handleError) 45 | resolve() 46 | } 47 | 48 | const handleError = () => { 49 | this.pusherClient.connection.unbind('connected', handleConnection) 50 | this.pusherClient.connection.unbind('error', handleError) 51 | reject(error) 52 | } 53 | 54 | this.pusherClient.connection.bind('connected', handleConnection) 55 | this.pusherClient.connection.bind('error', handleError) 56 | }) 57 | } 58 | 59 | disconnect () { 60 | this.channelsByName.forEach((channel) => { 61 | this.pusherClient.unsubscribe(channel) 62 | }) 63 | this.channelsByName.clear() 64 | this.pusherClient.disconnect() 65 | } 66 | } 67 | 68 | function createDisconnectedPusherClient (key, options) { 69 | const connectOptions = Object.assign({ 70 | encrypted: true, 71 | }, options) 72 | 73 | const client = new Pusher(key, connectOptions) // automatically connects to pusher 74 | client.disconnect() 75 | return client 76 | } 77 | -------------------------------------------------------------------------------- /lib/rest-gateway.js: -------------------------------------------------------------------------------- 1 | const {HTTPRequestError} = require('./errors') 2 | 3 | module.exports = 4 | class RestGateway { 5 | constructor ({baseURL, oauthToken}) { 6 | this.baseURL = baseURL 7 | this.oauthToken = oauthToken 8 | } 9 | 10 | setOauthToken (oauthToken) { 11 | this.oauthToken = oauthToken 12 | } 13 | 14 | get (relativeURL, options) { 15 | return this.fetch(relativeURL, { 16 | method: 'GET', 17 | headers: this.getDefaultHeaders() 18 | }) 19 | } 20 | 21 | post (relativeURL, requestBody) { 22 | return this.fetch(relativeURL, { 23 | method: 'POST', 24 | headers: Object.assign(this.getDefaultHeaders(), {'Content-Type': 'application/json'}), 25 | body: JSON.stringify(requestBody) 26 | }) 27 | } 28 | 29 | async fetch (relativeURL, {method, headers, body}) { 30 | const url = this.getAbsoluteURL(relativeURL) 31 | let response 32 | try { 33 | response = await window.fetch(url, {method, headers, body}) 34 | } catch (e) { 35 | const error = new HTTPRequestError('Connection failure') 36 | error.diagnosticMessage = getDiagnosticMessage({method, url}) 37 | throw error 38 | } 39 | 40 | const {ok, status} = response 41 | const rawBody = await response.text() 42 | 43 | try { 44 | const body = JSON.parse(rawBody) 45 | return {ok, body, status} 46 | } catch (e) { 47 | const error = new HTTPRequestError('Unexpected response') 48 | error.diagnosticMessage = getDiagnosticMessage({method, url, status, rawBody}) 49 | throw error 50 | } 51 | } 52 | 53 | getDefaultHeaders () { 54 | const headers = {'Accept': 'application/json'} 55 | if (this.oauthToken) headers['GitHub-OAuth-token'] = this.oauthToken 56 | 57 | return headers 58 | } 59 | 60 | getAbsoluteURL (relativeURL) { 61 | return this.baseURL + relativeURL 62 | } 63 | } 64 | 65 | const PORTAL_ID_REGEXP = /[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/g 66 | 67 | function getDiagnosticMessage ({method, url, status, rawBody}) { 68 | let message = `Request: ${method} ${url}` 69 | if (status) message += `\nStatus Code: ${status}` 70 | if (rawBody) message += `\nBody: ${rawBody}` 71 | return message.replace(PORTAL_ID_REGEXP, 'REDACTED') 72 | } 73 | -------------------------------------------------------------------------------- /lib/router.js: -------------------------------------------------------------------------------- 1 | const {CompositeDisposable, Emitter} = require('event-kit') 2 | const {RouterMessage} = require('./teletype-client_pb') 3 | const convertToProtobufCompatibleBuffer = require('./convert-to-protobuf-compatible-buffer') 4 | 5 | module.exports = 6 | class Router { 7 | constructor (network) { 8 | this.network = network 9 | this.emitter = new Emitter() 10 | this.subscriptions = new CompositeDisposable() 11 | this.subscriptions.add(network.onReceive(this.receive.bind(this))) 12 | this.nextRequestId = 0 13 | this.requestPromiseResolveCallbacks = new Map() 14 | this.peerIdsByRequestId = new Map() 15 | this.lastReceivePromise = Promise.resolve() 16 | } 17 | 18 | dispose () { 19 | this.subscriptions.dispose() 20 | } 21 | 22 | notify ({recipientId, channelId, body}) { 23 | body = convertToProtobufCompatibleBuffer(body) 24 | 25 | const notification = new RouterMessage.Notification() 26 | notification.setChannelId(channelId) 27 | if (body != null) notification.setBody(body) 28 | const routerMessage = new RouterMessage() 29 | routerMessage.setNotification(notification) 30 | 31 | if (recipientId) { 32 | this.network.unicast(recipientId, routerMessage.serializeBinary()) 33 | } else { 34 | this.network.broadcast(routerMessage.serializeBinary()) 35 | } 36 | } 37 | 38 | request ({recipientId, channelId, body}) { 39 | if (body) body = convertToProtobufCompatibleBuffer(body) 40 | 41 | const requestId = this.nextRequestId++ 42 | const request = new RouterMessage.Request() 43 | 44 | request.setChannelId(channelId) 45 | request.setRequestId(requestId) 46 | if (body) request.setBody(body) 47 | const routerMessage = new RouterMessage() 48 | routerMessage.setRequest(request) 49 | 50 | this.network.unicast(recipientId, routerMessage.serializeBinary()) 51 | 52 | return new Promise((resolve) => { 53 | this.requestPromiseResolveCallbacks.set(requestId, resolve) 54 | }) 55 | } 56 | 57 | respond ({requestId, ok, body}) { 58 | const recipientId = this.peerIdsByRequestId.get(requestId) 59 | if (!recipientId) throw new Error('Multiple responses to the same request are not allowed') 60 | 61 | if (ok == null) ok = true 62 | if (body) body = convertToProtobufCompatibleBuffer(body) 63 | 64 | const response = new RouterMessage.Response() 65 | response.setRequestId(requestId) 66 | response.setOk(ok) 67 | response.setBody(body) 68 | const routerMessage = new RouterMessage() 69 | routerMessage.setResponse(response) 70 | 71 | this.peerIdsByRequestId.delete(requestId) 72 | 73 | this.network.unicast(recipientId, routerMessage.serializeBinary()) 74 | } 75 | 76 | onNotification (channelId, callback) { 77 | return this.emitter.on('notification:' + channelId, callback) 78 | } 79 | 80 | onRequest (channelId, callback) { 81 | return this.emitter.on('request:' + channelId, callback) 82 | } 83 | 84 | receive ({senderId, message}) { 85 | const routerMessage = RouterMessage.deserializeBinary(message) 86 | 87 | if (routerMessage.hasNotification()) { 88 | this.receiveNotification(senderId, routerMessage.getNotification()) 89 | } else if (routerMessage.hasRequest()) { 90 | this.receiveRequest(senderId, routerMessage.getRequest()) 91 | } else if (routerMessage.hasResponse()) { 92 | this.receiveResponse(routerMessage.getResponse()) 93 | } else { 94 | throw new Error('Unsupported router message variant') 95 | } 96 | } 97 | 98 | receiveNotification (senderId, notification) { 99 | this.lastReceivePromise = this.lastReceivePromise.then(async () => { 100 | const channelId = notification.getChannelId() 101 | const body = convertToProtobufCompatibleBuffer(notification.getBody()) 102 | await this.emitter.emitAsync( 103 | 'notification:' + channelId, 104 | {senderId, body} 105 | ) 106 | }) 107 | } 108 | 109 | receiveRequest (senderId, request) { 110 | this.lastReceivePromise = this.lastReceivePromise.then(async () => { 111 | const channelId = request.getChannelId() 112 | const requestId = request.getRequestId() 113 | const eventName = 'request:' + channelId 114 | const body = convertToProtobufCompatibleBuffer(request.getBody()) 115 | this.peerIdsByRequestId.set(requestId, senderId) 116 | 117 | if (this.emitter.listenerCountForEventName(eventName) === 0) { 118 | this.respond({requestId, ok: false}) 119 | } else { 120 | await this.emitter.emitAsync(eventName, {senderId, requestId, body}) 121 | } 122 | }) 123 | } 124 | 125 | receiveResponse (response) { 126 | const requestId = response.getRequestId() 127 | const requestResolveCallback = this.requestPromiseResolveCallbacks.get(requestId) 128 | requestResolveCallback({ 129 | body: convertToProtobufCompatibleBuffer(response.getBody()), 130 | ok: response.getOk() 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /lib/star-overlay-network.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const {CompositeDisposable, Emitter} = require('event-kit') 3 | const {NetworkMessage, PeerIdentity} = require('./teletype-client_pb') 4 | const Errors = require('./errors') 5 | const convertToProtobufCompatibleBuffer = require('./convert-to-protobuf-compatible-buffer') 6 | 7 | module.exports = 8 | class StarOverlayNetwork { 9 | constructor ({id, peerPool, isHub, connectionTimeout}) { 10 | this.id = id 11 | this.peerPool = peerPool 12 | this.isHub = isHub 13 | this.connectionTimeout = connectionTimeout || 5000 14 | this.emitter = new Emitter() 15 | this.memberIdentitiesById = new Map([ 16 | [this.getPeerId(), this.peerPool.getPeerIdentity(this.getPeerId())] 17 | ]) 18 | this.connectedMemberIds = new Set() 19 | this.resetConnectedMembers() 20 | 21 | if (this.isHub) { 22 | this.spokes = new Set() 23 | this.state = 'connected' 24 | } else { 25 | this.state = 'disconnected' 26 | } 27 | 28 | this.subscriptions = new CompositeDisposable( 29 | peerPool.onDisconnection(this.didLoseConnectionToPeer.bind(this)), 30 | peerPool.onReceive(this.receive.bind(this)) 31 | ) 32 | } 33 | 34 | dispose () { 35 | this.subscriptions.dispose() 36 | this.disconnect() 37 | } 38 | 39 | async connectTo (hubId) { 40 | assert(!this.isHub, 'The hub should only receive connections') 41 | assert(!this.hubId, 'Can connect to hub only once') 42 | 43 | this.state = 'connecting' 44 | this.hubId = hubId 45 | await this.peerPool.connectTo(this.hubId) 46 | 47 | try { 48 | const starJoinRequest = new NetworkMessage.StarJoinRequest() 49 | starJoinRequest.setSenderId(this.getPeerId()) 50 | const networkMessage = new NetworkMessage() 51 | networkMessage.setStarJoinRequest(starJoinRequest) 52 | networkMessage.setNetworkId(this.id) 53 | this.send(this.hubId, networkMessage.serializeBinary()) 54 | } catch (error) { 55 | this.state = 'disconnected' 56 | this.hubId = null 57 | throw error 58 | } 59 | 60 | const timeoutError = new Errors.NetworkConnectionError('Connecting to the portal network timed out') 61 | return new Promise((resolve, reject) => { 62 | this.resolveConnectionPromise = resolve 63 | setTimeout(() => { 64 | if (this.state === 'connecting') { 65 | this.state = 'timeout' 66 | this.disconnect() 67 | reject(timeoutError) 68 | } 69 | }, this.connectionTimeout) 70 | }) 71 | } 72 | 73 | disconnect () { 74 | if (this.state === 'disconnected') return 75 | 76 | const leaveNotificationMessage = new NetworkMessage.StarLeaveNotification() 77 | leaveNotificationMessage.setMemberId(this.getPeerId()) 78 | leaveNotificationMessage.setConnectionLost(false) 79 | const networkMessage = new NetworkMessage() 80 | networkMessage.setStarLeaveNotification(leaveNotificationMessage) 81 | networkMessage.setNetworkId(this.id) 82 | 83 | if (this.isHub) { 84 | this.forwardBroadcast(this.getPeerId(), networkMessage.serializeBinary()) 85 | this.spokes.clear() 86 | } else { 87 | this.send(this.hubId, networkMessage.serializeBinary()) 88 | this.hubId = null 89 | } 90 | 91 | this.resetConnectedMembers() 92 | this.state = 'disconnected' 93 | } 94 | 95 | unicast (recipientId, message) { 96 | message = convertToProtobufCompatibleBuffer(message) 97 | 98 | const starUnicast = new NetworkMessage.StarUnicast() 99 | starUnicast.setSenderId(this.peerPool.peerId) 100 | starUnicast.setRecipientId(recipientId) 101 | starUnicast.setBody(message) 102 | const networkMessage = new NetworkMessage() 103 | networkMessage.setStarUnicast(starUnicast) 104 | networkMessage.setNetworkId(this.id) 105 | const rawNetworkMessage = networkMessage.serializeBinary() 106 | 107 | if (this.isHub) { 108 | this.forwardUnicast(recipientId, rawNetworkMessage) 109 | } else { 110 | this.send(this.hubId, rawNetworkMessage) 111 | } 112 | } 113 | 114 | broadcast (message) { 115 | message = convertToProtobufCompatibleBuffer(message) 116 | 117 | const starBroadcast = new NetworkMessage.StarBroadcast() 118 | starBroadcast.setSenderId(this.peerPool.peerId) 119 | starBroadcast.setBody(message) 120 | const networkMessage = new NetworkMessage() 121 | networkMessage.setStarBroadcast(starBroadcast) 122 | networkMessage.setNetworkId(this.id) 123 | const rawNetworkMessage = networkMessage.serializeBinary() 124 | 125 | if (this.isHub) { 126 | this.forwardBroadcast(this.peerPool.peerId, rawNetworkMessage) 127 | } else { 128 | this.send(this.hubId, rawNetworkMessage) 129 | } 130 | } 131 | 132 | onMemberJoin (callback) { 133 | return this.emitter.on('join', callback) 134 | } 135 | 136 | onMemberLeave (callback) { 137 | return this.emitter.on('leave', callback) 138 | } 139 | 140 | onReceive (callback) { 141 | return this.emitter.on('receive', callback) 142 | } 143 | 144 | getMemberIds () { 145 | return Array.from(this.connectedMemberIds) 146 | } 147 | 148 | getMemberIdentity (peerId) { 149 | if (!this.memberIdentitiesById.has(peerId)) { 150 | const originalStackTrace = new Error().stack 151 | const metadata = { 152 | localPeerId: this.getPeerId(), 153 | requestedPeerId: peerId, 154 | isHub: this.isHub, 155 | networkState: this.state, 156 | connectedMemberIds: Array.from(this.connectedMemberIds), 157 | memberIdsWithIdentity: Array.from(this.memberIdentitiesById.keys()), 158 | } 159 | 160 | process.nextTick(() => { 161 | const error = new Error('Attempted to get identity for non-connected member') 162 | error.metadata = metadata 163 | error.privateMetadata = { originalStackTrace } 164 | error.privateMetadataDescription = originalStackTrace 165 | throw error 166 | }) 167 | } 168 | 169 | return this.memberIdentitiesById.get(peerId) 170 | } 171 | 172 | getPeerId () { 173 | return this.peerPool.peerId 174 | } 175 | 176 | didLoseConnectionToPeer ({peerId}) { 177 | if (!this.connectedMemberIds.has(peerId)) return 178 | 179 | if (this.isHub) { 180 | this.spokes.delete(peerId) 181 | 182 | const leaveNotificationMessage = new NetworkMessage.StarLeaveNotification() 183 | leaveNotificationMessage.setMemberId(peerId) 184 | leaveNotificationMessage.setConnectionLost(true) 185 | const networkMessage = new NetworkMessage() 186 | networkMessage.setStarLeaveNotification(leaveNotificationMessage) 187 | networkMessage.setNetworkId(this.id) 188 | this.forwardBroadcast(peerId, networkMessage.serializeBinary()) 189 | } 190 | 191 | this.memberDidLeave(peerId, true) 192 | } 193 | 194 | receive ({senderId, message}) { 195 | if (this.state === 'disconnected') return 196 | 197 | const networkMessage = NetworkMessage.deserializeBinary(message) 198 | if (networkMessage.getNetworkId() !== this.id) return 199 | 200 | if (networkMessage.hasStarJoinRequest()) { 201 | this.receiveJoinRequest(message, networkMessage.getStarJoinRequest()) 202 | } else if (networkMessage.hasStarJoinResponse()) { 203 | this.receiveJoinResponse(networkMessage.getStarJoinResponse()) 204 | } else if (networkMessage.hasStarJoinNotification()) { 205 | this.receiveJoinNotification(networkMessage.getStarJoinNotification()) 206 | } else if (networkMessage.hasStarLeaveNotification()) { 207 | this.receiveLeaveNotification(message, networkMessage.getStarLeaveNotification()) 208 | } else if (networkMessage.hasStarUnicast()) { 209 | this.receiveUnicast(message, networkMessage.getStarUnicast()) 210 | } else if (networkMessage.hasStarBroadcast()) { 211 | this.receiveBroadcast(message, networkMessage.getStarBroadcast()) 212 | } 213 | } 214 | 215 | receiveJoinRequest (rawMessage, connectionMessage) { 216 | assert(this.isHub, 'Join requests should only be sent to the hub') 217 | const senderId = connectionMessage.getSenderId() 218 | const senderIdentity = this.peerPool.getPeerIdentity(senderId) 219 | 220 | this.state = 'connected' 221 | this.spokes.add(senderId) 222 | this.memberIdentitiesById.set(senderId, senderIdentity) 223 | this.connectedMemberIds.add(senderId) 224 | 225 | // Respond to new member 226 | const joinResponseMessage = new NetworkMessage.StarJoinResponse() 227 | const memberIdentitiesByIdMessage = joinResponseMessage.getMemberIdentitiesByIdMap() 228 | this.connectedMemberIds.forEach((peerId) => { 229 | const identity = this.getMemberIdentity(peerId) 230 | memberIdentitiesByIdMessage.set(peerId, serializePeerIdentity(identity)) 231 | }) 232 | const responseNetworkMessage = new NetworkMessage() 233 | responseNetworkMessage.setNetworkId(this.id) 234 | responseNetworkMessage.setStarJoinResponse(joinResponseMessage) 235 | this.send(senderId, responseNetworkMessage.serializeBinary()) 236 | 237 | // Notify other spokes of new member 238 | const joinNotificationMessage = new NetworkMessage.StarJoinNotification() 239 | joinNotificationMessage.setMemberId(senderId) 240 | joinNotificationMessage.setMemberIdentity(serializePeerIdentity(senderIdentity)) 241 | const notificationNetworkMessage = new NetworkMessage() 242 | notificationNetworkMessage.setNetworkId(this.id) 243 | notificationNetworkMessage.setStarJoinNotification(joinNotificationMessage) 244 | this.forwardBroadcast(senderId, notificationNetworkMessage.serializeBinary()) 245 | 246 | this.emitter.emit('join', {peerId: senderId}) 247 | } 248 | 249 | receiveJoinResponse (joinResponseMessage) { 250 | assert(!this.isHub, 'Connection responses cannot be sent to the hub') 251 | joinResponseMessage.getMemberIdentitiesByIdMap().forEach((identityMessage, peerId) => { 252 | this.memberIdentitiesById.set(peerId, deserializePeerIdentity(identityMessage)) 253 | this.connectedMemberIds.add(peerId) 254 | }) 255 | 256 | this.state = 'connected' 257 | this.resolveConnectionPromise() 258 | this.resolveConnectionPromise = null 259 | } 260 | 261 | receiveJoinNotification (joinNotificationMessage) { 262 | const memberId = joinNotificationMessage.getMemberId() 263 | const memberIdentity = deserializePeerIdentity(joinNotificationMessage.getMemberIdentity()) 264 | this.memberIdentitiesById.set(memberId, memberIdentity) 265 | this.connectedMemberIds.add(memberId) 266 | 267 | this.emitter.emit('join', {peerId: memberId}) 268 | } 269 | 270 | receiveLeaveNotification (rawMessage, leaveNotification) { 271 | const memberId = leaveNotification.getMemberId() 272 | if (!this.connectedMemberIds.has(memberId)) return 273 | 274 | if (this.isHub) { 275 | this.spokes.delete(memberId) 276 | this.forwardBroadcast(memberId, rawMessage) 277 | } 278 | this.memberDidLeave(memberId, leaveNotification.getConnectionLost()) 279 | } 280 | 281 | memberDidLeave (peerId, connectionLost) { 282 | if (peerId === this.hubId) { 283 | this.hubId = null 284 | this.state = 'disconnected' 285 | this.resetConnectedMembers() 286 | } else { 287 | this.connectedMemberIds.delete(peerId) 288 | } 289 | 290 | this.emitter.emit('leave', {peerId, connectionLost}) 291 | } 292 | 293 | receiveUnicast (rawMessage, unicastMessage) { 294 | const recipientId = unicastMessage.getRecipientId() 295 | if (recipientId === this.peerPool.peerId) { 296 | this.emitter.emit('receive', { 297 | senderId: unicastMessage.getSenderId(), 298 | message: convertToProtobufCompatibleBuffer(unicastMessage.getBody()) 299 | }) 300 | } else if (this.isHub) { 301 | this.forwardUnicast(recipientId, rawMessage) 302 | } else { 303 | throw new Error('Received a unicast not intended for this peer') 304 | } 305 | } 306 | 307 | receiveBroadcast (rawMessage, broadcastMessage) { 308 | const senderId = broadcastMessage.getSenderId() 309 | if (this.isHub) this.forwardBroadcast(senderId, rawMessage) 310 | this.emitter.emit('receive', { 311 | senderId, 312 | message: convertToProtobufCompatibleBuffer(broadcastMessage.getBody()) 313 | }) 314 | } 315 | 316 | forwardUnicast (recipientId, rawMessage) { 317 | if (this.spokes.has(recipientId)) { 318 | this.send(recipientId, rawMessage) 319 | } 320 | } 321 | 322 | forwardBroadcast (senderId, rawMessage) { 323 | this.spokes.forEach((peerId) => { 324 | if (peerId !== senderId) { 325 | this.send(peerId, rawMessage) 326 | } 327 | }) 328 | } 329 | 330 | send (peerId, message) { 331 | if (this.peerPool.isConnectedToPeer(peerId) ) { 332 | this.peerPool.send(peerId, message) 333 | } 334 | } 335 | 336 | resetConnectedMembers () { 337 | this.connectedMemberIds.clear() 338 | this.connectedMemberIds.add(this.getPeerId()) 339 | } 340 | } 341 | 342 | function serializePeerIdentity (identity) { 343 | const identityMessage = new PeerIdentity() 344 | identityMessage.setLogin(identity.login) 345 | return identityMessage 346 | } 347 | 348 | function deserializePeerIdentity (identityMessage) { 349 | return { 350 | login: identityMessage.getLogin() 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /lib/teletype-client.js: -------------------------------------------------------------------------------- 1 | const os = require('os') 2 | const uuidV1 = require('uuid/v1') 3 | const uuidV4 = require('uuid/v4') 4 | const PeerPool = require('./peer-pool') 5 | const Portal = require('./portal') 6 | const Errors = require('./errors') 7 | const PusherPubSubGateway = require('./pusher-pub-sub-gateway') 8 | const RestGateway = require('./rest-gateway') 9 | const {Emitter} = require('event-kit') 10 | const NOOP = () => {} 11 | const DEFAULT_TETHER_DISCONNECT_WINDOW = 1000 12 | const LOCAL_PROTOCOL_VERSION = 9 13 | 14 | module.exports = 15 | class TeletypeClient { 16 | constructor ({restGateway, pubSubGateway, connectionTimeout, tetherDisconnectWindow, testEpoch, pusherKey, pusherOptions, baseURL, didCreateOrJoinPortal}) { 17 | this.restGateway = restGateway || new RestGateway({baseURL}) 18 | this.pubSubGateway = pubSubGateway || new PusherPubSubGateway({key: pusherKey, options: pusherOptions}) 19 | this.connectionTimeout = connectionTimeout || 5000 20 | this.tetherDisconnectWindow = tetherDisconnectWindow || DEFAULT_TETHER_DISCONNECT_WINDOW 21 | this.testEpoch = testEpoch 22 | this.didCreateOrJoinPortal = didCreateOrJoinPortal || NOOP 23 | this.emitter = new Emitter() 24 | } 25 | 26 | async initialize () { 27 | const {ok, body} = await this.restGateway.get('/protocol-version') 28 | if (ok && body.version > LOCAL_PROTOCOL_VERSION) { 29 | throw new Errors.ClientOutOfDateError(`This version teletype-client is out of date. The local version is ${LOCAL_PROTOCOL_VERSION} but the remote version is ${body.version}.`) 30 | } 31 | } 32 | 33 | dispose () { 34 | if (this.peerPool) this.peerPool.dispose() 35 | } 36 | 37 | async signIn (oauthToken) { 38 | this.restGateway.setOauthToken(oauthToken) 39 | 40 | let response 41 | try { 42 | response = await this.restGateway.get('/identity') 43 | } catch (error) { 44 | const message = 'Authentication failed with message: ' + error.message 45 | throw new Errors.UnexpectedAuthenticationError(message) 46 | } 47 | 48 | if (response.ok) { 49 | this.peerPool = new PeerPool({ 50 | peerId: this.getClientId(), 51 | peerIdentity: response.body, 52 | restGateway: this.restGateway, 53 | pubSubGateway: this.pubSubGateway, 54 | fragmentSize: 16 * 1024, // 16KB 55 | connectionTimeout: this.connectionTimeout, 56 | testEpoch: this.testEpoch 57 | }) 58 | this.peerPool.onError(this.peerPoolDidError.bind(this)) 59 | await this.peerPool.initialize() 60 | 61 | this.signedIn = true 62 | this.emitter.emit('sign-in-change') 63 | 64 | return true 65 | } else if (response.status === 401) { 66 | return false 67 | } else { 68 | const message = 'Authentication failed with message: ' + response.body.message 69 | throw new Errors.UnexpectedAuthenticationError(message) 70 | } 71 | } 72 | 73 | signOut () { 74 | if (this.signedIn) { 75 | this.signedIn = false 76 | this.restGateway.setOauthToken(null) 77 | this.peerPool.dispose() 78 | this.peerPool = null 79 | this.emitter.emit('sign-in-change') 80 | } 81 | 82 | return true 83 | } 84 | 85 | isSignedIn () { 86 | return this.signedIn 87 | } 88 | 89 | getLocalUserIdentity () { 90 | return this.peerPool ? this.peerPool.getLocalPeerIdentity() : null 91 | } 92 | 93 | async createPortal () { 94 | let result 95 | try { 96 | result = await this.restGateway.post('/portals', {hostPeerId: this.getClientId()}) 97 | } catch (error) { 98 | throw new Errors.PortalCreationError('Could not contact server to create your portal') 99 | } 100 | 101 | if (result.ok) { 102 | const {id} = result.body 103 | const portal = new Portal({ 104 | id, 105 | siteId: 1, 106 | peerPool: this.peerPool, 107 | connectionTimeout: this.connectionTimeout, 108 | tetherDisconnectWindow: this.tetherDisconnectWindow 109 | }) 110 | await portal.initialize() 111 | this.didCreateOrJoinPortal(portal) 112 | 113 | return portal 114 | } else if (result.status === 401) { 115 | this.signOut() 116 | } else { 117 | throw new Errors.PortalCreationError('A server error occurred while creating your portal') 118 | } 119 | } 120 | 121 | async joinPortal (id) { 122 | let result 123 | try { 124 | result = await this.restGateway.get(`/portals/${id}`) 125 | } catch (error) { 126 | throw new Errors.PortalJoinError('Could not contact server to join the portal') 127 | } 128 | 129 | if (result.ok) { 130 | const {hostPeerId} = result.body 131 | const portal = new Portal({ 132 | id, 133 | hostPeerId, 134 | peerPool: this.peerPool, 135 | connectionTimeout: this.connectionTimeout, 136 | tetherDisconnectWindow: this.tetherDisconnectWindow 137 | }) 138 | await portal.initialize() 139 | await portal.join() 140 | this.didCreateOrJoinPortal(portal) 141 | 142 | return portal 143 | } else if (result.status === 401) { 144 | this.signOut() 145 | } else { 146 | throw new Errors.PortalNotFoundError() 147 | } 148 | } 149 | 150 | onConnectionError (callback) { 151 | return this.emitter.on('connection-error', callback) 152 | } 153 | 154 | onSignInChange (callback) { 155 | return this.emitter.on('sign-in-change', callback) 156 | } 157 | 158 | getClientId () { 159 | if (!this.clientId) { 160 | const EMPTY_MAC_ADDRESS = '00:00:00:00:00:00' 161 | const networkInterfaces = os.networkInterfaces() 162 | 163 | let macAddress 164 | for (const networkInterfaceName in networkInterfaces) { 165 | const networkAddress = networkInterfaces[networkInterfaceName][0] 166 | if (networkAddress && !networkAddress.internal && networkAddress.mac !== EMPTY_MAC_ADDRESS) { 167 | macAddress = networkAddress.mac 168 | break 169 | } 170 | } 171 | 172 | if (macAddress) { 173 | // If we can find a MAC address string, we first transform it into a sequence 174 | // of bytes. Then, we construct the clientId by concatenating two UUIDs: 175 | // * A UUIDv1, built using the MAC address so that it is guaranteed to be unique. 176 | // * A UUIDv4, built using random bytes so that the clientId can't be guessed. 177 | const macAddressBytes = macAddress.split(':').map((part) => Buffer.from(part, 'hex').readUInt8()) 178 | this.clientId = uuidV1({node: macAddressBytes}) + '.' + uuidV4() 179 | } else { 180 | // If no MAC address could be found, generate a completely random clientId with the same format. 181 | this.clientId = uuidV4() + '.' + uuidV4() 182 | } 183 | } 184 | 185 | return this.clientId 186 | } 187 | 188 | peerPoolDidError (error) { 189 | if (error instanceof Errors.InvalidAuthenticationTokenError) { 190 | this.signOut() 191 | } else { 192 | this.emitter.emit('connection-error', error) 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@atom/teletype-client", 3 | "version": "0.38.4", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "electron-mocha **/*.test.js --ui=tdd --renderer --enable-experimental-web-platform-features", 8 | "compile-protobuf": "protoc --js_out=import_style=commonjs,binary:lib teletype-client.proto teletype-crdt.proto" 9 | }, 10 | "author": "", 11 | "license": "MIT", 12 | "dependencies": { 13 | "@atom/teletype-crdt": "^0.9.0", 14 | "event-kit": "^2.3.0", 15 | "google-protobuf": "^3.5.0", 16 | "pusher-js": "^4.2.2", 17 | "uuid": "^3.2.1", 18 | "webrtc-adapter": "~6.1" 19 | }, 20 | "devDependencies": { 21 | "@atom/teletype-server": "^0.18.1", 22 | "deep-equal": "^1.0.1", 23 | "dotenv": "^4.0.0", 24 | "electron": "2.0.0", 25 | "electron-mocha": "^4.0.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /teletype-client.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "teletype-crdt.proto"; 4 | 5 | message PortalSubscriptionResponse { 6 | map site_ids_by_peer_id = 1; 7 | reserved 2; // active_editor_proxy field is no longer supported 8 | reserved 3; // active_buffer_proxy field is no longer supported 9 | repeated Tether tethers = 4; 10 | repeated BufferProxy active_buffer_proxies = 5; 11 | repeated EditorProxy active_editor_proxies = 6; 12 | map active_editor_proxy_ids_by_site_id = 7; 13 | repeated EditorProxyMetadata editor_proxies_metadata = 8; 14 | } 15 | 16 | message PortalUpdate { 17 | oneof variant { 18 | EditorProxySwitch editor_proxy_switch = 1; 19 | SiteAssignment site_assignment = 2; 20 | // EditorProxyRemoval editor_proxy_removal = 3; // No longer supported 21 | Tether tether_update = 4; 22 | EditorProxyCreation editor_proxy_creation = 5; 23 | } 24 | 25 | message EditorProxySwitch { 26 | uint32 editor_proxy_id = 1; 27 | reserved 2; // buffer_proxy_id field is no longer supported 28 | } 29 | 30 | message SiteAssignment { 31 | string peer_id = 1; 32 | uint32 site_id = 2; 33 | } 34 | 35 | message EditorProxyRemoval { 36 | uint32 editor_proxy_id = 1; 37 | } 38 | 39 | message EditorProxyCreation { 40 | EditorProxyMetadata editor_proxy_metadata = 1; 41 | } 42 | } 43 | 44 | message Tether { 45 | uint32 follower_site_id = 1; 46 | uint32 leader_site_id = 2; 47 | uint32 state = 3; 48 | } 49 | 50 | message EditorProxy { 51 | uint32 id = 1; 52 | uint32 buffer_proxy_id = 2; 53 | map selection_layer_ids_by_site_id = 3; 54 | reserved 4; // tethers_by_follower_id field is no longer supported 55 | } 56 | 57 | message EditorProxyMetadata { 58 | uint32 id = 1; 59 | uint32 buffer_proxy_id = 2; 60 | string buffer_proxy_uri = 3; 61 | } 62 | 63 | message EditorProxyUpdate { 64 | oneof variant { 65 | SelectionsUpdate selections_update = 1; 66 | // TetherUpdate tether_update = 2; // No longer supported 67 | } 68 | 69 | message SelectionsUpdate { 70 | map selection_layer_ids_by_site_id = 1; 71 | } 72 | } 73 | 74 | message BufferProxy { 75 | uint32 id = 1; 76 | string uri = 2; 77 | repeated Operation operations = 3; 78 | } 79 | 80 | message BufferProxyUpdate { 81 | reserved 1; // operations field is no longer supported; use OperationsUpdate instead 82 | 83 | oneof variant { 84 | OperationsUpdate operations_update = 2; 85 | URIUpdate uri_update = 3; 86 | } 87 | 88 | message OperationsUpdate { 89 | repeated Operation operations = 1; 90 | } 91 | 92 | message URIUpdate { 93 | string uri = 1; 94 | } 95 | } 96 | 97 | message RouterMessage { 98 | oneof variant { 99 | Notification notification = 2; 100 | Request request = 3; 101 | Response response = 4; 102 | } 103 | 104 | message Notification { 105 | string channel_id = 1; 106 | bytes body = 2; 107 | } 108 | 109 | message Request { 110 | string channel_id = 1; 111 | uint32 request_id = 2; 112 | bytes body = 3; 113 | } 114 | 115 | message Response { 116 | uint32 request_id = 1; 117 | bytes body = 2; 118 | bool ok = 3; 119 | } 120 | } 121 | 122 | message NetworkMessage { 123 | string network_id = 1; 124 | 125 | oneof variant { 126 | StarJoinRequest star_join_request = 2; 127 | StarJoinResponse star_join_response = 3; 128 | StarJoinNotification star_join_notification = 4; 129 | StarLeaveNotification star_leave_notification = 5; 130 | StarUnicast star_unicast = 6; 131 | StarBroadcast star_broadcast = 7; 132 | } 133 | 134 | message StarJoinRequest { 135 | string sender_id = 1; 136 | } 137 | 138 | message StarJoinResponse { 139 | map member_identities_by_id = 1; 140 | } 141 | 142 | message StarJoinNotification { 143 | string member_id = 1; 144 | PeerIdentity member_identity = 2; 145 | } 146 | 147 | message StarLeaveNotification { 148 | string member_id = 1; 149 | bool connection_lost = 2; 150 | } 151 | 152 | message StarUnicast { 153 | string sender_id = 1; 154 | string recipient_id = 2; 155 | bytes body = 3; 156 | } 157 | 158 | message StarBroadcast { 159 | string sender_id = 1; 160 | bytes body = 2; 161 | } 162 | } 163 | 164 | message PeerIdentity { 165 | string login = 1; 166 | } 167 | -------------------------------------------------------------------------------- /teletype-crdt.proto: -------------------------------------------------------------------------------- 1 | node_modules/@atom/teletype-crdt/teletype-crdt.proto -------------------------------------------------------------------------------- /test/helpers/build-star-network.js: -------------------------------------------------------------------------------- 1 | const StarOverlayNetwork = require('../../lib/star-overlay-network') 2 | 3 | module.exports = 4 | function buildStarNetwork (id, peerPool, {isHub, connectionTimeout}={}) { 5 | const network = new StarOverlayNetwork({id, peerPool, isHub, connectionTimeout}) 6 | 7 | network.testJoinEvents = [] 8 | network.onMemberJoin(({peerId}) => network.testJoinEvents.push(peerId)) 9 | 10 | network.testLeaveEvents = [] 11 | network.onMemberLeave(({peerId, connectionLost}) => network.testLeaveEvents.push({peerId, connectionLost})) 12 | 13 | network.testInbox = [] 14 | network.onReceive(({senderId, message}) => { 15 | network.testInbox.push({ 16 | senderId, 17 | message: message.toString() 18 | }) 19 | }) 20 | 21 | return network 22 | } 23 | -------------------------------------------------------------------------------- /test/helpers/condition.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | function condition (fn) { 3 | const timeoutError = new Error('Condition timed out: ' + fn.toString()) 4 | Error.captureStackTrace(timeoutError, condition) 5 | 6 | return new Promise((resolve, reject) => { 7 | const intervalId = global.setInterval(() => { 8 | if (fn()) { 9 | global.clearTimeout(timeout) 10 | global.clearInterval(intervalId) 11 | resolve() 12 | } 13 | }, 5) 14 | 15 | const timeout = global.setTimeout(() => { 16 | global.clearInterval(intervalId) 17 | reject(timeoutError) 18 | }, 500) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /test/helpers/fake-buffer-delegate.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | 3 | module.exports = 4 | class Buffer { 5 | constructor (text, {didSetText} = {}) { 6 | this.text = text 7 | this.didSetText = didSetText 8 | this.saveRequestCount = 0 9 | this.uriChangeCount = 0 10 | } 11 | 12 | dispose () { 13 | this.disposed = true 14 | } 15 | 16 | isDisposed () { 17 | return this.disposed 18 | } 19 | 20 | getText () { 21 | return this.text 22 | } 23 | 24 | setText (text) { 25 | this.text = text 26 | if (this.didSetText) this.didSetText(text) 27 | } 28 | 29 | updateText (textUpdates) { 30 | assert(Array.isArray(textUpdates)) 31 | 32 | for (let i = textUpdates.length - 1; i >= 0; i--) { 33 | const textUpdate = textUpdates[i] 34 | const oldExtent = traversal(textUpdate.oldEnd, textUpdate.oldStart) 35 | this.delete(textUpdate.oldStart, oldExtent) 36 | this.insert(textUpdate.newStart, textUpdate.newText) 37 | } 38 | } 39 | 40 | insert (position, text) { 41 | const index = characterIndexForPosition(this.text, position) 42 | this.text = this.text.slice(0, index) + text + this.text.slice(index) 43 | return [position, position, text] 44 | } 45 | 46 | delete (startPosition, extent) { 47 | const endPosition = traverse(startPosition, extent) 48 | const textExtent = extentForText(this.text) 49 | assert(compare(startPosition, textExtent) < 0) 50 | assert(compare(endPosition, textExtent) <= 0) 51 | const startIndex = characterIndexForPosition(this.text, startPosition) 52 | const endIndex = characterIndexForPosition(this.text, endPosition) 53 | this.text = this.text.slice(0, startIndex) + this.text.slice(endIndex) 54 | return [startPosition, endPosition, ''] 55 | } 56 | 57 | didChangeURI () { 58 | this.uriChangeCount++ 59 | } 60 | 61 | save () { 62 | this.saveRequestCount++ 63 | } 64 | } 65 | 66 | function compare (a, b) { 67 | if (a.row === b.row) { 68 | return a.column - b.column 69 | } else { 70 | return a.row - b.row 71 | } 72 | } 73 | 74 | function traverse (start, distance) { 75 | if (distance.row === 0) 76 | return {row: start.row, column: start.column + distance.column} 77 | else { 78 | return {row: start.row + distance.row, column: distance.column} 79 | } 80 | } 81 | 82 | function traversal (end, start) { 83 | if (end.row === start.row) { 84 | return {row: 0, column: end.column - start.column} 85 | } else { 86 | return {row: end.row - start.row, column: end.column} 87 | } 88 | } 89 | 90 | function extentForText (text) { 91 | let row = 0 92 | let column = 0 93 | let index = 0 94 | while (index < text.length) { 95 | const char = text[index] 96 | if (char === '\n') { 97 | column = 0 98 | row++ 99 | } else { 100 | column++ 101 | } 102 | index++ 103 | } 104 | 105 | return {row, column} 106 | } 107 | 108 | function characterIndexForPosition (text, target) { 109 | const position = {row: 0, column: 0} 110 | let index = 0 111 | while (compare(position, target) < 0 && index < text.length) { 112 | if (text[index] === '\n') { 113 | position.row++ 114 | position.column = 0 115 | } else { 116 | position.column++ 117 | } 118 | 119 | index++ 120 | } 121 | 122 | return index 123 | } 124 | -------------------------------------------------------------------------------- /test/helpers/fake-editor-delegate.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | 3 | module.exports = 4 | class Editor { 5 | constructor () { 6 | this.selectionsBySiteId = {} 7 | } 8 | 9 | dispose () { 10 | this.disposed = true 11 | } 12 | 13 | isDisposed () { 14 | return this.disposed 15 | } 16 | 17 | updateViewport (startRow, endRow) { 18 | this.viewport = {startRow, endRow} 19 | } 20 | 21 | isScrollNeededToViewPosition (position) { 22 | assert(position && position.row != null && position.column != null) 23 | 24 | if (this.viewport) { 25 | const {row} = position 26 | return row < this.viewport.startRow || row > this.viewport.endRow 27 | } else { 28 | return false 29 | } 30 | } 31 | 32 | getSelectionsForSiteId (siteId) { 33 | assert.equal(typeof siteId, 'number', 'siteId must be a number!') 34 | return this.selectionsBySiteId[siteId] 35 | } 36 | 37 | updateSelectionsForSiteId (siteId, selectionUpdates) { 38 | assert.equal(typeof siteId, 'number', 'siteId must be a number!') 39 | 40 | let selectionsForSite = this.selectionsBySiteId[siteId] 41 | if (!selectionsForSite) { 42 | selectionsForSite = {} 43 | this.selectionsBySiteId[siteId] = selectionsForSite 44 | } 45 | 46 | for (const selectionId in selectionUpdates) { 47 | const selectionUpdate = selectionUpdates[selectionId] 48 | if (selectionUpdate) { 49 | selectionsForSite[selectionId] = selectionUpdate 50 | } else { 51 | delete selectionsForSite[selectionId] 52 | } 53 | } 54 | } 55 | 56 | clearSelectionsForSiteId (siteId) { 57 | delete this.selectionsBySiteId[siteId] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/helpers/fake-portal-delegate.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | 3 | module.exports = 4 | class FakePortalDelegate { 5 | constructor () { 6 | this.hostClosedPortal = false 7 | this.hostLostConnection = false 8 | this.joinEvents = [] 9 | this.leaveEvents = [] 10 | this.tetherEditorProxyChangeCount = 0 11 | this.tetherPosition = null 12 | this.activePositionsBySiteId = {} 13 | this.editorProxiesChangeEventCount = 0 14 | } 15 | 16 | dispose () { 17 | this.disposed = true 18 | } 19 | 20 | isDisposed () { 21 | return this.disposed 22 | } 23 | 24 | hostDidClosePortal () { 25 | this.hostClosedPortal = true 26 | } 27 | 28 | hasHostClosedPortal () { 29 | return this.hostClosedPortal 30 | } 31 | 32 | hostDidLoseConnection () { 33 | this.hostLostConnection = true 34 | } 35 | 36 | hasHostLostConnection () { 37 | return this.hostLostConnection 38 | } 39 | 40 | getTetherEditorProxy () { 41 | return this.tetherEditorProxy 42 | } 43 | 44 | getTetherBufferProxyURI () { 45 | return (this.tetherEditorProxy) ? this.tetherEditorProxy.bufferProxy.uri : null 46 | } 47 | 48 | updateTether (state, editorProxy, position) { 49 | this.tetherState = state 50 | if (editorProxy != this.tetherEditorProxy) { 51 | this.tetherEditorProxy = editorProxy 52 | this.tetherEditorProxyChangeCount++ 53 | } 54 | this.tetherPosition = position 55 | } 56 | 57 | getTetherState () { 58 | return this.tetherState 59 | } 60 | 61 | getTetherPosition () { 62 | return this.tetherPosition 63 | } 64 | 65 | updateActivePositions (activePositionsBySiteId) { 66 | this.activePositionsBySiteId = activePositionsBySiteId 67 | } 68 | 69 | getActivePositions () { 70 | return Object.keys(this.activePositionsBySiteId).map((siteId) => { 71 | const {editorProxy, position, followState} = this.activePositionsBySiteId[siteId] 72 | const editorProxyId = editorProxy ? editorProxy.id : null 73 | return {siteId, editorProxyId, position, followState} 74 | }) 75 | } 76 | 77 | siteDidJoin (siteId) { 78 | this.joinEvents.push(siteId) 79 | } 80 | 81 | siteDidLeave (siteId) { 82 | this.leaveEvents.push(siteId) 83 | } 84 | 85 | didChangeEditorProxies () { 86 | this.editorProxiesChangeEventCount++ 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /test/helpers/peer-pools.js: -------------------------------------------------------------------------------- 1 | const PeerPool = require('../../lib/peer-pool') 2 | const RestGateway = require('../../lib/rest-gateway') 3 | 4 | let testEpoch = 0 5 | const peerPools = [] 6 | 7 | exports.buildPeerPool = 8 | async function buildPeerPool (peerId, server, options = {}) { 9 | const oauthToken = peerId + '-token' 10 | const peerPool = new PeerPool({ 11 | peerId, 12 | peerIdentity: await server.identityProvider.identityForToken(oauthToken), 13 | restGateway: new RestGateway({baseURL: server.address, oauthToken}), 14 | pubSubGateway: server.pubSubGateway, 15 | connectionTimeout: options.connectionTimeout, 16 | testEpoch 17 | }) 18 | await peerPool.initialize() 19 | if (options.listen !== false) await peerPool.listen() 20 | 21 | peerPool.testDisconnectionEvents = [] 22 | peerPool.onDisconnection(({peerId}) => { 23 | peerPool.testDisconnectionEvents.push(peerId) 24 | }) 25 | 26 | peerPool.testInbox = [] 27 | peerPool.onReceive(({senderId, message}) => { 28 | peerPool.testInbox.push({ 29 | senderId, 30 | message: message.toString() 31 | }) 32 | }) 33 | 34 | peerPool.testErrors = [] 35 | peerPool.onError((error) => { 36 | peerPool.testErrors.push(error) 37 | }) 38 | 39 | peerPools.push(peerPool) 40 | 41 | return peerPool 42 | } 43 | 44 | exports.clearPeerPools = 45 | function clearPeerPools () { 46 | for (const peerPool of peerPools) { 47 | peerPool.dispose() 48 | } 49 | peerPools.length = 0 50 | testEpoch++ 51 | } 52 | -------------------------------------------------------------------------------- /test/helpers/set-equal.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | function setEqual (a, b) { 3 | if (a instanceof Array) a = new Set(a) 4 | if (b instanceof Array) b = new Set(b) 5 | 6 | if (a.size !== b.size) return false 7 | 8 | for (const element of a) { 9 | if (!b.has(element)) return false 10 | } 11 | 12 | return true 13 | } 14 | -------------------------------------------------------------------------------- /test/helpers/timeout.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | function timeout (ms) { 3 | return new Promise((resolve) => window.setTimeout(resolve, ms)) 4 | } 5 | -------------------------------------------------------------------------------- /test/peer-pool.test.js: -------------------------------------------------------------------------------- 1 | require('./setup') 2 | const assert = require('assert') 3 | const deepEqual = require('deep-equal') 4 | const {startTestServer} = require('@atom/teletype-server') 5 | const condition = require('./helpers/condition') 6 | const RestGateway = require('../lib/rest-gateway') 7 | const Errors = require('../lib/errors') 8 | const PeerPool = require('../lib/peer-pool') 9 | const {buildPeerPool, clearPeerPools} = require('./helpers/peer-pools') 10 | const {Disposable} = require('event-kit') 11 | 12 | suite('PeerPool', () => { 13 | let server 14 | 15 | suiteSetup(async () => { 16 | server = await startTestServer() 17 | }) 18 | 19 | suiteTeardown(() => { 20 | return server.stop() 21 | }) 22 | 23 | setup(() => { 24 | return server.reset() 25 | }) 26 | 27 | teardown(() => { 28 | clearPeerPools() 29 | }) 30 | 31 | test('connection and sending messages between peers', async () => { 32 | const peer1Pool = await buildPeerPool('1', server) 33 | const peer2Pool = await buildPeerPool('2', server) 34 | const peer3Pool = await buildPeerPool('3', server) 35 | 36 | // Local peer identities 37 | assert.equal(peer1Pool.getLocalPeerIdentity().login, 'user-with-token-1-token') 38 | assert.equal(peer2Pool.getLocalPeerIdentity().login, 'user-with-token-2-token') 39 | assert.equal(peer3Pool.getLocalPeerIdentity().login, 'user-with-token-3-token') 40 | 41 | // Connection 42 | await peer1Pool.connectTo('3') 43 | await peer2Pool.connectTo('3') 44 | 45 | await peer1Pool.getConnectedPromise('3') 46 | await peer2Pool.getConnectedPromise('3') 47 | await peer3Pool.getConnectedPromise('1') 48 | await peer3Pool.getConnectedPromise('2') 49 | 50 | // Remote peer identities 51 | assert.equal(peer1Pool.getPeerIdentity('3').login, 'user-with-token-3-token') 52 | assert.equal(peer3Pool.getPeerIdentity('1').login, 'user-with-token-1-token') 53 | assert.equal(peer2Pool.getPeerIdentity('3').login, 'user-with-token-3-token') 54 | assert.equal(peer3Pool.getPeerIdentity('2').login, 'user-with-token-2-token') 55 | 56 | // Single-part messages 57 | peer1Pool.send('3', Buffer.from('a')) 58 | peer2Pool.send('3', Buffer.from('b')) 59 | 60 | await condition(() => { 61 | peer3Pool.testInbox.sort((a, b) => a.senderId.localeCompare(b.senderId)) 62 | return deepEqual(peer3Pool.testInbox, [ 63 | {senderId: '1', message: 'a'}, 64 | {senderId: '2', message: 'b'} 65 | ]) 66 | }) 67 | peer3Pool.testInbox.length = 0 68 | 69 | // Multi-part messages 70 | const longMessage = 'x'.repeat(22) 71 | peer1Pool.send('3', Buffer.from(longMessage)) 72 | await condition(() => deepEqual(peer3Pool.testInbox, [{senderId: '1', message: longMessage}])) 73 | peer3Pool.testInbox.length = 0 74 | 75 | // Disconnection 76 | peer2Pool.disconnect() 77 | await peer2Pool.getDisconnectedPromise('3') 78 | await peer3Pool.getDisconnectedPromise('2') 79 | assert.deepEqual(peer1Pool.testDisconnectionEvents, []) 80 | assert.deepEqual(peer2Pool.testDisconnectionEvents, ['3']) 81 | assert.deepEqual(peer3Pool.testDisconnectionEvents, ['2']) 82 | 83 | peer3Pool.disconnect() 84 | await peer1Pool.getDisconnectedPromise('3') 85 | await peer3Pool.getDisconnectedPromise('1') 86 | assert.deepEqual(peer1Pool.testDisconnectionEvents, ['3']) 87 | assert.deepEqual(peer2Pool.testDisconnectionEvents, ['3']) 88 | assert.deepEqual(peer3Pool.testDisconnectionEvents, ['2', '1']) 89 | 90 | // Retain local peer identities after disconnecting 91 | assert.equal(peer1Pool.getLocalPeerIdentity().login, 'user-with-token-1-token') 92 | assert.equal(peer2Pool.getLocalPeerIdentity().login, 'user-with-token-2-token') 93 | assert.equal(peer3Pool.getLocalPeerIdentity().login, 'user-with-token-3-token') 94 | 95 | // Retain remote peer identities after disconnecting 96 | assert.equal(peer1Pool.getPeerIdentity('3').login, 'user-with-token-3-token') 97 | assert.equal(peer3Pool.getPeerIdentity('1').login, 'user-with-token-1-token') 98 | assert.equal(peer2Pool.getPeerIdentity('3').login, 'user-with-token-3-token') 99 | assert.equal(peer3Pool.getPeerIdentity('2').login, 'user-with-token-2-token') 100 | }) 101 | 102 | test('timeouts connecting to the pub-sub service', async () => { 103 | const restGateway = new RestGateway({baseURL: server.address}) 104 | const subscription = { 105 | disposed: false, 106 | dispose () { 107 | this.disposed = true 108 | } 109 | } 110 | const pubSubGateway = { 111 | subscribe () { 112 | return new Promise((resolve) => setTimeout(() => { resolve(subscription) }, 150)) 113 | } 114 | } 115 | const peerPool = new PeerPool({peerId: '1', connectionTimeout: 100, restGateway, pubSubGateway}) 116 | await peerPool.initialize() 117 | 118 | let error 119 | try { 120 | await peerPool.listen() 121 | } catch (e) { 122 | error = e 123 | } 124 | assert(error instanceof Errors.PubSubConnectionError) 125 | 126 | // Ensure the subscription gets disposed when its promise finally resolves. 127 | await condition(() => subscription.disposed) 128 | }) 129 | 130 | test('authentication errors during signaling', async () => { 131 | const peer1Pool = await buildPeerPool('1', server, {connectionTimeout: 300}) 132 | const peer2Pool = await buildPeerPool('2', server) 133 | 134 | // Simulate peer 2 revoking their access token after initializing 135 | server.identityProvider.setIdentitiesByToken({ 136 | '1-token': {login: 'peer-1'}, 137 | '2-token': null, 138 | }) 139 | 140 | // Invalid token error during offer phase of signaling 141 | { 142 | let error 143 | try { 144 | await peer2Pool.connectTo('1') 145 | } catch (e) { 146 | error = e 147 | } 148 | assert(error instanceof Errors.InvalidAuthenticationTokenError) 149 | } 150 | 151 | // Invalid token error during answer phase of signaling 152 | { 153 | let error 154 | try { 155 | await peer1Pool.connectTo('2') 156 | } catch (e) { 157 | error = e 158 | } 159 | assert(error instanceof Errors.PeerConnectionError) 160 | assert(peer2Pool.testErrors.length > 0) 161 | assert(peer2Pool.testErrors.every((e) => e instanceof Errors.InvalidAuthenticationTokenError)) 162 | } 163 | 164 | // After restoring peer 2's identity, we should be able to connect 165 | server.identityProvider.setIdentitiesByToken({ 166 | '1-token': {login: 'peer-1'}, 167 | '2-token': {login: 'peer-2'}, 168 | }) 169 | await peer1Pool.connectTo('2') 170 | }) 171 | 172 | test('timeouts establishing a connection to a peer', async () => { 173 | const restGateway = new RestGateway({baseURL: server.address}) 174 | const subscribeRequests = [] 175 | const pubSubGateway = { 176 | subscribe () { 177 | subscribeRequests.push(arguments) 178 | return Promise.resolve({ 179 | dispose () {} 180 | }) 181 | } 182 | } 183 | 184 | const peer1Pool = new PeerPool({peerId: '1', connectionTimeout: 100, restGateway, pubSubGateway}) 185 | const peer2Pool = new PeerPool({peerId: '2', connectionTimeout: 100, restGateway, pubSubGateway}) 186 | await Promise.all([peer1Pool.initialize(), peer2Pool.initialize()]) 187 | await Promise.all([peer1Pool.listen(), peer2Pool.listen()]) 188 | 189 | let error 190 | try { 191 | await peer1Pool.connectTo('2') 192 | } catch (e) { 193 | error = e 194 | } 195 | assert(error instanceof Errors.PeerConnectionError) 196 | 197 | // Ensure the connection can be established later if the error resolves 198 | // itself. To do so, we will forward all the subscribe requests received so 199 | // far to the server's pub sub gateway, so that the two peers can 200 | // communicate with each other. 201 | for (const subscribeRequest of subscribeRequests) { 202 | server.pubSubGateway.subscribe(...subscribeRequest) 203 | } 204 | peer1Pool.connectionTimeout = 2000 205 | peer2Pool.connectionTimeout = 2000 206 | 207 | await peer1Pool.connectTo('2') 208 | await peer1Pool.getConnectedPromise('2') 209 | await peer2Pool.getConnectedPromise('1') 210 | }) 211 | 212 | test('attempting to connect to yourself', async () => { 213 | const peerPool = await buildPeerPool('1', server) 214 | 215 | let error 216 | try { 217 | await peerPool.connectTo('1') 218 | } catch (e) { 219 | error = e 220 | } 221 | assert(error instanceof Errors.PeerConnectionError) 222 | }) 223 | 224 | test('RTCPeerConnection and RTCDataChannel error events', async () => { 225 | const peer1Pool = await buildPeerPool('1', server) 226 | const peer2Pool = await buildPeerPool('2', server) 227 | const peer3Pool = await buildPeerPool('3', server) 228 | const peer4Pool = await buildPeerPool('4', server) 229 | await peer1Pool.connectTo('2') 230 | await peer1Pool.connectTo('3') 231 | await peer1Pool.connectTo('4') 232 | const peer2Connection = peer1Pool.getPeerConnection('2') 233 | const peer3Connection = peer1Pool.getPeerConnection('3') 234 | const peer4Connection = peer1Pool.getPeerConnection('4') 235 | 236 | const errorEvent1 = new ErrorEvent('') 237 | peer2Connection.rtcPeerConnection.onerror(errorEvent1) 238 | assert.deepEqual(peer1Pool.testErrors, [errorEvent1]) 239 | assert.deepEqual(peer1Pool.testDisconnectionEvents, ['2']) 240 | assert.notEqual(peer1Pool.getPeerConnection('2'), peer2Connection) 241 | 242 | const errorEvent2 = new ErrorEvent('') 243 | peer3Connection.rtcPeerConnection.onerror(errorEvent2) 244 | assert.deepEqual(peer1Pool.testDisconnectionEvents, ['2', '3']) 245 | assert.deepEqual(peer1Pool.testErrors, [errorEvent1, errorEvent2]) 246 | assert.notEqual(peer1Pool.getPeerConnection('3'), peer3Connection) 247 | 248 | const errorEvent3 = new ErrorEvent('') 249 | peer4Connection.channel.onerror(errorEvent3) 250 | assert.deepEqual(peer1Pool.testDisconnectionEvents, ['2', '3', '4']) 251 | assert.deepEqual(peer1Pool.testErrors, [errorEvent1, errorEvent2, errorEvent3]) 252 | assert.notEqual(peer1Pool.getPeerConnection('4'), peer4Connection) 253 | }) 254 | 255 | test('listening more than once for incoming connections', async () => { 256 | const peerPool = await buildPeerPool('1', server, {listen: false}) 257 | peerPool.pubSubGateway = { 258 | subscriptionsCount: 0, 259 | subscribe () { 260 | this.subscriptionsCount++ 261 | return new Disposable(() => this.subscriptionsCount--) 262 | } 263 | } 264 | 265 | // Parallel calls to `listen` will only subscribe once. 266 | const [listen1Disposable, listen2Disposable] = await Promise.all([peerPool.listen(), peerPool.listen()]) 267 | const listen3Disposable = await peerPool.listen() 268 | assert.equal(peerPool.pubSubGateway.subscriptionsCount, 1) 269 | 270 | // Dispose subscription when the last listener is disposed. 271 | listen1Disposable.dispose() 272 | assert.equal(peerPool.pubSubGateway.subscriptionsCount, 1) 273 | 274 | listen2Disposable.dispose() 275 | assert.equal(peerPool.pubSubGateway.subscriptionsCount, 1) 276 | 277 | listen3Disposable.dispose() 278 | assert.equal(peerPool.pubSubGateway.subscriptionsCount, 0) 279 | 280 | // Ensure PeerPool can listen again. 281 | const listen4Disposable = await peerPool.listen() 282 | assert.equal(peerPool.pubSubGateway.subscriptionsCount, 1) 283 | 284 | // Ensure disposing the PeerPool also disposes subscriptions. 285 | peerPool.dispose() 286 | assert.equal(peerPool.pubSubGateway.subscriptionsCount, 0) 287 | }) 288 | }) 289 | -------------------------------------------------------------------------------- /test/portal.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const {Disposable} = require('event-kit') 3 | const {startTestServer} = require('@atom/teletype-server') 4 | const {buildPeerPool, clearPeerPools} = require('./helpers/peer-pools') 5 | const condition = require('./helpers/condition') 6 | const setEqual = require('./helpers/set-equal') 7 | const FakePortalDelegate = require('./helpers/fake-portal-delegate') 8 | const Portal = require('../lib/portal') 9 | 10 | suite('Portal', () => { 11 | let server 12 | 13 | suiteSetup(async () => { 14 | server = await startTestServer() 15 | }) 16 | 17 | suiteTeardown(() => { 18 | return server.stop() 19 | }) 20 | 21 | setup(() => { 22 | return server.reset() 23 | }) 24 | 25 | teardown(() => { 26 | clearPeerPools() 27 | }) 28 | 29 | suite('join', () => { 30 | test('throws and disposes itself when a network error occurs', async () => { 31 | const peerPool = await buildPeerPool('guest', server) 32 | const portal = new Portal({id: 'id', hostPeerId: 'host', siteId: 2, peerPool}) 33 | portal.network.connectTo = function () { 34 | throw new Error('an error') 35 | } 36 | 37 | let error 38 | try { 39 | await portal.join() 40 | } catch (e) { 41 | error = e 42 | } 43 | assert.equal(error.message, 'an error') 44 | assert(portal.disposed) 45 | }) 46 | }) 47 | 48 | test('joining and leaving a portal', async () => { 49 | const hostPeerPool = await buildPeerPool('host', server) 50 | const guest1PeerPool = await buildPeerPool('guest1', server) 51 | const guest2PeerPool = await buildPeerPool('guest2', server) 52 | 53 | const hostPortal = await buildPortal('portal', hostPeerPool) 54 | const guest1Portal = await buildPortal('portal', guest1PeerPool, 'host') 55 | const guest2Portal = await buildPortal('portal', guest2PeerPool, 'host') 56 | await guest1Portal.join() 57 | await guest2Portal.join() 58 | 59 | assert(setEqual(hostPortal.getActiveSiteIds(), [1, 2, 3])) 60 | assert(setEqual(guest1Portal.getActiveSiteIds(), [1, 2, 3])) 61 | assert(setEqual(guest2Portal.getActiveSiteIds(), [1, 2, 3])) 62 | 63 | assert.deepEqual(hostPortal.testDelegate.joinEvents, [2, 3]) 64 | assert.deepEqual(guest1Portal.testDelegate.joinEvents, [3]) 65 | assert.deepEqual(guest2Portal.testDelegate.joinEvents, []) 66 | 67 | guest1Portal.dispose() 68 | await condition(() => ( 69 | setEqual(hostPortal.getActiveSiteIds(), [1, 3]) && 70 | setEqual(guest1Portal.getActiveSiteIds(), [2]) && 71 | setEqual(guest2Portal.getActiveSiteIds(), [1, 3]) 72 | )) 73 | 74 | assert.deepEqual(hostPortal.testDelegate.leaveEvents, [2]) 75 | assert.deepEqual(guest1Portal.testDelegate.leaveEvents, []) 76 | assert.deepEqual(guest2Portal.testDelegate.leaveEvents, [2]) 77 | 78 | // Ensure leave event is not emitted when the host disconnects. 79 | hostPortal.dispose() 80 | await condition(() => ( 81 | setEqual(hostPortal.getActiveSiteIds(), [1]) && 82 | setEqual(guest1Portal.getActiveSiteIds(), [2]) && 83 | setEqual(guest2Portal.getActiveSiteIds(), [3]) 84 | )) 85 | 86 | assert.deepEqual(hostPortal.testDelegate.leaveEvents, [2]) 87 | assert.deepEqual(guest1Portal.testDelegate.leaveEvents, []) 88 | assert.deepEqual(guest2Portal.testDelegate.leaveEvents, [2]) 89 | assert(guest2Portal.testDelegate.hasHostClosedPortal()) 90 | }) 91 | 92 | test('site identities', async () => { 93 | const hostIdentity = {login: 'host'} 94 | const guest1Identity = {login: 'guest1'} 95 | const guest2Identity = {login: 'guest2'} 96 | 97 | server.identityProvider.setIdentitiesByToken({ 98 | 'host-token': hostIdentity, 99 | 'guest1-token': guest1Identity, 100 | 'guest2-token': guest2Identity 101 | }) 102 | 103 | const hostPeerPool = await buildPeerPool('host', server) 104 | const guest1PeerPool = await buildPeerPool('guest1', server) 105 | const guest2PeerPool = await buildPeerPool('guest2', server) 106 | 107 | const hostPortal = await buildPortal('portal', hostPeerPool) 108 | const guest1Portal = await buildPortal('portal', guest1PeerPool, 'host') 109 | const guest2Portal = await buildPortal('portal', guest2PeerPool, 'host') 110 | await guest1Portal.join() 111 | await guest2Portal.join() 112 | 113 | assert.equal(hostPortal.getSiteIdentity(1).login, hostIdentity.login) 114 | assert.equal(hostPortal.getSiteIdentity(2).login, guest1Identity.login) 115 | assert.equal(hostPortal.getSiteIdentity(3).login, guest2Identity.login) 116 | 117 | assert.equal(guest1Portal.getSiteIdentity(1).login, hostIdentity.login) 118 | assert.equal(guest1Portal.getSiteIdentity(2).login, guest1Identity.login) 119 | assert.equal(guest1Portal.getSiteIdentity(3).login, guest2Identity.login) 120 | 121 | assert.equal(guest2Portal.getSiteIdentity(1).login, hostIdentity.login) 122 | assert.equal(guest2Portal.getSiteIdentity(2).login, guest1Identity.login) 123 | assert.equal(guest2Portal.getSiteIdentity(3).login, guest2Identity.login) 124 | }) 125 | 126 | test('notifying delegate of active position of other sites', async () => { 127 | const hostPeerPool = await buildPeerPool('host', server) 128 | const guestPeerPool = await buildPeerPool('guest', server) 129 | const hostPortal = await buildPortal('portal', hostPeerPool) 130 | const guestPortal = await buildPortal('portal', guestPeerPool, 'host') 131 | 132 | const joinPromise = guestPortal.join() 133 | await guestPortal.networkConnectionPromise 134 | 135 | const bufferProxy = await hostPortal.createBufferProxy({uri: 'uri', text: ''}) 136 | const editorProxy = await hostPortal.createEditorProxy({bufferProxy}) 137 | hostPortal.activateEditorProxy(editorProxy) 138 | 139 | // Guest has only partially joined, so it's not yet an active site. 140 | let activeSiteIds = Object.keys(hostPortal.testDelegate.activePositionsBySiteId) 141 | assert.deepEqual(activeSiteIds, ['1']) 142 | 143 | // When guest finally joins, it becomes an active site. 144 | await joinPromise 145 | activeSiteIds = Object.keys(hostPortal.testDelegate.activePositionsBySiteId) 146 | assert.deepEqual(activeSiteIds, ['1', '2']) 147 | }) 148 | 149 | suite('changing active editor proxy', () => { 150 | test('host only notifies guests when active editor proxy has changed', async () => { 151 | const hostPeerPool = await buildPeerPool('host', server) 152 | const guestPeerPool = await buildPeerPool('guest', server) 153 | const hostPortal = await buildPortal('portal', hostPeerPool) 154 | const guestPortal = await buildPortal('portal', guestPeerPool, 'host') 155 | await guestPortal.join() 156 | guestPortal.testDelegate.tetherEditorProxyChangeCount = 0 157 | 158 | // Don't notify guests when setting the active editor proxy to the same value it currently has. 159 | hostPortal.activateEditorProxy(null) 160 | 161 | // Set the active editor proxy to a different value to ensure guests are notified only of this change. 162 | const originalBufferProxy = await hostPortal.createBufferProxy({uri: 'original-uri', text: ''}) 163 | const originalEditorProxy = await hostPortal.createEditorProxy({bufferProxy: originalBufferProxy}) 164 | hostPortal.activateEditorProxy(originalEditorProxy) 165 | await condition(() => ( 166 | guestPortal.testDelegate.getTetherBufferProxyURI() === 'original-uri' && 167 | guestPortal.testDelegate.tetherEditorProxyChangeCount === 1 168 | )) 169 | }) 170 | 171 | test('guest gracefully handles race conditions', async () => { 172 | const hostPeerPool = await buildPeerPool('host', server) 173 | const guestPeerPool = await buildPeerPool('guest', server) 174 | const hostPortal = await buildPortal('portal', hostPeerPool) 175 | const guestPortal = await buildPortal('portal', guestPeerPool, 'host') 176 | await guestPortal.join() 177 | guestPortal.testDelegate.tetherEditorProxyChangeCount = 0 178 | 179 | // Ensure no race condition occurs on the guest when fetching new editor 180 | // proxies for the first time and, at the same time, receiving a request 181 | // to switch to a previous value of active editor proxy. 182 | const newBufferProxy = await hostPortal.createBufferProxy({uri: 'new-uri', text: ''}) 183 | const newEditorProxy = await hostPortal.createEditorProxy({bufferProxy: newBufferProxy}) 184 | hostPortal.activateEditorProxy(newEditorProxy) 185 | hostPortal.activateEditorProxy(null) 186 | await condition(() => ( 187 | guestPortal.testDelegate.getTetherBufferProxyURI() === null && 188 | guestPortal.testDelegate.tetherEditorProxyChangeCount === 2 189 | )) 190 | }) 191 | 192 | test('guest gracefully handles switching to an editor proxy whose buffer has already been destroyed', async () => { 193 | const hostPeerPool = await buildPeerPool('host', server) 194 | const guestPeerPool = await buildPeerPool('guest', server) 195 | const hostPortal = await buildPortal('portal', hostPeerPool) 196 | const guestPortal = await buildPortal('portal', guestPeerPool, 'host') 197 | await guestPortal.join() 198 | guestPortal.testDelegate.tetherEditorProxyChangeCount = 0 199 | 200 | const bufferProxy1 = await hostPortal.createBufferProxy({uri: 'uri-1', text: ''}) 201 | const editorProxy1 = await hostPortal.createEditorProxy({bufferProxy: bufferProxy1}) 202 | const bufferProxy2 = await hostPortal.createBufferProxy({uri: 'uri-2', text: ''}) 203 | const editorProxy2 = await hostPortal.createEditorProxy({bufferProxy: bufferProxy2}) 204 | 205 | hostPortal.activateEditorProxy(editorProxy1) 206 | bufferProxy1.dispose() 207 | hostPortal.activateEditorProxy(null) 208 | hostPortal.activateEditorProxy(editorProxy2) 209 | await condition(() => ( 210 | guestPortal.testDelegate.getTetherBufferProxyURI() === 'uri-2' && 211 | guestPortal.testDelegate.tetherEditorProxyChangeCount === 1 212 | )) 213 | }) 214 | 215 | test('guest gracefully handles switching to an editor proxy that has already been destroyed', async () => { 216 | const hostPeerPool = await buildPeerPool('host', server) 217 | const guestPeerPool = await buildPeerPool('guest', server) 218 | const hostPortal = await buildPortal('portal', hostPeerPool) 219 | const guestPortal = await buildPortal('portal', guestPeerPool, 'host') 220 | await guestPortal.join() 221 | guestPortal.testDelegate.tetherEditorProxyChangeCount = 0 222 | 223 | const bufferProxy1 = await hostPortal.createBufferProxy({uri: 'uri-1', text: ''}) 224 | const editorProxy1 = await hostPortal.createEditorProxy({bufferProxy: bufferProxy1}) 225 | const bufferProxy2 = await hostPortal.createBufferProxy({uri: 'uri-2', text: ''}) 226 | const editorProxy2 = await hostPortal.createEditorProxy({bufferProxy: bufferProxy2}) 227 | 228 | hostPortal.activateEditorProxy(editorProxy1) 229 | editorProxy1.dispose() 230 | hostPortal.activateEditorProxy(null) 231 | hostPortal.activateEditorProxy(editorProxy2) 232 | await condition(() => ( 233 | guestPortal.testDelegate.getTetherBufferProxyURI() === 'uri-2' && 234 | guestPortal.testDelegate.tetherEditorProxyChangeCount === 1 235 | )) 236 | }) 237 | 238 | test('host gracefully handles guests switching to an editor that has already been destroyed', async () => { 239 | const hostPeerPool = await buildPeerPool('host', server) 240 | const guestPeerPool = await buildPeerPool('guest', server) 241 | const hostPortal = await buildPortal('portal', hostPeerPool) 242 | const guestPortal = await buildPortal('portal', guestPeerPool, 'host') 243 | await guestPortal.join() 244 | 245 | const hostBufferProxy1 = await hostPortal.createBufferProxy({uri: 'uri-1', text: ''}) 246 | const hostEditorProxy1 = await hostPortal.createEditorProxy({bufferProxy: hostBufferProxy1}) 247 | 248 | hostPortal.activateEditorProxy(hostEditorProxy1) 249 | await condition(() => guestPortal.testDelegate.getTetherBufferProxyURI() === 'uri-1') 250 | const guestEditorProxy1 = guestPortal.testDelegate.getTetherEditorProxy() 251 | 252 | hostEditorProxy1.dispose() 253 | guestPortal.activateEditorProxy(null) 254 | guestPortal.activateEditorProxy(guestEditorProxy1) 255 | 256 | const hostBufferProxy2 = await hostPortal.createBufferProxy({uri: 'uri-2', text: ''}) 257 | const hostEditorProxy2 = await hostPortal.createEditorProxy({bufferProxy: hostBufferProxy2}) 258 | hostPortal.activateEditorProxy(hostEditorProxy2) 259 | await condition(() => guestPortal.getEditorProxiesMetadata().find((e) => e.bufferProxyURI === 'uri-2')) 260 | }) 261 | }) 262 | 263 | async function buildPortal (portalId, peerPool, hostPeerId) { 264 | const siteId = hostPeerId == null ? 1 : null 265 | const portal = new Portal({id: portalId, hostPeerId, siteId, peerPool}) 266 | await portal.initialize() 267 | portal.testDelegate = new FakePortalDelegate() 268 | await portal.setDelegate(portal.testDelegate) 269 | return portal 270 | } 271 | }) 272 | -------------------------------------------------------------------------------- /test/rest-gateway.test.js: -------------------------------------------------------------------------------- 1 | require('./setup') 2 | const assert = require('assert') 3 | const http = require('http') 4 | const Errors = require('../lib/errors') 5 | const RestGateway = require('../lib/rest-gateway') 6 | 7 | suite('RestGateway', () => { 8 | const servers = [] 9 | 10 | teardown(() => { 11 | for (const server of servers) { 12 | server.close() 13 | } 14 | servers.length = 0 15 | }) 16 | 17 | suite('get', () => { 18 | test('successful request and response', async () => { 19 | const address = listen(function (request, response) { 20 | response.writeHead(200, {'Content-Type': 'application/json'}) 21 | response.write('{"a": 1}') 22 | response.end() 23 | }) 24 | 25 | const gateway = new RestGateway({baseURL: address}) 26 | const response = await gateway.get('/') 27 | assert(response.ok) 28 | assert.deepEqual(response.body, {a: 1}) 29 | }) 30 | 31 | test('failed request', async () => { 32 | const gateway = new RestGateway({baseURL: 'http://localhost:0987654321'}) 33 | let error 34 | try { 35 | await gateway.get('/foo/b9e13e6b-9e6e-492c-b4d9-4ec75fd9c2bc/bar') 36 | } catch (e) { 37 | error = e 38 | } 39 | assert(error instanceof Errors.HTTPRequestError) 40 | assert(error.diagnosticMessage.includes('GET')) 41 | assert(error.diagnosticMessage.includes('/foo/REDACTED/bar')) 42 | }) 43 | 44 | test('non-JSON response', async () => { 45 | const address = listen(function (request, response) { 46 | response.writeHead(200, {'Content-Type': 'text/plain'}) 47 | response.write('some unexpected response (b9e13e6b-9e6e-492c-b4d9-4ec75fd9c2bc)') 48 | response.end() 49 | }) 50 | 51 | const gateway = new RestGateway({baseURL: address}) 52 | let error 53 | try { 54 | await gateway.get('/foo/b9e13e6b-9e6e-492c-b4d9-4ec75fd9c2bc/bar') 55 | } catch (e) { 56 | error = e 57 | } 58 | assert(error instanceof Errors.HTTPRequestError) 59 | assert(error.diagnosticMessage.includes('GET')) 60 | assert(error.diagnosticMessage.includes('/foo/REDACTED/bar')) 61 | assert(error.diagnosticMessage.includes('200')) 62 | assert(error.diagnosticMessage.includes('some unexpected response (REDACTED)')) 63 | }) 64 | }) 65 | 66 | suite('post', () => { 67 | test('successful request and response', async () => { 68 | const address = listen(function (request, response) { 69 | response.writeHead(200, {'Content-Type': 'application/json'}) 70 | response.write('{"a": 1}') 71 | response.end() 72 | }) 73 | 74 | const gateway = new RestGateway({baseURL: address}) 75 | const response = await gateway.post('/') 76 | assert(response.ok) 77 | assert.deepEqual(response.body, {a: 1}) 78 | }) 79 | 80 | test('failed request', async () => { 81 | const gateway = new RestGateway({baseURL: 'http://localhost:0987654321'}) 82 | let error 83 | try { 84 | await gateway.post('/foo/b9e13e6b-9e6e-492c-b4d9-4ec75fd9c2bc/bar', { "a": 1 }) 85 | } catch (e) { 86 | error = e 87 | } 88 | assert(error instanceof Errors.HTTPRequestError) 89 | assert(error.diagnosticMessage.includes('POST')) 90 | assert(error.diagnosticMessage.includes('/foo/REDACTED/bar')) 91 | }) 92 | 93 | test('non-JSON response', async () => { 94 | const address = listen(function (request, response) { 95 | response.writeHead(200, {'Content-Type': 'text/plain'}) 96 | response.write('some unexpected response (b9e13e6b-9e6e-492c-b4d9-4ec75fd9c2bc)') 97 | response.end() 98 | }) 99 | 100 | const gateway = new RestGateway({baseURL: address}) 101 | let error 102 | try { 103 | await gateway.post('/foo/b9e13e6b-9e6e-492c-b4d9-4ec75fd9c2bc/bar') 104 | } catch (e) { 105 | error = e 106 | } 107 | assert(error instanceof Errors.HTTPRequestError) 108 | assert(error.diagnosticMessage.includes('POST')) 109 | assert(error.diagnosticMessage.includes('/foo/REDACTED/bar')) 110 | assert(error.diagnosticMessage.includes('200')) 111 | assert(error.diagnosticMessage.includes('some unexpected response (REDACTED)')) 112 | }) 113 | }) 114 | 115 | function listen (requestListener) { 116 | const server = http.createServer(requestListener).listen(0) 117 | servers.push(server) 118 | return `http://localhost:${server.address().port}` 119 | } 120 | }) 121 | -------------------------------------------------------------------------------- /test/router.test.js: -------------------------------------------------------------------------------- 1 | require('./setup') 2 | const assert = require('assert') 3 | const deepEqual = require('deep-equal') 4 | const {startTestServer} = require('@atom/teletype-server') 5 | const condition = require('./helpers/condition') 6 | const {buildPeerPool, clearPeerPools} = require('./helpers/peer-pools') 7 | const buildStarNetwork = require('./helpers/build-star-network') 8 | const Router = require('../lib/router') 9 | 10 | suite('Router', () => { 11 | let server 12 | 13 | suiteSetup(async () => { 14 | server = await startTestServer() 15 | }) 16 | 17 | suiteTeardown(() => { 18 | return server.stop() 19 | }) 20 | 21 | setup(() => { 22 | return server.reset() 23 | }) 24 | 25 | teardown(() => { 26 | clearPeerPools() 27 | }) 28 | 29 | test('notifications', async () => { 30 | const hub = buildStarNetwork('some-network-id', await buildPeerPool('hub', server), {isHub: true}) 31 | const spoke1 = buildStarNetwork('some-network-id', await buildPeerPool('spoke-1', server), {isHub: false}) 32 | const spoke2 = buildStarNetwork('some-network-id', await buildPeerPool('spoke-2', server), {isHub: false}) 33 | await spoke1.connectTo('hub') 34 | await spoke2.connectTo('hub') 35 | 36 | const hubRouter = new Router(hub) 37 | const spoke1Router = new Router(spoke1) 38 | const spoke2Router = new Router(spoke2) 39 | recordNotifications(hubRouter, ['channel-1']) 40 | recordNotifications(spoke1Router, ['channel-1', 'channel-2']) 41 | recordNotifications(spoke2Router, ['channel-1', 'channel-2']) 42 | 43 | hubRouter.notify({channelId: 'channel-2', body: 'from-hub'}) 44 | spoke1Router.notify({channelId: 'channel-1', body: 'from-spoke-1'}) 45 | spoke2Router.notify({channelId: 'channel-2', body: 'from-spoke-2'}) 46 | 47 | await condition(() => deepEqual(hubRouter.testInbox, { 48 | 'channel-1': [{senderId: 'spoke-1', body: 'from-spoke-1'}] 49 | })) 50 | await condition(() => deepEqual(spoke1Router.testInbox, { 51 | 'channel-2': [ 52 | {senderId: 'hub', body: 'from-hub'}, 53 | {senderId: 'spoke-2', body: 'from-spoke-2'} 54 | ] 55 | })) 56 | await condition(() => deepEqual(spoke2Router.testInbox, { 57 | 'channel-1': [{senderId: 'spoke-1', body: 'from-spoke-1'}], 58 | 'channel-2': [{senderId: 'hub', body: 'from-hub'}] 59 | })) 60 | 61 | hubRouter.testInbox = [] 62 | spoke1Router.testInbox = [] 63 | spoke2Router.testInbox = [] 64 | spoke2Router.notify({recipientId: 'spoke-1', channelId: 'channel-1', body: 'direct-notification-from-spoke-2'}) 65 | 66 | await condition(() => deepEqual(spoke1Router.testInbox, { 67 | 'channel-1': [{senderId: 'spoke-2', body: 'direct-notification-from-spoke-2'}] 68 | })) 69 | assert.deepEqual(hubRouter.testInbox, []) 70 | assert.deepEqual(spoke2Router.testInbox, []) 71 | }) 72 | 73 | test('request/response', async () => { 74 | const hub = buildStarNetwork('some-network-id', await buildPeerPool('hub', server), {isHub: true}) 75 | const spoke1 = buildStarNetwork('some-network-id', await buildPeerPool('spoke-1', server), {isHub: false}) 76 | const spoke2 = buildStarNetwork('some-network-id', await buildPeerPool('spoke-2', server), {isHub: false}) 77 | await spoke1.connectTo('hub') 78 | await spoke2.connectTo('hub') 79 | 80 | const spoke1Router = new Router(spoke1) 81 | const spoke2Router = new Router(spoke2) 82 | 83 | spoke2Router.onRequest('channel-1', ({senderId, requestId, body}) => { 84 | assert.equal(body.toString(), 'request from spoke 1 on channel 1') 85 | spoke2Router.respond({requestId, body: 'response from spoke 2 on channel 1'}) 86 | }) 87 | spoke2Router.onRequest('channel-2', ({senderId, requestId, body}) => { 88 | assert.equal(body.toString(), 'request from spoke 1 on channel 2') 89 | spoke2Router.respond({requestId, body: 'response from spoke 2 on channel 2'}) 90 | }) 91 | 92 | { 93 | const response = await spoke1Router.request({ 94 | recipientId: 'spoke-2', 95 | channelId: 'channel-1', 96 | body: 'request from spoke 1 on channel 1' 97 | }) 98 | assert(response.ok) 99 | assert.equal(response.body.toString(), 'response from spoke 2 on channel 1') 100 | } 101 | 102 | { 103 | const response = await spoke1Router.request({ 104 | recipientId: 'spoke-2', 105 | channelId: 'channel-2', 106 | body: 'request from spoke 1 on channel 2' 107 | }) 108 | assert(response.ok) 109 | assert.equal(response.body.toString(), 'response from spoke 2 on channel 2') 110 | } 111 | 112 | // Ensure requests to nonexistent routes receive a failure response. 113 | { 114 | const response = await spoke1Router.request({ 115 | recipientId: 'spoke-2', 116 | channelId: 'nonexistent', 117 | body: 'request from spoke 1 on nonexistent channel' 118 | }) 119 | assert(!response.ok) 120 | assert.equal(response.body.length, 0) 121 | } 122 | 123 | // Ensure requests and responses with no body are allowed 124 | { 125 | spoke2Router.onRequest('channel-3', ({senderId, requestId, body}) => { 126 | assert.equal(body.length, 0) 127 | spoke2Router.respond({requestId}) 128 | }) 129 | const response = await spoke1Router.request({recipientId: 'spoke-2', channelId: 'channel-3'}) 130 | assert(response.ok) 131 | assert.equal(response.body.length, 0) 132 | } 133 | 134 | // Ensure that multiple responses are disallowed 135 | { 136 | spoke2Router.onRequest('channel-4', ({senderId, requestId, body}) => { 137 | spoke2Router.respond({requestId, body: 'response from spoke 2 on channel 4'}) 138 | assert.throws( 139 | () => spoke2Router.respond({requestId, body: 'duplicate response'}), 140 | 'Multiple responses to the same request are not allowed' 141 | ) 142 | }) 143 | 144 | await spoke1Router.request({ 145 | recipientId: 'spoke-2', 146 | channelId: 'channel-4', 147 | body: 'request from spoke 1 on channel 3' 148 | }) 149 | } 150 | }) 151 | 152 | test('async notification and request handlers', async () => { 153 | const hub = buildStarNetwork('some-network-id', await buildPeerPool('hub', server), {isHub: true}) 154 | const spoke = buildStarNetwork('some-network-id', await buildPeerPool('spoke', server), {isHub: false}) 155 | await spoke.connectTo('hub') 156 | 157 | const hubRouter = new Router(hub) 158 | const spokeRouter = new Router(spoke) 159 | const spokeInbox = [] 160 | 161 | spokeRouter.onNotification('notification-channel-1', async ({body}) => { 162 | await timeout(Math.random() * 50) 163 | spokeInbox.push(body.toString()) 164 | }) 165 | spokeRouter.onNotification('notification-channel-2', async ({body}) => { 166 | await timeout(Math.random() * 50) 167 | spokeInbox.push(body.toString()) 168 | }) 169 | spokeRouter.onRequest('request-channel-1', async ({body}) => { 170 | await timeout(Math.random() * 50) 171 | spokeInbox.push(body.toString()) 172 | }) 173 | spokeRouter.onRequest('request-channel-2', async ({body}) => { 174 | await timeout(Math.random() * 50) 175 | spokeInbox.push(body.toString()) 176 | }) 177 | 178 | hubRouter.notify({channelId: 'notification-channel-1', body: '1'}) 179 | hubRouter.notify({channelId: 'notification-channel-2', body: '2'}) 180 | hubRouter.request({recipientId: 'spoke', channelId: 'request-channel-1', body: '3'}) 181 | hubRouter.request({recipientId: 'spoke', channelId: 'request-channel-2', body: '4'}) 182 | 183 | await condition(() => deepEqual(spokeInbox, ['1', '2', '3', '4'])) 184 | }) 185 | }) 186 | 187 | function recordNotifications (router, channelIds) { 188 | if (!router.testInbox) router.testInbox = {} 189 | channelIds.forEach((channelId) => { 190 | router.onNotification(channelId, ({senderId, body}) => { 191 | if (!router.testInbox[channelId]) router.testInbox[channelId] = [] 192 | router.testInbox[channelId].push({ 193 | senderId, body: body.toString() 194 | }) 195 | router.testInbox[channelId].sort((a, b) => a.senderId.localeCompare(b.senderId)) 196 | }) 197 | }) 198 | } 199 | 200 | function timeout (ms) { 201 | return new Promise((resolve) => setTimeout(resolve, ms)) 202 | } 203 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | process.on('unhandledRejection', (reason) => { 4 | console.error(reason.stack) 5 | }) 6 | -------------------------------------------------------------------------------- /test/star-overlay-network.test.js: -------------------------------------------------------------------------------- 1 | require('./setup') 2 | const assert = require('assert') 3 | const deepEqual = require('deep-equal') 4 | const {startTestServer} = require('@atom/teletype-server') 5 | const setEqual = require('./helpers/set-equal') 6 | const condition = require('./helpers/condition') 7 | const {buildPeerPool, clearPeerPools} = require('./helpers/peer-pools') 8 | const buildStarNetwork = require('./helpers/build-star-network') 9 | const Errors = require('../lib/errors') 10 | 11 | suite('StarOverlayNetwork', () => { 12 | let server 13 | 14 | suiteSetup(async () => { 15 | server = await startTestServer() 16 | }) 17 | 18 | suiteTeardown(() => { 19 | return server.stop() 20 | }) 21 | 22 | setup(() => { 23 | return server.reset() 24 | }) 25 | 26 | teardown(() => { 27 | clearPeerPools() 28 | }) 29 | 30 | suite('membership', async () => { 31 | test('joining and leaving', async () => { 32 | const hubPool = await buildPeerPool('hub', server) 33 | const spoke1Pool = await buildPeerPool('spoke-1', server) 34 | const spoke2Pool = await buildPeerPool('spoke-2', server) 35 | const spoke3Pool = await buildPeerPool('spoke-3', server) 36 | 37 | const hub = buildStarNetwork('network', hubPool, {isHub: true}) 38 | assert(setEqual(hub.getMemberIds(), ['hub'])) 39 | 40 | const spoke1 = buildStarNetwork('network', spoke1Pool, {isHub: false}) 41 | assert(setEqual(spoke1.getMemberIds(), ['spoke-1'])) 42 | 43 | const spoke2 = buildStarNetwork('network', spoke2Pool, {isHub: false}) 44 | assert(setEqual(spoke2.getMemberIds(), ['spoke-2'])) 45 | 46 | const spoke3 = buildStarNetwork('network', spoke3Pool, {isHub: false}) 47 | assert(setEqual(spoke3.getMemberIds(), ['spoke-3'])) 48 | 49 | spoke1.connectTo('hub') 50 | await condition(() => ( 51 | setEqual(hub.getMemberIds(), ['hub', 'spoke-1']) && 52 | setEqual(spoke1.getMemberIds(), ['hub', 'spoke-1']) 53 | )) 54 | assert.deepEqual(hub.testJoinEvents, ['spoke-1']) 55 | assert.deepEqual(spoke1.testJoinEvents, []) 56 | assert.deepEqual(spoke2.testJoinEvents, []) 57 | assert.deepEqual(spoke3.testJoinEvents, []) 58 | 59 | spoke2.connectTo('hub') 60 | await condition(() => ( 61 | setEqual(hub.getMemberIds(), ['hub', 'spoke-1', 'spoke-2']) && 62 | setEqual(spoke1.getMemberIds(), ['hub', 'spoke-1', 'spoke-2']) && 63 | setEqual(spoke2.getMemberIds(), ['hub', 'spoke-1', 'spoke-2']) 64 | )) 65 | assert.deepEqual(hub.testJoinEvents, ['spoke-1', 'spoke-2']) 66 | assert.deepEqual(spoke1.testJoinEvents, ['spoke-2']) 67 | assert.deepEqual(spoke2.testJoinEvents, []) 68 | assert.deepEqual(spoke3.testJoinEvents, []) 69 | 70 | spoke3.connectTo('hub') 71 | await condition(() => ( 72 | setEqual(hub.getMemberIds(), ['hub', 'spoke-1', 'spoke-2', 'spoke-3']) && 73 | setEqual(spoke1.getMemberIds(), ['hub', 'spoke-1', 'spoke-2', 'spoke-3']) && 74 | setEqual(spoke2.getMemberIds(), ['hub', 'spoke-1', 'spoke-2', 'spoke-3']) && 75 | setEqual(spoke3.getMemberIds(), ['hub', 'spoke-1', 'spoke-2', 'spoke-3']) 76 | )) 77 | assert.deepEqual(hub.testJoinEvents, ['spoke-1', 'spoke-2', 'spoke-3']) 78 | assert.deepEqual(spoke1.testJoinEvents, ['spoke-2', 'spoke-3']) 79 | assert.deepEqual(spoke2.testJoinEvents, ['spoke-3']) 80 | assert.deepEqual(spoke3.testJoinEvents, []) 81 | 82 | spoke2.disconnect() 83 | await condition(() => ( 84 | setEqual(hub.getMemberIds(), ['hub', 'spoke-1', 'spoke-3']) && 85 | setEqual(spoke1.getMemberIds(), ['hub', 'spoke-1', 'spoke-3']) && 86 | setEqual(spoke2.getMemberIds(), ['spoke-2']) && 87 | setEqual(spoke3.getMemberIds(), ['hub', 'spoke-1', 'spoke-3']) 88 | )) 89 | assert.deepEqual(hub.testLeaveEvents, [{peerId: 'spoke-2', connectionLost: false}]) 90 | assert.deepEqual(spoke1.testLeaveEvents, [{peerId: 'spoke-2', connectionLost: false}]) 91 | assert.deepEqual(spoke2.testLeaveEvents, []) 92 | assert.deepEqual(spoke3.testLeaveEvents, [{peerId: 'spoke-2', connectionLost: false}]) 93 | 94 | hub.disconnect() 95 | await condition(() => ( 96 | setEqual(hub.getMemberIds(), ['hub']) && 97 | setEqual(spoke1.getMemberIds(), ['spoke-1']) && 98 | setEqual(spoke2.getMemberIds(), ['spoke-2']) && 99 | setEqual(spoke3.getMemberIds(), ['spoke-3']) 100 | )) 101 | assert.deepEqual(hub.testLeaveEvents, [{peerId: 'spoke-2', connectionLost: false}]) 102 | assert.deepEqual(spoke1.testLeaveEvents, [ 103 | {peerId: 'spoke-2', connectionLost: false}, 104 | {peerId: 'hub', connectionLost: false} 105 | ]) 106 | assert.deepEqual(spoke2.testLeaveEvents, []) 107 | assert.deepEqual(spoke3.testLeaveEvents, [ 108 | {peerId: 'spoke-2', connectionLost: false}, 109 | {peerId: 'hub', connectionLost: false} 110 | ]) 111 | }) 112 | 113 | test('losing connection to peer', async () => { 114 | const hubPool = await buildPeerPool('hub', server) 115 | const spoke1Pool = await buildPeerPool('spoke-1', server) 116 | const spoke2Pool = await buildPeerPool('spoke-2', server) 117 | const spoke3Pool = await buildPeerPool('spoke-3', server) 118 | 119 | const hub = buildStarNetwork('network', hubPool, {isHub: true}) 120 | const spoke1 = buildStarNetwork('network', spoke1Pool, {isHub: false}) 121 | const spoke2 = buildStarNetwork('network', spoke2Pool, {isHub: false}) 122 | const spoke3 = buildStarNetwork('network', spoke3Pool, {isHub: false}) 123 | await spoke1.connectTo('hub') 124 | await spoke2.connectTo('hub') 125 | await spoke3.connectTo('hub') 126 | 127 | spoke1Pool.disconnect() 128 | await condition(() => ( 129 | setEqual(hub.getMemberIds(), ['hub', 'spoke-2', 'spoke-3']) && 130 | setEqual(spoke1.getMemberIds(), ['spoke-1']) && 131 | setEqual(spoke2.getMemberIds(), ['hub', 'spoke-2', 'spoke-3']) && 132 | setEqual(spoke3.getMemberIds(), ['hub', 'spoke-2', 'spoke-3']) 133 | )) 134 | assert.deepEqual(hub.testLeaveEvents, [{peerId: 'spoke-1', connectionLost: true}]) 135 | assert.deepEqual(spoke1.testLeaveEvents, [{peerId: 'hub', connectionLost: true}]) 136 | assert.deepEqual(spoke2.testLeaveEvents, [{peerId: 'spoke-1', connectionLost: true}]) 137 | assert.deepEqual(spoke3.testLeaveEvents, [{peerId: 'spoke-1', connectionLost: true}]) 138 | 139 | // Ensure only one leave event is received when disconnecting both the network and the peer pool. 140 | spoke2.disconnect() 141 | spoke2Pool.disconnect() 142 | 143 | await condition(() => ( 144 | setEqual(hub.getMemberIds(), ['hub', 'spoke-3']) && 145 | setEqual(spoke1.getMemberIds(), ['spoke-1']) && 146 | setEqual(spoke2.getMemberIds(), ['spoke-2']) && 147 | setEqual(spoke3.getMemberIds(), ['hub', 'spoke-3']) 148 | )) 149 | assert.deepEqual(hub.testLeaveEvents, [ 150 | {peerId: 'spoke-1', connectionLost: true}, 151 | {peerId: 'spoke-2', connectionLost: false} 152 | ]) 153 | assert.deepEqual(spoke1.testLeaveEvents, [{peerId: 'hub', connectionLost: true}]) 154 | assert.deepEqual(spoke2.testLeaveEvents, [{peerId: 'spoke-1', connectionLost: true}]) 155 | assert.deepEqual(spoke3.testLeaveEvents, [ 156 | {peerId: 'spoke-1', connectionLost: true}, 157 | {peerId: 'spoke-2', connectionLost: false} 158 | ]) 159 | 160 | hubPool.disconnect() 161 | await condition(() => ( 162 | setEqual(hub.getMemberIds(), ['hub']) && 163 | setEqual(spoke1.getMemberIds(), ['spoke-1']) && 164 | setEqual(spoke2.getMemberIds(), ['spoke-2']) && 165 | setEqual(spoke3.getMemberIds(), ['spoke-3']) 166 | )) 167 | assert.deepEqual(hub.testLeaveEvents, [ 168 | {peerId: 'spoke-1', connectionLost: true}, 169 | {peerId: 'spoke-2', connectionLost: false}, 170 | {peerId: 'spoke-3', connectionLost: true} 171 | ]) 172 | assert.deepEqual(spoke1.testLeaveEvents, [{peerId: 'hub', connectionLost: true}]) 173 | assert.deepEqual(spoke2.testLeaveEvents, [{peerId: 'spoke-1', connectionLost: true}]) 174 | assert.deepEqual(spoke3.testLeaveEvents, [ 175 | {peerId: 'spoke-1', connectionLost: true}, 176 | {peerId: 'spoke-2', connectionLost: false}, 177 | {peerId: 'hub', connectionLost: true} 178 | ]) 179 | }) 180 | 181 | test('disconnecting after encountering an error while joining the network', async () => { 182 | const hubPool = await buildPeerPool('hub', server) 183 | const spoke1Pool = await buildPeerPool('spoke-1', server) 184 | const spoke2Pool = await buildPeerPool('spoke-2', server) 185 | 186 | const hub = buildStarNetwork('network', hubPool, {isHub: true}) 187 | const spoke1 = buildStarNetwork('network', spoke1Pool, {isHub: false}) 188 | const spoke2 = buildStarNetwork('network', spoke2Pool, {isHub: false}) 189 | 190 | // Prevent spoke-1 from sending connection messages. 191 | let originalSpoke1PoolSend = spoke1Pool.send 192 | const peerPoolError = new Error('Cannot send messages') 193 | spoke1Pool.send = () => { throw peerPoolError } 194 | 195 | let error 196 | try { 197 | await spoke1.connectTo('hub') 198 | } catch (e) { 199 | error = e 200 | } 201 | assert.equal(error, peerPoolError) 202 | 203 | // Re-allow spoke-1 to send messages, and simulate receiving a connection 204 | // from another spoke. This will ensure that the disconnection of spoke-1 205 | // is ignored. 206 | spoke1Pool.send = originalSpoke1PoolSend 207 | spoke1.disconnect() 208 | spoke2.connectTo('hub') 209 | 210 | await condition(() => ( 211 | setEqual(hub.getMemberIds(), ['hub', 'spoke-2']) && 212 | setEqual(spoke1.getMemberIds(), ['spoke-1']) && 213 | setEqual(spoke2.getMemberIds(), ['hub', 'spoke-2']) 214 | )) 215 | 216 | assert.deepEqual(hub.testJoinEvents, ['spoke-2']) 217 | assert.deepEqual(spoke1.testJoinEvents, []) 218 | assert.deepEqual(spoke2.testJoinEvents, []) 219 | 220 | assert.deepEqual(hub.testLeaveEvents, []) 221 | assert.deepEqual(spoke1.testLeaveEvents, []) 222 | assert.deepEqual(spoke2.testLeaveEvents, []) 223 | }) 224 | 225 | test('relaying peer identities to spokes', async () => { 226 | const hubIdentity = {login: 'hub'} 227 | const spoke1Identity = {login: 'spoke-1'} 228 | const spoke2Identity = {login: 'spoke-2'} 229 | 230 | server.identityProvider.setIdentitiesByToken({ 231 | 'hub-token': hubIdentity, 232 | 'spoke-1-token': spoke1Identity, 233 | 'spoke-2-token': spoke2Identity 234 | }) 235 | 236 | const hubPool = await buildPeerPool('hub', server) 237 | const spoke1Pool = await buildPeerPool('spoke-1', server) 238 | const spoke2Pool = await buildPeerPool('spoke-2', server) 239 | 240 | const hub = buildStarNetwork('network', hubPool, {isHub: true}) 241 | const spoke1 = buildStarNetwork('network', spoke1Pool, {isHub: false}) 242 | const spoke2 = buildStarNetwork('network', spoke2Pool, {isHub: false}) 243 | await spoke1.connectTo('hub') 244 | await spoke2.connectTo('hub') 245 | 246 | await condition(() => ( 247 | setEqual(hub.getMemberIds(), ['hub', 'spoke-1', 'spoke-2']) && 248 | setEqual(spoke1.getMemberIds(), ['hub', 'spoke-1', 'spoke-2']) && 249 | setEqual(spoke2.getMemberIds(), ['hub', 'spoke-1', 'spoke-2']) 250 | )) 251 | 252 | assert.equal(hub.getMemberIdentity('hub').login, hubIdentity.login) 253 | assert.equal(hub.getMemberIdentity('spoke-1').login, spoke1Identity.login) 254 | assert.equal(hub.getMemberIdentity('spoke-2').login, spoke2Identity.login) 255 | 256 | assert.equal(spoke1.getMemberIdentity('hub').login, hubIdentity.login) 257 | assert.equal(spoke1.getMemberIdentity('spoke-1').login, spoke1Identity.login) 258 | assert.equal(spoke1.getMemberIdentity('spoke-2').login, spoke2Identity.login) 259 | 260 | assert.equal(spoke2.getMemberIdentity('hub').login, hubIdentity.login) 261 | assert.equal(spoke2.getMemberIdentity('spoke-1').login, spoke1Identity.login) 262 | assert.equal(spoke2.getMemberIdentity('spoke-2').login, spoke2Identity.login) 263 | 264 | // Ensure peer identities can be retrieved on all spokes after disconnection. 265 | spoke1.disconnect() 266 | await condition(() => ( 267 | setEqual(hub.getMemberIds(), ['hub', 'spoke-2']) && 268 | setEqual(spoke1.getMemberIds(), ['spoke-1']) && 269 | setEqual(spoke2.getMemberIds(), ['hub', 'spoke-2']) 270 | )) 271 | 272 | assert.equal(hub.getMemberIdentity('hub').login, hubIdentity.login) 273 | assert.equal(hub.getMemberIdentity('spoke-1').login, spoke1Identity.login) 274 | assert.equal(hub.getMemberIdentity('spoke-2').login, spoke2Identity.login) 275 | 276 | assert.equal(spoke1.getMemberIdentity('hub').login, hubIdentity.login) 277 | assert.equal(spoke1.getMemberIdentity('spoke-1').login, spoke1Identity.login) 278 | assert.equal(spoke1.getMemberIdentity('spoke-2').login, spoke2Identity.login) 279 | 280 | assert.equal(spoke2.getMemberIdentity('hub').login, hubIdentity.login) 281 | assert.equal(spoke2.getMemberIdentity('spoke-1').login, spoke1Identity.login) 282 | assert.equal(spoke2.getMemberIdentity('spoke-2').login, spoke2Identity.login) 283 | }) 284 | }) 285 | 286 | suite('unicast', () => { 287 | test('sends messages to only one member of the network', async () => { 288 | const hubPool = await buildPeerPool('hub', server) 289 | const spoke1Pool = await buildPeerPool('spoke-1', server) 290 | const spoke2Pool = await buildPeerPool('spoke-2', server) 291 | 292 | const hub = buildStarNetwork('network-a', hubPool, {isHub: true}) 293 | const spoke1 = buildStarNetwork('network-a', spoke1Pool, {isHub: false}) 294 | const spoke2 = buildStarNetwork('network-a', spoke2Pool, {isHub: false}) 295 | await spoke1.connectTo('hub') 296 | await spoke2.connectTo('hub') 297 | 298 | spoke1.unicast('spoke-2', 'spoke-to-spoke') 299 | spoke2.unicast('hub', 'spoke-to-hub') 300 | hub.unicast('spoke-1', 'hub-to-spoke') 301 | 302 | await condition(() => deepEqual(hub.testInbox, [ 303 | {senderId: 'spoke-2', message: 'spoke-to-hub'} 304 | ])) 305 | await condition(() => deepEqual(spoke1.testInbox, [ 306 | {senderId: 'hub', message: 'hub-to-spoke'} 307 | ])) 308 | await condition(() => deepEqual(spoke2.testInbox, [ 309 | {senderId: 'spoke-1', message: 'spoke-to-spoke'} 310 | ])) 311 | }) 312 | 313 | test('sends messages only to peers that are part of the network', async () => { 314 | const hubPool = await buildPeerPool('hub', server) 315 | const spoke1Pool = await buildPeerPool('spoke-1', server) 316 | const spoke2Pool = await buildPeerPool('spoke-2', server) 317 | 318 | const hub = buildStarNetwork('network-a', hubPool, {isHub: true}) 319 | const spoke = buildStarNetwork('network-a', spoke1Pool, {isHub: false}) 320 | await spoke.connectTo('hub') 321 | await hubPool.connectTo('spoke-2') 322 | 323 | spoke.unicast('spoke-2', 'this should never arrive') 324 | hubPool.send('spoke-2', 'direct message') 325 | await condition(() => deepEqual(spoke2Pool.testInbox, [ 326 | {senderId: 'hub', message: 'direct message'} 327 | ])) 328 | }) 329 | }) 330 | 331 | suite('broadcast', () => { 332 | test('sends messages to all other members of the network', async () => { 333 | const peer1Pool = await buildPeerPool('peer-1', server) 334 | const peer2Pool = await buildPeerPool('peer-2', server) 335 | const peer3Pool = await buildPeerPool('peer-3', server) 336 | const peer4Pool = await buildPeerPool('peer-4', server) 337 | 338 | const hubA = buildStarNetwork('network-a', peer1Pool, {isHub: true}) 339 | const spokeA1 = buildStarNetwork('network-a', peer2Pool, {isHub: false}) 340 | const spokeA2 = buildStarNetwork('network-a', peer3Pool, {isHub: false}) 341 | await spokeA1.connectTo('peer-1') 342 | await spokeA2.connectTo('peer-1') 343 | 344 | const hubB = buildStarNetwork('network-b', peer1Pool, {isHub: true}) 345 | const spokeB1 = buildStarNetwork('network-b', peer2Pool, {isHub: false}) 346 | const spokeB2 = buildStarNetwork('network-b', peer3Pool, {isHub: false}) 347 | await spokeB1.connectTo('peer-1') 348 | await spokeB2.connectTo('peer-1') 349 | 350 | const hubC = buildStarNetwork('network-c', peer2Pool, {isHub: true}) 351 | const spokeC1 = buildStarNetwork('network-c', peer1Pool, {isHub: false}) 352 | const spokeC2 = buildStarNetwork('network-c', peer3Pool, {isHub: false}) 353 | await spokeC1.connectTo('peer-2') 354 | await spokeC2.connectTo('peer-2') 355 | 356 | hubA.broadcast('a1') 357 | spokeA1.broadcast('a2') 358 | spokeB1.broadcast('b') 359 | spokeC1.broadcast('c') 360 | 361 | await condition(() => deepEqual(hubA.testInbox, [ 362 | {senderId: 'peer-2', message: 'a2'} 363 | ])) 364 | await condition(() => deepEqual(spokeA1.testInbox, [ 365 | {senderId: 'peer-1', message: 'a1'} 366 | ])) 367 | await condition(() => deepEqual(spokeA2.testInbox, [ 368 | {senderId: 'peer-1', message: 'a1'}, 369 | {senderId: 'peer-2', message: 'a2'} 370 | ])) 371 | 372 | await condition(() => deepEqual(hubB.testInbox, [ 373 | {senderId: 'peer-2', message: 'b'} 374 | ])) 375 | await condition(() => deepEqual(spokeB2.testInbox, [ 376 | {senderId: 'peer-2', message: 'b'} 377 | ])) 378 | 379 | await condition(() => deepEqual(hubC.testInbox, [ 380 | {senderId: 'peer-1', message: 'c'} 381 | ])) 382 | await condition(() => deepEqual(spokeC2.testInbox, [ 383 | {senderId: 'peer-1', message: 'c'} 384 | ])) 385 | }) 386 | 387 | test('sends messages only to peers that are part of the network', async () => { 388 | const hubPool = await buildPeerPool('hub', server) 389 | const spoke1Pool = await buildPeerPool('spoke-1', server) 390 | const spoke2Pool = await buildPeerPool('spoke-2', server) 391 | const nonMemberPool = await buildPeerPool('non-member', server) 392 | 393 | const hub = buildStarNetwork('some-network-id', hubPool, {isHub: true}) 394 | const spoke1 = buildStarNetwork('some-network-id', spoke1Pool, {isHub: false}) 395 | const spoke2 = buildStarNetwork('some-network-id', spoke2Pool, {isHub: false}) 396 | await spoke1.connectTo('hub') 397 | await spoke2.connectTo('hub') 398 | await nonMemberPool.connectTo('hub') 399 | 400 | // Clear peer pool inboxes to delete initial handshake messages. 401 | hubPool.testInbox = [] 402 | spoke1Pool.testInbox = [] 403 | spoke2Pool.testInbox = [] 404 | nonMemberPool.testInbox = [] 405 | 406 | spoke1.broadcast('hello') 407 | await condition(() => deepEqual(hub.testInbox, [{ 408 | senderId: 'spoke-1', 409 | message: 'hello' 410 | }])) 411 | await condition(() => deepEqual(spoke2.testInbox, [{ 412 | senderId: 'spoke-1', 413 | message: 'hello' 414 | }])) 415 | 416 | // Ensure that spoke1 did not receive their own broadcast 417 | hubPool.send('spoke-1', 'direct message') 418 | await condition(() => deepEqual(spoke1Pool.testInbox, [ 419 | {senderId: 'hub', message: 'direct message'} 420 | ])) 421 | 422 | // Ensure that peer 4 did not receive the broadcast since they are 423 | // not a member of the network 424 | hubPool.send('non-member', 'direct message') 425 | await condition(() => deepEqual(nonMemberPool.testInbox, [ 426 | {senderId: 'hub', message: 'direct message'} 427 | ])) 428 | }) 429 | }) 430 | 431 | test('throws when connecting to a network exceeds the connection timeout', async () => { 432 | const hubPool = await buildPeerPool('hub', server) 433 | const spoke1Pool = await buildPeerPool('spoke-1', server) 434 | const hub = buildStarNetwork('network', hubPool, {isHub: true, connectionTimeout: 1000}) 435 | const spoke1 = buildStarNetwork('network', spoke1Pool, {isHub: false, connectionTimeout: 1}) 436 | 437 | let error 438 | try { 439 | await spoke1.connectTo('hub') 440 | } catch (e) { 441 | error = e 442 | } 443 | assert(error instanceof Errors.NetworkConnectionError) 444 | 445 | // The spoke that timed out may be able to connect to the hub eventually. 446 | // Here we simulate receiving a connection from another spoke, ensuring that 447 | // the spoke that timed out is not included in the members list. 448 | const spoke2Pool = await buildPeerPool('spoke-2', server) 449 | const spoke2 = buildStarNetwork('network', spoke2Pool, {isHub: false, connectionTimeout: 1000}) 450 | await spoke2.connectTo('hub') 451 | assert(setEqual(hub.getMemberIds(), ['hub', 'spoke-2'])) 452 | assert(setEqual(spoke1.getMemberIds(), ['spoke-1'])) 453 | assert(setEqual(spoke2.getMemberIds(), ['hub', 'spoke-2'])) 454 | }) 455 | }) 456 | -------------------------------------------------------------------------------- /test/teletype-client.test.js: -------------------------------------------------------------------------------- 1 | require('./setup') 2 | const assert = require('assert') 3 | const Errors = require('../lib/errors') 4 | const TeletypeClient = require('../lib/teletype-client') 5 | 6 | suite('TeletypeClient', () => { 7 | suite('initialize', () => { 8 | test('throws when the protocol version is out of date according to the server', async () => { 9 | const stubRestGateway = { 10 | get: (url) => { 11 | if (url === '/protocol-version') 12 | return {ok: true, body: {version: 99999}} 13 | } 14 | } 15 | const client = new TeletypeClient({ 16 | restGateway: stubRestGateway, 17 | pubSubGateway: {} 18 | }) 19 | 20 | let error 21 | try { 22 | await client.initialize() 23 | } catch (e) { 24 | error = e 25 | } 26 | assert(error instanceof Errors.ClientOutOfDateError) 27 | }) 28 | }) 29 | 30 | suite('signIn(oauthToken)', () => { 31 | test('throws when the server replies with an unexpected status code', async () => { 32 | const stubPubSubGateway = {} 33 | const stubRestGateway = { 34 | setOauthToken () {} 35 | } 36 | const client = new TeletypeClient({restGateway: stubRestGateway, pubSubGateway: stubPubSubGateway}) 37 | 38 | { 39 | let error 40 | try { 41 | stubRestGateway.get = function () { 42 | return {ok: false, status: 489, body: {message: 'some-error'}} 43 | } 44 | await client.signIn('token') 45 | } catch (e) { 46 | error = e 47 | } 48 | assert(error instanceof Errors.UnexpectedAuthenticationError) 49 | assert(error.message.includes('some-error')) 50 | } 51 | }) 52 | 53 | test('throws when contacting the server fails', async () => { 54 | const stubPubSubGateway = {} 55 | const stubRestGateway = { 56 | setOauthToken () {} 57 | } 58 | const client = new TeletypeClient({restGateway: stubRestGateway, pubSubGateway: stubPubSubGateway}) 59 | 60 | { 61 | let error 62 | try { 63 | stubRestGateway.get = function () { 64 | throw new Error('Failed to fetch') 65 | } 66 | await client.signIn('token') 67 | } catch (e) { 68 | error = e 69 | } 70 | assert(error instanceof Errors.UnexpectedAuthenticationError) 71 | } 72 | }) 73 | }) 74 | 75 | suite('createPortal', () => { 76 | test('throws if posting the portal to the server fails', async () => { 77 | const stubPubSubGateway = {} 78 | const stubRestGateway = {} 79 | const client = new TeletypeClient({restGateway: stubRestGateway, pubSubGateway: stubPubSubGateway}) 80 | 81 | { 82 | let error 83 | try { 84 | stubRestGateway.post = function () { 85 | throw new Error('Failed to fetch') 86 | } 87 | await client.createPortal() 88 | } catch (e) { 89 | error = e 90 | } 91 | assert(error instanceof Errors.PortalCreationError) 92 | } 93 | 94 | { 95 | let error 96 | try { 97 | stubRestGateway.post = function () { 98 | return Promise.resolve({ok: false, body: {}}) 99 | } 100 | await client.createPortal() 101 | } catch (e) { 102 | error = e 103 | } 104 | assert(error instanceof Errors.PortalCreationError) 105 | } 106 | }) 107 | }) 108 | 109 | suite('joinPortal', () => { 110 | test('throws if retrieving the portal from the server fails', async () => { 111 | const stubPubSubGateway = {} 112 | const stubRestGateway = {} 113 | const client = new TeletypeClient({restGateway: stubRestGateway, pubSubGateway: stubPubSubGateway}) 114 | 115 | { 116 | let error 117 | try { 118 | stubRestGateway.get = function () { 119 | throw new Error('Failed to fetch') 120 | } 121 | await client.joinPortal('1') 122 | } catch (e) { 123 | error = e 124 | } 125 | assert(error instanceof Errors.PortalJoinError) 126 | } 127 | 128 | { 129 | let error 130 | try { 131 | stubRestGateway.get = function () { 132 | return Promise.resolve({ok: false, body: {}}) 133 | } 134 | await client.joinPortal('1') 135 | } catch (e) { 136 | error = e 137 | } 138 | assert(error instanceof Errors.PortalNotFoundError) 139 | } 140 | }) 141 | }) 142 | 143 | suite('onConnectionError', () => { 144 | test('fires if the underlying PeerPool emits an error', async () => { 145 | const stubRestGateway = { 146 | setOauthToken () {}, 147 | get () { 148 | return Promise.resolve({ok: true, body: []}) 149 | } 150 | } 151 | const stubPubSubGateway = { 152 | subscribe () { 153 | return Promise.resolve({ 154 | dispose () {} 155 | }) 156 | } 157 | } 158 | const errorEvents = [] 159 | const client = new TeletypeClient({ 160 | pubSubGateway: stubPubSubGateway, 161 | restGateway: stubRestGateway 162 | }) 163 | client.onConnectionError((error) => errorEvents.push(error)) 164 | await client.initialize() 165 | await client.signIn('some-token') 166 | 167 | const errorEvent1 = new ErrorEvent('') 168 | client.peerPool.emitter.emit('error', errorEvent1) 169 | assert.deepEqual(errorEvents, [errorEvent1]) 170 | 171 | const errorEvent2 = new ErrorEvent('') 172 | client.peerPool.emitter.emit('error', errorEvent2) 173 | assert.deepEqual(errorEvents, [errorEvent1, errorEvent2]) 174 | }) 175 | }) 176 | }) 177 | --------------------------------------------------------------------------------