@soundworks/core 5.6.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,9 @@ import {
8
8
  import BaseStateManager, {
9
9
  kStateManagerInit,
10
10
  } from '../common/BaseStateManager.js';
11
+ import {
12
+ checkValidFilters,
13
+ } from '../common/shared-state-utils.js';
11
14
  import BatchedTransport from '../common/BatchedTransport.js';
12
15
  import ParameterBag from '../common/ParameterBag.js';
13
16
  import {
@@ -205,248 +208,251 @@ class ServerStateManager extends BaseStateManager {
205
208
 
206
209
  this[kStateManagerClientsByNodeId].set(nodeId, client);
207
210
 
208
- // ---------------------------------------------
209
- // CREATE
210
- // ---------------------------------------------
211
- client.transport.addListener(
212
- CREATE_REQUEST,
213
- async (reqId, className, initValues = {}) => {
214
- if (this.#classes.has(className)) {
215
- try {
216
- const classDescription = this.#classes.get(className);
217
- const stateId = generateStateId();
218
- const instanceId = instanceIdGenerator();
219
-
220
- // apply create hooks on init values
221
- const hooks = this.#createHooksByClassName.get(className);
222
- let hookAborted = false;
223
-
224
- for (let hook of hooks.values()) {
225
- const result = await hook(initValues);
226
-
227
- if (result === null) { // explicit abort
228
- hookAborted = true;
229
- break;
230
- } else if (result === undefined) { // implicit continue
231
- continue;
232
- } else {
233
- initValues = result;
234
- }
235
- }
236
-
237
- if (hookAborted) {
238
- throw new Error(`A 'serverStateManagerCreateHook' explicitly aborted state creation of class '${className}' by returning 'null'`);
239
- }
240
-
241
- const state = new SharedStatePrivate(this, className, classDescription, stateId, initValues);
242
- // attach client to the state as owner
243
- const isOwner = true;
244
- const filter = null;
245
- state[kSharedStatePrivateAttachClient](instanceId, client, isOwner, filter);
246
-
247
- this.#sharedStatePrivateById.set(stateId, state);
248
-
249
- const currentValues = state.parameters.getValues();
250
-
251
- client.transport.emit(
252
- CREATE_RESPONSE,
253
- reqId,
254
- stateId,
255
- instanceId,
256
- className,
257
- classDescription,
258
- currentValues,
259
- );
260
-
261
- const isObservable = this.#isObservableState(state);
262
-
263
- if (isObservable) {
264
- this.#observers.forEach(observer => {
265
- observer.transport.emit(OBSERVE_NOTIFICATION, className, stateId, nodeId);
266
- });
267
- }
268
- } catch (err) {
269
- const msg = `${err.message}`;
270
- client.transport.emit(CREATE_ERROR, reqId, msg);
271
- }
272
- } else {
273
- const msg = `Undefined SharedStateClassName '${className}'`;
274
- client.transport.emit(CREATE_ERROR, reqId, msg);
211
+ client.transport.addListener(CREATE_REQUEST, this.#onCreateRequest(client));
212
+ client.transport.addListener(ATTACH_REQUEST, this.#onAttachRequest(client));
213
+ client.transport.addListener(OBSERVE_REQUEST, this.#onObserveRequest(client));
214
+ client.transport.addListener(UNOBSERVE_NOTIFICATION, this.#onUnobserveNotification(client));
215
+ client.transport.addListener(GET_CLASS_DESCRIPTION_REQUEST, this.#onClassDescriptionRequest(client));
216
+ }
217
+
218
+ /**
219
+ * Remove a client from the manager and clean all related states.
220
+ *
221
+ * This method is automatically called by the {@link Server} when a client disconnects.
222
+ *
223
+ * @param {number} nodeId - Id of the client node, as given in
224
+ * {@link client.StateManager}
225
+ *
226
+ * @private
227
+ */
228
+ [kServerStateManagerRemoveClient](nodeId) {
229
+ // remove from observers
230
+ const client = this[kStateManagerClientsByNodeId].get(nodeId);
231
+ this.#observers.delete(client);
232
+ // delete from client list
233
+ this[kStateManagerClientsByNodeId].delete(nodeId);
234
+
235
+ // loop through all states to clean them
236
+ for (let state of this.#sharedStatePrivateById.values()) {
237
+ let shouldDelete = nodeId === state.ownerId;
238
+
239
+ // loop through clients of this state
240
+ for (let [instanceId, clientInfos] of state.attachedClients) {
241
+ const { client, isOwner } = clientInfos;
242
+
243
+ // if client is the disconnected one, remove from the state known clients
244
+ if (nodeId === client.id) {
245
+ state[kSharedStatePrivateDetachClient](instanceId, client);
275
246
  }
276
- },
277
- );
278
247
 
279
- // ---------------------------------------------
280
- // ATTACH (when creator, is attached by default)
281
- // ---------------------------------------------
282
- client.transport.addListener(
283
- ATTACH_REQUEST,
284
- (reqId, className, stateId = null, filter = null) => {
285
- if (this.#classes.has(className)) {
286
- let state = null;
287
-
288
- if (stateId !== null && this.#sharedStatePrivateById.has(stateId)) {
289
- state = this.#sharedStatePrivateById.get(stateId);
290
- } else if (stateId === null) {
291
- // if no `stateId` given, we try to find the first state with the given
292
- // `className` in the list, this allow a client to attach to a global
293
- // state created by the server (or some persistent client) without
294
- // having to know the `stateId` (e.g. some global state...)
295
- for (let existingState of this.#sharedStatePrivateById.values()) {
296
- if (existingState.className === className) {
297
- state = existingState;
298
- break;
299
- }
300
- }
301
- }
248
+ // notify client of state deletion if not owner
249
+ if (shouldDelete && !isOwner) {
250
+ client.transport.emit(`${DELETE_NOTIFICATION}-${state.id}-${instanceId}`);
251
+ }
252
+ }
302
253
 
303
- if (state !== null) {
304
- // @note - we use a unique remote id to allow a client to attach
305
- // several times to the same state.
306
- // i.e. same state -> several remote attach on the same node
307
- const instanceId = instanceIdGenerator();
308
- const isOwner = false;
309
- const currentValues = state.parameters.getValues();
310
- const classDescription = this.#classes.get(className);
311
-
312
- // if filter given, check that all filter entries are valid class keys
313
- // @todo - improve error reporting: report invalid filters
314
- if (filter !== null) {
315
- const keys = Object.keys(classDescription);
316
- const isValid = filter.reduce((acc, key) => acc && keys.includes(key), true);
317
-
318
- if (!isValid) {
319
- const msg = `Invalid filter (${filter.join(', ')}) for class '${className}'`;
320
- return client.transport.emit(ATTACH_ERROR, reqId, msg);
321
- }
322
- }
323
-
324
- state[kSharedStatePrivateAttachClient](instanceId, client, isOwner, filter);
325
-
326
- client.transport.emit(
327
- ATTACH_RESPONSE,
328
- reqId,
329
- state.id,
330
- instanceId,
331
- className,
332
- classDescription,
333
- currentValues,
334
- filter,
335
- );
254
+ if (shouldDelete) {
255
+ // clean private state
256
+ this[kServerStateManagerDeletePrivateState](state);
257
+ }
258
+ }
259
+ }
260
+
261
+ #isObservableState(state) {
262
+ // is observable if not in private states list
263
+ return !PRIVATE_STATES.includes(state.className);
264
+ }
336
265
 
266
+ #onCreateRequest = client => {
267
+ return async (reqId, className, initValues = {}) => {
268
+ if (!this.#classes.has(className)) {
269
+ const msg = `Undefined SharedStateClassName '${className}'`;
270
+ client.transport.emit(CREATE_ERROR, reqId, msg);
271
+ return;
272
+ }
273
+
274
+ try {
275
+ const classDescription = this.#classes.get(className);
276
+ const stateId = generateStateId();
277
+ const instanceId = instanceIdGenerator();
278
+
279
+ // apply create hooks on init values
280
+ const hooks = this.#createHooksByClassName.get(className);
281
+ let hookAborted = false;
282
+
283
+ for (let hook of hooks.values()) {
284
+ const result = await hook(initValues);
285
+
286
+ if (result === null) { // explicit abort
287
+ hookAborted = true;
288
+ break;
289
+ } else if (result === undefined) { // implicit continue
290
+ continue;
337
291
  } else {
338
- const msg = `No existing state for class "${className}" with stateId: "${stateId}"`;
339
- client.transport.emit(ATTACH_ERROR, reqId, msg);
292
+ initValues = result;
340
293
  }
341
- } else {
342
- const msg = `Undefined SharedStateClassName '${className}'`;
343
- client.transport.emit(ATTACH_ERROR, reqId, msg);
344
294
  }
345
- },
346
- );
347
295
 
348
- // ---------------------------------------------
349
- // OBSERVE PEERS (be notified when a state is created, lazy)
350
- // ---------------------------------------------
351
- client.transport.addListener(OBSERVE_REQUEST, (reqId, observedClassName) => {
352
- if (observedClassName === null || this.#classes.has(observedClassName)) {
353
- const list = [];
296
+ if (hookAborted) {
297
+ throw new Error(`A 'serverStateManagerCreateHook' explicitly aborted state creation of class '${className}' by returning 'null'`);
298
+ }
354
299
 
355
- this.#sharedStatePrivateById.forEach(state => {
356
- const isObservable = this.#isObservableState(state);
300
+ const state = new SharedStatePrivate(this, className, classDescription, stateId, initValues);
301
+ // attach client to the state as owner
302
+ const isOwner = true;
303
+ const filter = null;
304
+ state[kSharedStatePrivateAttachClient](instanceId, client, isOwner, filter);
357
305
 
358
- if (isObservable) {
359
- const { className, id, creatorId } = state;
360
- list.push([className, id, creatorId]);
361
- }
362
- });
306
+ this.#sharedStatePrivateById.set(stateId, state);
363
307
 
364
- // add client to observers first because if some synchronous server side
365
- // callback throws, the client would never be added to the list
366
- this.#observers.add(client);
308
+ const currentValues = state.parameters.getValues();
367
309
 
368
- client.transport.emit(OBSERVE_RESPONSE, reqId, ...list);
369
- } else {
370
- const msg = `Undefined SharedStateClassName '${observedClassName}'`;
371
- client.transport.emit(OBSERVE_ERROR, reqId, msg);
310
+ client.transport.emit(
311
+ CREATE_RESPONSE,
312
+ reqId,
313
+ stateId,
314
+ instanceId,
315
+ className,
316
+ classDescription,
317
+ currentValues,
318
+ );
319
+
320
+ const isObservable = this.#isObservableState(state);
321
+
322
+ if (isObservable) {
323
+ this.#observers.forEach(observer => {
324
+ observer.transport.emit(OBSERVE_NOTIFICATION, className, stateId, client.id);
325
+ });
326
+ }
327
+ } catch (err) {
328
+ client.transport.emit(CREATE_ERROR, reqId, err.message);
372
329
  }
373
- });
330
+ };
331
+ };
374
332
 
375
- client.transport.addListener(UNOBSERVE_NOTIFICATION, () => {
376
- this.#observers.delete(client);
377
- });
333
+ #onAttachRequest = client => {
334
+ return (reqId, className, stateId = null, options = null) => {
335
+ if (!this.#classes.has(className)) {
336
+ const msg = `Undefined SharedStateClassName '${className}'`;
337
+ client.transport.emit(ATTACH_ERROR, reqId, msg);
338
+ return;
339
+ }
378
340
 
379
- // ---------------------------------------------
380
- // GET CLASS DESCRIPTION
381
- // ---------------------------------------------
382
- client.transport.addListener(GET_CLASS_DESCRIPTION_REQUEST, (reqId, className) => {
383
- if (this.#classes.has(className)) {
341
+ let state = null;
342
+
343
+ if (stateId !== null && this.#sharedStatePrivateById.has(stateId)) {
344
+ state = this.#sharedStatePrivateById.get(stateId);
345
+ } else if (stateId === null) {
346
+ // if no `stateId` given, we try to find the first state with the given
347
+ // `className` in the list, this allow a client to attach to a global
348
+ // state created by the server (or some persistent client) without
349
+ // having to know the `stateId` (e.g. some global state...)
350
+ for (let existingState of this.#sharedStatePrivateById.values()) {
351
+ if (existingState.className === className) {
352
+ state = existingState;
353
+ break;
354
+ }
355
+ }
356
+ }
357
+
358
+ if (state !== null) {
359
+ // @note - we use a unique remote id to allow a client to attach
360
+ // several times to the same state.
361
+ // i.e. same state -> several remote attach on the same node
362
+ const instanceId = instanceIdGenerator();
363
+ const isOwner = false;
364
+ const currentValues = state.parameters.getValues();
384
365
  const classDescription = this.#classes.get(className);
366
+
367
+ // If filters are defined, check that all given values are valid param names
368
+ // Note that we need the class description, so it's more efficient to check here
369
+ // to avoid a network roundtrip
370
+ if (options !== null) {
371
+
372
+ try {
373
+ checkValidFilters(options, className, classDescription);
374
+ } catch (err) {
375
+ return client.transport.emit(ATTACH_ERROR, reqId, err.message);
376
+ }
377
+ }
378
+
379
+ state[kSharedStatePrivateAttachClient](instanceId, client, isOwner, options);
380
+
385
381
  client.transport.emit(
386
- GET_CLASS_DESCRIPTION_RESPONSE,
382
+ ATTACH_RESPONSE,
387
383
  reqId,
384
+ state.id,
385
+ instanceId,
388
386
  className,
389
387
  classDescription,
388
+ currentValues,
389
+ options,
390
390
  );
391
+
391
392
  } else {
392
- const msg = `Undefined SharedStateClassName '${className}'`;
393
- client.transport.emit(GET_CLASS_DESCRIPTION_ERROR, reqId, msg);
393
+ const msg = `No existing state for class "${className}" with stateId: "${stateId}"`;
394
+ client.transport.emit(ATTACH_ERROR, reqId, msg);
394
395
  }
395
- });
396
- }
396
+ };
397
+ };
397
398
 
398
399
  /**
399
- * Remove a client from the manager. Clean all created or attached states.
400
- *
401
- * This is automatically handled by the {@link Server} when a client disconnects.
402
- *
403
- * @param {number} nodeId - Id of the client node, as given in
404
- * {@link client.StateManager}
405
- *
406
- * @private
400
+ * @todo - Something seems to be wrong here. If a client observe different classes,
401
+ * what happens if it unobserve one of them.
402
+ * Looks to be tested cf. `should properly behave with several observers`
403
+ * but the logic is not clear, the should be documented properly
407
404
  */
408
- [kServerStateManagerRemoveClient](nodeId) {
409
- for (let [_id, state] of this.#sharedStatePrivateById.entries()) {
410
- let deleteState = false;
405
+ #onObserveRequest = client => {
406
+ // Note that `observedClassName` is only handled client-side, this allows
407
+ // to have several observe in parallel without much bookkeeping at the price
408
+ // of not minimizing network load
409
+ return (reqId, observedClassName) => {
410
+ if (observedClassName === null || this.#classes.has(observedClassName)) {
411
+ const list = [];
411
412
 
412
- // define if the client is the creator of the state, in which case
413
- // everybody must delete it
414
- for (let [instanceId, clientInfos] of state.attachedClients) {
415
- const attachedClient = clientInfos.client;
413
+ this.#sharedStatePrivateById.forEach(state => {
414
+ const isObservable = this.#isObservableState(state);
416
415
 
417
- if (nodeId === attachedClient.id && instanceId === state.creatorInstanceId) {
418
- deleteState = true;
419
- }
420
- }
416
+ if (isObservable) {
417
+ const { className, id, ownerId } = state;
418
+ list.push([className, id, ownerId]);
419
+ }
420
+ });
421
421
 
422
- for (let [instanceId, clientInfos] of state.attachedClients) {
423
- const attachedClient = clientInfos.client;
422
+ // Add client to observers first because if some synchronous server side
423
+ // callback throws, the client would never be added to the list
424
+ this.#observers.add(client);
424
425
 
425
- if (nodeId === attachedClient.id) {
426
- state[kSharedStatePrivateDetachClient](instanceId, attachedClient);
427
- }
426
+ client.transport.emit(OBSERVE_RESPONSE, reqId, ...list);
427
+ } else {
428
+ const msg = `Undefined SharedStateClassName '${observedClassName}'`;
429
+ client.transport.emit(OBSERVE_ERROR, reqId, msg);
430
+ }
431
+ };
432
+ };
428
433
 
429
- if (deleteState) {
430
- if (instanceId !== state.creatorInstanceId) {
431
- // send notification to other attached nodes
432
- attachedClient.transport.emit(`${DELETE_NOTIFICATION}-${state.id}-${instanceId}`);
433
- }
434
+ #onUnobserveNotification = client => {
435
+ return () => this.#observers.delete(client);
436
+ };
434
437
 
435
- this[kServerStateManagerDeletePrivateState](state);
436
- }
438
+ #onClassDescriptionRequest = client => {
439
+ return (reqId, className) => {
440
+ if (!this.#classes.has(className)) {
441
+ const msg = `Undefined SharedStateClassName '${className}'`;
442
+ client.transport.emit(GET_CLASS_DESCRIPTION_ERROR, reqId, msg);
443
+ return;
437
444
  }
438
- }
439
445
 
440
- // if is an observer, delete it
441
- const client = this[kStateManagerClientsByNodeId].get(nodeId);
442
- this.#observers.delete(client);
443
- this[kStateManagerClientsByNodeId].delete(nodeId);
444
- }
446
+ const classDescription = this.#classes.get(className);
445
447
 
