mirror of
https://github.com/cudr/slate-collaborative.git
synced 2024-10-27 20:34:06 +00:00
feat: cleaner error handling and basic client tests
This commit is contained in:
parent
c8df4f7ad0
commit
f425635cf8
@ -48,9 +48,9 @@ const decorator = useCursor(editor)
|
||||
## Backend
|
||||
|
||||
```ts
|
||||
const { SocketIOConnection } = require('@hiveteams/collab-backend')
|
||||
const { AutomergeCollaboration } = require('@hiveteams/collab-backend')
|
||||
|
||||
const connection = new SocketIOConnection(options)
|
||||
const collabBackend = new AutomergeCollaboration(options)
|
||||
```
|
||||
|
||||
### options:
|
||||
|
@ -53,7 +53,6 @@ class AutomergeBackend {
|
||||
|
||||
closeConnection(id: string) {
|
||||
this.connectionMap[id]?.close()
|
||||
|
||||
delete this.connectionMap[id]
|
||||
}
|
||||
|
||||
@ -62,16 +61,12 @@ class AutomergeBackend {
|
||||
*/
|
||||
|
||||
receiveOperation = (id: string, data: CollabAction) => {
|
||||
try {
|
||||
if (!this.connectionMap[id]) {
|
||||
debugCollabBackend('Could not receive op for closed connection %s', id)
|
||||
return
|
||||
}
|
||||
|
||||
this.connectionMap[id].receiveMsg(data.payload)
|
||||
} catch (e) {
|
||||
console.error('Unexpected error in receiveOperation', e)
|
||||
if (!this.connectionMap[id]) {
|
||||
debugCollabBackend('Could not receive op for closed connection %s', id)
|
||||
return
|
||||
}
|
||||
|
||||
this.connectionMap[id].receiveMsg(data.payload)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -85,19 +80,15 @@ class AutomergeBackend {
|
||||
*/
|
||||
|
||||
appendDocument = (docId: string, data: Node[]) => {
|
||||
try {
|
||||
if (this.getDocument(docId)) {
|
||||
throw new Error(`Already has document with id: ${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)
|
||||
} catch (e) {
|
||||
console.error(e, docId)
|
||||
if (this.getDocument(docId)) {
|
||||
throw new Error(`Already has document with id: ${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)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -105,10 +96,8 @@ class AutomergeBackend {
|
||||
*/
|
||||
|
||||
removeDocument = (docId: string) => {
|
||||
if (this.documentSetMap[docId]) {
|
||||
this.documentSetMap[docId].removeDoc(docId)
|
||||
delete this.documentSetMap[docId]
|
||||
}
|
||||
this.documentSetMap[docId]?.removeDoc(docId)
|
||||
delete this.documentSetMap[docId]
|
||||
}
|
||||
|
||||
/**
|
||||
@ -116,19 +105,16 @@ class AutomergeBackend {
|
||||
*/
|
||||
|
||||
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) => {
|
||||
delete d.cursors[id]
|
||||
})
|
||||
const change = Automerge.change(doc, (d: any) => {
|
||||
delete d.cursors[id]
|
||||
})
|
||||
|
||||
this.documentSetMap[docId].setDoc(docId, change)
|
||||
} catch (e) {
|
||||
console.error('Unexpected error in garbageCursor', e)
|
||||
}
|
||||
this.documentSetMap[docId].setDoc(docId, change)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import io from 'socket.io'
|
||||
import io, { Socket } from 'socket.io'
|
||||
import * as Automerge from 'automerge'
|
||||
import { Node } from 'slate'
|
||||
import { Server } from 'http'
|
||||
@ -8,26 +8,35 @@ import { getClients } from './utils/index'
|
||||
import { debugCollabBackend } from './utils/debug'
|
||||
import AutomergeBackend from './AutomergeBackend'
|
||||
|
||||
interface ErrorData {
|
||||
user: any
|
||||
docId: string
|
||||
serializedData: string
|
||||
opData?: string
|
||||
}
|
||||
|
||||
export interface IAutomergeCollaborationOptions {
|
||||
entry: Server
|
||||
connectOpts?: SocketIO.ServerOptions
|
||||
defaultValue: Node[]
|
||||
saveFrequency?: number
|
||||
onAuthRequest?: (query: any, socket?: SocketIO.Socket) => Promise<any>
|
||||
onDocumentLoad?: (pathname: string, query?: any) => Promise<Node[]> | Node[]
|
||||
onDocumentLoad?: (docId: string, query?: any) => Promise<Node[]> | Node[]
|
||||
onDocumentSave?: (
|
||||
pathname: string,
|
||||
docId: string,
|
||||
doc: Node[],
|
||||
user: any
|
||||
) => Promise<void> | void
|
||||
onDisconnect?: (pathname: string, user: any) => Promise<void> | void
|
||||
onDisconnect?: (docId: string, user: any) => Promise<void> | void
|
||||
onError: (error: Error, data: ErrorData) => Promise<void> | void
|
||||
}
|
||||
|
||||
export default class AutomergeCollaboration {
|
||||
private io: SocketIO.Server
|
||||
private options: IAutomergeCollaborationOptions
|
||||
private backend: AutomergeBackend
|
||||
public backend: AutomergeBackend
|
||||
private userMap: { [key: string]: any | undefined }
|
||||
private autoSaveDoc: (socket: SocketIO.Socket, docId: string) => void
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
@ -44,6 +53,15 @@ export default class AutomergeCollaboration {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -57,6 +75,24 @@ export default class AutomergeCollaboration {
|
||||
.use(this.authMiddleware)
|
||||
.on('connect', this.onConnect)
|
||||
|
||||
/**
|
||||
* Construct error data and call onError callback
|
||||
*/
|
||||
private handleError(socket: SocketIO.Socket, err: Error, opData?: string) {
|
||||
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: docId,
|
||||
serializedData: document ? Automerge.save(document) : 'No document',
|
||||
opData
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespace SocketIO middleware. Load document value and append it to CollaborationBackend.
|
||||
*/
|
||||
@ -73,13 +109,12 @@ export default class AutomergeCollaboration {
|
||||
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))
|
||||
socket.on('disconnect', this.onDisconnect(socket))
|
||||
|
||||
if (onAuthRequest) {
|
||||
const user = await onAuthRequest(query, socket)
|
||||
@ -98,55 +133,65 @@ export default class AutomergeCollaboration {
|
||||
|
||||
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 { name: docId } = socket.nsp
|
||||
const { onDocumentLoad } = this.options
|
||||
|
||||
if (!this.backend.getDocument(name)) {
|
||||
const doc = onDocumentLoad
|
||||
? await onDocumentLoad(name)
|
||||
: this.options.defaultValue
|
||||
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 ready state after async operation
|
||||
if (conn.readyState === 'closed') {
|
||||
return
|
||||
// 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\t\t%s', id)
|
||||
this.backend.appendDocument(docId, doc)
|
||||
}
|
||||
}
|
||||
|
||||
// recheck backend getDocument after async operation
|
||||
if (!this.backend.getDocument(name)) {
|
||||
debugCollabBackend('Append document\t\t%s', id)
|
||||
this.backend.appendDocument(name, doc)
|
||||
}
|
||||
}
|
||||
// Create a new backend connection for this socketId and docId
|
||||
debugCollabBackend('Create connection\t%s', id)
|
||||
this.backend.createConnection(
|
||||
id,
|
||||
docId,
|
||||
({ type, payload }: CollabAction) => {
|
||||
if (payload.docId === docId) {
|
||||
socket.emit('msg', { type, payload: { id: conn.id, ...payload } })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
// 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',
|
||||
name,
|
||||
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)
|
||||
@ -154,47 +199,41 @@ export default class AutomergeCollaboration {
|
||||
|
||||
debugCollabBackend('Open connection\t\t%s', id)
|
||||
this.backend.openConnection(id)
|
||||
})
|
||||
|
||||
this.garbageCursors(name)
|
||||
this.garbageCursors(socket)
|
||||
} catch (err) {
|
||||
this.handleError(socket, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On 'message' handler
|
||||
*/
|
||||
|
||||
private onMessage = (id: string, name: string) => (data: any) => {
|
||||
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(id, name)
|
||||
this.autoSaveDoc(socket, docId)
|
||||
|
||||
this.garbageCursors(name)
|
||||
this.garbageCursors(socket)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
this.handleError(socket, e, JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save document with throttle
|
||||
*/
|
||||
|
||||
private autoSaveDoc = throttle(
|
||||
async (id: string, docId: string) =>
|
||||
this.backend.getDocument(docId) && this.saveDocument(id, docId),
|
||||
// @ts-ignore: property used before initialization
|
||||
this.options?.saveFrequency || 2000
|
||||
)
|
||||
|
||||
/**
|
||||
* Save document
|
||||
*/
|
||||
|
||||
private saveDocument = async (id: string, docId: string) => {
|
||||
private saveDocument = async (socket: SocketIO.Socket, docId: string) => {
|
||||
try {
|
||||
const { id } = socket
|
||||
const { onDocumentSave } = this.options
|
||||
|
||||
const doc = this.backend.getDocument(docId)
|
||||
@ -212,7 +251,7 @@ export default class AutomergeCollaboration {
|
||||
await onDocumentSave(docId, toJS(doc.children), user)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e, docId)
|
||||
this.handleError(socket, e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,43 +259,56 @@ export default class AutomergeCollaboration {
|
||||
* On 'disconnect' handler
|
||||
*/
|
||||
|
||||
private onDisconnect = (id: string, socket: SocketIO.Socket) => async () => {
|
||||
debugCollabBackend('Connection closed\t%s', id)
|
||||
this.backend.closeConnection(id)
|
||||
private onDisconnect = (socket: SocketIO.Socket) => async () => {
|
||||
try {
|
||||
const { id } = socket
|
||||
const { name: docId } = socket.nsp
|
||||
socket.leave(docId)
|
||||
|
||||
await this.saveDocument(id, socket.nsp.name)
|
||||
debugCollabBackend('Connection closed\t%s', id)
|
||||
this.backend.closeConnection(id)
|
||||
|
||||
// trigger onDisconnect callback if one was provided
|
||||
// and if a user has been loaded for this socket connection
|
||||
const user = this.userMap[id]
|
||||
if (this.options.onDisconnect && user) {
|
||||
await this.options.onDisconnect(socket.nsp.name, user)
|
||||
await this.saveDocument(socket, docId)
|
||||
|
||||
// trigger onDisconnect callback if one was provided
|
||||
// and if a user has been loaded for this socket connection
|
||||
const user = this.userMap[id]
|
||||
if (this.options.onDisconnect && user) {
|
||||
await this.options.onDisconnect(docId, user)
|
||||
}
|
||||
|
||||
// cleanup automerge cursor and socket connection
|
||||
this.garbageCursors(socket)
|
||||
socket.leave(id)
|
||||
this.garbageNsp(socket)
|
||||
|
||||
// cleanup usermap
|
||||
delete this.userMap[id]
|
||||
} catch (err) {
|
||||
this.handleError(socket, err)
|
||||
}
|
||||
|
||||
// cleanup automerge cursor and socket connection
|
||||
this.garbageCursors(socket.nsp.name)
|
||||
socket.leave(id)
|
||||
this.garbageNsp(id)
|
||||
|
||||
// cleanup usermap
|
||||
delete this.userMap[id]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up unused SocketIO namespaces.
|
||||
*/
|
||||
|
||||
garbageNsp = (id: string) => {
|
||||
garbageNsp = (socket: SocketIO.Socket) => {
|
||||
const { name: docId } = socket.nsp
|
||||
Object.keys(this.io.nsps)
|
||||
.filter(n => n !== '/')
|
||||
.forEach(nsp => {
|
||||
getClients(this.io, nsp).then((clientsList: any) => {
|
||||
if (!clientsList.length) {
|
||||
debugCollabBackend('Removing document\t%s', id)
|
||||
this.backend.removeDocument(nsp)
|
||||
delete this.io.nsps[nsp]
|
||||
}
|
||||
})
|
||||
getClients(this.io, nsp)
|
||||
.then((clientsList: any) => {
|
||||
if (!clientsList.length) {
|
||||
debugCollabBackend('Removing document\t%s', docId)
|
||||
this.backend.removeDocument(nsp)
|
||||
delete this.io.nsps[nsp]
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
this.handleError(socket, err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -264,19 +316,24 @@ export default class AutomergeCollaboration {
|
||||
* 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
|
||||
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(nsp)
|
||||
const namespace = this.io.of(docId)
|
||||
|
||||
Object.keys(doc?.cursors)?.forEach(key => {
|
||||
if (!namespace.sockets[key]) {
|
||||
debugCollabBackend('Garbage cursor\t\t%s', key)
|
||||
this.backend.garbageCursor(nsp, key)
|
||||
}
|
||||
})
|
||||
Object.keys(doc?.cursors)?.forEach(key => {
|
||||
if (!namespace.sockets[key]) {
|
||||
debugCollabBackend('Garbage cursor\t\t%s', key)
|
||||
this.backend.garbageCursor(docId, key)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
this.handleError(socket, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -1,3 +1,3 @@
|
||||
import SocketIOConnection from './SocketIOConnection'
|
||||
import AutomergeCollaboration from './AutomergeCollaboration'
|
||||
|
||||
export { SocketIOConnection }
|
||||
export { AutomergeCollaboration }
|
||||
|
@ -1,3 +1,3 @@
|
||||
import debug from 'debug'
|
||||
|
||||
export const debugCollabBackend = debug('collab-backend')
|
||||
export const debugCollabBackend = debug('app-collab')
|
||||
|
@ -29,6 +29,7 @@
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@hiveteams/collab-bridge": "^0.7.19",
|
||||
"automerge": "0.14.0",
|
||||
"lodash": "^4.17.20",
|
||||
"slate": "0.58.3",
|
||||
"slate-history": "0.58.3",
|
||||
"socket.io-client": "^2.3.0",
|
||||
|
@ -13,35 +13,13 @@ import {
|
||||
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
|
||||
|
||||
automergeCleanup: () => void
|
||||
}
|
||||
import { AutomergeEditor } from './interfaces'
|
||||
|
||||
/**
|
||||
* `AutomergeEditor` contains methods for collaboration-enabled editors.
|
||||
*/
|
||||
|
||||
export const AutomergeEditor = {
|
||||
export const AutomergeConnector = {
|
||||
/**
|
||||
* Create Automerge connection
|
||||
*/
|
||||
@ -62,7 +40,7 @@ export const AutomergeEditor = {
|
||||
const doc = e.docSet.getDoc(docId)
|
||||
|
||||
if (!doc) {
|
||||
throw new TypeError(`Unknown docId: ${docId}!`)
|
||||
throw new TypeError('Cannot apply slate ops for missing docId')
|
||||
}
|
||||
|
||||
let changed: any
|
||||
@ -114,7 +92,7 @@ export const AutomergeEditor = {
|
||||
preserveExternalHistory?: boolean
|
||||
) => {
|
||||
try {
|
||||
const current: any = e.docSet.getDoc(docId)
|
||||
const current = e.docSet.getDoc(docId)
|
||||
|
||||
const updated = e.connection.receiveMsg(data)
|
||||
|
||||
@ -160,7 +138,7 @@ export const AutomergeEditor = {
|
||||
delete d.cursors
|
||||
})
|
||||
|
||||
e.docSet.setDoc(docId, changed)
|
||||
e.docSet.setDoc(docId, changed as any)
|
||||
|
||||
e.onCursor && e.onCursor(null)
|
||||
|
@ -1,185 +0,0 @@
|
||||
// import { createEditor, Element, Node, Transforms } from 'slate'
|
||||
// import * as Automerge from 'automerge'
|
||||
// import withAutomerge, { AutomergeOptions } from './withAutomerge'
|
||||
// import { SyncDoc, toJS } from '@hiveteams/collab-bridge'
|
||||
// import AutomergeCollaboration from '@hiveteams/collab-backend/lib/AutomergeCollaboration'
|
||||
// import { insertText } from '../../bridge/src/apply/text'
|
||||
|
||||
// describe('automerge editor client tests', () => {
|
||||
// const docId = 'test'
|
||||
// const automergeOptions: AutomergeOptions = {
|
||||
// docId,
|
||||
// onError: msg => console.log('Encountered test error', msg)
|
||||
// }
|
||||
// const editor = withAutomerge(createEditor(), automergeOptions)
|
||||
// const automergeBackend = new AutomergeBackend()
|
||||
// const backendSend = (msg: any) => {
|
||||
// serverMessages.push(msg)
|
||||
// }
|
||||
// const clientId = 'test-client'
|
||||
// editor.clientId = clientId
|
||||
|
||||
// /**
|
||||
// * Initialize a basic automerge backend
|
||||
// */
|
||||
|
||||
// // Create a new server automerge connection with a basic send function
|
||||
// let serverMessages: any[] = []
|
||||
// automergeBackend.appendDocument(docId, [
|
||||
// { type: 'paragraph', children: [{ text: 'Hi' }] }
|
||||
// ])
|
||||
// automergeBackend.createConnection(clientId, docId, backendSend)
|
||||
|
||||
// // define an editor send function for the clientside automerge editor
|
||||
// let clientMessages: any[] = []
|
||||
// editor.send = (msg: any) => {
|
||||
// clientMessages.push(msg)
|
||||
// }
|
||||
|
||||
// automergeBackend.openConnection(clientId)
|
||||
// // open the editor connection
|
||||
// editor.openConnection()
|
||||
|
||||
// /**
|
||||
// * Helper function to flush client messages and send them to the server
|
||||
// */
|
||||
// const sendClientMessagesToServer = () => {
|
||||
// if (!clientMessages.length) return
|
||||
|
||||
// console.log('clientMessages', JSON.stringify(clientMessages))
|
||||
// clientMessages.forEach(msg => {
|
||||
// automergeBackend.receiveOperation(clientId, msg)
|
||||
// })
|
||||
// clientMessages = []
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Helper function to flush server messages and send them to the client
|
||||
// */
|
||||
// const receiveMessagesFromServer = () => {
|
||||
// if (!serverMessages.length) return
|
||||
|
||||
// console.log('serverMessages', JSON.stringify(serverMessages))
|
||||
// serverMessages.forEach(msg => {
|
||||
// editor.receiveOperation(msg.payload)
|
||||
// })
|
||||
// serverMessages = []
|
||||
// }
|
||||
|
||||
// afterEach(() => {
|
||||
// sendClientMessagesToServer()
|
||||
// receiveMessagesFromServer()
|
||||
// })
|
||||
|
||||
// it('should properly receiveDocument', () => {
|
||||
// const initialDocData = Automerge.save(automergeBackend.getDocument(docId))
|
||||
// editor.receiveDocument(initialDocData)
|
||||
|
||||
// expect(editor.children.length).toEqual(1)
|
||||
// const paragraphNode = editor.children[0] as Element
|
||||
// expect(paragraphNode.type).toEqual('paragraph')
|
||||
// expect(paragraphNode.children.length).toEqual(1)
|
||||
// expect(Node.string(paragraphNode)).toEqual('Hi')
|
||||
// })
|
||||
|
||||
// it('should sync insert node operation with server', done => {
|
||||
// Transforms.insertNodes(editor, {
|
||||
// type: 'paragraph',
|
||||
// children: [{ text: 'a' }]
|
||||
// })
|
||||
|
||||
// // ensure that we eventually send a message for the insert_node oepration
|
||||
// const handle = setInterval(() => {
|
||||
// sendClientMessagesToServer()
|
||||
// receiveMessagesFromServer()
|
||||
|
||||
// const serverDoc = toJS(automergeBackend.getDocument(docId))
|
||||
// if (serverDoc.children.length === 2) {
|
||||
// const paragraphNode = serverDoc.children[1]
|
||||
// expect(Node.string(paragraphNode)).toEqual('a')
|
||||
// clearInterval(handle)
|
||||
// done()
|
||||
// }
|
||||
// }, 10)
|
||||
// })
|
||||
|
||||
// it('should sync insert text operation with client', done => {
|
||||
// const serverDoc = automergeBackend.getDocument(docId)
|
||||
|
||||
// const updatedServerDoc = Automerge.change(serverDoc, newServerDoc => {
|
||||
// insertText(newServerDoc as any, {
|
||||
// type: 'insert_text',
|
||||
// path: [1, 0],
|
||||
// offset: 1,
|
||||
// text: 'b'
|
||||
// })
|
||||
// })
|
||||
// automergeBackend.documentSetMap[docId].setDoc(docId, updatedServerDoc)
|
||||
|
||||
// // ensure that we eventually send a message for the insert_node oepration
|
||||
// const handle = setInterval(() => {
|
||||
// sendClientMessagesToServer()
|
||||
// receiveMessagesFromServer()
|
||||
// const [, secondParagraph] = editor.children
|
||||
// if (Node.string(secondParagraph) === 'ab') {
|
||||
// clearInterval(handle)
|
||||
// done()
|
||||
// }
|
||||
// }, 10)
|
||||
// })
|
||||
|
||||
// it('should reapply server state client side when server restarts', done => {
|
||||
// automergeBackend.closeConnection(clientId)
|
||||
// automergeBackend.removeDocument(docId)
|
||||
// automergeBackend.appendDocument(docId, [
|
||||
// { type: 'paragraph', children: [{ text: 'Hi' }] }
|
||||
// ])
|
||||
// automergeBackend.createConnection(clientId, docId, backendSend)
|
||||
// automergeBackend.openConnection(clientId)
|
||||
|
||||
// const docData = Automerge.save(automergeBackend.getDocument(docId))
|
||||
// editor.receiveDocument(docData)
|
||||
|
||||
// const handle = setInterval(() => {
|
||||
// sendClientMessagesToServer()
|
||||
// receiveMessagesFromServer()
|
||||
// console.log('server doc', toJS(automergeBackend.getDocument(docId)))
|
||||
// if (editor.children.length === 1) {
|
||||
// done()
|
||||
// clearInterval(handle)
|
||||
// }
|
||||
// }, 1000)
|
||||
// })
|
||||
|
||||
// // it('should ? on client restart', done => {
|
||||
// // editor.closeConnection()
|
||||
|
||||
// // Transforms.insertNodes(
|
||||
// // editor,
|
||||
// // {
|
||||
// // type: 'paragraph',
|
||||
// // children: [{ text: 'a' }]
|
||||
// // },
|
||||
// // { at: [1] }
|
||||
// // )
|
||||
|
||||
// // editor.openConnection()
|
||||
// // const docData = Automerge.save(automergeBackend.getDocument(docId))
|
||||
// // editor.receiveDocument(docData)
|
||||
// // // ensure that we eventually send a message for the insert_node operation
|
||||
// // const handle = setInterval(() => {
|
||||
// // sendClientMessagesToServer()
|
||||
// // receiveMessagesFromServer()
|
||||
|
||||
// // const serverDoc = toJS(automergeBackend.getDocument(docId))
|
||||
// // console.log(JSON.stringify(serverDoc))
|
||||
// // console.log(editor.children)
|
||||
// // if (serverDoc.children.length === 2) {
|
||||
// // const paragraphNode = serverDoc.children[1]
|
||||
// // expect(Node.string(paragraphNode)).toEqual('a')
|
||||
// // clearInterval(handle)
|
||||
// // done()
|
||||
// // }
|
||||
// // }, 1000)
|
||||
// // })
|
||||
// })
|
@ -1,185 +1,193 @@
|
||||
import { createEditor, Element, Node, Transforms } from 'slate'
|
||||
import * as Automerge from 'automerge'
|
||||
import withAutomerge, { AutomergeOptions } from './withAutomerge'
|
||||
import { SyncDoc, toJS } from '@hiveteams/collab-bridge'
|
||||
import AutomergeBackend from '@hiveteams/collab-backend/lib/AutomergeBackend'
|
||||
import { insertText } from '../../bridge/src/apply/text'
|
||||
import { createServer } from 'http'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { createEditor, Node, Transforms } from 'slate'
|
||||
import { toJS } 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', () => {
|
||||
const docId = 'test'
|
||||
const automergeOptions: AutomergeOptions = {
|
||||
docId,
|
||||
onError: msg => console.log('Encountered test error', msg)
|
||||
}
|
||||
const editor = withAutomerge(createEditor(), automergeOptions)
|
||||
const automergeBackend = new AutomergeBackend()
|
||||
const backendSend = (msg: any) => {
|
||||
serverMessages.push(msg)
|
||||
}
|
||||
const clientId = 'test-client'
|
||||
editor.clientId = clientId
|
||||
|
||||
/**
|
||||
* Initialize a basic automerge backend
|
||||
*/
|
||||
|
||||
// Create a new server automerge connection with a basic send function
|
||||
let serverMessages: any[] = []
|
||||
automergeBackend.appendDocument(docId, [
|
||||
{ type: 'paragraph', children: [{ text: 'Hi' }] }
|
||||
])
|
||||
automergeBackend.createConnection(clientId, docId, backendSend)
|
||||
|
||||
// define an editor send function for the clientside automerge editor
|
||||
let clientMessages: any[] = []
|
||||
editor.send = (msg: any) => {
|
||||
clientMessages.push(msg)
|
||||
}
|
||||
|
||||
automergeBackend.openConnection(clientId)
|
||||
// open the editor connection
|
||||
editor.openConnection()
|
||||
|
||||
/**
|
||||
* Helper function to flush client messages and send them to the server
|
||||
*/
|
||||
const sendClientMessagesToServer = () => {
|
||||
if (!clientMessages.length) return
|
||||
|
||||
console.log('clientMessages', JSON.stringify(clientMessages))
|
||||
clientMessages.forEach(msg => {
|
||||
automergeBackend.receiveOperation(clientId, msg)
|
||||
})
|
||||
clientMessages = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to flush server messages and send them to the client
|
||||
*/
|
||||
const receiveMessagesFromServer = () => {
|
||||
if (!serverMessages.length) return
|
||||
|
||||
console.log('serverMessages', JSON.stringify(serverMessages))
|
||||
serverMessages.forEach(msg => {
|
||||
editor.receiveOperation(msg.payload)
|
||||
})
|
||||
serverMessages = []
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
sendClientMessagesToServer()
|
||||
receiveMessagesFromServer()
|
||||
beforeAll(done => {
|
||||
//pass a callback to tell jest it is async
|
||||
//start the server before any test
|
||||
server.listen(5000, () => done())
|
||||
})
|
||||
|
||||
it('should properly receiveDocument', () => {
|
||||
const initialDocData = Automerge.save(automergeBackend.getDocument(docId))
|
||||
editor.receiveDocument(initialDocData)
|
||||
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)
|
||||
const paragraphNode = editor.children[0] as Element
|
||||
expect(paragraphNode.type).toEqual('paragraph')
|
||||
expect(paragraphNode.children.length).toEqual(1)
|
||||
expect(Node.string(paragraphNode)).toEqual('Hi')
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('should sync insert node operation with server', done => {
|
||||
Transforms.insertNodes(editor, {
|
||||
type: 'paragraph',
|
||||
children: [{ text: 'a' }]
|
||||
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
|
||||
})
|
||||
|
||||
// ensure that we eventually send a message for the insert_node oepration
|
||||
const handle = setInterval(() => {
|
||||
sendClientMessagesToServer()
|
||||
receiveMessagesFromServer()
|
||||
|
||||
const serverDoc = toJS(automergeBackend.getDocument(docId))
|
||||
if (serverDoc.children.length === 2) {
|
||||
const paragraphNode = serverDoc.children[1]
|
||||
expect(Node.string(paragraphNode)).toEqual('a')
|
||||
clearInterval(handle)
|
||||
done()
|
||||
}
|
||||
}, 10)
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('should sync insert text operation with client', done => {
|
||||
const serverDoc = automergeBackend.getDocument(docId)
|
||||
it('should sync updates across two clients', async () => {
|
||||
const editor1 = await createCollabEditor()
|
||||
const editor2 = await createCollabEditor()
|
||||
|
||||
const updatedServerDoc = Automerge.change(serverDoc, newServerDoc => {
|
||||
insertText(newServerDoc as any, {
|
||||
type: 'insert_text',
|
||||
path: [1, 0],
|
||||
offset: 1,
|
||||
text: 'b'
|
||||
})
|
||||
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
|
||||
})
|
||||
automergeBackend.documentSetMap[docId].setDoc(docId, updatedServerDoc)
|
||||
|
||||
// ensure that we eventually send a message for the insert_node oepration
|
||||
const handle = setInterval(() => {
|
||||
sendClientMessagesToServer()
|
||||
receiveMessagesFromServer()
|
||||
const [, secondParagraph] = editor.children
|
||||
if (Node.string(secondParagraph) === 'ab') {
|
||||
clearInterval(handle)
|
||||
done()
|
||||
}
|
||||
}, 10)
|
||||
editor1.destroy()
|
||||
editor2.destroy()
|
||||
})
|
||||
|
||||
it('should reapply server state client side when server restarts', done => {
|
||||
automergeBackend.closeConnection(clientId)
|
||||
automergeBackend.removeDocument(docId)
|
||||
automergeBackend.appendDocument(docId, [
|
||||
{ type: 'paragraph', children: [{ text: 'Hi' }] }
|
||||
])
|
||||
automergeBackend.createConnection(clientId, docId, backendSend)
|
||||
automergeBackend.openConnection(clientId)
|
||||
it('should sync offline changes on reconnect', async () => {
|
||||
const editor1 = await createCollabEditor()
|
||||
const editor2 = await createCollabEditor()
|
||||
|
||||
const docData = Automerge.save(automergeBackend.getDocument(docId))
|
||||
editor.receiveDocument(docData)
|
||||
editor1.insertNode({ type: 'paragraph', children: [{ text: 'hi' }] })
|
||||
|
||||
const handle = setInterval(() => {
|
||||
sendClientMessagesToServer()
|
||||
receiveMessagesFromServer()
|
||||
console.log('server doc', toJS(automergeBackend.getDocument(docId)))
|
||||
if (editor.children.length === 1) {
|
||||
done()
|
||||
clearInterval(handle)
|
||||
}
|
||||
}, 1000)
|
||||
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 ? on client restart', done => {
|
||||
// editor.closeConnection()
|
||||
it('should work with concurrent edits', async () => {
|
||||
const editor1 = await createCollabEditor()
|
||||
const editor2 = await createCollabEditor()
|
||||
|
||||
// Transforms.insertNodes(
|
||||
// editor,
|
||||
// {
|
||||
// type: 'paragraph',
|
||||
// children: [{ text: 'a' }]
|
||||
// },
|
||||
// { at: [1] }
|
||||
// )
|
||||
const numEdits = 10
|
||||
for (let i = 0; i < numEdits; i++) {
|
||||
editor1.insertNode({ type: 'paragraph', children: [{ text: '' }] })
|
||||
editor2.insertNode({ type: 'paragraph', children: [{ text: '' }] })
|
||||
}
|
||||
|
||||
// editor.openConnection()
|
||||
// const docData = Automerge.save(automergeBackend.getDocument(docId))
|
||||
// editor.receiveDocument(docData)
|
||||
// // ensure that we eventually send a message for the insert_node operation
|
||||
// const handle = setInterval(() => {
|
||||
// sendClientMessagesToServer()
|
||||
// receiveMessagesFromServer()
|
||||
await waitForCondition(() => {
|
||||
return (
|
||||
editor1.children.length === numEdits * 2 + 1 &&
|
||||
editor2.children.length === numEdits * 2 + 1
|
||||
)
|
||||
})
|
||||
|
||||
// const serverDoc = toJS(automergeBackend.getDocument(docId))
|
||||
// console.log(JSON.stringify(serverDoc))
|
||||
// console.log(editor.children)
|
||||
// if (serverDoc.children.length === 2) {
|
||||
// const paragraphNode = serverDoc.children[1]
|
||||
// expect(Node.string(paragraphNode)).toEqual('a')
|
||||
// clearInterval(handle)
|
||||
// done()
|
||||
// }
|
||||
// }, 1000)
|
||||
// })
|
||||
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()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
collabBackend.destroy()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
58
packages/client/src/interfaces.ts
Normal file
58
packages/client/src/interfaces.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import Automerge from 'automerge'
|
||||
import { Editor } from 'slate'
|
||||
import { CollabAction, CursorData, SyncDoc } from '@hiveteams/collab-bridge'
|
||||
|
||||
interface ErrorData {
|
||||
docId: string
|
||||
serializedData: string
|
||||
opData?: string
|
||||
slateOperations?: string
|
||||
}
|
||||
|
||||
export interface AutomergeOptions {
|
||||
docId: string
|
||||
cursorData?: CursorData
|
||||
preserveExternalHistory?: boolean
|
||||
onError?: (msg: string | Error, data: ErrorData) => 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
|
||||
|
||||
gabageCursor: () => void
|
||||
|
||||
onCursor: (data: any) => void
|
||||
|
||||
handleError: (err: Error | string, opData?: string) => void
|
||||
}
|
||||
|
||||
export interface SocketIOPluginOptions {
|
||||
url: string
|
||||
connectOpts: SocketIOClient.ConnectOpts
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
onError?: (msg: string | Error, data: ErrorData) => void
|
||||
}
|
||||
|
||||
export interface WithSocketIOEditor {
|
||||
clientId: string
|
||||
socket: SocketIOClient.Socket
|
||||
connect: () => void
|
||||
disconnect: () => void
|
||||
send: (op: CollabAction) => void
|
||||
receive: (op: CollabAction) => void
|
||||
destroy: () => void
|
||||
}
|
@ -4,8 +4,8 @@ import { Text, Range, Path, NodeEntry } from 'slate'
|
||||
|
||||
import { toJS, Cursor, Cursors } from '@hiveteams/collab-bridge'
|
||||
|
||||
import { AutomergeEditor } from './automerge-editor'
|
||||
import useMounted from './useMounted'
|
||||
import { AutomergeEditor } from './interfaces'
|
||||
|
||||
const useCursor = (
|
||||
e: AutomergeEditor
|
||||
|
@ -2,59 +2,69 @@ import Automerge from 'automerge'
|
||||
|
||||
import { Editor } from 'slate'
|
||||
|
||||
import { AutomergeEditor } from './automerge-editor'
|
||||
import { AutomergeConnector } from './automerge-connector'
|
||||
|
||||
import { CursorData, CollabAction } from '@hiveteams/collab-bridge'
|
||||
|
||||
export interface AutomergeOptions {
|
||||
docId: string
|
||||
cursorData?: CursorData
|
||||
preserveExternalHistory?: boolean
|
||||
onError?: (msg: string | Error) => void
|
||||
}
|
||||
import { CollabAction } from '@hiveteams/collab-bridge'
|
||||
import {
|
||||
AutomergeEditor,
|
||||
AutomergeOptions,
|
||||
WithSocketIOEditor
|
||||
} from './interfaces'
|
||||
|
||||
/**
|
||||
* The `withAutomerge` plugin contains core collaboration logic.
|
||||
*/
|
||||
|
||||
const withAutomerge = <T extends Editor>(
|
||||
editor: T,
|
||||
slateEditor: T,
|
||||
options: AutomergeOptions
|
||||
) => {
|
||||
const e = editor as T & AutomergeEditor
|
||||
const { docId, cursorData, preserveExternalHistory } = options || {}
|
||||
|
||||
const { onChange } = e
|
||||
const editor = slateEditor as T & AutomergeEditor & WithSocketIOEditor
|
||||
|
||||
const {
|
||||
docId,
|
||||
cursorData,
|
||||
preserveExternalHistory,
|
||||
onError = (err: string | Error) => console.log('AutomergeEditor error', err)
|
||||
} = options || {}
|
||||
const { onChange } = editor
|
||||
|
||||
e.docSet = new Automerge.DocSet()
|
||||
editor.docSet = new Automerge.DocSet()
|
||||
|
||||
/**
|
||||
* Helper function for handling errors
|
||||
*/
|
||||
|
||||
editor.handleError = (err: Error | string, opData?: string) => {
|
||||
const { docId, cursorData, onError } = options
|
||||
if (onError && cursorData) {
|
||||
const document = editor.docSet.getDoc(docId)
|
||||
onError(err, {
|
||||
docId: docId,
|
||||
serializedData: document ? Automerge.save(document) : 'No document',
|
||||
opData,
|
||||
slateOperations: JSON.stringify(editor.operations)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Automerge Connection
|
||||
*/
|
||||
|
||||
e.openConnection = () => {
|
||||
e.connection = AutomergeEditor.createConnection(e, (data: CollabAction) =>
|
||||
//@ts-ignore
|
||||
e.send(data)
|
||||
editor.openConnection = () => {
|
||||
editor.connection = AutomergeConnector.createConnection(
|
||||
editor,
|
||||
(data: CollabAction) => editor.send(data)
|
||||
)
|
||||
|
||||
e.connection.open()
|
||||
editor.connection.open()
|
||||
}
|
||||
|
||||
/**
|
||||
* Close Automerge Connection
|
||||
*/
|
||||
|
||||
e.closeConnection = () => {
|
||||
editor.closeConnection = () => {
|
||||
// close any actively open connections
|
||||
if (e.connection) {
|
||||
e.connection.close()
|
||||
if (editor.connection) {
|
||||
editor.connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,30 +72,25 @@ const withAutomerge = <T extends Editor>(
|
||||
* Clear cursor data
|
||||
*/
|
||||
|
||||
e.gabageCursor = () => {
|
||||
editor.gabageCursor = () => {
|
||||
try {
|
||||
AutomergeEditor.garbageCursor(e, docId)
|
||||
AutomergeConnector.garbageCursor(editor, docId)
|
||||
} catch (err) {
|
||||
console.log('garbageCursor error', err)
|
||||
editor.handleError(err)
|
||||
}
|
||||
}
|
||||
|
||||
e.automergeCleanup = () => {
|
||||
e.docSet = new Automerge.DocSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor onChange
|
||||
*/
|
||||
editor.onChange = () => {
|
||||
const operations = editor.operations
|
||||
|
||||
e.onChange = () => {
|
||||
const operations: any = e.operations
|
||||
|
||||
if (!e.isRemote) {
|
||||
if (!editor.isRemote) {
|
||||
try {
|
||||
AutomergeEditor.applySlateOps(e, docId, operations, cursorData)
|
||||
AutomergeConnector.applySlateOps(editor, docId, operations, cursorData)
|
||||
} catch (err) {
|
||||
onError(err)
|
||||
editor.handleError(err)
|
||||
}
|
||||
|
||||
onChange()
|
||||
@ -96,11 +101,11 @@ const withAutomerge = <T extends Editor>(
|
||||
* Receive document value
|
||||
*/
|
||||
|
||||
e.receiveDocument = data => {
|
||||
editor.receiveDocument = data => {
|
||||
try {
|
||||
AutomergeEditor.receiveDocument(e, docId, data)
|
||||
AutomergeConnector.receiveDocument(editor, docId, data)
|
||||
} catch (err) {
|
||||
onError(err)
|
||||
editor.handleError(err, JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,18 +113,23 @@ const withAutomerge = <T extends Editor>(
|
||||
* Receive Automerge sync operations
|
||||
*/
|
||||
|
||||
e.receiveOperation = data => {
|
||||
editor.receiveOperation = data => {
|
||||
// ignore document updates for differnt docIds
|
||||
if (docId !== data.docId) return
|
||||
|
||||
try {
|
||||
AutomergeEditor.applyOperation(e, docId, data, preserveExternalHistory)
|
||||
AutomergeConnector.applyOperation(
|
||||
editor,
|
||||
docId,
|
||||
data,
|
||||
preserveExternalHistory
|
||||
)
|
||||
} catch (err) {
|
||||
// report any errors during apply operation
|
||||
onError(err)
|
||||
editor.handleError(err, JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
return e
|
||||
return editor
|
||||
}
|
||||
|
||||
export default withAutomerge
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { Editor } from 'slate'
|
||||
import { AutomergeEditor } from './automerge-editor'
|
||||
|
||||
import withAutomerge, { AutomergeOptions } from './withAutomerge'
|
||||
import withSocketIO, {
|
||||
WithSocketIOEditor,
|
||||
SocketIOPluginOptions
|
||||
} from './withSocketIO'
|
||||
import withAutomerge from './withAutomerge'
|
||||
import {
|
||||
AutomergeEditor,
|
||||
AutomergeOptions,
|
||||
SocketIOPluginOptions,
|
||||
WithSocketIOEditor
|
||||
} from './interfaces'
|
||||
import withSocketIO from './withSocketIO'
|
||||
|
||||
/**
|
||||
* The `withIOCollaboration` plugin contains collaboration with SocketIO.
|
||||
|
@ -1,102 +1,85 @@
|
||||
import io from 'socket.io-client'
|
||||
|
||||
import { AutomergeEditor } from './automerge-editor'
|
||||
|
||||
import Automerge from 'automerge'
|
||||
import { CollabAction } from '@hiveteams/collab-bridge'
|
||||
|
||||
export interface SocketIOPluginOptions {
|
||||
url: string
|
||||
connectOpts: SocketIOClient.ConnectOpts
|
||||
|
||||
onConnect?: () => void
|
||||
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
|
||||
}
|
||||
import {
|
||||
AutomergeEditor,
|
||||
AutomergeOptions,
|
||||
SocketIOPluginOptions,
|
||||
WithSocketIOEditor
|
||||
} from './interfaces'
|
||||
|
||||
/**
|
||||
* The `withSocketIO` plugin contains SocketIO layer logic.
|
||||
*/
|
||||
|
||||
const withSocketIO = <T extends AutomergeEditor>(
|
||||
editor: T,
|
||||
options: SocketIOPluginOptions
|
||||
slateEditor: T,
|
||||
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
|
||||
|
||||
const { onConnect, onDisconnect, onError, connectOpts, url } = options
|
||||
|
||||
/**
|
||||
* Connect to Socket.
|
||||
*/
|
||||
|
||||
e.connect = () => {
|
||||
editor.connect = () => {
|
||||
socket = io(url, { ...connectOpts })
|
||||
|
||||
// On socket io connect, open a new automerge connection
|
||||
socket.on('connect', () => {
|
||||
e.clientId = socket.id
|
||||
|
||||
e.openConnection()
|
||||
|
||||
editor.clientId = socket.id
|
||||
editor.openConnection()
|
||||
onConnect && onConnect()
|
||||
})
|
||||
|
||||
// On socket io error
|
||||
socket.on('error', (msg: string) => {
|
||||
onError && onError(msg)
|
||||
editor.handleError(msg)
|
||||
})
|
||||
|
||||
// On socket io msg, process the collab operation
|
||||
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', () => {
|
||||
e.gabageCursor()
|
||||
|
||||
editor.gabageCursor()
|
||||
onDisconnect && onDisconnect()
|
||||
})
|
||||
|
||||
socket.connect()
|
||||
|
||||
return e
|
||||
return editor
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Socket.
|
||||
*/
|
||||
|
||||
e.disconnect = () => {
|
||||
editor.disconnect = () => {
|
||||
socket.removeListener('msg')
|
||||
|
||||
socket.close()
|
||||
|
||||
e.closeConnection()
|
||||
editor.closeConnection()
|
||||
|
||||
return e
|
||||
return editor
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive transport msg.
|
||||
*/
|
||||
|
||||
e.receive = (msg: CollabAction) => {
|
||||
editor.receive = (msg: CollabAction) => {
|
||||
switch (msg.type) {
|
||||
case 'operation':
|
||||
return e.receiveOperation(msg.payload)
|
||||
return editor.receiveOperation(msg.payload)
|
||||
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.
|
||||
*/
|
||||
|
||||
e.send = (msg: CollabAction) => {
|
||||
editor.send = (msg: CollabAction) => {
|
||||
socket.emit('msg', msg)
|
||||
}
|
||||
|
||||
@ -112,13 +95,12 @@ const withSocketIO = <T extends AutomergeEditor>(
|
||||
* Close socket and connection.
|
||||
*/
|
||||
|
||||
e.destroy = () => {
|
||||
editor.destroy = () => {
|
||||
socket.close()
|
||||
e.closeConnection()
|
||||
e.automergeCleanup()
|
||||
editor.closeConnection()
|
||||
}
|
||||
|
||||
return e
|
||||
return editor
|
||||
}
|
||||
|
||||
export default withSocketIO
|
||||
|
@ -7,8 +7,9 @@
|
||||
"outDir": "./lib",
|
||||
"composite": true,
|
||||
"paths": {
|
||||
"@hiveteams/collab-bridge": ["../../bridge"]
|
||||
"@hiveteams/collab-bridge": ["../../bridge"],
|
||||
"@hiveteams/collab-backend": ["../../backend"]
|
||||
}
|
||||
},
|
||||
"references": [{ "path": "../bridge" }]
|
||||
"references": [{ "path": "../bridge" }, { "path": "../backend" }]
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
const { SocketIOConnection } = require('@hiveteams/collab-backend')
|
||||
const { AutomergeCollaboration } = require('@hiveteams/collab-backend')
|
||||
const express = require('express')
|
||||
|
||||
const defaultValue = [
|
||||
@ -36,4 +36,4 @@ const config = {
|
||||
}
|
||||
}
|
||||
|
||||
const connection = new SocketIOConnection(config)
|
||||
const connection = new AutomergeCollaboration(config)
|
||||
|
Loading…
Reference in New Issue
Block a user