@soundworks/core 5.5.0 → 5.7.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 (65) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +6 -0
  3. package/package.json +17 -11
  4. package/src/client/Client.js +7 -4
  5. package/src/client/ClientSocket.js +2 -3
  6. package/src/common/BasePlugin.js +2 -2
  7. package/src/common/BasePluginManager.js +2 -2
  8. package/src/common/BaseStateManager.js +253 -221
  9. package/src/common/ParameterBag.js +14 -14
  10. package/src/common/SharedState.js +405 -247
  11. package/src/common/SharedStateCollection.js +82 -32
  12. package/src/common/constants.js +2 -0
  13. package/src/common/isomorphic/colored-logger.browser.js +17 -0
  14. package/src/common/isomorphic/colored-logger.node.js +19 -0
  15. package/src/common/isomorphic/web-socket.browser.js +1 -0
  16. package/src/common/isomorphic/web-socket.node.js +3 -0
  17. package/src/common/logs/gated-logger.js +30 -0
  18. package/src/common/logs/warnings.js +36 -0
  19. package/src/common/shared-state-utils.js +102 -0
  20. package/src/common/version.js +1 -1
  21. package/src/server/Server.js +23 -35
  22. package/src/server/ServerClient.js +7 -3
  23. package/src/server/ServerStateManager.js +242 -227
  24. package/src/server/SharedStatePrivate.js +105 -68
  25. package/src/server/create-http-server.js +81 -39
  26. package/src/server/crypto.js +2 -3
  27. package/types/client/Client.d.ts.map +1 -1
  28. package/types/client/ClientSocket.d.ts.map +1 -1
  29. package/types/common/BaseStateManager.d.ts +34 -47
  30. package/types/common/BaseStateManager.d.ts.map +1 -1
  31. package/types/common/ParameterBag.d.ts +28 -6
  32. package/types/common/ParameterBag.d.ts.map +1 -1
  33. package/types/common/SharedState.d.ts +55 -29
  34. package/types/common/SharedState.d.ts.map +1 -1
  35. package/types/common/SharedStateCollection.d.ts +26 -8
  36. package/types/common/SharedStateCollection.d.ts.map +1 -1
  37. package/types/common/constants.d.ts +1 -0
  38. package/types/common/constants.d.ts.map +1 -1
  39. package/types/common/isomorphic/colored-logger.browser.d.ts +8 -0
  40. package/types/common/isomorphic/colored-logger.browser.d.ts.map +1 -0
  41. package/types/common/isomorphic/colored-logger.node.d.ts +8 -0
  42. package/types/common/isomorphic/colored-logger.node.d.ts.map +1 -0
  43. package/types/common/isomorphic/web-socket.browser.d.ts +10 -0
  44. package/types/common/isomorphic/web-socket.browser.d.ts.map +1 -0
  45. package/types/common/isomorphic/web-socket.node.d.ts +2 -0
  46. package/types/common/isomorphic/web-socket.node.d.ts.map +1 -0
  47. package/types/common/logs/gated-logger.d.ts +9 -0
  48. package/types/common/logs/gated-logger.d.ts.map +1 -0
  49. package/types/common/logs/warnings.d.ts +7 -0
  50. package/types/common/logs/warnings.d.ts.map +1 -0
  51. package/types/common/shared-state-utils.d.ts +12 -0
  52. package/types/common/shared-state-utils.d.ts.map +1 -0
  53. package/types/common/version.d.ts +1 -1
  54. package/types/server/Server.d.ts.map +1 -1
  55. package/types/server/ServerClient.d.ts.map +1 -1
  56. package/types/server/ServerStateManager.d.ts +10 -10
  57. package/types/server/ServerStateManager.d.ts.map +1 -1
  58. package/types/server/SharedStatePrivate.d.ts +2 -3
  59. package/types/server/SharedStatePrivate.d.ts.map +1 -1
  60. package/types/server/create-http-server.d.ts +1 -0
  61. package/types/server/create-http-server.d.ts.map +1 -1
  62. package/types/server/crypto.d.ts.map +1 -1
  63. package/src/common/logger.js +0 -150
  64. package/types/common/logger.d.ts +0 -19
  65. package/types/common/logger.d.ts.map +0 -1
@@ -1,4 +1,7 @@
1
- import { isPlainObject, isString } from '@ircam/sc-utils';
1
+ import {
2
+ isPlainObject,
3
+ isString,
4
+ } from '@ircam/sc-utils';
2
5
 
3
6
  import ParameterBag from './ParameterBag.js';
4
7
  import PromiseStore from './PromiseStore.js';
@@ -14,14 +17,20 @@ import {
14
17
  UPDATE_RESPONSE,
15
18
  UPDATE_ABORT,
16
19
  UPDATE_NOTIFICATION,
20
+ HAS_SIBLINGS_NOTIFICATION,
17
21
  } from './constants.js';
