@hocuspocus/provider 3.0.8-rc.0 → 3.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.
@@ -1,508 +1,496 @@
1
- import {
2
- WsReadyStates,
3
- } from '@hocuspocus/common'
4
- import { retry } from '@lifeomic/attempt'
5
- import * as time from 'lib0/time'
6
- import * as url from 'lib0/url'
7
- import type { MessageEvent , Event } from 'ws'
8
- import EventEmitter from './EventEmitter.ts'
9
- import type { HocuspocusProvider } from './HocuspocusProvider.ts'
1
+ import { WsReadyStates } from "@hocuspocus/common";
2
+ import { retry } from "@lifeomic/attempt";
3
+ import * as time from "lib0/time";
4
+ import type { Event, MessageEvent } from "ws";
5
+ import EventEmitter from "./EventEmitter.ts";
6
+ import type { HocuspocusProvider } from "./HocuspocusProvider.ts";
7
+ import { IncomingMessage } from "./IncomingMessage.ts";
8
+ import { CloseMessage } from "./OutgoingMessages/CloseMessage.ts";
10
9
  import type {
11
- onAwarenessChangeParameters, onAwarenessUpdateParameters,
12
- onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatusParameters} from './types.ts'
13
- import {
14
- WebSocketStatus,
15
- } from './types.ts'
16
- import { IncomingMessage } from './IncomingMessage.ts'
17
- import { CloseMessage } from "./OutgoingMessages/CloseMessage.ts"
10
+ onAwarenessChangeParameters,
11
+ onAwarenessUpdateParameters,
12
+ onCloseParameters,
13
+ onDisconnectParameters,
14
+ onMessageParameters,
15
+ onOpenParameters,
16
+ onOutgoingMessageParameters,
17
+ onStatusParameters,
18
+ } from "./types.ts";
19
+ import { WebSocketStatus } from "./types.ts";
18
20
 
19
21
  export type HocusPocusWebSocket = WebSocket & { identifier: string };
20
22
 
21
- export type HocuspocusProviderWebsocketConfiguration =
22
- Required<Pick<CompleteHocuspocusProviderWebsocketConfiguration, 'url'>>
23
- & Partial<CompleteHocuspocusProviderWebsocketConfiguration>
23
+ export type HocuspocusProviderWebsocketConfiguration = Required<
24
+ Pick<CompleteHocuspocusProviderWebsocketConfiguration, "url">
25
+ > &
26
+ Partial<CompleteHocuspocusProviderWebsocketConfiguration>;
24
27
 
