@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.
- package/.github/workflows/gh-pages.yml +31 -0
- package/.github/workflows/unit-tests.yml +18 -0
- package/package.json +4 -5
- package/src/client/Client.js +8 -1
- package/src/client/SharedStateCollection.js +35 -0
- package/src/client/Socket.js +16 -8
- package/src/common/BaseSharedState.js +48 -13
- package/src/common/BaseSharedStateCollection.js +179 -0
- package/src/common/BaseStateManager.js +70 -16
- package/src/common/ParameterBag.js +63 -16
- package/src/server/Client.js +6 -0
- package/src/server/Server.js +183 -17
- package/src/server/SharedStateCollection.js +36 -0
- package/src/server/Sockets.js +25 -4
- package/src/server/StateManager.js +4 -2
- package/src/server/crypto.js +38 -0
- package/.github/workflows/main.yml +0 -15
|
@@ -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.
|
|
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": "
|
|
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
|
|
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",
|
package/src/client/Client.js
CHANGED
|
@@ -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;
|
package/src/client/Socket.js
CHANGED
|
@@ -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
|
-
|
|
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'
|
|
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
|
-
|
|
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(`[
|
|
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
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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('
|
|
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
|
-
*
|
|
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|
|
|
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 -
|
|
462
|
-
*
|
|
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 -
|
|
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
|
|
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
|
-
|
|
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, (
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
*
|
|
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>} -
|
|
177
|
-
* as been executed
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
193
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
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
|
-
|
|
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') {
|
package/src/server/Client.js
CHANGED
package/src/server/Server.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
240
|
-
//
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
//
|
|
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
|
-
(
|
|
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
|
|
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(
|
|
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
|
+
|
package/src/server/Sockets.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|