@soundworks/core 3.1.0-beta.1 → 3.1.0-beta.5

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/client/Client.js CHANGED
@@ -118,7 +118,7 @@ class Client {
118
118
  // url: '',
119
119
  path: 'socket' // pingInterval: 5 * 1000,
120
120
 
121
- }, config.env.websockets); // init communications
121
+ }, config.env.websockets); // init socket communications (string and binary)
122
122
 
123
123
  await this.socket.init(this.type, this.config);
124
124
  return Promise.resolve();
@@ -142,7 +142,7 @@ class PluginManager {
142
142
  register(name, factory, options = {}, deps = []) {
143
143
  if (this._registeredPlugins[name]) {
144
144
  const ctor = factory(_AbstractPlugin.default);
145
- throw new Error(`[soundworks:core] plugin "${name}" of type "${ctor.name}" already registered`);
145
+ throw new Error(`[soundworks:core] Plugin "${name}" of type "${ctor.name}" already registered`);
146
146
  }
147
147
 
148
148
  const ctor = factory(_AbstractPlugin.default);
@@ -165,7 +165,7 @@ class PluginManager {
165
165
 
166
166
  get(name, _experienceRequired = false) {
167
167
  if (!this._registeredPlugins[name]) {
168
- throw new Error(`Cannot get or require plugin "${name}", plugin is not registered
168
+ throw new Error(`[soundworks:core] Cannot get or require plugin "${name}", plugin is not registered
169
169
  > registered plugins are:
170
170
  ${Object.keys(this._registeredPlugins).map(n => `> - ${n}\n`).join('')}
171
171
  `);
@@ -173,7 +173,7 @@ ${Object.keys(this._registeredPlugins).map(n => `> - ${n}\n`).join('')}
173
173
 
174
174
 
175
175
  if (_experienceRequired && this.signals.start.value === true) {
176
- throw new Error(`Plugin "${name}" required after pluginManager start`);
176
+ throw new Error(`[soundworks:core] Plugin "${name}" required after pluginManager start`);
177
177
  }
178
178
 
179
179
  if (!this._instances[name]) {
package/client/Socket.js CHANGED
@@ -53,6 +53,13 @@ class Socket {
53
53
 
54
54
  /** @private */
55
55
  this.ws = null;
56
+ /**
57
+ * WebSocket instance (binary protocol - binaryType = 'arraybuffer').
58
+ */
59
+
60
+ /** @private */
61
+
62
+ this.binaryWs = null;
56
63
  this._stringListeners = new Map();
57
64
  this._binaryListeners = new Map();
58
65
  }
@@ -67,11 +74,11 @@ class Socket {
67
74
  /** @private */
68
75
 
69
76
 
70
- init(clientType, config) {
77
+ async init(clientType, config) {
71
78
  // unique key that allows to associate the two sockets to the same client.
72
79
  // note: the key is only used to pair to two sockets, so its usage is very
73
80
  // limited in time therefore a random number should hopefully be sufficient.
74
- const key = (Math.random() + '').replace(/^0./, ''); // open web sockets
81
+ const key = (Math.random() + '').replace(/^0\./, ''); // open web sockets
75
82
 
76
83
  let {
77
84
  path
@@ -106,47 +113,71 @@ class Socket {
106
113
  // ----------------------------------------------------------
107
114
 
108
115
  const stringSocketUrl = `${url}?binary=0&${queryParams}`;
109
- this.ws = new _isomorphicWs.default(stringSocketUrl);
110
- log(`string socket initialized - url: ${stringSocketUrl}`);
111
- const stringSocketPromise = new Promise((resolve, reject) => {
112
- this.ws.addEventListener('open', resolve);
113
- }); // parse incoming messages for pubsub
114
-
115
- this.ws.addEventListener('message', e => {
116
- const [channel, args] = (0, _socketsEncoderDecoder.unpackStringMessage)(e.data);
117
-
118
- this._emit(false, channel, ...args);
119
- }); // broadcast all `WebSocket` native events
120
-
121
- ['open', 'close', 'error', 'upgrade', 'message'].forEach(eventName => {
122
- this.ws.addEventListener(eventName, e => {
123
- this._emit(false, eventName, e.data);
124
- });
125
- }); // ----------------------------------------------------------
126
- // init binary socket
127
- // ----------------------------------------------------------
116
+ await new Promise((resolve, reject) => {
117
+ const trySocket = async () => {
118
+ log(`[string socket] trying connection - url: ${stringSocketUrl}`);
119
+ const ws = new _isomorphicWs.default(stringSocketUrl);
120
+ ws.addEventListener('open', connectEvent => {
121
+ // parse incoming messages for pubsub
122
+ this.ws = ws;
123
+ this.ws.addEventListener('message', e => {
124
+ const [channel, args] = (0, _socketsEncoderDecoder.unpackStringMessage)(e.data);
125
+
126
+ this._emit(false, channel, ...args);
127
+ }); // broadcast all `WebSocket` native events
128
+
129
+ ['close', 'error', 'upgrade', 'message'].forEach(eventName => {
130
+ this.ws.addEventListener(eventName, e => {
131
+ this._emit(false, eventName, e);
132
+ });
133
+ }); // forward open event
134
+
135
+ this._emit(false, 'open', connectEvent); // continue with raw socket
136
+
137
+
138
+ resolve();
139
+ }); // cf. https://github.com/collective-soundworks/soundworks/issues/17
140
+
141
+ ws.addEventListener('error', e => {
142
+ if (e.type === 'error' && e.error.code === 'ECONNREFUSED') {
143
+ console.log('[socket] connection refused - (retry in 1s)');
144
+ setTimeout(trySocket, 1000);
145
+ }
146
+ });
147
+ };
148
+
149
+ trySocket();
150
+ }); // // ----------------------------------------------------------
151
+ // // init binary socket
152
+ // // ----------------------------------------------------------
128
153
 
129
154
  const binarySocketUrl = `${url}?binary=1&${queryParams}`;
130
- this.binaryWs = new _isomorphicWs.default(binarySocketUrl);
131
- this.binaryWs.binaryType = 'arraybuffer';
132
- log(`binary socket initialized - url: ${binarySocketUrl}`);
133
- const binarySocketPromise = new Promise((resolve, reject) => {
134
- this.binaryWs.addEventListener('open', resolve);
135
- }); // parse incoming messages for pubsub
136
-
137
- this.binaryWs.addEventListener('message', e => {
138
- const [channel, data] = (0, _socketsEncoderDecoder.unpackBinaryMessage)(e.data);
139
-
140
- this._emit(true, channel, data);
141
- }); // broadcast all `WebSocket` native events
142
-
143
- ['open', 'close', 'error', 'upgrade', 'message'].forEach(eventName => {
144
- this.binaryWs.addEventListener(eventName, e => {
145
- this._emit(true, eventName, e.data);
155
+ await new Promise((resolve, reject) => {
156
+ log(`[binary socket] trying connection - url: ${binarySocketUrl}`);
157
+ const ws = new _isomorphicWs.default(binarySocketUrl);
158
+ ws.binaryType = 'arraybuffer';
159
+ ws.addEventListener('open', connectEvent => {
160
+ // parse incoming messages for pubsub
161
+ this.binaryWs = ws;
162
+ this.binaryWs.addEventListener('message', e => {
163
+ const [channel, data] = (0, _socketsEncoderDecoder.unpackBinaryMessage)(e.data);
164
+
165
+ this._emit(true, channel, data);
166
+ }); // broadcast all `WebSocket` native events
167
+
168
+ ['close', 'error', 'upgrade', 'message'].forEach(eventName => {
169
+ this.binaryWs.addEventListener(eventName, e => {
170
+ this._emit(true, eventName, e);
171
+ });
172
+ }); // forward open event
173
+
174
+ this._emit(true, 'open', connectEvent);
175
+
176
+ resolve();
146
177
  });
147
- }); // wait for both socket to be opened
178
+ }); // everyone is connected
148
179
 
149
- return Promise.all([stringSocketPromise, binarySocketPromise]);
180
+ return Promise.resolve();
150
181
  }
151
182
  /** @private */
152
183
 
@@ -3,15 +3,16 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.default = exports.types = exports.sharedOptions = void 0;
6
+ exports.types = exports.sharedOptions = exports.default = void 0;
7
7
 
8
- var _lodash = _interopRequireDefault(require("lodash.cloneDeep"));
8
+ var _lodash = _interopRequireDefault(require("lodash.clonedeep"));
9
9
 
10
10
  var _fastDeepEqual = _interopRequireDefault(require("fast-deep-equal"));
11
11
 
12
12
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
13
13
 
14
14
  // import types from './types';
15
+ // import shallowClone from 'shallow-clone';
15
16
 
16
17
  /**
17
18
  * @typedef {Object} server.SharedStateManagerServer~schema
@@ -182,6 +183,9 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de
182
183
  /**
183
184
  * @typedef {Object} server.SharedStateManagerServer~schemaAnyDef
184
185
  *
186
+ * Note that the `any` type always return a shallow copy of the state internal
187
+ * value. Mutating the returned value will not modify the internal state.
188
+ *
185
189
  * @property {String} [type='any'] - Parameter of any type.
186
190
  * @property {Mixed} default - Default value of the parameter.
187
191
  * @property {Boolean} [nullable=false] - Define if the parameter is nullable. If
@@ -209,8 +213,8 @@ const sharedOptions = {
209
213
  event: false,
210
214
  // if event=true, nullable=true
211
215
  metas: {},
212
- filterChange: true // immediate: false,
213
-
216
+ filterChange: true,
217
+ immediate: false
214
218
  };
215
219
  exports.sharedOptions = sharedOptions;
216
220
  const types = {
@@ -423,10 +427,17 @@ class ParameterBag {
423
427
 
424
428
 
425
429
  getValues() {
426
- return Object.assign({}, this._values);
430
+ let values = {};
431
+
432
+ for (let name in this._values) {
433
+ values[name] = this.get(name);
434
+ }
435
+
436
+ return values;
427
437
  }
428
438
  /**
429
- * Return the value of the given parameter.
439
+ * Return the value of the given parameter. If the parameter is of `any` type,
440
+ * a deep copy is returned.
430
441
  *
431
442
  * @param {String} name - Name of the parameter.
432
443
  * @return {Mixed} - Value of the parameter.
@@ -438,7 +449,11 @@ class ParameterBag {
438
449
  throw new ReferenceError(`[stateManager] Cannot get value of undefined parameter "${name}"`);
439
450
  }
440
451
 
441
- return this._values[name];
452
+ if (this._schema[name].type === 'any') {
453
+ return (0, _lodash.default)(this._values[name]);
454
+ } else {
455
+ return this._values[name];
456
+ }
442
457
  }
443
458
  /**
444
459
  * Set the value of a parameter. If the value of the parameter is updated
@@ -473,17 +488,14 @@ class ParameterBag {
473
488
 
474
489
  const currentValue = this._values[name];
475
490
  const updated = !(0, _fastDeepEqual.default)(currentValue, value);
476
- this._values[name] = value;
477
-
478
- if (def.event === true) {
479
- this._values[name] = null;
480
- } // return tuple so that the state manager can handle the `filterChange` option
481
-
491
+ this._values[name] = value; // return tuple so that the state manager can handle the `filterChange` option
482
492
 
483
493
  return [value, updated];
484
494
  }
485
495
  /**
486
496
  * Reset a parameter to its initialization values. Reset all parameters if no argument.
497
+ * @note - prefer `state.set(state.getInitValues())`
498
+ * or `state.set(state.getDefaultValues())`
487
499
  *
488
500
  * @param {String} [name=null] - Name of the parameter to reset.
489
501
  */
@@ -46,20 +46,18 @@ ${JSON.stringify(initValues, null, 2)}`);
46
46
  this._onDetachCallbacks = new Set();
47
47
  this._onDeleteCallbacks = new Set(); // add listener for state updates
48
48
 
49
- client.transport.addListener(`${_sharedStateUtils.UPDATE_RESPONSE}-${id}-${this.remoteId}`, (reqId, updates, context) => {
50
- const updated = this._commit(updates, context, true, true);
51
-
49
+ client.transport.addListener(`${_sharedStateUtils.UPDATE_RESPONSE}-${id}-${this.remoteId}`, async (reqId, updates, context) => {
50
+ const updated = await this._commit(updates, context, true, true);
52
51
  (0, _sharedStateUtils.resolveRequest)(reqId, updated);
53
52
  }); // retrieve values but do not propagate to subscriptions
54
53
 
55
- client.transport.addListener(`${_sharedStateUtils.UPDATE_ABORT}-${id}-${this.remoteId}`, (reqId, updates, context) => {
56
- const updated = this._commit(updates, context, false, true);
57
-
54
+ client.transport.addListener(`${_sharedStateUtils.UPDATE_ABORT}-${id}-${this.remoteId}`, async (reqId, updates, context) => {
55
+ const updated = await this._commit(updates, context, false, true);
58
56
  (0, _sharedStateUtils.resolveRequest)(reqId, updated);
59
57
  });
60
- client.transport.addListener(`${_sharedStateUtils.UPDATE_NOTIFICATION}-${id}-${this.remoteId}`, (updates, context) => {
58
+ client.transport.addListener(`${_sharedStateUtils.UPDATE_NOTIFICATION}-${id}-${this.remoteId}`, async (updates, context) => {
61
59
  // cf. https://github.com/collective-soundworks/soundworks/issues/18
62
- this._commit(updates, context, true, false);
60
+ await this._commit(updates, context, true, false);
63
61
  }); // ---------------------------------------------
64
62
  // DELETE initiated by creator, or schema deleted
65
63
  // ---------------------------------------------
@@ -145,7 +143,7 @@ ${JSON.stringify(initValues, null, 2)}`);
145
143
  /** @private */
146
144
 
147
145
 
148
- _commit(updates, context, propagate = true, initiator = false) {
146
+ async _commit(updates, context, propagate = true, initiator = false) {
149
147
  const newValues = {};
150
148
  const oldValues = {};
151
149
 
@@ -154,7 +152,11 @@ ${JSON.stringify(initValues, null, 2)}`);
154
152
  immediate,
155
153
  filterChange,
156
154
  event
157
- } = this._parameters.getSchema(name);
155
+ } = this._parameters.getSchema(name); // @note 20211209 - we have an issue here server-side, because if the value
156
+ // is an object or an array, the reference is shared by everybody, therefore
157
+ // `changed` is always false and the new value is never propagated...
158
+ // FIXED - `state.get` now returns a deep copy when `type` is `any`
159
+
158
160
 
159
161
  const oldValue = this._parameters.get(name);
160
162
 
@@ -165,7 +167,7 @@ ${JSON.stringify(initValues, null, 2)}`);
165
167
  // @note - we don't need to check filterChange here because the value
166
168
  // has been updated in parameters on the `set` side so can rely on `changed`
167
169
  // to avoid retrigger listeners.
168
- // If the value has overriden by the server, `changed` will true
170
+ // If the value has been overriden by the server, `changed` will true
169
171
  // anyway so it should behave correctly.
170
172
  if (!changed || event) {
171
173
  continue;
@@ -177,14 +179,48 @@ ${JSON.stringify(initValues, null, 2)}`);
177
179
  } // if the `UPDATE_REQUEST` as been aborted by the server, do not propagate
178
180
 
179
181
 
182
+ let promises = [];
183
+
180
184
  if (propagate && Object.keys(newValues).length > 0) {
181
- this._subscriptions.forEach(listener => listener(newValues, oldValues, context));
185
+ this._subscriptions.forEach(listener => {
186
+ promises.push(listener(newValues, oldValues, context));
187
+ });
188
+ }
189
+
190
+ await Promise.all(promises); // reset events to null after propagation of all listeners
191
+
192
+ for (let name in newValues) {
193
+ const {
194
+ event
195
+ } = this._parameters.getSchema(name);
196
+
197
+ if (event) {
198
+ this._parameters.set(name, null);
199
+ }
182
200
  }
183
201
 
184
202
  return newValues;
185
203
  }
186
204
  /**
187
- * Updates values of the state.
205
+ * Updates values of the state. Wait for all subscriptions to be resolved
206
+ * before resolving itself, i.e.:
207
+ *
208
+ * ```js
209
+ * const a = await server.stateManager.create('a');
210
+ * let asyncCallbackCalled = false;
211
+ *
212
+ * a.subscribe(updates => {
213
+ * return new Promise(resolve => {
214
+ * setTimeout(() => {
215
+ * asyncCallbackCalled = true;
216
+ * resolve();
217
+ * }, 100);
218
+ * });
219
+ * });
220
+ *
221
+ * await a.set({ bool: true });
222
+ * assert.equal(asyncCallbackCalled, true);
223
+ * ```
188
224
  *
189
225
  * @async
190
226
  * @param {Object} updates - key / value pairs of updates to apply to the state.
@@ -230,7 +266,7 @@ ${JSON.stringify(initValues, null, 2)}`);
230
266
 
231
267
  const [newValue, changed] = this._parameters.set(name, updates[name]);
232
268
 
233
- if (changed || filterChange === false) {
269
+ if (changed || filterChange === false || event) {
234
270
  immediateOldValues[name] = oldValue;
235
271
  immediateNewValues[name] = newValue;
236
272
  propagateNow = true;
@@ -318,7 +354,7 @@ ${JSON.stringify(initValues, null, 2)}`);
318
354
  * // do something
319
355
  * }
320
356
  * }
321
- * }
357
+ * });
322
358
  *
323
359
  * @see {common.SharedState#set}
324
360
  * @see {common.SharedState#subscribe}
@@ -329,6 +365,8 @@ ${JSON.stringify(initValues, null, 2)}`);
329
365
  *
330
366
  * @param {common.SharedState~subscribeCallback} callback - callback to execute
331
367
  * when an update is applied on the state.
368
+ * @param {Boolean} [executeListener=false] - execute the given listener with
369
+ * current state values. (`oldValues` will be set to `{}`, and `context` to `null`)
332
370
  *
333
371
  * @example
334
372
  * state.subscribe(async (newValues, oldValues) => {
@@ -337,13 +375,20 @@ ${JSON.stringify(initValues, null, 2)}`);
337
375
  * // do something
338
376
  * }
339
377
  * }
340
- * }
378
+ * });
341
379
  */
342
380
 
343
381
 
344
- subscribe(listener) {
382
+ subscribe(listener, executeListener = false) {
345
383
  this._subscriptions.add(listener);
346
384
 
385
+ if (executeListener === true) {
386
+ const currentValues = this.getValues();
387
+ const oldValues = {};
388
+ const context = null;
389
+ listener(currentValues, oldValues, context);
390
+ }
391
+
347
392
  return () => {
348
393
  this._subscriptions.delete(listener);
349
394
  };
@@ -99,6 +99,12 @@ class SharedStateManagerClient {
99
99
  list.forEach(([schemaName, stateId, nodeId]) => {
100
100
  this._observeListeners.forEach(callback => callback(schemaName, stateId, nodeId));
101
101
  });
102
+ }); // ---------------------------------------------
103
+ // Clear cache when schema is deleted
104
+ // ---------------------------------------------
105
+
106
+ this.client.transport.addListener(_sharedStateUtils.DELETE_SCHEMA, schemaName => {
107
+ this._cachedSchemas.delete(schemaName);
102
108
  });
103
109
  }
104
110
  /**
@@ -273,6 +273,7 @@ class SharedStateManagerServer extends _SharedStateManagerClient.default {
273
273
 
274
274
 
275
275
  deleteSchema(schemaName) {
276
+ // @note: deleting schema
276
277
  for (let [id, state] of this._serverStatesById.entries()) {
277
278
  if (state.schemaName === schemaName) {
278
279
  for (let [remoteId, attached] of state._attachedClients.entries()) {
@@ -283,6 +284,11 @@ class SharedStateManagerServer extends _SharedStateManagerClient.default {
283
284
 
284
285
  this._serverStatesById.delete(this.id);
285
286
  }
287
+ } // clear schema cache of all connected clients
288
+
289
+
290
+ for (let client of this._clientByNodeId.values()) {
291
+ client.transport.emit(`${_sharedStateUtils.DELETE_SCHEMA}`, schemaName);
286
292
  }
287
293
 
288
294
  this._schemas.delete(schemaName); // delete registered hooks
@@ -7,6 +7,8 @@ exports.default = void 0;
7
7
 
8
8
  var _ParameterBag = _interopRequireDefault(require("./ParameterBag.js"));
9
9
 
10
+ var _lodash = _interopRequireDefault(require("lodash.clonedeep"));
11
+
10
12
  var _sharedStateUtils = require("./shared-state-utils");
11
13
 
12
14
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
@@ -41,83 +43,114 @@ class SharedStatePrivate {
41
43
  // apply registered hooks
42
44
  const hooks = this._manager._hooksBySchemaName.get(this.schemaName);
43
45
 
44
- const values = this._parameters.getValues(); // @note: we may need a proper update queue to avoid race conditions
46
+ const values = this._parameters.getValues();
45
47
 
48
+ let hookAborted = false; // cf. https://github.com/collective-soundworks/soundworks/issues/45
46
49
 
47
50
  for (let hook of hooks.values()) {
48
- updates = await hook(updates, values, context);
49
- }
50
-
51
- const filteredUpdates = {};
52
- let hasUpdates = false;
53
-
54
- for (let name in updates) {
55
- // from v3.1.0 - the `filteredUpdates` check is made using 'fast-deep-equal'
56
- // cf. https://github.com/epoberezkin/fast-deep-equal
57
- // therefore unchanged objects are not considered changed
58
- // nor propagated anymore.
59
- // until v3.0.4 - we checked the `schema[name].type === 'any'`, to always consider
60
- // objects as dirty, because if the state is attached locally, we
61
- // compare the Object instances instead of their values.
62
- // @note - this should be made more robust but how?
63
- const [newValue, changed] = this._parameters.set(name, updates[name]); // if `filterChange` is set to `false` we don't check if the value
64
- // has been changed or not, it is always propagated to client states
65
-
66
-
67
- const {
68
- filterChange
69
- } = this._parameters.getSchema(name);
70
-
71
- if (filterChange && changed || !filterChange) {
72
- filteredUpdates[name] = newValue;
73
- hasUpdates = true;
51
+ const result = await hook(updates, values, context);
52
+
53
+ if (result === null) {
54
+ // explicit abort if hook returns null
55
+ hookAborted = true;
56
+ break;
57
+ } else if (result === undefined) {
58
+ // implicit continue if hook returns undefined
59
+ continue;
60
+ } else {
61
+ // the hook returned an updates object
62
+ updates = result;
74
63
  }
75
64
  }
76
65
 
77
- if (hasUpdates) {
78
- // send response to requester
79
- // client.transport.emit(`${UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates);
80
- // @note: we propagate server-side last, because as the server transport
81
- // is synchronous it can break ordering if a subscription function makes
82
- // itself an update in reaction to an update, therefore network messages
83
- // order would be broken,
84
- // we need to handle cases where:
85
- // client state (client.id: 2) sends a request
86
- // server attached state (client.id: -1) spot a problem and overrides the value
87
- // we want the remote client (id: 2) to receive in the right order:
88
- // * 1. the value it requested,
89
- // * 2. the value overriden by the server-side attached state (id: -1)
90
- // this problem could be solved properly with a reducer system:
91
- // if (dirty) {
92
- // -> call (async) reducer
93
- // -> get values from reducer
94
- //. -> dispatch to everybody
95
- // }
96
- for (let [peerRemoteId, peer] of this._attachedClients.entries()) {
97
- // propagate notification to all other attached clients except server
98
- if (remoteId !== peerRemoteId && peer.id !== -1) {
99
- peer.transport.emit(`${_sharedStateUtils.UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`, filteredUpdates, context);
66
+ if (hookAborted === false) {
67
+ const filteredUpdates = {};
68
+ let hasUpdates = false;
69
+
70
+ for (let name in updates) {
71
+ // from v3.1.0 - the `filteredUpdates` check is made using 'fast-deep-equal'
72
+ // cf. https://github.com/epoberezkin/fast-deep-equal
73
+ // therefore unchanged objects are not considered changed
74
+ // nor propagated anymore.
75
+ // until v3.0.4 - we checked the `schema[name].type === 'any'`, to always consider
76
+ // objects as dirty, because if the state is attached locally, we
77
+ // compare the Object instances instead of their values.
78
+ // @note - this should be made more robust but how?
79
+ const [newValue, changed] = this._parameters.set(name, updates[name]); // if `filterChange` is set to `false` we don't check if the value
80
+ // has been changed or not, it is always propagated to client states
81
+
82
+
83
+ const {
84
+ event,
85
+ filterChange
86
+ } = this._parameters.getSchema(name); // if event type reset internal value to null
87
+
88
+
89
+ if (event) {
90
+ this._parameters.set(name, null);
100
91
  }
101
- }
102
92
 
103
- if (client.id !== -1) {
104
- client.transport.emit(`${_sharedStateUtils.UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates, context);
93
+ if (filterChange && changed || !filterChange) {
94
+ filteredUpdates[name] = newValue;
95
+ hasUpdates = true;
96
+ }
105
97
  }
106
98
 
107
- for (let [peerRemoteId, peer] of this._attachedClients.entries()) {
108
- // propagate notification to server
109
- if (remoteId !== peerRemoteId && peer.id === -1) {
110
- peer.transport.emit(`${_sharedStateUtils.UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`, filteredUpdates, context);
99
+ if (hasUpdates) {
100
+ // send response to requester
101
+ // client.transport.emit(`${UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates);
102
+ // @note: we propagate server-side last, because as the server transport
103
+ // is synchronous it can break ordering if a subscription function makes
104
+ // itself an update in reaction to an update, therefore network messages
105
+ // order would be broken,
106
+ // we need to handle cases where:
107
+ // client state (client.id: 2) sends a request
108
+ // server attached state (client.id: -1) spot a problem and overrides the value
109
+ // we want the remote client (id: 2) to receive in the right order:
110
+ // * 1. the value it requested,
111
+ // * 2. the value overriden by the server-side attached state (id: -1)
112
+ // this problem could be solved properly with a reducer system:
113
+ // if (dirty) {
114
+ // -> call (async) reducer
115
+ // -> get values from reducer
116
+ //. -> dispatch to everybody
117
+ // }
118
+ for (let [peerRemoteId, peer] of this._attachedClients.entries()) {
119
+ // propagate notification to all other attached clients except server
120
+ if (remoteId !== peerRemoteId && peer.id !== -1) {
121
+ peer.transport.emit(`${_sharedStateUtils.UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`, filteredUpdates, context);
122
+ }
123
+ }
124
+
125
+ if (client.id !== -1) {
126
+ client.transport.emit(`${_sharedStateUtils.UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates, context);
127
+ }
128
+
129
+ for (let [peerRemoteId, peer] of this._attachedClients.entries()) {
130
+ // propagate notification to server
131
+ if (remoteId !== peerRemoteId && peer.id === -1) {
132
+ peer.transport.emit(`${_sharedStateUtils.UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`, filteredUpdates, context);
133
+ }
111
134
  }
112
- }
113
135
 
114
- if (client.id === -1) {
115
- client.transport.emit(`${_sharedStateUtils.UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates, context);
136
+ if (client.id === -1) {
137
+ client.transport.emit(`${_sharedStateUtils.UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates, context);
138
+ }
139
+ } else {
140
+ // propagate back to the requester that the update has been aborted
141
+ // ignore all other attached clients.
142
+ client.transport.emit(`${_sharedStateUtils.UPDATE_ABORT}-${this.id}-${remoteId}`, reqId, updates, context);
116
143
  }
117
144
  } else {
118
- // propagate back to the requester that the update has been aborted
119
- // ignore all other attached clients.
120
- client.transport.emit(`${_sharedStateUtils.UPDATE_ABORT}-${this.id}-${remoteId}`, reqId, updates, context);
145
+ // retrieve values from inner state (also handle immediate approriately)
146
+ const oldValues = {};
147
+
148
+ for (let name in updates) {
149
+ oldValues[name] = this._parameters.get(name);
150
+ } // aborted by hook (updates have been overriden to {})
151
+
152
+
153
+ client.transport.emit(`${_sharedStateUtils.UPDATE_ABORT}-${this.id}-${remoteId}`, reqId, oldValues, context);
121
154
  }
122
155
  });
123
156
 
@@ -3,11 +3,11 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
+ exports.UPDATE_RESPONSE = exports.UPDATE_REQUEST = exports.UPDATE_NOTIFICATION = exports.UPDATE_ABORT = exports.SERVER_ID = exports.OBSERVE_RESPONSE = exports.OBSERVE_REQUEST = exports.OBSERVE_NOTIFICATION = exports.DETACH_RESPONSE = exports.DETACH_REQUEST = exports.DETACH_ERROR = exports.DELETE_SCHEMA = exports.DELETE_RESPONSE = exports.DELETE_REQUEST = exports.DELETE_NOTIFICATION = exports.DELETE_ERROR = exports.CREATE_RESPONSE = exports.CREATE_REQUEST = exports.CREATE_ERROR = exports.ATTACH_RESPONSE = exports.ATTACH_REQUEST = exports.ATTACH_ERROR = void 0;
6
7
  exports.idGenerator = idGenerator;
7
- exports.storeRequestPromise = storeRequestPromise;
8
- exports.resolveRequest = resolveRequest;
9
8
  exports.rejectRequest = rejectRequest;
10
- exports.UPDATE_NOTIFICATION = exports.UPDATE_ABORT = exports.UPDATE_RESPONSE = exports.UPDATE_REQUEST = exports.OBSERVE_NOTIFICATION = exports.OBSERVE_RESPONSE = exports.OBSERVE_REQUEST = exports.DETACH_ERROR = exports.DETACH_RESPONSE = exports.DETACH_REQUEST = exports.ATTACH_ERROR = exports.ATTACH_RESPONSE = exports.ATTACH_REQUEST = exports.DELETE_NOTIFICATION = exports.DELETE_ERROR = exports.DELETE_RESPONSE = exports.DELETE_REQUEST = exports.CREATE_ERROR = exports.CREATE_RESPONSE = exports.CREATE_REQUEST = exports.SERVER_ID = void 0;
9
+ exports.resolveRequest = resolveRequest;
10
+ exports.storeRequestPromise = storeRequestPromise;
11
11
  // id of the server when owner of a state
12
12
  const SERVER_ID = -1;
13
13
  exports.SERVER_ID = SERVER_ID;
@@ -51,6 +51,8 @@ const UPDATE_ABORT = 's:u:ab';
51
51
  exports.UPDATE_ABORT = UPDATE_ABORT;
52
52
  const UPDATE_NOTIFICATION = 's:u:not';
53
53
  exports.UPDATE_NOTIFICATION = UPDATE_NOTIFICATION;
54
+ const DELETE_SCHEMA = 's:d:s';
55
+ exports.DELETE_SCHEMA = DELETE_SCHEMA;
54
56
 
55
57
  function* idGenerator() {
56
58
  for (let i = 0; true; i++) {
@@ -4,8 +4,8 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.packBinaryMessage = packBinaryMessage;
7
- exports.unpackBinaryMessage = unpackBinaryMessage;
8
7
  exports.packStringMessage = packStringMessage;
8
+ exports.unpackBinaryMessage = unpackBinaryMessage;
9
9
  exports.unpackStringMessage = unpackStringMessage;
10
10
 
11
11
  require("fast-text-encoding");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soundworks/core",
3
- "version": "3.1.0-beta.1",
3
+ "version": "3.1.0-beta.5",
4
4
  "description": "full-stack javascript framework for distributed audio visual experiences on the web",
5
5
  "authors": [
6
6
  "Benjamin Matuszewski",
@@ -32,36 +32,38 @@
32
32
  "dev": "npm run build && (npm run dev:client & npm run dev:server & npm run dev:common)"
33
33
  },
34
34
  "dependencies": {
35
- "chalk": "^2.4.2",
35
+ "chalk": "^4.1.2",
36
36
  "columnify": "^1.5.4",
37
37
  "compression": "^1.7.1",
38
38
  "debug": "^4.1.1",
39
39
  "fast-deep-equal": "^3.1.3",
40
40
  "fast-text-encoding": "^1.0.0",
41
41
  "isomorphic-ws": "^4.0.1",
42
- "jsdoc-template": "^1.2.0",
43
- "keyv": "^3.1.0",
44
- "keyv-file": "^0.1.13",
42
+ "keyv": "^4.0.3",
43
+ "keyv-file": "^0.2.0",
45
44
  "lodash.clonedeep": "^4.5.0",
46
45
  "lodash.merge": "^4.6.2",
47
46
  "pem": "^1.12.5",
48
47
  "polka": "^0.5.2",
49
48
  "serve-static": "^1.13.2",
50
- "uuidv4": "^4.0.0",
49
+ "uuid": "^8.3.2",
51
50
  "window-or-global": "^1.0.1",
52
- "ws": "^7.0.0"
51
+ "ws": "^8.2.3"
53
52
  },
54
53
  "devDependencies": {
55
54
  "@babel/cli": "^7.4.4",
56
55
  "@babel/core": "^7.4.5",
57
56
  "@babel/preset-env": "^7.4.5",
58
- "braintree-jsdoc-template": "^3.3.0",
59
57
  "chai": "^4.2.0",
60
58
  "chokidar": "^3.0.1",
61
- "chokidar-cli": "^1.2.2",
59
+ "chokidar-cli": "^3.0.0",
62
60
  "docdash": "^1.2.0",
63
61
  "jsdoc": "^3.6.2",
64
62
  "markdown-toc": "^1.2.0",
65
- "mocha": "^8.1.3"
63
+ "mocha": "^9.1.3"
64
+ },
65
+ "optionalDependencies": {
66
+ "bufferutil": "^4.0.5",
67
+ "utf-8-validate": "^5.0.7"
66
68
  }
67
69
  }
package/server/Client.js CHANGED
@@ -5,9 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.default = void 0;
7
7
 
8
- var _uuidv = _interopRequireDefault(require("uuidv4"));
9
-
10
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
8
+ var _uuid = require("uuid");
11
9
 
12
10
  function* idGenerator() {
13
11
  for (let i = 0; true; i++) {
@@ -47,7 +45,7 @@ class Client {
47
45
  * @type {String}
48
46
  */
49
47
 
50
- this.uuid = (0, _uuidv.default)();
48
+ this.uuid = (0, _uuid.v4)();
51
49
  /**
52
50
  * Socket connection with the remote {@link client.Client}.
53
51
  * @type {server.Socket}
@@ -87,7 +87,7 @@ class PluginManager {
87
87
  register(name, factory = null, options = {}, deps = []) {
88
88
  if (this._registeredPlugins[name]) {
89
89
  const ctor = factory(_AbstractPlugin.default);
90
- throw new Error(`[soundworks:core] plugin "${name}" of type "${ctor.name}" already registered`);
90
+ throw new Error(`[soundworks:core] Plugin "${name}" of type "${ctor.name}" already registered`);
91
91
  }
92
92
 
93
93
  const ctor = factory(_AbstractPlugin.default);
@@ -106,7 +106,7 @@ class PluginManager {
106
106
 
107
107
  get(name, _experience = null) {
108
108
  if (!this._registeredPlugins[name]) {
109
- throw new Error(`Cannot get or require plugin "${name}", plugin is not registered
109
+ throw new Error(`[soundworks:core] Cannot get or require plugin "${name}", plugin is not registered
110
110
  > registered plugins are:
111
111
  ${Object.keys(this._registeredPlugins).map(n => `> - ${n}\n`).join('')}
112
112
  `);
@@ -114,7 +114,7 @@ ${Object.keys(this._registeredPlugins).map(n => `> - ${n}\n`).join('')}
114
114
 
115
115
 
116
116
  if (_experience && this.signals.start.value === true) {
117
- throw new Error(`Plugin "${name}" required after pluginManager start`);
117
+ throw new Error(`[soundworks:core] Plugin "${name}" required after pluginManager start`);
118
118
  }
119
119
 
120
120
  if (!this._instances[name]) {
package/server/Server.js CHANGED
@@ -264,7 +264,37 @@ class Server {
264
264
  }
265
265
  }
266
266
 
267
- this._clientConfigFunction = clientConfigFunction;
267
+ this._clientConfigFunction = clientConfigFunction; // basic http authentication
268
+
269
+ if (this.config.env.auth) {
270
+ this.router.use((req, res, next) => {
271
+ const isProtected = this.config.env.auth.clients.map(type => req.path.endsWith(`/${type}`)).reduce((acc, value) => acc || value, false);
272
+
273
+ if (isProtected) {
274
+ // authentication middleware
275
+ const auth = this.config.env.auth; // parse login and password from headers
276
+
277
+ const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
278
+ const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':'); // verify login and password are set and correct
279
+
280
+ if (login && password && login === auth.login && password === auth.password) {
281
+ // -> access granted...
282
+ return next();
283
+ } // -> access denied...
284
+
285
+
286
+ res.writeHead(401, {
287
+ 'WWW-Authenticate': 'Basic',
288
+ 'Content-Type': 'text/plain'
289
+ });
290
+ res.end('Authentication required.');
291
+ } else {
292
+ // route not protected
293
+ return next();
294
+ }
295
+ });
296
+ }
297
+
268
298
  return Promise.resolve();
269
299
  }
270
300
  /**
@@ -414,7 +444,7 @@ class Server {
414
444
  }
415
445
  }
416
446
 
417
- _logger.default.clientConfigAndRouting(routes, this.config.app.clients, this.config.env.serverIp);
447
+ _logger.default.clientConfigAndRouting(routes, this.config);
418
448
 
419
449
  return Promise.resolve();
420
450
  }).then(() => {
@@ -512,8 +542,16 @@ class Server {
512
542
 
513
543
  if (this.config.env.subpath && !data.env.subpath) {
514
544
  data.env.subpath = this.config.env.subpath;
515
- }
545
+ } // cors / coop / coep headers for `crossOriginIsolated pages, enables
546
+ // sharedArrayBuffers and high precision timers
547
+ // cf. https://web.dev/why-coop-coep/
548
+
516
549
 
550
+ res.writeHead(200, {
551
+ 'Cross-Origin-Resource-Policy': 'same-origin',
552
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
553
+ 'Cross-Origin-Opener-Policy': 'same-origin'
554
+ });
517
555
  const appIndex = tmpl(data);
518
556
  res.end(appIndex);
519
557
  }); // return route infos for logging on server start
@@ -19,7 +19,10 @@ const logger = {
19
19
  console.log(_chalk.default.cyan(`+ ${msg}`));
20
20
  },
21
21
 
22
- clientConfigAndRouting(routes, clientsConfig, serverIp) {
22
+ clientConfigAndRouting(routes, config) {
23
+ const clientsConfig = config.app.clients;
24
+ const servIp = config.env.serverIp;
25
+ const auth = config.env.auth;
23
26
  const table = [];
24
27
 
25
28
  for (let clientType in clientsConfig) {
@@ -27,18 +30,20 @@ const logger = {
27
30
 
28
31
  if (client.target === 'node') {
29
32
  const line = {
30
- clientType: `${clientType}`,
33
+ client_type: `> ${clientType}`,
31
34
  target: _chalk.default.red(client.target),
32
35
  path: `server ip: ${_chalk.default.green(serverIp)}`,
33
- default: undefined
36
+ default: undefined,
37
+ auth: undefined
34
38
  };
35
39
  table.push(line);
36
40
  } else if (client.target === 'browser') {
37
41
  const line = {
38
- clientType: `${clientType}`,
42
+ client_type: `> ${clientType}`,
39
43
  target: _chalk.default.red(client.target),
40
44
  path: routes.find(r => r.clientType === clientType) ? _chalk.default.green(routes.find(r => r.clientType === clientType).path) : _chalk.default.red('no route defined'),
41
- default: client.default ? 'default' : undefined
45
+ default: client.default ? 'x' : undefined,
46
+ auth: auth && auth.clients.indexOf(clientType) !== -1 ? 'x' : undefined
42
47
  };
43
48
  table.push(line);
44
49
  } else {
@@ -46,10 +51,21 @@ const logger = {
46
51
  }
47
52
  }
48
53
 
54
+ console.log(``);
49
55
  console.log((0, _columnify.default)(table, {
50
- showHeaders: false,
51
- minWidth: 14
52
- })); // check if a route is defined but not in config
56
+ showHeaders: true,
57
+ minWidth: 6,
58
+ columnSplitter: ' | ',
59
+ config: {
60
+ default: {
61
+ align: 'center'
62
+ },
63
+ auth: {
64
+ align: 'center'
65
+ }
66
+ }
67
+ }));
68
+ console.log(``); // check if a route is defined but not in config
53
69
 
54
70
  const configClientTypes = Object.keys(clientsConfig);
55
71
  routes.forEach(r => {