25
28
  export interface CompleteHocuspocusProviderWebsocketConfiguration {
26
- /**
27
- * URL of your @hocuspocus/server instance
28
- */
29
- url: string,
30
-
31
- /**
32
- * Pass `false` to start the connection manually.
33
- */
34
- connect: boolean,
35
-
36
- /**
37
- * URL parameters that should be added.
38
- */
39
- parameters: { [key: string]: any },
40
- /**
41
- * An optional WebSocket polyfill, for example for Node.js
42
- */
43
- WebSocketPolyfill: any,
44
-
45
- /**
46
- * Disconnect when no message is received for the defined amount of milliseconds.
47
- */
48
- messageReconnectTimeout: number,
49
- /**
50
- * The delay between each attempt in milliseconds. You can provide a factor to have the delay grow exponentially.
51
- */
52
- delay: number,
53
- /**
54
- * The initialDelay is the amount of time to wait before making the first attempt. This option should typically be 0 since you typically want the first attempt to happen immediately.
55
- */
56
- initialDelay: number,
57
- /**
58
- * The factor option is used to grow the delay exponentially.
59
- */
60
- factor: number,
61
- /**
62
- * The maximum number of attempts or 0 if there is no limit on number of attempts.
63
- */
64
- maxAttempts: number,
65
- /**
66
- * minDelay is used to set a lower bound of delay when jitter is enabled. This property has no effect if jitter is disabled.
67
- */
68
- minDelay: number,
69
- /**
70
- * The maxDelay option is used to set an upper bound for the delay when factor is enabled. A value of 0 can be provided if there should be no upper bound when calculating delay.
71
- */
72
- maxDelay: number,
73
- /**
74
- * If jitter is true then the calculated delay will be a random integer value between minDelay and the calculated delay for the current iteration.
75
- */
76
- jitter: boolean,
77
- /**
78
- * A timeout in milliseconds. If timeout is non-zero then a timer is set using setTimeout. If the timeout is triggered then future attempts will be aborted.
79
- */
80
- timeout: number,
81
- onOpen: (data: onOpenParameters) => void,
82
- onConnect: () => void,
83
- onMessage: (data: onMessageParameters) => void,
84
- onOutgoingMessage: (data: onOutgoingMessageParameters) => void,
85
- onStatus: (data: onStatusParameters) => void,
86
- onDisconnect: (data: onDisconnectParameters) => void,
87
- onClose: (data: onCloseParameters) => void,
88
- onDestroy: () => void,
89
- onAwarenessUpdate: (data: onAwarenessUpdateParameters) => void,
90
- onAwarenessChange: (data: onAwarenessChangeParameters) => void,
91
-
92
- /**
93
- * Map of attached providers keyed by documentName.
94
- */
95
- providerMap: Map<string, HocuspocusProvider>,
29
+ /**
30
+ * URL of your @hocuspocus/server instance
31
+ */
32
+ url: string;
33
+
34
+ /**
35
+ * An optional WebSocket polyfill, for example for Node.js
36
+ */
37
+ WebSocketPolyfill: any;
38
+
39
+ /**
40
+ * Disconnect when no message is received for the defined amount of milliseconds.
41
+ */
42
+ messageReconnectTimeout: number;
43
+ /**
44
+ * The delay between each attempt in milliseconds. You can provide a factor to have the delay grow exponentially.
45
+ */
46
+ delay: number;
47
+ /**
48
+ * The initialDelay is the amount of time to wait before making the first attempt. This option should typically be 0 since you typically want the first attempt to happen immediately.
49
+ */
50
+ initialDelay: number;
51
+ /**
52
+ * The factor option is used to grow the delay exponentially.
53
+ */
54
+ factor: number;
55
+ /**
56
+ * The maximum number of attempts or 0 if there is no limit on number of attempts.
57
+ */
58
+ maxAttempts: number;
59
+ /**
60
+ * minDelay is used to set a lower bound of delay when jitter is enabled. This property has no effect if jitter is disabled.
61
+ */
62
+ minDelay: number;
63
+ /**
64
+ * The maxDelay option is used to set an upper bound for the delay when factor is enabled. A value of 0 can be provided if there should be no upper bound when calculating delay.
65
+ */
66
+ maxDelay: number;
67
+ /**
68
+ * If jitter is true then the calculated delay will be a random integer value between minDelay and the calculated delay for the current iteration.
69
+ */
70
+ jitter: boolean;
71
+ /**
72
+ * A timeout in milliseconds. If timeout is non-zero then a timer is set using setTimeout. If the timeout is triggered then future attempts will be aborted.
73
+ */
74
+ timeout: number;
75
+ onOpen: (data: onOpenParameters) => void;
76
+ onConnect: () => void;
77
+ onMessage: (data: onMessageParameters) => void;
78
+ onOutgoingMessage: (data: onOutgoingMessageParameters) => void;
79
+ onStatus: (data: onStatusParameters) => void;
80
+ onDisconnect: (data: onDisconnectParameters) => void;
81
+ onClose: (data: onCloseParameters) => void;
82
+ onDestroy: () => void;
83
+ onAwarenessUpdate: (data: onAwarenessUpdateParameters) => void;
84
+ onAwarenessChange: (data: onAwarenessChangeParameters) => void;
85
+
86
+ /**
87
+ * Map of attached providers keyed by documentName.
88
+ */
89
+ providerMap: Map<string, HocuspocusProvider>;
96
90
  }
97
91
 
