@soundworks/core 4.0.0-alpha.5 → 4.0.0-alpha.6

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.
@@ -0,0 +1,31 @@
1
+ name: GH Pages
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - '*'
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ deploy:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - name: Checkout code
16
+ uses: actions/checkout@v3
17
+
18
+ - name: 'Install Node'
19
+ uses: actions/setup-node@v3.6.0
20
+
21
+ - name: Install dependencies
22
+ run: npm install
23
+
24
+ - name: Build Docs
25
+ run: npm run doc
26
+
27
+ - name: Deploy
28
+ uses: JamesIves/github-pages-deploy-action@v4
29
+ with:
30
+ folder: docs
31
+
@@ -0,0 +1,18 @@
1
+ name: Unit Tests
2
+ on: push
3
+
4
+ jobs:
5
+ test:
6
+ runs-on: ubuntu-latest
7
+ steps:
8
+ - name: Checkout
9
+ uses: actions/checkout@v3
10
+
11
+ - name: 'Install Node'
12
+ uses: actions/setup-node@v3.6.0
13
+
14
+ - name: Install dependencies
15
+ run: npm install
16
+
17
+ - name: "Run Test"
18
+ run: npm test -- tests/**.spec.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soundworks/core",
3
- "version": "4.0.0-alpha.5",
3
+ "version": "4.0.0-alpha.6",
4
4
  "description": "Open-source creative coding framework for distributed applications based on Web technologies",
5
5
  "authors": [
6
6
  "Benjamin Matuszewski",
@@ -43,13 +43,12 @@
43
43
  "node": ">=16.0.0"
44
44
  },
45
45
  "scripts": {
46
- "doc": "npm run jsdoc",
47
- "jsdoc": "rm -Rf docs && jsdoc -c .jsdoc.json --verbose && cp -R assets docs/",
46
+ "doc": "rm -Rf docs && jsdoc -c .jsdoc.json --verbose && cp -R assets docs/",
48
47
  "lint": "npx eslint src",
49
48
  "test": "mocha",
50
49
  "toc": "markdown-toc -i README.md",
51
50
  "types": "rm -Rf types && tsc",
52
- "version": "npm run toc && npm run doc && git add docs"
51
+ "version": "npm run toc"
53
52
  },
54
53
  "dependencies": {
55
54
  "@ircam/sc-gettime": "^1.0.0",
@@ -57,6 +56,7 @@
57
56
  "chalk": "^5.2.0",
58
57
  "columnify": "^1.6.0",
59
58
  "compression": "^1.7.1",
59
+ "cross-fetch": "^3.1.5",
60
60
  "express": "^4.18.1",
61
61
  "fast-deep-equal": "^3.1.3",
62
62
  "fast-text-encoding": "^1.0.0",
@@ -66,7 +66,6 @@
66
66
  "lodash.clonedeep": "^4.5.0",
67
67
  "lodash.merge": "^4.6.2",
68
68
  "pem": "^1.14.6",
69
- "serve-static": "^1.15.0",
70
69
  "template-literal": "^1.0.4",
71
70
  "uuid": "^9.0.0",
72
71
  "window-or-global": "^1.0.1",
@@ -198,6 +198,12 @@ class Client {
198
198
  */
199
199
  this.status = 'idle';
200
200
 
201
+ /**
202
+ * Token of the client if connected through HTTP authentication.
203
+ * @private
204
+ */
205
+ this.token = null;
206
+
201
207
  /** @private */
202
208
  this._onStatusChangeCallbacks = new Set();
203
209
  /** @private */