18
22
 
19
23
  import {
20
24
  kStateManagerClient,
21
25
  kStateManagerDeleteState,
26
+ kPendingSharedStateConstructionData,
22
27
  } from './BaseStateManager.js';
23
28
 
24
- import logger from './logger.js';
29
+ import {
30
+ sanitizeOnUpdateParams,
31
+ } from './shared-state-utils.js';
32
+
33
+ import warnings from './logs/warnings.js';
25
34
 
26
35
  // for testing purposes
27
36
  export const kSharedStatePromiseStore = Symbol('soundworks:shared-state-promise-store');
@@ -30,10 +39,14 @@ export const kSharedStatePromiseStore = Symbol('soundworks:shared-state-promise-
30
39
  * Callback executed when updates are applied on a {@link SharedState}.
31
40
  *
32
41
  * @callback sharedStateOnUpdateCallback
33
- * @param {Object} newValues - Key / value pairs of the updates that have been
34
- * applied to the state.
35
- * @param {Object} oldValues - Key / value pairs of the updated params before
36
- * the updates has been applied to the state.
42
+ * @param {Object|any} newValues - Key / value pairs of the updates that have been
43
+ * applied to the state, or single value if the callback has been registered against
44
+ * a parameter name filter.
45
+ * @param {Object|any} oldValues - Key / value pairs of the updated params before
46
+ * the updates has been applied to the state, or single value if the callback has been
47
+ * registered against a parameter name filter.
48
+ * @param {sharedStateDeleteOnUpdateCallback} unsubscribe - Reference to unsubscribe
49
+ * function returned by the `SharedState#onUpdate` method.
37
50
  */
38
51
 
39
52
  /**
@@ -106,217 +119,197 @@ class SharedState {
106
119
  #client = null;
107
120
  #manager = null;
108
121
  #filter = null;
109
- // true is the state has been detached or deleted
110
122
  #detached = false;
123
+ #hasSiblings = false;
111
124
  #parameters = null;
112
125
  #onUpdateCallbacks = new Set();
113
126
  #onDetachCallbacks = new Set();
114
127
  #onDeleteCallbacks = new Set();
115
-
116
- constructor({
117
- stateId,
118
- instanceId,
119
- className,
120
- classDescription,
121
- isOwner,
122
- manager,
123
- initValues,
124
- filter,
125
- }) {
126
- this.#manager = manager;
127
- this.#client = manager[kStateManagerClient];
128
- this.#className = className;
129
- this.#id = stateId;
130
- this.#instanceId = instanceId;
131
- this.#isOwner = isOwner; // may be any node
132
- this.#filter = filter;
128
+ #waitForConditions = new Set();
129
+
130
+ constructor() {
131
+ // cf. BaseStateManager#buildSharedState to get the whole pattern
132
+ // rationale is to have a clean constructor API for future derived classes
133
+ this.#manager = globalThis[kPendingSharedStateConstructionData].manager;
134
+ this.#client = globalThis[kPendingSharedStateConstructionData].manager[kStateManagerClient];
135
+ this.#className = globalThis[kPendingSharedStateConstructionData].className;
136
+ this.#id = globalThis[kPendingSharedStateConstructionData].stateId;
137
+ this.#instanceId =globalThis[kPendingSharedStateConstructionData]. instanceId;
138
+ this.#isOwner = globalThis[kPendingSharedStateConstructionData].isOwner; // may be any node
139
+ this.#filter = globalThis[kPendingSharedStateConstructionData].filter;
133
140
 
134
141
  try {
135
- this.#parameters = new ParameterBag(classDescription, initValues);
142
+ this.#parameters = new ParameterBag(
143
+ globalThis[kPendingSharedStateConstructionData].classDescription,
144
+ globalThis[kPendingSharedStateConstructionData].initValues,
145
+ );
136
146
  } catch (err) {
137
- throw new Error(`Cannot construct 'SharedState': ${err.message}`);
147
+ throw new Error(`Cannot construct 'SharedState' (${this.#className}): ${err.message}`);
138
148
  }
139
149
 
140
150
  /** @private */
141
151
  this[kSharedStatePromiseStore] = new PromiseStore(this.constructor.name);
142
152
 
143
- // add listener for state updates
144
- this.#client.transport.addListener(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`, async (reqId, updates) => {
145
- const updated = await this.#commit(updates, true, true);
146
- this[kSharedStatePromiseStore].resolve(reqId, updated);
147
- });
153
+ this.#client.transport.addListener(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onUpdateResponse);
154
+ this.#client.transport.addListener(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`, this.#onUpdateAbort);
155
+ this.#client.transport.addListener(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onUpdateNotification);
156
+ this.#client.transport.addListener(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onDeleteNotification);
148
157
 
