feat: update to slate 0.5x (#10)

Update Slate-Collaboration to be compatible with Slate 0.5x versions.
This commit is contained in:
George
2020-05-10 16:50:12 +03:00
committed by GitHub
parent fee0098c3d
commit 0fd9390a99
79 changed files with 2017 additions and 1596 deletions

View File

@@ -3,6 +3,7 @@
"plugins": [
"@babel/plugin-transform-runtime",
"@babel/proposal-class-properties",
"@babel/proposal-object-rest-spread"
"@babel/proposal-object-rest-spread",
"@babel/plugin-proposal-optional-chaining"
]
}

View File

@@ -1 +0,0 @@
scripts-prepend-node-path=true

View File

@@ -1,11 +1,11 @@
{
"name": "@slate-collaborative/backend",
"version": "0.0.3",
"version": "0.5.0",
"files": [
"lib"
],
"main": "lib/index.js",
"types": "lib/model.d.ts",
"types": "lib/index.d.ts",
"description": "slate-collaborative bridge",
"repository": {
"type": "git",
@@ -18,20 +18,22 @@
"license": "MIT",
"scripts": {
"prepublishOnly": "yarn run build",
"type-check": "tsc --noEmit",
"build": "yarn run build:types && yarn run build:js",
"build:types": "tsc --emitDeclarationOnly",
"build:js": "babel src --out-dir lib --extensions \".ts,.tsx\" --source-maps inline",
"watch": "yarn build:js -w"
},
"dependencies": {
"@babel/plugin-proposal-optional-chaining": "^7.9.0",
"@babel/runtime": "^7.6.3",
"@slate-collaborative/bridge": "^0.0.3",
"automerge": "^0.12.1",
"@slate-collaborative/bridge": "^0.5.0",
"@types/lodash": "^4.14.150",
"@types/socket.io": "^2.1.4",
"automerge": "^0.14.0",
"lodash": "^4.17.15",
"slate": "^0.47.8",
"socket.io": "^2.2.0",
"typescript": "^3.6.3"
"slate": "^0.57.2",
"socket.io": "^2.3.0",
"typescript": "^3.8.3"
},
"devDependencies": {
"@babel/cli": "^7.6.0",
@@ -40,8 +42,7 @@
"@babel/plugin-proposal-object-rest-spread": "^7.5.5",
"@babel/plugin-transform-runtime": "^7.6.0",
"@babel/preset-env": "^7.6.0",
"@babel/preset-typescript": "^7.6.0",
"@types/socket.io": "^2.1.2"
"@babel/preset-typescript": "^7.6.0"
},
"directories": {
"lib": "lib"

View File

@@ -0,0 +1,125 @@
import * as Automerge from 'automerge'
import { Element } from 'slate'
import {
toCollabAction,
toSync,
SyncDoc,
CollabAction
} from '@slate-collaborative/bridge'
export interface Connections {
[key: string]: Automerge.Connection<SyncDoc>
}
/**
* AutomergeBackend contains collaboration with Automerge
*/
class AutomergeBackend {
connections: Connections = {}
docSet: Automerge.DocSet<SyncDoc> = new Automerge.DocSet()
/**
* Create Autmorge Connection
*/
createConnection = (id: string, send: any) => {
if (this.connections[id]) {
console.warn(
`Already has connection with id: ${id}. It will be terminated before creating new connection`
)
this.closeConnection(id)
}
this.connections[id] = new Automerge.Connection(
this.docSet,
toCollabAction('operation', send)
)
}
/**
* Start Automerge Connection
*/
openConnection = (id: string) => this.connections[id].open()
/**
* Close Automerge Connection and remove it from connections
*/
closeConnection(id: string) {
this.connections[id]?.close()
delete this.connections[id]
}
/**
* Receive and apply operation to Automerge Connection
*/
receiveOperation = (id: string, data: CollabAction) => {
try {
this.connections[id].receiveMsg(data.payload)
} catch (e) {
console.error('Unexpected error in receiveOperation', e)
}
}
/**
* Get document from Automerge DocSet
*/
getDocument = (docId: string) => this.docSet.getDoc(docId)
/**
* Append document to Automerge DocSet
*/
appendDocument = (docId: string, data: Element[]) => {
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.docSet.setDoc(docId, doc)
} catch (e) {
console.error(e, docId)
}
}
/**
* Remove document from Automerge DocSet
*/
removeDocument = (docId: string) => this.docSet.removeDoc(docId)
/**
* Remove client cursor data
*/
garbageCursor = (docId: string, id: string) => {
try {
const doc = this.getDocument(docId)
if (!doc.cursors) return
const change = Automerge.change(doc, d => {
delete d.cursors[id]
})
this.docSet.setDoc(docId, change)
} catch (e) {
console.error('Unexpected error in garbageCursor', e)
}
}
}
export default AutomergeBackend

View File

@@ -1,196 +0,0 @@
import io from 'socket.io'
import { ValueJSON } from 'slate'
import * as Automerge from 'automerge'
import throttle from 'lodash/throttle'
import merge from 'lodash/merge'
import { toSync, toJS } from '@slate-collaborative/bridge'
import { getClients, defaultValue, defaultOptions } from './utils'
import { ConnectionOptions } from './model'
export default class Connection {
private io: any
private docSet: any
private connections: { [key: string]: Automerge.Connection<any> }
private options: ConnectionOptions
constructor(options: ConnectionOptions = defaultOptions) {
this.io = io(options.entry, options.connectOpts)
this.docSet = new Automerge.DocSet()
this.connections = {}
this.options = merge(defaultOptions, options)
this.configure()
return this
}
private configure = () =>
this.io
.of(this.nspMiddleware)
.use(this.authMiddleware)
.on('connect', this.onConnect)
private appendDoc = (path: string, value: ValueJSON) => {
const sync = toSync(value)
sync.annotations = {}
const doc = Automerge.from(sync)
this.docSet.setDoc(path, doc)
}
private saveDoc = throttle(pathname => {
try {
if (this.options.onDocumentSave) {
const doc = this.docSet.getDoc(pathname)
if (doc) {
const data = toJS(doc)
delete data.annotations
this.options.onDocumentSave(pathname, data)
}
}
} catch (e) {
console.log(e)
}
}, (this.options && this.options.saveTreshold) || 2000)
private nspMiddleware = async (path, query, next) => {
const { onDocumentLoad } = this.options
if (!this.docSet.getDoc(path)) {
const valueJson = onDocumentLoad
? await onDocumentLoad(path)
: this.options.defaultValue || defaultValue
if (!valueJson) return next(null, false)
this.appendDoc(path, valueJson)
}
return next(null, true)
}
private authMiddleware = async (socket, next) => {
const { query } = socket.handshake
const { onAuthRequest } = this.options
if (onAuthRequest) {
const permit = await onAuthRequest(query, socket)
if (!permit)
return next(new Error(`Authentification error: ${socket.id}`))
}
return next()
}
private onConnect = socket => {
const { id, conn } = socket
const { name } = socket.nsp
const doc = this.docSet.getDoc(name)
const data = Automerge.save(doc)
this.connections[id] = new Automerge.Connection(this.docSet, data => {
socket.emit('operation', { id: conn.id, ...data })
})
socket.join(id, () => {
this.connections[id].open()
socket.emit('document', data)
})
socket.on('operation', this.onOperation(id, name))
socket.on('disconnect', this.onDisconnect(id, socket))
this.garbageCursors(name)
}
private onOperation = (id, name) => data => {
try {
this.connections[id].receiveMsg(data)
this.saveDoc(name)
this.garbageCursors(name)
} catch (e) {
console.log(e)
}
}
private onDisconnect = (id, socket) => () => {
this.connections[id].close()
delete this.connections[id]
socket.leave(id)
this.garbageCursor(socket.nsp.name, id)
this.garbageCursors(socket.nsp.name)
this.garbageNsp()
}
garbageNsp = () => {
Object.keys(this.io.nsps)
.filter(n => n !== '/')
.forEach(nsp => {
getClients(this.io, nsp).then((clientsList: any[]) => {
if (!clientsList.length) this.removeDoc(nsp)
})
})
}
garbageCursor = (nsp, id) => {
const doc = this.docSet.getDoc(nsp)
if (!doc.annotations) return
const change = Automerge.change(doc, `remove cursor ${id}`, (d: any) => {
delete d.annotations[id]
})
this.docSet.setDoc(nsp, change)
}
garbageCursors = nsp => {
const doc = this.docSet.getDoc(nsp)
if (!doc.annotations) return
const namespace = this.io.of(nsp)
Object.keys(doc.annotations).forEach(key => {
if (
!namespace.sockets[key] &&
doc.annotations[key].type === this.options.cursorAnnotationType
) {
this.garbageCursor(nsp, key)
}
})
}
removeDoc = async nsp => {
const doc = this.docSet.getDoc(nsp)
if (this.options.onDocumentSave) {
await this.options.onDocumentSave(nsp, toJS(doc))
}
this.docSet.removeDoc(nsp)
delete this.io.nsps[nsp]
}
destroy = async () => {
this.io.close()
}
}

View File

@@ -0,0 +1,237 @@
import io from 'socket.io'
import * as Automerge from 'automerge'
import { Element } from 'slate'
import { Server } from 'http'
import throttle from 'lodash/throttle'
import { SyncDoc, CollabAction, toJS } from '@slate-collaborative/bridge'
import { getClients } from './utils'
import AutomergeBackend from './AutomergeBackend'
export interface SocketIOCollaborationOptions {
entry: number | Server
connectOpts?: SocketIO.ServerOptions
defaultValue?: Element[]
saveFrequency?: number
onAuthRequest?: (
query: Object,
socket?: SocketIO.Socket
) => Promise<boolean> | boolean
onDocumentLoad?: (pathname: string, query?: Object) => Element[]
onDocumentSave?: (pathname: string, doc: Element[]) => Promise<void> | void
}
export default class SocketIOCollaboration {
private io: SocketIO.Server
private options: SocketIOCollaborationOptions
private backend: AutomergeBackend
/**
* Constructor
*/
constructor(options: SocketIOCollaborationOptions) {
this.io = io(options.entry, options.connectOpts)
this.backend = new AutomergeBackend()
this.options = options
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) => {
const { onDocumentLoad } = this.options
if (!this.backend.getDocument(path)) {
const doc = onDocumentLoad
? await onDocumentLoad(path)
: this.options.defaultValue
if (!doc) return next(null, false)
this.backend.appendDocument(path, doc)
}
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
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 = (socket: SocketIO.Socket) => {
const { id, conn } = socket
const { name } = socket.nsp
this.backend.createConnection(id, ({ type, payload }: CollabAction) => {
socket.emit('msg', { type, payload: { id: conn.id, ...payload } })
})
socket.on('msg', this.onMessage(id, name))
socket.on('disconnect', this.onDisconnect(id, socket))
socket.join(id, () => {
const doc = this.backend.getDocument(name)
socket.emit('msg', {
type: 'document',
payload: Automerge.save<SyncDoc>(doc)
})
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(name)
this.garbageCursors(name)
} catch (e) {
console.log(e)
}
}
}
/**
* Save document with throttle
*/
private autoSaveDoc = throttle(
async (docId: string) =>
this.backend.getDocument(docId) && this.saveDocument(docId),
this.options?.saveFrequency || 2000
)
/**
* Save document
*/
private saveDocument = async (docId: string) => {
try {
const { onDocumentSave } = this.options
const doc = this.backend.getDocument(docId)
if (!doc) {
throw new Error(`Can't receive document by id: ${docId}`)
}
onDocumentSave && (await onDocumentSave(docId, toJS(doc.children)))
} catch (e) {
console.error(e, docId)
}
}
/**
* On 'disconnect' handler
*/
private onDisconnect = (id: string, socket: SocketIO.Socket) => async () => {
this.backend.closeConnection(id)
await this.saveDocument(socket.nsp.name)
this.garbageCursors(socket.nsp.name)
socket.leave(id)
this.garbageNsp()
}
/**
* Clean up unused SocketIO namespaces.
*/
garbageNsp = () => {
Object.keys(this.io.nsps)
.filter(n => n !== '/')
.forEach(nsp => {
getClients(this.io, nsp).then((clientsList: any) => {
if (!clientsList.length) {
this.backend.removeDocument(nsp)
delete this.io.nsps[nsp]
}
})
})
}
/**
* Clean up unused cursor data.
*/
garbageCursors = (nsp: string) => {
const doc = this.backend.getDocument(nsp)
if (!doc.cursors) return
const namespace = this.io.of(nsp)
Object.keys(doc?.cursors)?.forEach(key => {
if (!namespace.sockets[key]) {
this.backend.garbageCursor(nsp, key)
}
})
}
/**
* Destroy SocketIO connection
*/
destroy = async () => {
this.io.close()
}
}

View File

@@ -1,3 +1,3 @@
import Connection from './Connection'
import SocketIOConnection from './SocketIOConnection'
module.exports = Connection
export { SocketIOConnection }

View File

@@ -1,19 +0,0 @@
import { ValueJSON } from 'slate'
import { Server } from 'http'
export interface ConnectionOptions {
entry: number | Server
connectOpts?: SocketIO.ServerOptions
defaultValue?: ValueJSON
saveTreshold?: number
cursorAnnotationType?: string
onAuthRequest?: (
query: Object,
socket?: SocketIO.Socket
) => Promise<boolean> | boolean
onDocumentLoad?: (
pathname: string,
query?: Object
) => ValueJSON | null | false | undefined
onDocumentSave?: (pathname: string, json: ValueJSON) => Promise<void> | void
}

View File

@@ -1,21 +0,0 @@
import { ValueJSON } from 'slate'
const json: ValueJSON = {
document: {
nodes: [
{
object: 'block',
type: 'paragraph',
nodes: [
{
object: 'text',
marks: [],
text: ''
}
]
}
]
}
}
export default json

View File

@@ -1,14 +1,4 @@
import defaultValue from './defaultValue'
export const getClients = (io, nsp) =>
export const getClients = (io: SocketIO.Server, nsp: string) =>
new Promise((r, j) => {
io.of(nsp).clients((e, c) => (e ? j(e) : r(c)))
io.of(nsp).clients((e: any, c: any) => (e ? j(e) : r(c)))
})
export const defaultOptions = {
entry: 9000,
saveTreshold: 2000,
cursorAnnotationType: 'collaborative_selection'
}
export { defaultValue }

View File

@@ -1,12 +1,16 @@
{
"include": ["src/**/*"],
"extends": "../../tsconfig.base.json",
"include": ["./src/**/*"],
"compilerOptions": {
"outDir": "lib",
"rootDir": "src",
"baseUrl": "src",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"lib": ["dom", "dom.iterable", "esnext"]
}
}
"rootDir": "./src",
"baseUrl": "./src",
"outDir": "./lib",
"composite": true,
"paths": {
"@slate-collaborative/bridge": ["../../bridge"]
}
},
"references": [
{ "path": "../bridge" }
]
}