@soundworks/core 3.6.0 → 4.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc +16 -0
- package/LICENSE +1 -1
- package/README.md +32 -30
- package/package.json +57 -26
- package/src/client/Client.js +391 -0
- package/src/client/Context.js +225 -0
- package/src/client/ContextManager.js +111 -0
- package/src/client/Plugin.js +66 -0
- package/src/client/PluginManager.js +123 -0
- package/src/client/SharedState.js +84 -0
- package/src/client/Socket.js +389 -0
- package/src/client/StateManager.js +67 -0
- package/src/client/index.js +77 -0
- package/src/common/BasePlugin.js +163 -0
- package/src/common/BasePluginManager.js +227 -0
- package/src/common/BaseSharedState.js +473 -0
- package/src/common/BaseStateManager.js +200 -0
- package/{common → src/common}/ParameterBag.js +82 -122
- package/{common → src/common}/SharedStatePrivate.js +56 -64
- package/src/common/constants.js +51 -0
- package/src/common/logger.js +154 -0
- package/src/common/promise-store.js +25 -0
- package/src/common/sockets-utils.js +73 -0
- package/src/common/utils.js +22 -0
- package/src/server/Client.js +50 -0
- package/src/server/Context.js +99 -0
- package/src/server/ContextManager.js +266 -0
- package/src/server/Plugin.js +77 -0
- package/src/server/PluginManager.js +150 -0
- package/src/server/Server.js +849 -0
- package/src/server/SharedState.js +86 -0
- package/{server → src/server}/Socket.js +119 -94
- package/{server → src/server}/Sockets.js +73 -61
- package/{common/SharedStateManagerServer.js → src/server/StateManager.js} +183 -131
- package/src/server/audit-network-latency.worker.js +57 -0
- package/src/server/audit-schema.js +42 -0
- package/src/server/index.js +45 -0
- package/tsconfig.json +21 -0
- package/types/client/Client.d.ts +194 -0
- package/types/client/Client.d.ts.map +1 -0
- package/types/client/Context.d.ts +139 -0
- package/types/client/Context.d.ts.map +1 -0
- package/types/client/ContextManager.d.ts +45 -0
- package/types/client/ContextManager.d.ts.map +1 -0
- package/types/client/Plugin.d.ts +53 -0
- package/types/client/Plugin.d.ts.map +1 -0
- package/types/client/PluginManager.d.ts +96 -0
- package/types/client/PluginManager.d.ts.map +1 -0
- package/types/client/SharedState.d.ts +85 -0
- package/types/client/SharedState.d.ts.map +1 -0
- package/types/client/Socket.d.ts +138 -0
- package/types/client/Socket.d.ts.map +1 -0
- package/types/client/StateManager.d.ts +72 -0
- package/types/client/StateManager.d.ts.map +1 -0
- package/types/client/index.d.ts +11 -0
- package/types/client/index.d.ts.map +1 -0
- package/types/common/BasePlugin.d.ts +145 -0
- package/types/common/BasePlugin.d.ts.map +1 -0
- package/types/common/BasePluginManager.d.ts +88 -0
- package/types/common/BasePluginManager.d.ts.map +1 -0
- package/types/common/BaseSharedState.d.ts +173 -0
- package/types/common/BaseSharedState.d.ts.map +1 -0
- package/types/common/BaseStateManager.d.ts +70 -0
- package/types/common/BaseStateManager.d.ts.map +1 -0
- package/types/common/ParameterBag.d.ts +143 -0
- package/types/common/ParameterBag.d.ts.map +1 -0
- package/types/common/SharedStatePrivate.d.ts +20 -0
- package/types/common/SharedStatePrivate.d.ts.map +1 -0
- package/types/common/constants.d.ts +35 -0
- package/types/common/constants.d.ts.map +1 -0
- package/types/common/logger.d.ts +16 -0
- package/types/common/logger.d.ts.map +1 -0
- package/types/common/promise-store.d.ts +4 -0
- package/types/common/promise-store.d.ts.map +1 -0
- package/types/common/sockets-utils.d.ts +9 -0
- package/types/common/sockets-utils.d.ts.map +1 -0
- package/types/common/utils.d.ts +5 -0
- package/types/common/utils.d.ts.map +1 -0
- package/types/server/Client.d.ts +40 -0
- package/types/server/Client.d.ts.map +1 -0
- package/types/server/Context.d.ts +59 -0
- package/types/server/Context.d.ts.map +1 -0
- package/types/server/ContextManager.d.ts +69 -0
- package/types/server/ContextManager.d.ts.map +1 -0
- package/types/server/Plugin.d.ts +70 -0
- package/types/server/Plugin.d.ts.map +1 -0
- package/types/server/PluginManager.d.ts +91 -0
- package/types/server/PluginManager.d.ts.map +1 -0
- package/types/server/Server.d.ts +223 -0
- package/types/server/Server.d.ts.map +1 -0
- package/types/server/SharedState.d.ts +86 -0
- package/types/server/SharedState.d.ts.map +1 -0
- package/types/server/Socket.d.ts +128 -0
- package/types/server/Socket.d.ts.map +1 -0
- package/types/server/Sockets.d.ts +73 -0
- package/types/server/Sockets.d.ts.map +1 -0
- package/types/server/StateManager.d.ts +190 -0
- package/types/server/StateManager.d.ts.map +1 -0
- package/types/server/audit-network-latency.worker.d.ts +2 -0
- package/types/server/audit-network-latency.worker.d.ts.map +1 -0
- package/types/server/audit-schema.d.ts +27 -0
- package/types/server/audit-schema.d.ts.map +1 -0
- package/types/server/index.d.ts +7 -0
- package/types/server/index.d.ts.map +1 -0
- package/client/AbstractExperience.js +0 -54
- package/client/AbstractPlugin.js +0 -230
- package/client/Client.js +0 -190
- package/client/PluginManager.js +0 -267
- package/client/Socket.js +0 -338
- package/client/index.js +0 -48
- package/client.js +0 -28
- package/common/SharedState.js +0 -480
- package/common/SharedStateManagerClient.js +0 -194
- package/common/Signal.js +0 -40
- package/common/SignalAll.js +0 -44
- package/common/shared-state-utils.js +0 -90
- package/common/sockets-encoder-decoder.js +0 -73
- package/server/AbstractExperience.js +0 -126
- package/server/AbstractPlugin.js +0 -211
- package/server/Client.js +0 -70
- package/server/PluginManager.js +0 -177
- package/server/Server.js +0 -746
- package/server/index.js +0 -49
- package/server/utils/logger.js +0 -134
- package/server.js +0 -28
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import isPlainObject from 'is-plain-obj';
|
|
2
|
+
|
|
3
|
+
import logger from './logger.js';
|
|
4
|
+
import { isString } from './utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared functionnality between server-side and client-size plugin manager
|
|
8
|
+
*
|
|
9
|
+
* @private
|
|
10
|
+
*/
|
|
11
|
+
class BasePluginManager {
|
|
12
|
+
constructor(node) {
|
|
13
|
+
// node may be either a soundworks server or a soundworks client
|
|
14
|
+
/** @private */
|
|
15
|
+
this._node = node;
|
|
16
|
+
/** @private */
|
|
17
|
+
this._registeredPlugins = new Map();
|
|
18
|
+
/** @private */
|
|
19
|
+
this._instances = new Map();
|
|
20
|
+
/** @private */
|
|
21
|
+
this._instanceStartPromises = new Map();
|
|
22
|
+
/** @private */
|
|
23
|
+
this._onStateChangeCallbacks = new Set();
|
|
24
|
+
|
|
25
|
+
this.status = 'idle';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Register a plugin into soundworks.
|
|
30
|
+
*
|
|
31
|
+
* _A plugin must always be registered both on client-side and on server-side_
|
|
32
|
+
*
|
|
33
|
+
* Refer to the plugin documentation to check its options and proper way of
|
|
34
|
+
* registering it.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} id - Unique id of the plugin. Enables the registration of the
|
|
37
|
+
* same plugin factory under different ids.
|
|
38
|
+
* @param {Function} factory - Factory function that returns the Plugin class.
|
|
39
|
+
* @param {object} [options={}] - Options to configure the plugin.
|
|
40
|
+
* @param {array} [deps=[]] - List of plugins' names the plugin depends on, i.e.
|
|
41
|
+
* the plugin initialization will start only after the plugins it depends on are
|
|
42
|
+
* fully started themselves.
|
|
43
|
+
* @see {@link client.PluginManager#register}
|
|
44
|
+
* @see {@link server.PluginManager#register}
|
|
45
|
+
* @example
|
|
46
|
+
* // client-side
|
|
47
|
+
* client.pluginManager.register('user-defined-id', pluginFactory);
|
|
48
|
+
* // server-side
|
|
49
|
+
* server.pluginManager.register('user-defined-id', pluginFactory);
|
|
50
|
+
*/
|
|
51
|
+
register(id, ctor, options = {}, deps = []) {
|
|
52
|
+
// For now we don't allow to register a plugin after `client|server.init()`.
|
|
53
|
+
// This is subject to change in the future as we may want to dynamically
|
|
54
|
+
// register new plugins during application lifetime.
|
|
55
|
+
if (this._node.status === 'inited') {
|
|
56
|
+
throw new Error(`[soundworks.PluginManager] Cannot register plugin (${id}) after "client.init()"`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!isString(id)) {
|
|
60
|
+
throw new Error(`[soundworks.PluginManager] Invalid argument, "pluginManager.register" first argument should be a string`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!isPlainObject(options)) {
|
|
64
|
+
throw new Error(`[soundworks.PluginManager] Invalid argument, "pluginManager.register" third optionnal argument should be an object`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!Array.isArray(deps)) {
|
|
68
|
+
throw new Error(`[soundworks.PluginManager] Invalid argument, "pluginManager.register" fourth optionnal argument should be an array`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (this._registeredPlugins.has(id)) {
|
|
72
|
+
throw new Error(`[soundworks:PluginManager] Plugin "${id}" of type "${ctor.name}" already registered`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this._registeredPlugins.set(id, { ctor, options, deps });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Initialize all the registered plugin. Executed during the `Client.init()` or
|
|
80
|
+
* `Server.init()` initialization step.
|
|
81
|
+
* @private
|
|
82
|
+
*/
|
|
83
|
+
async start() {
|
|
84
|
+
logger.title('starting registered plugins');
|
|
85
|
+
|
|
86
|
+
if (this.status !== 'idle') {
|
|
87
|
+
throw new Error(`[soundworks:PluginManager] Cannot call "pluginManager.init()" twice`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.status = 'inited';
|
|
91
|
+
// instanciate all plugins
|
|
92
|
+
for (let [id, { ctor, options }] of this._registeredPlugins.entries()) {
|
|
93
|
+
const instance = new ctor(this._node, id, options);
|
|
94
|
+
this._instances.set(id, instance);
|
|
95
|
+
instance.onStateChange(_values => this._propagateStateChange(instance));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// propagate all 'idle' statuses before start
|
|
99
|
+
this._propagateStateChange();
|
|
100
|
+
|
|
101
|
+
const promises = Array.from(this._registeredPlugins.keys()).map(id => this.getUnsafe(id));
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await Promise.all(promises);
|
|
105
|
+
this.status = 'started';
|
|
106
|
+
} catch(err) {
|
|
107
|
+
this.status = 'errored';
|
|
108
|
+
throw new Error(err);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async stop() {
|
|
113
|
+
for (let instance of this._instances.values()) {
|
|
114
|
+
await instance.stop();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Retrieve an fully started instance of a registered plugin, without checking
|
|
120
|
+
* that the pluginManager is started. This is important for starting the plugin
|
|
121
|
+
* manager itself.
|
|
122
|
+
*
|
|
123
|
+
* @private
|
|
124
|
+
*/
|
|
125
|
+
async getUnsafe(id) {
|
|
126
|
+
if (!isString(id)) {
|
|
127
|
+
throw new Error(`[soundworks.PluginManager] Invalid argument, "pluginManager.get(name)" argument should be a string`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!this._registeredPlugins.has(id)) {
|
|
131
|
+
throw new Error(`[soundworks:PluginManager] Cannot get plugin "${id}", plugin is not registered`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// @note - For now, all instances are created at the beginning of `start()`
|
|
135
|
+
// to be able to properly propagate the states. The code bellow should allow
|
|
136
|
+
// to dynamically register and launch plugins at runtime.
|
|
137
|
+
//
|
|
138
|
+
// if (!this._instances.has(id)) {
|
|
139
|
+
// const { ctor, options } = this._registeredPlugins.get(id);
|
|
140
|
+
// const instance = new ctor(this._node, id, options);
|
|
141
|
+
// this._instances.set(id, instance);
|
|
142
|
+
// }
|
|
143
|
+
|
|
144
|
+
const instance = this._instances.get(id);
|
|
145
|
+
|
|
146
|
+
// recursively get the dependency chain
|
|
147
|
+
const { deps } = this._registeredPlugins.get(id);
|
|
148
|
+
const promises = deps.map(id => this.getUnsafe(id));
|
|
149
|
+
|
|
150
|
+
await Promise.all(promises);
|
|
151
|
+
|
|
152
|
+
// 'plugin.start' has already been called, just await the start promise
|
|
153
|
+
if (this._instanceStartPromises.has(id)) {
|
|
154
|
+
await this._instanceStartPromises.get(id);
|
|
155
|
+
} else {
|
|
156
|
+
this._propagateStateChange(instance, 'inited');
|
|
157
|
+
let errored = false;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const startPromise = instance.start();
|
|
161
|
+
this._instanceStartPromises.set(id, startPromise);
|
|
162
|
+
|
|
163
|
+
await startPromise;
|
|
164
|
+
} catch(err) {
|
|
165
|
+
errored = true;
|
|
166
|
+
this._propagateStateChange(instance, 'errored');
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// this looks silly but it prevents the try / catch to catch errors that could
|
|
171
|
+
// be triggered by the propagate status callback, putting the plugin in errored state
|
|
172
|
+
if (!errored) {
|
|
173
|
+
this._propagateStateChange(instance, 'started');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return instance;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Propagate a notification each time a plugin is updated (status or inner state).
|
|
182
|
+
* The callback will receive the list of all plugins as first parameter, and the
|
|
183
|
+
* plugin instance that initiated the state change event as second parameter.
|
|
184
|
+
*
|
|
185
|
+
* _In most cases, you should not have to rely on this method._
|
|
186
|
+
*
|
|
187
|
+
* @param {client.PluginManager~onStateChangeCallback|server.PluginManager~onStateChangeCallback} callback
|
|
188
|
+
* Callback to be executed on state change
|
|
189
|
+
* @param {client.PluginManager~deleteOnStateChangeCallback|client.PluginManager~deleteOnStateChangeCallback}
|
|
190
|
+
* Function to execute to listening for changes.
|
|
191
|
+
* @example
|
|
192
|
+
* const unsubscribe = client.pluginManager.onStateChange(pluginList, initiator => {
|
|
193
|
+
* // log the current status of all plugins
|
|
194
|
+
* for (let name in pluginList) {
|
|
195
|
+
* console.log(name, pluginList[name].status);
|
|
196
|
+
* }
|
|
197
|
+
* // if the change was initiated by a plugin, log its status and state
|
|
198
|
+
* if (initiator !== null) {
|
|
199
|
+
*. console.log(initiator.name, initiator.status, initiator.state);
|
|
200
|
+
* }
|
|
201
|
+
* });
|
|
202
|
+
* // stop listening for updates later
|
|
203
|
+
* unsubscribe();
|
|
204
|
+
*/
|
|
205
|
+
onStateChange(callback) {
|
|
206
|
+
this._onStateChangeCallbacks.add(callback);
|
|
207
|
+
return () => this._onStateChangeCallbacks.delete(callback);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @private */
|
|
211
|
+
_propagateStateChange(instance = null, status = null) {
|
|
212
|
+
if (instance !== null) {
|
|
213
|
+
// status is null if wew forward some inner state change from the instance
|
|
214
|
+
if (status !== null) {
|
|
215
|
+
instance.status = status;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const fullState = Object.fromEntries(this._instances);
|
|
219
|
+
this._onStateChangeCallbacks.forEach(callback => callback(fullState, instance));
|
|
220
|
+
} else {
|
|
221
|
+
const fullState = Object.fromEntries(this._instances);
|
|
222
|
+
this._onStateChangeCallbacks.forEach(callback => callback(fullState, null));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export default BasePluginManager;
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import ParameterBag from './ParameterBag.js';
|
|
2
|
+
import {
|
|
3
|
+
DELETE_REQUEST,
|
|
4
|
+
DELETE_RESPONSE,
|
|
5
|
+
DELETE_ERROR,
|
|
6
|
+
DELETE_NOTIFICATION,
|
|
7
|
+
DETACH_REQUEST,
|
|
8
|
+
DETACH_RESPONSE,
|
|
9
|
+
DETACH_ERROR,
|
|
10
|
+
UPDATE_REQUEST,
|
|
11
|
+
UPDATE_RESPONSE,
|
|
12
|
+
UPDATE_ABORT,
|
|
13
|
+
UPDATE_NOTIFICATION,
|
|
14
|
+
} from './constants.js';
|
|
15
|
+
import {
|
|
16
|
+
storeRequestPromise,
|
|
17
|
+
resolveRequest,
|
|
18
|
+
rejectRequest,
|
|
19
|
+
} from './promise-store.js';
|
|
20
|
+
|
|
21
|
+
class BaseSharedState {
|
|
22
|
+
constructor(id, remoteId, schemaName, schema, client, isOwner, manager, initValues = {}) {
|
|
23
|
+
this.id = id;
|
|
24
|
+
this.remoteId = remoteId;
|
|
25
|
+
this.schemaName = schemaName;
|
|
26
|
+
|
|
27
|
+
/** @private */
|
|
28
|
+
this._isOwner = isOwner; // may be the server or any client
|
|
29
|
+
/** @private */
|
|
30
|
+
this._client = client;
|
|
31
|
+
/** @private */
|
|
32
|
+
this._manager = manager;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
/** @private */
|
|
36
|
+
this._parameters = new ParameterBag(schema, initValues);
|
|
37
|
+
} catch(err) {
|
|
38
|
+
console.error(err.stack);
|
|
39
|
+
|
|
40
|
+
throw new Error(`Error creating or attaching state "${schemaName}" w/ values:\n
|
|
41
|
+
${JSON.stringify(initValues, null, 2)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @private */
|
|
45
|
+
this._onUpdateCallbacks = new Set();
|
|
46
|
+
/** @private */
|
|
47
|
+
this._onDetachCallbacks = new Set();
|
|
48
|
+
/** @private */
|
|
49
|
+
this._onDeleteCallbacks = new Set();
|
|
50
|
+
|
|
51
|
+
// add listener for state updates
|
|
52
|
+
this._client.transport.addListener(`${UPDATE_RESPONSE}-${this.id}-${this.remoteId}`, async (reqId, updates, context) => {
|
|
53
|
+
const updated = await this._commit(updates, context, true, true);
|
|
54
|
+
resolveRequest(reqId, updated);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// retrieve values but do not propagate to subscriptions
|
|
58
|
+
this._client.transport.addListener(`${UPDATE_ABORT}-${this.id}-${this.remoteId}`, async (reqId, updates, context) => {
|
|
59
|
+
const updated = await this._commit(updates, context, false, true);
|
|
60
|
+
resolveRequest(reqId, updated);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
this._client.transport.addListener(`${UPDATE_NOTIFICATION}-${this.id}-${this.remoteId}`, async (updates, context) => {
|
|
64
|
+
// https://github.com/collective-soundworks/soundworks/issues/18
|
|
65
|
+
//
|
|
66
|
+
// # note: 2002-10-03
|
|
67
|
+
//
|
|
68
|
+
// `setTimeout(async () => this._commit(updates, context, true, false));`
|
|
69
|
+
// appears to be the only way to push the update commit in the next event
|
|
70
|
+
// cycle so that `attach` can resolve before the update notification is
|
|
71
|
+
// actually dispatched. The alternative:
|
|
72
|
+
// `Promise.resolve().then(() => this._commit(updates, context, true, false))``
|
|
73
|
+
// does not behave as expected...
|
|
74
|
+
//
|
|
75
|
+
// However this breaks the reliability of:
|
|
76
|
+
// ```
|
|
77
|
+
// /* given a0, a1 and a2 being 3 similar attached states */
|
|
78
|
+
// await state.set({ int: i });
|
|
79
|
+
//
|
|
80
|
+
// assert.equal(a0.get('int'), i);
|
|
81
|
+
// assert.equal(a1.get('int'), i);
|
|
82
|
+
// assert.equal(a2.get('int'), i);
|
|
83
|
+
// ```
|
|
84
|
+
// which is far more important than the edge case reported in the issue
|
|
85
|
+
// therefore this wont be fixed for now
|
|
86
|
+
this._commit(updates, context, true, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------
|
|
90
|
+
// DELETE initiated by creator, or schema deleted
|
|
91
|
+
// ---------------------------------------------
|
|
92
|
+
this._client.transport.addListener(`${DELETE_NOTIFICATION}-${this.id}-${this.remoteId}`, () => {
|
|
93
|
+
this._manager._statesById.delete(this.id);
|
|
94
|
+
this._clearTransport();
|
|
95
|
+
|
|
96
|
+
this._onDetachCallbacks.forEach(callback => callback());
|
|
97
|
+
this._onDeleteCallbacks.forEach(callback => callback());
|
|
98
|
+
|
|
99
|
+
this._clearDetach();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if (this._isOwner) {
|
|
104
|
+
// ---------------------------------------------
|
|
105
|
+
// DELETE (can only delete if creator)
|
|
106
|
+
// ---------------------------------------------
|
|
107
|
+
this._client.transport.addListener(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`, (reqId) => {
|
|
108
|
+
this._manager._statesById.delete(this.id);
|
|
109
|
+
this._clearTransport();
|
|
110
|
+
|
|
111
|
+
this._onDetachCallbacks.forEach(callback => callback());
|
|
112
|
+
this._onDeleteCallbacks.forEach(callback => callback());
|
|
113
|
+
|
|
114
|
+
this._clearDetach();
|
|
115
|
+
resolveRequest(reqId, this);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
this._client.transport.addListener(`${DELETE_ERROR}-${this.id}`, (reqId, msg) => {
|
|
119
|
+
rejectRequest(reqId, msg);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
} else {
|
|
123
|
+
// ---------------------------------------------
|
|
124
|
+
// DETACH
|
|
125
|
+
// ---------------------------------------------
|
|
126
|
+
this._client.transport.addListener(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`, (reqId) => {
|
|
127
|
+
this._manager._statesById.delete(this.id);
|
|
128
|
+
this._clearTransport();
|
|
129
|
+
|
|
130
|
+
this._onDetachCallbacks.forEach(func => func());
|
|
131
|
+
|
|
132
|
+
this._clearDetach();
|
|
133
|
+
resolveRequest(reqId, this);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// the state does not exists anymore in the server (should not happen)
|
|
137
|
+
this._client.transport.addListener(`${DETACH_ERROR}-${this.id}`, (reqId, msg) => {
|
|
138
|
+
rejectRequest(reqId, msg);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @private */
|
|
145
|
+
_clearDetach() {
|
|
146
|
+
this._onDetachCallbacks.clear();
|
|
147
|
+
this._onDeleteCallbacks.clear();
|
|
148
|
+
|
|
149
|
+
// Monkey patch detach so it throws if called twice. Doing nothing blocks
|
|
150
|
+
// the process on a second `detach` call as the Promise never resolves
|
|
151
|
+
this.detach = () => {
|
|
152
|
+
throw new Error(`[stateManager] State "${this.schemaName} (${this.id})" already detached, cannot detach twice`);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @private */
|
|
157
|
+
_clearTransport() {
|
|
158
|
+
// remove listeners
|
|
159
|
+
this._client.transport.removeAllListeners(`${UPDATE_RESPONSE}-${this.id}-${this.remoteId}`);
|
|
160
|
+
this._client.transport.removeAllListeners(`${UPDATE_NOTIFICATION}-${this.id}-${this.remoteId}`);
|
|
161
|
+
this._client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`);
|
|
162
|
+
this._client.transport.removeAllListeners(`${DELETE_NOTIFICATION}-${this.id}-${this.remoteId}`);
|
|
163
|
+
this._client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** @private */
|
|
167
|
+
async _commit(updates, context, propagate = true, initiator = false) {
|
|
168
|
+
const newValues = {};
|
|
169
|
+
const oldValues = {};
|
|
170
|
+
|
|
171
|
+
for (let name in updates) {
|
|
172
|
+
const { immediate, event } = this._parameters.getSchema(name);
|
|
173
|
+
// @note 20211209 - we had an issue here server-side, because if the value
|
|
174
|
+
// is an object or an array, the reference is shared by everybody, therefore
|
|
175
|
+
// `changed` is always false and the new value is never propagated...
|
|
176
|
+
// FIXED - `state.get` now returns a deep copy when `type` is `any`
|
|
177
|
+
const oldValue = this._parameters.get(name);
|
|
178
|
+
const [newValue, changed] = this._parameters.set(name, updates[name]);
|
|
179
|
+
|
|
180
|
+
// handle immediate stuff
|
|
181
|
+
if (initiator && immediate) {
|
|
182
|
+
// @note - we don't need to check filterChange here because the value
|
|
183
|
+
// has been updated in parameters on the `set` side so can rely on `changed`
|
|
184
|
+
// to avoid retrigger listeners.
|
|
185
|
+
// If the value has been overriden by the server, `changed` will true
|
|
186
|
+
// anyway so it should behave correctly.
|
|
187
|
+
if (!changed || event) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
newValues[name] = newValue;
|
|
193
|
+
oldValues[name] = oldValue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// if the `UPDATE_REQUEST` as been aborted by the server, do not propagate
|
|
197
|
+
let promises = [];
|
|
198
|
+
|
|
199
|
+
if (propagate && Object.keys(newValues).length > 0) {
|
|
200
|
+
this._onUpdateCallbacks.forEach(listener => {
|
|
201
|
+
promises.push(listener(newValues, oldValues, context));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// on a given client, `await state.set(update)` resolves after all
|
|
206
|
+
// update callbacks have themselves resolved
|
|
207
|
+
await Promise.all(promises);
|
|
208
|
+
|
|
209
|
+
// reset events to null after propagation of all listeners
|
|
210
|
+
for (let name in newValues) {
|
|
211
|
+
const { event } = this._parameters.getSchema(name);
|
|
212
|
+
|
|
213
|
+
if (event) {
|
|
214
|
+
this._parameters.set(name, null);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return newValues;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Update values of the state.
|
|
223
|
+
*
|
|
224
|
+
* The returned `Promise` resolves on the applied updates, when all the `onUpdate`
|
|
225
|
+
* callbacks have resolved themselves, i.e.:
|
|
226
|
+
*
|
|
227
|
+
* ```js
|
|
228
|
+
* server.stateManager.registerSchema('test', {
|
|
229
|
+
* myBool: { type: 'boolean', default: false },
|
|
230
|
+
* });
|
|
231
|
+
* const a = await server.stateManager.create('a');
|
|
232
|
+
* let asyncCallbackCalled = false;
|
|
233
|
+
*
|
|
234
|
+
* a.onUpdate(updates => {
|
|
235
|
+
* return new Promise(resolve => {
|
|
236
|
+
* setTimeout(() => {
|
|
237
|
+
* asyncCallbackCalled = true;
|
|
238
|
+
* resolve();
|
|
239
|
+
* }, 100);
|
|
240
|
+
* });
|
|
241
|
+
* });
|
|
242
|
+
*
|
|
243
|
+
* const updates = await a.set({ myBool: true });
|
|
244
|
+
* assert.equal(asyncCallbackCalled, true);
|
|
245
|
+
* assert.deepEqual(updates, { myBool: true });
|
|
246
|
+
* ```
|
|
247
|
+
*
|
|
248
|
+
* @param {object} updates - key / value pairs of updates to apply to the state.
|
|
249
|
+
* @param {mixed} [context=null] - optionnal contextual object that will be propagated
|
|
250
|
+
* alongside the updates of the state. The context is valid only for the
|
|
251
|
+
* current call and will be passed as third argument to all update listeners.
|
|
252
|
+
* @returns {Promise<Object>} A promise to the (coerced) updates.
|
|
253
|
+
* @example
|
|
254
|
+
* const state = await client.state.attach('globals');
|
|
255
|
+
* const updates = await state.set({ myParam: Math.random() });
|
|
256
|
+
*/
|
|
257
|
+
async set(updates, context = null) {
|
|
258
|
+
// handle immediate option
|
|
259
|
+
const immediateNewValues = {};
|
|
260
|
+
const immediateOldValues = {};
|
|
261
|
+
let propagateNow = false;
|
|
262
|
+
|
|
263
|
+
for (let name in updates) {
|
|
264
|
+
// throw early (client-side and not only server-side) if parameter is undefined
|
|
265
|
+
if (!this._parameters.has(name)) {
|
|
266
|
+
throw new ReferenceError(`[stateManager] Cannot set value of undefined parameter "${name}"`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// @note: general idea...
|
|
270
|
+
// if immediate=true
|
|
271
|
+
// - call listeners if value changed
|
|
272
|
+
// - go through normal server path
|
|
273
|
+
// - retrigger only if response from server is different from current value
|
|
274
|
+
// if immediate=true && (filterChange=false || event=true)
|
|
275
|
+
// - call listeners with value regarless it changed
|
|
276
|
+
// - go through normal server path
|
|
277
|
+
// - if the node is initiator of the update (UPDATE_RESPONSE), (re-)check
|
|
278
|
+
// to prevent execute the listeners twice
|
|
279
|
+
|
|
280
|
+
const { immediate, filterChange, event } = this._parameters.getSchema(name);
|
|
281
|
+
|
|
282
|
+
if (immediate) {
|
|
283
|
+
const oldValue = this._parameters.get(name);
|
|
284
|
+
const [newValue, changed] = this._parameters.set(name, updates[name]);
|
|
285
|
+
|
|
286
|
+
if (changed || filterChange === false || event) {
|
|
287
|
+
immediateOldValues[name] = oldValue;
|
|
288
|
+
immediateNewValues[name] = newValue;
|
|
289
|
+
propagateNow = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (propagateNow) {
|
|
295
|
+
this._onUpdateCallbacks.forEach(listener => listener(immediateNewValues, immediateOldValues, context));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// go through server-side normal behavior
|
|
299
|
+
return new Promise((resolve, reject) => {
|
|
300
|
+
const reqId = storeRequestPromise(resolve, reject);
|
|
301
|
+
this._client.transport.emit(`${UPDATE_REQUEST}-${this.id}-${this.remoteId}`, reqId, updates, context);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Get the value of a paramter of the state.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} name - Name of the param.
|
|
309
|
+
* @throws Throws if `name` does not correspond to an existing field
|
|
310
|
+
* of the state.
|
|
311
|
+
* @return {mixed}
|
|
312
|
+
* @example
|
|
313
|
+
* const value = state.get('name');
|
|
314
|
+
*/
|
|
315
|
+
get(name) {
|
|
316
|
+
return this._parameters.get(name);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get all the key / value pairs of the state.
|
|
321
|
+
*
|
|
322
|
+
* @return {object}
|
|
323
|
+
* @example
|
|
324
|
+
* const values = state.getValues();
|
|
325
|
+
*/
|
|
326
|
+
getValues() {
|
|
327
|
+
return this._parameters.getValues();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get the schema from which the state has been created.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} [name=null] - If given, returns only the definition corresponding
|
|
334
|
+
* to the given param name.
|
|
335
|
+
* @throws Throws if `name` does not correspond to an existing field
|
|
336
|
+
* of the state.
|
|
337
|
+
* @return {object}
|
|
338
|
+
* @example
|
|
339
|
+
* const schema = state.getSchema();
|
|
340
|
+
*/
|
|
341
|
+
getSchema(name = null) {
|
|
342
|
+
return this._parameters.getSchema(name);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Get the values with which the state has been created. May defer from the
|
|
347
|
+
* default values declared in the schema.
|
|
348
|
+
*
|
|
349
|
+
* @return {object}
|
|
350
|
+
* @example
|
|
351
|
+
* const initValues = state.getInitValues();
|
|
352
|
+
*/
|
|
353
|
+
getInitValues() {
|
|
354
|
+
return this._parameters.getInitValues();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Get the default values as declared in the schema.
|
|
359
|
+
*
|
|
360
|
+
* @return {object}
|
|
361
|
+
* @example
|
|
362
|
+
* const defaults = state.getDefaults();
|
|
363
|
+
*/
|
|
364
|
+
getDefaults() {
|
|
365
|
+
return this._parameters.getDefaults();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Detach from the state. If the client is the creator of the state, the state
|
|
370
|
+
* is deleted and all attached nodes get notified
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* const state = await client.state.attach('globals');
|
|
374
|
+
* // later
|
|
375
|
+
* await state.detach();
|
|
376
|
+
*/
|
|
377
|
+
async detach() {
|
|
378
|
+
this._onUpdateCallbacks.clear();
|
|
379
|
+
|
|
380
|
+
if (this._isOwner) {
|
|
381
|
+
return new Promise((resolve, reject) => {
|
|
382
|
+
const reqId = storeRequestPromise(resolve, reject);
|
|
383
|
+
this._client.transport.emit(`${DELETE_REQUEST}-${this.id}-${this.remoteId}`, reqId);
|
|
384
|
+
});
|
|
385
|
+
} else {
|
|
386
|
+
return new Promise((resolve, reject) => {
|
|
387
|
+
const reqId = storeRequestPromise(resolve, reject);
|
|
388
|
+
this._client.transport.emit(`${DETACH_REQUEST}-${this.id}-${this.remoteId}`, reqId);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Delete the state. Only the creator/owner of the state can use this method.
|
|
395
|
+
* All nodes attached to the state will be deteched, triggering the `onDetach`
|
|
396
|
+
* callback. The creator of the state will also have its `onDelete` callback
|
|
397
|
+
* triggered.
|
|
398
|
+
*
|
|
399
|
+
* @throws Throws if the method is called by a node which is not the owner of
|
|
400
|
+
* the state.
|
|
401
|
+
* @example
|
|
402
|
+
* const state = await client.state.create('my-schema-name');
|
|
403
|
+
* // later
|
|
404
|
+
* await state.delete();
|
|
405
|
+
*/
|
|
406
|
+
async delete() {
|
|
407
|
+
if (this._isOwner) {
|
|
408
|
+
return this.detach();
|
|
409
|
+
} else {
|
|
410
|
+
throw new Error(`[stateManager] can delete state "${this.schemaName}", only owner of the state (i.e. the node that "create[d]" it) can delete it`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Subscribe to state updates.
|
|
416
|
+
*
|
|
417
|
+
* @param {client.SharedState~onUpdateCallback|client.SharedState~onUpdateCallback} callback
|
|
418
|
+
* Callback to execute when an update is applied on the state.
|
|
419
|
+
* @param {Boolean} [executeListener=false] - Execute the callback immediately
|
|
420
|
+
* with current state values. (`oldValues` will be set to `{}`, and `context` to `null`)
|
|
421
|
+
* @returns {client.SharedState~deleteOnUpdateCallback|server.SharedState~deleteOnUpdateCallback}
|
|
422
|
+
* @example
|
|
423
|
+
* const unsubscribe = state.onUpdate(async (newValues, oldValues, context) => {
|
|
424
|
+
* for (let [key, value] of Object.entries(newValues)) {
|
|
425
|
+
* switch (key) {
|
|
426
|
+
* // do something
|
|
427
|
+
* }
|
|
428
|
+
* }
|
|
429
|
+
* });
|
|
430
|
+
*
|
|
431
|
+
* // later
|
|
432
|
+
* unsubscribe();
|
|
433
|
+
*/
|
|
434
|
+
onUpdate(listener, executeListener = false) {
|
|
435
|
+
this._onUpdateCallbacks.add(listener);
|
|
436
|
+
|
|
437
|
+
if (executeListener === true) {
|
|
438
|
+
const currentValues = this.getValues();
|
|
439
|
+
const oldValues = {};
|
|
440
|
+
const context = null;
|
|
441
|
+
listener(currentValues, oldValues, context);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return () => {
|
|
445
|
+
this._onUpdateCallbacks.delete(listener);
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Register a function to execute when detaching from the state
|
|
451
|
+
*
|
|
452
|
+
* @param {Function} callback - callback to execute when detaching from the state.
|
|
453
|
+
* wether the client as called `detach`, or the state has been deleted by its
|
|
454
|
+
* creator.
|
|
455
|
+
*/
|
|
456
|
+
onDetach(callback) {
|
|
457
|
+
this._onDetachCallbacks.add(callback);
|
|
458
|
+
return () => this._onDetachCallbacks.delete(callback);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Register a function to execute when the state is deleted. Only called if the
|
|
463
|
+
* node was the creator of the state. Is called after `onDetach`
|
|
464
|
+
*
|
|
465
|
+
* @param {Function} callback - callback to execute when the state is deleted.
|
|
466
|
+
*/
|
|
467
|
+
onDelete(callback) {
|
|
468
|
+
this._onDeleteCallbacks.add(callback);
|
|
469
|
+
return () => this._onDeleteCallbacks.delete(callback);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export default BaseSharedState;
|