149
- // retrieve values but do not propagate to subscriptions
150
- this.#client.transport.addListener(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`, async (reqId, updates) => {
151
- const updated = await this.#commit(updates, false, true);
152
- this[kSharedStatePromiseStore].resolve(reqId, updated);
153
- });
158
+ if (this.#isOwner) {
159
+ this.#client.transport.addListener(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onDeleteResponse);
160
+ this.#client.transport.addListener(`${DELETE_ERROR}-${this.#id}`, this.#onDeleteError);
161
+ this.#client.transport.addListener(`${HAS_SIBLINGS_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onHasSiblingsNotification);
162
+ } else {
163
+ this.#client.transport.addListener(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onDetachResponse);
164
+ this.#client.transport.addListener(`${DETACH_ERROR}-${this.#id}`, this.#onDetachError);
165
+ }
166
+ }
154
167
 
155
- this.#client.transport.addListener(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, async (updates) => {
156
- // https://github.com/collective-soundworks/soundworks/issues/18
157
- //
158
- // # note: 2002-10-03
159
- //
160
- // `setTimeout(async () => this.#commit(updates, true, false));`
161
- // appears to be the only way to push the update commit in the next event
162
- // cycle so that `attach` can resolve before the update notification is
163
- // actually dispatched. The alternative:
164
- // `Promise.resolve().then(() => this.#commit(updates, true, false))``
165
- // does not behave as expected...
166
- //
167
- // However this breaks the reliability of:
168
- // ```
169
- // /* given a0, a1 and a2 being 3 similar attached states */
170
- // await state.set({ int: i });
171
- //
172
- // assert.equal(a0.get('int'), i);
173
- // assert.equal(a1.get('int'), i);
174
- // assert.equal(a2.get('int'), i);
175
- // ```
176
- // which is far more important than the edge case reported in the issue
177
- // therefore this wont be fixed for now
178
- this.#commit(updates, true, false);
179
- });
168
+ #cleanup() {
169
+ this.#detached = true;
170
+ this.#manager[kStateManagerDeleteState](this.#id);
180
171
 
181
- // ---------------------------------------------
182
- // state has been deleted by its creator or the class has been deleted
183
- // ---------------------------------------------
184
- this.#client.transport.addListener(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, async () => {
185
- this.#manager[kStateManagerDeleteState](this.#id);
186
- this.#clearTransport();
187
-
188
- for (let callback of this.#onDetachCallbacks) {
189
- try {
190
- await callback();
191
- } catch (err) {
192
- console.error(err.message);
193
- }
194
- }
172
+ this.#client.transport.removeAllListeners(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`);
173
+ this.#client.transport.removeAllListeners(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
174
+ this.#client.transport.removeAllListeners(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`);
175
+ this.#client.transport.removeAllListeners(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
195
176
 
196
- if (this.#isOwner) {
197
- for (let callback of this.#onDeleteCallbacks) {
198
- await callback();
199
- }
177
+ if (this.#isOwner) {
178
+ this.#client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`);
179
+ this.#client.transport.removeAllListeners(`${DELETE_ERROR}-${this.#id}-${this.#instanceId}`);
180
+ this.#client.transport.removeAllListeners(`${HAS_SIBLINGS_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
181
+ } else {
182
+ this.#client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`);
183
+ this.#client.transport.removeAllListeners(`${DETACH_ERROR}-${this.#id}-${this.#instanceId}`);
184
+ }
185
+ }
186
+
187
+ #onUpdateResponse = async (reqId, updates) => {
188
+ const updated = await this.#commit(updates, true, true);
189
+ this[kSharedStatePromiseStore].resolve(reqId, updated);
190
+ this.#checkPendingWaitForConditions();
191
+ };
192
+
193
+ #onUpdateAbort = async (reqId, updates) => {
194
+ const updated = await this.#commit(updates, false, true);
195
+ this[kSharedStatePromiseStore].resolve(reqId, updated);
196
+ this.#checkPendingWaitForConditions();
197
+ };
198
+
199
+ #onUpdateNotification = updates => {
200
+ this.#commit(updates, true, false);
201
+ this.#checkPendingWaitForConditions();
202
+ };
203
+
204
+ #onDetachResponse = async (reqId) => {
205
+ this.#cleanup();
206
+
207
+ for (let callback of this.#onDetachCallbacks) {
208
+ try {
209
+ await callback();
210
+ } catch (err) {
211
+ console.log(err.message);
200
212
  }
213
+ }
201
214
 
202
- this.#onDetachCallbacks.clear();
203
- this.#onDeleteCallbacks.clear();
204
- this[kSharedStatePromiseStore].flush();
205
- });
215
+ this.#onDetachCallbacks.clear();
216
+ this.#onDeleteCallbacks.clear();
217
+ this[kSharedStatePromiseStore].resolve(reqId, this);
218
+ this[kSharedStatePromiseStore].flush();
219
+ };
206
220
 
