@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 +9 -0
- package/package.json +2 -1
- package/src/client/Client.js +12 -0
- package/src/client/ClientContext.js +8 -8
- package/src/client/index.js +1 -1
- package/src/common/BaseStateManager.js +39 -39
- package/src/common/BatchedTransport.js +1 -5
- package/src/common/ParameterBag.js +1 -0
- package/src/common/PromiseStore.js +38 -22
- package/src/common/SharedState.js +65 -46
- package/src/common/shared-state-types.js +14 -6
- package/src/common/version.js +1 -1
- package/src/server/Server.js +12 -0
- package/src/server/SharedStatePrivate.js +64 -34
- package/types/client/index.d.ts +19 -1
- package/types/common/BatchedTransport.d.ts +1 -5
- package/types/common/ParameterBag.d.ts +1 -0
- package/types/common/PromiseStore.d.ts +8 -4
- package/types/common/shared-state-types.d.ts +20 -9
- package/types/common/version.d.ts +1 -1
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.
|
|
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",
|
package/src/client/Client.js
CHANGED
|
@@ -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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
}
|
package/src/client/index.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Environment configuration object.
|
|
19
19
|
*
|
|
20
20
|
* @typedef ClientEnvConfig
|
|
21
|
-
* @type {
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
|
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 {
|
|
@@ -1,52 +1,68 @@
|
|
|
1
|
-
import {
|
|
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.
|
|
11
|
+
this.generateId = counter();
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
+
createPromise() {
|
|
15
|
+
const id = this.generateId();
|
|
16
|
+
let resolve, reject;
|
|
14
17
|
|
|
15
|
-
|
|
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(
|
|
19
|
-
if (this.store.has(
|
|
20
|
-
const { resolve,
|
|
21
|
-
this.store.delete(
|
|
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 (
|
|
25
|
-
Object.assign(data,
|
|
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 (${
|
|
47
|
+
throw new ReferenceError(`Cannot resolve request id (${id}): id does not exist`);
|
|
31
48
|
}
|
|
32
49
|
}
|
|
33
50
|
|
|
34
|
-
reject(
|
|
35
|
-
if (this.store.has(
|
|
36
|
-
const { reject } = this.store.get(
|
|
37
|
-
this.store.delete(
|
|
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 (${
|
|
58
|
+
throw new ReferenceError(`Cannot resolve request id (${id}): id does not exist`);
|
|
42
59
|
}
|
|
43
60
|
}
|
|
44
61
|
|
|
45
|
-
//
|
|
62
|
+
// cancel all pending request
|
|
46
63
|
flush() {
|
|
47
|
-
for (let [
|
|
48
|
-
|
|
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
|
|
469
|
-
const
|
|
468
|
+
const locallyResolvedParam = {};
|
|
469
|
+
const networkPropagatedParams = {};
|
|
470
470
|
let hasLocalParam = false;
|
|
471
|
-
let
|
|
472
|
-
let
|
|
473
|
-
let
|
|
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
|
|
477
|
-
// on the node requesting the update
|
|
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
|
-
//
|
|
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`
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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
|
|
534
|
-
|
|
535
|
-
|
|
548
|
+
// note that immediate params are shared and acknowledged
|
|
549
|
+
hasRegularSharedParam = true;
|
|
550
|
+
networkPropagatedParams[name] = updates[name];
|
|
536
551
|
}
|
|
537
552
|
}
|
|
538
553
|
|
|
539
|
-
//
|
|
540
|
-
if (
|
|
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
|
-
//
|
|
545
|
-
if (hasLocalParam) {
|
|
546
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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}):
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
69
|
-
*
|
|
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
|
package/src/common/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// [WARNING] This file is generated, do not edit
|
|
2
|
-
export default '5.
|
|
2
|
+
export default '5.3.0';
|
package/src/server/Server.js
CHANGED
|
@@ -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
|
|
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}
|
|
22
|
+
* @param {array|null} whiteList
|
|
23
23
|
* @private
|
|
24
24
|
*/
|
|
25
|
-
function
|
|
26
|
-
if (
|
|
25
|
+
function whiteListFilterUpdates(updates, whiteList) {
|
|
26
|
+
if (whiteList === null) {
|
|
27
27
|
return updates;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
return
|
|
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
|
|
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
|
-
|
|
163
|
+
|
|
164
|
+
if (acknowledge === false) {
|
|
165
|
+
acknowledgeFilter.push(name);
|
|
166
|
+
}
|
|
141
167
|
}
|
|
142
168
|
}
|
|
143
169
|
|
|
144
|
-
if (
|
|
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
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
206
|
+
// propagate NOTIFICATION to all peer states except on server-side
|
|
175
207
|
for (let [peerInstanceId, clientInfos] of this.#attachedClients) {
|
|
176
|
-
const peer = clientInfos
|
|
208
|
+
const { client: peer, filter } = clientInfos;
|
|
177
209
|
|
|
178
210
|
if (instanceId !== peerInstanceId && peer.id !== -1) {
|
|
179
|
-
const
|
|
180
|
-
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
234
|
+
// propagate NOTIFICATION to all peer states on the server-side
|
|
203
235
|
for (let [peerInstanceId, clientInfos] of this.#attachedClients) {
|
|
204
|
-
const peer = clientInfos
|
|
236
|
+
const { client: peer, filter } = clientInfos;
|
|
205
237
|
|
|
206
238
|
if (instanceId !== peerInstanceId && peer.id === -1) {
|
|
207
|
-
const
|
|
208
|
-
|
|
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 {
|
package/types/client/index.d.ts
CHANGED
|
@@ -18,7 +18,25 @@ export type ClientAppConfig = {
|
|
|
18
18
|
/**
|
|
19
19
|
* Environment configuration object.
|
|
20
20
|
*/
|
|
21
|
-
export type ClientEnvConfig =
|
|
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
|
|
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 {
|
|
@@ -3,9 +3,13 @@ export default class PromiseStore {
|
|
|
3
3
|
constructor(name: any);
|
|
4
4
|
name: any;
|
|
5
5
|
store: Map<any, any>;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
1
|
+
declare const _default: "5.3.0";
|
|
2
2
|
export default _default;
|