feat(websockets): First SyncEngine appearance

A new SyncEngine module has been added to the JavaScript code. It aims
to sync the local changes with remote ones. This first implementation
relies on a websocket connection.
This commit is contained in:
Alexis Métaireau 2024-04-19 15:22:15 +02:00
parent 1128348db6
commit e2b9b161e6
5 changed files with 276 additions and 18 deletions

View file

@ -13,31 +13,34 @@ import { AjaxAutocomplete, AjaxAutocompleteMultiple } from './autocomplete.js'
import Orderable from './orderable.js'
import Importer from './importer.js'
import Help from './help.js'
import { SyncEngine } from './sync/engine.js'
// Import modules and export them to the global scope.
// For the not yet module-compatible JS out there.
// By alphabetic order
window.U = {
URLs,
Request,
ServerRequest,
RequestError,
HTTPError,
NOKError,
Browser,
Facets,
Panel,
Alert,
Dialog,
Tooltip,
EditPanel,
FullPanel,
Utils,
SCHEMA,
Importer,
Orderable,
Caption,
AjaxAutocomplete,
AjaxAutocompleteMultiple,
Browser,
Caption,
Dialog,
EditPanel,
Facets,
FullPanel,
Help,
HTTPError,
Importer,
NOKError,
Orderable,
Panel,
Request,
RequestError,
SCHEMA,
ServerRequest,
SyncEngine,
Tooltip,
URLs,
Utils,
}

View file

@ -0,0 +1,91 @@
import { WebSocketTransport } from './websocket.js'
import {
MapUpdater,
MarkerUpdater,
PolygonUpdater,
PolylineUpdater,
DatalayerUpdater,
} from './updaters.js'
export class SyncEngine {
constructor(map, webSocketURI, authToken) {
this.map = map
this.receiver = new MessagesDispatcher(this.map)
this.transport = new WebSocketTransport(webSocketURI, authToken, this.receiver)
this.sender = new MessagesSender(this.transport)
this.upsert = this.sender.upsert.bind(this.sender)
this.update = this.sender.update.bind(this.sender)
this.delete = this.sender.delete.bind(this.sender)
}
}
export class MessagesDispatcher {
constructor(map) {
this.map = map
this.updaters = {
map: new MapUpdater(this.map),
marker: new MarkerUpdater(this.map),
polyline: new PolylineUpdater(this.map),
polygon: new PolygonUpdater(this.map),
datalayer: new DatalayerUpdater(this.map),
}
}
getUpdater(subject, metadata) {
switch (subject) {
case 'feature':
const featureTypeExists = Object.keys(this.updaters).includes(
metadata.featureType
)
if (featureTypeExists) {
const updater = this.updaters[metadata.featureType]
console.log(`found updater ${metadata.featureType}, ${updater}`)
return updater
}
case 'map':
case 'datalayer':
return this.updaters[subject]
default:
throw new Error(`Unknown updater ${subject}, ${metadata}`)
}
}
dispatch({ kind, payload }) {
console.log(kind, payload)
if (kind == 'sync-protocol') {
let updater = this.getUpdater(payload.subject, payload.metadata)
updater.applyMessage(payload)
}
}
}
/**
* Sends the message to the other party (using the specified transport):
*
* - `subject` is the type of object this is referering to (map, feature, layer)
* - `metadata` contains information about the object we're refering to (id, layerId for instance)
* - `key` and
* - `value` are the keys and values that are being modified.
*/
export class MessagesSender {
constructor(transport) {
this._transport = transport
}
send(message) {
this._transport.send('sync-protocol', message)
}
upsert(subject, metadata, value) {
this.send({ verb: 'upsert', subject, metadata, value })
}
update(subject, metadata, key, value) {
this.send({ verb: 'update', subject, metadata, key, value })
}
delete(subject, metadata, key) {
this.send({ verb: 'delete', subject, metadata, key })
}
}

View file