221
+ #onDetachError = (reqId, msg) => {
222
+ this.#onDetachCallbacks.clear();
223
+ this.#onDeleteCallbacks.clear();
224
+ this[kSharedStatePromiseStore].reject(reqId, msg);
225
+ this[kSharedStatePromiseStore].flush();
226
+ };
207
227
 
208
- if (this.#isOwner) {
209
- // ---------------------------------------------
210
- // the creator has called `.delete()`
211
- // ---------------------------------------------
212
- this.#client.transport.addListener(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`, async (reqId) => {
213
- this.#manager[kStateManagerDeleteState](this.#id);
214
- this.#clearTransport();
215
-
216
- for (let callback of this.#onDetachCallbacks) {
217
- try {
218
- await callback();
219
- } catch (err) {
220
- console.log(err.message);
221
- }
222
- }
228
+ #onDeleteResponse = async (reqId) => {
229
+ this.#cleanup();
223
230
 
224
- for (let callback of this.#onDeleteCallbacks) {
225
- try {
226
- await callback();
227
- } catch (err) {
228
- console.log(err.message);
229
- }
230
- }
231
+ for (let callback of this.#onDetachCallbacks) {
232
+ try {
233
+ await callback();
234
+ } catch (err) {
235
+ console.log(err.message);
236
+ }
237
+ }
231
238
 
232
- this.#onDetachCallbacks.clear();
233
- this.#onDeleteCallbacks.clear();
234
- this[kSharedStatePromiseStore].resolve(reqId, this);
235
- this[kSharedStatePromiseStore].flush();
236
- });
239
+ for (let callback of this.#onDeleteCallbacks) {
240
+ try {
241
+ await callback();
242
+ } catch (err) {
243
+ console.log(err.message);
244
+ }
245
+ }
237
246
 
238
- this.#client.transport.addListener(`${DELETE_ERROR}-${this.#id}`, (reqId, msg) => {
239
- this[kSharedStatePromiseStore].reject(reqId, msg);
240
- });
247
+ this.#onDetachCallbacks.clear();
248
+ this.#onDeleteCallbacks.clear();
249
+ this[kSharedStatePromiseStore].resolve(reqId, this);
250
+ this[kSharedStatePromiseStore].flush();
251
+ };
241
252
 
242
- } else {
243
- // ---------------------------------------------
244
- // the attached node has called `.detach()`
245
- // ---------------------------------------------
246
- this.#client.transport.addListener(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`, async (reqId) => {
247
- this.#manager[kStateManagerDeleteState](this.#id);
248
- this.#clearTransport();
249
-
250
- for (let callback of this.#onDetachCallbacks) {
251
- try {
252
- await callback();
253
- } catch (err) {
254
- console.log(err.message);
255
- }
256
- }
253
+ #onDeleteError = (reqId, msg) => {
254
+ this[kSharedStatePromiseStore].reject(reqId, msg);
255
+ };
257
256
 
258
- this.#onDetachCallbacks.clear();
259
- this.#onDeleteCallbacks.clear();
260
- this[kSharedStatePromiseStore].resolve(reqId, this);
261
- this[kSharedStatePromiseStore].flush();
262
- });
257
+ #onDeleteNotification = async () => {
258
+ this.#cleanup();
263
259
 
264
- // the state does not exists anymore in the server (should not happen)
265
- this.#client.transport.addListener(`${DETACH_ERROR}-${this.#id}`, (reqId, msg) => {
266
- this.#onDetachCallbacks.clear();
267
- this.#onDeleteCallbacks.clear();
268
- this[kSharedStatePromiseStore].reject(reqId, msg);
269
- this[kSharedStatePromiseStore].flush();
270
- });
260
+ for (let callback of this.#onDetachCallbacks) {
261
+ try {
262
+ await callback();
263
+ } catch (err) {
264
+ console.error(err.message);
265
+ }
271
266
  }
272
- }
273
267
 
274
- /**
275
- * Id of the state
276
- * @type {Number}
277
- */
278
- get id() {
279
- return this.#id;
280
- }
268
+ if (this.#isOwner) {
269
+ for (let callback of this.#onDeleteCallbacks) {
270
+ await callback();
271
+ }
272
+ }
281
273
 
282
- /**
283
- * Name of the underlying {@link SharedState} class.
284
- * @type {String}
285
- */
286
- get className() {
287
- return this.#className;
288
- }
274
+ this.#onDetachCallbacks.clear();
275
+ this.#onDeleteCallbacks.clear();
276
+ this[kSharedStatePromiseStore].flush();
277
+ };
289
278
 
290
- /**
291
- * @deprecated Use {@link SharedState#className} instead.
292
- */
293
- get schemaName() {
294
- logger.deprecated('SharedState#schemaName', 'SharedState#className', '4.0.0-alpha.29');
295
- return this.className;
296
- }
279
+ #onHasSiblingsNotification = hasSiblings => {
280
+ this.#hasSiblings = hasSiblings;
281
+ };
297
282
 
