@veltdev/reactflow-crdt
Version:
ReactFlow CRDT library for Velt
1,754 lines (1,664 loc) • 97.9 kB
JavaScript
'use strict';
var zustand = require('zustand');
var react = require('@xyflow/react');
var Y = require('yjs');
var react$1 = require('@veltdev/react');
var React = require('react');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var Y__namespace = /*#__PURE__*/_interopNamespaceDefault(Y);
/* eslint-env browser */
const BIT8 = 128;
const BITS7 = 127;
/**
* Common Math expressions.
*
* @module math
*/
const floor = Math.floor;
/**
* @function
* @param {number} a
* @param {number} b
* @return {number} The smaller element of a and b
*/
const min = (a, b) => a < b ? a : b;
/**
* @function
* @param {number} a
* @param {number} b
* @return {number} The bigger element of a and b
*/
const max = (a, b) => a > b ? a : b;
/**
* Utility helpers for working with numbers.
*
* @module number
*/
const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
/**
* Utility module to work with sets.
*
* @module set
*/
const create$2 = () => new Set();
/**
* Utility module to work with Arrays.
*
* @module array
*/
/**
* Transforms something array-like to an actual Array.
*
* @function
* @template T
* @param {ArrayLike<T>|Iterable<T>} arraylike
* @return {T}
*/
const from = Array.from;
/**
* @param {string} str
* @return {Uint8Array}
*/
const _encodeUtf8Polyfill = str => {
const encodedString = unescape(encodeURIComponent(str));
const len = encodedString.length;
const buf = new Uint8Array(len);
for (let i = 0; i < len; i++) {
buf[i] = /** @type {number} */encodedString.codePointAt(i);
}
return buf;
};
/* c8 ignore next */
const utf8TextEncoder = /** @type {TextEncoder} */typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
/**
* @param {string} str
* @return {Uint8Array}
*/
const _encodeUtf8Native = str => utf8TextEncoder.encode(str);
/**
* @param {string} str
* @return {Uint8Array}
*/
/* c8 ignore next */
const encodeUtf8 = utf8TextEncoder ? _encodeUtf8Native : _encodeUtf8Polyfill;
/* c8 ignore next */
let utf8TextDecoder = typeof TextDecoder === 'undefined' ? null : new TextDecoder('utf-8', {
fatal: true,
ignoreBOM: true
});
/* c8 ignore start */
if (utf8TextDecoder && utf8TextDecoder.decode(new Uint8Array()).length === 1) {
// Safari doesn't handle BOM correctly.
// This fixes a bug in Safari 13.0.5 where it produces a BOM the first time it is called.
// utf8TextDecoder.decode(new Uint8Array()).length === 1 on the first call and
// utf8TextDecoder.decode(new Uint8Array()).length === 1 on the second call
// Another issue is that from then on no BOM chars are recognized anymore
/* c8 ignore next */
utf8TextDecoder = null;
}
/**
* Error helpers.
*
* @module error
*/
/**
* @param {string} s
* @return {Error}
*/
/* c8 ignore next */
const create$1 = s => new Error(s);
/**
* Efficient schema-less binary encoding with support for variable length encoding.
*
* Use [lib0/encoding] with [lib0/decoding]. Every encoding function has a corresponding decoding function.
*
* Encodes numbers in little-endian order (least to most significant byte order)
* and is compatible with Golang's binary encoding (https://golang.org/pkg/encoding/binary/)
* which is also used in Protocol Buffers.
*
* ```js
* // encoding step
* const encoder = encoding.createEncoder()
* encoding.writeVarUint(encoder, 256)
* encoding.writeVarString(encoder, 'Hello world!')
* const buf = encoding.toUint8Array(encoder)
* ```
*
* ```js
* // decoding step
* const decoder = decoding.createDecoder(buf)
* decoding.readVarUint(decoder) // => 256
* decoding.readVarString(decoder) // => 'Hello world!'
* decoding.hasContent(decoder) // => false - all data is read
* ```
*
* @module encoding
*/
/**
* A BinaryEncoder handles the encoding to an Uint8Array.
*/
class Encoder {
constructor() {
this.cpos = 0;
this.cbuf = new Uint8Array(100);
/**
* @type {Array<Uint8Array>}
*/
this.bufs = [];
}
}
/**
* @function
* @return {Encoder}
*/
const createEncoder = () => new Encoder();
/**
* The current length of the encoded data.
*
* @function
* @param {Encoder} encoder
* @return {number}
*/
const length$1 = encoder => {
let len = encoder.cpos;
for (let i = 0; i < encoder.bufs.length; i++) {
len += encoder.bufs[i].length;
}
return len;
};
/**
* Transform to Uint8Array.
*
* @function
* @param {Encoder} encoder
* @return {Uint8Array} The created ArrayBuffer.
*/
const toUint8Array = encoder => {
const uint8arr = new Uint8Array(length$1(encoder));
let curPos = 0;
for (let i = 0; i < encoder.bufs.length; i++) {
const d = encoder.bufs[i];
uint8arr.set(d, curPos);
curPos += d.length;
}
uint8arr.set(new Uint8Array(encoder.cbuf.buffer, 0, encoder.cpos), curPos);
return uint8arr;
};
/**
* Write one byte to the encoder.
*
* @function
* @param {Encoder} encoder
* @param {number} num The byte that is to be encoded.
*/
const write = (encoder, num) => {
const bufferLen = encoder.cbuf.length;
if (encoder.cpos === bufferLen) {
encoder.bufs.push(encoder.cbuf);
encoder.cbuf = new Uint8Array(bufferLen * 2);
encoder.cpos = 0;
}
encoder.cbuf[encoder.cpos++] = num;
};
/**
* Write a variable length unsigned integer. Max encodable integer is 2^53.
*
* @function
* @param {Encoder} encoder
* @param {number} num The number that is to be encoded.
*/
const writeVarUint = (encoder, num) => {
while (num > BITS7) {
write(encoder, BIT8 | BITS7 & num);
num = floor(num / 128); // shift >>> 7
}
write(encoder, BITS7 & num);
};
/**
* A cache to store strings temporarily
*/
const _strBuffer = new Uint8Array(30000);
const _maxStrBSize = _strBuffer.length / 3;
/**
* Write a variable length string.
*
* @function
* @param {Encoder} encoder
* @param {String} str The string that is to be encoded.
*/
const _writeVarStringNative = (encoder, str) => {
if (str.length < _maxStrBSize) {
// We can encode the string into the existing buffer
/* c8 ignore next */
const written = utf8TextEncoder.encodeInto(str, _strBuffer).written || 0;
writeVarUint(encoder, written);
for (let i = 0; i < written; i++) {
write(encoder, _strBuffer[i]);
}
} else {
writeVarUint8Array(encoder, encodeUtf8(str));
}
};
/**
* Write a variable length string.
*
* @function
* @param {Encoder} encoder
* @param {String} str The string that is to be encoded.
*/
const _writeVarStringPolyfill = (encoder, str) => {
const encodedString = unescape(encodeURIComponent(str));
const len = encodedString.length;
writeVarUint(encoder, len);
for (let i = 0; i < len; i++) {
write(encoder, /** @type {number} */encodedString.codePointAt(i));
}
};
/**
* Write a variable length string.
*
* @function
* @param {Encoder} encoder
* @param {String} str The string that is to be encoded.
*/
/* c8 ignore next */
const writeVarString = utf8TextEncoder && /** @type {any} */utf8TextEncoder.encodeInto ? _writeVarStringNative : _writeVarStringPolyfill;
/**
* Append fixed-length Uint8Array to the encoder.
*
* @function
* @param {Encoder} encoder
* @param {Uint8Array} uint8Array
*/
const writeUint8Array = (encoder, uint8Array) => {
const bufferLen = encoder.cbuf.length;
const cpos = encoder.cpos;
const leftCopyLen = min(bufferLen - cpos, uint8Array.length);
const rightCopyLen = uint8Array.length - leftCopyLen;
encoder.cbuf.set(uint8Array.subarray(0, leftCopyLen), cpos);
encoder.cpos += leftCopyLen;
if (rightCopyLen > 0) {
// Still something to write, write right half..
// Append new buffer
encoder.bufs.push(encoder.cbuf);
// must have at least size of remaining buffer
encoder.cbuf = new Uint8Array(max(bufferLen * 2, rightCopyLen));
// copy array
encoder.cbuf.set(uint8Array.subarray(leftCopyLen));
encoder.cpos = rightCopyLen;
}
};
/**
* Append an Uint8Array to Encoder.
*
* @function
* @param {Encoder} encoder
* @param {Uint8Array} uint8Array
*/
const writeVarUint8Array = (encoder, uint8Array) => {
writeVarUint(encoder, uint8Array.byteLength);
writeUint8Array(encoder, uint8Array);
};
/**
* Efficient schema-less binary decoding with support for variable length encoding.
*
* Use [lib0/decoding] with [lib0/encoding]. Every encoding function has a corresponding decoding function.
*
* Encodes numbers in little-endian order (least to most significant byte order)
* and is compatible with Golang's binary encoding (https://golang.org/pkg/encoding/binary/)
* which is also used in Protocol Buffers.
*
* ```js
* // encoding step
* const encoder = encoding.createEncoder()
* encoding.writeVarUint(encoder, 256)
* encoding.writeVarString(encoder, 'Hello world!')
* const buf = encoding.toUint8Array(encoder)
* ```
*
* ```js
* // decoding step
* const decoder = decoding.createDecoder(buf)
* decoding.readVarUint(decoder) // => 256
* decoding.readVarString(decoder) // => 'Hello world!'
* decoding.hasContent(decoder) // => false - all data is read
* ```
*
* @module decoding
*/
const errorUnexpectedEndOfArray = create$1('Unexpected end of array');
const errorIntegerOutOfRange = create$1('Integer out of Range');
/**
* A Decoder handles the decoding of an Uint8Array.
*/
class Decoder {
/**
* @param {Uint8Array} uint8Array Binary data to decode
*/
constructor(uint8Array) {
/**
* Decoding target.
*
* @type {Uint8Array}
*/
this.arr = uint8Array;
/**
* Current decoding position.
*
* @type {number}
*/
this.pos = 0;
}
}
/**
* @function
* @param {Uint8Array} uint8Array
* @return {Decoder}
*/
const createDecoder = uint8Array => new Decoder(uint8Array);
/**
* Create an Uint8Array view of the next `len` bytes and advance the position by `len`.
*
* Important: The Uint8Array still points to the underlying ArrayBuffer. Make sure to discard the result as soon as possible to prevent any memory leaks.
* Use `buffer.copyUint8Array` to copy the result into a new Uint8Array.
*
* @function
* @param {Decoder} decoder The decoder instance
* @param {number} len The length of bytes to read
* @return {Uint8Array}
*/
const readUint8Array = (decoder, len) => {
const view = new Uint8Array(decoder.arr.buffer, decoder.pos + decoder.arr.byteOffset, len);
decoder.pos += len;
return view;
};
/**
* Read variable length Uint8Array.
*
* Important: The Uint8Array still points to the underlying ArrayBuffer. Make sure to discard the result as soon as possible to prevent any memory leaks.
* Use `buffer.copyUint8Array` to copy the result into a new Uint8Array.
*
* @function
* @param {Decoder} decoder
* @return {Uint8Array}
*/
const readVarUint8Array = decoder => readUint8Array(decoder, readVarUint(decoder));
/**
* Read one byte as unsigned integer.
* @function
* @param {Decoder} decoder The decoder instance
* @return {number} Unsigned 8-bit integer
*/
const readUint8 = decoder => decoder.arr[decoder.pos++];
/**
* Read unsigned integer (32bit) with variable length.
* 1/8th of the storage is used as encoding overhead.
* * numbers < 2^7 is stored in one bytlength
* * numbers < 2^14 is stored in two bylength
*
* @function
* @param {Decoder} decoder
* @return {number} An unsigned integer.length
*/
const readVarUint = decoder => {
let num = 0;
let mult = 1;
const len = decoder.arr.length;
while (decoder.pos < len) {
const r = decoder.arr[decoder.pos++];
// num = num | ((r & binary.BITS7) << len)
num = num + (r & BITS7) * mult; // shift $r << (7*#iterations) and add it to num
mult *= 128; // next iteration, shift 7 "more" to the left
if (r < BIT8) {
return num;
}
/* c8 ignore start */
if (num > MAX_SAFE_INTEGER) {
throw errorIntegerOutOfRange;
}
/* c8 ignore stop */
}
throw errorUnexpectedEndOfArray;
};
/**
* We don't test this function anymore as we use native decoding/encoding by default now.
* Better not modify this anymore..
*
* Transforming utf8 to a string is pretty expensive. The code performs 10x better
* when String.fromCodePoint is fed with all characters as arguments.
* But most environments have a maximum number of arguments per functions.
* For effiency reasons we apply a maximum of 10000 characters at once.
*
* @function
* @param {Decoder} decoder
* @return {String} The read String.
*/
/* c8 ignore start */
const _readVarStringPolyfill = decoder => {
let remainingLen = readVarUint(decoder);
if (remainingLen === 0) {
return '';
} else {
let encodedString = String.fromCodePoint(readUint8(decoder)); // remember to decrease remainingLen
if (--remainingLen < 100) {
// do not create a Uint8Array for small strings
while (remainingLen--) {
encodedString += String.fromCodePoint(readUint8(decoder));
}
} else {
while (remainingLen > 0) {
const nextLen = remainingLen < 10000 ? remainingLen : 10000;
// this is dangerous, we create a fresh array view from the existing buffer
const bytes = decoder.arr.subarray(decoder.pos, decoder.pos + nextLen);
decoder.pos += nextLen;
// Starting with ES5.1 we can supply a generic array-like object as arguments
encodedString += String.fromCodePoint.apply(null, /** @type {any} */bytes);
remainingLen -= nextLen;
}
}
return decodeURIComponent(escape(encodedString));
}
};
/* c8 ignore stop */
/**
* @function
* @param {Decoder} decoder
* @return {String} The read String
*/
const _readVarStringNative = decoder => /** @type any */utf8TextDecoder.decode(readVarUint8Array(decoder));
/**
* Read string of variable length
* * varUint is used to store the length of the string
*
* @function
* @param {Decoder} decoder
* @return {String} The read String
*
*/
/* c8 ignore next */
const readVarString = utf8TextDecoder ? _readVarStringNative : _readVarStringPolyfill;
/**
* Utility module to work with time.
*
* @module time
*/
/**
* Return current unix time.
*
* @return {number}
*/
const getUnixTime = Date.now;
/**
* Utility module to work with key-value stores.
*
* @module map
*/
/**
* Creates a new Map instance.
*
* @function
* @return {Map<any, any>}
*
* @function
*/
const create = () => new Map();
/**
* Get map property. Create T if property is undefined and set T on map.
*
* ```js
* const listeners = map.setIfUndefined(events, 'eventName', set.create)
* listeners.add(listener)
* ```
*
* @function
* @template {Map<any, any>} MAP
* @template {MAP extends Map<any,infer V> ? function():V : unknown} CF
* @param {MAP} map
* @param {MAP extends Map<infer K,any> ? K : unknown} key
* @param {CF} createT
* @return {ReturnType<CF>}
*/
const setIfUndefined = (map, key, createT) => {
let set = map.get(key);
if (set === undefined) {
map.set(key, set = createT());
}
return set;
};
/**
* Observable class prototype.
*
* @module observable
*/
/* c8 ignore start */
/**
* Handles named events.
*
* @deprecated
* @template N
*/
class Observable {
constructor() {
/**
* Some desc.
* @type {Map<N, any>}
*/
this._observers = create();
}
/**
* @param {N} name
* @param {function} f
*/
on(name, f) {
setIfUndefined(this._observers, name, create$2).add(f);
}
/**
* @param {N} name
* @param {function} f
*/
once(name, f) {
/**
* @param {...any} args
*/
const _f = (...args) => {
this.off(name, _f);
f(...args);
};
this.on(name, _f);
}
/**
* @param {N} name
* @param {function} f
*/
off(name, f) {
const observers = this._observers.get(name);
if (observers !== undefined) {
observers.delete(f);
if (observers.size === 0) {
this._observers.delete(name);
}
}
}
/**
* Emit a named event. All registered event listeners that listen to the
* specified name will receive the event.
*
* @todo This should catch exceptions
*
* @param {N} name The event name.
* @param {Array<any>} args The arguments that are applied to the event listener.
*/
emit(name, args) {
// copy all listeners to an array first to make sure that no event is emitted to listeners that are subscribed while the event handler is called.
return from((this._observers.get(name) || create()).values()).forEach(f => f(...args));
}
destroy() {
this._observers = create();
}
}
/* c8 ignore end */
/**
* Utility functions for working with EcmaScript objects.
*
* @module object
*/
/**
* @param {Object<string,any>} obj
*/
const keys = Object.keys;
/**
* @deprecated use object.size instead
* @param {Object<string,any>} obj
* @return {number}
*/
const length = obj => keys(obj).length;
/**
* Calls `Object.prototype.hasOwnProperty`.
*
* @param {any} obj
* @param {string|number|symbol} key
* @return {boolean}
*/
const hasProperty = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
const EqualityTraitSymbol = Symbol('Equality');
/**
* @typedef {{ [EqualityTraitSymbol]:(other:EqualityTrait)=>boolean }} EqualityTrait
*/
/**
* Common functions and function call helpers.
*
* @module function
*/
/* c8 ignore start */
/**
* @param {any} a
* @param {any} b
* @return {boolean}
*/
const equalityDeep = (a, b) => {
if (a === b) {
return true;
}
if (a == null || b == null || a.constructor !== b.constructor) {
return false;
}
if (a[EqualityTraitSymbol] != null) {
return a[EqualityTraitSymbol](b);
}
switch (a.constructor) {
case ArrayBuffer:
a = new Uint8Array(a);
b = new Uint8Array(b);
// eslint-disable-next-line no-fallthrough
case Uint8Array:
{
if (a.byteLength !== b.byteLength) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
break;
}
case Set:
{
if (a.size !== b.size) {
return false;
}
for (const value of a) {
if (!b.has(value)) {
return false;
}
}
break;
}
case Map:
{
if (a.size !== b.size) {
return false;
}
for (const key of a.keys()) {
if (!b.has(key) || !equalityDeep(a.get(key), b.get(key))) {
return false;
}
}
break;
}
case Object:
if (length(a) !== length(b)) {
return false;
}
for (const key in a) {
if (!hasProperty(a, key) || !equalityDeep(a[key], b[key])) {
return false;
}
}
break;
case Array:
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!equalityDeep(a[i], b[i])) {
return false;
}
}
break;
default:
return false;
}
return true;
};
/**
* @module awareness-protocol
*/
const outdatedTimeout = 30000;
/**
* @typedef {Object} MetaClientState
* @property {number} MetaClientState.clock
* @property {number} MetaClientState.lastUpdated unix timestamp
*/
/**
* The Awareness class implements a simple shared state protocol that can be used for non-persistent data like awareness information
* (cursor, username, status, ..). Each client can update its own local state and listen to state changes of
* remote clients. Every client may set a state of a remote peer to `null` to mark the client as offline.
*
* Each client is identified by a unique client id (something we borrow from `doc.clientID`). A client can override
* its own state by propagating a message with an increasing timestamp (`clock`). If such a message is received, it is
* applied if the known state of that client is older than the new state (`clock < newClock`). If a client thinks that
* a remote client is offline, it may propagate a message with
* `{ clock: currentClientClock, state: null, client: remoteClient }`. If such a
* message is received, and the known clock of that client equals the received clock, it will override the state with `null`.
*
* Before a client disconnects, it should propagate a `null` state with an updated clock.
*
* Awareness states must be updated every 30 seconds. Otherwise the Awareness instance will delete the client state.
*
* @extends {Observable<string>}
*/
class Awareness extends Observable {
/**
* @param {Y.Doc} doc
*/
constructor(doc) {
super();
this.doc = doc;
/**
* @type {number}
*/
this.clientID = doc.clientID;
/**
* Maps from client id to client state
* @type {Map<number, Object<string, any>>}
*/
this.states = new Map();
/**
* @type {Map<number, MetaClientState>}
*/
this.meta = new Map();
this._checkInterval = /** @type {any} */setInterval(() => {
const now = getUnixTime();
if (this.getLocalState() !== null && outdatedTimeout / 2 <= now - /** @type {{lastUpdated:number}} */this.meta.get(this.clientID).lastUpdated) {
// renew local clock
this.setLocalState(this.getLocalState());
}
/**
* @type {Array<number>}
*/
const remove = [];
this.meta.forEach((meta, clientid) => {
if (clientid !== this.clientID && outdatedTimeout <= now - meta.lastUpdated && this.states.has(clientid)) {
remove.push(clientid);
}
});
if (remove.length > 0) {
removeAwarenessStates(this, remove, 'timeout');
}
}, floor(outdatedTimeout / 10));
doc.on('destroy', () => {
this.destroy();
});
this.setLocalState({});
}
destroy() {
this.emit('destroy', [this]);
this.setLocalState(null);
super.destroy();
clearInterval(this._checkInterval);
}
/**
* @return {Object<string,any>|null}
*/
getLocalState() {
return this.states.get(this.clientID) || null;
}
/**
* @param {Object<string,any>|null} state
*/
setLocalState(state) {
const clientID = this.clientID;
const currLocalMeta = this.meta.get(clientID);
const clock = currLocalMeta === undefined ? 0 : currLocalMeta.clock + 1;
const prevState = this.states.get(clientID);
if (state === null) {
this.states.delete(clientID);
} else {
this.states.set(clientID, state);
}
this.meta.set(clientID, {
clock,
lastUpdated: getUnixTime()
});
const added = [];
const updated = [];
const filteredUpdated = [];
const removed = [];
if (state === null) {
removed.push(clientID);
} else if (prevState == null) {
if (state != null) {
added.push(clientID);
}
} else {
updated.push(clientID);
if (!equalityDeep(prevState, state)) {
filteredUpdated.push(clientID);
}
}
if (added.length > 0 || filteredUpdated.length > 0 || removed.length > 0) {
this.emit('change', [{
added,
updated: filteredUpdated,
removed
}, 'local']);
}
this.emit('update', [{
added,
updated,
removed
}, 'local']);
}
/**
* @param {string} field
* @param {any} value
*/
setLocalStateField(field, value) {
const state = this.getLocalState();
if (state !== null) {
this.setLocalState({
...state,
[field]: value
});
}
}
/**
* @return {Map<number,Object<string,any>>}
*/
getStates() {
return this.states;
}
}
/**
* Mark (remote) clients as inactive and remove them from the list of active peers.
* This change will be propagated to remote clients.
*
* @param {Awareness} awareness
* @param {Array<number>} clients
* @param {any} origin
*/
const removeAwarenessStates = (awareness, clients, origin) => {
const removed = [];
for (let i = 0; i < clients.length; i++) {
const clientID = clients[i];
if (awareness.states.has(clientID)) {
awareness.states.delete(clientID);
if (clientID === awareness.clientID) {
const curMeta = /** @type {MetaClientState} */awareness.meta.get(clientID);
awareness.meta.set(clientID, {
clock: curMeta.clock + 1,
lastUpdated: getUnixTime()
});
}
removed.push(clientID);
}
}
if (removed.length > 0) {
awareness.emit('change', [{
added: [],
updated: [],
removed
}, origin]);
awareness.emit('update', [{
added: [],
updated: [],
removed
}, origin]);
}
};
/**
* @param {Awareness} awareness
* @param {Array<number>} clients
* @return {Uint8Array}
*/
const encodeAwarenessUpdate = (awareness, clients, states = awareness.states) => {
const len = clients.length;
const encoder = createEncoder();
writeVarUint(encoder, len);
for (let i = 0; i < len; i++) {
const clientID = clients[i];
const state = states.get(clientID) || null;
const clock = /** @type {MetaClientState} */awareness.meta.get(clientID).clock;
writeVarUint(encoder, clientID);
writeVarUint(encoder, clock);
writeVarString(encoder, JSON.stringify(state));
}
return toUint8Array(encoder);
};
/**
* @param {Awareness} awareness
* @param {Uint8Array} update
* @param {any} origin This will be added to the emitted change event
*/
const applyAwarenessUpdate = (awareness, update, origin) => {
const decoder = createDecoder(update);
const timestamp = getUnixTime();
const added = [];
const updated = [];
const filteredUpdated = [];
const removed = [];
const len = readVarUint(decoder);
for (let i = 0; i < len; i++) {
const clientID = readVarUint(decoder);
let clock = readVarUint(decoder);
const state = JSON.parse(readVarString(decoder));
const clientMeta = awareness.meta.get(clientID);
const prevState = awareness.states.get(clientID);
const currClock = clientMeta === undefined ? 0 : clientMeta.clock;
if (currClock < clock || currClock === clock && state === null && awareness.states.has(clientID)) {
if (state === null) {
// never let a remote client remove this local state
if (clientID === awareness.clientID && awareness.getLocalState() != null) {
// remote client removed the local state. Do not remote state. Broadcast a message indicating
// that this client still exists by increasing the clock
clock++;
} else {
awareness.states.delete(clientID);
}
} else {
awareness.states.set(clientID, state);
}
awareness.meta.set(clientID, {
clock,
lastUpdated: timestamp
});
if (clientMeta === undefined && state !== null) {
added.push(clientID);
} else if (clientMeta !== undefined && state === null) {
removed.push(clientID);
} else if (state !== null) {
if (!equalityDeep(state, prevState)) {
filteredUpdated.push(clientID);
}
updated.push(clientID);
}
}
}
if (added.length > 0 || filteredUpdated.length > 0 || removed.length > 0) {
awareness.emit('change', [{
added,
updated: filteredUpdated,
removed
}, origin]);
}
if (added.length > 0 || updated.length > 0 || removed.length > 0) {
awareness.emit('update', [{
added,
updated,
removed
}, origin]);
}
};
/**
* @module sync-protocol
*/
/**
* @typedef {Map<number, number>} StateMap
*/
/**
* Core Yjs defines two message types:
* • YjsSyncStep1: Includes the State Set of the sending client. When received, the client should reply with YjsSyncStep2.
* • YjsSyncStep2: Includes all missing structs and the complete delete set. When received, the client is assured that it
* received all information from the remote client.
*
* In a peer-to-peer network, you may want to introduce a SyncDone message type. Both parties should initiate the connection
* with SyncStep1. When a client received SyncStep2, it should reply with SyncDone. When the local client received both
* SyncStep2 and SyncDone, it is assured that it is synced to the remote client.
*
* In a client-server model, you want to handle this differently: The client should initiate the connection with SyncStep1.
* When the server receives SyncStep1, it should reply with SyncStep2 immediately followed by SyncStep1. The client replies
* with SyncStep2 when it receives SyncStep1. Optionally the server may send a SyncDone after it received SyncStep2, so the
* client knows that the sync is finished. There are two reasons for this more elaborated sync model: 1. This protocol can
* easily be implemented on top of http and websockets. 2. The server should only reply to requests, and not initiate them.
* Therefore it is necessary that the client initiates the sync.
*
* Construction of a message:
* [messageType : varUint, message definition..]
*
* Note: A message does not include information about the room name. This must to be handled by the upper layer protocol!
*
* stringify[messageType] stringifies a message definition (messageType is already read from the bufffer)
*/
const messageYjsSyncStep1 = 0;
const messageYjsSyncStep2 = 1;
const messageYjsUpdate = 2;
/**
* Create a sync step 1 message based on the state of the current shared document.
*
* @param {encoding.Encoder} encoder
* @param {Y.Doc} doc
*/
const writeSyncStep1 = (encoder, doc) => {
writeVarUint(encoder, messageYjsSyncStep1);
const sv = Y__namespace.encodeStateVector(doc);
writeVarUint8Array(encoder, sv);
};
/**
* @param {encoding.Encoder} encoder
* @param {Y.Doc} doc
* @param {Uint8Array} [encodedStateVector]
*/
const writeSyncStep2 = (encoder, doc, encodedStateVector) => {
writeVarUint(encoder, messageYjsSyncStep2);
writeVarUint8Array(encoder, Y__namespace.encodeStateAsUpdate(doc, encodedStateVector));
};
/**
* Read SyncStep1 message and reply with SyncStep2.
*
* @param {decoding.Decoder} decoder The reply to the received message
* @param {encoding.Encoder} encoder The received message
* @param {Y.Doc} doc
*/
const readSyncStep1 = (decoder, encoder, doc) => writeSyncStep2(encoder, doc, readVarUint8Array(decoder));
/**
* Read and apply Structs and then DeleteStore to a y instance.
*
* @param {decoding.Decoder} decoder
* @param {Y.Doc} doc
* @param {any} transactionOrigin
*/
const readSyncStep2 = (decoder, doc, transactionOrigin) => {
try {
Y__namespace.applyUpdate(doc, readVarUint8Array(decoder), transactionOrigin);
} catch (error) {
// This catches errors that are thrown by event handlers
console.error('Caught error while handling a Yjs update', error);
}
};
/**
* @param {encoding.Encoder} encoder
* @param {Uint8Array} update
*/
const writeUpdate = (encoder, update) => {
writeVarUint(encoder, messageYjsUpdate);
writeVarUint8Array(encoder, update);
};
/**
* Read and apply Structs and then DeleteStore to a y instance.
*
* @param {decoding.Decoder} decoder
* @param {Y.Doc} doc
* @param {any} transactionOrigin
*/
const readUpdate = readSyncStep2;
/**
* @param {decoding.Decoder} decoder A message received from another client
* @param {encoding.Encoder} encoder The reply message. Does not need to be sent if empty.
* @param {Y.Doc} doc
* @param {any} transactionOrigin
*/
const readSyncMessage = (decoder, encoder, doc, transactionOrigin) => {
const messageType = readVarUint(decoder);
switch (messageType) {
case messageYjsSyncStep1:
readSyncStep1(decoder, encoder, doc);
break;
case messageYjsSyncStep2:
readSyncStep2(decoder, doc, transactionOrigin);
break;
case messageYjsUpdate:
readUpdate(decoder, doc, transactionOrigin);
break;
default:
throw new Error('Unknown message type');
}
return messageType;
};
const config = {
enableDevelopmentLogging: false
};
const catchError$1 = (message, error) => {
try {
// handle error
if (sessionStorage.getItem('forceCrdtDebugMode')) {
console.warn(message, error);
}
} catch (err) {
// do nothing
}
};
// Base Realtime Database provider class
// export class BaseRealtimeProvider {
// protected dbRef: DatabaseReference;
// protected userId: string;
// public getDatabaseRef(): DatabaseReference {
// try {
// return this.dbRef;
// } catch (err) {
// catchError('Error in BaseRealtimeProvider getDatabaseRef', err);
// throw err;
// }
// }
// constructor(path: string, userId: string = 'anonymous') {
// try {
// this.dbRef = ref(realtimeDb, path);
// this.userId = userId;
// } catch (err) {
// catchError('Error in BaseRealtimeProvider constructor', err);
// throw err;
// }
// }
// protected async set(data: any): Promise<void> {
// try {
// await set(this.dbRef, data);
// } catch (err) {
// catchError('Error in BaseRealtimeProvider set', err);
// throw err;
// }
// }
// protected async update(updates: Record<string, any>): Promise<void> {
// try {
// await update(this.dbRef, updates);
// } catch (err) {
// catchError('Error in BaseRealtimeProvider update', err);
// throw err;
// }
// }
// protected async push(data: any): Promise<string> {
// try {
// const newRef = push(this.dbRef);
// await set(newRef, data);
// return newRef.key!;
// } catch (err) {
// catchError('Error in BaseRealtimeProvider push', err);
// throw err;
// }
// }
// public onValue(callback: (snapshot: DataSnapshot) => void): () => void {
// try {
// const unsubscribe = onValue(this.dbRef, callback);
// return () => {
// try {
// unsubscribe();
// } catch (err) {
// catchError('Error in BaseRealtimeProvider onValue unsubscribe', err);
// }
// };
// } catch (err) {
// catchError('Error in BaseRealtimeProvider onValue', err);
// return () => { };
// }
// }
// protected async remove(): Promise<void> {
// try {
// await remove(this.dbRef);
// } catch (err) {
// catchError('Error in BaseRealtimeProvider remove', err);
// throw err;
// }
// }
// }
// Firestore service that provides a centralized API for Firestore operations
class FirestoreService {
constructor(config) {
var _a;
this.config = config;
this.snapshotUnsubscribe = null;
try {
this.userId = config.userId || 'anonymous';
if ((_a = config === null || config === void 0 ? void 0 : config.veltClient) === null || _a === void 0 ? void 0 : _a.getCrdtElement) {
this.crdtElement = config.veltClient.getCrdtElement();
}
} catch (err) {
catchError$1('Error in FirestoreService constructor', err);
}
}
/**
* Initialize the Firestore document and set up real-time syncing
* @param doc Y.Doc instance to sync
* @param onUpdate Optional callback for remote updates
*/
async initialize(doc, onUpdate, enablePresence = true) {
var _a, _b;
try {
// Load initial state
// const snapshot = await getDoc(this.docRef);
// const data = snapshot.data() as FirestoreDocData;
const data = await ((_a = this.crdtElement) === null || _a === void 0 ? void 0 : _a.getData({
id: this.config.id
}));
if (data === null || data === void 0 ? void 0 : data.state) {
if (onUpdate) {
// Use the onUpdate callback which will handle remote marking
const update = new Uint8Array(data.state);
onUpdate(update);
} else {
Y__namespace.applyUpdate(doc, new Uint8Array(data.state));
}
}
// Set up real-time sync
if ((_b = this.crdtElement) === null || _b === void 0 ? void 0 : _b.onDataChange) {
this.snapshotUnsubscribe = this.crdtElement.onDataChange({
id: this.config.id,
callback: data => {
try {
if ((data === null || data === void 0 ? void 0 : data.state) && data.lastUpdatedBy !== this.userId) {
const update = new Uint8Array(data.state);
if (onUpdate) {
onUpdate(update);
} else {
Y__namespace.applyUpdate(doc, update);
}
}
} catch (err) {
catchError$1('Error in FirestoreService initialize callback', err);
}
}
});
}
// Set up presence only if enabled
if (enablePresence !== false) {
await this.setupPresence();
}
} catch (err) {
catchError$1('Error in FirestoreService initialize', err);
throw err;
}
}
/**
* Set up real-time presence tracking
*/
async setupPresence() {
var _a;
try {
await ((_a = this.crdtElement) === null || _a === void 0 ? void 0 : _a.setPresence({
id: this.config.id
}));
} catch (err) {
catchError$1('Error in FirestoreService setupPresence', err);
throw err;
}
}
/**
* Get the current document state
*/
async getState() {
var _a;
try {
const data = await ((_a = this.crdtElement) === null || _a === void 0 ? void 0 : _a.getData({
id: this.config.id
}));
return (data === null || data === void 0 ? void 0 : data.state) ? new Uint8Array(data.state) : null;
} catch (err) {
catchError$1('Error in FirestoreService getState', err);
return null;
}
}
/**
* Update the document state
* @param state Updated state as Uint8Array or number[]
*/
async updateState(state) {
var _a;
try {
await ((_a = this.crdtElement) === null || _a === void 0 ? void 0 : _a.updateData({
id: this.config.id,
state
}));
} catch (err) {
catchError$1('Error in FirestoreService updateState', err);
throw err;
}
}
onPresenceChange(callback) {
var _a;
try {
if ((_a = this.crdtElement) === null || _a === void 0 ? void 0 : _a.onPresenceChange) {
return this.crdtElement.onPresenceChange({
id: this.config.id,
callback: presenceData => {
try {
callback(presenceData || {});
} catch (err) {
catchError$1('Error in FirestoreService onPresenceChange callback', err);
}
}
});
}
return () => {};
} catch (err) {
catchError$1('Error in FirestoreService onPresenceChange', err);
return () => {};
}
}
/**
* Clean up resources
*/
destroy() {
try {
if (this.snapshotUnsubscribe) {
this.snapshotUnsubscribe();
}
} catch (err) {
catchError$1('Error in FirestoreService destroy', err);
}
}
}
/**
* Store class provides a wrapper around Yjs data structures with database synchronization.
* It supports various data types including arrays, maps, text, and XML fragments.
*
* @template {any} T - The type of data being stored
*/
/**
* Message types used for communication between clients
*/
const messageSync = 0;
const messageAwareness = 1;
const messageQueryAwareness = 3;
/**
* FirestoreProvider for Yjs. Creates a Firestore connection to sync the shared document.
* Similar to the WebsocketProvider but uses Firestore and RealtimeDB for communication.
*/
/**
* RealtimeDbService for Yjs. Creates a Realtime Database connection for synchronization.
* This service is specifically designed to avoid conflicts with Firestore by using only Realtime DB.
*/
class RealtimeDbService {
constructor(config) {
var _a;
this.onUpdateCallback = null;
this.valueUnsubscribe = null;
const path = `sync/${config.collection}/${config.id}`;
// super(path, config.userId || 'anonymous');
this.userId = config.userId || 'anonymous';
this.config = config;
this.docPath = path;
if ((_a = config === null || config === void 0 ? void 0 : config.veltClient) === null || _a === void 0 ? void 0 : _a.getCrdtElement) {
this.crdtElement = config.veltClient.getCrdtElement();
}
}
/**
* Initialize the Realtime Database document and set up real-time syncing
* @param doc Y.Doc instance to sync
* @param onUpdate Optional callback for remote updates
*/
async initialize(doc, onUpdate, enablePresence = true) {
var _a;
try {
this.onUpdateCallback = onUpdate || null;
// Set up real-time sync
this.valueUnsubscribe = (_a = this.crdtElement) === null || _a === void 0 ? void 0 : _a.onStateChange({
id: this.config.id,
callback: data => {
try {
if ((data === null || data === void 0 ? void 0 : data.state) && data.lastUpdatedBy !== this.userId) {
const update = new Uint8Array(data.state);
if (this.onUpdateCallback) {
this.onUpdateCallback(update);
} else {
Y__namespace.applyUpdate(doc, update);
}
}
} catch (err) {
catchError$1('Error in RealtimeDbService onStateChange', err);
}
}
});
// Set up presence only if enabled
if (enablePresence) {
await this.setupPresence();
}
} catch (err) {
catchError$1('Error in RealtimeDbService initialize', err);
}
}
/**
* Set up real-time presence tracking
*/
async setupPresence() {
var _a;
try {
await ((_a = this.crdtElement) === null || _a === void 0 ? void 0 : _a.registerSyncUser({
id: this.config.id
}));
} catch (err) {
catchError$1('Error in RealtimeDbService setupPresence', err);
}
}
/**
* Update the document state
* @param state Updated state as Uint8Array or number[]
*/
async updateState(state) {
var _a;
try {
await ((_a = this.crdtElement) === null || _a === void 0 ? void 0 : _a.updateState({
id: this.config.id,
state
}));
} catch (err) {
catchError$1('Error in RealtimeDbService updateState', err);
}
}
/**
* Subscribe to presence changes
* @param callback Callback function receiving presence data
*/
onPresenceChange(callback) {
var _a;
try {
// const presenceRef = ref(realtimeDb, `${this.docPath}/presence`);
// return onValue(presenceRef, (snapshot) => {
// try {
// const presenceData = snapshot.val() || {};
// console.log('onPresenceChange val', this.docPath, presenceData);
// callback(presenceData);
// } catch (err) {
// catchError('Error in RealtimeDbService onPresenceChange onValue', err);
// }
// });
if ((_a = this.crdtElement) === null || _a === void 0 ? void 0 : _a.onRegisteredUserChange) {
return this.crdtElement.onRegisteredUserChange({
id: this.config.id,
callback: presenceData => {
try {
callback(presenceData || {});
} catch (err) {
catchError$1('Error in RealtimeDbService onPresenceChange callback', err);
}
}
});
}
return () => {};
} catch (err) {
catchError$1('Error in RealtimeDbService onPresenceChange', err);
return () => {};
}
}
/**
* Clean up resources
*/
destroy() {
try {
if (this.valueUnsubscribe) {
this.valueUnsubscribe();
}
// remove(this.dbRef);
} catch (err) {
catchError$1('Error in RealtimeDbService destroy', err);
}
}
}
/**
* RealtimeDbProvider for Yjs. Creates a database connection to sync the shared document.
* This is a standalone provider that uses database.
*/
class RealtimeDbProvider {
constructor(collection, roomname, doc, {
connect = true,
awareness = new Awareness(doc),
userId = 'anonymous',
resyncInterval = -1,
enablePresence = true,
veltClient = null
} = {}) {
// Event handlers
this._eventHandlers = new Map();
this._synced = false;
this.checkInterval = null;
this.resyncInterval = null;
this.lastMessageReceived = 0;
this.messageReconnectTimeout = 30000; // 30 seconds
this.veltClient = null;
try {
// Initialize the provider
this.doc = doc;
this.awareness = awareness;
this.roomname = roomname;
this.collection = collection;
this.shouldConnect = connect;
this.enablePresence = enablePresence;
this.veltClient = veltClient;
// Initialize RealtimeDB service
this.realtimeDbService = new RealtimeDbService({
// firestore,
// realtimeDb,
collection,
id: roomname,
userId,
enablePresence: enablePresence,
veltClient: veltClient
});
// Set up message handlers
this.messageHandlers = [];
// Handler for sync messages
this.messageHandlers[messageSync] = (encoder, decoder, provider, emitSynced, _messageType) => {
try {
writeVarUint(encoder, messageSync);
const syncMessageType = readSyncMessage(decoder, encoder, provider.doc, provider);
if (emitSynced && syncMessageType === messageYjsSyncStep2 && !provider._synced) {
provider.synced = true;
}
} catch (err) {
catchError$1('Error in RealtimeDbProvider sync message handler', err);
}
};
// Handler for awareness query messages
this.messageHandlers[messageQueryAwareness] = (encoder, _decoder, provider, _emitSynced, _messageType) => {
try {
writeVarUint(encoder, messageAwareness);
writeVarUint8Array(encoder, encodeAwarenessUpdate(provider.awareness, Array.from(provider.awareness.getStates().keys())));
} catch (err) {
catchError$1('Error in RealtimeDbProvider awareness query handler', err);
}
};
// Handler for awareness update messages
this.messageHandlers[messageAwareness] = (_encoder, decoder, provider, _emitSynced, _messageType) => {
try {
applyAwarenessUpdate(provider.awareness, readVarUint8Array(decoder), provider);
} catch (err) {
catchError$1('Error in RealtimeDbProvider awareness update handler', err);
}
};
// Set up update handler for document changes
this.updateHandler = (update, origin) => {
try {
if (origin !== this) {
const encoder = createEncoder();
writeVarUint(encoder, messageSync);
writeUpdate(encoder, update);
this.broadcastMessage(toUint8Array(encoder));
}
} catch (err) {
catchError$1('Error in RealtimeDbProvider updateHandler', err);
}
};
// Set up awareness update handler
this.awarenessUpdateHandler = ({
added,
updated,
removed
}, _origin) => {
try {
const changedClients = added.concat(updated).concat(removed);
const encoder = createEncoder();
writeVarUint(encoder, messageAwareness);
writeVarUint8Array(encoder, encodeAwarenessUpdate(awareness, changedClients));
this.broadcastMessage(toUint8Array(encoder));
} catch (err) {
catchError$1('Error in RealtimeDbProvider awarenessUpdateHandler', err);
}
};
// Register event handlers
this.doc.on('update', this.updateHandler);
this.awareness.on('update', this.awarenessUpdateHandler);
// Set up periodic checks
this.checkInterval = setInterval(() => {
try {
if (this.messageReconnectTimeout < getUnixTime() - this.lastMessageReceived) {
// No message received in a long time - reconnect
this.disconnect();
this.connect();
}
} catch (err) {
catchError$1('Error in RealtimeDbProvider checkInterval', err);
}
}, this.messageReconnectTimeout / 10);
// Set up resync interval if specified
if (resyncInterval > 0) {
this.resyncInterval = setInterval(() => {
try {
// Resend sync step 1
const encoder = createEncoder();
writeVarUint(encoder, messageSync);
writeSyncStep1(encoder, doc);
this.broadcastMessage(toUint8Array(encoder));
} catch (err) {
catchError$1('Error in RealtimeDbProvider resyncInterval', err);
}
}, resyncInterval);
}
// Connect if specified
if (connect) {
this.connect();
}
} catch (err) {
catchError$1('Error in RealtimeDbProvider constructor', err);
throw err;
}
}
/**
* Getter/setter for the synced state
*/
get synced() {
return this._synced;
}
set synced(state) {
if (this._synced !== state) {
this._synced = state;
this._emit(RealtimeDbProvider.EVENT_SYNCED, state);
}
}
/**
* Register an event listener
* @param {string} eventName - Name of the event to listen to
* @param {Function} f - Event handler
*/
on(eventName, f) {
try {
if (!this._eventHandlers.has(eventName)) {
this._eventHandlers.set(eventName, new Set());
}
this._eventHandlers.get(eventName).add(f);
} catch (err) {
catchError$1('Error in RealtimeDbProvider on', err);
}
}
/**
* Unregister an event listener
* @param {string} eventName - Name of the event
* @param {Function} f - Event handler
*/
off(eventName, f) {
try {
const handlers = this._eventHandlers.get(eventName);
if (handlers) {
handlers.delete(f);
if (handlers.size ===