@soundworks/core
Version:
Open-source creative coding framework for distributed applications based on Web technologies
278 lines (240 loc) • 9.87 kB
JavaScript
import ParameterBag from '../common/ParameterBag.js';
import {
DELETE_REQUEST,
DELETE_RESPONSE,
DELETE_NOTIFICATION,
DETACH_REQUEST,
DETACH_RESPONSE,
UPDATE_REQUEST,
UPDATE_RESPONSE,
UPDATE_ABORT,
UPDATE_NOTIFICATION,
} from '../common/constants.js';
import {
kServerStateManagerDeletePrivateState,
kServerStateManagerGetUpdateHooks,
} from '../server/ServerStateManager.js';
/**
* Filter update object according to filter list.
* Return identity is filter is null
* @param {object} updates
* @param {array|null} filter
* @private
*/
function filterUpdates(updates, filter) {
if (filter === null) {
return updates;
}
return filter.reduce((acc, key) => {
if (key in updates) {
acc[key] = updates[key];
}
return acc;
}, {});
}
export const kSharedStatePrivateAttachClient = Symbol('soundworks:shared-state-private-attach-client');
export const kSharedStatePrivateDetachClient = Symbol('soundworks:shared-state-private-detach-client');
export const kSharedStatePrivateGetValues = Symbol('soundworks:shared-state-private-get-values');
/**
* The "real" state, this instance is kept private by the server.StateManager.
* It can only be accessed through a SharedState proxy.
*
* @private
*/
class SharedStatePrivate {
#id = null;
#className = null;
#manager = null;
#parameters = null;
#creatorId = null;
#creatorInstanceId = null;
#attachedClients = new Map();
constructor(manager, className, classDefinition, id, initValues = {}) {
this.#manager = manager;
this.#className = className;
this.#id = id;
// This can throw but will be catch in ServerStateManager CREATE_REQUEST handler
this.#parameters = new ParameterBag(classDefinition, initValues);
}
get id() {
return this.#id;
}
get className() {
return this.#className;
}
get creatorId() {
return this.#creatorId;
}
get creatorInstanceId() {
return this.#creatorInstanceId;
}
get attachedClients() {
return this.#attachedClients;
}
get parameters() {
return this.#parameters;
}
[kSharedStatePrivateGetValues]() {
return this.#parameters.getValues();
}
[kSharedStatePrivateAttachClient](instanceId, client, isOwner, filter) {
const clientInfos = { client, isOwner, filter };
this.#attachedClients.set(instanceId, clientInfos);
if (isOwner) {
this.#creatorId = client.id;
this.#creatorInstanceId = instanceId;
}
// attach client listeners
client.transport.addListener(`${UPDATE_REQUEST}-${this.id}-${instanceId}`, async (reqId, updates) => {
// apply registered hooks
const hooks = this.#manager[kServerStateManagerGetUpdateHooks](this.className);
const values = this.#parameters.getValues();
let hookAborted = false;
// cf. https://github.com/collective-soundworks/soundworks/issues/45
for (let hook of hooks.values()) {
const result = await hook(updates, values);
if (result === null) { // explicit abort if hook returns null
hookAborted = true;
break;
} else if (result === undefined) { // implicit continue if hook returns undefined
continue;
} else { // the hook returned an updates object
updates = result;
}
}
if (hookAborted === false) {
let acknowledgedUpdates = {};
let hasUpdates = false;
for (let name in updates) {
const [newValue, changed] = this.#parameters.set(name, updates[name]);
// if `filterChange` is set to `false` we don't check if the value
// has been changed or not, it is always propagated to client states
const { event, filterChange } = this.#parameters.getDescription(name);
// if event type reset internal value to null
if (event) {
this.#parameters.set(name, null);
}
if ((filterChange && changed) || !filterChange) {
acknowledgedUpdates[name] = newValue;
hasUpdates = true;
}
}
if (hasUpdates) {
// Normal case
//
// We need to handle cases where:
// - client state (client.id: 2) sends a request
// - server attached state (client.id: -1) spot a problem and overrides the value
// We want the remote client (id: 2) to receive in the right order:
// * 1. the value it requested,
// * 2. the value overridden by the server-side attached state (id: -1)
//
// This problem is now better solved with the the updateHook system,
// nevertheless we don't want to introduce inconsistencies here
//
// Then we propagate server-side last, because as the server transport
// is synchronous it can break ordering if a subscription function makes
// itself an update in reaction to an update. Propagating to server last
// allows to maintain network messages order consistent.
//
// @note - instanceId correspond to unique remote state id
// propagate RESPONSE to the client that originates the request if not the server
if (client.id !== -1) {
// no need to filter updates on requested, is blocked on client-side
client.transport.emit(
`${UPDATE_RESPONSE}-${this.id}-${instanceId}`,
reqId,
acknowledgedUpdates,
);
}
// propagate NOTIFICATION to all attached clients except server
for (let [peerInstanceId, clientInfos] of this.#attachedClients) {
const peer = clientInfos.client;
if (instanceId !== peerInstanceId && peer.id !== -1) {
const filter = clientInfos.filter;
const filteredUpdates = filterUpdates(acknowledgedUpdates, filter);
// propagate only if there something left after applying the filter
if (Object.keys(filteredUpdates).length > 0) {
peer.transport.emit(
`${UPDATE_NOTIFICATION}-${this.id}-${peerInstanceId}`,
filteredUpdates,
);
}
}
}
// propagate RESPONSE to server if it is the requester
if (client.id === -1) {
// no need to filter updates on requested, is blocked on client-side
client.transport.emit(
`${UPDATE_RESPONSE}-${this.id}-${instanceId}`,
reqId,
acknowledgedUpdates,
);
}
// propagate NOTIFICATION to other state attached on the server
for (let [peerInstanceId, clientInfos] of this.#attachedClients) {
const peer = clientInfos.client;
if (instanceId !== peerInstanceId && peer.id === -1) {
const filter = clientInfos.filter;
const filteredUpdates = filterUpdates(acknowledgedUpdates, filter);
if (Object.keys(filteredUpdates).length > 0) {
peer.transport.emit(
`${UPDATE_NOTIFICATION}-${this.id}-${peerInstanceId}`,
filteredUpdates,
);
}
}
}
} else {
// propagate back to the requester that the update has been aborted
// ignore all other attached clients.
client.transport.emit(`${UPDATE_ABORT}-${this.id}-${instanceId}`, reqId, updates);
}
} else {
// retrieve values from inner state (also handle immediate appropriately)
const oldValues = {};
for (let name in updates) {
oldValues[name] = this.#parameters.get(name);
}
// aborted by hook (updates have been overridden to {})
client.transport.emit(`${UPDATE_ABORT}-${this.id}-${instanceId}`, reqId, oldValues);
}
});
if (isOwner) {
// delete only if creator
client.transport.addListener(`${DELETE_REQUEST}-${this.id}-${instanceId}`, async reqId => {
// make sure hooks have been called when `delete()` fulfills
await this.#manager[kServerStateManagerDeletePrivateState](this);
// --------------------------------------------------------------------
// WARNING - MAKE SURE WE DON'T HAVE PROBLEM W/ THAT
// --------------------------------------------------------------------
// @todo - propagate server-side last, because if a subscription function sends a
// message to a client, network messages order are kept coherent
// this._subscriptions.forEach(func => func(updated));
for (let [instanceId, clientInfos] of this.#attachedClients) {
const attached = clientInfos.client;
this[kSharedStatePrivateDetachClient](instanceId, attached);
if (instanceId === this.#creatorInstanceId) {
attached.transport.emit(`${DELETE_RESPONSE}-${this.id}-${instanceId}`, reqId);
} else {
attached.transport.emit(`${DELETE_NOTIFICATION}-${this.id}-${instanceId}`);
}
}
});
} else {
// detach only if not creator
client.transport.addListener(`${DETACH_REQUEST}-${this.id}-${instanceId}`, (reqId) => {
this[kSharedStatePrivateDetachClient](instanceId, client);
client.transport.emit(`${DETACH_RESPONSE}-${this.id}-${instanceId}`, reqId);
});
}
}
[kSharedStatePrivateDetachClient](instanceId, client) {
this.#attachedClients.delete(instanceId);
// delete listeners
client.transport.removeAllListeners(`${UPDATE_REQUEST}-${this.id}-${instanceId}`);
client.transport.removeAllListeners(`${DELETE_REQUEST}-${this.id}-${instanceId}`);
client.transport.removeAllListeners(`${DETACH_REQUEST}-${this.id}-${instanceId}`);
}
}
export default SharedStatePrivate;