298
- /**
299
- * Indicates if the node is the owner of the state, i.e. if it created the state.
300
- * @type {Boolean}
301
- */
302
- get isOwner() {
303
- return this.#isOwner;
283
+ #checkParamNameAgainstFilters(paramName) {
284
+ if (this.#filter === null) {
285
+ return;
286
+ }
287
+
288
+ const { whiteList, blackList } = this.#filter;
289
+
290
+ if (whiteList && !whiteList.includes(paramName)) {
291
+ throw new Error(`Parameter '${paramName}' is not in white list`);
292
+ } else if (blackList && blackList.includes(paramName)) {
293
+ throw new Error(`Parameter '${paramName}' is in black list`);
294
+ }
304
295
  }
305
296
 
306
- #clearTransport() {
307
- // remove listeners
308
- this.#client.transport.removeAllListeners(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`);
309
- this.#client.transport.removeAllListeners(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
310
- this.#client.transport.removeAllListeners(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`);
311
- this.#client.transport.removeAllListeners(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
297
+ #sanitizeValuesAgainstFilters(values) {
298
+ if (this.#filter === null) {
299
+ return values;
300
+ }
312
301
 
313
- if (this.#isOwner) {
314
- this.#client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`);
315
- this.#client.transport.removeAllListeners(`${DELETE_ERROR}-${this.#id}-${this.#instanceId}`);
316
- } else {
317
- this.#client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`);
318
- this.#client.transport.removeAllListeners(`${DETACH_ERROR}-${this.#id}-${this.#instanceId}`);
302
+ const { whiteList, blackList } = this.#filter;
303
+
304
+ for (let name in values) {
305
+ if (whiteList && !whiteList.includes(name)) {
306
+ delete values[name];
307
+ } else if (blackList && blackList.includes(name)) {
308
+ delete values[name];
309
+ }
319
310
  }
311
+
312
+ return values;
320
313
  }
321
314
 
322
315
  async #commit(updates, propagate = true, initiator = false) {
@@ -348,19 +341,14 @@ class SharedState {
348
341
  oldValues[name] = oldValue;
349
342
  }
350
343
 
351
- // if the `UPDATE_REQUEST` as been aborted by the server, do not propagate
352
- let promises = [];
353
-
344
+ // do not propagate if the `UPDATE_REQUEST` as been aborted by the server.
354
345
  if (propagate && Object.keys(newValues).length > 0) {
355
- this.#onUpdateCallbacks.forEach(listener => {
356
- promises.push(listener(newValues, oldValues));
357
- });
346
+ const callbackPromises = this.#executeUpdateCallbacks(newValues, oldValues);
347
+ // on a given client, `await state.set(update)` resolves after all
348
+ // update callbacks have themselves resolved
349
+ await Promise.all(callbackPromises);
358
350
  }
359
351
 
360
- // on a given client, `await state.set(update)` resolves after all
361
- // update callbacks have themselves resolved
362
- await Promise.all(promises);
363
-
364
352
  // reset events to null after propagation of all listeners