98
92
  export class HocuspocusProviderWebsocket extends EventEmitter {
99
- private messageQueue: any[] = []
100
-
101
- public configuration: CompleteHocuspocusProviderWebsocketConfiguration = {
102
- url: '',
103
- // @ts-ignore
104
- document: undefined,
105
- WebSocketPolyfill: undefined,
106
- parameters: {},
107
- connect: true,
108
- broadcast: true,
109
- forceSyncInterval: false,
110
- // TODO: this should depend on awareness.outdatedTime
111
- messageReconnectTimeout: 30000,
112
- // 1 second
113
- delay: 1000,
114
- // instant
115
- initialDelay: 0,
116
- // double the delay each time
117
- factor: 2,
118
- // unlimited retries
119
- maxAttempts: 0,
120
- // wait at least 1 second
121
- minDelay: 1000,
122
- // at least every 30 seconds
123
- maxDelay: 30000,
124
- // randomize
125
- jitter: true,
126
- // retry forever
127
- timeout: 0,
128
- onOpen: () => null,
129
- onConnect: () => null,
130
- onMessage: () => null,
131
- onOutgoingMessage: () => null,
132
- onStatus: () => null,
133
- onDisconnect: () => null,
134
- onClose: () => null,
135
- onDestroy: () => null,
136
- onAwarenessUpdate: () => null,
137
- onAwarenessChange: () => null,
138
- providerMap: new Map(),
139
- }
140
-
141
- webSocket: HocusPocusWebSocket | null = null
142
-
143
- webSocketHandlers: { [key: string]: any } = {}
144
-
145
- shouldConnect = true
146
-
147
- status = WebSocketStatus.Disconnected
148
-
149
- lastMessageReceived = 0
150
-
151
- identifier = 0
152
-
153
- intervals: any = {
154
- connectionChecker: null,
155
- }
156
-
157
- connectionAttempt: {
158
- resolve: (value?: any) => void;
159
- reject: (reason?: any) => void;
160
- } | null = null
161
-
162
- constructor(configuration: HocuspocusProviderWebsocketConfiguration) {
163
- super()
164
- this.setConfiguration(configuration)
165
-
166
- this.configuration.WebSocketPolyfill = configuration.WebSocketPolyfill
167
- ? configuration.WebSocketPolyfill
168
- : WebSocket
169
-
170
- this.on('open', this.configuration.onOpen)
171
- this.on('open', this.onOpen.bind(this))
172
- this.on('connect', this.configuration.onConnect)
173
- this.on('message', this.configuration.onMessage)
174
- this.on('outgoingMessage', this.configuration.onOutgoingMessage)
175
- this.on('status', this.configuration.onStatus)
176
- this.on('disconnect', this.configuration.onDisconnect)
177
- this.on('close', this.configuration.onClose)
178
- this.on('destroy', this.configuration.onDestroy)
179
- this.on('awarenessUpdate', this.configuration.onAwarenessUpdate)
180
- this.on('awarenessChange', this.configuration.onAwarenessChange)
181
-
182
- this.on('close', this.onClose.bind(this))
183
- this.on('message', this.onMessage.bind(this))
184
-
185
- this.intervals.connectionChecker = setInterval(
186
- this.checkConnection.bind(this),
187
- this.configuration.messageReconnectTimeout / 10,
188
- )
189
-
190
- if (typeof configuration.connect !== 'undefined') {
191
- this.shouldConnect = configuration.connect
192
- }
193
-
194
- if (!this.shouldConnect) {
195
- return
196
- }
197
-
198
- this.connect()
199
- }
200
-
201
- receivedOnOpenPayload?: Event | undefined = undefined
202
-
203
-
204
- async onOpen(event: Event) {
205
- this.cancelWebsocketRetry = undefined
206
- this.receivedOnOpenPayload = event
207
- }
208
-
209
- attach(provider: HocuspocusProvider) {
210
- this.configuration.providerMap.set(provider.configuration.name, provider)
211
-
212
- if (this.status === WebSocketStatus.Disconnected && this.shouldConnect) {
213
- this.connect()
214
- }
215
-
216
- if (this.receivedOnOpenPayload && this.status === WebSocketStatus.Connected) {
217
- provider.onOpen(this.receivedOnOpenPayload)
218
- }
219
- }
220
-
221
- detach(provider: HocuspocusProvider) {
222
- if( this.configuration.providerMap.has(provider.configuration.name )) {
223
- provider.send(CloseMessage, { documentName: provider.configuration.name })
224
- this.configuration.providerMap.delete(provider.configuration.name)
225
- }
226
- }
227
-
228
- public setConfiguration(
229
- configuration: Partial<HocuspocusProviderWebsocketConfiguration> = {},
230
- ): void {
231
- this.configuration = { ...this.configuration, ...configuration }
232
- }
233
-
234
- cancelWebsocketRetry?: () => void
235
-
236
- async connect() {
237
- if (this.status === WebSocketStatus.Connected) {
238
- return
239
- }
240
-
241
- // Always cancel any previously initiated connection retryer instances
242
- if (this.cancelWebsocketRetry) {
243
- this.cancelWebsocketRetry()
244
- this.cancelWebsocketRetry = undefined
245
- }
246
-
247
- this.receivedOnOpenPayload = undefined
248
- this.shouldConnect = true
249
-
250
- const abortableRetry = () => {
251
- let cancelAttempt = false
252
-
253
- const retryPromise = retry(this.createWebSocketConnection.bind(this), {
254
- delay: this.configuration.delay,
255
- initialDelay: this.configuration.initialDelay,
256
- factor: this.configuration.factor,
257
- maxAttempts: this.configuration.maxAttempts,
258
- minDelay: this.configuration.minDelay,
259
- maxDelay: this.configuration.maxDelay,
260
- jitter: this.configuration.jitter,
261
- timeout: this.configuration.timeout,
262
- beforeAttempt: context => {
263
- if (!this.shouldConnect || cancelAttempt) {
264
- context.abort()
265
- }
266
- },
267
- }).catch((error: any) => {
268
- // If we aborted the connection attempt then don’t throw an error
269
- // ref: https://github.com/lifeomic/attempt/blob/master/src/index.ts#L136
270
- if (error && error.code !== 'ATTEMPT_ABORTED') {
271
- throw error
272
- }
273
- })
274
-
275
- return {
276
- retryPromise,
277
- cancelFunc: () => {
278
- cancelAttempt = true
279
- },
280
- }
281
- }
282
-
283
- const { retryPromise, cancelFunc } = abortableRetry()
284
- this.cancelWebsocketRetry = cancelFunc
285
-
286
- return retryPromise
287
- }
288
-
289
- // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
290
- attachWebSocketListeners(ws: HocusPocusWebSocket, reject: Function) {
291
- const { identifier } = ws
292
- const onMessageHandler = (payload: any) => this.emit('message', payload)
293
- const onCloseHandler = (payload: any) => this.emit('close', { event: payload })
294
- const onOpenHandler = (payload: any) => this.emit('open', payload)
295
- const onErrorHandler = (err: any) => {
296
- reject(err)
297
- }
298
-
299
- this.webSocketHandlers[identifier] = {
300
- message: onMessageHandler,
301
- close: onCloseHandler,
302
- open: onOpenHandler,
303
- error: onErrorHandler,
304
- }
305
-
306
- const handlers = this.webSocketHandlers[ws.identifier]
307
-
308
- Object.keys(handlers).forEach(name => {
309
- ws.addEventListener(name, handlers[name])
310
- })
311
- }
312
-
313
- cleanupWebSocket() {
314
- if (!this.webSocket) {
315
- return
316
- }
317
- const { identifier } = this.webSocket
318
- const handlers = this.webSocketHandlers[identifier]
319
-
320
- Object.keys(handlers).forEach(name => {
321
- this.webSocket?.removeEventListener(name, handlers[name])
322
- delete this.webSocketHandlers[identifier]
323
- })
324
- this.webSocket.close()
325
- this.webSocket = null
326
- }
327
-
328
- createWebSocketConnection() {
329
- return new Promise((resolve, reject) => {
330
- if (this.webSocket) {
331
- this.messageQueue = []
332
- this.cleanupWebSocket()
333
- }
334
- this.lastMessageReceived = 0
335
- this.identifier += 1
336
-
337
- // Init the WebSocket connection
338
- const ws = new this.configuration.WebSocketPolyfill(this.url)
339
- ws.binaryType = 'arraybuffer'
340
- ws.identifier = this.identifier
341
-
342
- this.attachWebSocketListeners(ws, reject)
343
-
344
- this.webSocket = ws
345
-
346
- // Reset the status
347
- this.status = WebSocketStatus.Connecting
348
- this.emit('status', { status: WebSocketStatus.Connecting })
349
-
350
- // Store resolve/reject for later use
351
- this.connectionAttempt = {
352
- resolve,
353
- reject,
354
- }
355
- })
356
- }
357
-
358
- onMessage(event: MessageEvent) {
359
- this.resolveConnectionAttempt()
360
-
361
- this.lastMessageReceived = time.getUnixTime()
362
-
363
- const message = new IncomingMessage(event.data)
364
- const documentName = message.peekVarString()
365
-
366
- this.configuration.providerMap.get(documentName)?.onMessage(event)
367
- }
368
-
369
- resolveConnectionAttempt() {
370
- if (this.connectionAttempt) {
371
- this.connectionAttempt.resolve()
372
- this.connectionAttempt = null
373
-
374
- this.status = WebSocketStatus.Connected
375
- this.emit('status', { status: WebSocketStatus.Connected })
376
- this.emit('connect')
377
- this.messageQueue.forEach(message => this.send(message))
378
- this.messageQueue = []
379
- }
380
- }
381
-
382
- stopConnectionAttempt() {
383
- this.connectionAttempt = null
384
- }
385
-
386
- rejectConnectionAttempt() {
387
- this.connectionAttempt?.reject()
388
- this.connectionAttempt = null
389
- }
390
-
391
- closeTries = 0
392
-
393
- checkConnection() {
394
- // Don’t check the connection when it’s not even established
395
- if (this.status !== WebSocketStatus.Connected) {
396
- return
397
- }
398
-
399
- // Don’t close the connection while waiting for the first message
400
- if (!this.lastMessageReceived) {
401
- return
402
- }
403
-
404
- // Don’t close the connection when a message was received recently
405
- if (
406
- this.configuration.messageReconnectTimeout
407
- >= time.getUnixTime() - this.lastMessageReceived
408
- ) {
409
- return
410
- }
411
-
412
- // No message received in a long time, not even your own
413
- // Awareness updates, which are updated every 15 seconds
414
- // if awareness is enabled.
415
- this.closeTries += 1
416
- // https://bugs.webkit.org/show_bug.cgi?id=247943
417
- if (this.closeTries > 2) {
418
- this.onClose({
419
- event: {
420
- code: 4408,
421
- reason: 'forced',
422
- },
423
- })
424
- this.closeTries = 0
425
- } else {
426
- this.webSocket?.close()
427
- this.messageQueue = []
428
- }
429
- }
430
-
431
- // Ensure that the URL never ends with /
432
- get serverUrl() {
433
- while (this.configuration.url[this.configuration.url.length - 1] === '/') {
434
- return this.configuration.url.slice(0, this.configuration.url.length - 1)
435
- }
436
-
437
- return this.configuration.url
438
- }
439
-
440
- get url() {
441
- const encodedParams = url.encodeQueryParams(this.configuration.parameters)
442
-
443
- return `${this.serverUrl}${encodedParams.length === 0 ? '' : `?${encodedParams}`}`
444
- }
445
-
446
- disconnect() {
447
- this.shouldConnect = false
448
-
449
- if (this.webSocket === null) {
450
- return
451
- }
452
-
453
- try {
454
- this.webSocket.close()
455
- this.messageQueue = []
456
- } catch(e) {
457
- console.error(e)
458
- }
459
- }
460
-
461
- send(message: any) {
462
- if (this.webSocket?.readyState === WsReadyStates.Open) {
463
- this.webSocket.send(message)
464
- } else {
465
- this.messageQueue.push(message)
466
- }
467
- }
468
-
469
- onClose({ event }: onCloseParameters) {
470
- this.closeTries = 0
471
- this.cleanupWebSocket()
472
-
473
- if (this.connectionAttempt) {
474
- // That connection attempt failed.
475
- this.rejectConnectionAttempt()
476
- }
477
-
478
- // Let’s update the connection status.
479
- this.status = WebSocketStatus.Disconnected
480
- this.emit('status', { status: WebSocketStatus.Disconnected })
481
- this.emit('disconnect', { event })
482
-
483
- // trigger connect if no retry is running and we want to have a connection
484
- if( !this.cancelWebsocketRetry && this.shouldConnect ) {
485
-
486
- setTimeout(() => {
487
- this.connect()
488
- }, this.configuration.delay)
489
- }
490
- }
491
-
492
- destroy() {
493
- this.emit('destroy')
494
-
495
- clearInterval(this.intervals.connectionChecker)
496
-
497
- // If there is still a connection attempt outstanding then we should stop
498
- // it before calling disconnect, otherwise it will be rejected in the onClose
499
- // handler and trigger a retry
500
- this.stopConnectionAttempt()
501
-
502
- this.disconnect()
503
-
504
- this.removeAllListeners()
505
-
506
- this.cleanupWebSocket()
507
- }
93
+ private messageQueue: any[] = [];
94
+
95
+ public configuration: CompleteHocuspocusProviderWebsocketConfiguration = {
96
+ url: "",
97
+ // @ts-ignore
98
+ document: undefined,
99
+ WebSocketPolyfill: undefined,
100
+ // TODO: this should depend on awareness.outdatedTime
101
+ messageReconnectTimeout: 30000,
102
+ // 1 second
103
+ delay: 1000,
104
+ // instant
105
+ initialDelay: 0,
106
+ // double the delay each time
107
+ factor: 2,
108
+ // unlimited retries
109
+ maxAttempts: 0,
110
+ // wait at least 1 second
111
+ minDelay: 1000,
112
+ // at least every 30 seconds
113
+ maxDelay: 30000,
114
+ // randomize
115
+ jitter: true,
116
+ // retry forever
117
+ timeout: 0,
118
+ onOpen: () => null,
119
+ onConnect: () => null,
120
+ onMessage: () => null,
121
+ onOutgoingMessage: () => null,
122
+ onStatus: () => null,
123
+ onDisconnect: () => null,
124
+ onClose: () => null,
125
+ onDestroy: () => null,
126
+ onAwarenessUpdate: () => null,
127
+ onAwarenessChange: () => null,
128
+ providerMap: new Map(),
129
+ };
130
+
131
+ webSocket: HocusPocusWebSocket | null = null;
132
+
133
+ webSocketHandlers: { [key: string]: any } = {};
134
+
135
+ shouldConnect = true;
136
+
137
+ status = WebSocketStatus.Disconnected;
138
+
139
+ lastMessageReceived = 0;
140
+
141
+ identifier = 0;
142
+
143
+ intervals: any = {
144
+ connectionChecker: null,
145
+ };
146
+
147
+ connectionAttempt: {
148
+ resolve: (value?: any) => void;
149
+ reject: (reason?: any) => void;
150
+ } | null = null;
151
+
152
+ constructor(configuration: HocuspocusProviderWebsocketConfiguration) {
153
+ super();
154
+ this.setConfiguration(configuration);
155
+
156
+ this.configuration.WebSocketPolyfill = configuration.WebSocketPolyfill
157
+ ? configuration.WebSocketPolyfill
158
+ : WebSocket;
159
+
160
+ this.on("open", this.configuration.onOpen);
161
+ this.on("open", this.onOpen.bind(this));
162
+ this.on("connect", this.configuration.onConnect);
163
+ this.on("message", this.configuration.onMessage);
164
+ this.on("outgoingMessage", this.configuration.onOutgoingMessage);
165
+ this.on("status", this.configuration.onStatus);
166
+ this.on("disconnect", this.configuration.onDisconnect);
167
+ this.on("close", this.configuration.onClose);
168
+ this.on("destroy", this.configuration.onDestroy);
169
+ this.on("awarenessUpdate", this.configuration.onAwarenessUpdate);
170
+ this.on("awarenessChange", this.configuration.onAwarenessChange);
171
+
172
+ this.on("close", this.onClose.bind(this));
173
+ this.on("message", this.onMessage.bind(this));
174
+
175
+ this.intervals.connectionChecker = setInterval(
176
+ this.checkConnection.bind(this),
177
+ this.configuration.messageReconnectTimeout / 10,
178
+ );
179
+
180
+ if (!this.shouldConnect) {
181
+ return;
182
+ }
183
+
184
+ this.connect();
185
+ }
186
+
187
+ receivedOnOpenPayload?: Event | undefined = undefined;
188
+
189
+ async onOpen(event: Event) {
190
+ this.cancelWebsocketRetry = undefined;
191
+ this.receivedOnOpenPayload = event;
192
+ }
193
+
194
+ attach(provider: HocuspocusProvider) {
195
+ this.configuration.providerMap.set(provider.configuration.name, provider);
196
+
197
+ if (this.status === WebSocketStatus.Disconnected && this.shouldConnect) {
198
+ this.connect();
199
+ }
200
+
201
+ if (
202
+ this.receivedOnOpenPayload &&
203
+ this.status === WebSocketStatus.Connected
204
+ ) {
205
+ provider.onOpen(this.receivedOnOpenPayload);
206
+ }
207
+ }
208
+
209
+ detach(provider: HocuspocusProvider) {
210
+ if (this.configuration.providerMap.has(provider.configuration.name)) {
211
+ provider.send(CloseMessage, {
212
+ documentName: provider.configuration.name,
213
+ });
214
+ this.configuration.providerMap.delete(provider.configuration.name);
215
+ }
216
+ }
217
+
218
+ public setConfiguration(
219
+ configuration: Partial<HocuspocusProviderWebsocketConfiguration> = {},
220
+ ): void {
221
+ this.configuration = { ...this.configuration, ...configuration };
222
+ }
223
+
224
+ cancelWebsocketRetry?: () => void;
225
+
226
+ async connect() {
227
+ if (this.status === WebSocketStatus.Connected) {
228
+ return;
229
+ }
230
+
231
+ // Always cancel any previously initiated connection retryer instances
232
+ if (this.cancelWebsocketRetry) {
233
+ this.cancelWebsocketRetry();
234
+ this.cancelWebsocketRetry = undefined;
235
+ }
236
+
237
+ this.receivedOnOpenPayload = undefined;
238
+ this.shouldConnect = true;
239
+
240
+ const abortableRetry = () => {
241
+ let cancelAttempt = false;
242
+
243
+ const retryPromise = retry(this.createWebSocketConnection.bind(this), {
244
+ delay: this.configuration.delay,
245
+ initialDelay: this.configuration.initialDelay,
246
+ factor: this.configuration.factor,
247
+ maxAttempts: this.configuration.maxAttempts,
248
+ minDelay: this.configuration.minDelay,
249
+ maxDelay: this.configuration.maxDelay,
250
+ jitter: this.configuration.jitter,
251
+ timeout: this.configuration.timeout,
252
+ beforeAttempt: (context) => {
253
+ if (!this.shouldConnect || cancelAttempt) {
254
+ context.abort();
255
+ }
256
+ },
257
+ }).catch((error: any) => {
258
+ // If we aborted the connection attempt then don’t throw an error
259
+ // ref: https://github.com/lifeomic/attempt/blob/master/src/index.ts#L136
260
+ if (error && error.code !== "ATTEMPT_ABORTED") {
261
+ throw error;
262
+ }
263
+ });
264
+
265
+ return {
266
+ retryPromise,
267
+ cancelFunc: () => {
268
+ cancelAttempt = true;
269
+ },
270
+ };
271
+ };
272
+
273
+ const { retryPromise, cancelFunc } = abortableRetry();
274
+ this.cancelWebsocketRetry = cancelFunc;
275
+
276
+ return retryPromise;
277
+ }
278
+
279
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
280
+ attachWebSocketListeners(ws: HocusPocusWebSocket, reject: Function) {
281
+ const { identifier } = ws;
282
+ const onMessageHandler = (payload: any) => this.emit("message", payload);
283
+ const onCloseHandler = (payload: any) =>
284
+ this.emit("close", { event: payload });
285
+ const onOpenHandler = (payload: any) => this.emit("open", payload);
286
+ const onErrorHandler = (err: any) => {
287
+ reject(err);
288
+ };
289
+
290
+ this.webSocketHandlers[identifier] = {
291
+ message: onMessageHandler,
292
+ close: onCloseHandler,
293
+ open: onOpenHandler,
294
+ error: onErrorHandler,
295
+ };
296
+
297
+ const handlers = this.webSocketHandlers[ws.identifier];
298
+
299
+ Object.keys(handlers).forEach((name) => {
300
+ ws.addEventListener(name, handlers[name]);
301
+ });
302
+ }
303
+
304
+ cleanupWebSocket() {
305
+ if (!this.webSocket) {
306
+ return;
307
+ }
308
+ const { identifier } = this.webSocket;
309
+ const handlers = this.webSocketHandlers[identifier];
310
+
311
+ Object.keys(handlers).forEach((name) => {
312
+ this.webSocket?.removeEventListener(name, handlers[name]);
313
+ delete this.webSocketHandlers[identifier];
314
+ });
315
+ this.webSocket.close();
316
+ this.webSocket = null;
317
+ }
318
+
319
+ createWebSocketConnection() {
320
+ return new Promise((resolve, reject) => {
321
+ if (this.webSocket) {
322
+ this.messageQueue = [];
323
+ this.cleanupWebSocket();
324
+ }
325
+ this.lastMessageReceived = 0;
326
+ this.identifier += 1;
327
+
328
+ // Init the WebSocket connection
329
+ const ws = new this.configuration.WebSocketPolyfill(this.url);
330
+ ws.binaryType = "arraybuffer";
331
+ ws.identifier = this.identifier;
332
+
333
+ this.attachWebSocketListeners(ws, reject);
334
+
335
+ this.webSocket = ws;
336
+
337
+ // Reset the status
338
+ this.status = WebSocketStatus.Connecting;
339
+ this.emit("status", { status: WebSocketStatus.Connecting });
340
+
341
+ // Store resolve/reject for later use
342
+ this.connectionAttempt = {
343
+ resolve,
344
+ reject,
345
+ };
346
+ });
347
+ }
348
+
349
+ onMessage(event: MessageEvent) {
350
+ this.resolveConnectionAttempt();
351
+
352
+ this.lastMessageReceived = time.getUnixTime();
353
+
354
+ const message = new IncomingMessage(event.data);
355
+ const documentName = message.peekVarString();
356
+
357
+ this.configuration.providerMap.get(documentName)?.onMessage(event);
358
+ }
359
+
360
+ resolveConnectionAttempt() {
361
+ if (this.connectionAttempt) {
362
+ this.connectionAttempt.resolve();
363
+ this.connectionAttempt = null;
364
+
365
+ this.status = WebSocketStatus.Connected;
366
+ this.emit("status", { status: WebSocketStatus.Connected });
367
+ this.emit("connect");
368
+ this.messageQueue.forEach((message) => this.send(message));
369
+ this.messageQueue = [];
370
+ }
371
+ }
372
+
373
+ stopConnectionAttempt() {
374
+ this.connectionAttempt = null;
375
+ }
376
+
377
+ rejectConnectionAttempt() {
378
+ this.connectionAttempt?.reject();
379
+ this.connectionAttempt = null;
380
+ }
381
+
382
+ closeTries = 0;
383
+
384
+ checkConnection() {
385
+ // Don’t check the connection when it’s not even established
386
+ if (this.status !== WebSocketStatus.Connected) {
387
+ return;
388
+ }
389
+
390
+ // Don’t close the connection while waiting for the first message
391
+ if (!this.lastMessageReceived) {
392
+ return;
393
+ }
394
+
395
+ // Don’t close the connection when a message was received recently
396
+ if (
397
+ this.configuration.messageReconnectTimeout >=
398
+ time.getUnixTime() - this.lastMessageReceived
399
+ ) {
400
+ return;
401
+ }
402
+
403
+ // No message received in a long time, not even your own
404
+ // Awareness updates, which are updated every 15 seconds
405
+ // if awareness is enabled.
406
+ this.closeTries += 1;
407
+ // https://bugs.webkit.org/show_bug.cgi?id=247943
408
+ if (this.closeTries > 2) {
409
+ this.onClose({
410
+ event: {
411
+ code: 4408,
412
+ reason: "forced",
413
+ },
414
+ });
415
+ this.closeTries = 0;
416
+ } else {
417
+ this.webSocket?.close();
418
+ this.messageQueue = [];
419
+ }
420
+ }
421
+
422
+ // Ensure that the URL never ends with /
423
+ get serverUrl() {
424
+ while (this.configuration.url[this.configuration.url.length - 1] === "/") {
425
+ return this.configuration.url.slice(0, this.configuration.url.length - 1);
426
+ }
427
+
428
+ return this.configuration.url;
429
+ }
430
+
431
+ get url() {
432
+ return this.serverUrl;
433
+ }
434
+
435
+ disconnect() {
436
+ this.shouldConnect = false;
437
+
438
+ if (this.webSocket === null) {
439
+ return;
440
+ }
441
+
442
+ try {
443
+ this.webSocket.close();
444
+ this.messageQueue = [];
445
+ } catch (e) {
446
+ console.error(e);
447
+ }
448
+ }
449
+
450
+ send(message: any) {
451
+ if (this.webSocket?.readyState === WsReadyStates.Open) {
452
+ this.webSocket.send(message);
453
+ } else {
454
+ this.messageQueue.push(message);
455
+ }
456
+ }
457
+
458
+ onClose({ event }: onCloseParameters) {
459
+ this.closeTries = 0;
460
+ this.cleanupWebSocket();
461
+
462
+ if (this.connectionAttempt) {
463
+ // That connection attempt failed.
464
+ this.rejectConnectionAttempt();
465
+ }
466
+
467
+ // Let’s update the connection status.
468
+ this.status = WebSocketStatus.Disconnected;
469
+ this.emit("status", { status: WebSocketStatus.Disconnected });
470
+ this.emit("disconnect", { event });
471
+
472
+ // trigger connect if no retry is running and we want to have a connection
473
+ if (!this.cancelWebsocketRetry && this.shouldConnect) {
474
+ setTimeout(() => {
475
+ this.connect();
476
+ }, this.configuration.delay);
477
+ }
478
+ }
479
+
480
+ destroy() {
481
+ this.emit("destroy");
482
+
483
+ clearInterval(this.intervals.connectionChecker);
484
+
485
+ // If there is still a connection attempt outstanding then we should stop
486
+ // it before calling disconnect, otherwise it will be rejected in the onClose
487
+ // handler and trigger a retry
488
+ this.stopConnectionAttempt();
489
+
490
+ this.disconnect();
491
+
492
+ this.removeAllListeners();
493
+
494
+ this.cleanupWebSocket();
495
+ }
508
496
  }