@soundworks/core 4.0.0-alpha.20 → 4.0.0-alpha.21

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,10 @@
1
+ ## v4.0.0-alpha.21 - 19/02/2025
2
+
3
+ - Fix: prevent multiple 'close' events
4
+ - Fix: disable heartbeat check on client-side, was unstable
5
+ - Feat(SharedState): introduce 'local' option for parameters
6
+ - Feat(SharedState): ability to filter parameters of interest
7
+
1
8
  ## v4.0.0-alpha.20 - 19/02/2024
2
9
 
3
10
  - Refactor heartbeat - #86
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soundworks/core",
3
- "version": "4.0.0-alpha.20",
3
+ "version": "4.0.0-alpha.21",
4
4
  "description": "Open-source creative coding framework for distributed applications based on Web technologies",
5
5
  "authors": [
6
6
  "Benjamin Matuszewski",
@@ -189,7 +189,7 @@ class Client {
189
189
  * @see {@link client.StateManager}
190
190
  * @type {client.StateManager}
191
191
  */
192
- this.stateManager = null;
192
+ this.stateManager = new StateManager();
193
193
 
194
194
  /**
195
195
  * Status of the client, 'idle', 'inited', 'started' or 'errored'.
@@ -288,7 +288,7 @@ class Client {
288
288
  // ------------------------------------------------------------
289
289
  // CREATE STATE MANAGER
290
290
  // ------------------------------------------------------------
291
- this.stateManager = new StateManager(this.id, {
291
+ this.stateManager.init(this.id, {
292
292
  emit: this.socket.send.bind(this.socket), // need to alias this
293
293
  addListener: this.socket.addListener.bind(this.socket),
294
294
  removeAllListeners: this.socket.removeAllListeners.bind(this.socket),
@@ -138,20 +138,25 @@ class Socket {
138
138
  ws.addEventListener('open', connectEvent => {
139
139
  // parse incoming messages for pubsub
140
140
  this.ws = ws;
141
- // ping / pong to check connection
142
- const heartbeat = () => {
143
- clearTimeout(this._heartbeatId);
144
141
 
145
- this._heartbeatId = setTimeout(() => {
146
- this.terminate();
147
- }, PING_INTERVAL + PING_LATENCY_TOLERANCE);
148
- };
142
+ // Heartbeat ping / pong check, to detect if connection has been
143
+ // broken but not detected by the client
144
+ // @note (2024-04): unstable and creates more issues than it solves, disable
145
+ // for now and review later
146
+ // const heartbeat = () => {
147
+ // clearTimeout(this._heartbeatId);
148
+
149
+ // this._heartbeatId = setTimeout(() => {
150
+ // console.log('> HEARTBEAT missed, closing...');
151
+ // this.terminate();
152
+ // }, PING_INTERVAL + PING_LATENCY_TOLERANCE);
153
+ // };
149
154
 
150
- heartbeat();
155
+ // heartbeat();
151
156
 
152
157
  this.ws.addEventListener('message', e => {
153
158
  if (e.data === PING_MESSAGE) {
154
- heartbeat();
159
+ // heartbeat();
155
160
  this.ws.send(PONG_MESSAGE);
156
161
  // do not propagate ping / pong messages
157
162
  return;
@@ -250,15 +255,30 @@ class Socket {
250
255
  * Removes all listeners and immediately close the two sockets. Is automatically
251
256
  * called on `client.stop()`
252
257
  *
258
+ * Is also called when a disconnection is detected by the heartbeat (note that
259
+ * in this case, the launcher will call `client.stop()` but the listeners are
260
+ * already cleared so the event will be trigerred only once)
261
+ *
253
262
  * @private
254
263
  */
255
264
  async terminate() {
265
+ const closeListeners = this._stringListeners.get('close');
266
+ const closeBinaryListeners = this._binaryListeners.get('close');
267
+
256
268
  this.removeAllListeners();
257
269
  this.removeAllBinaryListeners();
258
270
 
259
271
  this.ws.close();
260
272
  this.binaryWs.close();
261
273
 
274
+ if (closeListeners) {
275
+ closeListeners.forEach(listener => listener());
276
+ }
277
+
278
+ if (closeBinaryListeners) {
279
+ closeBinaryListeners.forEach(listener => listener());
280
+ }
281
+
262
282
  return Promise.resolve();
263
283
  }
264
284
 
@@ -24,7 +24,7 @@ import {
24
24
  // }
25
25
 
26
26
  class BaseSharedState {
27
- constructor(id, remoteId, schemaName, schema, client, isOwner, manager, initValues = {}) {
27
+ constructor(id, remoteId, schemaName, schema, client, isOwner, manager, initValues, filter) {
28
28
  /** @private */
29
29
  this._id = id;
30
30
  /** @private */
@@ -37,6 +37,9 @@ class BaseSharedState {
37
37
  this._client = client;
38
38
  /** @private */
39
39
  this._manager = manager;
40
+ /** @private */
41
+ this._filter = filter;
42
+
40
43
  /**
41
44
  * true is the state has been detached or deleted
42
45
  * @private
@@ -332,55 +335,103 @@ ${JSON.stringify(initValues, null, 2)}`);
332
335
  }
333
336
 
334
337
  if (!isPlainObject(updates)) {
335
- throw new ReferenceError(`[SharedState] State "${this.schemaName}": state.set(updates[, context]) should receive an object as first parameter`);
338
+ throw new TypeError(`[SharedState] State "${this.schemaName}": state.set(updates[, context]) should receive an object as first parameter`);
336
339
  }
337
340
 
338
341
  if (context !== null && !isPlainObject(context)) {
339
- throw new ReferenceError(`[SharedState] State "${this.schemaName}": state.set(updates[, context]) should receive an object as second parameter`);
342
+ throw new TypeError(`[SharedState] State "${this.schemaName}": state.set(updates[, context]) should receive an object as second parameter`);
340
343
  }
341
344
 
342
- // handle immediate option
343
- const immediateNewValues = {};
344
- const immediateOldValues = {};
345
+ const newValues = {};
346
+ const oldValues = {};
347
+ const localParams = {};
348
+ const sharedParams = {};
349
+ let hasLocalParam = false;
350
+ let hasSharedParam = false;
351
+ let forwardParams = undefined;
345
352
  let propagateNow = false;
346
353
 
347
354
  for (let name in updates) {
348
- // try to coerce value early, so that eventual errors are triggered early
349
- // on the node requesting the update
355
+ // Try to coerce value early, so that eventual errors are triggered early
356
+ // on the node requesting the update, and not only on the server side
357
+ // This throws if name does not exists
350
358
  this._parameters.coerceValue(name, updates[name]);
351
359
 
352
- // @note: general idea...
353
- // if immediate=true
360
+ // Check that name is in filter list, if any
361
+ if (this._filter !== null) {
362
+ if (!this._filter.includes(name)) {
363
+ throw new DOMException(`[SharedState] State "${this.schemaName}": cannot set parameter '${name}', parameter is not in filter list`, 'NotSupportedError');
364
+ }
365
+ }
366
+
367
+ // `immediate` option behavior
368
+ //
369
+ // If immediate=true
354
370
  // - call listeners if value changed
355
371
  // - go through normal server path
356
372
  // - retrigger only if response from server is different from current value
357
- // if immediate=true && (filterChange=false || event=true)
373
+ // If immediate=true && (filterChange=false || event=true)
358
374
  // - call listeners with value regarless it changed
359
375
  // - go through normal server path
360
376
  // - if the node is initiator of the update (UPDATE_RESPONSE), (re-)check
361
377
  // to prevent execute the listeners twice
362
378
 
363
- const { immediate, filterChange, event } = this._parameters.getSchema(name);
379
+ // `local` option behavior
380
+ //
381
+ // - If the `updates` object only contains local variables, we can call the
382
+ // update listeners and return a fulfilled promise immediately
383
+ // - If parameters that require network communication are present, we call the
384
+ // update callback onces with the local payload, then we need to wait for the server
385
+ // response, call update listeners with server response and resolve promise with
386
+ // the full payload, i.e. reintegrating the local params in the resolve payload
387
+
388
+ const { local, immediate, filterChange, event } = this._parameters.getSchema(name);
364
389
 
365
- if (immediate) {
390
+ if (immediate || local) {
366
391
  const oldValue = this._parameters.get(name);
367
392
  const [newValue, changed] = this._parameters.set(name, updates[name]);
368
393
 
394
+ // prepare data for immediate propagation of listeners
369
395
  if (changed || filterChange === false || event) {
370
- immediateOldValues[name] = oldValue;
371
- immediateNewValues[name] = newValue;
396
+ oldValues[name] = oldValue;
397
+ newValues[name] = newValue;
372
398
  propagateNow = true;
373
399
  }
374
400
  }
401
+
402
+ // define params that must go through network or not
403
+ if (local) {
404
+ hasLocalParam = true;
405
+ // get sanitize value for fulfilling promise
406
+ localParams[name] = this._parameters.get(name);
407
+ } else {
408
+ // note that immediate are shared params too
409
+ hasSharedParam = true;
410
+ sharedParams[name] = updates[name];
411
+ }
375
412
  }
376
413
 
414
+ // propagate immediate params if changed
377
415
  if (propagateNow) {
378
- this._onUpdateCallbacks.forEach(listener => listener(immediateNewValues, immediateOldValues, context));
416
+ this._onUpdateCallbacks.forEach(listener => listener(newValues, oldValues, context));
417
+ }
418
+
419
+ // check if we can resolve immediately or if we need to go through network
420
+ if (hasLocalParam) {
421
+ if (!hasSharedParam) {
422
+ return Promise.resolve(localParams);
423
+ } else {
424
+ // store local params to fulfill promise with all values, see PromiseStore
425
+ forwardParams = localParams;
426
+ }
379
427
  }
380
428
 
429
+ // override updates to be shared on network without local params
430
+ updates = sharedParams;
431
+
381
432
  // go through server-side normal behavior
382
433
  return new Promise((resolve, reject) => {
383
- const reqId = this._promiseStore.add(resolve, reject, 'update-request');
434
+ const reqId = this._promiseStore.add(resolve, reject, 'update-request', forwardParams);
384
435
  this._client.transport.emit(`${UPDATE_REQUEST}-${this.id}-${this.remoteId}`, reqId, updates, context);
385
436
  });
386
437
  }
@@ -397,6 +448,16 @@ ${JSON.stringify(initValues, null, 2)}`);
397
448
  * const value = state.get('paramName');
398
449
  */
399
450
  get(name) {
451
+ if (!this._parameters.has(name)) {
452
+ throw new ReferenceError(`[SharedState] State "${this.schemaName}": Cannot get value of undefined parameter "${name}"`);
453
+ }
454
+
455
+ if (this._filter !== null) {
456
+ if (!this._filter.includes(name)) {
457
+ throw new DOMException(`[SharedState] State "${this.schemaName}": cannot get parameter '${name}', parameter is not in filter list`, 'NotSupportedError');
458
+ }
459
+ }
460
+
400
461
  return this._parameters.get(name);
401
462
  }
402
463
 
@@ -415,24 +476,45 @@ ${JSON.stringify(initValues, null, 2)}`);
415
476
  * const value = state.getUnsafe('paramName');
416
477
  */
417
478
  getUnsafe(name) {
479
+ if (!this._parameters.has(name)) {
480
+ throw new ReferenceError(`[SharedState] State "${this.schemaName}": Cannot get value of undefined parameter "${name}"`);
481
+ }
482
+
483
+ if (this._filter !== null) {
484
+ if (!this._filter.includes(name)) {
485
+ throw new DOMException(`[SharedState] State "${this.schemaName}": cannot get parameter '${name}', parameter is not in filter list`, 'NotSupportedError');
486
+ }
487
+ }
488
+
418
489
  return this._parameters.getUnsafe(name);
419
490
  }
420
491
 
421
492
  /**
422
- * Get all the key / value pairs of the state. If a parameter is of `any`
423
- * type, a deep copy is made.
493
+ * Get all the key / value pairs of the state.
494
+ *
495
+ * If a parameter is of `any` type, a deep copy is made.
424
496
  *
425
497
  * @return {object}
426
498
  * @example
427
499
  * const values = state.getValues();
428
500
  */
429
501
  getValues() {
430
- return this._parameters.getValues();
502
+ const values = this._parameters.getValues();
503
+
504
+ if (this._filter !== null) {
505
+ for (let name in values) {
506
+ if (!this._filter.includes(name)) {
507
+ delete values[name];
508
+ }
509
+ }
510
+ }
511
+
512
+ return values;
431
513
  }
432
514
 
433
515
  /**
434
- * Get all the key / value pairs of the state. If a parameter is of `any`
435
- * type, a deep copy is made.
516
+ * Get all the key / value pairs of the state.
517
+ *
436
518
  * Similar to `getValues` but returns a reference to the underlying value in
437
519
  * case of `any` type. May be usefull if the underlying value is big (e.g.
438
520
  * sensors recordings, etc.) and deep cloning expensive. Be aware that if
@@ -444,7 +526,17 @@ ${JSON.stringify(initValues, null, 2)}`);
444
526
  * const values = state.getValues();
445
527
  */
446
528
  getValuesUnsafe() {
447
- return this._parameters.getValuesUnsafe();
529
+ const values = this._parameters.getValuesUnsafe();
530
+
531
+ if (this._filter !== null) {
532
+ for (let name in values) {
533
+ if (!this._filter.includes(name)) {
534
+ delete values[name];
535
+ }
536
+ }
537
+ }
538
+
539
+ return values;
448
540
  }
449
541
 
450
542
  /**
@@ -1,8 +1,9 @@
1
1
  /** @private */
2
2
  class BaseSharedStateCollection {
3
- constructor(stateManager, schemaName, options = {}) {
3
+ constructor(stateManager, schemaName, filter = null, options = {}) {
4
4
  this._stateManager = stateManager;
5
5
  this._schemaName = schemaName;
6
+ this._filter = filter;
6
7
  this._options = Object.assign({ excludeLocal: false }, options);
7
8
 
8
9
  this._schema = null;
@@ -18,8 +19,19 @@ class BaseSharedStateCollection {
18
19
  async _init() {
19
20
  this._schema = await this._stateManager.getSchema(this._schemaName);
20
21
 
22
+ // if filter is set, check that it contains only valid param names
23
+ if (this._filter !== null) {
24
+ const keys = Object.keys(this._schema);
25
+
26
+ for (let filter of this._filter) {
27
+ if (!keys.includes(filter)) {
28
+ throw new ReferenceError(`[SharedStateCollection] Invalid filter key (${filter}) for schema "${this._schemaName}"`)
29
+ }
30
+ }
31
+ }
32
+
21
33
  this._unobserve = await this._stateManager.observe(this._schemaName, async (schemaName, stateId) => {
22
- const state = await this._stateManager.attach(schemaName, stateId);
34
+ const state = await this._stateManager.attach(schemaName, stateId, this._filter);
23
35
  this._states.push(state);
24
36
 
25
37
  state.onDetach(() => {
@@ -108,6 +120,21 @@ class BaseSharedStateCollection {
108
120
  return this._states.map(state => state.getValues());
109
121
  }
110
122
 
123
+ /**
124
+ * Return the current values of all the states in the collection.
125
+ *
126
+ * Similar to `getValues` but returns a reference to the underlying value in
127
+ * case of `any` type. May be usefull if the underlying value is big (e.g.
128
+ * sensors recordings, etc.) and deep cloning expensive. Be aware that if
129
+ * changes are made on the returned object, the state of your application will
130
+ * become inconsistent.
131
+ *
132
+ * @return {Object[]}
133
+ */
134
+ getValuesUnsafe() {
135
+ return this._states.map(state => state.getValues());
136
+ }
137
+
111
138
  /**
112
139
  * Return the current param value of all the states in the collection.
113
140
  *
@@ -115,6 +142,24 @@ class BaseSharedStateCollection {
115
142
  * @return {any[]}
116
143
  */
117
144
  get(name) {
145
+ // we can delegate to the state.get(name) method for throwing in case of filtered
146
+ // keys, as the Promise.all will reject on first reject Promise
147
+ return this._states.map(state => state.get(name));
148
+ }
149
+
150
+ /**
151
+ * Similar to `get` but returns a reference to the underlying value in case of
152
+ * `any` type. May be usefull if the underlying value is big (e.g. sensors
153
+ * recordings, etc.) and deep cloning expensive. Be aware that if changes are
154
+ * made on the returned object, the state of your application will become
155
+ * inconsistent.
156
+ *
157
+ * @param {String} name - Name of the parameter
158
+ * @return {any[]}
159
+ */
160
+ getUnsafe(name) {
161
+ // we can delegate to the state.get(name) method for throwing in case of filtered
162
+ // keys, as the Promise.all will reject on first reject Promise
118
163
  return this._states.map(state => state.get(name));
119
164
  }
120
165
 
@@ -126,8 +171,8 @@ class BaseSharedStateCollection {
126
171
  * current call and will be passed as third argument to all update listeners.
127
172
  */
128
173
  async set(updates, context = null) {
129
- // hot fix for https://github.com/collective-soundworks/soundworks/issues/85
130
- // to be cleaned soon
174
+ // we can delegate to the state.set(update) method for throwing in case of
175
+ // filtered keys, as the Promise.all will reject on first reject Promise
131
176
  const promises = this._states.map(state => state.set(updates, context));
132
177
  return Promise.all(promises);
133
178
  }
@@ -22,19 +22,9 @@ import {
22
22
  GET_SCHEMA_ERROR,
23
23
  } from './constants.js';
24
24
 
25
- /**
26
- * @private
27
- */
25
+ /** @private */
28
26
  class BaseStateManager {
29
- /**
30
- * @param {Number} id - Id of the node.
31
- * @param {Object} transport - Transport to use for synchronizing the state.
32
- * Must implement a basic EventEmitter API.
33
- */
34
- constructor(id, transport) {
35
- // proxy transport with BatchedTransport;
36
- this.client = { id, transport: new BatchedTransport(transport) };
37
-
27
+ constructor() {
38
28
  this._statesById = new Map(); // <id, state>
39
29
  this._cachedSchemas = new Map(); // <shemaName, definition>
40
30
 
@@ -42,12 +32,24 @@ class BaseStateManager {
42
32
  this._observeRequestCallbacks = new Map(); // Map <reqId, [observedSchemaName, callback, options]>
43
33
 
44
34
  this._promiseStore = new PromiseStore();
35
+ }
36
+
37
+ /**
38
+ * Executed on `client.init`
39
+ * @param {Number} id - Id of the node.
40
+ * @param {Object} transport - Transport to use for synchronizing the states.
41
+ * Must implement a basic EventEmitter API.
42
+ */
43
+ /** @private */
44
+ init(id, transport) {
45
+ const batchedTransport = new BatchedTransport(transport);
46
+ this.client = { id, transport: batchedTransport };
45
47
 
46
48
  // ---------------------------------------------
47
49
  // CREATE
48
50
  // ---------------------------------------------
49
51
 
50
- this.client.transport.addListener(CREATE_RESPONSE, (reqId, stateId, remoteId, schemaName, schema, initValues) => {
52
+ this.client.transport.addListener(CREATE_RESPONSE, (reqId, stateId, remoteId, schemaName, schema, initValues, filter) => {
51
53
 
52
54
  // cache schema when first dealing it to save some bandwidth
53
55
  if (!this._cachedSchemas.has(schemaName)) {
@@ -56,7 +58,7 @@ class BaseStateManager {
56
58
 
57
59
  schema = this._cachedSchemas.get(schemaName);
58
60
 
59
- const state = new SharedState(stateId, remoteId, schemaName, schema, this.client, true, this, initValues);
61
+ const state = new SharedState(stateId, remoteId, schemaName, schema, this.client, true, this, initValues, null);
60
62
  this._statesById.set(state.id, state);
61
63
 
62
64
  this._promiseStore.resolve(reqId, state);
@@ -69,7 +71,7 @@ class BaseStateManager {
69
71
  // ---------------------------------------------
70
72
  // ATTACH (when creator, is attached by default)
71
73
  // ---------------------------------------------
72
- this.client.transport.addListener(ATTACH_RESPONSE, (reqId, stateId, remoteId, schemaName, schema, currentValues) => {
74
+ this.client.transport.addListener(ATTACH_RESPONSE, (reqId, stateId, remoteId, schemaName, schema, currentValues, filter) => {
73
75
  // cache schema when first dealing with it to save some bandwidth
74
76
  // note: when we make the schemas dynamic at some point
75
77
  // the server should be able to invalidate the schema and send
@@ -80,7 +82,7 @@ class BaseStateManager {
80
82
 
81
83
  schema = this._cachedSchemas.get(schemaName);
82
84
 
83
- const state = new SharedState(stateId, remoteId, schemaName, schema, this.client, false, this, currentValues);
85
+ const state = new SharedState(stateId, remoteId, schemaName, schema, this.client, false, this, currentValues, filter);
84
86
  this._statesById.set(state.id, state);
85
87
 
86
88
  this._promiseStore.resolve(reqId, state);
@@ -229,22 +231,64 @@ class BaseStateManager {
229
231
  /**
230
232
  * Attach to an existing `SharedState` instance.
231
233
  *
234
+ * Alternative signatures:
235
+ * - `stateManager.attach(schemaName)`
236
+ * - `stateManager.attach(schemaName, stateId)`
237
+ * - `stateManager.attach(schemaName, filter)`
238
+ * - `stateManager.attach(schemaName, stateId, filter)`
239
+ *
232
240
  * @param {String} schemaName - Name of the schema as given on registration
233
241
  * (cf. ServerStateManager)
234
242
  * @param {Object} [stateId=null] - Id of the state to attach to. If `null`,
235
243
  * attach to the first state found with the given schema name (usefull for
236
244
  * globally shared states owned by the server).
245
+ * @param {array|null} [filter=null] - Filter parameters of interest in the
246
+ * returned state. If set to `null`, no filter applied.
237
247
  * @returns {client.SharedState|server.SharedState}
238
248
  * @see {@link client.SharedState}
239
249
  * @see {@link server.SharedState}
250
+ *
240
251
  * @example
241
252
  * const state = await client.stateManager.attach('my-schema');
242
253
  */
243
- async attach(schemaName, stateId = null) {
254
+ async attach(schemaName, stateIdOrFilter = null, filter = null) {
255
+ let stateId = null;
256
+
257
+ if (!isString(schemaName)) {
258
+ throw new TypeError(`Cannot execute 'attach' on 'StateManager': argument 1 should be either a number or an array`);
259
+ }
260
+
261
+ if (arguments.length === 2) {
262
+ if (stateIdOrFilter === null) {
263
+ stateId = null;
264
+ filter = null;
265
+ } else if (Number.isFinite(stateIdOrFilter)) {
266
+ stateId = stateIdOrFilter;
267
+ filter = null;
268
+ } else if (Array.isArray(stateIdOrFilter)) {
269
+ stateId = null;
270
+ filter = stateIdOrFilter;
271
+ } else {
272
+ throw new TypeError(`Cannot execute 'attach' on 'StateManager': argument 2 should be either null, a number or an array`);
273
+ }
274
+ }
275
+
276
+ if (arguments.length === 3) {
277
+ stateId = stateIdOrFilter;
278
+
279
+ if (stateId !== null && !Number.isFinite(stateId)) {
280
+ throw new TypeError(`Cannot execute 'attach' on 'StateManager': argument 2 should be either null or a number`);
281
+ }
282
+
283
+ if (filter !== null && !Array.isArray(filter)) {
284
+ throw new TypeError(`Cannot execute 'attach' on 'StateManager': argument 3 should be either null or an array`);
285
+ }
286
+ }
287
+
244
288
  return new Promise((resolve, reject) => {
245
289
  const reqId = this._promiseStore.add(resolve, reject, 'attach-request');
246
290
  const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
247
- this.client.transport.emit(ATTACH_REQUEST, reqId, schemaName, stateId, requireSchema);
291
+ this.client.transport.emit(ATTACH_REQUEST, reqId, schemaName, stateId, requireSchema, filter);
248
292
  });
249
293
  }
250
294
 
@@ -263,10 +307,10 @@ class BaseStateManager {
263
307
  * Such strategy allows to share the observe notifications between all observers.
264
308
  *
265
309
  * Alternative signatures:
266
- * - .observe(callback)
267
- * - .observe(schemaName, callback)
268
- * - .observe(callback, options)
269
- * - .observe(schemaName, callback, options)
310
+ * - `stateManager.observe(callback)`
311
+ * - `stateManager.observe(schemaName, callback)`
312
+ * - `stateManager.observe(callback, options)`
313
+ * - `stateManager.observe(schemaName, callback, options)`
270
314
  *
271
315
  * @param {string} [schemaName] - optionnal schema name to filter the observed
272
316
  * states.
@@ -278,6 +322,7 @@ class BaseStateManager {
278
322
  * @returns {Promise<Function>} - Returns a Promise that resolves when the given
279
323
  * callback as been executed on each existing states. The promise value is a
280
324
  * function which allows to stop observing the states on the network.
325
+ *
281
326
  * @example
282
327
  * client.stateManager.observe(async (schemaName, stateId) => {
283
328
  * if (schemaName === 'something') {
@@ -333,7 +378,7 @@ class BaseStateManager {
333
378
  options = Object.assign(defaultOptions, args[1]);
334
379
 
335
380
  } else {
336
- throw new Error(`[stateManager] Invalid signature, refer to the StateManager.observe documentation"`);
381
+ throw new TypeError(`[stateManager] Invalid signature, refer to the StateManager.observe documentation"`);
337
382
  }
338
383
 
339
384
  break;
@@ -397,22 +442,60 @@ class BaseStateManager {
397
442
  /**
398
443
  * Returns a collection of all the states created from the schema name.
399
444
  *
445
+ * Alternitive signatures:
446
+ * - `stateManager.getCollection(schemaName)`
447
+ * - `stateManager.getCollection(schemaName, filter)`
448
+ * - `stateManager.getCollection(schemaName, options)`
449
+ * - `stateManager.getCollection(schemaName, filter, options)`
450
+ *
400
451
  * @param {string} schemaName - Name of the schema.
401
- * @param {object} options - Options.
402
- * @param {boolean} [options.excludeLocal = false] - If set to true, exclude states
403
- * created locallly, i.e. by the same node, from the collection.
452
+ * @param {array|null} [filter=null] - Filter parameter of interest for each
453
+ * state of the collection. If set to `null`, no filter applied.
454
+ * @param {object} [options={}] - Options.
455
+ * @param {boolean} [options.excludeLocal=false] - If set to true, exclude states
456
+ * created by the same node from the collection.
404
457
  * @returns {server.SharedStateCollection|client.SharedStateCollection}
458
+ *
459
+ * @example
460
+ * const collection = await client.stateManager.getCollection(schemaName);
405
461
  */
406
- async getCollection(schemaName, options) {
407
- const collection = new SharedStateCollection(this, schemaName, options);
408
-
409
- try {
410
- await collection._init();
411
- } catch (err) {
412
- console.log(err.message);
413
- throw new Error(`[stateManager] Cannot create collection, schema "${schemaName}" does not exists`);
462
+ async getCollection(schemaName, filterOrOptions = null, options = {}) {
463
+ if (!isString(schemaName)) {
464
+ throw new TypeError(`[stateManager] Cannot execute 'getCollection' on 'StateManager': 'schemaName' should be a string"`);
465
+ }
466
+
467
+ let filter;
468
+
469
+ if (arguments.length === 2) {
470
+ if (filterOrOptions === null) {
471
+ filter = null;
472
+ options = null;
473
+ } else if (Array.isArray(filterOrOptions)) {
474
+ filter = filterOrOptions;
475
+ options = {};
476
+ } else if (typeof filterOrOptions === 'object') {
477
+ filter = null;
478
+ options = filterOrOptions;
479
+ } else {
480
+ throw new TypeError(`[stateManager] Cannot execute 'getCollection' on 'StateManager': argument 2 should be either null, an array or an object"`);
481
+ }
414
482
  }
415
483
 
484
+ if (arguments.length === 3) {
485
+ filter = filterOrOptions;
486
+
487
+ if (filter !== null && !Array.isArray(filter)) {
488
+ throw new TypeError(`[stateManager] Cannot execute 'getCollection' on 'StateManager': 'filter' should be either an array or null"`);
489
+ }
490
+
491
+ if (options === null || typeof options !== 'object') {
492
+ throw new TypeError(`[stateManager] Cannot execute 'getCollection' on 'StateManager': 'options' should be an object"`);
493
+ }
494
+ }
495
+
496
+ const collection = new SharedStateCollection(this, schemaName, filter, options);
497
+ await collection._init();
498
+
416
499
  return collection;
417
500
  }
418
501
  }
@@ -1,14 +1,20 @@
1
+ import { delay } from '@ircam/sc-utils';
2
+
1
3
  import { BATCHED_TRANSPORT_CHANNEL } from './constants.js';
2
4
 
3
5
  /**
4
6
  * This class proxies transports given the SharedStateManager to batch messages
7
+ *
8
+ * @param {number} [options.wait=0] - Wait for given number of milliseconds
9
+ * for stacking messages before emitting on the network. If 0 is given, network
10
+ * message is emitted on next microtask
5
11
  * @private
6
12
  */
7
13
  class BatchedTransport {
8
14
  constructor(transport) {
9
15
  this._transport = transport;
10
- this._stack = [];
11
16
  this._listeners = new Map();
17
+ this._stack = [];
12
18
  this._sending = false;
13
19
 
14
20
  this._transport.addListener(BATCHED_TRANSPORT_CHANNEL, stack => {
@@ -39,7 +45,8 @@ class BatchedTransport {
39
45
 
40
46
  if (!this._sending) {
41
47
  this._sending = true;
42
- this._sending = await false;
48
+ await false;
49
+ this._sending = false;
43
50
  const stack = this._stack;
44
51
  this._stack = [];
45
52
  this._transport.emit(BATCHED_TRANSPORT_CHANNEL, stack);
@@ -7,18 +7,23 @@ export default class PromiseStore {
7
7
  this.generateRequestId = idGenerator();
8
8
  }
9
9
 
10
- add(resolve, reject, type) {
10
+ add(resolve, reject, type, localParams) {
11
11
  const reqId = this.generateRequestId.next().value;
12
- this.store.set(reqId, { resolve, reject, type });
12
+ this.store.set(reqId, { resolve, reject, type, localParams });
13
13
 
14
14
  return reqId;
15
15
  }
16
16
 
17
17
  resolve(reqId, data) {
18
18
  if (this.store.has(reqId)) {
19
- const { resolve } = this.store.get(reqId);
19
+ const { resolve, localParams } = this.store.get(reqId);
20
20
  this.store.delete(reqId);
21
21
 
22
+ // re-merge local params into network response
23
+ if (localParams !== undefined) {
24
+ Object.assign(data, localParams);
25
+ }
26
+
22
27
  resolve(data);
23
28
  } else {
24
29
  throw new Error(`[${this.name}] cannot resolve request id (${reqId}), id does not exist`);
@@ -36,7 +41,7 @@ export default class PromiseStore {
36
41
  }
37
42
  }
38
43
 
39
- // reject all pendeing request
44
+ // reject all pending request
40
45
  flush() {
41
46
  for (let [_reqId, entry] of this.store) {
42
47
  const { reject, type } = entry;
@@ -11,6 +11,25 @@ import {
11
11
  UPDATE_NOTIFICATION,
12
12
  } from '../common/constants.js';
13
13
 
14
+ /**
15
+ * Filter update object according to filter list.
16
+ * Return identity is filter is null
17
+ * @param {object} updates
18
+ * @param {array|null} filter
19
+ */
20
+ function filterUpdates(updates, filter) {
21
+ if (filter === null) {
22
+ return updates;
23
+ }
24
+
25
+ return filter.reduce((acc, key) => {
26
+ if (key in updates) {
27
+ acc[key] = updates[key];
28
+ }
29
+ return acc;
30
+ }, {});
31
+ }
32
+
14
33
  /**
15
34
  * The "real" state, this instance is kept private by the server.StateManager.
16
35
  * It can only be accessed through a SharedState proxy.
@@ -30,8 +49,8 @@ class SharedStatePrivate {
30
49
  this._creatorId = null;
31
50
  }
32
51
 
33
- _attachClient(remoteId, client, isOwner = false) {
34
- const clientInfos = { client, isOwner };
52
+ _attachClient(remoteId, client, isOwner, filter) {
53
+ const clientInfos = { client, isOwner, filter };
35
54
  this._attachedClients.set(remoteId, clientInfos);
36
55
 
37
56
  if (isOwner) {
@@ -61,7 +80,7 @@ class SharedStatePrivate {
61
80
  }
62
81
 
63
82
  if (hookAborted === false) {
64
- const filteredUpdates = {};
83
+ let acknowledgedUpdates = {};
65
84
  let hasUpdates = false;
66
85
 
67
86
  for (let name in updates) {
@@ -76,7 +95,7 @@ class SharedStatePrivate {
76
95
  }
77
96
 
78
97
  if ((filterChange && changed) || !filterChange) {
79
- filteredUpdates[name] = newValue;
98
+ acknowledgedUpdates[name] = newValue;
80
99
  hasUpdates = true;
81
100
  }
82
101
  }
@@ -103,41 +122,54 @@ class SharedStatePrivate {
103
122
 
104
123
  // propagate RESPONSE to the client that originates the request if not the server
105
124
  if (client.id !== -1) {
125
+ // no need to filter updates on requested, is blocked on client-side
106
126
  client.transport.emit(
107
127
  `${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
108
- reqId, filteredUpdates, context,
128
+ reqId, acknowledgedUpdates, context,
109
129
  );
110
130
  }
111
131
 
112
- // propagate NOTIFICATION to all other attached clients except server
132
+ // propagate NOTIFICATION to all attached clients except server
113
133
  for (let [peerRemoteId, clientInfos] of this._attachedClients) {
114
134
  const peer = clientInfos.client;
115
135
 
116
136
  if (remoteId !== peerRemoteId && peer.id !== -1) {
117
- peer.transport.emit(
118
- `${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
119
- filteredUpdates, context,
120
- );
137
+ const filter = clientInfos.filter;
138
+ const filteredUpdates = filterUpdates(acknowledgedUpdates, filter);
139
+
140
+ // propagate only if there something left after applying the filter
141
+ if (Object.keys(filteredUpdates).length > 0) {
142
+ peer.transport.emit(
143
+ `${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
144
+ filteredUpdates, context,
145
+ );
146
+ }
121
147
  }
122
148
  }
123
149
 
124
150
  // propagate RESPONSE to server if it is the requester
125
151
  if (client.id === -1) {
152
+ // no need to filter updates on requested, is blocked on client-side
126
153
  client.transport.emit(
127
154
  `${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
128
- reqId, filteredUpdates, context,
155
+ reqId, acknowledgedUpdates, context,
129
156
  );
130
157
  }
131
158
 
132
- // propagate NOTIFICATION other attached state that belongs to server
159
+ // propagate NOTIFICATION to other state attached on the server
133
160
  for (let [peerRemoteId, clientInfos] of this._attachedClients) {
134
161
  const peer = clientInfos.client;
135
162
 
136
163
  if (remoteId !== peerRemoteId && peer.id === -1) {
137
- peer.transport.emit(
138
- `${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
139
- filteredUpdates, context,
140
- );
164
+ const filter = clientInfos.filter;
165
+ const filteredUpdates = filterUpdates(acknowledgedUpdates, filter);
166
+
167
+ if (Object.keys(filteredUpdates).length > 0) {
168
+ peer.transport.emit(
169
+ `${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
170
+ filteredUpdates, context,
171
+ );
172
+ }
141
173
  }
142
174
  }
143
175
  } else {
@@ -1,3 +1,4 @@
1
+ import EventEmitter from 'node:events';
1
2
  import fs from 'node:fs';
2
3
  import http from 'node:http';
3
4
  import https from 'node:https';
@@ -371,6 +372,11 @@ class Server {
371
372
  * await server.start(); // init is called implicitely
372
373
  */
373
374
  async init() {
375
+ // ------------------------------------------------------------
376
+ // INIT STATE MANAGER
377
+ // ------------------------------------------------------------
378
+ this.stateManager.init(SERVER_ID, new EventEmitter());
379
+
374
380
  const numClients = {};
375
381
  for (let name in this.config.app.clients) {
376
382
  numClients[name] = 0;
@@ -905,11 +911,11 @@ Invalid certificate files, please check your:
905
911
  const { role, registeredPlugins } = payload;
906
912
 
907
913
  if (!roles.includes(role)) {
908
- console.error(`[soundworks.Server] A client with invalid type ("${role}") attempted to connect`);
914
+ console.error(`[soundworks.Server] A client with invalid role ("${role}") attempted to connect`);
909
915
 
910
916
  socket.send(CLIENT_HANDSHAKE_ERROR, {
911
917
  type: 'invalid-client-type',
912
- message: `Invalid client type, please check server configuration (valid client types are: ${roles.join(', ')})`,
918
+ message: `Invalid client role, please check server configuration (valid client roles are: ${roles.join(', ')})`,
913
919
  });
914
920
  return;
915
921
  }
@@ -122,6 +122,11 @@ class Socket {
122
122
  this.ws.send(PING_MESSAGE);
123
123
  }, PING_INTERVAL);
124
124
 
125
+ // for testing purpose
126
+ if (this.sockets._DEBUG_PREVENT_HEARTBEAT === true) {
127
+ clearInterval(this._heartbeatId);
128
+ }
129
+
125
130
  // broadcast all "native" events
126
131
  [
127
132
  'close',
@@ -32,6 +32,8 @@ class Sockets {
32
32
  /** @private */
33
33
  this._auditState = null;
34
34
 
35
+ /** @private */
36
+ this._DEBUG_PREVENT_HEARTBEAT = false;
35
37
  }
36
38
 
37
39
  /**
@@ -1,5 +1,3 @@
1
- import EventEmitter from 'node:events';
2
-
3
1
  import { idGenerator, isString, isPlainObject } from '@ircam/sc-utils';
4
2
  import clonedeep from 'lodash/cloneDeep.js';
5
3
 
@@ -8,7 +6,6 @@ import BatchedTransport from '../common/BatchedTransport.js';
8
6
  import ParameterBag from '../common/ParameterBag.js';
9
7
  import SharedStatePrivate from '../common/SharedStatePrivate.js';
10
8
  import {
11
- SERVER_ID,
12
9
  CREATE_REQUEST,
13
10
  CREATE_RESPONSE,
14
11
  CREATE_ERROR,
@@ -314,19 +311,19 @@ const kIsObservableState = Symbol('StateManager::isObservableState');
314
311
  */
315
312
  class StateManager extends BaseStateManager {
316
313
  constructor() {
317
- // acts as a client of itself locally
318
- const localClientId = SERVER_ID;
319
- const localTransport = new EventEmitter();
320
-
321
- super(localClientId, localTransport);
314
+ super();
322
315
 
323
316
  this._clientByNodeId = new Map();
324
317
  this._sharedStatePrivateById = new Map();
325
318
  this._schemas = new Map();
326
319
  this._observers = new Set();
327
320
  this._hooksBySchemaName = new Map(); // protected
321
+ }
328
322
 
329
- this.addClient(localClientId, localTransport);
323
+ init(id, transport) {
324
+ super.init(id, transport);
325
+ // add itself as client of the state manager server
326
+ this.addClient(id, transport);
330
327
  }
331
328
 
332
329
  [kIsObservableState](state) {
@@ -348,9 +345,12 @@ class StateManager extends BaseStateManager {
348
345
  * @private
349
346
  */
350
347
  addClient(nodeId, transport) {
351
- transport = new BatchedTransport(transport);
348
+ const batchedTransport = new BatchedTransport(transport);
349
+ const client = {
350
+ id: nodeId,
351
+ transport: batchedTransport,
352
+ };
352
353
 
353
- const client = { id: nodeId, transport };
354
354
  this._clientByNodeId.set(nodeId, client);
355
355
 
356
356
  // ---------------------------------------------
@@ -368,7 +368,8 @@ class StateManager extends BaseStateManager {
368
368
 
369
369
  // attach client to the state as owner
370
370
  const isOwner = true;
371
- state._attachClient(remoteId, client, isOwner);
371
+ const filter = null;
372
+ state._attachClient(remoteId, client, isOwner, filter);
372
373
 
373
374
  this._sharedStatePrivateById.set(stateId, state);
374
375
 
@@ -407,7 +408,7 @@ class StateManager extends BaseStateManager {
407
408
  // ---------------------------------------------
408
409
  client.transport.addListener(
409
410
  ATTACH_REQUEST,
410
- (reqId, schemaName, stateId = null, requireSchema = true) => {
411
+ (reqId, schemaName, stateId = null, requireSchema = true, filter = null) => {
411
412
  if (this._schemas.has(schemaName)) {
412
413
  let state = null;
413
414
 
@@ -432,15 +433,29 @@ class StateManager extends BaseStateManager {
432
433
  // i.e. same state -> several remote attach on the same node
433
434
  const remoteId = generateRemoteId.next().value;
434
435
  const isOwner = false;
435
- state._attachClient(remoteId, client, isOwner);
436
-
437
436
  const currentValues = state._parameters.getValues();
438
437
  const schema = this._schemas.get(schemaName);
439
438
  const schemaOption = requireSchema ? schema : null;
440
439
 
440
+ // if filter given, check that all filter entries are valid schema keys
441
+ // @todo - improve error reportin: report invalid filters
442
+ if (filter !== null) {
443
+ const keys = Object.keys(schema);
444
+ const isValid = filter.reduce((acc, key) => acc && keys.includes(key), true);
445
+
446
+ if (!isValid) {
447
+ const msg = `[stateManager] Cannot attach, invalid filter (${filter.join(', ')}) for schema "${schemaName}"`;
448
+ console.error(msg);
449
+
450
+ return client.transport.emit(ATTACH_ERROR, reqId, msg);
451
+ }
452
+ }
453
+
454
+ state._attachClient(remoteId, client, isOwner, filter);
455
+
441
456
  client.transport.emit(
442
457
  ATTACH_RESPONSE,
443
- reqId, state.id, remoteId, schemaName, schemaOption, currentValues,
458
+ reqId, state.id, remoteId, schemaName, schemaOption, currentValues, filter,
444
459
  );
445
460
 
446
461
  } else {