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