365
353
  for (let name in newValues) {
366
354
  const { event } = this.#parameters.getDescription(name);
@@ -373,6 +361,98 @@ class SharedState {
373
361
  return newValues;
374
362
  }
375
363
 
364
+ #executeUpdateCallbacks(newValues, oldValues) {
365
+ let promises = [];
366
+
367
+ this.#onUpdateCallbacks.forEach(listenerPayload => {
368
+ let somePromise = this.#executeUpdateCallback(listenerPayload, newValues, oldValues);
369
+ promises.push(somePromise);
370
+ });
371
+
372
+ return promises;
373
+ }
374
+
375
+ #executeUpdateCallback(listenerPayload, newValues, oldValues) {
376
+ const { paramName, listener, unsubscribe } = listenerPayload;
377
+
378
+ if (paramName === null) {
379
+ return listener(newValues, oldValues, unsubscribe);
380
+ } else if (paramName in newValues) {
381
+ return listener(newValues[paramName], oldValues[paramName], unsubscribe);
382
+ }
383
+ }
384
+
385
+ #checkPendingWaitForConditions() {
386
+ const values = this.getValuesUnsafe();
387
+
388
+ this.#waitForConditions.forEach(payload => {
389
+ const { resolve, condition, timeoutId } = payload;
390
+ let match = true;
391
+
392
+ for (let [key, value] of Object.entries(condition)) {
393
+ if (values[key] !== value) {
394
+ match = false;
395
+ }
396
+ }
397
+
398
+ if (match) {
399
+ clearTimeout(timeoutId);
400
+ this.#waitForConditions.delete(payload);
401
+ resolve();
402
+ }
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Id of the state
408
+ * @type {Number}
409
+ */
410
+ get id() {
411
+ return this.#id;
412
+ }
413
+
414
+ /**
415
+ * Name of the underlying {@link SharedState} class.
416
+ * @type {String}
417
+ */
418
+ get className() {
419
+ return this.#className;
420
+ }
421
+
422
+ /**
423
+ * @deprecated Use {@link SharedState#className} instead.
424
+ */
425
+ get schemaName() {
426
+ warnings.deprecated('SharedState#schemaName', 'SharedState#className', '4.0.0-alpha.29');
427
+ return this.className;
428
+ }
429
+
430
+ /**
431
+ * Indicates if the node is the owner of the state, i.e. if it created the state.
432
+ * @type {Boolean}
433
+ */
434
+ get isOwner() {
435
+ return this.#isOwner;
436
+ }
437
+
438
+ /**
439
+ * Indicates if the node as siblings, i.e. if several versions of the same state
440
+ * exists on the network
441
+ * Always return true if the state instance is attached
442
+ * @type {Boolean}
443
+ */
444
+ get hasSiblings() {
445
+ if (this.#detached) {
446
+ return false;
447
+ }
448
+
449
+ if (!this.isOwner) {
450
+ return true;
451
+ }
452
+
453
+ return this.#hasSiblings;
454
+ }
455
+
376
456
  /**
377
457
  * Return the underlying {@link SharedStateClassDescription} or the
378
458
  * {@link SharedStateParameterDescription} if `paramName` is given.
@@ -393,14 +473,6 @@ class SharedState {
393
473
  }
394
474
  }
395
475
 
396
- /**
397
- * @deprecated Use {@link SharedState#getDescription} instead.
398
- */
399
- getSchema(paramName = null) {
400
- logger.deprecated('SharedState#getSchema', 'SharedState#getDescription', '4.0.0-alpha.29');
401
- return this.getDescription(paramName);
402
- }
403
-
404
476
  /**
405
477
  * Update the values of the state.
406
478
  *
@@ -466,7 +538,7 @@ class SharedState {
466
538
  }
467
539
 
468
540
  if (isPlainObject(arguments[0]) && isPlainObject(arguments[1])) {
469
- logger.removed('`context` argument in SharedState.set(updates, context)', 'a regular parameter configured with `event=true`', '4.0.0-alpha.29');
541
+ warnings.removed('`context` argument in SharedState.set(updates, context)', 'a regular parameter configured with `event=true`', '4.0.0-alpha.29');
470
542
  }
471
543
 
472
544
  if (arguments.length === 2 && isString(updates)) {
@@ -485,6 +557,12 @@ class SharedState {
485
557
  let syncedOnUpdate = false;
486
558
 
487
559
  for (let name in updates) {
560
+ // Throw early if some param is blacklisted or not whitelisted
561
+ try {
562
+ this.#checkParamNameAgainstFilters(name);
563
+ } catch (err) {
564
+ throw new DOMException(`Cannot execute 'set' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
565
+ }
488
566
  // Try to coerce value now, so that eventual errors are triggered early
489
567
  // on the node requesting the update and not only on the server side.
490
568
  try {
@@ -494,13 +572,6 @@ class SharedState {
494
572
  throw new TypeError(`Cannot execute 'set' on SharedState (${this.#className}): ${err.message}`);
495
573
  }
496
574
 
497
- // Make sure that given name is in filter white list (if any)
498
- if (this.#filter !== null) {
499
- if (!this.#filter.includes(name)) {
500
- throw new DOMException(`Cannot execute 'set' on SharedState (${this.#className}): Parameter '${name}' is not in white list`, 'NotSupportedError');
501
- }
502
- }
503
-
504
575
  // ### `immediate` modifier behavior
505
576
  //
506
577
  // If immediate=true
@@ -551,11 +622,14 @@ class SharedState {
551
622
  hasLocalParam = true;
552
623
  locallyResolvedParam[name] = this.#parameters.get(name);
553
624
  } else if (acknowledge === false) {
554
- // these go to the network but don't receive an ack
555
- // they are both locally forwarded and network propagated then
625
+ // when acknowledge is false, e.g. streaming values, values are
626
+ // propagated on the network only if hasSiblings is true
556
627
  hasNonAcknowledgedParams = true;
557
628
  locallyResolvedParam[name] = this.#parameters.get(name);
558
- networkPropagatedParams[name] = updates[name];
629
+ // propagate only if there is an attached state somewhere
630
+ if (this.hasSiblings) {
631
+ networkPropagatedParams[name] = updates[name];
632
+ }
559
633
  } else {
560
634
  // note that immediate params are shared and acknowledged
561
635
  hasRegularSharedParam = true;
@@ -563,9 +637,9 @@ class SharedState {
563
637
  }
564
638
  }
565
639
 
566
- // params that trigger a synced onUpdate call: immediate, local, non-acknowledged
640
+ // params that trigger a synced onUpdate call are: immediate, local, non-acknowledged
567
641
  if (syncedOnUpdate) {
568
- this.#onUpdateCallbacks.forEach(listener => listener(newValues, oldValues));
642
+ this.#executeUpdateCallbacks(newValues, oldValues);
569
643
  }
570
644
 
571
645
  // if we only have local params, we can resolve immediately
@@ -580,7 +654,9 @@ class SharedState {
580
654
  this[kSharedStatePromiseStore].associateResolveData(reqId, locallyResolvedParam);
581
655
  }
582
656
 
583
- this.#client.transport.emit(`${UPDATE_REQUEST}-${this.#id}-${this.#instanceId}`, reqId, networkPropagatedParams);
657
+ if (Object.keys(networkPropagatedParams).length > 0) {
658
+ this.#client.transport.emit(`${UPDATE_REQUEST}-${this.#id}-${this.#instanceId}`, reqId, networkPropagatedParams);
659
+ }
584
660
 
585
661
  // if we don't have regular shared params, we can resolve now
586
662
  if (!hasRegularSharedParam) {
@@ -610,10 +686,11 @@ class SharedState {
610
686
  throw new ReferenceError(`Cannot execute 'get' on SharedState (${this.#className}): Parameter '${name}' is not defined`);
611
687
  }
612
688
 
613
- if (this.#filter !== null) {
614
- if (!this.#filter.includes(name)) {
615
- throw new DOMException(`Cannot execute 'get' on SharedState (${this.#className}): Parameter '${name}' is not in white list`, 'NotSupportedError');
616
- }
689
+ // Throw early if some param is blacklisted or not whitelisted
690
+ try {
691
+ this.#checkParamNameAgainstFilters(name);
692
+ } catch (err) {
693
+ throw new DOMException(`Cannot execute 'get' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
617
694
  }
618
695
 
619
696
  return this.#parameters.get(name);
@@ -639,10 +716,11 @@ class SharedState {
639
716
  throw new ReferenceError(`Cannot execute 'getUnsafe' on SharedState (${this.#className}): Parameter '${name}' is not defined`);
640
717
  }
641
718
 
642
- if (this.#filter !== null) {
643
- if (!this.#filter.includes(name)) {
644
- throw new DOMException(`Cannot execute 'getUnsafe' on SharedState (${this.#className}): Parameter '${name}' is not in white list`, 'NotSupportedError');
645
- }
719
+ // Throw early if some param is blacklisted or not whitelisted
720
+ try {
721
+ this.#checkParamNameAgainstFilters(name);
722
+ } catch (err) {
723
+ throw new DOMException(`Cannot execute 'getUnsafe' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
646
724
  }
647
725
 
648
726
  return this.#parameters.getUnsafe(name);
@@ -659,16 +737,7 @@ class SharedState {
659
737
  */