446
- #isObservableState(state) {
447
- // is observable if not in private states list
448
- return !PRIVATE_STATES.includes(state.className);
449
- }
448
+ client.transport.emit(
449
+ GET_CLASS_DESCRIPTION_RESPONSE,
450
+ reqId,
451
+ className,
452
+ classDescription,
453
+ );
454
+ };
455
+ };
450
456
 
451
457
  /**
452
458
  * Define a generic class from which {@link SharedState}s can be created.
@@ -497,14 +503,6 @@ class ServerStateManager extends BaseStateManager {
497
503
  this.#deleteHooksByClassName.set(className, new Set());
498
504
  }
499
505
 
500
- /**
501
- * @deprecated Use {@link ServerStateManager#defineClass} instead.
502
- */
503
- registerSchema(className, classDescription) {
504
- warnings.deprecated('ServerStateManager#registerSchema', 'ServerStateManager#defineClass', '4.0.0-alpha.29');
505
- this.defineClass(className, classDescription);
506
- }
507
-
508
506
  /**
509
507
  * Delete a whole class of {@link SharedState}.
510
508
  *
@@ -543,14 +541,6 @@ class ServerStateManager extends BaseStateManager {
543
541
  this.#deleteHooksByClassName.delete(className);
544
542
  }
545
543
 
546
- /**
547
- * @deprecated Use {@link ServerStateManager#defineClass} instead.
548
- */
549
- deleteSchema(className) {
550
- warnings.deprecated('ServerStateManager#deleteSchema', 'ServerStateManager#deleteClass', '4.0.0-alpha.29');
551
- this.deleteClass(className);
552
- }
553
-
554
544
  /**
555
545
  * Check wether a {@link SharedState} is defined or not, given its {@link SharedStateClassName}
556
546
  *
@@ -717,6 +707,27 @@ class ServerStateManager extends BaseStateManager {
717
707
 
718
708
  return () => hooks.delete(updateHook);
719
709
  }
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // DEPRECATED
713
+ // ---------------------------------------------------------------------------
714
+
715
+ /**
716
+ * @deprecated Use {@link ServerStateManager#defineClass} instead.
717
+ */
718
+ registerSchema(className, classDescription) {
719
+ warnings.deprecated('ServerStateManager#registerSchema', 'ServerStateManager#defineClass', '4.0.0-alpha.29');
720
+ this.defineClass(className, classDescription);
721
+ }
722
+
723
+ /**
724
+ * @deprecated Use {@link ServerStateManager#defineClass} instead.
725
+ */
726
+ deleteSchema(className) {
727
+ warnings.deprecated('ServerStateManager#deleteSchema', 'ServerStateManager#deleteClass', '4.0.0-alpha.29');
728
+ this.deleteClass(className);
729
+ }
730
+
720
731
  }
721
732
 
722
733
  export default ServerStateManager;