From 04066a56782246795444ec9dda4744079a377ea9 Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Tue, 11 Dec 2018 19:49:21 +0100 Subject: [PATCH] permission protocol + reduce circular dependencies --- README.v13.md | 7 +- bindings/prosemirror.js | 53 ++++++++++---- index.js | 13 ++-- lib/decoding.js | 16 +++++ package-lock.json | 46 ++++++------ package.json | 1 + protocols/auth.js | 33 +++++++++ .../{awarenessProtocol.js => awareness.js} | 13 +++- protocols/{syncProtocol.js => sync.js} | 58 ++------------- provider/websocket/WebSocketProvider.js | 71 +++++++++++-------- provider/websocket/server.js | 53 +++++++------- structs/Delete.js | 10 +-- structs/GC.js | 4 +- structs/Item.js | 23 ++++-- structs/ItemEmbed.js | 4 +- structs/ItemFormat.js | 4 +- structs/ItemJSON.js | 11 +-- structs/ItemString.js | 4 +- tests/helper.js | 2 +- tsconfig.json | 58 +++++++++++++++ types/YArray.js | 4 +- types/YMap.js | 4 +- types/YText.js | 4 +- types/YXmlElement.js | 4 +- utils/OperationStore.js | 16 ++--- utils/Transaction.js | 23 +----- utils/Y.js | 2 +- utils/structEncoding.js | 5 ++ utils/structStringify.js | 47 ++++++++++++ 29 files changed, 380 insertions(+), 213 deletions(-) create mode 100644 protocols/auth.js rename protocols/{awarenessProtocol.js => awareness.js} (90%) rename protocols/{syncProtocol.js => sync.js} (88%) create mode 100644 tsconfig.json create mode 100644 utils/structEncoding.js create mode 100644 utils/structStringify.js diff --git a/README.v13.md b/README.v13.md index 441e6611..217e79c0 100644 --- a/README.v13.md +++ b/README.v13.md @@ -9,10 +9,13 @@ Until [this](https://github.com/Microsoft/TypeScript/issues/7546) is fixed, the ```json { - "checkJs": true, + "compilerOptions": { + "allowJs": true, + "checkJs": true, + .. + }, "include": [ "./node_modules/yjs/" ] - .. } ``` diff --git a/bindings/prosemirror.js b/bindings/prosemirror.js index 8ff2b0a0..514d27e3 100644 --- a/bindings/prosemirror.js +++ b/bindings/prosemirror.js @@ -94,7 +94,13 @@ export const cursorPlugin = new Plugin({ const decorations = [] awareness.forEach((aw, userID) => { if (aw.cursor != null) { - const username = `User: ${userID}` + let user = aw.user || {} + if (user.color == null) { + user.color = '#ffa50070' + } + if (user.name == null) { + user.name = `User: ${userID}` + } let anchor = relativePositionToAbsolutePosition(ystate.type, aw.cursor.anchor || null, ystate.binding.mapping) let head = relativePositionToAbsolutePosition(ystate.type, aw.cursor.head || null, ystate.binding.mapping) if (anchor !== null && head !== null) { @@ -104,14 +110,16 @@ export const cursorPlugin = new Plugin({ decorations.push(Decoration.widget(head, () => { const cursor = document.createElement('span') cursor.classList.add('ProseMirror-yjs-cursor') - const user = document.createElement('div') - user.insertBefore(document.createTextNode(username), null) - cursor.insertBefore(user, null) + cursor.setAttribute('style', `border-color: ${user.color}`) + const userDiv = document.createElement('div') + userDiv.setAttribute('style', `background-color: ${user.color}`) + userDiv.insertBefore(document.createTextNode(user.name), null) + cursor.insertBefore(userDiv, null) return cursor - }, { key: username })) + }, { key: userID + '' })) const from = math.min(anchor, head) const to = math.max(anchor, head) - decorations.push(Decoration.inline(from, to, { style: 'background-color: #ffa50070' })) + decorations.push(Decoration.inline(from, to, { style: `background-color: ${user.color}` })) } } }) @@ -429,7 +437,10 @@ export const createTypeFromNode = (node, mapping) => { } else { type = new YXmlElement(node.type.name) for (let key in node.attrs) { - type.setAttribute(key, node.attrs[key]) + const val = node.attrs[key] + if (val !== null) { + type.setAttribute(key, val) + } } const ins = [] for (let i = 0; i < node.childCount; i++) { @@ -441,15 +452,25 @@ export const createTypeFromNode = (node, mapping) => { return type } +const equalAttrs = (pattrs, yattrs) => { + const keys = Object.keys(pattrs).filter(key => pattrs[key] === null) + let eq = keys.length === Object.keys(yattrs).filter(key => yattrs[key] === null).length + for (let i = 0; i < keys.length && eq; i++) { + const key = keys[i] + eq = pattrs[key] === yattrs[key] + } + return eq +} + const equalYTextPText = (ytext, ptext) => { const d = ytext.toDelta()[0] - return d.insert === ptext.text && object.keys(d.attributes || {}).length === ptext.marks.length && ptext.marks.every(mark => object.equalFlat(d.attributes[mark.type.name], mark.attrs)) + return d.insert === ptext.text && object.keys(d.attributes || {}).length === ptext.marks.length && ptext.marks.every(mark => equalAttrs(d.attributes[mark.type.name], mark.attrs)) } const equalYTypePNode = (ytype, pnode) => ytype.constructor === YText ? equalYTextPText(ytype, pnode) - : (matchNodeName(ytype, pnode) && ytype.length === pnode.childCount && object.equalFlat(ytype.getAttributes(), pnode.attrs) && ytype.toArray().every((ychild, i) => equalYTypePNode(ychild, pnode.child(i)))) + : (matchNodeName(ytype, pnode) && ytype.length === pnode.childCount && equalAttrs(ytype.getAttributes(), pnode.attrs) && ytype.toArray().every((ychild, i) => equalYTypePNode(ychild, pnode.child(i)))) const computeChildEqualityFactor = (ytype, pnode, mapping) => { const yChildren = ytype.toArray() @@ -497,13 +518,19 @@ const updateYFragment = (yDomFragment, pContent, mapping) => { // update attributes if (yDomFragment instanceof YXmlElement) { const yDomAttrs = yDomFragment.getAttributes() - for (let key in pContent.attrs) { - if (yDomAttrs[key] !== pContent.attrs[key]) { - yDomFragment.setAttribute(key, pContent.attrs[key]) + const pAttrs = pContent.attrs + for (let key in pAttrs) { + if (pAttrs[key] !== null) { + if (yDomAttrs[key] !== pAttrs[key]) { + yDomFragment.setAttribute(key, pAttrs[key]) + } + } else { + yDomFragment.removeAttribute(key) } } + // remove all keys that are no longer in pAttrs for (let key in yDomAttrs) { - if (yDomAttrs[key] === undefined) { + if (pAttrs[key] === undefined) { yDomFragment.removeAttribute(key) } } diff --git a/index.js b/index.js index b3248df2..78375df2 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ +import './structs/Item.js' import { Delete } from './structs/Delete.js' import { ItemJSON } from './structs/ItemJSON.js' import { ItemString } from './structs/ItemString.js' @@ -15,6 +16,14 @@ import { YXmlElement, YXmlFragment } from './types/YXmlElement.js' import { registerStruct } from './utils/structReferences.js' +import * as decoding from './lib/decoding.js' +import * as encoding from './lib/encoding.js' +import * as awarenessProtocol from './protocols/awareness.js' +import * as syncProtocol from './protocols/sync.js' +import * as authProtocol from './protocols/auth.js' + +export { decoding, encoding, awarenessProtocol, syncProtocol, authProtocol } + export { Y } from './utils/Y.js' export { UndoManager } from './utils/UndoManager.js' export { Transaction } from './utils/Transaction.js' @@ -28,10 +37,6 @@ export { YXmlElement as XmlElement, YXmlFragment as XmlFragment } from './types/ export { getRelativePosition, fromRelativePosition } from './utils/relativePosition.js' export { registerStruct } from './utils/structReferences.js' -export * from './protocols/syncProtocol.js' -export * from './protocols/awarenessProtocol.js' -export * from './lib/encoding.js' -export * from './lib/decoding.js' export * from './lib/mutex.js' registerStruct(0, GC) diff --git a/lib/decoding.js b/lib/decoding.js index 384577cb..4d2d03b0 100644 --- a/lib/decoding.js +++ b/lib/decoding.js @@ -148,6 +148,21 @@ export const readVarUint = decoder => { } } +/** + * Look ahead and read varUint without incrementing position + * + * @function + * @param {Decoder} decoder + * @return {number} + */ +export const peekVarUint = decoder => { + let pos = decoder.pos + let s = readVarUint(decoder) + decoder.pos = pos + return s +} + + /** * Read string of variable length * * varUint is used to store the length of the string @@ -189,3 +204,4 @@ export const peekVarString = decoder => { decoder.pos = pos return s } + diff --git a/package-lock.json b/package-lock.json index e7a204ad..82889f95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,12 +67,28 @@ "integrity": "sha512-F/v7t1LwS4vnXuPooJQGBRKRGIoxWUTmA4VHfqjOccFsNDThD5bfUNpITive6s352O7o384wcpEaDV8rHCehDA==", "dev": true }, + "@types/events": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", + "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==", + "dev": true + }, "@types/node": { "version": "6.0.110", "resolved": "https://registry.npmjs.org/@types/node/-/node-6.0.110.tgz", "integrity": "sha512-LiaH3mF+OAqR+9Wo1OTJDbZDtCewAVjTbMhF1ZgUJ3fc8xqOJq6VqbpBh9dJVCVzByGmYIg2fREbuXNX0TKiJA==", "dev": true }, + "@types/ws": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.1.tgz", + "integrity": "sha512-EzH8k1gyZ4xih/MaZTXwT2xOkPiIMSrhQ9b8wrlX88L0T02eYsddatQlwVFlEPyEqV0ChpdpNnE51QPH6NVT4Q==", + "dev": true, + "requires": { + "@types/events": "*", + "@types/node": "*" + } + }, "abab": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz", @@ -3413,14 +3429,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3435,20 +3449,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -3565,8 +3576,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -3578,7 +3588,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3593,7 +3602,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -3601,14 +3609,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -3627,7 +3633,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -3708,8 +3713,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -3721,7 +3725,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -3843,7 +3846,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5958,7 +5960,7 @@ }, "quill": { "version": "1.3.6", - "resolved": "http://registry.npmjs.org/quill/-/quill-1.3.6.tgz", + "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.6.tgz", "integrity": "sha512-K0mvhimWZN6s+9OQ249CH2IEPZ9JmkFuCQeHAOQax3EZ2nDJ3wfGh59mnlQaZV2i7u8eFarx6wAtvQKgShojug==", "dev": true, "requires": { diff --git a/package.json b/package.json index 9a34b5f0..6ef8c5cd 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ }, "homepage": "http://y-js.org", "devDependencies": { + "@types/ws": "^6.0.1", "babel-cli": "^6.26.0", "babel-plugin-external-helpers": "^6.22.0", "babel-plugin-transform-regenerator": "^6.26.0", diff --git a/protocols/auth.js b/protocols/auth.js new file mode 100644 index 00000000..9546c2b2 --- /dev/null +++ b/protocols/auth.js @@ -0,0 +1,33 @@ + +import * as encoding from '../lib/encoding.js' +import * as decoding from '../lib/decoding.js' +import { Y } from '../utils/Y.js'; + +export const messagePermissionDenied = 0 + +/** + * @param {encoding.Encoder} encoder + * @param {string} reason + */ +export const writePermissionDenied = (encoder, reason) => { + encoding.writeVarUint(encoder, messagePermissionDenied) + encoding.writeVarString(encoder, reason) +} + +/** + * @callback PermissionDeniedHandler + * @param {any} y + * @param {string} reason + */ + +/** + * + * @param {decoding.Decoder} decoder + * @param {Y} y + * @param {PermissionDeniedHandler} permissionDeniedHandler + */ +export const readAuthMessage = (decoder, y, permissionDeniedHandler) => { + switch (decoding.readVarUint(decoder)) { + case messagePermissionDenied: permissionDeniedHandler(y, decoding.readVarString(decoder)) + } +} diff --git a/protocols/awarenessProtocol.js b/protocols/awareness.js similarity index 90% rename from protocols/awarenessProtocol.js rename to protocols/awareness.js index a2ca0c80..3291d92b 100644 --- a/protocols/awarenessProtocol.js +++ b/protocols/awareness.js @@ -11,7 +11,7 @@ const messageUsersStateChanged = 0 /** * @typedef {Object} UserStateUpdate * @property {number} UserStateUpdate.userID - * @property {Object} state + * @property {Object} UserStateUpdate.state */ /** @@ -91,13 +91,22 @@ export const readAwarenessMessage = (decoder, y) => { } } +/** + * @typedef {Object} UserState + * @property {number} UserState.userID + * @property {any} UserState.state + */ + /** * @param {decoding.Decoder} decoder * @param {encoding.Encoder} encoder + * @return {Array} Array of state updates */ export const forwardAwarenessMessage = (decoder, encoder) => { + let s = [] switch (decoding.readVarUint(decoder)) { case messageUsersStateChanged: - return forwardUsersStateChange(decoder, encoder) + s = forwardUsersStateChange(decoder, encoder) } + return s } diff --git a/protocols/syncProtocol.js b/protocols/sync.js similarity index 88% rename from protocols/syncProtocol.js rename to protocols/sync.js index 4fade2ae..3f80edf2 100644 --- a/protocols/syncProtocol.js +++ b/protocols/sync.js @@ -10,6 +10,7 @@ import { deleteItemRange } from '../utils/structManipulation.js' import { integrateRemoteStruct } from '../utils/integrateRemoteStructs.js' import { Y } from '../utils/Y.js' // eslint-disable-line import { Item } from '../structs/Item.js' +import * as stringify from '../utils/structStringify.js' /** * @typedef {Map} StateSet @@ -40,9 +41,9 @@ import { Item } from '../structs/Item.js' * stringify[messageType] stringifies a message definition (messageType is already read from the bufffer) */ -const messageYjsSyncStep1 = 0 -const messageYjsSyncStep2 = 1 -const messageYjsUpdate = 2 +export const messageYjsSyncStep1 = 0 +export const messageYjsSyncStep2 = 1 +export const messageYjsUpdate = 2 /** * Stringifies a message-encoded Delete Set. @@ -234,50 +235,6 @@ export const readStateSet = decoder => { return ss } -/** - * Stringify an item id. - * - * @param {ID.ID | ID.RootID} id - * @return {string} - */ -export const stringifyID = id => id instanceof ID.ID ? `(${id.user},${id.clock})` : `(${id.name},${id.type})` - -/** - * Stringify an item as ID. HHere, an item could also be a Yjs instance (e.g. item._parent). - * - * @param {Item | Y | null} item - * @return {string} - */ -export const stringifyItemID = item => { - let result - if (item === null) { - result = '()' - } else if (item instanceof Item) { - result = stringifyID(item._id) - } else { - // must be a Yjs instance - // Don't include Y in this module, so we prevent circular dependencies. - result = 'y' - } - return result -} - -/** - * Helper utility to convert an item to a readable format. - * - * @param {String} name The name of the item class (YText, ItemString, ..). - * @param {Item} item The item instance. - * @param {String} [append] Additional information to append to the returned - * string. - * @return {String} A readable string that represents the item object. - * - */ -export const logItemHelper = (name, item, append) => { - const left = item._left !== null ? stringifyID(item._left._lastId) : '()' - const origin = item._origin !== null ? stringifyID(item._origin._lastId) : '()' - return `${name}(id:${stringifyItemID(item)},left:${left},origin:${origin},right:${stringifyItemID(item._right)},parent:${stringifyItemID(item._parent)},parentSub:${item._parentSub}${append !== undefined ? ' - ' + append : ''})` -} - /** * @param {decoding.Decoder} decoder * @param {Y} y @@ -293,7 +250,7 @@ export const stringifyStructs = (decoder, y) => { let missing = struct._fromBinary(y, decoder) let logMessage = ' ' + struct._logString() if (missing.length > 0) { - logMessage += ' .. missing: ' + missing.map(stringifyItemID).join(', ') + logMessage += ' .. missing: ' + missing.map(stringify.stringifyItemID).join(', ') } str += logMessage + '\n' } @@ -410,10 +367,9 @@ export const stringifySyncStep2 = (decoder, y) => { * Read and apply Structs and then DeleteSet to a y instance. * * @param {decoding.Decoder} decoder - * @param {encoding.Encoder} encoder * @param {Y} y */ -export const readSyncStep2 = (decoder, encoder, y) => { +export const readSyncStep2 = (decoder, y) => { readStructs(decoder, y) readDeleteSet(decoder, y) } @@ -480,7 +436,7 @@ export const readSyncMessage = (decoder, encoder, y) => { readSyncStep1(decoder, encoder, y) break case messageYjsSyncStep2: - y.transact(() => readSyncStep2(decoder, encoder, y), true) + y.transact(() => readSyncStep2(decoder, y), true) break case messageYjsUpdate: y.transact(() => readUpdate(decoder, y), true) diff --git a/provider/websocket/WebSocketProvider.js b/provider/websocket/WebSocketProvider.js index ea2cd9e1..107ba649 100644 --- a/provider/websocket/WebSocketProvider.js +++ b/provider/websocket/WebSocketProvider.js @@ -9,28 +9,37 @@ import * as bc from '../../lib/broadcastchannel.js' const messageSync = 0 const messageAwareness = 1 +const messageAuth = 2 const reconnectTimeout = 3000 +/** + * @param {WebsocketsSharedDocument} doc + * @param {string} reason + */ +const permissionDeniedHandler = (doc, reason) => console.warn(`Permission denied to access ${doc.url}.\n${reason}`) + /** * @param {WebsocketsSharedDocument} doc * @param {ArrayBuffer} buf - * @return {Y.Encoder} + * @return {Y.encoding.Encoder} */ const readMessage = (doc, buf) => { - const decoder = Y.createDecoder(buf) - const encoder = Y.createEncoder() - const messageType = Y.readVarUint(decoder) + const decoder = Y.decoding.createDecoder(buf) + const encoder = Y.encoding.createEncoder() + const messageType = Y.decoding.readVarUint(decoder) switch (messageType) { case messageSync: - Y.writeVarUint(encoder, messageSync) + Y.encoding.writeVarUint(encoder, messageSync) doc.mux(() => - Y.readSyncMessage(decoder, encoder, doc) + Y.syncProtocol.readSyncMessage(decoder, encoder, doc) ) break case messageAwareness: - Y.readAwarenessMessage(decoder, doc) + Y.awarenessProtocol.readAwarenessMessage(decoder, doc) break + case messageAuth: + Y.authProtocol.readAuthMessage(decoder, doc, permissionDeniedHandler) } return encoder } @@ -41,8 +50,8 @@ const setupWS = (doc, url) => { doc.ws = websocket websocket.onmessage = event => { const encoder = readMessage(doc, event.data) - if (Y.length(encoder) > 1) { - websocket.send(Y.toBuffer(encoder)) + if (Y.encoding.length(encoder) > 1) { + websocket.send(Y.encoding.toBuffer(encoder)) } } websocket.onclose = () => { @@ -59,10 +68,10 @@ const setupWS = (doc, url) => { status: 'disconnected' }) // always send sync step 1 when connected - const encoder = Y.createEncoder() - Y.writeVarUint(encoder, messageSync) - Y.writeSyncStep1(encoder, doc) - websocket.send(Y.toBuffer(encoder)) + const encoder = Y.encoding.createEncoder() + Y.encoding.writeVarUint(encoder, messageSync) + Y.syncProtocol.writeSyncStep1(encoder, doc) + websocket.send(Y.encoding.toBuffer(encoder)) // force send stored awareness info doc.setAwarenessField(null, null) } @@ -71,10 +80,10 @@ const setupWS = (doc, url) => { const broadcastUpdate = (y, transaction) => { if (transaction.encodedStructsLen > 0) { y.mux(() => { - const encoder = Y.createEncoder() - Y.writeVarUint(encoder, messageSync) - Y.writeUpdate(encoder, transaction.encodedStructsLen, transaction.encodedStructs) - const buf = Y.toBuffer(encoder) + const encoder = Y.encoding.createEncoder() + Y.encoding.writeVarUint(encoder, messageSync) + Y.syncProtocol.writeUpdate(encoder, transaction.encodedStructsLen, transaction.encodedStructs) + const buf = Y.encoding.toBuffer(encoder) if (y.wsconnected) { y.ws.send(buf) } @@ -95,20 +104,20 @@ class WebsocketsSharedDocument extends Y.Y { setupWS(this, url) this.on('afterTransaction', broadcastUpdate) this._bcSubscriber = data => { - const encoder = readMessage(this, data) - if (Y.length(encoder) > 1) { - this.mux(() => { - bc.publish(url, Y.toBuffer(encoder)) - }) - } + this.mux(() => { + const encoder = readMessage(this, data) + if (Y.encoding.length(encoder) > 1) { + bc.publish(url, Y.encoding.toBuffer(encoder)) + } + }) } bc.subscribe(url, this._bcSubscriber) // send sync step1 to bc this.mux(() => { - const encoder = Y.createEncoder() - Y.writeVarUint(encoder, messageSync) - Y.writeSyncStep1(encoder, this) - bc.publish(url, Y.toBuffer(encoder)) + const encoder = Y.encoding.createEncoder() + Y.encoding.writeVarUint(encoder, messageSync) + Y.syncProtocol.writeSyncStep1(encoder, this) + bc.publish(url, Y.encoding.toBuffer(encoder)) }) } getLocalAwarenessInfo () { @@ -122,10 +131,10 @@ class WebsocketsSharedDocument extends Y.Y { this._localAwarenessState[field] = value } if (this.wsconnected) { - const encoder = Y.createEncoder() - Y.writeVarUint(encoder, messageAwareness) - Y.writeUsersStateChange(encoder, [{ userID: this.userID, state: this._localAwarenessState }]) - const buf = Y.toBuffer(encoder) + const encoder = Y.encoding.createEncoder() + Y.encoding.writeVarUint(encoder, messageAwareness) + Y.awarenessProtocol.writeUsersStateChange(encoder, [{ userID: this.userID, state: this._localAwarenessState }]) + const buf = Y.encoding.toBuffer(encoder) this.ws.send(buf) } } diff --git a/provider/websocket/server.js b/provider/websocket/server.js index 6bbda267..22c8aff2 100644 --- a/provider/websocket/server.js +++ b/provider/websocket/server.js @@ -19,13 +19,14 @@ const docs = new Map() const messageSync = 0 const messageAwareness = 1 +const messageAuth = 2 const afterTransaction = (doc, transaction) => { if (transaction.encodedStructsLen > 0) { - const encoder = Y.createEncoder() - Y.writeVarUint(encoder, messageSync) - Y.writeUpdate(encoder, transaction.encodedStructsLen, transaction.encodedStructs) - const message = Y.toBuffer(encoder) + const encoder = Y.encoding.createEncoder() + Y.encoding.writeVarUint(encoder, messageSync) + Y.syncProtocol.writeUpdate(encoder, transaction.encodedStructsLen, transaction.encodedStructs) + const message = Y.encoding.toBuffer(encoder) doc.conns.forEach((_, conn) => conn.send(message)) } } @@ -45,25 +46,25 @@ class WSSharedDoc extends Y.Y { } const messageListener = (conn, doc, message) => { - const encoder = Y.createEncoder() - const decoder = Y.createDecoder(message) - const messageType = Y.readVarUint(decoder) + const encoder = Y.encoding.createEncoder() + const decoder = Y.decoding.createDecoder(message) + const messageType = Y.decoding.readVarUint(decoder) switch (messageType) { case messageSync: - Y.writeVarUint(encoder, messageSync) - Y.readSyncMessage(decoder, encoder, doc) - if (Y.length(encoder) > 1) { - conn.send(Y.toBuffer(encoder)) + Y.encoding.writeVarUint(encoder, messageSync) + Y.syncProtocol.readSyncMessage(decoder, encoder, doc) + if (Y.encoding.length(encoder) > 1) { + conn.send(Y.encoding.toBuffer(encoder)) } break case messageAwareness: { - Y.writeVarUint(encoder, messageAwareness) - const updates = Y.forwardAwarenessMessage(decoder, encoder) + Y.encoding.writeVarUint(encoder, messageAwareness) + const updates = Y.awarenessProtocol.forwardAwarenessMessage(decoder, encoder) updates.forEach(update => { doc.awareness.set(update.userID, update.state) doc.conns.get(conn).add(update.userID) }) - const buff = Y.toBuffer(encoder) + const buff = Y.encoding.toBuffer(encoder) doc.conns.forEach((_, c) => { c.send(buff) }) @@ -86,29 +87,29 @@ const setupConnection = (conn, req) => { conn.on('close', () => { const controlledIds = doc.conns.get(conn) doc.conns.delete(conn) - const encoder = Y.createEncoder() - Y.writeVarUint(encoder, messageAwareness) - Y.writeUsersStateChange(encoder, Array.from(controlledIds).map(userID => { + const encoder = Y.encoding.createEncoder() + Y.encoding.writeVarUint(encoder, messageAwareness) + Y.awarenessProtocol.writeUsersStateChange(encoder, Array.from(controlledIds).map(userID => { doc.awareness.delete(userID) return { userID, state: null } })) - const buf = Y.toBuffer(encoder) + const buf = Y.encoding.toBuffer(encoder) doc.conns.forEach((_, conn) => conn.send(buf)) }) // send sync step 1 - const encoder = Y.createEncoder() - Y.writeVarUint(encoder, messageSync) - Y.writeSyncStep1(encoder, doc) - conn.send(Y.toBuffer(encoder)) + const encoder = Y.encoding.createEncoder() + Y.encoding.writeVarUint(encoder, messageSync) + Y.syncProtocol.writeSyncStep1(encoder, doc) + conn.send(Y.encoding.toBuffer(encoder)) if (doc.awareness.size > 0) { - const encoder = Y.createEncoder() + const encoder = Y.encoding.createEncoder() const userStates = [] doc.awareness.forEach((state, userID) => { userStates.push({ state, userID }) }) - Y.writeVarUint(encoder, messageAwareness) - Y.writeUsersStateChange(encoder, userStates) - conn.send(Y.toBuffer(encoder)) + Y.encoding.writeVarUint(encoder, messageAwareness) + Y.awarenessProtocol.writeUsersStateChange(encoder, userStates) + conn.send(Y.encoding.toBuffer(encoder)) } } diff --git a/structs/Delete.js b/structs/Delete.js index 3e6d9a3f..16724b81 100644 --- a/structs/Delete.js +++ b/structs/Delete.js @@ -4,13 +4,13 @@ import { getStructReference } from '../utils/structReferences.js' import * as ID from '../utils/ID.js' -import { stringifyID } from '../protocols/syncProtocol.js' -import { writeStructToTransaction } from '../utils/Transaction.js' +import { writeStructToTransaction } from '../utils/structEncoding.js' import * as decoding from '../lib/decoding.js' import * as encoding from '../lib/encoding.js' -import { Item } from './Item.js' // eslint-disable-line -import { Y } from '../utils/Y.js' // eslint-disable-line +// import { Item } from './Item.js' // eslint-disable-line +// import { Y } from '../utils/Y.js' // eslint-disable-line import { deleteItemRange } from '../utils/structManipulation.js' +import * as stringify from '../utils/structStringify.js' /** * @private @@ -99,6 +99,6 @@ export class Delete { * @private */ _logString () { - return `Delete - target: ${stringifyID(this._targetID)}, len: ${this._length}` + return `Delete - target: ${stringify.stringifyID(this._targetID)}, len: ${this._length}` } } diff --git a/structs/GC.js b/structs/GC.js index 969fe33c..1abf7411 100644 --- a/structs/GC.js +++ b/structs/GC.js @@ -4,10 +4,10 @@ import { getStructReference } from '../utils/structReferences.js' import * as ID from '../utils/ID.js' -import { writeStructToTransaction } from '../utils/Transaction.js' +import { writeStructToTransaction } from '../utils/structEncoding.js' import * as decoding from '../lib/decoding.js' import * as encoding from '../lib/encoding.js' -import { Y } from '../utils/Y.js' // eslint-disable-line +// import { Y } from '../utils/Y.js' // eslint-disable-line // TODO should have the same base class as Item export class GC { diff --git a/structs/Item.js b/structs/Item.js index d704cffb..2c034c52 100644 --- a/structs/Item.js +++ b/structs/Item.js @@ -5,12 +5,27 @@ import { getStructReference } from '../utils/structReferences.js' import * as ID from '../utils/ID.js' import { Delete } from './Delete.js' -import { transactionTypeChanged, writeStructToTransaction } from '../utils/Transaction.js' +import { writeStructToTransaction } from '../utils/structEncoding.js' import { GC } from './GC.js' import * as encoding from '../lib/encoding.js' import * as decoding from '../lib/decoding.js' -import { Y } from '../utils/Y.js' -import { Type } from './Type.js' // eslint-disable-line +// import { Type } from './Type.js' // eslint-disable-line + +/** + * @private + */ +export const transactionTypeChanged = (y, type, sub) => { + if (type !== y && !type._deleted && !y._transaction.newTypes.has(type)) { + const changedTypes = y._transaction.changedTypes + let subs = changedTypes.get(type) + if (subs === undefined) { + // create if it doesn't exist yet + subs = new Set() + changedTypes.set(type, subs) + } + subs.add(sub) + } +} /** * @private @@ -159,7 +174,7 @@ export class Item { if (this._redone !== null) { return this._redone } - if (this._parent instanceof Y) { + if (!(this._parent instanceof Item)) { return } let struct = this._copy() diff --git a/structs/ItemEmbed.js b/structs/ItemEmbed.js index 52974acd..e7f008a4 100644 --- a/structs/ItemEmbed.js +++ b/structs/ItemEmbed.js @@ -3,7 +3,7 @@ */ import { Item } from './Item.js' -import { logItemHelper } from '../protocols/syncProtocol.js' +import * as stringify from '../utils/structStringify.js' import * as encoding from '../lib/encoding.js' import * as decoding from '../lib/decoding.js' import { Y } from '../utils/Y.js' // eslint-disable-line @@ -44,6 +44,6 @@ export class ItemEmbed extends Item { * @private */ _logString () { - return logItemHelper('ItemEmbed', this, `embed:${JSON.stringify(this.embed)}`) + return stringify.logItemHelper('ItemEmbed', this, `embed:${JSON.stringify(this.embed)}`) } } diff --git a/structs/ItemFormat.js b/structs/ItemFormat.js index f002c7bb..39df91eb 100644 --- a/structs/ItemFormat.js +++ b/structs/ItemFormat.js @@ -3,7 +3,7 @@ */ import { Item } from './Item.js' -import { logItemHelper } from '../protocols/syncProtocol.js' +import * as stringify from '../utils/structStringify.js' import * as encoding from '../lib/encoding.js' import * as decoding from '../lib/decoding.js' import { Y } from '../utils/Y.js' // eslint-disable-line @@ -51,6 +51,6 @@ export class ItemFormat extends Item { * @private */ _logString () { - return logItemHelper('ItemFormat', this, `key:${JSON.stringify(this.key)},value:${JSON.stringify(this.value)}`) + return stringify.logItemHelper('ItemFormat', this, `key:${JSON.stringify(this.key)},value:${JSON.stringify(this.value)}`) } } diff --git a/structs/ItemJSON.js b/structs/ItemJSON.js index 1d5036a8..e85cf892 100644 --- a/structs/ItemJSON.js +++ b/structs/ItemJSON.js @@ -3,7 +3,7 @@ */ import { Item, splitHelper } from './Item.js' -import { logItemHelper } from '../protocols/syncProtocol.js' +import * as stringify from '../utils/structStringify.js' import * as encoding from '../lib/encoding.js' import * as decoding from '../lib/decoding.js' import { Y } from '../utils/Y.js' // eslint-disable-line @@ -19,7 +19,8 @@ export class ItemJSON extends Item { return struct } get _length () { - return this._content.length + const c = this._content + return c !== null ? c.length : 0 } /** * @param {Y} y @@ -46,11 +47,11 @@ export class ItemJSON extends Item { */ _toBinary (encoder) { super._toBinary(encoder) - let len = this._content.length + const len = this._length encoding.writeVarUint(encoder, len) for (let i = 0; i < len; i++) { let encoded - let content = this._content[i] + const content = this._content[i] if (content === undefined) { encoded = 'undefined' } else { @@ -66,7 +67,7 @@ export class ItemJSON extends Item { * @private */ _logString () { - return logItemHelper('ItemJSON', this, `content:${JSON.stringify(this._content)}`) + return stringify.logItemHelper('ItemJSON', this, `content:${JSON.stringify(this._content)}`) } _splitAt (y, diff) { if (diff === 0) { diff --git a/structs/ItemString.js b/structs/ItemString.js index ed6a0b1f..b10f0566 100644 --- a/structs/ItemString.js +++ b/structs/ItemString.js @@ -3,7 +3,7 @@ */ import { Item, splitHelper } from './Item.js' -import { logItemHelper } from '../protocols/syncProtocol.js' +import * as stringify from '../utils/structStringify.js' import * as encoding from '../lib/encoding.js' import * as decoding from '../lib/decoding.js' import { Y } from '../utils/Y.js' // eslint-disable-line @@ -44,7 +44,7 @@ export class ItemString extends Item { * @private */ _logString () { - return logItemHelper('ItemString', this, `content:"${this._content}"`) + return stringify.logItemHelper('ItemString', this, `content:"${this._content}"`) } _splitAt (y, diff) { if (diff === 0) { diff --git a/tests/helper.js b/tests/helper.js index 1a864c11..2db8397c 100644 --- a/tests/helper.js +++ b/tests/helper.js @@ -6,7 +6,7 @@ import { defragmentItemContent } from '../utils/defragmentItemContent.js' import Quill from 'quill' import { GC } from '../structs/GC.js' import * as random from '../lib/prng/prng.js' -import * as syncProtocol from '../protocols/syncProtocol.js' +import * as syncProtocol from '../protocols/sync.js' import * as encoding from '../lib/encoding.js' import * as decoding from '../lib/decoding.js' import { createMutex } from '../lib/mutex.js' diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..f16a4aed --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,58 @@ +{ + "compilerOptions": { + /* Basic Options */ + "target": "es2018", + "lib": ["es2018", "dom"], /* Specify library files to be included in the compilation. */ + "allowJs": true, /* Allow javascript files to be compiled. */ + "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + // "outDir": "./build", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "removeComments": true, /* Do not emit comments to output. */ + "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + + /* Module Resolution Options */ + "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + } +} diff --git a/types/YArray.js b/types/YArray.js index 3fb196b0..82dc2a3d 100644 --- a/types/YArray.js +++ b/types/YArray.js @@ -5,7 +5,7 @@ import { Type } from '../structs/Type.js' import { ItemJSON } from '../structs/ItemJSON.js' import { ItemString } from '../structs/ItemString.js' -import { stringifyItemID, logItemHelper } from '../protocols/syncProtocol.js' +import * as stringify from '../utils/structStringify.js' import { YEvent } from '../utils/YEvent.js' import { Transaction } from '../utils/Transaction.js' // eslint-disable-line import { Item } from '../structs/Item.js' // eslint-disable-line @@ -368,6 +368,6 @@ export class YArray extends Type { * @private */ _logString () { - return logItemHelper('YArray', this, `start:${stringifyItemID(this._start)}"`) + return stringify.logItemHelper('YArray', this, `start:${stringify.stringifyItemID(this._start)}"`) } } diff --git a/types/YMap.js b/types/YMap.js index 50aaa41e..813d8154 100644 --- a/types/YMap.js +++ b/types/YMap.js @@ -5,7 +5,7 @@ import { Item } from '../structs/Item.js' import { Type } from '../structs/Type.js' import { ItemJSON } from '../structs/ItemJSON.js' -import { logItemHelper } from '../protocols/syncProtocol.js' +import * as stringify from '../utils/structStringify.js' import { YEvent } from '../utils/YEvent.js' /** @@ -175,6 +175,6 @@ export class YMap extends Type { * @private */ _logString () { - return logItemHelper('YMap', this, `mapSize:${this._map.size}`) + return stringify.logItemHelper('YMap', this, `mapSize:${this._map.size}`) } } diff --git a/types/YText.js b/types/YText.js index ab321707..b1bc160c 100644 --- a/types/YText.js +++ b/types/YText.js @@ -5,7 +5,7 @@ import { ItemEmbed } from '../structs/ItemEmbed.js' import { ItemString } from '../structs/ItemString.js' import { ItemFormat } from '../structs/ItemFormat.js' -import { logItemHelper } from '../protocols/syncProtocol.js' +import * as stringify from '../utils/structStringify.js' import { YArrayEvent, YArray } from './YArray.js' /** @@ -702,6 +702,6 @@ export class YText extends YArray { * @private */ _logString () { - return logItemHelper('YText', this) + return stringify.logItemHelper('YText', this) } } diff --git a/types/YXmlElement.js b/types/YXmlElement.js index ad8de404..0111b915 100644 --- a/types/YXmlElement.js +++ b/types/YXmlElement.js @@ -11,7 +11,7 @@ import { DomBinding } from '../bindings/dom/DomBinding.js' // eslint-disable-lin import { YXmlTreeWalker } from './YXmlTreeWalker.js' import { YArray } from './YArray.js' import { YXmlEvent } from './YXmlEvent.js' -import { logItemHelper } from '../protocols/syncProtocol.js' +import * as stringify from '../utils/structStringify.js' /** * Dom filter function. @@ -164,7 +164,7 @@ export class YXmlFragment extends YArray { * @private */ _logString () { - return logItemHelper('YXml', this) + return stringify.logItemHelper('YXml', this) } } diff --git a/utils/OperationStore.js b/utils/OperationStore.js index d473dd19..b6a77165 100644 --- a/utils/OperationStore.js +++ b/utils/OperationStore.js @@ -5,8 +5,8 @@ import { Tree } from '../lib/Tree.js' import * as ID from '../utils/ID.js' import { getStruct } from '../utils/structReferences.js' -import { stringifyID, stringifyItemID } from '../protocols/syncProtocol.js' import { GC } from '../structs/GC.js' +import * as stringify from '../utils/structStringify.js' export class OperationStore extends Tree { constructor (y) { @@ -18,18 +18,18 @@ export class OperationStore extends Tree { this.iterate(null, null, item => { if (item.constructor === GC) { items.push({ - id: stringifyItemID(item), + id: stringify.stringifyItemID(item), content: item._length, deleted: 'GC' }) } else { items.push({ - id: stringifyItemID(item), - origin: item._origin === null ? '()' : stringifyID(item._origin._lastId), - left: item._left === null ? '()' : stringifyID(item._left._lastId), - right: stringifyItemID(item._right), - right_origin: stringifyItemID(item._right_origin), - parent: stringifyItemID(item._parent), + id: stringify.stringifyItemID(item), + origin: item._origin === null ? '()' : stringify.stringifyID(item._origin._lastId), + left: item._left === null ? '()' : stringify.stringifyID(item._left._lastId), + right: stringify.stringifyItemID(item._right), + right_origin: stringify.stringifyItemID(item._right_origin), + parent: stringify.stringifyItemID(item._parent), parentSub: item._parentSub, deleted: item._deleted, content: JSON.stringify(item._content) diff --git a/utils/Transaction.js b/utils/Transaction.js index ce34d5f0..83a0b7e1 100644 --- a/utils/Transaction.js +++ b/utils/Transaction.js @@ -4,8 +4,8 @@ import * as encoding from '../lib/encoding.js' import { Y } from '../utils/Y.js' // eslint-disable-line -import { Type } from '../structs/Type.js' // eslint-disable-line import { Item } from '../structs/Item.js' // eslint-disable-line +import { Type } from '../structs/Type.js' // eslint-disable-line import { YEvent } from './YEvent.js' // eslint-disable-line /** * A transaction is created for every change on the Yjs model. It is possible @@ -70,24 +70,3 @@ export class Transaction { this.encodedStructs = encoding.createEncoder() } } - -export const writeStructToTransaction = (transaction, struct) => { - transaction.encodedStructsLen++ - struct._toBinary(transaction.encodedStructs) -} - -/** - * @private - */ -export const transactionTypeChanged = (y, type, sub) => { - if (type !== y && !type._deleted && !y._transaction.newTypes.has(type)) { - const changedTypes = y._transaction.changedTypes - let subs = changedTypes.get(type) - if (subs === undefined) { - // create if it doesn't exist yet - subs = new Set() - changedTypes.set(type, subs) - } - subs.add(sub) - } -} diff --git a/utils/Y.js b/utils/Y.js index 2b7d9b91..522079ee 100644 --- a/utils/Y.js +++ b/utils/Y.js @@ -6,7 +6,7 @@ import { createRootID } from './ID.js' import { NamedEventHandler } from '../lib/NamedEventHandler.js' import { Transaction } from './Transaction.js' import * as encoding from '../lib/encoding.js' -import * as message from '../protocols/syncProtocol.js' +import * as message from '../protocols/sync.js' import { integrateRemoteStructs } from './integrateRemoteStructs.js' import { Type } from '../structs/Type.js' // eslint-disable-line import { Decoder } from '../lib/decoding.js' // eslint-disable-line diff --git a/utils/structEncoding.js b/utils/structEncoding.js new file mode 100644 index 00000000..9e7e23ae --- /dev/null +++ b/utils/structEncoding.js @@ -0,0 +1,5 @@ + +export const writeStructToTransaction = (transaction, struct) => { + transaction.encodedStructsLen++ + struct._toBinary(transaction.encodedStructs) +} \ No newline at end of file diff --git a/utils/structStringify.js b/utils/structStringify.js new file mode 100644 index 00000000..14d0ae8e --- /dev/null +++ b/utils/structStringify.js @@ -0,0 +1,47 @@ + +import * as ID from './ID.js' + +/** + * Stringify an item id. + * + * @param {ID.ID | ID.RootID} id + * @return {string} + */ +export const stringifyID = id => id instanceof ID.ID ? `(${id.user},${id.clock})` : `(${id.name},${id.type})` + +/** + * Stringify an item as ID. HHere, an item could also be a Yjs instance (e.g. item._parent). + * + * @param {Item | Y | null} item + * @return {string} + */ +export const stringifyItemID = item => { + let result + if (item === null) { + result = '()' + } else if (item._id != null) { + result = stringifyID(item._id) + } else { + // must be a Yjs instance + // Don't include Y in this module, so we prevent circular dependencies. + result = 'y' + } + return result +} + + +/** + * Helper utility to convert an item to a readable format. + * + * @param {String} name The name of the item class (YText, ItemString, ..). + * @param {Item} item The item instance. + * @param {String} [append] Additional information to append to the returned + * string. + * @return {String} A readable string that represents the item object. + * + */ +export const logItemHelper = (name, item, append) => { + const left = item._left !== null ? stringifyID(item._left._lastId) : '()' + const origin = item._origin !== null ? stringifyID(item._origin._lastId) : '()' + return `${name}(id:${stringifyItemID(item)},left:${left},origin:${origin},right:${stringifyItemID(item._right)},parent:${stringifyItemID(item._parent)},parentSub:${item._parentSub}${append !== undefined ? ' - ' + append : ''})` +}