@@ -240,9 +246,10 @@ class Client {
240
246
  try {
241
247
  await new Promise((resolve, reject) => {
242
248
  // wait for handshake response before starting stateManager and pluginManager
243
- this.socket.addListener(CLIENT_HANDSHAKE_RESPONSE, async ({ id, uuid }) => {
249
+ this.socket.addListener(CLIENT_HANDSHAKE_RESPONSE, async ({ id, uuid, token }) => {
244
250
  this.id = id;
245
251
  this.uuid = uuid;
252
+ this.token = token;
246
253
 
247
254
  resolve();
248
255
  });
@@ -0,0 +1,35 @@
1
+ import BaseSharedStateCollection from '../common/BaseSharedStateCollection.js';
2
+
3
+ // this file exists only for documentation purposesSharedStateCollection.js
4
+
5
+ /**
6
+ * @callback client.SharedStateCollection~onUpdateCallback
7
+ * @param {client.SharedState} state - State that triggered the update.
8
+ * @param {Object} newValues - Key / value pairs of the updates that have been
9
+ * applied to the state.
10
+ * @param {Object} oldValues - Key / value pairs of the updated params before
11
+ * the updates has been applied to the state.
12
+ * @param {Mixed} [context=null] - Optionnal context object that has been passed
13
+ * with the values updates in the `set` call.
14
+ */
15
+
16
+ /**
17
+ * The `SharedStateCollection` interface represent a collection of all states
18
+ * created from a given schema name on the network, at the execption of the ones
19
+ * created by the current node.
20
+ *
21
+ * ```
22
+ * const collection = await client.stateManager.getCollection('my-schema');
23
+ * const allValues = collection.getValues();
24
+ * collection.onUpdate((state, newValues, oldValues, context) => {
25
+ * // do something
26
+ * });
27
+ * ```
28
+ * @memberof client
29
+ * @extends BaseSharedStateCollection
30
+ * @inheritdoc
31
+ * @hideconstructor
32
+ */
33
+ class SharedStateCollection extends BaseSharedStateCollection {}
34
+
35
+ export default SharedStateCollection;
@@ -1,4 +1,5 @@
1
1
  import { isBrowser } from '@ircam/sc-utils';
2
+ import fetch from 'cross-fetch';
2
3
  import WebSocket from 'isomorphic-ws';
3
4
 
4
5
  import {
@@ -110,7 +111,11 @@ class Socket {
110
111
  };
111
112
  }
112
113
 
113
- const queryParams = `role=${role}&key=${key}`;
114
+ let queryParams = `role=${role}&key=${key}`;
115
+
116
+ if (config.token) {
117
+ queryParams += `&token=${config.token}`;
118
+ }
114
119
 
115
120
  // ----------------------------------------------------------
116
121
  // init string socket
@@ -146,19 +151,22 @@ class Socket {
146
151
 
147
152
  // cf. https://github.com/collective-soundworks/soundworks/issues/17
148
153
  ws.addEventListener('error', e => {
149
- if (e.type === 'error' && e.error.code === 'ECONNREFUSED') {
150
- if (!connectionRefusedLogged) {
151
- logger.log('[soundworks.Socket] Connection refused, waiting for the server to start');
152
- connectionRefusedLogged = true;
153
- }
154
-
154
+ if (e.type === 'error') {
155
155
  if (ws.terminate) {
156
156
  ws.terminate();
157
157
  } else {
158
158
  ws.close();
159
159
  }
160
160
 
161
- setTimeout(trySocket, 1000);
161
+ // for node clients, retry connection
162
+ if (e.error && e.error.code === 'ECONNREFUSED') {
163
+ if (!connectionRefusedLogged) {
164
+ logger.log('[soundworks.Socket] Connection refused, waiting for the server to start');
165
+ connectionRefusedLogged = true;
166
+ }
167
+
168
+ setTimeout(trySocket, 1000);
169
+ }
162
170
  }
163
171
  });
164
172
  };
@@ -138,7 +138,6 @@ ${JSON.stringify(initValues, null, 2)}`);
138
138
  this._client.transport.addListener(`${DETACH_ERROR}-${this.id}`, (reqId, msg) => {
139
139
  rejectRequest(reqId, msg);
140
140
  });
141
-
142
141
  }
143
142
  }
144
143
 
@@ -150,7 +149,7 @@ ${JSON.stringify(initValues, null, 2)}`);
150
149
  // Monkey patch detach so it throws if called twice. Doing nothing blocks
151
150
  // the process on a second `detach` call as the Promise never resolves
152
151
  this.detach = () => {
153
- throw new Error(`[stateManager] State "${this.schemaName} (${this.id})" already detached, cannot detach twice`);
152
+ throw new Error(`[SharedState] State "${this.schemaName} (${this.id})" already detached, cannot detach twice`);
154
153
  };
155
154
  }
156
155
 
@@ -270,10 +269,9 @@ ${JSON.stringify(initValues, null, 2)}`);
270
269
  let propagateNow = false;
271
270
 
272
271
  for (let name in updates) {
273
- // throw early (client-side and not only server-side) if parameter is undefined
274
- if (!this._parameters.has(name)) {
275
- throw new ReferenceError(`[SharedState] State "${this.schemaName}": cannot set value of undefined parameter "${name}"`);
276
- }
272
+ // try to coerce value early, so that eventual errors are triggered early
273
+ // on the node requesting the update
274
+ const _ = this._parameters.coerceValue(name, updates[name]);
277
275
 
278
276
  // @note: general idea...
279
277
  // if immediate=true
@@ -312,21 +310,41 @@ ${JSON.stringify(initValues, null, 2)}`);
312
310
  }
313
311
 
314
312
  /**
315
- * Get the value of a paramter of the state.
313
+ * Get the value of a parameter of the state. If the parameter is of `any` type,
314
+ * a deep copy is returned.
316
315
  *
317
316
  * @param {string} name - Name of the param.
318
317
  * @throws Throws if `name` does not correspond to an existing field
319
318
  * of the state.
320
319
  * @return {mixed}
321
320
  * @example
322
- * const value = state.get('name');
321
+ * const value = state.get('paramName');
323
322
  */
324
323
  get(name) {
325
324
  return this._parameters.get(name);
326
325
  }
327
326
 
328
327
  /**
329
- * Get all the key / value pairs of the state.
328
+ * Similar to `get` but returns a reference to the underlying value in case of
329
+ * `any` type. May be usefull if the underlying value is big (e.g. sensors
330
+ * recordings, etc.) and deep cloning expensive. Be aware that if changes are
331
+ * made on the returned object, the state of your application will become
332
+ * inconsistent.
333
+ *
334
+ * @param {string} name - Name of the param.
335
+ * @throws Throws if `name` does not correspond to an existing field
336
+ * of the state.
337
+ * @return {mixed}
338
+ * @example
339
+ * const value = state.getUnsafe('paramName');
340
+ */
341
+ getUnsafe(name) {
342
+ return this._parameters.getUnsafe(name);
343
+ }
344
+
345
+ /**
346
+ * Get all the key / value pairs of the state. If a parameter is of `any`
347
+ * type, a deep copy is made.
330
348
  *
331
349
  * @return {object}
332
350
  * @example
@@ -336,6 +354,23 @@ ${JSON.stringify(initValues, null, 2)}`);
336
354
  return this._parameters.getValues();
337
355
  }
338
356
 
357
+ /**
358
+ * Get all the key / value pairs of the state. If a parameter is of `any`
359
+ * type, a deep copy is made.
360
+ * Similar to `getValues` but returns a reference to the underlying value in
361
+ * case of `any` type. May be usefull if the underlying value is big (e.g.
362
+ * sensors recordings, etc.) and deep cloning expensive. Be aware that if
363
+ * changes are made on the returned object, the state of your application will
364
+ * become inconsistent.
365
+ *
366
+ * @return {object}
367
+ * @example
368
+ * const values = state.getValues();
369
+ */
370
+ getValuesUnsafe() {
371
+ return this._parameters.getValuesUnsafe();
372
+ }
373
+
339
374
  /**
340
375
  * Get the schema from which the state has been created.
341
376
  *
@@ -423,7 +458,7 @@ ${JSON.stringify(initValues, null, 2)}`);
423
458
  /**
424
459
  * Subscribe to state updates.
425
460
  *
426
- * @param {client.SharedState~onUpdateCallback|client.SharedState~onUpdateCallback} callback
461
+ * @param {client.SharedState~onUpdateCallback|server.SharedState~onUpdateCallback} callback
427
462
  * Callback to execute when an update is applied on the state.
428
463
  * @param {Boolean} [executeListener=false] - Execute the callback immediately
429
464
  * with current state values. (`oldValues` will be set to `{}`, and `context` to `null`)
@@ -458,8 +493,8 @@ ${JSON.stringify(initValues, null, 2)}`);
458
493
  /**
459
494
  * Register a function to execute when detaching from the state
460
495
  *
461
- * @param {Function} callback - callback to execute when detaching from the state.
462
- * wether the client as called `detach`, or the state has been deleted by its
496
+ * @param {Function} callback - Callback to execute when detaching from the state.
497
+ * Whether the client as called `detach`, or the state has been deleted by its
463
498
  * creator.
464
499
  */
465
500
  onDetach(callback) {
@@ -471,7 +506,7 @@ ${JSON.stringify(initValues, null, 2)}`);
471
506
  * Register a function to execute when the state is deleted. Only called if the
472
507
  * node was the creator of the state. Is called after `onDetach`
473
508
  *
474
- * @param {Function} callback - callback to execute when the state is deleted.
509
+ * @param {Function} callback - Callback to execute when the state is deleted.
475
510
  */
476
511
  onDelete(callback) {
477
512
  this._onDeleteCallbacks.add(callback);
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @private
3
+ */
4
+ class BaseSharedStateCollection {
5
+ constructor(stateManager, schemaName) {
6
+ this._stateManager = stateManager;
7
+ this._schemaName = schemaName;
8
+ this._states = [];
9
+ this._onUpdateCallbacks = new Set();
10
+ this._onAttachCallbacks = new Set();
11
+ this._onDetachCallbacks = new Set();
12
+ this._unobserve = null;
13
+ }
14
+
15
+ async _init() {
16
+ this._unobserve = await this._stateManager.observe(this._schemaName, async (schemaName, stateId, nodeId) => {
17
+ const state = await this._stateManager.attach(schemaName, stateId);
18
+
19
+ this._states.push(state);
20
+
21
+ state.onDetach(() => {
22
+ const index = this._states.indexOf(state);
23
+ this._states.splice(index, 1);
24
+
25
+ this._onDetachCallbacks.forEach(callback => callback(state));
26
+ });
27
+
28
+ state.onUpdate((newValues, oldValues, context) => {
29
+ Array.from(this._onUpdateCallbacks).forEach(callback => {
30
+ callback(state, newValues, oldValues, context);
31
+ });
32
+ });
33
+
34
+ this._onAttachCallbacks.forEach(callback => callback(state));
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Size of the collection
40
+ * @type {number}
41
+ */
42
+ get length() {
43
+ return this._states.length;
44
+ }
45
+
46
+ /**
47
+ * Detach from the collection, i.e. detach all underlying shared states.
48
+ * @type {number}
49
+ */
50
+ async detach() {
51
+ this._unobserve();
52
+
53
+ const promises = Array.from(this._states).map(state => state.detach());
54
+ await Promise.all(promises);
55
+
56
+ this._onUpdateCallbacks.clear();
57
+ this._onDetachCallbacks.clear();
58
+ }
59
+
60
+ /**
61
+ * Return the current values of all the states in the collection.
62
+ * @return {Object[]}
63
+ */
64
+ getValues() {
65
+ return this._states.map(state => state.getValues());
66
+ }
67
+
68
+ /**
69
+ * Return the current param value of all the states in the collection.
70
+ *
71
+ * @param {String} name - Name of the parameter
72
+ * @return {any[]}
73
+ */
74
+ get(name) {
75
+ return this._states.map(state => state.get(name));
76
+ }
77
+
78
+ /**
79
+ * Subscribe to any state update of the collection.
80
+ *
81
+ * @param {server.SharedStateCollection~onUpdateCallback|client.SharedStateCollection~onUpdateCallback}
82
+ * callback - Callback to execute when an update is applied on a state.
83
+ * @param {Boolean} [executeListener=false] - Execute the callback immediately
84
+ * for all underlying states with current state values. (`oldValues` will be
85
+ * set to `{}`, and `context` to `null`)
86
+ * @returns {Function} - Function that delete the registered listener.
87
+ */
88
+ onUpdate(callback, executeListener = false) {
89
+ this._onUpdateCallbacks.add(callback);
90
+
91
+ if (executeListener === true) {
92
+ this._states.forEach(state => {
93
+ const currentValues = state.getValues();
94
+ const oldValues = {};
95
+ const context = null;
96
+
97
+ callback(state, currentValues, oldValues, context);
98
+ });
99
+ }
100
+
101
+ return () => this._onUpdateCallbacks.delete(callback);
102
+ }
103
+
104
+ /**
105
+ * Register a function to execute when a state is added to the collection.
106
+ *
107
+ * @param {Function} callback - callback to execute when a state is added to
108
+ * the collection.
109
+ */
110
+ onAttach(callback) {
111
+ this._onAttachCallbacks.add(callback);
112
+ }
113
+
114
+ /**
115
+ * Register a function to execute when a state is removed from the collection.
116
+ *
117
+ * @param {Function} callback - callback to execute when a state is removed
118
+ * from the collection.
119
+ */
120
+ onDetach(callback) {
121
+ this._onDetachCallbacks.add(callback);
122
+ }
123
+
124
+ /**
125
+ * Execute the given function once for each states of the collection (see `Array.forEach`).
126
+ *
127
+ * @param {Function} func - A function to execute for each element in the array.
128
+ * Its return value is discarded.
129
+ */
130
+ forEach(func) {
131
+ this._states.forEach(func);
132
+ }
133
+
134
+ /**
135
+ * Creates a new array populated with the results of calling a provided function
136
+ * on every state of the collection (see `Array.map`).
137
+ *
138
+ * @param {Function} func - A function to execute for each element in the array.
139
+ * Its return value is added as a single element in the new array.
140
+ */
141
+ map(func) {
142
+ return this._states.map(func);
143
+ }
144
+
145
+ /**
146
+ * Creates a shallow copy of a portion of the collection, filtered down to just
147
+ * the estates that pass the test implemented by the provided function (see `Array.filter`).
148
+ *
149
+ * @param {Function} func - A function to execute for each element in the array.
150
+ * It should return a truthy to keep the element in the resulting array, and a f
151
+ * alsy value otherwise.
152
+ */
153
+ filter(func) {
154
+ return this._states.filter(func);
155
+ }
156
+
157
+ /**
158
+ * Sort the elements of the collection in place (see `Array.sort`).
159
+ *
160
+ * @param {Function} func - Function that defines the sort order.
161
+ */
162
+ sort(func) {
163
+ this._states.sort(func);
164
+ }
165
+
166
+ /**
167
+ * Returns the first element of the collection that satisfies the provided testing
168
+ * function. If no values satisfy the testing function, undefined is returned.
169
+ *
170
+ * @param {Function} func - Function to execute for each element in the array.
171
+ * It should return a truthy value to indicate a matching element has been found.
172
+ * @return {}
173
+ */
174
+ find(func) {
175
+ return this._states.find(func);
176
+ }
177
+ }
178
+
179
+ export default BaseSharedStateCollection;
@@ -1,4 +1,6 @@
1
+ import { isString, isFunction } from '@ircam/sc-utils';
1
2
  import SharedState from './BaseSharedState.js';
3
+ import SharedStateCollection from './BaseSharedStateCollection.js';
2
4
  import {
3
5
  CREATE_REQUEST,
4
6
  CREATE_RESPONSE,
@@ -31,7 +33,7 @@ class BaseStateManager {
31
33
  this.client = { id, transport };
32
34
 
33
35
  this._statesById = new Map();
34
- this._observeListeners = new Set();
36
+ this._observeListeners = new Map(); // Map <callback, filterSchemaName>
35
37
  this._cachedSchemas = new Map();
36
38
  this._observeRequestCallbacks = new Map();
37
39
 
@@ -88,28 +90,35 @@ class BaseStateManager {
88
90
  this.client.transport.addListener(OBSERVE_RESPONSE, async (reqId, ...list) => {
89
91
  // retrieve the callback that have been stored in observe to make sure
90
92
  // we don't call another callback that may have been registered earlier.
91
- const callback = this._observeRequestCallbacks.get(reqId);
93
+ const [callback, filterSchemaName] = this._observeRequestCallbacks.get(reqId);
92
94
  this._observeRequestCallbacks.delete(reqId);
93
95
 
94
96
  const promises = list.map(([schemaName, stateId, nodeId]) => {
95
- return callback(schemaName, stateId, nodeId);
97
+ if (filterSchemaName === '*' || filterSchemaName === schemaName) {
98
+ return callback(schemaName, stateId, nodeId);
99
+ } else {
100
+ return Promise.resolve();
101
+ }
96
102
  });
97
103
 
98
104
  await Promise.all(promises);
99
105
 
100
106
  const unsubscribe = () => {
101
107
  this._observeListeners.delete(callback);
102
-
108
+ // no more listeners, we can stop receiving notification from the server
103
109
  if (this._observeListeners.size === 0) {
104
110
  this.client.transport.emit(UNOBSERVE_NOTIFICATION);
105
111
  }
106
112
  };
113
+
107
114
  resolveRequest(reqId, unsubscribe);
108
115
  });
109
116
 
110
- this.client.transport.addListener(OBSERVE_NOTIFICATION, (...list) => {
111
- list.forEach(([schemaName, stateId, nodeId]) => {
112
- this._observeListeners.forEach(callback => callback(schemaName, stateId, nodeId));
117
+ this.client.transport.addListener(OBSERVE_NOTIFICATION, (schemaName, stateId, nodeId) => {
118
+ this._observeListeners.forEach((filterSchemaName, callback) => {
119
+ if (filterSchemaName === '*' || filterSchemaName === schemaName) {
120
+ callback(schemaName, stateId, nodeId);
121
+ }
113
122
  });
114
123
  });
115
124
 
@@ -170,12 +179,16 @@ class BaseStateManager {
170
179
  * the state of all other clients of the application, to monitor them and/or take
171
180
  * control over them from a single point.
172
181
  *
173
- * @todo Optionnal schema name
182
+ * Note that the states that are created by the same node are not propagated through
183
+ * the observe callback.
184
+ *
185
+ * @param {String} [schemaName] - optionnal schema name to filter the observed
186
+ * states.
174
187
  * @param {server.StateManager~ObserveCallback|client.StateManager~ObserveCallback}
175
188
  * callback - Function to be called when a new state is created on the network.
176
- * @returns {Promise<Function>} - Return a Promise that resolves when the callback
177
- * as been executed for the first time. The promise value is a function which
178
- * allows to stop observing the network.
189
+ * @returns {Promise<Function>} - Returns a Promise that resolves when the given
190
+ * callback as been executed on each existing states. The promise value is a
191
+ * function which allows to stop observing the states on the network.
179
192
  * @example
180
193
  * client.stateManager.observe(async (schemaName, stateId, nodeId) => {
181
194
  * if (schemaName === 'something') {
@@ -184,16 +197,57 @@ class BaseStateManager {
184
197
  * }
185
198
  * });
186
199
  */
187
- async observe(callback) {
188
- this._observeListeners.add(callback);
189
- // store function
200
+ // note: all filtering is done only on client-side as it is really more simple to
201
+ // handle this way and the network overhead is very low for observe notifications:
202
+ // i.e. schemaName, stateId, nodeId
203
+ async observe(...args) {
204
+ let filterSchemaName;
205
+ let callback;
206
+
207
+ if (args.length === 1) {
208
+ filterSchemaName = '*';
209
+ callback = args[0];
210
+
211
+ if (!isFunction(callback)) {
212
+ throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`);
213
+ }
214
+ } else if (args.length === 2) {
215
+ filterSchemaName = args[0];
216
+ callback = args[1];
217
+
218
+ if (!isString(filterSchemaName) || !isFunction(callback)) {
219
+ throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`);
220
+ }
221
+ } else {
222
+ throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`);
223
+ }
224
+
225
+ // resend request to get updated list of states
190
226
  return new Promise((resolve, reject) => {
191
227
  const reqId = storeRequestPromise(resolve, reject);
192
- // store the callback to be executed on the response
193
- this._observeRequestCallbacks.set(reqId, callback);
228
+ // store the callback for execution on the response. the returned Promise
229
+ // is fullfiled once callback has been executed with each existing states
230
+ this._observeRequestCallbacks.set(reqId, [callback, filterSchemaName]);
231
+ // store the callback for execution on subsequent notifications
232
+ this._observeListeners.set(callback, filterSchemaName);
233
+
194
234
  this.client.transport.emit(OBSERVE_REQUEST, reqId);
195
235
  });
196
236
  }
