@soundworks/core 5.2.0 → 5.3.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/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## v5.2.1 - 2025-08-11
2
+
3
+ - Fix: Use CancelablePromise to cancel pending promise rather than reject when a state is deleted
4
+
5
+ ## v5.2.0 - 2025-05-05
6
+
7
+ - Feat: add ServerStateManager#isClassDefined method
8
+ - Fix: Remove class description cache mechanism to avoid possible concurrency issues when recreating a SharedStateClass with same name
9
+
1
10
  ## v5.1.1 - 2025-04-28
2
11
 
3
12
  - Hotfix: run behind https proxy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soundworks/core",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "description": "Open-source creative coding framework for distributed applications based on Web technologies",
5
5
  "collaborators": [
6
6
  "Benjamin Matuszewski",
@@ -55,6 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@ircam/sc-utils": "^1.8.0",
58
+ "cancelable-promise": "^4.3.1",
58
59
  "chalk": "^5.3.0",
59
60
  "fast-deep-equal": "^3.1.3",
60
61
  "isomorphic-ws": "^5.0.0",
@@ -314,6 +314,10 @@ class Client {
314
314
  * await client.start();
315
315
  */
316
316
  async init() {
317
+ if (this.#status === 'inited') {
318
+ throw new DOMException(`Cannot execute 'init' twice on Client: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
319
+ }
320
+
317
321
  if (this.#status !== 'idle') {
318
322
  throw new DOMException(`Cannot execute 'init' on Client: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
319
323
  }
@@ -391,6 +395,10 @@ class Client {
391
395
  await this.init();
392
396
  }
393
397
 
398
+ if (this.#status === 'started') {
399
+ throw new DOMException(`Cannot execute 'start' twice on Client: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
400
+ }
401
+
394
402
  if (this.#status !== 'inited') {
395
403
  throw new DOMException(`Cannot execute 'start' on Client: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
396
404
  }
@@ -416,6 +424,10 @@ class Client {
416
424
  * await client.stop();
417
425
  */
418
426
  async stop() {
427
+ if (this.#status === 'stopped') {
428
+ throw new DOMException(`Cannot execute 'stop' twice on Client: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
429
+ }
430
+
419
431
  if (this.#status !== 'started') {
420
432
  throw new DOMException(`Cannot execute 'stop' on Client: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
421
433
  }
@@ -214,10 +214,10 @@ class ClientContext {
214
214
 
215
215
  // we need the try/catch block to change the promise rejection into proper error
216
216
  try {
217
- await new Promise((resolve, reject) => {
218
- const reqId = promiseStore.add(resolve, reject, 'ClientContext#enter');
219
- this.#client.socket.send(CONTEXT_ENTER_REQUEST, reqId, this.name);
220
- });
217
+ const { id: reqId, promise } = promiseStore.createPromise();
218
+ this.#client.socket.send(CONTEXT_ENTER_REQUEST, reqId, this.name);
219
+
220
+ await promise;
221
221
  } catch (err) {
222
222
  throw new Error(err);
223
223
  }
@@ -248,10 +248,10 @@ class ClientContext {
248
248
  async exit() {
249
249
  // we need the try/catch block to change the promise rejection into proper error
250
250
  try {
251
- await new Promise((resolve, reject) => {
252
- const reqId = promiseStore.add(resolve, reject, 'ClientContext#exit');
253
- this.#client.socket.send(CONTEXT_EXIT_REQUEST, reqId, this.name);
254
- });
251
+ const { id: reqId, promise } = promiseStore.createPromise();
252
+ this.#client.socket.send(CONTEXT_EXIT_REQUEST, reqId, this.name);
253
+
254
+ await promise;
255
255
  } catch (err) {
256
256
  throw new Error(err);
257
257
  }
@@ -18,7 +18,7 @@
18
18
  * Environment configuration object.
19
19
  *
20
20
  * @typedef ClientEnvConfig
21
- * @type {objecy}
21
+ * @type {object}
22
22
  * @property {number} env.port - Port on which the server is listening.
23
23
  * @property {string} env.serverAddress - Domain name or IP of the server.
24
24
  * Mandatory when node clients are declared
@@ -244,10 +244,10 @@ class BaseStateManager {
244
244
  throw new DOMException(`Cannot execute 'getClassDescription' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
245
245
  }
246
246
 
247
- return new Promise((resolve, reject) => {
248
- const reqId = this.#promiseStore.add(resolve, reject, 'BaseStateManager#getClassDescription');
249
- this[kStateManagerClient].transport.emit(GET_CLASS_DESCRIPTION_REQUEST, reqId, className);
250
- });
247
+ const { id: reqId, promise } = this.#promiseStore.createPromise();
248
+ this[kStateManagerClient].transport.emit(GET_CLASS_DESCRIPTION_REQUEST, reqId, className);
249
+
250
+ return promise;
251
251
  }
252
252
 
253
253
  /**
@@ -272,10 +272,10 @@ class BaseStateManager {
272
272
  throw new DOMException(`Cannot execute 'create' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
273
273
  }
274
274
 
275
- return new Promise((resolve, reject) => {
276
- const reqId = this.#promiseStore.add(resolve, reject, 'BaseStateManager#create');
277
- this[kStateManagerClient].transport.emit(CREATE_REQUEST, reqId, className, initValues);
278
- });
275
+ const { id: reqId, promise } = this.#promiseStore.createPromise();
276
+ this[kStateManagerClient].transport.emit(CREATE_REQUEST, reqId, className, initValues);
277
+
278
+ return promise;
279
279
  }
280
280
 
281
281
  /**
@@ -380,10 +380,10 @@ class BaseStateManager {
380
380
  }
381
381
  }
382
382
 
383
- return new Promise((resolve, reject) => {
384
- const reqId = this.#promiseStore.add(resolve, reject, 'BaseStateManager#attach');
385
- this[kStateManagerClient].transport.emit(ATTACH_REQUEST, reqId, className, stateId, filter);
386
- });
383
+ const { id: reqId, promise } = this.#promiseStore.createPromise();
384
+ this[kStateManagerClient].transport.emit(ATTACH_REQUEST, reqId, className, stateId, filter);
385
+
386
+ return promise;
387
387
  }
388
388
 
389
389
  /**
@@ -562,33 +562,33 @@ class BaseStateManager {
562
562
  }
563
563
 
564
564
  // resend request to get updated list of states
565
- return new Promise((resolve, reject) => {
566
- const reqId = this.#promiseStore.add(resolve, reject, 'BaseStateManager#observe');
567
- // store the callback for execution on the response. the returned Promise
568
- // is fulfilled once callback has been executed with each existing states
569
- const observeInfos = [observedClassName, callback, options];
570
- this.#observeRequestCallbacks.set(reqId, observeInfos);
571
-
572
- // NOTE: do not store in `_observeListeners` yet as it can produce race
573
- // conditions, e.g.:
574
- // ```
575
- // await client.stateManager.observe(async (className, stateId, nodeId) => {});
576
- // // client now receives OBSERVE_NOTIFICATIONS
577
- // await otherClient.stateManager.create('a');
578
- // // second observer added in between
579
- // client.stateManager.observe(async (className, stateId, nodeId) => {});
580
- // ````
581
- // OBSERVE_NOTIFICATION is received before the OBSERVE_RESPONSE, then the
582
- // second observer is called twice:
583
- // - OBSERVE_RESPONSE 1 []
584
- // - OBSERVE_NOTIFICATION [ 'a', 1, 0 ]
585
- // - OBSERVE_NOTIFICATION [ 'a', 1, 0 ] // this should not happen
586
- // - OBSERVE_RESPONSE 1 [ [ 'a', 1, 0 ] ]
587
- //
588
- // cf. unit test `observe should properly behave in race condition`
589
-
590
- this[kStateManagerClient].transport.emit(OBSERVE_REQUEST, reqId, observedClassName);
591
- });
565
+ const { id: reqId, promise } = this.#promiseStore.createPromise();
566
+ // store the callback for execution on the response. the returned Promise
567
+ // is fulfilled once callback has been executed with each existing states
568
+ const observeInfos = [observedClassName, callback, options];
569
+ this.#observeRequestCallbacks.set(reqId, observeInfos);
570
+
571
+ // NOTE: do not store in `_observeListeners` yet as it can produce race
572
+ // conditions, e.g.:
573
+ // ```
574
+ // await client.stateManager.observe(async (className, stateId, nodeId) => {});
575
+ // // client now receives OBSERVE_NOTIFICATIONS
576
+ // await otherClient.stateManager.create('a');
577
+ // // second observer added in between
578
+ // client.stateManager.observe(async (className, stateId, nodeId) => {});
579
+ // ````
580
+ // OBSERVE_NOTIFICATION is received before the OBSERVE_RESPONSE, then the
581
+ // second observer is called twice:
582
+ // - OBSERVE_RESPONSE 1 []
583
+ // - OBSERVE_NOTIFICATION [ 'a', 1, 0 ]
584
+ // - OBSERVE_NOTIFICATION [ 'a', 1, 0 ] // this should not happen
585
+ // - OBSERVE_RESPONSE 1 [ [ 'a', 1, 0 ] ]
586
+ //
587
+ // cf. unit test `observe should properly behave in race condition`
588
+
589
+ this[kStateManagerClient].transport.emit(OBSERVE_REQUEST, reqId, observedClassName);
590
+
591
+ return promise;
592
592
  }
593
593
 
594
594
  /**
@@ -1,11 +1,7 @@
1
1
  import { BATCHED_TRANSPORT_CHANNEL } from './constants.js';
2
2
 
3
3
  /**
4
- * This class proxies transports given the SharedStateManager to batch messages
5
- *
6
- * @param {number} [options.wait=0] - Wait for given number of milliseconds
7
- * for stacking messages before emitting on the network. If 0 is given, network
8
- * message is emitted on next microtask
4
+ * This class proxies transports, i.e. WebSockets, to batch message sends
9
5
  * @private
10
6
  */
11
7
  class BatchedTransport {
@@ -12,6 +12,7 @@ export const sharedOptions = {
12
12
  metas: {},
13
13
  filterChange: true,
14
14
  immediate: false,
15
+ acknowledge: true,
15
16
  };
16
17
 
17
18
  export const types = {
@@ -1,52 +1,68 @@
1
- import { idGenerator } from '@ircam/sc-utils';
1
+ import { counter } from '@ircam/sc-utils';
2
+ // common js module...
3
+ import cancelable from 'cancelable-promise';
4
+ const { CancelablePromise } = cancelable;
2
5
 
3
6
  /** @private */
4
7
  export default class PromiseStore {
5
8
  constructor(name) {
6
9
  this.name = name;
7
10
  this.store = new Map();
8
- this.generateRequestId = idGenerator();
11
+ this.generateId = counter();
9
12
  }
10
13
 
11
- add(resolve, reject, type, localParams) {
12
- const reqId = this.generateRequestId.next().value;
13
- this.store.set(reqId, { resolve, reject, type, localParams });
14
+ createPromise() {
15
+ const id = this.generateId();
16
+ let resolve, reject;
14
17
 
15
- return reqId;
18
+ const promise = new CancelablePromise((resolveFunc, rejectFunc) => {
19
+ resolve = resolveFunc;
20
+ reject = rejectFunc;
21
+ });
22
+
23
+ this.store.set(id, { promise, resolve, reject });
24
+
25
+ return { id, promise };
26
+ }
27
+
28
+ // associate data to merge within the data given when calling `resolve`
29
+ // cf. SharedState#set
30
+ associateResolveData(id, associatedData) {
31
+ const stored = this.store.get(id);
32
+ stored.associatedData = associatedData;
16
33
  }
17
34
 
18
- resolve(reqId, data) {
19
- if (this.store.has(reqId)) {
20
- const { resolve, localParams } = this.store.get(reqId);
21
- this.store.delete(reqId);
35
+ resolve(id, data) {
36
+ if (this.store.has(id)) {
37
+ const { resolve, associatedData } = this.store.get(id);
38
+ this.store.delete(id);
22
39
 
23
40
  // re-merge local params into network response
24
- if (localParams !== undefined) {
25
- Object.assign(data, localParams);
41
+ if (associatedData !== undefined) {
42
+ Object.assign(data, associatedData);
26
43
  }
27
44
 
28
45
  resolve(data);
29
46
  } else {
30
- throw new ReferenceError(`Cannot resolve request id (${reqId}): id does not exist`);
47
+ throw new ReferenceError(`Cannot resolve request id (${id}): id does not exist`);
31
48
  }
32
49
  }
33
50
 
34
- reject(reqId, msg) {
35
- if (this.store.has(reqId)) {
36
- const { reject } = this.store.get(reqId);
37
- this.store.delete(reqId);
51
+ reject(id, msg) {
52
+ if (this.store.has(id)) {
53
+ const { reject } = this.store.get(id);
54
+ this.store.delete(id);
38
55
 
39
56
  reject(new Error(msg));
40
57
  } else {
41
- throw new ReferenceError(`Cannot resolve request id (${reqId}): id does not exist`);
58
+ throw new ReferenceError(`Cannot resolve request id (${id}): id does not exist`);
42
59
  }
43
60
  }
44
61
 
45
- // reject all pending request
62
+ // cancel all pending request
46
63
  flush() {
47
- for (let [_reqId, entry] of this.store) {
48
- const { reject, type } = entry;
49
- reject(new Error(`Discard promise '${type}'`));
64
+ for (let [_id, entry] of this.store) {
65
+ entry.promise.cancel();
50
66
  }
51
67
 
52
68
  this.store.clear();
@@ -465,31 +465,31 @@ class SharedState {
465
465
 
466
466
  const newValues = {};
467
467
  const oldValues = {};
468
- const localParams = {};
469
- const sharedParams = {};
468
+ const locallyResolvedParam = {};
469
+ const networkPropagatedParams = {};
470
470
  let hasLocalParam = false;
471
- let hasSharedParam = false;
472
- let forwardParams = undefined;
473
- let propagateNow = false;
471
+ let hasRegularSharedParam = false;
472
+ let hasNonAcknowledgedParams = false;
473
+ let syncedOnUpdate = false;
474
474
 
475
475
  for (let name in updates) {
476
- // Try to coerce value early, so that eventual errors are triggered early
477
- // on the node requesting the update, and not only on the server side
478
- // This throws if name does not exists
476
+ // Try to coerce value now, so that eventual errors are triggered early
477
+ // on the node requesting the update and not only on the server side.
479
478
  try {
479
+ // This also throws if name does not exists
480
480
  this.#parameters.coerceValue(name, updates[name]);
481
481
  } catch (err) {
482
482
  throw new TypeError(`Cannot execute 'set' on SharedState (${this.#className}): ${err.message}`);
483
483
  }
484
484
 
485
- // Check that name is in filter list, if any
485
+ // Make sure that given name is in filter white list (if any)
486
486
  if (this.#filter !== null) {
487
487
  if (!this.#filter.includes(name)) {
488
488
  throw new DOMException(`Cannot execute 'set' on SharedState (${this.#className}): Parameter '${name}' is not in white list`, 'NotSupportedError');
489
489
  }
490
490
  }
491
491
 
492
- // `immediate` option behavior
492
+ // ### `immediate` modifier behavior
493
493
  //
494
494
  // If immediate=true
495
495
  // - call listeners if value changed
@@ -501,7 +501,7 @@ class SharedState {
501
501
  // - if the node is initiator of the update (UPDATE_RESPONSE), (re-)check
502
502
  // to prevent execute the listeners twice
503
503
 
504
- // `local` option behavior
504
+ // ### `local` modifier behavior
505
505
  //
506
506
  // - If the `updates` object only contains local variables, we can call the
507
507
  // update listeners and return a fulfilled promise immediately
@@ -510,9 +510,19 @@ class SharedState {
510
510
  // response, call update listeners with server response and resolve promise with
511
511
  // the full payload, i.e. reintegrating the local params in the resolve payload
512
512
 
513
- const { local, immediate, filterChange, event } = this.#parameters.getDescription(name);
513
+ // ### `acknowledge` modifier behavior
514
+ //
515
+ // Same as immediate, but no feedback from server
516
+
517
+ const {
518
+ local,
519
+ immediate,
520
+ filterChange,
521
+ event,
522
+ acknowledge,
523
+ } = this.#parameters.getDescription(name);
514
524
 
515
- if (immediate || local) {
525
+ if (immediate || local || acknowledge === false) {
516
526
  const oldValue = this.#parameters.get(name);
517
527
  const [newValue, changed] = this.#parameters.set(name, updates[name]);
518
528
 
@@ -520,45 +530,52 @@ class SharedState {
520
530
  if (changed || filterChange === false || event) {
521
531
  oldValues[name] = oldValue;
522
532
  newValues[name] = newValue;
523
- propagateNow = true;
533
+ syncedOnUpdate = true;
524
534
  }
525
535
  }
526
536
 
527
- // define params that must go through network or not
528
537
  if (local) {
538
+ // these don't go to the network
529
539
  hasLocalParam = true;
530
- // get sanitize value for fulfilling promise
531
- localParams[name] = this.#parameters.get(name);
540
+ locallyResolvedParam[name] = this.#parameters.get(name);
541
+ } else if (acknowledge === false) {
542
+ // these go to the network but don't receive an ack
543
+ // they are both locally forwarded and network propagated then
544
+ hasNonAcknowledgedParams = true;
545
+ locallyResolvedParam[name] = this.#parameters.get(name);
546
+ networkPropagatedParams[name] = updates[name];
532
547
  } else {
533
- // note that immediate are shared params too
534
- hasSharedParam = true;
535
- sharedParams[name] = updates[name];
548
+ // note that immediate params are shared and acknowledged
549
+ hasRegularSharedParam = true;
550
+ networkPropagatedParams[name] = updates[name];
536
551
  }
537
552
  }
538
553
 
539
- // propagate immediate params if changed
540
- if (propagateNow) {
554
+ // params that trigger a synced onUpdate call: immediate, local, non-acknowledged
555
+ if (syncedOnUpdate) {
541
556
  this.#onUpdateCallbacks.forEach(listener => listener(newValues, oldValues));
542
557
  }
543
558
 
544
- // check if we can resolve immediately or if we need to go through network
545
- if (hasLocalParam) {
546
- if (!hasSharedParam) {
547
- return Promise.resolve(localParams);
548
- } else {
549
- // store local params to fulfill promise with all values, see PromiseStore
550
- forwardParams = localParams;
551
- }
559
+ // if we only have local params, we can resolve immediately
560
+ if (hasLocalParam && !hasNonAcknowledgedParams && !hasRegularSharedParam) {
561
+ return Promise.resolve(locallyResolvedParam);
552
562
  }
553
563
 
554
- // override updates to be shared on network without local params
555
- updates = sharedParams;
556
-
557
564
  // go through server-side normal behavior
558
- return new Promise((resolve, reject) => {
559
- const reqId = this[kSharedStatePromiseStore].add(resolve, reject, 'SharedState#set', forwardParams);
560
- this.#client.transport.emit(`${UPDATE_REQUEST}-${this.#id}-${this.#instanceId}`, reqId, updates);
561
- });
565
+ const { id: reqId, promise } = this[kSharedStatePromiseStore].createPromise();
566
+ // store local params that should be re-merged w/ regular ones
567
+ if (Object.keys(locallyResolvedParam).length > 0) {
568
+ this[kSharedStatePromiseStore].associateResolveData(reqId, locallyResolvedParam);
569
+ }
570
+
571
+ this.#client.transport.emit(`${UPDATE_REQUEST}-${this.#id}-${this.#instanceId}`, reqId, networkPropagatedParams);
572
+
573
+ // if we don't have regular shared params, we can resolve now
574
+ if (!hasRegularSharedParam) {
575
+ this[kSharedStatePromiseStore].resolve(reqId, {});
576
+ }
577
+
578
+ return promise;
562
579
  }
563
580
 
564
581
  /**
@@ -710,15 +727,13 @@ class SharedState {
710
727
  this.#onUpdateCallbacks.clear();
711
728
 
712
729
  if (this.#isOwner) {
713
- return new Promise((resolve, reject) => {
714
- const reqId = this[kSharedStatePromiseStore].add(resolve, reject, 'SharedState#delete');
715
- this.#client.transport.emit(`${DELETE_REQUEST}-${this.#id}-${this.#instanceId}`, reqId);
716
- });
730
+ const { id: reqId, promise } = this[kSharedStatePromiseStore].createPromise();
731
+ this.#client.transport.emit(`${DELETE_REQUEST}-${this.#id}-${this.#instanceId}`, reqId);
732
+ return promise;
717
733
  } else {
718
- return new Promise((resolve, reject) => {
719
- const reqId = this[kSharedStatePromiseStore].add(resolve, reject, 'SharedState#detach');
720
- this.#client.transport.emit(`${DETACH_REQUEST}-${this.#id}-${this.#instanceId}`, reqId);
721
- });
734
+ const { id: reqId, promise } = this[kSharedStatePromiseStore].createPromise();
735
+ this.#client.transport.emit(`${DETACH_REQUEST}-${this.#id}-${this.#instanceId}`, reqId);
736
+ return promise;
722
737
  }
723
738
  }
724
739
 
@@ -745,7 +760,7 @@ class SharedState {
745
760
 
746
761
  return this.detach();
747
762
  } else {
748
- throw new DOMException(`Cannot execute 'delete' on SharedState (${this.#className}): SharedState (${this.#id}) is not owned by this node. Use 'SharedState#detach' instead`, 'NotSupportedError');
763
+ throw new DOMException(`Cannot execute 'delete' on SharedState (${this.#className}): This instance is attached and not owned. Use 'SharedState#detach' instead`, 'NotSupportedError');
749
764
  }
750
765
  }
751
766
 
@@ -813,6 +828,10 @@ class SharedState {
813
828
  * @param {Function} callback - Callback to execute when the state is deleted.
814
829
  */
815
830
  onDelete(callback) {
831
+ if (!this.#isOwner) {
832
+ throw new DOMException(`Cannot execute 'onDelete' on SharedState (${this.#className}): This instance is attached and not owned. Use 'SharedState#onDetach' instead`, 'NotSupportedError');
833
+ }
834
+
816
835
  this.#onDeleteCallbacks.add(callback);
817
836
  return () => this.#onDeleteCallbacks.delete(callback);
818
837
  }
@@ -52,25 +52,33 @@
52
52
  * @property {boolean} [event=false] - Define if the parameter is a volatile, i.e.
53
53
  * its value only exists on an update and is set back to `null` after propagation.
54
54
  * When `true`, `nullable` is automatically set to `true` and `default` to `null`.
55
+ * @property {boolean} [required=false] - When set to true, the parameter must be
56
+ * provided at the creation of the shared state.
55
57
  * @property {boolean} [filterChange=true] - When set to `false`, an update will
56
58
  * trigger the propagation of a parameter even when its value didn't change.
57
- * This option provides a sort of middle ground between the default behavior
59
+ * This modifier provides a sort of middle ground between the default behavior
58
60
  * (e.g. where only changed values are propagated) and the behavior of the `event`
59
61
  * option (which has no state per se). Hence, setting this options to `false` if
60
62
  * `event=true` makes no sens.
61
63
  * @property {boolean} [immediate=false] - When set to `true`, an update will
62
64
  * trigger the update listeners immediately on the node that generated the update,
63
- * before propagating the change on the network.
64
- * This option is useful in cases the network would introduce a noticeable
65
- * latency on the client.
65
+ * before propagating the change on the network. This modifier is useful in cases
66
+ * the network would introduce a noticeable latency on the client.
66
67
  * If for some reason the value is overridden server-side (e.g. in an `updateHook`)
67
68
  * the listeners will be called again when the "real" value is received.
68
- * @property {boolean} [required=false] - When set to true, the parameter must be
69
- * provided in the initialization values when the state is created.
69
+ * Setting this modifier to `true` will trigger the `onUpdate` callback synchronously
70
+ * according to the `set` call.
70
71
  * @property {boolean} [local=false] - When set to true, the parameter is never
71
72
  * propagated on the network (hence it is no longer a shared parameter :). This
72
73
  * is useful to declare some common parameter (e.g. some interface state) that
73
74
  * don't need to be shared but to stay in the shared state API paradigm.
75
+ * Setting this modifier to `true` will trigger the `onUpdate` callback synchronously
76
+ * according to the `set` call.
77
+ * @property {boolean} [acknowledge=true] - When set to false, the acknowledgement
78
+ * is never sent back to the shared state that initiated an update. This can be
79
+ * useful to e.g. stream values to the network, but can lead to invalid state.
80
+ * Setting this modifier to `true` will trigger the `onUpdate` callback synchronously
81
+ * according to the `set` call.
74
82
  * @property {number} [min=-Number.MIN_VALUE] - Minimum value of the parameter. Only applies
75
83
  * for `integer` and `float` types.
76
84
  * @property {number} [max=Number.MAX_VALUE] - Maximum value of the parameter. Only applies
@@ -1,2 +1,2 @@
1
1
  // [WARNING] This file is generated, do not edit
2
- export default '5.2.0';
2
+ export default '5.3.0';
@@ -482,6 +482,10 @@ class Server {
482
482
  * await server.start(); // init is called implicitly
483
483
  */
484
484
  async init() {
485
+ if (this.#status === 'inited') {
486
+ throw new DOMException(`Cannot execute 'init' twice on Server: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
487
+ }
488
+
485
489
  if (this.#status !== 'idle') {
486
490
  throw new DOMException(`Cannot execute 'init' on Server: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
487
491
  }
@@ -547,6 +551,10 @@ class Server {
547
551
  await this.init();
548
552
  }
549
553
 
554
+ if (this.#status === 'started') {
555
+ throw new DOMException(`Cannot execute 'start' twice on Server: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
556
+ }
557
+
550
558
  if (this.#status !== 'inited') {
551
559
  throw new DOMException(`Cannot execute 'start' on Server: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
552
560
  }
@@ -601,6 +609,10 @@ class Server {
601
609
  * await server.stop();
602
610
  */
603
611
  async stop() {
612
+ if (this.#status === 'stopped') {
613
+ throw new DOMException(`Cannot execute 'stop' twice on Server: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
614
+ }
615
+
604
616
  if (this.#status !== 'started') {
605
617
  throw new DOMException(`Cannot execute 'stop' on Server: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
606
618
  }
@@ -16,18 +16,18 @@ import {
16
16
  } from '../server/ServerStateManager.js';
17
17
 
18
18
  /**
19
- * Filter update object according to filter list.
19
+ * Filter update object according to a given white list.
20
20
  * Return identity is filter is null
21
21
  * @param {object} updates
22
- * @param {array|null} filter
22
+ * @param {array|null} whiteList
23
23
  * @private
24
24
  */
25
- function filterUpdates(updates, filter) {
26
- if (filter === null) {
25
+ function whiteListFilterUpdates(updates, whiteList) {
26
+ if (whiteList === null) {
27
27
  return updates;
28
28
  }
29
29
 
30
- return filter.reduce((acc, key) => {
30
+ return whiteList.reduce((acc, key) => {
31
31
  if (key in updates) {
32
32
  acc[key] = updates[key];
33
33
  }
@@ -35,6 +35,29 @@ function filterUpdates(updates, filter) {
35
35
  }, {});
36
36
  }
37
37
 
38
+ /**
39
+ * Filter update object according to a given black list.
40
+ * Return identity is filter is null
41
+ * @param {object} updates
42
+ * @param {array|null} filter
43
+ * @private
44
+ */
45
+ function blackListFilterUpdates(updates, blackList) {
46
+ if (blackList === null) {
47
+ return updates;
48
+ }
49
+ // create a shallow copy so we can have multiple filters applied on same source
50
+ const filtered = Object.assign({}, updates);
51
+
52
+ blackList.forEach(key => {
53
+ if (key in updates) {
54
+ delete filtered[key];
55
+ }
56
+ });
57
+
58
+ return filtered;
59
+ }
60
+
38
61
  export const kSharedStatePrivateAttachClient = Symbol('soundworks:shared-state-private-attach-client');
39
62
  export const kSharedStatePrivateDetachClient = Symbol('soundworks:shared-state-private-detach-client');
40
63
  export const kSharedStatePrivateGetValues = Symbol('soundworks:shared-state-private-get-values');
@@ -122,13 +145,13 @@ class SharedStatePrivate {
122
145
 
123
146
  if (hookAborted === false) {
124
147
  let acknowledgedUpdates = {};
125
- let hasUpdates = false;
148
+ let acknowledgeFilter = [];
126
149
 
127
150
  for (let name in updates) {
128
151
  const [newValue, changed] = this.#parameters.set(name, updates[name]);
129
152
  // if `filterChange` is set to `false` we don't check if the value
130
153
  // has been changed or not, it is always propagated to client states
131
- const { event, filterChange } = this.#parameters.getDescription(name);
154
+ const { event, filterChange, acknowledge } = this.#parameters.getDescription(name);
132
155
 
133
156
  // if event type reset internal value to null
134
157
  if (event) {
@@ -137,11 +160,14 @@ class SharedStatePrivate {
137
160
 
138
161
  if ((filterChange && changed) || !filterChange) {
139
162
  acknowledgedUpdates[name] = newValue;
140
- hasUpdates = true;
163
+
164
+ if (acknowledge === false) {
165
+ acknowledgeFilter.push(name);
166
+ }
141
167
  }
142
168
  }
143
169
 
144
- if (hasUpdates) {
170
+ if (Object.keys(acknowledgedUpdates).length > 0) {
145
171
  // Normal case
146
172
  //
147
173
  // We need to handle cases where:
@@ -161,25 +187,29 @@ class SharedStatePrivate {
161
187
  //
162
188
  // @note - instanceId correspond to unique remote state id
163
189
 
190
+ // - Apply acknowledge filter on `acknowledgedUpdates`
191
+ // - We don't need to apply the regular filter on update request, they are blocked client-side
192
+ const requesterFilteredUpdates = blackListFilterUpdates(acknowledgedUpdates, acknowledgeFilter);
193
+
164
194
  // propagate RESPONSE to the client that originates the request if not the server
165
195
  if (client.id !== -1) {
166
- // no need to filter updates on requested, is blocked on client-side
167
- client.transport.emit(
168
- `${UPDATE_RESPONSE}-${this.id}-${instanceId}`,
169
- reqId,
170
- acknowledgedUpdates,
171
- );
196
+ // propagate only if there something left after applying the acknowledge filter
197
+ if (Object.keys(requesterFilteredUpdates).length > 0) {
198
+ client.transport.emit(
199
+ `${UPDATE_RESPONSE}-${this.id}-${instanceId}`,
200
+ reqId,
201
+ requesterFilteredUpdates,
202
+ );
203
+ };
172
204
  }
173
205
 
174
- // propagate NOTIFICATION to all attached clients except server
206
+ // propagate NOTIFICATION to all peer states except on server-side
175
207
  for (let [peerInstanceId, clientInfos] of this.#attachedClients) {
176
- const peer = clientInfos.client;
208
+ const { client: peer, filter } = clientInfos;
177
209
 
178
210
  if (instanceId !== peerInstanceId && peer.id !== -1) {
179
- const filter = clientInfos.filter;
180
- const filteredUpdates = filterUpdates(acknowledgedUpdates, filter);
181
-
182
- // propagate only if there something left after applying the filter
211
+ const filteredUpdates = whiteListFilterUpdates(acknowledgedUpdates, filter);
212
+ // propagate only if there something left after applying the white list filter
183
213
  if (Object.keys(filteredUpdates).length > 0) {
184
214
  peer.transport.emit(
185
215
  `${UPDATE_NOTIFICATION}-${this.id}-${peerInstanceId}`,
@@ -191,22 +221,23 @@ class SharedStatePrivate {
191
221
 
192
222
  // propagate RESPONSE to server if it is the requester
193
223
  if (client.id === -1) {
194
- // no need to filter updates on requested, is blocked on client-side
195
- client.transport.emit(
196
- `${UPDATE_RESPONSE}-${this.id}-${instanceId}`,
197
- reqId,
198
- acknowledgedUpdates,
199
- );
224
+ // propagate only if there something left after applying the acknowledge filter
225
+ if (Object.keys(requesterFilteredUpdates).length > 0) {
226
+ client.transport.emit(
227
+ `${UPDATE_RESPONSE}-${this.id}-${instanceId}`,
228
+ reqId,
229
+ requesterFilteredUpdates,
230
+ );
231
+ }
200
232
  }
201
233
 
202
- // propagate NOTIFICATION to other state attached on the server
234
+ // propagate NOTIFICATION to all peer states on the server-side
203
235
  for (let [peerInstanceId, clientInfos] of this.#attachedClients) {
204
- const peer = clientInfos.client;
236
+ const { client: peer, filter } = clientInfos;
205
237
 
206
238
  if (instanceId !== peerInstanceId && peer.id === -1) {
207
- const filter = clientInfos.filter;
208
- const filteredUpdates = filterUpdates(acknowledgedUpdates, filter);
209
-
239
+ const filteredUpdates = whiteListFilterUpdates(acknowledgedUpdates, filter);
240
+ // propagate only if there something left after applying the white list filter
210
241
  if (Object.keys(filteredUpdates).length > 0) {
211
242
  peer.transport.emit(
212
243
  `${UPDATE_NOTIFICATION}-${this.id}-${peerInstanceId}`,
@@ -216,8 +247,7 @@ class SharedStatePrivate {
216
247
  }
217
248
  }
218
249
  } else {
219
- // propagate back to the requester that the update has been aborted
220
- // ignore all other attached clients.
250
+ // propagate back to the requester that the update has been aborted, ignore all peers
221
251
  client.transport.emit(`${UPDATE_ABORT}-${this.id}-${instanceId}`, reqId, updates);
222
252
  }
223
253
  } else {
@@ -18,7 +18,25 @@ export type ClientAppConfig = {
18
18
  /**
19
19
  * Environment configuration object.
20
20
  */
21
- export type ClientEnvConfig = objecy;
21
+ export type ClientEnvConfig = {
22
+ /**
23
+ * - Port on which the server is listening.
24
+ */
25
+ port: number;
26
+ /**
27
+ * - Domain name or IP of the server.
28
+ * Mandatory when node clients are declared
29
+ */
30
+ serverAddress: string;
31
+ /**
32
+ * - Define is the server run in http or in https.
33
+ */
34
+ useHttps: boolean;
35
+ /**
36
+ * - If running behind a proxy, base URL of the application.
37
+ */
38
+ baseUrl?: string;
39
+ };
22
40
  /**
23
41
  * Configuration object for a client running in a browser runtime.
24
42
  */
@@ -1,10 +1,6 @@
1
1
  export default BatchedTransport;
2
2
  /**
3
- * This class proxies transports given the SharedStateManager to batch messages
4
- *
5
- * @param {number} [options.wait=0] - Wait for given number of milliseconds
6
- * for stacking messages before emitting on the network. If 0 is given, network
7
- * message is emitted on next microtask
3
+ * This class proxies transports, i.e. WebSockets, to batch message sends
8
4
  * @private
9
5
  */
10
6
  declare class BatchedTransport {
@@ -5,6 +5,7 @@ export namespace sharedOptions {
5
5
  let metas: {};
6
6
  let filterChange: boolean;
7
7
  let immediate: boolean;
8
+ let acknowledge: boolean;
8
9
  }
9
10
  export namespace types {
10
11
  export namespace boolean {
@@ -3,9 +3,13 @@ export default class PromiseStore {
3
3
  constructor(name: any);
4
4
  name: any;
5
5
  store: Map<any, any>;
6
- generateRequestId: any;
7
- add(resolve: any, reject: any, type: any, localParams: any): any;
8
- resolve(reqId: any, data: any): void;
9
- reject(reqId: any, msg: any): void;
6
+ generateId: any;
7
+ createPromise(): {
8
+ id: any;
9
+ promise: any;
10
+ };
11
+ associateResolveData(id: any, associatedData: any): void;
12
+ resolve(id: any, data: any): void;
13
+ reject(id: any, msg: any): void;
10
14
  flush(): void;
11
15
  }
@@ -42,10 +42,15 @@ type SharedStateParameterDescription = {
42
42
  * When `true`, `nullable` is automatically set to `true` and `default` to `null`.
43
43
  */
44
44
  event?: boolean;
45
+ /**
46
+ * - When set to true, the parameter must be
47
+ * provided at the creation of the shared state.
48
+ */
49
+ required?: boolean;
45
50
  /**
46
51
  * - When set to `false`, an update will
47
52
  * trigger the propagation of a parameter even when its value didn't change.
48
- * This option provides a sort of middle ground between the default behavior
53
+ * This modifier provides a sort of middle ground between the default behavior
49
54
  * (e.g. where only changed values are propagated) and the behavior of the `event`
50
55
  * option (which has no state per se). Hence, setting this options to `false` if
51
56
  * `event=true` makes no sens.
@@ -54,25 +59,31 @@ type SharedStateParameterDescription = {
54
59
  /**
55
60
  * - When set to `true`, an update will
56
61
  * trigger the update listeners immediately on the node that generated the update,
57
- * before propagating the change on the network.
58
- * This option is useful in cases the network would introduce a noticeable
59
- * latency on the client.
62
+ * before propagating the change on the network. This modifier is useful in cases
63
+ * the network would introduce a noticeable latency on the client.
60
64
  * If for some reason the value is overridden server-side (e.g. in an `updateHook`)
61
65
  * the listeners will be called again when the "real" value is received.
66
+ * Setting this modifier to `true` will trigger the `onUpdate` callback synchronously
67
+ * according to the `set` call.
62
68
  */
63
69
  immediate?: boolean;
64
- /**
65
- * - When set to true, the parameter must be
66
- * provided in the initialization values when the state is created.
67
- */
68
- required?: boolean;
69
70
  /**
70
71
  * - When set to true, the parameter is never
71
72
  * propagated on the network (hence it is no longer a shared parameter :). This
72
73
  * is useful to declare some common parameter (e.g. some interface state) that
73
74
  * don't need to be shared but to stay in the shared state API paradigm.
75
+ * Setting this modifier to `true` will trigger the `onUpdate` callback synchronously
76
+ * according to the `set` call.
74
77
  */
75
78
  local?: boolean;
79
+ /**
80
+ * - When set to false, the acknowledgement
81
+ * is never sent back to the shared state that initiated an update. This can be
82
+ * useful to e.g. stream values to the network, but can lead to invalid state.
83
+ * Setting this modifier to `true` will trigger the `onUpdate` callback synchronously
84
+ * according to the `set` call.
85
+ */
86
+ acknowledge?: boolean;
76
87
  /**
77
88
  * - Minimum value of the parameter. Only applies
78
89
  * for `integer` and `float` types.
@@ -1,2 +1,2 @@
1
- declare const _default: "5.2.0";
1
+ declare const _default: "5.3.0";
2
2
  export default _default;