660
738
  getValues() {
661
739
  const values = this.#parameters.getValues();
662
-
663
- if (this.#filter !== null) {
664
- for (let name in values) {
665
- if (!this.#filter.includes(name)) {
666
- delete values[name];
667
- }
668
- }
669
- }
670
-
671
- return values;
740
+ return this.#sanitizeValuesAgainstFilters(values);
672
741
  }
673
742
 
674
743
  /**
@@ -686,16 +755,7 @@ class SharedState {
686
755
  */
687
756
  getValuesUnsafe() {
688
757
  const values = this.#parameters.getValuesUnsafe();
689
-
690
- if (this.#filter !== null) {
691
- for (let name in values) {
692
- if (!this.#filter.includes(name)) {
693
- delete values[name];
694
- }
695
- }
696
- }
697
-
698
- return values;
758
+ return this.#sanitizeValuesAgainstFilters(values);
699
759
  }
700
760
 
701
761
  /**
@@ -776,9 +836,43 @@ class SharedState {
776
836
  }
777
837
  }
778
838
 
839
+ /**
840
+ * Subscribe to any updates in the state.
841
+ *
842
+ * @overload
843
+ * @param {sharedStateOnUpdateCallback} callback
844
+ * Callback to execute when an update is applied on the state.
845
+ * @param {Boolean} [executeListener=false] - Execute the callback immediately
846
+ * with current state values. Note that `oldValues` will be set to `{}`.
847
+ * @returns {sharedStateDeleteOnUpdateCallback}
848
+ * @example
849
+ * state.onUpdate(updates => {
850
+ * // ...
851
+ * });
852
+ */
853
+ /**
854
+ * Subscribe to updates in the state filtered by a given parameter name.
855
+ *
856
+ * @overload
857
+ * @param {SharedStateParameterName} paramName
858
+ * @param {sharedStateOnUpdateCallback} callback
859
+ * Callback to execute when an update is applied on the state.
860
+ * @param {Boolean} [executeListener=false] - Execute the callback immediately
861
+ * with current state values. Note that `oldValues` will be set to `{}`.
862
+ * @returns {sharedStateDeleteOnUpdateCallback}
863
+ * @example
864
+ * state.onUpdate('my-param', myValue => {
865
+ * // ...
866
+ * });
867
+ */
779
868
  /**
780
869
  * Subscribe to state updates.
781
870
  *
871
+ * Alternative signatures:
872
+ * - `state.onUpdate(callback, executeListener)`
873
+ * - `state.onUpdate(paramName, callback, executeListener)`
874
+ *
875
+ * @param {SharedStateParameterName} paramName
782
876
  * @param {sharedStateOnUpdateCallback} callback
783
877
  * Callback to execute when an update is applied on the state.
784
878
  * @param {Boolean} [executeListener=false] - Execute the callback immediately
@@ -796,8 +890,13 @@ class SharedState {
796
890
  * // later
797
891
  * unsubscribe();
798
892
  */
