Start real-time collaboration for wysiwyg; flitter sockets
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Garrett Mills 2021-01-02 15:12:29 -06:00
parent 157283a1cc
commit 282331d788
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246
6 changed files with 326 additions and 1 deletions

View File

@ -62,6 +62,7 @@ const FlitterUnits = {
* Custom units should be specified here. They will be loaded in order
* after the core of Flitter has been initialized.
*/
'Socket' : require('flitter-socket/SocketUnit'),
'Ionic' : require('./app/IonicUnit'),
// 'CustomUnit' : new CustomUnit(),

View File

@ -2,12 +2,30 @@ const Unit = require('libflitter/Unit')
const cors = require('cors')
class CORSUnit extends Unit {
static get services() {
return [...super.services, 'output']
}
static get name() {
return 'cors'
}
async go(app) {
app.express.use(cors())
app.express.use(cors({
origin: (origin, callback) => {
const allowed = [
'https://noded.garrettmills.dev',
'https://noded-dev.garrettmills.dev',
'http://noded.garrettmills.dev',
'http://noded-dev.garrettmills.dev',
'http://localhost:8000',
'http://localhost:8100'
]
if ( allowed.includes(origin) || !origin ) callback(null, true)
else callback(new Error('Invalid origin.'))
}
}))
}
}

View File

@ -0,0 +1,264 @@
const SocketController = require('flitter-socket/Controller')
const uuid = require('uuid').v4
class NormEditorGroup {
constructor(page, node) {
this.id = `${page.UUID}-${node.UUID}`
this.page = page
this.node = node
this.connections = {}
this.connection_id_x_session = {}
}
join(connection_manager) {
this.connections[connection_manager.id] = connection_manager
this.connection_id_x_session[connection_manager.id] = {}
connection_manager.on_close().then(() => {
delete this.connection_id_x_session[connection_manager.id]
delete this.connections[connection_manager.id]
this.blast_editor_group_users()
})
}
has(connection_manager) {
return !!this.connections[connection_manager.id]
}
unique_users() {
const users = {}
const conns = Object.values(this.connections)
for ( const conn of conns ) {
const session = this.session(conn)
const user = conn.request.user
if ( user && !users[user.uuid] ) {
if ( !session.user_color ) {
session.user_color = '#' + Math.floor(Math.random() * 16777215).toString(16)
}
users[user.uuid] = { user, color: session.user_color }
}
}
return Object.values(users)
}
session(conn) {
return this.connection_id_x_session[conn.id]
}
blast_editor_group_users() {
const conns = Object.values(this.connections)
const unique_users = this.unique_users()
for ( const conn of conns ) {
const users = unique_users.filter(data => data.user.uuid !== conn.request.user.uuid)
.map(data => {
const { user, color } = data
return {
uuid: user.uuid,
uid: user.uid,
display: user.uid,
color,
}
})
conn._request('setEditorGroupUsers', { users })
}
}
blast_selections() {
const conns = Object.values(this.connections)
for ( const conn of conns ) {
const selections = conns.filter(maybeConn => conn.id !== maybeConn.id)
.map(otherConn => {
const session = this.session(otherConn)
const user_data = {
uuid: otherConn.request.user.uuid,
uid: otherConn.request.user.uid,
display: otherConn.request.user.uid,
}
if ( session.user_color ) {
user_data.color = session.user_color
}
return {...(this.session(otherConn).selection || {}), user_data}
})
.filter(Boolean)
conn._request('setEditorGroupSelections', { selections })
}
}
set_member_selection(conn, selection) {
const session = this.session(conn)
if ( session ) {
session.selection = selection
this.blast_selections()
}
}
}
class NormEditorController extends SocketController {
editor_groups = {}
static get services() {
return [...super.services, 'output', 'models']
}
ping(transaction, socket) {
this.output.info('Got norm editor socket ping.')
transaction.status(200)
.message('Pong!')
.send(transaction.incoming)
}
_get_editor_group_or_fail(transaction) {
if ( !transaction.incoming.editor_group_id ) {
transaction.status(400)
.message('Missing editor_group_id.')
.send()
return
}
const editor_group = this.editor_groups[transaction.incoming.editor_group_id]
if ( !editor_group || !editor_group.has(transaction.cm) ) {
transaction.status(400)
.message('Invalid editor_group_id.')
.send()
return
}
return editor_group
}
set_member_selection(transaction, socket) {
const editor_group = this._get_editor_group_or_fail(transaction)
if ( !editor_group ) return;
if ( !transaction.incoming.selection ) {
return transaction.status(400)
.message('Missing selection.')
.send()
}
editor_group.set_member_selection(transaction.cm, transaction.incoming.selection)
transaction.status(200).send()
}
get_selections(transaction, socket) {
const editor_group = this._get_editor_group_or_fail(transaction)
if ( !editor_group ) return;
const selections = Object.values(editor_group.connections)
.filter(maybeConn => transaction.cm.id !== maybeConn.id)
.map(otherConn => {
const session = editor_group.session(otherConn)
const user_data = {
uuid: otherConn.request.user.uuid,
uid: otherConn.request.user.uid,
display: otherConn.request.user.uid,
}
if ( session.user_color ) {
user_data.color = session.user_color
}
return {...(editor_group.session(otherConn).selection || {}), user_data}
})
.filter(Boolean)
transaction.status(200)
.send({ selections })
}
get_editor_group_users(transaction, socket) {
const editor_group = this._get_editor_group_or_fail(transaction)
if ( !editor_group ) return;
transaction.status(200)
.send(
editor_group.unique_users()
.filter(data => data.user.uuid !== transaction.cm.request.user.uuid)
.map(data => {
const { user, color } = data
return {
uuid: user.uuid,
uid: user.uid,
display: user.uid,
color,
}
})
)
}
async join_editor_group(transaction, socket) {
// FIXME support versioning
const Page = this.models.get('api:Page')
const Node = this.models.get('api:Node')
if ( !transaction.incoming.PageId ) {
return transaction.status(400)
.message('Missing pageId.')
.send()
}
const page = await Page.findOne({ UUID: transaction.incoming.PageId, Active: true })
if ( !page || !(await page.is_accessible_by(transaction.cm.request.user, 'update')) ) {
return transaction.status(401)
.message('Invalid PageId.')
.send()
}
if ( !transaction.incoming.NodeId ) {
transaction.status(400)
.message('Missing NodeId.')
.send()
}
const node = await Node.findOne({ UUID: transaction.incoming.NodeId, PageId: page.UUID })
if ( !node ) {
transaction.status(400)
.message('Invalid NodeId.')
.send()
}
// Try to look up an existing editor group
const editor_group_id = `${page.UUID}-${node.UUID}`
let editor_group = this.editor_groups[editor_group_id]
if ( !editor_group ) {
// create a new editor group
editor_group = new NormEditorGroup(page, node)
this.editor_groups[editor_group_id] = editor_group
}
if ( !editor_group.has(transaction.cm) )
editor_group.join(transaction.cm)
transaction.status(200)
.message('Joined editor group.')
.send({ editor_group_id })
editor_group.blast_editor_group_users()
}
_client_connected(connection_manager) {
super._client_connected(connection_manager)
this.output.debug('New client connected to norm editor socket.')
if ( !connection_manager.request?.session?.auth?.user_id ) {
delete this.connections[connection_manager.id]
connection_manager.socket.close()
}
}
}
module.exports = exports = NormEditorController

View File

@ -0,0 +1,14 @@
module.exports = exports = {
prefix: '/api/v1/socket',
middleware: [],
socket: {
'/norm-editor': [
'controller::socket:NormEditor',
],
'/norm-editor/.websocket': [
'controller::socket:NormEditor',
],
}
}

View File

@ -24,6 +24,7 @@
"flitter-flap": "^0.5.2",
"flitter-forms": "^0.8.1",
"flitter-orm": "^0.4.0",
"flitter-socket": "^0.8.1",
"flitter-upload": "^0.9.2",
"jsonwebtoken": "^8.5.1",
"libflitter": "^0.58.1",

View File

@ -486,6 +486,11 @@ ast-types@0.9.6:
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.6.tgz#102c9e9e9005d3e7e3829bf0c4fa24ee862ee9b9"
integrity sha1-ECyenpAF0+fjgpvwxPok7oYu6bk=
async-limiter@~1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
aws-sdk@^2.792.0:
version "2.792.0"
resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.792.0.tgz#d124a6074244a4675e0416887734e8f6934bdd30"
@ -1516,6 +1521,13 @@ express-session@^1.15.6:
safe-buffer "5.1.2"
uid-safe "~2.1.5"
express-ws@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/express-ws/-/express-ws-4.0.0.tgz#dabd8dc974516418902a41fe6e30ed949b4d36c4"
integrity sha512-KEyUw8AwRET2iFjFsI1EJQrJ/fHeGiJtgpYgEWG3yDv4l/To/m3a2GaYfeGyB3lsWdvbesjF5XCMx+SVBgAAYw==
dependencies:
ws "^5.2.0"
express@^4.16.4:
version "4.16.4"
resolved "https://registry.yarnpkg.com/express/-/express-4.16.4.tgz#fddef61926109e24c515ea97fd2f1bdbf62df12e"
@ -1705,6 +1717,14 @@ flitter-orm@^0.4.0:
sinon "^9.0.0"
uuid "^3.4.0"
flitter-socket@^0.8.1:
version "0.8.1"
resolved "https://registry.yarnpkg.com/flitter-socket/-/flitter-socket-0.8.1.tgz#46dfc8081e98cc06418bb9876d1c16eec06e8a82"
integrity sha512-p1oLytTHGhhl9Qal1pHtlduaEuwfEXwRe9JFDboKLDjFaS2UxR/fzKpO+EM4MCnrRSN3MjtLJoBVr+bPKfVw7g==
dependencies:
express-ws "^4.0.0"
uuid "^3.3.2"
flitter-upload@^0.9.2:
version "0.9.2"
resolved "https://registry.yarnpkg.com/flitter-upload/-/flitter-upload-0.9.2.tgz#1cb7ab9ad5f70be83b1a97d67822480462a0bc3f"
@ -4539,6 +4559,13 @@ write-file-atomic@^3.0.0:
signal-exit "^3.0.2"
typedarray-to-buffer "^3.1.5"
ws@^5.2.0:
version "5.2.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.2.tgz#dffef14866b8e8dc9133582514d1befaf96e980f"
integrity sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==
dependencies:
async-limiter "~1.0.0"
xml2js@0.4.19:
version "0.4.19"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"