Merge pull request #1 from hiveteams/fix/old-state-error

Fix/old state error
This commit is contained in:
emaciel10 2021-01-15 17:16:50 -05:00 committed by GitHub
commit ff417d59fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1494 additions and 653 deletions

View File

@ -2,7 +2,54 @@
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.7.16](https://github.com/hiveteams/slate-collaborative/compare/v0.7.16...v0.7.16) #### [v0.7.23](https://github.com/hiveteams/slate-collaborative/compare/v0.7.22...v0.7.23)
- fix: remove node and remove text errors [`eb6f396`](https://github.com/hiveteams/slate-collaborative/commit/eb6f39679b9b4f9dfb652f25ffd0429abcde5b0c)
- fix: slateOps undefined on toSlateOp error [`89dafd7`](https://github.com/hiveteams/slate-collaborative/commit/89dafd78b989227b3f02ab77c2deb73d23e4cd88)
#### [v0.7.22](https://github.com/hiveteams/slate-collaborative/compare/v0.7.21...v0.7.22)
> 12 January 2021
- feat: better client side error handling [`d4b089a`](https://github.com/hiveteams/slate-collaborative/commit/d4b089a4805fd25347764948b287c9f5802f2ead)
- feat: update backend error handling [`c29df0d`](https://github.com/hiveteams/slate-collaborative/commit/c29df0d8e72349edd5893a1ab8640745ac2acc4e)
#### [v0.7.21](https://github.com/hiveteams/slate-collaborative/compare/v0.7.20...v0.7.21)
> 8 January 2021
- fix: tsconfigs for packages [`f6d3af8`](https://github.com/hiveteams/slate-collaborative/commit/f6d3af83c95ff825f8a7fcaf4daef0afba3c155a)
#### [v0.7.20](https://github.com/hiveteams/slate-collaborative/compare/v0.7.20-alpha.1...v0.7.20)
> 8 January 2021
- feat: cleaner error handling and basic client tests [`f425635`](https://github.com/hiveteams/slate-collaborative/commit/f425635cf849718aa863402ec6450f9a35353fbb)
- fix: make onError optional [`2faeda1`](https://github.com/hiveteams/slate-collaborative/commit/2faeda163b955341d2d5c066158973939fe0baad)
#### [v0.7.20-alpha.1](https://github.com/hiveteams/slate-collaborative/compare/v0.7.20-alpha.0...v0.7.20-alpha.1)
> 6 January 2021
- feat: automerge collaboration to backend [`0894a7a`](https://github.com/hiveteams/slate-collaborative/commit/0894a7a917949e3ef08a5cd9533258a59b7a3486)
#### [v0.7.20-alpha.0](https://github.com/hiveteams/slate-collaborative/compare/v0.7.19...v0.7.20-alpha.0)
> 5 January 2021
- fix: automerge backend import [`2f05bce`](https://github.com/hiveteams/slate-collaborative/commit/2f05bced2bb3f89df829408ecb146fbc51004bd0)
#### [v0.7.19](https://github.com/hiveteams/slate-collaborative/compare/v0.7.17...v0.7.19)
> 5 January 2021
- fix: old state passed to connection error [`2b8206d`](https://github.com/hiveteams/slate-collaborative/commit/2b8206d1c574ec82b3d1687515d1e4db8652573b)
- fix: package json and version [`217864d`](https://github.com/hiveteams/slate-collaborative/commit/217864dc69613ae6467a14f68cc5c01015dd5000)
- fix: bump package version [`d7e0151`](https://github.com/hiveteams/slate-collaborative/commit/d7e01518a8f3972369424a3143fadc21158940ba)
#### [v0.7.17](https://github.com/hiveteams/slate-collaborative/compare/v0.7.16...v0.7.17)
> 2 November 2020
- fix: close connection error [`31a14e2`](https://github.com/hiveteams/slate-collaborative/commit/31a14e2a3519ea3076071a4d8fdfd48b26bb3d34) - fix: close connection error [`31a14e2`](https://github.com/hiveteams/slate-collaborative/commit/31a14e2a3519ea3076071a4d8fdfd48b26bb3d34)

View File

@ -48,9 +48,9 @@ const decorator = useCursor(editor)
## Backend ## Backend
```ts ```ts
const { SocketIOConnection } = require('@hiveteams/collab-backend') const { AutomergeCollaboration } = require('@hiveteams/collab-backend')
const connection = new SocketIOConnection(options) const collabBackend = new AutomergeCollaboration(options)
``` ```
### options: ### options:

View File

@ -1,6 +1,6 @@
{ {
"lerna": "2.7.1", "lerna": "2.7.1",
"version": "0.7.17", "version": "0.7.23",
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true "useWorkspaces": true
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@hiveteams/collab-backend", "name": "@hiveteams/collab-backend",
"version": "0.7.16", "version": "0.7.23",
"files": [ "files": [
"lib" "lib"
], ],
@ -26,7 +26,7 @@
"dependencies": { "dependencies": {
"@babel/plugin-proposal-optional-chaining": "^7.9.0", "@babel/plugin-proposal-optional-chaining": "^7.9.0",
"@babel/runtime": "^7.6.3", "@babel/runtime": "^7.6.3",
"@hiveteams/collab-bridge": "^0.7.16", "@hiveteams/collab-bridge": "^0.7.23",
"@types/debug": "^4.1.5", "@types/debug": "^4.1.5",
"@types/lodash": "^4.14.150", "@types/lodash": "^4.14.150",
"@types/socket.io": "^2.1.4", "@types/socket.io": "^2.1.4",

View File

@ -8,7 +8,7 @@ import {
SyncDoc, SyncDoc,
CollabAction CollabAction
} from '@hiveteams/collab-bridge' } from '@hiveteams/collab-bridge'
import { debugCollabBackend } from 'utils/debug' import { debugCollabBackend } from './utils/debug'
/** /**
* AutomergeBackend contains collaboration with Automerge * AutomergeBackend contains collaboration with Automerge
@ -45,7 +45,7 @@ class AutomergeBackend {
* Start Automerge Connection * Start Automerge Connection
*/ */
openConnection = (id: string) => this.connectionMap[id].open() openConnection = (id: string) => this.connectionMap[id]?.open()
/** /**
* Close Automerge Connection and remove it from connections * Close Automerge Connection and remove it from connections
@ -53,7 +53,6 @@ class AutomergeBackend {
closeConnection(id: string) { closeConnection(id: string) {
this.connectionMap[id]?.close() this.connectionMap[id]?.close()
delete this.connectionMap[id] delete this.connectionMap[id]
} }
@ -62,16 +61,12 @@ class AutomergeBackend {
*/ */
receiveOperation = (id: string, data: CollabAction) => { receiveOperation = (id: string, data: CollabAction) => {
try { if (!this.connectionMap[id]) {
if (!this.connectionMap[id]) { debugCollabBackend('Could not receive op for closed connection %s', id)
debugCollabBackend('Could not receive op for closed connection %s', id) return
return
}
this.connectionMap[id].receiveMsg(data.payload)
} catch (e) {
console.error('Unexpected error in receiveOperation', e)
} }
this.connectionMap[id].receiveMsg(data.payload)
} }
/** /**
@ -85,22 +80,15 @@ class AutomergeBackend {
*/ */
appendDocument = (docId: string, data: Node[]) => { appendDocument = (docId: string, data: Node[]) => {
try { if (this.getDocument(docId)) {
if (this.getDocument(docId)) { throw new Error(`Already has document with id: ${docId}`)
throw new Error(`Already has document with id: ${docId}`)
}
const sync = toSync({ cursors: {}, children: data })
const doc = Automerge.from<SyncDoc>(sync)
if (!this.documentSetMap[docId]) {
this.documentSetMap[docId] = new Automerge.DocSet<SyncDoc>()
}
this.documentSetMap[docId].setDoc(docId, doc)
} catch (e) {
console.error(e, docId)
} }
const sync = toSync({ cursors: {}, children: data })
const doc = Automerge.from<SyncDoc>(sync)
this.documentSetMap[docId] = new Automerge.DocSet<SyncDoc>()
this.documentSetMap[docId].setDoc(docId, doc)
} }
/** /**
@ -108,10 +96,8 @@ class AutomergeBackend {
*/ */
removeDocument = (docId: string) => { removeDocument = (docId: string) => {
if (this.documentSetMap[docId]) { this.documentSetMap[docId]?.removeDoc(docId)
this.documentSetMap[docId].removeDoc(docId) delete this.documentSetMap[docId]
delete this.documentSetMap[docId]
}
} }
/** /**
@ -119,19 +105,16 @@ class AutomergeBackend {
*/ */
garbageCursor = (docId: string, id: string) => { garbageCursor = (docId: string, id: string) => {
try { const doc = this.getDocument(docId)
const doc = this.getDocument(docId)
if (!doc || !doc.cursors) return // no need to delete cursor if the document or cursors have already been deleted
if (!doc || !doc.cursors) return
const change = Automerge.change(doc, (d: any) => { const change = Automerge.change(doc, (d: any) => {
delete d.cursors[id] delete d.cursors[id]
}) })
this.documentSetMap[docId].setDoc(docId, change) this.documentSetMap[docId].setDoc(docId, change)
} catch (e) {
console.error('Unexpected error in garbageCursor', e)
}
} }
} }

View File

@ -0,0 +1,344 @@
import io, { Socket } from 'socket.io'
import * as Automerge from 'automerge'
import { Node } from 'slate'
import { Server } from 'http'
import throttle from 'lodash/throttle'
import { SyncDoc, CollabAction, toJS } from '@hiveteams/collab-bridge'
import { debugCollabBackend } from './utils/debug'
import AutomergeBackend from './AutomergeBackend'
export interface IAutomergeCollaborationOptions {
entry: Server
connectOpts?: SocketIO.ServerOptions
defaultValue: Node[]
saveFrequency?: number
onAuthRequest?: (query: any, socket?: SocketIO.Socket) => Promise<any>
onDocumentLoad?: (docId: string, query?: any) => Promise<Node[]> | Node[]
onDocumentSave?: (
docId: string,
doc: Node[],
user: any
) => Promise<void> | void
onDisconnect?: (docId: string, user: any) => Promise<void> | void
onError?: (error: Error, data: any) => Promise<void> | void
}
export default class AutomergeCollaboration {
private io: SocketIO.Server
private options: IAutomergeCollaborationOptions
public backend: AutomergeBackend
private userMap: { [key: string]: any | undefined }
private autoSaveDoc: (socket: SocketIO.Socket, docId: string) => void
/**
* Constructor
*/
constructor(options: IAutomergeCollaborationOptions) {
this.io = io(options.entry, options.connectOpts)
this.backend = new AutomergeBackend()
this.options = options
this.configure()
this.userMap = {}
/**
* Save document with throttle
*/
this.autoSaveDoc = throttle(
async (socket: SocketIO.Socket, docId: string) =>
this.backend.getDocument(docId) && this.saveDocument(socket, docId),
this.options?.saveFrequency || 2000
)
return this
}
/**
* Initial IO configuration
*/
private configure = () =>
this.io
.of(this.nspMiddleware)
.use(this.authMiddleware)
.on('connect', this.onConnect)
/**
* Construct error data and call onError callback
*/
private handleError(socket: SocketIO.Socket, err: Error, data: any = {}) {
const { id } = socket
const { name: docId } = socket.nsp
if (this.options.onError) {
const document = this.backend.getDocument(docId)
this.options.onError(err, {
user: this.userMap[id],
docId,
automergeDocument: document ? Automerge.save(document) : null,
...data
})
}
}
/**
* Namespace SocketIO middleware. Load document value and append it to CollaborationBackend.
*/
private nspMiddleware = async (path: string, query: any, next: any) => {
return next(null, true)
}
/**
* SocketIO auth middleware. Used for user authentification.
*/
private authMiddleware = async (
socket: SocketIO.Socket,
next: (e?: any) => void
) => {
const { query } = socket.handshake
const { onAuthRequest } = this.options
// we connect before any async logic so that we
// never miss a socket disconnection event
socket.on('disconnect', this.onDisconnect(socket))
if (onAuthRequest) {
const user = await onAuthRequest(query, socket)
if (!user) return next(new Error(`Authentification error: ${socket.id}`))
this.userMap[socket.id] = user
}
return next()
}
/**
* On 'connect' handler.
*/
private onConnect = async (socket: SocketIO.Socket) => {
const { id, conn } = socket
// do nothing if the socket connection has already been closed
if (conn.readyState === 'closed') {
return
}
const { name: docId } = socket.nsp
const { onDocumentLoad } = this.options
try {
// Load document if no document state is already stored in our automerge backend
if (!this.backend.getDocument(docId)) {
// If the user provided an onDocumentLoad function use that, otherwise use the
// default value that was provided in the options
const doc = onDocumentLoad
? await onDocumentLoad(docId)
: this.options.defaultValue
// Ensure socket is still opened
// recheck websocket connection state after the previous potentially async document load
if (conn.readyState === 'closed') {
return
}
// recheck backend getDocument after async operation
// to avoid duplicatively loading a document
if (!this.backend.getDocument(docId)) {
debugCollabBackend('Append document %s', id)
this.backend.appendDocument(docId, doc)
}
}
// Create a new backend connection for this socketId and docId
debugCollabBackend('Create connection %s', id)
this.backend.createConnection(
id,
docId,
({ type, payload }: CollabAction) => {
if (payload.docId === docId) {
socket.emit('msg', { type, payload: { id: conn.id, ...payload } })
}
}
)
// Setup the on message callback
socket.on('msg', this.onMessage(socket, docId))
const doc = this.backend.getDocument(docId)
if (!doc) {
debugCollabBackend(
'onConnect: No document available at the time of socket.io join docId=%s socketId=%s',
docId,
id
)
return
}
// Emit the socket message needed for receiving the automerge document
// on connect and reconnect
socket.emit('msg', {
type: 'document',
payload: Automerge.save<SyncDoc>(doc)
})
debugCollabBackend('Open connection %s', id)
this.backend.openConnection(id)
this.garbageCursors(socket)
} catch (err) {
this.handleError(socket, err)
}
}
/**
* On 'message' handler
*/
private onMessage = (socket: SocketIO.Socket, docId: string) => (
data: any
) => {
const { id } = socket
switch (data.type) {
case 'operation':
try {
this.backend.receiveOperation(id, data)
this.autoSaveDoc(socket, docId)
this.garbageCursors(socket)
} catch (err) {
this.handleError(socket, err, { onMessageData: data })
}
}
}
/**
* Save document
*/
private saveDocument = async (socket: SocketIO.Socket, docId: string) => {
try {
const { id } = socket
const { onDocumentSave } = this.options
const doc = this.backend.getDocument(docId)
// Return early if there is no valid document in our crdt backend
// Note: this will happen when user disconnects from the collab server
// before document load has completed
if (!doc) {
return
}
const user = this.userMap[id]
if (onDocumentSave && user) {
await onDocumentSave(docId, toJS(doc.children), user)
}
} catch (err) {
this.handleError(socket, err)
}
}
/**
* On 'disconnect' handler
*/
private onDisconnect = (socket: SocketIO.Socket) => async () => {
try {
const { id } = socket
const { name: docId } = socket.nsp
// promises for the cleanup operations so that we
// perform all the necessary cleanup here synchronously
const cleanupPromises: (Promise<void> | void)[] = []
cleanupPromises.push(this.saveDocument(socket, docId))
// Note not sure if both of these are necessary
socket.leave(id)
// close automerge connection
debugCollabBackend('Connection closed %s', id)
this.backend.closeConnection(id)
// cleanup cursors and namespace for socket
this.garbageCursors(socket)
this.garbageNsp(socket)
// grab current user and cleanup the user map
const user = this.userMap[id]
delete this.userMap[id]
// trigger onDisconnect callback if one was provided
// and if a user has been loaded for this socket connection
if (this.options.onDisconnect && user) {
cleanupPromises.push(this.options.onDisconnect(docId, user))
}
await Promise.all(cleanupPromises)
} catch (err) {
this.handleError(socket, err)
}
}
/**
* Clean up unused SocketIO namespaces.
*/
garbageNsp = (socket: SocketIO.Socket) => {
const { name: docId } = socket.nsp
// This is the only way to synchronously check the number of active Automerge.Connections
// for this docId.
// @ts-ignore
const activeConnectionsCount = this.backend.documentSetMap[docId]?.handlers
.size
debugCollabBackend(
'Garbage namespace activeConnections=%s',
activeConnectionsCount
)
// If we have no more active connections for this docId, removeDocument from our backend
if (activeConnectionsCount === 0) {
this.backend.removeDocument(docId)
}
}
/**
* Clean up unused cursor data.
*/
garbageCursors = (socket: SocketIO.Socket) => {
const { name: docId } = socket.nsp
try {
const doc = this.backend.getDocument(docId)
// if document has already been cleaned up, it is safe to return early
if (!doc || !doc.cursors) return
const namespace = this.io.of(docId)
Object.keys(doc?.cursors)?.forEach(key => {
if (!namespace.sockets[key]) {
debugCollabBackend('Garbage cursor %s', key)
this.backend.garbageCursor(docId, key)
}
})
} catch (err) {
this.handleError(socket, err)
}
}
/**
* Destroy SocketIO connection
*/
destroy = async () => {
this.io.close()
}
}

View File

@ -1,282 +0,0 @@
import io from 'socket.io'
import * as Automerge from 'automerge'
import { Node } from 'slate'
import { Server } from 'http'
import throttle from 'lodash/throttle'
import { SyncDoc, CollabAction, toJS } from '@hiveteams/collab-bridge'
import { getClients } from './utils'
import AutomergeBackend from './AutomergeBackend'
import { debugCollabBackend } from 'utils/debug'
export interface SocketIOCollaborationOptions {
entry: Server
connectOpts?: SocketIO.ServerOptions
defaultValue: Node[]
saveFrequency?: number
onAuthRequest?: (
query: Object,
socket?: SocketIO.Socket
) => Promise<boolean> | boolean
onDocumentLoad?: (
pathname: string,
query?: Object
) => Promise<Node[]> | Node[]
onDocumentSave?: (pathname: string, doc: Node[]) => Promise<void> | void
}
export default class SocketIOCollaboration {
private io: SocketIO.Server
private options: SocketIOCollaborationOptions
private backend: AutomergeBackend
private autoSaveDoc: (id: string, docId: string) => void
/**
* Constructor
*/
constructor(options: SocketIOCollaborationOptions) {
this.io = io(options.entry, options.connectOpts)
this.backend = new AutomergeBackend()
this.options = options
/**
* Save document with throttle
*/
this.autoSaveDoc = throttle(
async (id: string, docId: string) =>
this.backend.getDocument(docId) && this.saveDocument(id, docId),
this.options?.saveFrequency || 2000
)
this.configure()
return this
}
/**
* Initial IO configuration
*/
private configure = () =>
this.io
.of(this.nspMiddleware)
.use(this.authMiddleware)
.on('connect', this.onConnect)
/**
* Namespace SocketIO middleware. Load document value and append it to CollaborationBackend.
*/
private nspMiddleware = async (path: string, query: any, next: any) => {
return next(null, true)
}
/**
* SocketIO auth middleware. Used for user authentification.
*/
private authMiddleware = async (
socket: SocketIO.Socket,
next: (e?: any) => void
) => {
const { id } = socket
const { query } = socket.handshake
const { onAuthRequest } = this.options
// we connect before any async logic so that we
// never miss a socket disconnection event
socket.on('disconnect', this.onDisconnect(id, socket))
if (onAuthRequest) {
const permit = await onAuthRequest(query, socket)
if (!permit)
return next(new Error(`Authentification error: ${socket.id}`))
}
return next()
}
/**
* On 'connect' handler.
*/
private onConnect = async (socket: SocketIO.Socket) => {
const { id, conn } = socket
// do nothing if the socket connection has already been closed
if (conn.readyState === 'closed') {
return
}
const { name } = socket.nsp
const { onDocumentLoad } = this.options
if (!this.backend.getDocument(name)) {
const doc = onDocumentLoad
? await onDocumentLoad(name)
: this.options.defaultValue
// Ensure socket is still opened
// recheck ready state after async operation
if (conn.readyState === 'closed') {
return
}
// recheck backend getDocument after async operation
if (!this.backend.getDocument(name)) {
debugCollabBackend('Append document\t\t%s', id)
this.backend.appendDocument(name, doc)
}
}
debugCollabBackend('Create connection\t%s', id)
this.backend.createConnection(
id,
name,
({ type, payload }: CollabAction) => {
socket.emit('msg', { type, payload: { id: conn.id, ...payload } })
}
)
socket.on('msg', this.onMessage(id, name))
socket.join(id, () => {
const doc = this.backend.getDocument(name)
if (!doc) {
debugCollabBackend(
'onConnect: No document available at the time of socket.io join docId=%s socketId=%s',
name,
id
)
return
}
socket.emit('msg', {
type: 'document',
payload: Automerge.save<SyncDoc>(doc)
})
debugCollabBackend('Open connection\t\t%s', id)
this.backend.openConnection(id)
})
this.garbageCursors(name)
}
/**
* On 'message' handler
*/
private onMessage = (id: string, name: string) => (data: any) => {
switch (data.type) {
case 'operation':
try {
this.backend.receiveOperation(id, data)
this.autoSaveDoc(id, name)
this.garbageCursors(name)
} catch (e) {
console.log(e)
}
}
}
/**
* Save document
*/
private saveDocument = async (id: string, docId: string) => {
try {
const { onDocumentSave } = this.options
const doc = this.backend.getDocument(docId)
// Return early if there is no valid document in our crdt backend
// Note: this will happen when user disconnects from the collab server
// before document load has completed
if (!doc) {
return
}
onDocumentSave && (await onDocumentSave(docId, toJS(doc.children)))
} catch (e) {
console.error(e, docId)
}
}
/**
* On 'disconnect' handler
*/
private onDisconnect = (id: string, socket: SocketIO.Socket) => async () => {
debugCollabBackend('Connection closed\t%s', id)
this.backend.closeConnection(id)
await this.saveDocument(id, socket.nsp.name)
// cleanup automerge cursor and socket connection
this.garbageCursors(socket.nsp.name)
socket.leave(id)
this.garbageNsp(id)
}
/**
* Clean up unused SocketIO namespaces.
*/
garbageNsp = (id: string) => {
Object.keys(this.io.nsps)
.filter(n => n !== '/')
.forEach(nsp => {
getClients(this.io, nsp).then((clientsList: any) => {
debugCollabBackend(
'Garbage namespace\t%s clientsList=%o %s',
id,
clientsList,
nsp
)
if (!clientsList.length) {
debugCollabBackend('Removing document\t%s', id)
this.backend.removeDocument(nsp)
delete this.io.nsps[nsp]
}
})
})
}
/**
* Clean up unused cursor data.
*/
garbageCursors = (nsp: string) => {
const doc = this.backend.getDocument(nsp)
// if document has already been cleaned up, it is safe to return early
if (!doc || !doc.cursors) return
const namespace = this.io.of(nsp)
Object.keys(doc?.cursors)?.forEach(key => {
if (!namespace.sockets[key]) {
debugCollabBackend('Garbage cursor\t\t%s', key)
this.backend.garbageCursor(nsp, key)
}
})
}
/**
* Destroy SocketIO connection
*/
destroy = async () => {
this.io.close()
}
}

View File

@ -1,3 +1,3 @@
import SocketIOConnection from './SocketIOConnection' import AutomergeCollaboration from './AutomergeCollaboration'
export { SocketIOConnection } export { AutomergeCollaboration }

View File

@ -1,3 +1,3 @@
import debug from 'debug' import debug from 'debug'
export const debugCollabBackend = debug('collab-backend') export const debugCollabBackend = debug('app-collab')

View File

@ -7,7 +7,7 @@
"outDir": "./lib", "outDir": "./lib",
"composite": true, "composite": true,
"paths": { "paths": {
"@hiveteams/collab-bridge": ["../../bridge"] "@hiveteams/collab-bridge": ["../../collab-bridge"]
} }
}, },
"references": [{ "path": "../bridge" }] "references": [{ "path": "../bridge" }]

View File

@ -1,6 +1,6 @@
{ {
"name": "@hiveteams/collab-bridge", "name": "@hiveteams/collab-bridge",
"version": "0.7.16", "version": "0.7.23",
"files": [ "files": [
"lib" "lib"
], ],

View File

@ -22,6 +22,10 @@ export const removeText = (
): SyncValue => { ): SyncValue => {
const node = getTarget(doc, op.path) const node = getTarget(doc, op.path)
// if we are removing text for a node that no longer exists
// treat this as a noop
if (!node) return doc
const offset = Math.min(node.text.length, op.offset) const offset = Math.min(node.text.length, op.offset)
node.text.deleteAt(offset, op.text.length) node.text.deleteAt(offset, op.text.length)

View File

@ -0,0 +1,51 @@
import * as Automerge from 'automerge'
interface TestDoc {
_id: string
status: string
}
// TODO: delete this?
describe('old state error replication', () => {
const clientDocSet = new Automerge.DocSet()
const serverDocSet = new Automerge.DocSet()
const docId = 'test'
let clientDoc = Automerge.from<TestDoc>({
_id: docId,
status: 'Unstarted'
})
let serverDoc = Automerge.from<TestDoc>({
_id: docId,
status: 'Unstarted'
})
it('replicate old state error', () => {
clientDocSet.setDoc(docId, clientDoc)
serverDocSet.setDoc(docId, serverDoc)
let clientMessages: string[] = []
const clientConnection = new Automerge.Connection(clientDocSet, msg => {
clientMessages.push(JSON.stringify(msg))
})
clientConnection.open()
let serverMessages: string[] = []
const serverConnection = new Automerge.Connection(serverDocSet, msg => {
serverMessages.push(JSON.stringify(msg))
})
serverConnection.open()
let oldClientDoc = clientDoc
clientDoc = Automerge.change(clientDoc, newClientDoc => {
newClientDoc.status = 'In progress'
})
clientDocSet.setDoc(docId, clientDoc)
expect(clientMessages.length).toEqual(2)
expect(serverMessages.length).toEqual(1)
expect(() => {
clientDocSet.setDoc(docId, oldClientDoc)
}).toThrow()
})
})

View File

@ -10,12 +10,12 @@ const removeTextOp = (op: Automerge.Diff) => (map: any, doc: Element) => {
const slatePath = toSlatePath(path).slice(0, path?.length) const slatePath = toSlatePath(path).slice(0, path?.length)
let node const node = getTarget(doc, slatePath) || map[obj]
try { // if we are removing text for a node that has already been removed
node = getTarget(doc, slatePath) || map[obj] // treat this as a noop
} catch (e) { if (!node) {
console.error(e, op, doc) return
} }
if (typeof index !== 'number') return if (typeof index !== 'number') return
@ -41,6 +41,11 @@ const removeNodeOp = ({ index, obj, path }: Automerge.Diff) => (
const slatePath = toSlatePath(path) const slatePath = toSlatePath(path)
const parent = getTarget(doc, slatePath) const parent = getTarget(doc, slatePath)
// if we are removing a node that has already been removed
// treat this as a noop
if (!parent) return
const target = parent?.children?.[index as number] || { children: [] } const target = parent?.children?.[index as number] || { children: [] }
if (!map.hasOwnProperty(obj)) { if (!map.hasOwnProperty(obj)) {

View File

@ -1,6 +1,7 @@
import { Operation, Range } from 'slate' import { Operation, Range } from 'slate'
import { CursorData } from '../model' import { CursorData } from '../model'
import { toJS } from '../utils'
export const setCursor = ( export const setCursor = (
id: string, id: string,
@ -9,26 +10,30 @@ export const setCursor = (
operations: Operation[], operations: Operation[],
cursorData: CursorData cursorData: CursorData
) => { ) => {
const cursorOps = operations.filter(op => op.type === 'set_selection') try {
const cursorOps = operations.filter(op => op.type === 'set_selection')
if (!doc.cursors) doc.cursors = {} if (!doc.cursors) doc.cursors = {}
const newCursor = cursorOps[cursorOps.length - 1]?.newProperties || {} const newCursor = cursorOps[cursorOps.length - 1]?.newProperties || {}
if (selection) { if (selection) {
const newCursorData = Object.assign( const newCursorData = Object.assign(
(doc.cursors[id] && JSON.parse(doc.cursors[id])) || {}, (doc.cursors[id] && JSON.parse(doc.cursors[id])) || {},
newCursor, newCursor,
selection, selection,
{ {
...cursorData, ...cursorData,
isForward: Range.isForward(selection) isForward: Range.isForward(selection)
} }
) )
doc.cursors[id] = JSON.stringify(newCursorData) doc.cursors[id] = JSON.stringify(newCursorData)
} else { } else {
delete doc.cursors[id] delete doc.cursors[id]
}
} catch (e) {
console.error(e, toJS(doc))
} }
return doc return doc

View File

@ -6,10 +6,8 @@ export const isTree = (node: Node): boolean => Boolean(node?.children)
export const getTarget = (doc: SyncValue | Element, path: Path) => { export const getTarget = (doc: SyncValue | Element, path: Path) => {
const iterate = (current: any, idx: number) => { const iterate = (current: any, idx: number) => {
if (!(isTree(current) || current[idx])) { if (current === null || !(isTree(current) || current[idx])) {
throw new TypeError( return null
`path ${path.toString()} does not match tree ${JSON.stringify(current)}`
)
} }
return current[idx] || current?.children[idx] return current[idx] || current?.children[idx]

View File

@ -5,14 +5,7 @@ import { CollabAction } from '../model'
export * from './testUtils' export * from './testUtils'
const toJS = (node: any) => { const toJS = (node: any) => JSON.parse(JSON.stringify(node))
try {
return JSON.parse(JSON.stringify(node))
} catch (e) {
console.error('Convert to js failed!!! Return null')
return null
}
}
const cloneNode = (node: any) => toSync(toJS(node)) const cloneNode = (node: any) => toSync(toJS(node))

View File

@ -1,6 +1,6 @@
{ {
"name": "@hiveteams/collab-client", "name": "@hiveteams/collab-client",
"version": "0.7.17", "version": "0.7.23",
"files": [ "files": [
"lib" "lib"
], ],
@ -21,13 +21,15 @@
"build:module": "npm run build:types && npm run build:js", "build:module": "npm run build:types && npm run build:js",
"build:types": "tsc --emitDeclarationOnly", "build:types": "tsc --emitDeclarationOnly",
"build:js": "babel src --out-dir lib --extensions \".ts,.tsx\" --source-maps inline", "build:js": "babel src --out-dir lib --extensions \".ts,.tsx\" --source-maps inline",
"watch": "yarn build:js -w" "watch": "yarn build:js -w",
"test": "jest"
}, },
"dependencies": { "dependencies": {
"@babel/plugin-proposal-optional-chaining": "^7.9.0", "@babel/plugin-proposal-optional-chaining": "^7.9.0",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@hiveteams/collab-bridge": "^0.7.16", "@hiveteams/collab-bridge": "^0.7.23",
"automerge": "0.14.0", "automerge": "0.14.0",
"lodash": "^4.17.20",
"slate": "0.58.3", "slate": "0.58.3",
"slate-history": "0.58.3", "slate-history": "0.58.3",
"socket.io-client": "^2.3.0", "socket.io-client": "^2.3.0",
@ -40,11 +42,30 @@
"@babel/plugin-proposal-object-rest-spread": "^7.5.5", "@babel/plugin-proposal-object-rest-spread": "^7.5.5",
"@babel/preset-env": "^7.6.0", "@babel/preset-env": "^7.6.0",
"@babel/preset-typescript": "^7.6.0", "@babel/preset-typescript": "^7.6.0",
"@hiveteams/collab-backend": "^0.7.23",
"@types/jest": "^24.9.0",
"@types/react": "^16.9.34", "@types/react": "^16.9.34",
"@types/socket.io-client": "^1.4.32" "@types/socket.io-client": "^1.4.32",
"jest": "^26.6.3",
"ts-jest": "^26.4.4"
}, },
"directories": { "directories": {
"lib": "lib" "lib": "lib"
}, },
"gitHead": "89dd1657ba1b39db298e00a380f45089b8b52a91" "gitHead": "89dd1657ba1b39db298e00a380f45089b8b52a91",
"jest": {
"preset": "ts-jest",
"globals": {
"ts-jest": {
"babelConfig": ".babelrc"
}
},
"roots": [
"<rootDir>/src"
],
"transform": {
"^.+\\.ts?$": "ts-jest"
},
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$"
}
} }

View File

@ -0,0 +1,192 @@
import Automerge from 'automerge'
import { Editor, Operation } from 'slate'
import { HistoryEditor } from 'slate-history'
import {
toJS,
SyncDoc,
CollabAction,
toCollabAction,
applyOperation,
setCursor,
toSlateOp,
CursorData
} from '@hiveteams/collab-bridge'
import { AutomergeEditor } from './interfaces'
/**
* `AutomergeEditor` contains methods for collaboration-enabled editors.
*/
export const AutomergeConnector = {
/**
* Create Automerge connection
*/
createConnection: (e: AutomergeEditor, emit: (data: CollabAction) => void) =>
new Automerge.Connection(e.docSet, toCollabAction('operation', emit)),
/**
* Apply Slate operations to Automerge
*/
applySlateOps: (
e: AutomergeEditor,
docId: string,
operations: Operation[],
cursorData?: CursorData
) => {
const doc = e.docSet.getDoc(docId)
if (!doc) {
throw new TypeError('Cannot apply slate ops for missing docId')
}
let changed: any
for (let i = 0; i < operations.length; i++) {
const op = operations[i]
try {
changed = Automerge.change<SyncDoc>(changed || doc, d =>
applyOperation(d.children, op)
)
} catch (err) {
e.handleError(err, {
type: 'applySlateOps - applyOperation',
automergeChanged: Automerge.save(changed || doc),
operation: op
})
// return early to avoid applying any further operations after we encounter an error
return
}
}
try {
changed = Automerge.change(changed || doc, d => {
setCursor(e.clientId, e.selection, d, operations, cursorData || {})
})
} catch (err) {
e.handleError(err, {
type: 'applySlateOps - setCursor',
clientId: e.clientId,
automergeDocument: Automerge.save(changed || doc),
operations,
cursorData
})
}
e.docSet.setDoc(docId, changed)
},
/**
* Receive and apply document to Automerge docSet
*/
receiveDocument: (e: AutomergeEditor, docId: string, data: string) => {
let currentDoc: Automerge.FreezeObject<SyncDoc> | null = null
let externalDoc: Automerge.FreezeObject<SyncDoc> | null = null
let mergedDoc: Automerge.FreezeObject<SyncDoc> | null = null
try {
currentDoc = e.docSet.getDoc(docId)
externalDoc = Automerge.load<SyncDoc>(data)
mergedDoc = Automerge.merge<SyncDoc>(
externalDoc,
currentDoc || Automerge.init()
)
e.docSet.setDoc(docId, mergedDoc)
Editor.withoutNormalizing(e, () => {
e.children = toJS(mergedDoc).children
e.onChange()
})
} catch (err) {
e.handleError(err, {
type: 'receiveDocument',
currentDoc: currentDoc && Automerge.save(currentDoc),
externalDoc: externalDoc && Automerge.save(externalDoc),
mergedDoc: mergedDoc && Automerge.save(mergedDoc)
})
}
},
/**
* Generate automerge diff, convert and apply operations to Editor
*/
applyOperation: (
e: AutomergeEditor,
docId: string,
data: Automerge.Message,
preserveExternalHistory?: boolean
) => {
try {
const current = e.docSet.getDoc(docId)
const updated = e.connection.receiveMsg(data)
const operations = Automerge.diff(current, updated)
if (operations.length) {
let slateOps: any[] = []
try {
slateOps = toSlateOp(operations, current)
} catch (err) {
e.handleError(err, {
type: 'applyOperation - toSlateOp',
operations,
current: Automerge.save(current)
})
}
e.isRemote = true
Editor.withoutNormalizing(e, () => {
if (HistoryEditor.isHistoryEditor(e) && !preserveExternalHistory) {
HistoryEditor.withoutSaving(e, () => {
slateOps.forEach((o: Operation) => e.apply(o))
})
} else {
slateOps.forEach((o: Operation) => e.apply(o))
}
e.onCursor && e.onCursor(updated.cursors)
})
Promise.resolve().then(_ => (e.isRemote = false))
}
} catch (err) {
// unset remote flag
if (e.isRemote) {
e.isRemote = false
}
const current = e.docSet.getDoc(docId)
e.handleError(err, {
type: 'applyOperation',
data,
current: current ? Automerge.save(current) : null
})
}
},
garbageCursor: (e: AutomergeEditor, docId: string) => {
const doc = e.docSet.getDoc(docId)
// if the document has already been cleaned up
// return early and do nothing
if (!doc) return
const changed = Automerge.change<SyncDoc>(doc, (d: any) => {
delete d.cursors
})
e.docSet.setDoc(docId, changed as any)
e.onCursor && e.onCursor(null)
e.onChange()
}
}

View File

@ -1,163 +0,0 @@
import Automerge from 'automerge'
import { Editor, Operation } from 'slate'
import { HistoryEditor } from 'slate-history'
import {
toJS,
SyncDoc,
CollabAction,
toCollabAction,
applyOperation,
setCursor,
toSlateOp,
CursorData
} from '@hiveteams/collab-bridge'
export interface AutomergeEditor extends Editor {
clientId: string
isRemote: boolean
docSet: Automerge.DocSet<SyncDoc>
connection: Automerge.Connection<SyncDoc>
onConnectionMsg: (msg: Automerge.Message) => void
openConnection: () => void
closeConnection: () => void
receiveDocument: (data: string) => void
receiveOperation: (data: Automerge.Message) => void
gabageCursor: () => void
onCursor: (data: any) => void
}
/**
* `AutomergeEditor` contains methods for collaboration-enabled editors.
*/
export const AutomergeEditor = {
/**
* Create Automerge connection
*/
createConnection: (e: AutomergeEditor, emit: (data: CollabAction) => void) =>
new Automerge.Connection(e.docSet, toCollabAction('operation', emit)),
/**
* Apply Slate operations to Automerge
*/
applySlateOps: async (
e: AutomergeEditor,
docId: string,
operations: Operation[],
cursorData?: CursorData
) => {
const doc = e.docSet.getDoc(docId)
if (!doc) {
throw new TypeError(`Unknown docId: ${docId}!`)
}
let changed
for await (let op of operations) {
changed = Automerge.change<SyncDoc>(changed || doc, d =>
applyOperation(d.children, op)
)
}
changed = Automerge.change(changed || doc, d => {
setCursor(e.clientId, e.selection, d, operations, cursorData || {})
})
e.docSet.setDoc(docId, changed as any)
},
/**
* Receive and apply document to Automerge docSet
*/
receiveDocument: (e: AutomergeEditor, docId: string, data: string) => {
const currentDoc = e.docSet.getDoc(docId)
const externalDoc = Automerge.load<SyncDoc>(data)
const mergedDoc = Automerge.merge<SyncDoc>(
externalDoc,
currentDoc || Automerge.init()
)
e.docSet.setDoc(docId, mergedDoc)
Editor.withoutNormalizing(e, () => {
e.children = toJS(mergedDoc).children
e.onChange()
})
},
/**
* Generate automerge diff, convert and apply operations to Editor
*/
applyOperation: (
e: AutomergeEditor,
docId: string,
data: Automerge.Message,
preserveExternalHistory?: boolean
) => {
try {
const current: any = e.docSet.getDoc(docId)
const updated = e.connection.receiveMsg(data)
const operations = Automerge.diff(current, updated)
if (operations.length) {
const slateOps = toSlateOp(operations, current)
e.isRemote = true
Editor.withoutNormalizing(e, () => {
if (HistoryEditor.isHistoryEditor(e) && !preserveExternalHistory) {
HistoryEditor.withoutSaving(e, () => {
slateOps.forEach((o: Operation) => e.apply(o))
})
} else {
slateOps.forEach((o: Operation) => e.apply(o))
}
e.onCursor && e.onCursor(updated.cursors)
})
Promise.resolve().then(_ => (e.isRemote = false))
}
} catch (e) {
// unset remove flag
if (e.isRemote) {
e.isRemote = false
}
throw e
}
},
garbageCursor: (e: AutomergeEditor, docId: string) => {
const doc = e.docSet.getDoc(docId)
const changed = Automerge.change<SyncDoc>(doc, (d: any) => {
delete d.cursors
})
e.onCursor && e.onCursor(null)
e.docSet.setDoc(docId, changed)
e.onChange()
}
}

View File

@ -0,0 +1,212 @@
import Automerge, { Frontend } from 'automerge'
import { createServer } from 'http'
import fs from 'fs'
import isEqual from 'lodash/isEqual'
import { createEditor, Node, Transforms } from 'slate'
import { SyncDoc, toJS, toSlateOp } from '@hiveteams/collab-bridge'
import AutomergeCollaboration from '@hiveteams/collab-backend/lib/AutomergeCollaboration'
import withIOCollaboration from './withIOCollaboration'
import { AutomergeOptions, SocketIOPluginOptions } from './interfaces'
const connectionSlug = 'test'
const docId = `/${connectionSlug}`
const options: AutomergeOptions & SocketIOPluginOptions = {
docId,
onError: msg => console.log('Encountered test error', msg),
url: `http://localhost:5000/${connectionSlug}`,
connectOpts: {
query: {
name: 'test-user',
slug: connectionSlug
},
forceNew: true
}
}
const waitForCondition = (condition: () => boolean, ms = 10) =>
new Promise<void>(resolve => {
const handle = setInterval(() => {
if (condition()) {
clearInterval(handle)
resolve()
}
}, ms)
})
const server = createServer(function(req, res) {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.write('Hello World!')
res.end()
})
const defaultSlateJson = [{ type: 'paragraph', children: [{ text: '' }] }]
const collabBackend = new AutomergeCollaboration({
entry: server,
defaultValue: defaultSlateJson,
saveFrequency: 1000,
async onAuthRequest(query) {
return { _id: 'test-id', name: 'Eric' }
},
async onDocumentLoad(pathname) {
return defaultSlateJson
}
})
describe('automerge editor client tests', () => {
beforeAll(done => {
//pass a callback to tell jest it is async
//start the server before any test
server.listen(5000, () => done())
})
const createCollabEditor = async (
editorOptions: AutomergeOptions & SocketIOPluginOptions = options
) => {
const editor = withIOCollaboration(createEditor(), editorOptions)
const oldReceiveDocument = editor.receiveDocument
const promise = new Promise<void>(resolve => {
editor.receiveDocument = data => {
oldReceiveDocument(data)
resolve()
}
})
editor.connect()
await promise
return editor
}
it('should receiveDocument', async () => {
const editor = await createCollabEditor()
expect(editor.children.length).toEqual(1)
editor.destroy()
})
it('should send client update to server', async () => {
const editor = await createCollabEditor()
editor.insertNode({ type: 'paragraph', children: [{ text: 'hi' }] })
await waitForCondition(() => {
const serverDoc = toJS(collabBackend.backend.getDocument(docId))
return serverDoc.children.length === 2
})
editor.destroy()
})
it('should sync updates across two clients', async () => {
const editor1 = await createCollabEditor()
const editor2 = await createCollabEditor()
editor1.insertNode({ type: 'paragraph', children: [{ text: 'hi' }] })
await waitForCondition(() => {
const serverDoc = toJS(collabBackend.backend.getDocument(docId))
return serverDoc.children.length === 2 && editor2.children.length === 2
})
editor1.destroy()
editor2.destroy()
})
it('should sync offline changes on reconnect', async () => {
const editor1 = await createCollabEditor()
const editor2 = await createCollabEditor()
editor1.insertNode({ type: 'paragraph', children: [{ text: 'hi' }] })
await waitForCondition(() => {
const serverDoc = toJS(collabBackend.backend.getDocument(docId))
return serverDoc.children.length === 2 && editor2.children.length === 2
})
editor1.destroy()
editor1.insertNode({ type: 'paragraph', children: [{ text: 'offline' }] })
editor1.connect()
await waitForCondition(() => {
const serverDoc = toJS(collabBackend.backend.getDocument(docId))
return serverDoc.children.length === 3 && editor2.children.length === 3
})
expect(Node.string(editor2.children[2])).toEqual('offline')
editor1.destroy()
editor2.destroy()
})
it('should work with concurrent edits', async () => {
const editor1 = await createCollabEditor()
const editor2 = await createCollabEditor()
const numEdits = 10
for (let i = 0; i < numEdits; i++) {
editor1.insertNode({ type: 'paragraph', children: [{ text: '' }] })
editor2.insertNode({ type: 'paragraph', children: [{ text: '' }] })
}
await waitForCondition(() => {
return (
editor1.children.length === numEdits * 2 + 1 &&
editor2.children.length === numEdits * 2 + 1
)
})
expect(isEqual(editor1.children, editor2.children)).toBeTruthy()
editor1.destroy()
editor2.destroy()
})
it('should work with concurrent insert text operations', async () => {
const editor1 = await createCollabEditor()
const editor2 = await createCollabEditor()
Transforms.select(editor1, [0, 0])
Transforms.select(editor2, [0, 0])
const numEdits = 10
for (let i = 0; i < numEdits; i++) {
editor1.insertText('a')
editor2.insertText('b')
}
await waitForCondition(() => {
return (
Node.string(editor1.children[0]).length === numEdits * 2 &&
Node.string(editor2.children[0]).length === numEdits * 2
)
})
expect(isEqual(editor1.children, editor2.children)).toBeTruthy()
editor1.destroy()
editor2.destroy()
})
it('deep nested tree error', () => {
// Ready from our test json file for the deep tree error
// This allows us to easily reproduce real production errors
// and create test cases that resolve those errors
const rawData = fs.readFileSync(
`${__dirname}/test-json/deep-tree.json`,
'utf-8'
)
const parsedData = JSON.parse(rawData)
const { current, operations } = parsedData
const currentDoc = Automerge.load<SyncDoc>(current)
// ensure no errors throw when removing a deep tree node
// that has already been removed
toSlateOp(operations, currentDoc)
})
afterAll(() => {
collabBackend.destroy()
server.close()
})
})

View File

@ -0,0 +1,51 @@
import Automerge from 'automerge'
import { Editor } from 'slate'
import { CollabAction, CursorData, SyncDoc } from '@hiveteams/collab-bridge'
export interface AutomergeOptions {
docId: string
cursorData?: CursorData
preserveExternalHistory?: boolean
onError?: (msg: string | Error, data: any) => void
}
export interface AutomergeEditor extends Editor {
clientId: string
isRemote: boolean
docSet: Automerge.DocSet<SyncDoc>
connection: Automerge.Connection<SyncDoc>
onConnectionMsg: (msg: Automerge.Message) => void
openConnection: () => void
closeConnection: () => void
receiveDocument: (data: string) => void
receiveOperation: (data: Automerge.Message) => void
garbageCursor: () => void
onCursor: (data: any) => void
handleError: (err: Error | string, data?: any) => void
}
export interface SocketIOPluginOptions {
url: string
connectOpts: SocketIOClient.ConnectOpts
onConnect?: () => void
onDisconnect?: () => void
onError?: (msg: string | Error, data: any) => void
}
export interface WithSocketIOEditor {
clientId: string
socket: SocketIOClient.Socket
connect: () => void
disconnect: () => void
send: (op: CollabAction) => void
receive: (op: CollabAction) => void
destroy: () => void
}

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@ import { Text, Range, Path, NodeEntry } from 'slate'
import { toJS, Cursor, Cursors } from '@hiveteams/collab-bridge' import { toJS, Cursor, Cursors } from '@hiveteams/collab-bridge'
import { AutomergeEditor } from './automerge-editor'
import useMounted from './useMounted' import useMounted from './useMounted'
import { AutomergeEditor } from './interfaces'
const useCursor = ( const useCursor = (
e: AutomergeEditor e: AutomergeEditor

View File

@ -2,65 +2,63 @@ import Automerge from 'automerge'
import { Editor } from 'slate' import { Editor } from 'slate'
import { AutomergeEditor } from './automerge-editor' import { AutomergeConnector } from './automerge-connector'
import { CursorData, CollabAction } from '@hiveteams/collab-bridge' import { CollabAction } from '@hiveteams/collab-bridge'
import {
export interface AutomergeOptions { AutomergeEditor,
docId: string AutomergeOptions,
cursorData?: CursorData WithSocketIOEditor
preserveExternalHistory?: boolean } from './interfaces'
onError?: (msg: string | Error) => void
}
/** /**
* The `withAutomerge` plugin contains core collaboration logic. * The `withAutomerge` plugin contains core collaboration logic.
*/ */
const withAutomerge = <T extends Editor>( const withAutomerge = <T extends Editor>(
editor: T, slateEditor: T,
options: AutomergeOptions options: AutomergeOptions
) => { ) => {
const e = editor as T & AutomergeEditor const { docId, cursorData, preserveExternalHistory } = options || {}
const { onChange } = e const editor = slateEditor as T & AutomergeEditor & WithSocketIOEditor
const { const { onChange } = editor
docId,
cursorData,
preserveExternalHistory,
onError = (err: string | Error) => console.log('AutomergeEditor error', err)
} = options || {}
e.docSet = new Automerge.DocSet() editor.docSet = new Automerge.DocSet()
const createConnection = () => { /**
e.connection = AutomergeEditor.createConnection(e, (data: CollabAction) => * Helper function for handling errors
//@ts-ignore */
e.send(data)
)
e.connection.open() editor.handleError = (err: Error | string, data: any = {}) => {
const { onError } = options
if (onError) {
onError(err, data)
}
} }
/** /**
* Open Automerge Connection * Open Automerge Connection
*/ */
e.openConnection = () => { editor.openConnection = () => {
createConnection() editor.connection = AutomergeConnector.createConnection(
editor,
(data: CollabAction) => editor.send(data)
)
e.connection.open() editor.connection.open()
} }
/** /**
* Close Automerge Connection * Close Automerge Connection
*/ */
e.closeConnection = () => { editor.closeConnection = () => {
// close any actively open connections // close any actively open connections
if (e.connection) { if (editor.connection) {
e.connection.close() editor.connection.close()
} }
} }
@ -68,26 +66,18 @@ const withAutomerge = <T extends Editor>(
* Clear cursor data * Clear cursor data
*/ */
e.gabageCursor = () => { editor.garbageCursor = () => {
try { AutomergeConnector.garbageCursor(editor, docId)
AutomergeEditor.garbageCursor(e, docId)
} catch (err) {
console.log('garbageCursor error', err)
}
} }
/** /**
* Editor onChange * Editor onChange
*/ */
editor.onChange = () => {
const operations = editor.operations
e.onChange = () => { if (!editor.isRemote) {
const operations: any = e.operations AutomergeConnector.applySlateOps(editor, docId, operations, cursorData)
if (!e.isRemote) {
AutomergeEditor.applySlateOps(e, docId, operations, cursorData).catch(
onError
)
onChange() onChange()
} }
} }
@ -96,26 +86,27 @@ const withAutomerge = <T extends Editor>(
* Receive document value * Receive document value
*/ */
e.receiveDocument = data => { editor.receiveDocument = data => {
AutomergeEditor.receiveDocument(e, docId, data) AutomergeConnector.receiveDocument(editor, docId, data)
} }
/** /**
* Receive Automerge sync operations * Receive Automerge sync operations
*/ */
e.receiveOperation = data => { editor.receiveOperation = data => {
// ignore document updates for differnt docIds
if (docId !== data.docId) return if (docId !== data.docId) return
try { AutomergeConnector.applyOperation(
AutomergeEditor.applyOperation(e, docId, data, preserveExternalHistory) editor,
} catch (err) { docId,
// report any errors during apply operation data,
onError(err) preserveExternalHistory
} )
} }
return e return editor
} }
export default withAutomerge export default withAutomerge

View File

@ -1,11 +1,13 @@
import { Editor } from 'slate' import { Editor } from 'slate'
import { AutomergeEditor } from './automerge-editor'
import withAutomerge, { AutomergeOptions } from './withAutomerge' import withAutomerge from './withAutomerge'
import withSocketIO, { import {
WithSocketIOEditor, AutomergeEditor,
SocketIOPluginOptions AutomergeOptions,
} from './withSocketIO' SocketIOPluginOptions,
WithSocketIOEditor
} from './interfaces'
import withSocketIO from './withSocketIO'
/** /**
* The `withIOCollaboration` plugin contains collaboration with SocketIO. * The `withIOCollaboration` plugin contains collaboration with SocketIO.

View File

@ -1,102 +1,85 @@
import io from 'socket.io-client' import io from 'socket.io-client'
import { AutomergeEditor } from './automerge-editor' import Automerge from 'automerge'
import { CollabAction } from '@hiveteams/collab-bridge' import { CollabAction } from '@hiveteams/collab-bridge'
import {
export interface SocketIOPluginOptions { AutomergeEditor,
url: string AutomergeOptions,
connectOpts: SocketIOClient.ConnectOpts SocketIOPluginOptions,
WithSocketIOEditor
onConnect?: () => void } from './interfaces'
onDisconnect?: () => void
onError?: (msg: string | Error) => void
}
export interface WithSocketIOEditor {
socket: SocketIOClient.Socket
connect: () => void
disconnect: () => void
send: (op: CollabAction) => void
receive: (op: CollabAction) => void
destroy: () => void
}
/** /**
* The `withSocketIO` plugin contains SocketIO layer logic. * The `withSocketIO` plugin contains SocketIO layer logic.
*/ */
const withSocketIO = <T extends AutomergeEditor>( const withSocketIO = <T extends AutomergeEditor>(
editor: T, slateEditor: T,
options: SocketIOPluginOptions options: SocketIOPluginOptions & AutomergeOptions
) => { ) => {
const e = editor as T & WithSocketIOEditor const { onConnect, onDisconnect, connectOpts, url } = options
const editor = slateEditor as T & WithSocketIOEditor & AutomergeEditor
let socket: SocketIOClient.Socket let socket: SocketIOClient.Socket
const { onConnect, onDisconnect, onError, connectOpts, url } = options
/** /**
* Connect to Socket. * Connect to Socket.
*/ */
e.connect = () => { editor.connect = () => {
socket = io(url, { ...connectOpts }) socket = io(url, { ...connectOpts })
// On socket io connect, open a new automerge connection
socket.on('connect', () => { socket.on('connect', () => {
e.clientId = socket.id editor.clientId = socket.id
editor.openConnection()
e.openConnection()
onConnect && onConnect() onConnect && onConnect()
}) })
// On socket io error
socket.on('error', (msg: string) => { socket.on('error', (msg: string) => {
onError && onError(msg) editor.handleError(msg)
}) })
// On socket io msg, process the collab operation
socket.on('msg', (data: CollabAction) => { socket.on('msg', (data: CollabAction) => {
e.receive(data) editor.receive(data)
}) })
// On socket io disconnect, cleanup cursor and call the provided onDisconnect callback
socket.on('disconnect', () => { socket.on('disconnect', () => {
e.gabageCursor() editor.garbageCursor()
onDisconnect && onDisconnect() onDisconnect && onDisconnect()
}) })
socket.connect() socket.connect()
return e return editor
} }
/** /**
* Disconnect from Socket. * Disconnect from Socket.
*/ */
e.disconnect = () => { editor.disconnect = () => {
socket.removeListener('msg') socket.removeListener('msg')
socket.close() socket.close()
e.closeConnection() editor.closeConnection()
return e return editor
} }
/** /**
* Receive transport msg. * Receive transport msg.
*/ */
e.receive = (msg: CollabAction) => { editor.receive = (msg: CollabAction) => {
switch (msg.type) { switch (msg.type) {
case 'operation': case 'operation':
return e.receiveOperation(msg.payload) return editor.receiveOperation(msg.payload)
case 'document': case 'document':
return e.receiveDocument(msg.payload) return editor.receiveDocument(msg.payload)
} }
} }
@ -104,7 +87,7 @@ const withSocketIO = <T extends AutomergeEditor>(
* Send message to socket. * Send message to socket.
*/ */
e.send = (msg: CollabAction) => { editor.send = (msg: CollabAction) => {
socket.emit('msg', msg) socket.emit('msg', msg)
} }
@ -112,12 +95,12 @@ const withSocketIO = <T extends AutomergeEditor>(
* Close socket and connection. * Close socket and connection.
*/ */
e.destroy = () => { editor.destroy = () => {
socket.close() socket.close()
e.closeConnection() editor.closeConnection()
} }
return e return editor
} }
export default withSocketIO export default withSocketIO

View File

@ -7,8 +7,9 @@
"outDir": "./lib", "outDir": "./lib",
"composite": true, "composite": true,
"paths": { "paths": {
"@hiveteams/collab-bridge": ["../../bridge"] "@hiveteams/collab-bridge": ["../../collab-bridge"],
"@hiveteams/collab-backend": ["../../collab-backend"]
} }
}, },
"references": [{ "path": "../bridge" }] "references": [{ "path": "../bridge" }, { "path": "../backend" }]
} }

View File

@ -1,12 +1,12 @@
{ {
"name": "@hiveteams/collab-example", "name": "@hiveteams/collab-example",
"version": "0.7.17", "version": "0.7.23",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@emotion/core": "^10.0.17", "@emotion/core": "^10.0.17",
"@emotion/styled": "^10.0.17", "@emotion/styled": "^10.0.17",
"@hiveteams/collab-backend": "^0.7.16", "@hiveteams/collab-backend": "^0.7.23",
"@hiveteams/collab-client": "^0.7.17", "@hiveteams/collab-client": "^0.7.23",
"@types/faker": "^4.1.5", "@types/faker": "^4.1.5",
"@types/is-url": "^1.2.28", "@types/is-url": "^1.2.28",
"@types/jest": "24.0.18", "@types/jest": "24.0.18",

View File

@ -1,4 +1,4 @@
const { SocketIOConnection } = require('@hiveteams/collab-backend') const { AutomergeCollaboration } = require('@hiveteams/collab-backend')
const express = require('express') const express = require('express')
const defaultValue = [ const defaultValue = [
@ -36,4 +36,4 @@ const config = {
} }
} }
const connection = new SocketIOConnection(config) const connection = new AutomergeCollaboration(config)