@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.
Files changed (125) hide show
  1. package/.eslintrc +16 -0
  2. package/LICENSE +1 -1
  3. package/README.md +32 -30
  4. package/package.json +57 -26
  5. package/src/client/Client.js +391 -0
  6. package/src/client/Context.js +225 -0
  7. package/src/client/ContextManager.js +111 -0
  8. package/src/client/Plugin.js +66 -0
  9. package/src/client/PluginManager.js +123 -0
  10. package/src/client/SharedState.js +84 -0
  11. package/src/client/Socket.js +389 -0
  12. package/src/client/StateManager.js +67 -0
  13. package/src/client/index.js +77 -0
  14. package/src/common/BasePlugin.js +163 -0
  15. package/src/common/BasePluginManager.js +227 -0
  16. package/src/common/BaseSharedState.js +473 -0
  17. package/src/common/BaseStateManager.js +200 -0
  18. package/{common → src/common}/ParameterBag.js +82 -122
  19. package/{common → src/common}/SharedStatePrivate.js +56 -64
  20. package/src/common/constants.js +51 -0
  21. package/src/common/logger.js +154 -0
  22. package/src/common/promise-store.js +25 -0
  23. package/src/common/sockets-utils.js +73 -0
  24. package/src/common/utils.js +22 -0
  25. package/src/server/Client.js +50 -0
  26. package/src/server/Context.js +99 -0
  27. package/src/server/ContextManager.js +266 -0
  28. package/src/server/Plugin.js +77 -0
  29. package/src/server/PluginManager.js +150 -0
  30. package/src/server/Server.js +849 -0
  31. package/src/server/SharedState.js +86 -0
  32. package/{server → src/server}/Socket.js +119 -94
  33. package/{server → src/server}/Sockets.js +73 -61
  34. package/{common/SharedStateManagerServer.js → src/server/StateManager.js} +183 -131
  35. package/src/server/audit-network-latency.worker.js +57 -0
  36. package/src/server/audit-schema.js +42 -0
  37. package/src/server/index.js +45 -0
  38. package/tsconfig.json +21 -0
  39. package/types/client/Client.d.ts +194 -0
  40. package/types/client/Client.d.ts.map +1 -0
  41. package/types/client/Context.d.ts +139 -0
  42. package/types/client/Context.d.ts.map +1 -0
  43. package/types/client/ContextManager.d.ts +45 -0
  44. package/types/client/ContextManager.d.ts.map +1 -0
  45. package/types/client/Plugin.d.ts +53 -0
  46. package/types/client/Plugin.d.ts.map +1 -0
  47. package/types/client/PluginManager.d.ts +96 -0
  48. package/types/client/PluginManager.d.ts.map +1 -0
  49. package/types/client/SharedState.d.ts +85 -0
  50. package/types/client/SharedState.d.ts.map +1 -0
  51. package/types/client/Socket.d.ts +138 -0
  52. package/types/client/Socket.d.ts.map +1 -0
  53. package/types/client/StateManager.d.ts +72 -0
  54. package/types/client/StateManager.d.ts.map +1 -0
  55. package/types/client/index.d.ts +11 -0
  56. package/types/client/index.d.ts.map +1 -0
  57. package/types/common/BasePlugin.d.ts +145 -0
  58. package/types/common/BasePlugin.d.ts.map +1 -0
  59. package/types/common/BasePluginManager.d.ts +88 -0
  60. package/types/common/BasePluginManager.d.ts.map +1 -0
  61. package/types/common/BaseSharedState.d.ts +173 -0
  62. package/types/common/BaseSharedState.d.ts.map +1 -0
  63. package/types/common/BaseStateManager.d.ts +70 -0
  64. package/types/common/BaseStateManager.d.ts.map +1 -0
  65. package/types/common/ParameterBag.d.ts +143 -0
  66. package/types/common/ParameterBag.d.ts.map +1 -0
  67. package/types/common/SharedStatePrivate.d.ts +20 -0
  68. package/types/common/SharedStatePrivate.d.ts.map +1 -0
  69. package/types/common/constants.d.ts +35 -0
  70. package/types/common/constants.d.ts.map +1 -0
  71. package/types/common/logger.d.ts +16 -0
  72. package/types/common/logger.d.ts.map +1 -0
  73. package/types/common/promise-store.d.ts +4 -0
  74. package/types/common/promise-store.d.ts.map +1 -0
  75. package/types/common/sockets-utils.d.ts +9 -0
  76. package/types/common/sockets-utils.d.ts.map +1 -0
  77. package/types/common/utils.d.ts +5 -0
  78. package/types/common/utils.d.ts.map +1 -0
  79. package/types/server/Client.d.ts +40 -0
  80. package/types/server/Client.d.ts.map +1 -0
  81. package/types/server/Context.d.ts +59 -0
  82. package/types/server/Context.d.ts.map +1 -0
  83. package/types/server/ContextManager.d.ts +69 -0
  84. package/types/server/ContextManager.d.ts.map +1 -0
  85. package/types/server/Plugin.d.ts +70 -0
  86. package/types/server/Plugin.d.ts.map +1 -0
  87. package/types/server/PluginManager.d.ts +91 -0
  88. package/types/server/PluginManager.d.ts.map +1 -0
  89. package/types/server/Server.d.ts +223 -0
  90. package/types/server/Server.d.ts.map +1 -0
  91. package/types/server/SharedState.d.ts +86 -0
  92. package/types/server/SharedState.d.ts.map +1 -0
  93. package/types/server/Socket.d.ts +128 -0
  94. package/types/server/Socket.d.ts.map +1 -0
  95. package/types/server/Sockets.d.ts +73 -0
  96. package/types/server/Sockets.d.ts.map +1 -0
  97. package/types/server/StateManager.d.ts +190 -0
  98. package/types/server/StateManager.d.ts.map +1 -0
  99. package/types/server/audit-network-latency.worker.d.ts +2 -0
  100. package/types/server/audit-network-latency.worker.d.ts.map +1 -0
  101. package/types/server/audit-schema.d.ts +27 -0
  102. package/types/server/audit-schema.d.ts.map +1 -0
  103. package/types/server/index.d.ts +7 -0
  104. package/types/server/index.d.ts.map +1 -0
  105. package/client/AbstractExperience.js +0 -54
  106. package/client/AbstractPlugin.js +0 -230
  107. package/client/Client.js +0 -190
  108. package/client/PluginManager.js +0 -267
  109. package/client/Socket.js +0 -338
  110. package/client/index.js +0 -48
  111. package/client.js +0 -28
  112. package/common/SharedState.js +0 -480
  113. package/common/SharedStateManagerClient.js +0 -194
  114. package/common/Signal.js +0 -40
  115. package/common/SignalAll.js +0 -44
  116. package/common/shared-state-utils.js +0 -90
  117. package/common/sockets-encoder-decoder.js +0 -73
  118. package/server/AbstractExperience.js +0 -126
  119. package/server/AbstractPlugin.js +0 -211
  120. package/server/Client.js +0 -70
  121. package/server/PluginManager.js +0 -177
  122. package/server/Server.js +0 -746
  123. package/server/index.js +0 -49
  124. package/server/utils/logger.js +0 -134
  125. 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;