@hocuspocus/provider 4.3.0 → 4.5.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,4 +1,8 @@
1
- import { awarenessStatesToArray, makeRoutingKey, parseRoutingKey } from "@hocuspocus/common";
1
+ import {
2
+ awarenessStatesToArray,
3
+ makeRoutingKey,
4
+ parseRoutingKey,
5
+ } from "@hocuspocus/common";
2
6
  import { Awareness, removeAwarenessStates } from "y-protocols/awareness";
3
7
  import * as Y from "yjs";
4
8
  import EventEmitter from "./EventEmitter.ts";
@@ -82,7 +86,7 @@ export interface CompleteHocuspocusProviderConfiguration {
82
86
  * sessionId in the documentName field of every message, allowing multiple
83
87
  * providers with the same document name on a single WebSocket connection.
84
88
  *
85
- * Only set this to `true` when connecting to a v4 server that does
89
+ * Only set this to `true` when connecting to a v4 server that does
86
90
  * support session awareness.
87
91
  *
88
92
  * Default: false
@@ -94,6 +98,22 @@ export interface CompleteHocuspocusProviderConfiguration {
94
98
  */
95
99
  forceSyncInterval: false | number;
96
100
 
101
+ /**
102
+ * Batch outgoing document and awareness updates over a short window (in
103
+ * milliseconds) instead of sending one message per change. During heavy
104
+ * editing this drastically reduces the number of websocket messages: Yjs
105
+ * updates collected in the window are merged with `Y.mergeUpdates` into a
106
+ * single message, and awareness collapses to the latest state of each
107
+ * changed client.
108
+ *
109
+ * The window is a fixed batch, not a resetting debounce, so the added
110
+ * latency is capped at `flushDelay` even while the user keeps typing. Keep
111
+ * it small (e.g. 500) to avoid delaying what other clients see.
112
+ *
113
+ * Set to `false` (the default) to send every change immediately.
114
+ */
115
+ flushDelay: false | number;
116
+
97
117
  onAuthenticated: (data: onAuthenticatedParameters) => void;
98
118
  onAuthenticationFailed: (data: onAuthenticationFailedParameters) => void;
99
119
  onOpen: (data: onOpenParameters) => void;
@@ -125,6 +145,7 @@ export class HocuspocusProvider extends EventEmitter {
125
145
  token: null,
126
146
  sessionAwareness: false,
127
147
  forceSyncInterval: false,
148
+ flushDelay: false,
128
149
  onAuthenticated: () => null,
129
150
  onAuthenticationFailed: () => null,
130
151
  onOpen: () => null,
@@ -155,6 +176,18 @@ export class HocuspocusProvider extends EventEmitter {
155
176
 
156
177
  private _isAttached = false;
157
178
 
179
+ /**
180
+ * Outgoing document updates buffered for the current `flushDelay` window.
181
+ */
182
+ private pendingUpdates: Uint8Array[] = [];
183
+
184
+ /**
185
+ * Awareness client ids whose latest state should be sent on the next flush.
186
+ */
187
+ private pendingAwarenessClients = new Set<number>();
188
+
189
+ private flushTimeout: ReturnType<typeof setTimeout> | null = null;
190
+
158
191
  /**
159
192
  * Unique session identifier for this provider instance.
160
193
  * Used for multiplexing multiple providers with the same document name on a single WebSocket.
@@ -355,23 +388,103 @@ export class HocuspocusProvider extends EventEmitter {
355
388
  });
356
389
  }
357
390
 
391
+ private get batchingEnabled(): boolean {
392
+ return (
393
+ !!this.configuration.flushDelay &&
394
+ typeof this.configuration.flushDelay === "number"
395
+ );
396
+ }
397
+
358
398
  documentUpdateHandler(update: Uint8Array, origin: any) {
359
399
  if (origin === this) {
360
400
  return;
361
401
  }
362
402
 
363
- this.incrementUnsyncedChanges();
364
- this.send(UpdateMessage, { update, documentName: this.effectiveName });
403
+ if (!this.batchingEnabled) {
404
+ this.incrementUnsyncedChanges();
405
+ this.send(UpdateMessage, { update, documentName: this.effectiveName });
406
+ return;
407
+ }
408
+
409
+ // Count one outstanding change per batch: the server acks once per
410
+ // merged message, so incrementing per buffered update would never balance.
411
+ if (this.pendingUpdates.length === 0) {
412
+ this.incrementUnsyncedChanges();
413
+ }
414
+
415
+ this.pendingUpdates.push(update);
416
+ this.scheduleFlush();
365
417
  }
366
418
 
367
419
  awarenessUpdateHandler({ added, updated, removed }: any, origin: any) {
420
+ if (origin === this) {
421
+ return;
422
+ }
423
+
368
424
  const changedClients = added.concat(updated).concat(removed);
369
425
 
370
- this.send(AwarenessMessage, {
371
- awareness: this.awareness,
372
- clients: changedClients,
373
- documentName: this.effectiveName,
374
- });
426
+ if (!this.batchingEnabled) {
427
+ this.send(AwarenessMessage, {
428
+ awareness: this.awareness,
429
+ clients: changedClients,
430
+ documentName: this.effectiveName,
431
+ });
432
+ return;
433
+ }
434
+
435
+ for (const client of changedClients) {
436
+ this.pendingAwarenessClients.add(client);
437
+ }
438
+
439
+ this.scheduleFlush();
440
+ }
441
+
442
+ private scheduleFlush() {
443
+ // Fixed-window batching: the first pending change starts the timer and
444
+ // everything until it fires is flushed together, so latency stays capped
445
+ // at `flushDelay` instead of growing while the user keeps typing.
446
+ if (this.flushTimeout !== null) {
447
+ return;
448
+ }
449
+
450
+ this.flushTimeout = setTimeout(() => {
451
+ this.flushTimeout = null;
452
+ this.flushPendingUpdates();
453
+ }, this.configuration.flushDelay as number);
454
+ }
455
+
456
+ /**
457
+ * Send everything buffered for the current `flushDelay` window right away.
458
+ * Buffered document updates are merged into a single message and awareness
459
+ * collapses to the latest state of each changed client. Safe to call when
460
+ * nothing is pending.
461
+ */
462
+ flushPendingUpdates() {
463
+ if (this.flushTimeout !== null) {
464
+ clearTimeout(this.flushTimeout);
465
+ this.flushTimeout = null;
466
+ }
467
+
468
+ if (this.pendingUpdates.length > 0) {
469
+ const update =
470
+ this.pendingUpdates.length === 1
471
+ ? this.pendingUpdates[0]
472
+ : Y.mergeUpdates(this.pendingUpdates);
473
+
474
+ this.pendingUpdates = [];
475
+ this.send(UpdateMessage, { update, documentName: this.effectiveName });
476
+ }
477
+
478
+ if (this.pendingAwarenessClients.size > 0) {
479
+ const clients = Array.from(this.pendingAwarenessClients);
480
+
481
+ this.pendingAwarenessClients.clear();
482
+ this.send(AwarenessMessage, {
483
+ awareness: this.awareness,
484
+ clients,
485
+ documentName: this.effectiveName,
486
+ });
487
+ }
375
488
  }
376
489
 
377
490
  /**
@@ -482,6 +595,15 @@ export class HocuspocusProvider extends EventEmitter {
482
595
  this.isAuthenticated = false;
483
596
  this.synced = false;
484
597
 
598
+ // Drop anything buffered for batching; the reconnect sync handshake will
599
+ // reconcile these changes from the document via state vectors.
600
+ if (this.flushTimeout !== null) {
601
+ clearTimeout(this.flushTimeout);
602
+ this.flushTimeout = null;
603
+ }
604
+ this.pendingUpdates = [];
605
+ this.pendingAwarenessClients.clear();
606
+
485
607
  // update awareness (all users except local left)
486
608
  if (this.awareness) {
487
609
  removeAwarenessStates(
@@ -511,6 +633,10 @@ export class HocuspocusProvider extends EventEmitter {
511
633
  this.awareness.destroy();
512
634
  }
513
635
 
636
+ // Send any buffered updates before the socket is torn down. The websocket
637
+ // safely queues the message if it is no longer open.
638
+ this.flushPendingUpdates();
639
+
514
640
  this.document.off("update", this.boundDocumentUpdateHandler);
515
641
 
516
642
  this.removeAllListeners();
@@ -11,6 +11,8 @@ export class OutgoingMessage implements OutgoingMessageInterface {
11
11
 
12
12
  type?: MessageType;
13
13
 
14
+ description?: string;
15
+
14
16
  constructor() {
15
17
  this.encoder = createEncoder();
16
18
  }
package/src/types.ts CHANGED
@@ -34,6 +34,7 @@ export type AuthorizedScope = "read-write" | "readonly";
34
34
  export interface OutgoingMessageInterface {
35
35
  encoder: Encoder;
36
36
  type?: MessageType;
37
+ description?: string;
37
38
  }
38
39
 
39
40
  export interface OutgoingMessageArguments {