mirror of
https://github.com/cudr/slate-collaborative.git
synced 2024-10-27 20:34:06 +00:00
Merge pull request #1 from hiveteams/fix/old-state-error
Fix/old state error
This commit is contained in:
commit
ff417d59fd
49
CHANGELOG.md
49
CHANGELOG.md
@ -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)
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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)
|
this.connectionMap[id].receiveMsg(data.payload)
|
||||||
} catch (e) {
|
|
||||||
console.error('Unexpected error in receiveOperation', e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -85,7 +80,6 @@ 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}`)
|
||||||
}
|
}
|
||||||
@ -93,14 +87,8 @@ class AutomergeBackend {
|
|||||||
const sync = toSync({ cursors: {}, children: data })
|
const sync = toSync({ cursors: {}, children: data })
|
||||||
|
|
||||||
const doc = Automerge.from<SyncDoc>(sync)
|
const doc = Automerge.from<SyncDoc>(sync)
|
||||||
|
|
||||||
if (!this.documentSetMap[docId]) {
|
|
||||||
this.documentSetMap[docId] = new Automerge.DocSet<SyncDoc>()
|
this.documentSetMap[docId] = new Automerge.DocSet<SyncDoc>()
|
||||||
}
|
|
||||||
this.documentSetMap[docId].setDoc(docId, doc)
|
this.documentSetMap[docId].setDoc(docId, doc)
|
||||||
} catch (e) {
|
|
||||||
console.error(e, docId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -108,20 +96,18 @@ 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]
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove client cursor data
|
* Remove client cursor data
|
||||||
*/
|
*/
|
||||||
|
|
||||||
garbageCursor = (docId: string, id: string) => {
|
garbageCursor = (docId: string, id: string) => {
|
||||||
try {
|
|
||||||
const doc = this.getDocument(docId)
|
const doc = this.getDocument(docId)
|
||||||
|
|
||||||
|
// no need to delete cursor if the document or cursors have already been deleted
|
||||||
if (!doc || !doc.cursors) return
|
if (!doc || !doc.cursors) return
|
||||||
|
|
||||||
const change = Automerge.change(doc, (d: any) => {
|
const change = Automerge.change(doc, (d: any) => {
|
||||||
@ -129,9 +115,6 @@ class AutomergeBackend {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.documentSetMap[docId].setDoc(docId, change)
|
this.documentSetMap[docId].setDoc(docId, change)
|
||||||
} catch (e) {
|
|
||||||
console.error('Unexpected error in garbageCursor', e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
344
packages/backend/src/AutomergeCollaboration.ts
Normal file
344
packages/backend/src/AutomergeCollaboration.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
@ -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'
|
import debug from 'debug'
|
||||||
|
|
||||||
export const debugCollabBackend = debug('collab-backend')
|
export const debugCollabBackend = debug('app-collab')
|
||||||
|
@ -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" }]
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@hiveteams/collab-bridge",
|
"name": "@hiveteams/collab-bridge",
|
||||||
"version": "0.7.16",
|
"version": "0.7.23",
|
||||||
"files": [
|
"files": [
|
||||||
"lib"
|
"lib"
|
||||||
],
|
],
|
||||||
|
@ -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)
|
||||||
|
51
packages/bridge/src/connection/connection.spec.ts
Normal file
51
packages/bridge/src/connection/connection.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
@ -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)) {
|
||||||
|
@ -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,6 +10,7 @@ export const setCursor = (
|
|||||||
operations: Operation[],
|
operations: Operation[],
|
||||||
cursorData: CursorData
|
cursorData: CursorData
|
||||||
) => {
|
) => {
|
||||||
|
try {
|
||||||
const cursorOps = operations.filter(op => op.type === 'set_selection')
|
const cursorOps = operations.filter(op => op.type === 'set_selection')
|
||||||
|
|
||||||
if (!doc.cursors) doc.cursors = {}
|
if (!doc.cursors) doc.cursors = {}
|
||||||
@ -30,6 +32,9 @@ export const setCursor = (
|
|||||||
} else {
|
} else {
|
||||||
delete doc.cursors[id]
|
delete doc.cursors[id]
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e, toJS(doc))
|
||||||
|
}
|
||||||
|
|
||||||
return doc
|
return doc
|
||||||
}
|
}
|
||||||
|
@ -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]
|
||||||
|
@ -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))
|
||||||
|
|
||||||
|
@ -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?$"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
192
packages/client/src/automerge-connector.ts
Normal file
192
packages/client/src/automerge-connector.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
212
packages/client/src/client.spec.ts
Normal file
212
packages/client/src/client.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
51
packages/client/src/interfaces.ts
Normal file
51
packages/client/src/interfaces.ts
Normal 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
|
||||||
|
}
|
403
packages/client/src/test-json/deep-tree.json
Normal file
403
packages/client/src/test-json/deep-tree.json
Normal file
File diff suppressed because one or more lines are too long
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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" }]
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user