mirror of
https://github.com/cudr/slate-collaborative.git
synced 2026-03-02 03:40:18 +00:00
feat: update to slate 0.5x (#10)
Update Slate-Collaboration to be compatible with Slate 0.5x versions.
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
scripts-prepend-node-path=true
|
||||
@@ -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"
|
||||
|
||||
125
packages/backend/src/AutomergeBackend.ts
Normal file
125
packages/backend/src/AutomergeBackend.ts
Normal 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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
237
packages/backend/src/SocketIOConnection.ts
Normal file
237
packages/backend/src/SocketIOConnection.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import Connection from './Connection'
|
||||
import SocketIOConnection from './SocketIOConnection'
|
||||
|
||||
module.exports = Connection
|
||||
export { SocketIOConnection }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user