799
- onUpdate(listener, executeListener = false) {
800
- this.#onUpdateCallbacks.add(listener);
893
+ onUpdate(...args) {
894
+ const { paramName, listener, executeListener } = sanitizeOnUpdateParams(this, ...args);
895
+
896
+ const listenerPayload = { paramName, listener };
897
+ this.#onUpdateCallbacks.add(listenerPayload);
898
+ // create unsubscribe and add to payload, to pass it back to the listeners as argument
899
+ listenerPayload.unsubscribe = () => this.#onUpdateCallbacks.delete(listenerPayload);
801
900
 
802
901
  if (executeListener === true) {
803
902
  const currentValues = this.getValues();
@@ -811,12 +910,10 @@ class SharedState {
811
910
  }
812
911
  }
813
912
 
814
- listener(currentValues, {});
913
+ this.#executeUpdateCallback(listenerPayload, currentValues, {});
815
914
  }
816
915
 
817
- return () => {
818
- this.#onUpdateCallbacks.delete(listener);
819
- };
916
+ return listenerPayload.unsubscribe;
820
917
  }
821
918
 
822
919
  /**
@@ -847,6 +944,67 @@ class SharedState {
847
944
  this.#onDeleteCallbacks.add(callback);
848
945
  return () => this.#onDeleteCallbacks.delete(callback);
849
946
  }
947
+
948
+ /**
949
+ * Wait for a given condition in the state of the SharedState instance.
950
+ * If the state watches the condition when `waitFor` is called, the promise is
951
+ * resolved immediately.
952
+ *
953
+ * @note - mark as private until tested in real-world
954
+ *
955
+ * @private
956
+ * @param {object} condition - Condition to be meet in the state for the
957
+ * returned promise to resolve.
958
+ * @param {object} condition - Timeout (in milliseconds) that trigger the rejection
959
+ * of the returned promise.
960
+ * @return {Promise}
961
+ */
962
+ waitFor(condition, timeout = null) {
963
+ if (!isPlainObject(condition)) {
964
+ throw new TypeError(`Cannot execute 'waitFor' on SharedState: argument 0 must be an object`);
965
+ }
966
+
967
+ if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
968
+ throw new TypeError(`Cannot execute 'waitFor' on SharedState: optional argument 1 must be a finite strictly positive number`);
969
+ }
970
+
971
+ // @todo - check condition keys are not filtered, this could never resolve
972
+ const descriptionKeys = Object.keys(this.getDescription());
973
+ const conditionKeys = Object.keys(condition);
974
+ const diff = conditionKeys.filter(key => !descriptionKeys.includes(key));
975
+
976
+ if (diff.length > 0) {
977
+ throw new ReferenceError(`Cannot execute 'waitFor' on SharedState: condition contains keys (${diff.join(', ')}) that are not declared in class description`);
978
+ }
979
+
980
+ const { promise, resolve, reject } = Promise.withResolvers();
981
+ const payload = { resolve, condition };
982
+
983
+ if (timeout !== null) {
984
+ payload.timeoutId = setTimeout(() => {
985
+ this.#waitForConditions.delete(payload);
986
+ reject();
987
+ }, timeout);
988
+ }
989
+
990
+ this.#waitForConditions.add(payload);
991
+ this.#checkPendingWaitForConditions();
992
+
993
+ return promise;
994
+ }
995
+
996
+ // ---------------------------------------------------------------------------
997
+ // DEPRECATED
998
+ // ---------------------------------------------------------------------------
999
+
1000
+ /**
1001
+ * @deprecated Use {@link SharedState#getDescription} instead.
1002
+ */
1003
+ getSchema(paramName = null) {
1004
+ warnings.deprecated('SharedState#getSchema', 'SharedState#getDescription', '4.0.0-alpha.29');
1005
+ return this.getDescription(paramName);
1006
+ }
1007
+
850
1008
  }
851
1009
 
852
1010
  export default SharedState;