237
+
238
+ /**
239
+ * Returns a collection of all the states created from the schema name. Except
240
+ * the ones created by the current node.
241
+ *
242
+ * @param {string} schemaName - Name of the schema.
243
+ * @returns {server.SharedStateCollection|client.SharedStateCollection}
244
+ */
245
+ async getCollection(schemaName) {
246
+ const collection = new SharedStateCollection(this, schemaName);
247
+ await collection._init();
248
+
249
+ return collection;
250
+ }
197
251
  }
198
252
 
199
253
  export default BaseStateManager;
@@ -17,7 +17,7 @@ export const types = {
17
17
  },
18
18
  coerceFunction: (name, def, value) => {
19
19
  if (typeof value !== 'boolean') {
20
- throw new TypeError(`[SharedState] Invalid value for boolean param "${name}": ${value}`);
20
+ throw new TypeError(`[SharedState] Invalid value "${value}" for boolean parameter "${name}"`);
21
21
  }
22
22
 
23
23
  return value;
@@ -30,7 +30,7 @@ export const types = {
30
30
  },
31
31
  coerceFunction: (name, def, value) => {
32
32
  if (typeof value !== 'string') {
33
- throw new TypeError(`[SharedState] Invalid value for string param "${name}": ${value}`);
33
+ throw new TypeError(`[SharedState] Invalid value "${value}" for string parameter "${name}"`);
34
34
  }
35
35
 
36
36
  return value;
@@ -63,7 +63,7 @@ export const types = {
63
63
  },
64
64
  coerceFunction: (name, def, value) => {
65
65
  if (!(typeof value === 'number' && Math.floor(value) === value)) {
66
- throw new TypeError(`[SharedState] Invalid value for integer param "${name}": ${value}`);
66
+ throw new TypeError(`[SharedState] Invalid value "${value}" for integer parameter "${name}"`);
67
67
  }
68
68
 
69
69
  return Math.max(def.min, Math.min(def.max, value));
@@ -96,7 +96,7 @@ export const types = {
96
96
  },
97
97
  coerceFunction: (name, def, value) => {
98
98
  if (typeof value !== 'number' || value !== value) { // reject NaN
99
- throw new TypeError(`[SharedState] Invalid value for float param "${name}": ${value}`);
99
+ throw new TypeError(`[SharedState] Invalid value "${value}" for float parameter "${name}"`);
100
100
  }
101
101
 
102
102
  return Math.max(def.min, Math.min(def.max, value));
@@ -109,7 +109,7 @@ export const types = {
109
109
  },
110
110
  coerceFunction: (name, def, value) => {
111
111
  if (def.list.indexOf(value) === -1) {
112
- throw new TypeError(`[SharedState] Invalid value for enum param "${name}": ${value}`);
112
+ throw new TypeError(`[SharedState] Invalid value "${value}" for enum parameter "${name}"`);
113
113
  }
114
114
 
115
115
 
@@ -241,7 +241,8 @@ class ParameterBag {
241
241
  }
242
242
 
243
243
  /**
244
- * Return values of all parameters as a flat object.
244
+ * Return values of all parameters as a flat object. If a parameter is of `any`
245
+ * type, a deep copy is made.
245
246
  *
246
247
  * @return {object}
247
248
  */
@@ -255,6 +256,25 @@ class ParameterBag {
255
256
  return values;
256
257
  }
257
258
 
259
+ /**
260
+ * Return values of all parameters as a flat object. Similar to `getValues` but
261
+ * returns a reference to the underlying value in case of `any` type. May be
262
+ * usefull if the underlying value is big (e.g. sensors recordings, etc.) and
263
+ * deep cloning expensive. Be aware that if changes are made on the returned
264
+ * object, the state of your application will become inconsistent.
265
+ *
266
+ * @return {object}
267
+ */
268
+ getValuesUnsafe() {
269
+ let values = {};
270
+
271
+ for (let name in this._values) {
272
+ values[name] = this.getUnsafe(name);
273
+ }
274
+
275
+ return values;
276
+ }
277
+
258
278
  /**
259
279
  * Return the value of the given parameter. If the parameter is of `any` type,
260
280
  * a deep copy is returned.
@@ -277,39 +297,66 @@ class ParameterBag {
277
297
  }
278
298
 
279
299
  /**
280
- * Set the value of a parameter. If the value of the parameter is updated
281
- * (aka if previous value is different from new value) all registered
282
- * callbacks are registered.
300
+ * Similar to `get` but returns a reference to the underlying value in case of
301
+ * `any` type. May be usefull if the underlying value is big (e.g. sensors
302
+ * recordings, etc.) and deep cloning expensive. Be aware that if changes are
303
+ * made on the returned object, the state of your application will become
304
+ * inconsistent.
283
305
  *
284
306
  * @param {string} name - Name of the parameter.
307
+ * @return {Mixed} - Value of the parameter.
308
+ */
309
+ getUnsafe(name) {
310
+ if (!this.has(name)) {
311
+ throw new ReferenceError(`[SharedState] Cannot get value of undefined parameter "${name}"`);
312
+ }
313
+
314
+ return this._values[name];
315
+ }
316
+
317
+ /**
318
+ * Check that the value is valid according to the schema and return it coerced
319
+ * to the schema definition
320
+ *
321
+ * @param {String} name - Name of the parameter.
285
322
  * @param {Mixed} value - Value of the parameter.
286
- * @param {boolean} [forcePropagation=false] - if true, propagate value even
287
- * if the value has not changed.
288
- * @return {Array} - [new value, updated flag].
289
323
  */
290
- set(name, value) {
324
+ coerceValue(name, value) {
291
325
  if (!this.has(name)) {
292
326
  throw new ReferenceError(`[SharedState] Cannot set value of undefined parameter "${name}"`);
293
327
  }
294
328
 
295
329
  const def = this._schema[name];
296
- const { coerceFunction } = types[def.type];
297
330
 
298
331
  if (value === null && def.nullable === false) {
299
332
  throw new TypeError(`[SharedState] Invalid value for ${def.type} param "${name}": value is null and param is not nullable`);
300
333
  } else if (value === null && def.nullable === true) {
301
334
  value = null;
302
335
  } else {
336
+ const { coerceFunction } = types[def.type];
303
337
  value = coerceFunction(name, def, value);
304
338
  }
305
339
 
340
+ return value;
341
+ }
342
+
343
+ /**
344
+ * Set the value of a parameter. If the value of the parameter is updated
345
+ * (aka if previous value is different from new value) all registered
346
+ * callbacks are registered.
347
+ *
348
+ * @param {string} name - Name of the parameter.
349
+ * @param {Mixed} value - Value of the parameter.
350
+ * @return {Array} - [new value, updated flag].
351
+ */
352
+ set(name, value) {
353
+ value = this.coerceValue(name, value);
306
354
  const currentValue = this._values[name];
307
355
  const updated = !equal(currentValue, value);
308
356
 
309
357
  // we store a deep copy of the object as we don't want the client to be able
310
- // to modify our underlying data, which leads unexpected behavior where the
358
+ // to modify our underlying data, which leads to unexpected behavior where the
311
359
  // deep equal check to returns true, and therefore the update is not triggered.
312
- //
313
360
  // @see tests/common.state-manager.spec.js
314
361
  // 'should copy stored value for "any" type to have a predictable behavior'
315
362
  if (this._schema[name].type === 'any') {
@@ -43,6 +43,12 @@ class Client {
43
43
  * @type {server.Socket}
44
44
  */
45
45
  this.socket = socket;
46
+
47
+ /**
48
+ * Is set in server._onSocketConnection
49
+ * @private
50
+ */
51
+ this.token = null;
46
52
  }
47
53
  }
48
54
 
@@ -5,7 +5,8 @@ import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import { X509Certificate, createPrivateKey } from 'node:crypto';
7
7
 
8
- import { isPlainObject } from '@ircam/sc-utils';
8
+ import { getTime } from '@ircam/sc-gettime';
9
+ import { isPlainObject, idGenerator } from '@ircam/sc-utils';
9
10
  import chalk from 'chalk';
10
11
  import compression from 'compression';
11
12
  import express from 'express';
@@ -16,6 +17,7 @@ import pem from 'pem';
16
17
  import compile from 'template-literal';
17
18
 
18
19
  import auditSchema from './audit-schema.js';
20
+ import { encryptData, decryptData } from './crypto.js';
19
21
  import Client from './Client.js';
20
22
  import ContextManager from './ContextManager.js';
21
23
  import PluginManager from './PluginManager.js';
@@ -29,6 +31,7 @@ import {
29
31
  AUDIT_STATE_NAME,
30
32
  } from '../common/constants.js';
31
33
 
34
+
32
35
  let _dbNamespaces = new Set();
33
36
 
34
37
  /**
@@ -73,6 +76,8 @@ const DEFAULT_CONFIG = {
73
76
  },
74
77
  };
75
78
 
79
+ const TOKEN_VALID_DURATION = 20; // sec
80
+
76
81
  // set terminal title
77
82
  /** @private */
78
83
  function setTerminalTitle(server) {
@@ -156,8 +161,8 @@ class Server {
156
161
  throw new Error(`[soundworks:Server] Invalid argument for Server constructor, config should be an object`);
157
162
  }
158
163
  /**
159
- * Given config object merged with the following defaults:
160
- * ```
164
+ * @description Given config object merged with the following defaults:
165
+ * @example
161
166
  * {
162
167
  * env: {
163
168
  * type: 'development',
@@ -178,7 +183,6 @@ class Server {
178
183
  * clients: {},
179
184
  * }
180
185
  * }
181
- * ```
182
186
  */
183
187
  this.config = merge({}, DEFAULT_CONFIG, config);
184
188
 
@@ -236,8 +240,10 @@ class Server {
236
240
  * // expose assets located in the `soundfiles` directory on the network
237
241
  * server.router.use('/soundfiles', express.static('soundfiles')));
238
242
  */
239
- this.router = express.Router();
240
- // compression (must be set before serve-static)
243
+ // @note: we use express() instead of express.Router() because all 404 and
244
+ // error stuff is handled by default
245
+ this.router = express();
246
+ // compression (must be set before express.static())
241
247
  this.router.use(compression());
242
248
 
243
249
  /**
@@ -313,6 +319,10 @@ class Server {
313
319
  this._onStatusChangeCallbacks = new Set();
314
320
  /** @private */
315
321
  this._auditState = null;
322
+ /** @private */
323
+ this._pendingConnectionTokens = new Set();
324
+ /** @private */
325
+ this._trustedClients = new Set();
316
326
 
317
327
  // register audit state schema
318
328
  this.stateManager.registerSchema(AUDIT_STATE_NAME, auditSchema);
@@ -357,11 +367,24 @@ class Server {
357
367
 
358
368
  // basic http authentication
359
369
  if (this.config.env.auth) {
370
+ const ids = idGenerator();
371
+
360
372
  const soundworksAuth = (req, res, next) => {
373
+ let role = null;
374
+
375
+ for (let [_role, config] of Object.entries(this.config.app.clients)) {
376
+ if (req.path === config.route) {
377
+ role = _role;
378
+ }
379
+ }
361
380
 
362
- const isProtected = this.config.env.auth.clients
363
- .map(type => req.path.endsWith(`/${type}`))
364
- .reduce((acc, value) => acc || value, false);
381
+ // route that are not client entry points just pass through the middleware
382
+ if (role === null) {
383
+ next();
384
+ return;
385
+ }
386
+
387
+ const isProtected = this.isProtected(role);
365
388
 
366
389
  if (isProtected) {
367
390
  // authentication middleware
@@ -373,17 +396,34 @@ class Server {
373
396
  // verify login and password are set and correct
374
397
  if (login && password && login === auth.login && password === auth.password) {
375
398
  // -> access granted...
399
+ // generate token for web socket to check connections
400
+ const id = ids.next().value;
401
+ const ip = req.ip;
402
+ const time = getTime();
403
+ const token = { id, ip, time };
404
+ const encryptedToken = encryptData(token);
405
+
406
+ this._pendingConnectionTokens.add(encryptedToken);
407
+
408
+ setTimeout(() => {
409
+ this._pendingConnectionTokens.delete(encryptedToken);
410
+ }, TOKEN_VALID_DURATION * 1000);
411
+
412
+ // pass to the response object to be send to the client
413
+ res.swToken = encryptedToken;
414
+
376
415
  return next();
377
416
  }
378
417
 
379
- // -> access denied...
418
+ // show login / password modal
380
419
  res.writeHead(401, {
381
420
  'WWW-Authenticate':'Basic',
382
421
  'Content-Type':'text/plain',
383
422
  });
423
+
384
424
  res.end('Authentication required.');
385
425
  } else {
386
- // route not protected
426
+ // route is not protected
387
427
  return next();
388
428
  }
389
429
  };
@@ -593,7 +633,7 @@ Invalid certificate files, please check your:
593
633
  await this.sockets.start(
594
634
  this,
595
635
  this.config.env.websockets,
596
- (role, socket) => this._onSocketConnection(role, socket),
636
+ (...args) => this._onSocketConnection(...args),
597
637
  );
598
638
 
599
639
  // ------------------------------------------------------------
@@ -712,6 +752,8 @@ Invalid certificate files, please check your:
712
752
  route += `${role}`;
713
753
  }
714
754
 
755
+ this.config.app.clients[role].route = route;
756
+
715
757
  // define template filename: `${role}.html` or `default.html`
716
758
  const {
717
759
  templatePath,
@@ -745,6 +787,12 @@ Invalid certificate files, please check your:
745
787
  const soundworksClientHandler = (req, res) => {
746
788
  const data = clientConfigFunction(role, this.config, req);
747
789
 
790
+ // if the client has gone through the connection middleware (add succedeed),
791
+ // add the token to the data object
792
+ if (res.swToken) {
793
+ data.token = res.swToken;
794
+ }
795
+
748
796
  // CORS / COOP / COEP headers for `crossOriginIsolated pages,
749
797
  // enables `sharedArrayBuffers` and high precision timers
750
798
  // cf. https://web.dev/why-coop-coep/
@@ -759,6 +807,7 @@ Invalid certificate files, please check your:
759
807
  const appIndex = tmpl(data);
760
808
  res.end(appIndex);
761
809
  };
810
+
762
811
  // http request
763
812
  router.get(route, soundworksClientHandler);
764
813
 
@@ -770,18 +819,38 @@ Invalid certificate files, please check your:
770
819
  * Socket connection callback.
771
820
  * @private
772
821
  */
773
- _onSocketConnection(role, socket) {
822
+ _onSocketConnection(role, socket, connectionToken) {
774
823
  const client = new Client(role, socket);
775
824
  const roles = Object.keys(this.config.app.clients);
776
825
 
826
+ // this has been validated
827
+ if (this.isProtected(role) && this.isValidConnectionToken(connectionToken)) {
828
+ const { ip } = decryptData(connectionToken);
829
+ const newData = {
830
+ ip: ip,
831
+ id: client.id,
832
+ };
833
+
834
+ const newToken = encryptData(newData);
835
+
836
+ client.token = newToken;
837
+
838
+ this._pendingConnectionTokens.delete(connectionToken);
839
+ this._trustedClients.add(client);
840
+ }
841
+
777
842
  socket.addListener('close', async () => {
778
- // do nothin if client type was invalid
843
+ // do nothing if client role is invalid
779
844
  if (roles.includes(role)) {
780
845
  // decrement audit state counter
781
846
  const numClients = this._auditState.get('numClients');
782
847
  numClients[role] -= 1;
783
848
  this._auditState.set({ numClients });
784
849
 
850
+ // delete token
851
+ if (this._trustedClients.has(client)) {
852
+ this._trustedClients.delete(client);
853
+ }
785
854
  // clean context manager, await before cleaning state manager
786
855
  await this.contextManager.removeClient(client);
787
856
  // remove client from pluginManager
@@ -834,8 +903,8 @@ Invalid certificate files, please check your:
834
903
  // add client to context manager
835
904
  await this.contextManager.addClient(client);
836
905
 
837
- const { id, uuid } = client;
838
- socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid });
906
+ const { id, uuid, token } = client;
907
+ socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid, token });
839
908
  });
840
909
  }
841
910
 
@@ -912,6 +981,27 @@ Invalid certificate files, please check your:
912
981
  * you should rely on these defaults._
913
982
  */
914
983
  useDefaultApplicationTemplate() {
984
+ const buildDirectory = path.join('.build', 'public');
985
+
986
+ const useMinifiedFile = {};
987
+ const roles = Object.keys(this.config.app.clients);
988
+
989
+ roles.forEach(role => {
990
+ if (this.config.env.type === 'production') {
991
+ // check if minified file exists
992
+ const minifiedFilePath = path.join(buildDirectory, `${role}.min.js`);
993
+
994
+ if (fs.existsSync(minifiedFilePath)) {
995
+ useMinifiedFile[role] = true;
996
+ } else {
997
+ console.log(chalk.yellow(` > Minified file not found for client "${role}", falling back to normal build file (use \`npm run build:production && npm start\` to use minified files)`));
998
+ useMinifiedFile[role] = false;
999
+ }
1000
+ } else {
1001
+ useMinifiedFile[role] = false;
1002
+ }
1003
+ });
1004
+
915
1005
  this._applicationTemplateOptions = {
916
1006
  templateEngine: { compile },
917
1007
  templatePath: path.join('.build', 'server', 'tmpl'),
@@ -926,13 +1016,14 @@ Invalid certificate files, please check your:
926
1016
  type: config.env.type,
927
1017
  websockets: config.env.websockets,
928
1018
  subpath: config.env.subpath,
1019
+ useMinifiedFile: useMinifiedFile[role],
929
1020
  },
930
1021
  };
931
1022
  },
932
1023
  };
933
1024
 
934
1025
  this.router.use(express.static('public'));
935
- this.router.use('/build', express.static(path.join('.build', 'public')));
1026
+ this.router.use('/build', express.static(buildDirectory));
936
1027
  }
937
1028
 
938
1029
  /**
@@ -968,6 +1059,81 @@ Invalid certificate files, please check your:
968
1059
 
969
1060
  return this._auditState;
970
1061
  }
1062
+
1063
+ /** @private */
1064
+ isProtected(role) {
1065
+ if (this.config.env.auth && Array.isArray(this.config.env.auth.clients)) {
1066
+ return this.config.env.auth.clients.includes(role);
1067
+ }
1068
+
1069
+ return false;
1070
+ }
1071
+
1072
+ /** @private */
1073
+ isValidConnectionToken(token) {
1074
+ // token should be in pending token list
1075
+ if (!this._pendingConnectionTokens.has(token)) {
1076
+ return false;
1077
+ }
1078
+
1079
+ // check the token is not too old
1080
+ const data = decryptData(token);
1081
+ const now = getTime();
1082
+
1083
+ // token is valid only for 30 seconds (this is arbitrary)
1084
+ if (now > data.time + TOKEN_VALID_DURATION) {
1085
+ // delete the token, is too old
1086
+ this._pendingConnectionTokens.delete(token);
1087
+ return false;
1088
+ } else {
1089
+ return true;
1090
+ }
1091
+ }
1092
+
1093
+ /**
1094
+ * Check if the given client is trusted, i.e. config.env.type == 'production'
1095
+ * and the client is protected behind a password.
1096
+ *
1097
+ * @param {server.Client} client - Client to be tested
1098
+ * @returns {Boolean}
1099
+ */
1100
+ isTrustedClient(client) {
1101
+ if (this.config.env.type !== 'production') {
1102
+ return true;
1103
+ } else {
1104
+ return this._trustedClients.has(client);
1105
+ }
1106
+ }
1107
+
1108
+ /**
1109
+ * Check if the token from a client is trusted, i.e. config.env.type == 'production'
1110
+ * and the client is protected behind a password.
1111
+ *
1112
+ * @param {Number} clientId - Id of the client
1113
+ * @param {Number} clientIp - Ip of the client
1114
+ * @param {String} token - Token to be tested
1115
+ * @returns {Boolean}
1116
+ */
1117
+ // for stateless interactions, e.g. POST files
1118
+ isTrustedToken(clientId, clientIp, token) {
1119
+ if (this.config.env.type !== 'production') {
1120
+ return true;
1121
+ } else {
1122
+ for (let client of this._trustedClients) {
1123
+ if (client.id === clientId && client.token === token) {
1124
+ // check that given token is consistent with client ip and id
1125
+ const { id, ip } = decryptData(client.token);
1126
+
1127
+ if (clientId === id && clientIp === ip) {
1128
+ return true;
1129
+ }
1130
+ }
1131
+
1132
+ }
1133
+
1134
+ return false;
1135
+ }
1136
+ }
971
1137
  }
972
1138
 
973
1139
  export default Server;
@@ -0,0 +1,36 @@
1
+ import BaseSharedStateCollection from '../common/BaseSharedStateCollection.js';
2
+
3
+ // this file exists only for documentation purposesSharedStateCollection.js
4
+
5
+ /**
6
+ * @callback server.SharedStateCollection~onUpdateCallback
7
+ * @param {server.SharedState} state - State that triggered the update.
8
+ * @param {Object} newValues - Key / value pairs of the updates that have been
9
+ * applied to the state.
10
+ * @param {Object} oldValues - Key / value pairs of the updated params before
11
+ * the updates has been applied to the state.
12
+ * @param {Mixed} [context=null] - Optionnal context object that has been passed
13
+ * with the values updates in the `set` call.
14
+ */
15
+
16
+ /**
17
+ * The `SharedStateCollection` interface represent a collection of all states
18
+ * created from a given schema name on the network, at the execption of the ones
19
+ * created by the current node.
20
+ *
21
+ * ```
22
+ * const collection = await server.stateManager.getCollection('my-schema');
23
+ * const allValues = collection.getValues();
24
+ * collection.onUpdate((state, newValues, oldValues, context) => {
25
+ * // do something
26
+ * });
27
+ * ```
28
+ * @memberof server
29
+ * @extends BaseSharedStateCollection
30
+ * @inheritdoc
31
+ * @hideconstructor
32
+ */
33
+ class SharedStateCollection extends BaseSharedStateCollection {}
34
+
35
+ export default SharedStateCollection;
36
+
@@ -2,9 +2,9 @@ import { Worker } from 'node:worker_threads';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
+ import querystring from 'querystring';
5
6
  import { WebSocketServer } from 'ws';
6
7
  import WebSocket from 'ws';
7
- import querystring from 'querystring';
8
8
 
9
9
  import Socket from './Socket.js';
10
10
 
@@ -69,13 +69,13 @@ class Sockets {
69
69
 
70
70
  // init ws server
71
71
  this._wss = new WebSocketServer({
72
- server: server.httpServer,
72
+ noServer: true,
73
73
  path: `/${config.path}`, // @note - update according to existing config files (aka cosima-apps)
74
74
  });
75
75
 
76
76
  this._wss.on('connection', (ws, req) => {
77
77
  const queryString = querystring.decode(req.url.split('?')[1]);
78
- const { role, key } = queryString;
78
+ const { role, key, token } = queryString;
79
79
  const binary = !!(parseInt(queryString.binary));
80
80
 
81
81
  if (binary) {
@@ -96,8 +96,29 @@ class Sockets {
96
96
  socket.addToRoom('*');
97
97
  socket.addToRoom(role);
98
98
 
99
- onConnectionCallback(role, socket);
99
+ onConnectionCallback(role, socket, token);
100
+ }
101
+ });
102
+
103
+ // check if client can connect
104
+ server.httpServer.on('upgrade', async (req, socket, head) => {
105
+ const queryString = querystring.decode(req.url.split('?')[1]);
106
+
107
+ const { role, token } = queryString;
108
+
109
+ if (server.isProtected(role)) {
110
+ // we don't have any ip in the upgrade request, so we just check the
111
+ // connection token is pending
112
+ const allowed = server.isValidConnectionToken(token);
113
+
114
+ if (!allowed) {
115
+ socket.destroy('not allowed');
116
+ }
100
117
  }
118
+
119
+ this._wss.handleUpgrade(req, socket, head, (ws) => {
120
+ this._wss.emit('connection', ws, req);
121
+ });
101
122
  });
102
123
  }
103
124
 
@@ -359,7 +359,9 @@ class StateManager extends BaseStateManager {
359
359
  client.transport.emit(CREATE_RESPONSE, reqId, stateId, remoteId, schemaName, sendedSchema, currentValues);
360
360
 
361
361
  this._observers.forEach(observer => {
362
- observer.transport.emit(OBSERVE_NOTIFICATION, [schemaName, stateId, nodeId]);
362
+ if (observer.id !== nodeId) {
363
+ observer.transport.emit(OBSERVE_NOTIFICATION, schemaName, stateId, nodeId);
364
+ }
363
365
  });
364
366
  } catch (err) {
365
367
  client.transport.emit(CREATE_ERROR, reqId, err.message);
@@ -428,7 +430,7 @@ class StateManager extends BaseStateManager {
428
430
  const { schemaName, id, _creatorId } = state;
429
431
  // only track application states
430
432
  // (e.g. do not propagate infos about audit state)
431
- if (!PRIVATE_STATES.includes(schemaName)) {
433
+ if (!PRIVATE_STATES.includes(schemaName) && _creatorId !== client.id) {
432
434
  statesInfos.push([schemaName, id, _creatorId]);
433
435
  }
434
436
  });
@@ -0,0 +1,38 @@
1
+ import crypto from 'node:crypto';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ // adapted from https://dev.to/jobizil/encrypt-and-decrypt-data-in-nodejs-using-aes-256-cbc-2l6d
5
+ // generate a new key and init vector each time the server restart, we don't need
6
+ // any persistency here
7
+ const secretKey = uuidv4();
8
+ const secretIv = uuidv4();
9
+ const encryptionMethod = 'aes-256-cbc';
10
+
11
+ const key = crypto
12
+ .createHash('sha512')
13
+ .update(secretKey)
14
+ .digest('hex')
15
+ .substring(0, 32);
16
+
17
+ const encryptionIV = crypto
18
+ .createHash('sha512')
19
+ .update(secretIv)
20
+ .digest('hex')
21
+ .substring(0, 16);
22
+
23
+
24
+ export function encryptData(obj) {
25
+ const data = JSON.stringify(obj);
26
+ const cipher = crypto.createCipheriv(encryptionMethod, key, encryptionIV)
27
+ return Buffer.from(
28
+ cipher.update(data, 'utf8', 'hex') + cipher.final('hex')
29
+ ).toString('base64')
30
+ }
31
+
32
+ export function decryptData(encryptedData) {
33
+ const buff = Buffer.from(encryptedData, 'base64')
34
+ const decipher = crypto.createDecipheriv(encryptionMethod, key, encryptionIV)
35
+
36
+ const decrypted = `${decipher.update(buff.toString('utf8'), 'hex', 'utf8')}${decipher.final('utf8')}`
37
+ return JSON.parse(decrypted);
38
+ }
@@ -1,15 +0,0 @@
1
- name: "Unit Tests"
2
- on: push
3
-
4
- jobs:
5
- test:
6
- runs-on: ubuntu-latest
7
- steps:
8
- - name: Checkout
9
- uses: actions/checkout@v3.3.0
10
- - name: 'Install Node'
11
- uses: actions/setup-node@v3.6.0
12
- - name: Install dependencies
13
- run: npm install
14
- - name: "Run Test"
15
- run: npm test -- tests/**.spec.js