@styris-ame/y-engineio 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,554 @@
1
+ /**
2
+ * @module provider/engineio
3
+ */
4
+
5
+ /* eslint-env browser */
6
+ import * as Y from 'yjs' // eslint-disable-line
7
+ import * as bc from 'lib0/broadcastchannel'
8
+ import * as time from 'lib0/time'
9
+ import * as encoding from 'lib0/encoding'
10
+ import * as decoding from 'lib0/decoding'
11
+ import * as syncProtocol from 'y-protocols/sync'
12
+ import * as authProtocol from 'y-protocols/auth'
13
+ import * as awarenessProtocol from 'y-protocols/awareness'
14
+ import { ObservableV2 } from 'lib0/observable'
15
+ import * as math from 'lib0/math'
16
+ import * as url from 'lib0/url'
17
+ import * as env from 'lib0/environment'
18
+ import { Socket as Engine } from 'engine.io-client'
19
+
20
+ export const messageSync = 0
21
+ export const messageQueryAwareness = 3
22
+ export const messageAwareness = 1
23
+ export const messageAuth = 2
24
+
25
+ /**
26
+ * encoder, decoder, provider, emitSynced, messageType
27
+ * @type {Array<function(encoding.Encoder, decoding.Decoder, EngineIOProvider, boolean, number):void>}
28
+ */
29
+ const messageHandlers = []
30
+
31
+ messageHandlers[messageSync] = (
32
+ encoder,
33
+ decoder,
34
+ provider,
35
+ emitSynced,
36
+ _messageType
37
+ ) => {
38
+ encoding.writeVarUint(encoder, messageSync)
39
+ const syncMessageType = syncProtocol.readSyncMessage(
40
+ decoder,
41
+ encoder,
42
+ provider.doc,
43
+ provider
44
+ )
45
+ if (
46
+ emitSynced && syncMessageType === syncProtocol.messageYjsSyncStep2 &&
47
+ !provider.synced
48
+ ) {
49
+ provider.synced = true
50
+ }
51
+ }
52
+
53
+ messageHandlers[messageQueryAwareness] = (
54
+ encoder,
55
+ _decoder,
56
+ provider,
57
+ _emitSynced,
58
+ _messageType
59
+ ) => {
60
+ encoding.writeVarUint(encoder, messageAwareness)
61
+ encoding.writeVarUint8Array(
62
+ encoder,
63
+ awarenessProtocol.encodeAwarenessUpdate(
64
+ provider.awareness,
65
+ Array.from(provider.awareness.getStates().keys())
66
+ )
67
+ )
68
+ }
69
+
70
+ messageHandlers[messageAwareness] = (
71
+ _encoder,
72
+ decoder,
73
+ provider,
74
+ _emitSynced,
75
+ _messageType
76
+ ) => {
77
+ awarenessProtocol.applyAwarenessUpdate(
78
+ provider.awareness,
79
+ decoding.readVarUint8Array(decoder),
80
+ provider
81
+ )
82
+ }
83
+
84
+ messageHandlers[messageAuth] = (
85
+ _encoder,
86
+ decoder,
87
+ provider,
88
+ _emitSynced,
89
+ _messageType
90
+ ) => {
91
+ authProtocol.readAuthMessage(
92
+ decoder,
93
+ provider.doc,
94
+ (_ydoc, reason) => permissionDeniedHandler(provider, reason)
95
+ )
96
+ }
97
+
98
+ const messageReconnectTimeout = 30000
99
+
100
+ /**
101
+ * @param {EngineIOProvider} provider
102
+ * @param {string} reason
103
+ */
104
+ const permissionDeniedHandler = (provider, reason) =>
105
+ console.warn(`Permission denied to access ${provider.url}.\n${reason}`)
106
+
107
+ /**
108
+ * @param {ArrayBuffer | ArrayBufferView} data
109
+ * @return {Uint8Array}
110
+ */
111
+ const toUint8Array = (data) =>
112
+ data instanceof ArrayBuffer
113
+ ? new Uint8Array(data)
114
+ : new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
115
+
116
+ /**
117
+ * @param {EngineIOProvider} provider
118
+ * @param {Uint8Array} buf
119
+ * @param {boolean} emitSynced
120
+ * @return {encoding.Encoder}
121
+ */
122
+ const readMessage = (provider, buf, emitSynced) => {
123
+ const decoder = decoding.createDecoder(buf)
124
+ const encoder = encoding.createEncoder()
125
+ const messageType = decoding.readVarUint(decoder)
126
+ const messageHandler = provider.messageHandlers[messageType]
127
+ if (/** @type {any} */ (messageHandler)) {
128
+ messageHandler(encoder, decoder, provider, emitSynced, messageType)
129
+ }
130
+ return encoder
131
+ }
132
+
133
+ /**
134
+ * Outsource this function so that a new engine.io connection is created immediately.
135
+ * I suspect that the `close` event is not always fired if there are network issues.
136
+ *
137
+ * @param {EngineIOProvider} provider
138
+ * @param {Engine} engine
139
+ * @param {any} event
140
+ */
141
+ const closeEngineConnection = (provider, engine, event) => {
142
+ if (engine === provider.engine) {
143
+ provider.emit('connection-close', [event, provider])
144
+ provider.engine = null
145
+ const anyEngine = /** @type {any} */ (engine)
146
+ if (anyEngine._yUnsubscribe) {
147
+ anyEngine._yUnsubscribe()
148
+ }
149
+ engine.close()
150
+ provider.connecting = false
151
+ if (provider.connected) {
152
+ provider.connected = false
153
+ provider.synced = false
154
+ // update awareness (all users except local left)
155
+ awarenessProtocol.removeAwarenessStates(
156
+ provider.awareness,
157
+ Array.from(provider.awareness.getStates().keys()).filter((client) =>
158
+ client !== provider.doc.clientID
159
+ ),
160
+ provider
161
+ )
162
+ provider.emit('status', [{
163
+ status: 'disconnected'
164
+ }])
165
+ } else {
166
+ provider.unsuccessfulReconnects++
167
+ }
168
+ // Start with no reconnect timeout and increase timeout by
169
+ // using exponential backoff starting with 100ms
170
+ setTimeout(
171
+ setupEngine,
172
+ math.min(
173
+ math.pow(2, provider.unsuccessfulReconnects) * 100,
174
+ provider.maxBackoffTime
175
+ ),
176
+ provider
177
+ )
178
+ }
179
+ }
180
+
181
+ /**
182
+ * @param {EngineIOProvider} provider
183
+ */
184
+ const setupEngine = (provider) => {
185
+ if (provider.shouldConnect && provider.engine === null) {
186
+ const engine = new provider._Engine(provider.serverUrl, {
187
+ ...provider.engineOptions,
188
+ query: { ...provider.params, room: provider.roomname }
189
+ })
190
+ engine.binaryType = 'arraybuffer'
191
+ provider.engine = engine
192
+ provider.connecting = true
193
+ provider.connected = false
194
+ provider.synced = false
195
+
196
+ const onMessage = (/** @type {any} */ data) => {
197
+ if (provider.engine !== engine) return
198
+ if (typeof data === 'string') return
199
+ provider.lastMessageReceived = time.getUnixTime()
200
+ const encoder = readMessage(provider, toUint8Array(data), true)
201
+ if (encoding.length(encoder) > 1) {
202
+ engine.send(encoding.toUint8Array(encoder))
203
+ }
204
+ }
205
+ const onError = (/** @type {any} */ event) => {
206
+ if (provider.engine !== engine) return
207
+ provider.emit('connection-error', [event, provider])
208
+ }
209
+ const onPing = () => {
210
+ if (provider.engine !== engine) return
211
+ provider.lastMessageReceived = time.getUnixTime()
212
+ }
213
+ const onClose = (/** @type {any} */ reason, /** @type {any} */ description) => {
214
+ closeEngineConnection(provider, engine, { reason, description })
215
+ }
216
+ const onOpen = () => {
217
+ if (provider.engine !== engine) return
218
+ provider.lastMessageReceived = time.getUnixTime()
219
+ provider.connecting = false
220
+ provider.connected = true
221
+ provider.unsuccessfulReconnects = 0
222
+ provider.emit('status', [{
223
+ status: 'connected'
224
+ }])
225
+ // always send sync step 1 when connected
226
+ const encoder = encoding.createEncoder()
227
+ encoding.writeVarUint(encoder, messageSync)
228
+ syncProtocol.writeSyncStep1(encoder, provider.doc)
229
+ engine.send(encoding.toUint8Array(encoder))
230
+ // broadcast local awareness state
231
+ if (provider.awareness.getLocalState() !== null) {
232
+ const encoderAwarenessState = encoding.createEncoder()
233
+ encoding.writeVarUint(encoderAwarenessState, messageAwareness)
234
+ encoding.writeVarUint8Array(
235
+ encoderAwarenessState,
236
+ awarenessProtocol.encodeAwarenessUpdate(provider.awareness, [
237
+ provider.doc.clientID
238
+ ])
239
+ )
240
+ engine.send(encoding.toUint8Array(encoderAwarenessState))
241
+ }
242
+ }
243
+ engine.on('message', onMessage)
244
+ engine.on('error', onError)
245
+ engine.on('ping', onPing)
246
+ engine.on('close', onClose)
247
+ engine.on('open', onOpen)
248
+ const anyEngine = /** @type {any} */ (engine)
249
+ anyEngine._yUnsubscribe = () => {
250
+ engine.off('message', onMessage)
251
+ engine.off('error', onError)
252
+ engine.off('ping', onPing)
253
+ engine.off('close', onClose)
254
+ engine.off('open', onOpen)
255
+ }
256
+ provider.emit('status', [{
257
+ status: 'connecting'
258
+ }])
259
+ }
260
+ }
261
+
262
+ /**
263
+ * @param {EngineIOProvider} provider
264
+ * @param {Uint8Array} buf
265
+ */
266
+ const broadcastMessage = (provider, buf) => {
267
+ const engine = provider.engine
268
+ if (provider.connected && engine && engine.readyState === 'open') {
269
+ engine.send(buf)
270
+ }
271
+ if (provider.bcconnected) {
272
+ bc.publish(provider.bcChannel, buf, provider)
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Engine.IO Provider for Yjs. Creates an engine.io connection to sync the shared
278
+ * document. Unlike a raw WebSocket, engine.io connects to a single endpoint and
279
+ * has no document name in the URL, so the room name is sent as the `room`
280
+ * handshake query parameter, letting the server accept or reject the connection
281
+ * before any sync frames are exchanged.
282
+ *
283
+ * @example
284
+ * import * as Y from 'yjs'
285
+ * import { EngineIOProvider } from '@styris-ame/y-engineio'
286
+ * const doc = new Y.Doc()
287
+ * const provider = new EngineIOProvider('http://localhost:1234', 'my-document-name', doc)
288
+ *
289
+ * @extends {ObservableV2<{ 'connection-close': (event: any, provider: EngineIOProvider) => any, 'status': (event: { status: 'connected' | 'disconnected' | 'connecting' }) => any, 'connection-error': (event: any, provider: EngineIOProvider) => any, 'sync': (state: boolean) => any, 'synced': (state: boolean) => any }>}
290
+ */
291
+ export class EngineIOProvider extends ObservableV2 {
292
+ /**
293
+ * @param {string} serverUrl engine.io server origin, e.g. 'http://localhost:1234'
294
+ * @param {string} roomname
295
+ * @param {Y.Doc} doc
296
+ * @param {object} opts
297
+ * @param {boolean} [opts.connect]
298
+ * @param {awarenessProtocol.Awareness} [opts.awareness]
299
+ * @param {Object<string,string>} [opts.params] url query params, passed to engine.io as `query`
300
+ * @param {object} [opts.engineOptions] options forwarded to the engine.io-client `Socket` (e.g. `path`, `transports`, `withCredentials`, `extraHeaders`)
301
+ * @param {typeof Engine} [opts.EngineClass] override the engine.io `Socket` constructor (e.g. for testing)
302
+ * @param {number} [opts.resyncInterval] Request server state every `resyncInterval` milliseconds
303
+ * @param {number} [opts.maxBackoffTime] Maximum amount of time to wait before trying to reconnect (we try to reconnect using exponential backoff)
304
+ * @param {boolean} [opts.disableBc] Disable cross-tab BroadcastChannel communication
305
+ */
306
+ constructor (serverUrl, roomname, doc, {
307
+ connect = true,
308
+ awareness = new awarenessProtocol.Awareness(doc),
309
+ params = {},
310
+ engineOptions = {},
311
+ EngineClass = Engine,
312
+ resyncInterval = -1,
313
+ maxBackoffTime = 2500,
314
+ disableBc = false
315
+ } = {}) {
316
+ super()
317
+ // ensure that serverUrl does not end with /
318
+ while (serverUrl[serverUrl.length - 1] === '/') {
319
+ serverUrl = serverUrl.slice(0, serverUrl.length - 1)
320
+ }
321
+ this.serverUrl = serverUrl
322
+ this.bcChannel = serverUrl + '/' + roomname
323
+ this.maxBackoffTime = maxBackoffTime
324
+ /**
325
+ * The specified url parameters. This can be safely updated. The changed parameters will be used
326
+ * when a new connection is established.
327
+ * @type {Object<string,string>}
328
+ */
329
+ this.params = params
330
+ /**
331
+ * Options forwarded verbatim to the engine.io-client `Socket`.
332
+ * @type {object}
333
+ */
334
+ this.engineOptions = engineOptions
335
+ this.roomname = roomname
336
+ this.doc = doc
337
+ this._Engine = EngineClass
338
+ this.awareness = awareness
339
+ this.connected = false
340
+ this.connecting = false
341
+ this.bcconnected = false
342
+ this.disableBc = disableBc
343
+ this.unsuccessfulReconnects = 0
344
+ this.messageHandlers = messageHandlers.slice()
345
+ /**
346
+ * @type {boolean}
347
+ */
348
+ this._synced = false
349
+ /**
350
+ * The engine.io-client `Socket`, or null when disconnected.
351
+ * @type {Engine?}
352
+ */
353
+ this.engine = null
354
+ this.lastMessageReceived = 0
355
+ /**
356
+ * Whether to connect to other peers or not
357
+ * @type {boolean}
358
+ */
359
+ this.shouldConnect = connect
360
+
361
+ /**
362
+ * @type {number}
363
+ */
364
+ this._resyncInterval = 0
365
+ if (resyncInterval > 0) {
366
+ this._resyncInterval = /** @type {any} */ (setInterval(() => {
367
+ if (this.engine && this.engine.readyState === 'open') {
368
+ // resend sync step 1
369
+ const encoder = encoding.createEncoder()
370
+ encoding.writeVarUint(encoder, messageSync)
371
+ syncProtocol.writeSyncStep1(encoder, doc)
372
+ this.engine.send(encoding.toUint8Array(encoder))
373
+ }
374
+ }, resyncInterval))
375
+ }
376
+
377
+ /**
378
+ * @param {ArrayBuffer} data
379
+ * @param {any} origin
380
+ */
381
+ this._bcSubscriber = (data, origin) => {
382
+ if (origin !== this) {
383
+ const encoder = readMessage(this, new Uint8Array(data), false)
384
+ if (encoding.length(encoder) > 1) {
385
+ bc.publish(this.bcChannel, encoding.toUint8Array(encoder), this)
386
+ }
387
+ }
388
+ }
389
+ /**
390
+ * Listens to Yjs updates and sends them to remote peers (engine.io and broadcastchannel)
391
+ * @param {Uint8Array} update
392
+ * @param {any} origin
393
+ */
394
+ this._updateHandler = (update, origin) => {
395
+ if (origin !== this) {
396
+ const encoder = encoding.createEncoder()
397
+ encoding.writeVarUint(encoder, messageSync)
398
+ syncProtocol.writeUpdate(encoder, update)
399
+ broadcastMessage(this, encoding.toUint8Array(encoder))
400
+ }
401
+ }
402
+ this.doc.on('update', this._updateHandler)
403
+ /**
404
+ * @param {any} changed
405
+ * @param {any} _origin
406
+ */
407
+ this._awarenessUpdateHandler = ({ added, updated, removed }, _origin) => {
408
+ const changedClients = added.concat(updated).concat(removed)
409
+ const encoder = encoding.createEncoder()
410
+ encoding.writeVarUint(encoder, messageAwareness)
411
+ encoding.writeVarUint8Array(
412
+ encoder,
413
+ awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients)
414
+ )
415
+ broadcastMessage(this, encoding.toUint8Array(encoder))
416
+ }
417
+ this._exitHandler = () => {
418
+ awarenessProtocol.removeAwarenessStates(
419
+ this.awareness,
420
+ [doc.clientID],
421
+ 'app closed'
422
+ )
423
+ }
424
+ if (env.isNode && typeof process !== 'undefined') {
425
+ process.on('exit', this._exitHandler)
426
+ }
427
+ awareness.on('update', this._awarenessUpdateHandler)
428
+ this._checkInterval = /** @type {any} */ (setInterval(() => {
429
+ if (
430
+ this.connected &&
431
+ messageReconnectTimeout <
432
+ time.getUnixTime() - this.lastMessageReceived
433
+ ) {
434
+ // no message received in a long time - not even your own awareness
435
+ // updates (which are updated every 15 seconds)
436
+ closeEngineConnection(this, /** @type {Engine} */ (this.engine), null)
437
+ }
438
+ }, messageReconnectTimeout / 10))
439
+ if (connect) {
440
+ this.connect()
441
+ }
442
+ }
443
+
444
+ get url () {
445
+ const encodedParams = url.encodeQueryParams({ ...this.params, room: this.roomname })
446
+ return this.serverUrl + (encodedParams.length === 0 ? '' : '?' + encodedParams)
447
+ }
448
+
449
+ /**
450
+ * @type {boolean}
451
+ */
452
+ get synced () {
453
+ return this._synced
454
+ }
455
+
456
+ set synced (state) {
457
+ if (this._synced !== state) {
458
+ this._synced = state
459
+ // @ts-ignore
460
+ this.emit('synced', [state])
461
+ this.emit('sync', [state])
462
+ }
463
+ }
464
+
465
+ destroy () {
466
+ if (this._resyncInterval !== 0) {
467
+ clearInterval(this._resyncInterval)
468
+ }
469
+ clearInterval(this._checkInterval)
470
+ this.disconnect()
471
+ if (env.isNode && typeof process !== 'undefined') {
472
+ process.off('exit', this._exitHandler)
473
+ }
474
+ this.awareness.off('update', this._awarenessUpdateHandler)
475
+ this.doc.off('update', this._updateHandler)
476
+ super.destroy()
477
+ }
478
+
479
+ connectBc () {
480
+ if (this.disableBc) {
481
+ return
482
+ }
483
+ if (!this.bcconnected) {
484
+ bc.subscribe(this.bcChannel, this._bcSubscriber)
485
+ this.bcconnected = true
486
+ }
487
+ // send sync step1 to bc
488
+ // write sync step 1
489
+ const encoderSync = encoding.createEncoder()
490
+ encoding.writeVarUint(encoderSync, messageSync)
491
+ syncProtocol.writeSyncStep1(encoderSync, this.doc)
492
+ bc.publish(this.bcChannel, encoding.toUint8Array(encoderSync), this)
493
+ // broadcast local state
494
+ const encoderState = encoding.createEncoder()
495
+ encoding.writeVarUint(encoderState, messageSync)
496
+ syncProtocol.writeSyncStep2(encoderState, this.doc)
497
+ bc.publish(this.bcChannel, encoding.toUint8Array(encoderState), this)
498
+ // write queryAwareness
499
+ const encoderAwarenessQuery = encoding.createEncoder()
500
+ encoding.writeVarUint(encoderAwarenessQuery, messageQueryAwareness)
501
+ bc.publish(
502
+ this.bcChannel,
503
+ encoding.toUint8Array(encoderAwarenessQuery),
504
+ this
505
+ )
506
+ // broadcast local awareness state
507
+ const encoderAwarenessState = encoding.createEncoder()
508
+ encoding.writeVarUint(encoderAwarenessState, messageAwareness)
509
+ encoding.writeVarUint8Array(
510
+ encoderAwarenessState,
511
+ awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
512
+ this.doc.clientID
513
+ ])
514
+ )
515
+ bc.publish(
516
+ this.bcChannel,
517
+ encoding.toUint8Array(encoderAwarenessState),
518
+ this
519
+ )
520
+ }
521
+
522
+ disconnectBc () {
523
+ // broadcast message with local awareness state set to null (indicating disconnect)
524
+ const encoder = encoding.createEncoder()
525
+ encoding.writeVarUint(encoder, messageAwareness)
526
+ encoding.writeVarUint8Array(
527
+ encoder,
528
+ awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
529
+ this.doc.clientID
530
+ ], new Map())
531
+ )
532
+ broadcastMessage(this, encoding.toUint8Array(encoder))
533
+ if (this.bcconnected) {
534
+ bc.unsubscribe(this.bcChannel, this._bcSubscriber)
535
+ this.bcconnected = false
536
+ }
537
+ }
538
+
539
+ disconnect () {
540
+ this.shouldConnect = false
541
+ this.disconnectBc()
542
+ if (this.engine !== null) {
543
+ closeEngineConnection(this, this.engine, null)
544
+ }
545
+ }
546
+
547
+ connect () {
548
+ this.shouldConnect = true
549
+ if (!this.connected && this.engine === null) {
550
+ setupEngine(this)
551
+ this.connectBc()
552
+ }
553
+ }
554
+ }