@ -0,0 +1,131 @@
/**
* This file contains the updaters: classes that are able to convert messages
* received from another party (or the server) to changes on the map.
*/
class BaseUpdater {
constructor(map) {
this.map = map
}
updateObjectValue(obj, key, value) {
// XXX refactor so it's cleaner
let path = key.split('.')
let what
for (var i = 0, l = path.length; i < l; i++) {
what = path[i]
if (what === path[l - 1]) {
if (typeof value === 'undefined') {
delete obj[what]
} else {
obj[what] = value
}
} else {
obj = obj[what]
}
}
}
getLayerFromID(layerId) {
if (layerId) return this.map.getDataLayerByUmapId(layerId)
return this.map.defaultEditDataLayer()
}
applyMessage(message) {
let { verb } = message
return this[verb](message)
}
}
export class MapUpdater extends BaseUpdater {
update({ key, value }) {
console.log(key, value)
this.updateObjectValue(this.map, key, value)
this.map.renderProperties([key.replace('options.', '')])
}
}
export class DatalayerUpdater extends BaseUpdater {
update({ key, metadata, value }) {
const datalayer = this.getLayerFromID(metadata.id)
console.log(datalayer, key, value)
this.updateObjectValue(datalayer, key, value)
const property = key.replace('options.', '')
datalayer.renderProperties([property])
}
}
/**
* This is an abstract base class
* And needs to be subclassed to be used.
*
* The child classes need to expose:
* - `featureClass`: the name of the class to create the feature
* - `featureArgument`: an object with the properties to pass to the class when bulding it.
**/
class FeatureUpdater extends BaseUpdater {
getFeatureFromMetadata({ id, layerId }) {
const datalayer = this.getLayerFromID(layerId)
return datalayer.getFeatureById(id)
}
// XXX Not sure about the naming. It's returning latlng OR latlngS
getGeometry({ type, coordinates }) {
if (type == 'Point') {
return L.GeoJSON.coordsToLatLng(coordinates)
}
return L.GeoJSON.coordsToLatLngs(coordinates)
}
upsert({ metadata, value }) {
let { id, layerId } = metadata
const datalayer = this.getLayerFromID(layerId)
let feature = this.getFeatureFromMetadata(metadata, value)
feature = datalayer.geometryToFeature({ geometry: value.geometry, id, feature })
feature.addTo(datalayer)
}
update({ key, metadata, value }) {
let feature = this.getFeatureFromMetadata(metadata)
switch (key) {
case 'geometry':
const datalayer = this.getLayerFromID(metadata.layerId)
datalayer.geometryToFeature({ geometry: value, id: metadata.id, feature })
default:
this.updateObjectValue(feature, key, value)
}
feature.datalayer.indexProperties(feature)
feature.renderProperties([key])
}
delete({ metadata }) {
// XXX Distinguish between properties getting deleted
// and the wole feature getting deleted
let feature = this.getFeatureFromMetadata(metadata)
if (feature) feature.del()
}
}
class PathUpdater extends FeatureUpdater {}
class MarkerUpdater extends FeatureUpdater {
featureType = 'marker'
featureClass = U.Marker
featureArgument = 'latlng'
}
class PolygonUpdater extends PathUpdater {
featureType = 'polygon'
featureClass = U.Polygon
featureArgument = 'latlngs'
}
class PolylineUpdater extends PathUpdater {
featureType = 'polyline'
featureClass = U.Polyline
featureArgument = 'latlngs'
}
export { MarkerUpdater, PolygonUpdater, PolylineUpdater }

View file

@ -0,0 +1,22 @@
export class WebSocketTransport {
constructor(webSocketURI, authToken, messagesReceiver) {
this.websocket = new WebSocket(webSocketURI)
this.websocket.onopen = () => {
this.send('join', { token: authToken })
}
this.websocket.addEventListener('message', this.onMessage.bind(this))
this.receiver = messagesReceiver
}
onMessage(wsMessage) {
// XXX validate incoming data.
this.receiver.dispatch(JSON.parse(wsMessage.data))
}
send(kind, payload) {
const message = { ...payload }
message.kind = kind
let encoded = JSON.stringify(message)
this.websocket.send(encoded)
}
}

View file

@ -248,6 +248,17 @@ U.Map = L.Map.extend({
this.backup()
this.initContextMenu()
this.on('click contextmenu.show', this.closeInplaceToolbar)
Promise.resolve(this.getSyncEngine())
},
getSyncEngine: async function () {
// Get the authentication token from the server
// And pass it to the sync engine.
const [response, _, error] = await this.server.get('/map/2/ws-token/')
if (!error) {
this.sync = new U.SyncEngine(this, 'ws://localhost:8001', response.token)
}
},
render: function (fields) {