Quick and dirty network synced vue state

Quick and dirty network synced vue state
Photo by henry perks / Unsplash

For some apps it can be useful to have the state server-side and sync it to the clients live using websockets.

One option is to just send the full state everytime it has changed, this has some disadvantages though

  • overhead: we send the full state even if just one property has changed
  • reactivity: having the state as a ref or using Object.assign can have challenging side-effects regarding vue reactivity
    • Object.assign does not delete properties
    • Object.assign can destroy deeply nested objects which causes nasty referential loss behaviour that is hard to debug
  • traceability: multiple changes since the last state are compressed into one change -if an array element is deleted and a new element is added in the same state update, a watcher for the array length would not be triggered if the full state is replaced instead of many, single assignments / updates (a delete and an add operation)

So a more selective update approach is to only send and change the stuff that has changed using fast-json-patch

This way, properties in the object are modified minimally invasive, just as if you modified this property at it's nesting level, as one single change instead of basically destroying and rebuilding the full reactivity tree.

Server

The server is a nodejs express server with socket.io

import express from "express";
import { Server as SocketIoServer } from "socket.io";

const app = express();
const server = app.listen(MAIN_PORT, () => {
    console.log("express started on port " + MAIN_PORT);
});

const io = new SocketIoServer(server);

This is the main class with the update loop on the server

import { compare } from 'fast-json-patch/index.mjs';
import { createHash } from 'crypto';

export default class Main extends Serializable {
    constructor() {
        super();

        this.last_state = {};
        this.last_state_hash = null;

        this.example = {foo: "bar"};
        this.test = true;
    }
  
    getSerializable() {
        return ["example", "test"];
    }

    init() {
        setInterval(() => {
            this.sendState();
        }, 100);

        //when a client connects, send it the full current state
        io.on("connection", socket => {
            socket.emit("state", this.last_state);
        });
    }

    sendState() {
        //serialize "this", triggering toJSON recursively
        let state_string = JSON.stringify(this);
        let hash = createHash("sha1").update(state_string).digest("hex");

        //check if state has changed
        if (this.last_state_hash === hash) return;

        let state = JSON.parse(state_string);
        let diff = compare(this.last_state, state);

        this.last_state = state;
        this.last_state_hash = hash;

        //send state changes to all clients
        io.emit("state-diff", diff);
    }
}

Serializable is just a little helper class that only serializes properties returned by this.getSerializable when being passed through JSON.stringify . This way last_state and last_state_hash are not part of the sync, only example and test

export default class Serializable {
    toJSON() {
        const props = {};
        this.getSerializable().forEach(key => {
            props[key] = this[key];
        });
        return props;
    }
}

The json diff library also includes an "observe" feature but its essentially just a setInterval loop that runs as fast as possible, so we can just leave it like it is and have more control over it.

Client

This is the client side "receiver" state

import { io } from "socket.io-client";
import { reactive } from "vue";
import { applyReducer } from "fast-json-patch/index.mjs";

const state = reactive({
    loaded: false
});

socket.on("state", initial => {
    Object.assign(state, initial);
    state.loaded = true;
})

socket.on("state-diff", patches => {
    patches.reduce(applyReducer, state);
})

export default state;

And that's it, quick and dirty