@soundworks/core 4.0.0-alpha.15 → 4.0.0-alpha.17
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/.jsdoc +57 -0
- package/CHANGELOG.md +8 -0
- package/package.json +2 -2
- package/src/client/Context.js +9 -9
- package/src/common/BaseSharedState.js +16 -4
- package/src/common/BaseSharedStateCollection.js +62 -15
- package/src/common/BaseStateManager.js +25 -2
- package/src/common/PromiseStore.js +16 -9
- package/src/common/SharedStatePrivate.js +100 -39
- package/src/server/Sockets.js +8 -4
- package/src/server/StateManager.js +107 -77
- package/src/server/audit-network-latency.worker.js +16 -4
package/.jsdoc
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": {
|
|
3
|
+
"include": [
|
|
4
|
+
"misc/assets/DOC.md",
|
|
5
|
+
"src/client",
|
|
6
|
+
"src/server",
|
|
7
|
+
"src/common"
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
"plugins": [
|
|
11
|
+
"plugins/markdown"
|
|
12
|
+
],
|
|
13
|
+
"opts": {
|
|
14
|
+
"projectTitle": "Soundworks",
|
|
15
|
+
"destination": "./docs",
|
|
16
|
+
"encoding": "utf8",
|
|
17
|
+
"template": "./node_modules/docdash"
|
|
18
|
+
},
|
|
19
|
+
"templates": {
|
|
20
|
+
"default": {
|
|
21
|
+
"useLongnameInNav": true
|
|
22
|
+
},
|
|
23
|
+
"footer": ""
|
|
24
|
+
},
|
|
25
|
+
"docdash": {
|
|
26
|
+
"sectionOrder": [
|
|
27
|
+
"Namespaces",
|
|
28
|
+
"Classes",
|
|
29
|
+
"Modules",
|
|
30
|
+
"Externals",
|
|
31
|
+
"Events",
|
|
32
|
+
"Mixins",
|
|
33
|
+
"Tutorials",
|
|
34
|
+
"Interfaces"
|
|
35
|
+
],
|
|
36
|
+
"scripts": [
|
|
37
|
+
"assets/custom.css"
|
|
38
|
+
],
|
|
39
|
+
"meta": {
|
|
40
|
+
"title": "soundworks",
|
|
41
|
+
"description": "Documentation of soundworks, a full-stack JavaScript framework for distributed WebAudio and multimedia applications"
|
|
42
|
+
},
|
|
43
|
+
"search": true,
|
|
44
|
+
"menu": {
|
|
45
|
+
"Website": {
|
|
46
|
+
"href":"http://soundworks.dev/",
|
|
47
|
+
"target":"_blank",
|
|
48
|
+
"class":"menu-item"
|
|
49
|
+
},
|
|
50
|
+
"Github": {
|
|
51
|
+
"href": "https://github.com/collective-soundworks/soundworks",
|
|
52
|
+
"target":"_blank",
|
|
53
|
+
"class":"menu-item"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
package/CHANGELOG.md
CHANGED
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.17",
|
|
4
4
|
"description": "Open-source creative coding framework for distributed applications based on Web technologies",
|
|
5
5
|
"authors": [
|
|
6
6
|
"Benjamin Matuszewski",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"node": ">=16.0.0"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
|
-
"doc": "rm -Rf docs && jsdoc -c .jsdoc
|
|
46
|
+
"doc": "rm -Rf docs && jsdoc -c .jsdoc --verbose && cp -R misc/assets docs/",
|
|
47
47
|
"lint": "npx eslint src",
|
|
48
48
|
"test": "npx mocha",
|
|
49
49
|
"test:all": "npx mocha tests/*/*.spec.js",
|
package/src/client/Context.js
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
CONTEXT_EXIT_ERROR,
|
|
10
10
|
} from '../common/constants.js';
|
|
11
11
|
|
|
12
|
+
// share between all context, as channels are common to all contexts
|
|
13
|
+
const promiseStore = new PromiseStore('Context');
|
|
14
|
+
|
|
12
15
|
/**
|
|
13
16
|
* Base class to extend in order to implement a new Context.
|
|
14
17
|
*
|
|
@@ -77,15 +80,12 @@ class Context {
|
|
|
77
80
|
*/
|
|
78
81
|
this.status = 'idle';
|
|
79
82
|
|
|
80
|
-
/** @private */
|
|
81
|
-
this._promiseStore = new PromiseStore(this.constructor.name);
|
|
82
|
-
|
|
83
83
|
this.client.socket.addListener(CONTEXT_ENTER_RESPONSE, (reqId, contextName) => {
|
|
84
84
|
if (contextName !== this.name) {
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
promiseStore.resolve(reqId);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
this.client.socket.addListener(CONTEXT_ENTER_ERROR, (reqId, contextName, msg) => {
|
|
@@ -93,7 +93,7 @@ class Context {
|
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
promiseStore.reject(reqId, msg);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
this.client.socket.addListener(CONTEXT_EXIT_RESPONSE, (reqId, contextName) => {
|
|
@@ -101,7 +101,7 @@ class Context {
|
|
|
101
101
|
return;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
promiseStore.resolve(reqId);
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
this.client.socket.addListener(CONTEXT_EXIT_ERROR, (reqId, contextName, msg) => {
|
|
@@ -109,7 +109,7 @@ class Context {
|
|
|
109
109
|
return;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
promiseStore.reject(reqId, msg);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
this.client.contextManager.register(this);
|
|
@@ -205,7 +205,7 @@ class Context {
|
|
|
205
205
|
// we need the try/catch block to change the promise rejection into proper error
|
|
206
206
|
try {
|
|
207
207
|
await new Promise((resolve, reject) => {
|
|
208
|
-
const reqId =
|
|
208
|
+
const reqId = promiseStore.add(resolve, reject, 'enter-context');
|
|
209
209
|
this.client.socket.send(CONTEXT_ENTER_REQUEST, reqId, this.name);
|
|
210
210
|
});
|
|
211
211
|
} catch (err) {
|
|
@@ -239,7 +239,7 @@ class Context {
|
|
|
239
239
|
// we need the try/catch block to change the promise rejection into proper error
|
|
240
240
|
try {
|
|
241
241
|
await new Promise((resolve, reject) => {
|
|
242
|
-
const reqId =
|
|
242
|
+
const reqId = promiseStore.add(resolve, reject, 'exit-context');
|
|
243
243
|
this.client.socket.send(CONTEXT_EXIT_REQUEST, reqId, this.name);
|
|
244
244
|
});
|
|
245
245
|
} catch (err) {
|
|
@@ -15,6 +15,14 @@ import {
|
|
|
15
15
|
UPDATE_NOTIFICATION,
|
|
16
16
|
} from './constants.js';
|
|
17
17
|
|
|
18
|
+
// @todo - replace isOwner by `type`:
|
|
19
|
+
// enum {
|
|
20
|
+
// 'owner'
|
|
21
|
+
// 'attached'
|
|
22
|
+
// 'collection-controlller'
|
|
23
|
+
// 'collection-attached'
|
|
24
|
+
// }
|
|
25
|
+
|
|
18
26
|
class BaseSharedState {
|
|
19
27
|
constructor(id, remoteId, schemaName, schema, client, isOwner, manager, initValues = {}) {
|
|
20
28
|
/** @private */
|
|
@@ -312,6 +320,10 @@ ${JSON.stringify(initValues, null, 2)}`);
|
|
|
312
320
|
* const updates = await state.set({ myParam: Math.random() });
|
|
313
321
|
*/
|
|
314
322
|
async set(updates, context = null) {
|
|
323
|
+
if (this._detached) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
315
327
|
if (!isPlainObject(updates)) {
|
|
316
328
|
throw new ReferenceError(`[SharedState] State "${this.schemaName}": state.set(updates[, context]) should receive an object as first parameter`);
|
|
317
329
|
}
|
|
@@ -429,12 +441,12 @@ ${JSON.stringify(initValues, null, 2)}`);
|
|
|
429
441
|
}
|
|
430
442
|
|
|
431
443
|
/**
|
|
432
|
-
*
|
|
444
|
+
* Definition of schema from which the state has been created.
|
|
433
445
|
*
|
|
434
|
-
* @param {string} [name=null] - If given, returns only the definition
|
|
435
|
-
* to the given param name.
|
|
446
|
+
* @param {string} [name=null] - If given, returns only the definition
|
|
447
|
+
* corresponding to the given param name.
|
|
436
448
|
* @throws Throws if `name` does not correspond to an existing field
|
|
437
|
-
* of the
|
|
449
|
+
* of the schema.
|
|
438
450
|
* @return {object}
|
|
439
451
|
* @example
|
|
440
452
|
* const schema = state.getSchema();
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
kCreateCollectionController,
|
|
3
|
+
kAttachInCollection,
|
|
4
|
+
} from './BaseStateManager.js';
|
|
5
|
+
|
|
6
|
+
/** @private */
|
|
4
7
|
class BaseSharedStateCollection {
|
|
5
8
|
constructor(stateManager, schemaName, options = {}) {
|
|
6
9
|
this._stateManager = stateManager;
|
|
7
10
|
this._schemaName = schemaName;
|
|
8
11
|
this._options = Object.assign({ excludeLocal: false }, options);
|
|
9
12
|
|
|
13
|
+
this._controller = null;
|
|
10
14
|
this._states = [];
|
|
11
15
|
|
|
12
16
|
this._onUpdateCallbacks = new Set();
|
|
@@ -15,10 +19,17 @@ class BaseSharedStateCollection {
|
|
|
15
19
|
this._unobserve = null;
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
/** @private */
|
|
18
23
|
async _init() {
|
|
19
|
-
this.
|
|
20
|
-
|
|
24
|
+
this._controller = await this._stateManager[kCreateCollectionController](this._schemaName);
|
|
25
|
+
this._controller.onUpdate(async (updates, context) => {
|
|
26
|
+
for (let state of this._states) {
|
|
27
|
+
await state._commit(updates, context, true, false);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
21
30
|
|
|
31
|
+
this._unobserve = await this._stateManager.observe(this._schemaName, async (schemaName, stateId) => {
|
|
32
|
+
const state = await this._stateManager[kAttachInCollection](schemaName, stateId);
|
|
22
33
|
this._states.push(state);
|
|
23
34
|
|
|
24
35
|
state.onDetach(() => {
|
|
@@ -55,17 +66,38 @@ class BaseSharedStateCollection {
|
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
/**
|
|
58
|
-
*
|
|
59
|
-
* @type {
|
|
69
|
+
* Name of the schema from which the collection has been created.
|
|
70
|
+
* @type {String}
|
|
71
|
+
* @readonly
|
|
60
72
|
*/
|
|
61
|
-
|
|
62
|
-
this.
|
|
63
|
-
|
|
73
|
+
get schemaName() {
|
|
74
|
+
return this._controller.schemaName;
|
|
75
|
+
}
|
|
64
76
|
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Definition of schema from which the collection has been created.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} [name=null] - If given, returns only the definition
|
|
81
|
+
* corresponding to the given param name.
|
|
82
|
+
* @throws Throws if `name` does not correspond to an existing field
|
|
83
|
+
* of the schema.
|
|
84
|
+
* @return {object}
|
|
85
|
+
* @example
|
|
86
|
+
* const schema = collection.getSchema();
|
|
87
|
+
*/
|
|
88
|
+
getSchema(name = null) {
|
|
89
|
+
return this._controller.getSchema(name);
|
|
90
|
+
}
|
|
67
91
|
|
|
68
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Get the default values as declared in the schema.
|
|
94
|
+
*
|
|
95
|
+
* @return {object}
|
|
96
|
+
* @example
|
|
97
|
+
* const defaults = state.getDefaults();
|
|
98
|
+
*/
|
|
99
|
+
getDefaults() {
|
|
100
|
+
return this._controller.getDefaults();
|
|
69
101
|
}
|
|
70
102
|
|
|
71
103
|
/**
|
|
@@ -94,8 +126,7 @@ class BaseSharedStateCollection {
|
|
|
94
126
|
* current call and will be passed as third argument to all update listeners.
|
|
95
127
|
*/
|
|
96
128
|
async set(updates, context = null) {
|
|
97
|
-
|
|
98
|
-
return Promise.all(promises);
|
|
129
|
+
return await this._controller.set(updates, context);
|
|
99
130
|
}
|
|
100
131
|
|
|
101
132
|
/**
|
|
@@ -156,6 +187,22 @@ class BaseSharedStateCollection {
|
|
|
156
187
|
return () => this._onDetachCallbacks.delete(callback);
|
|
157
188
|
}
|
|
158
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Detach from the collection, i.e. detach all underlying shared states.
|
|
192
|
+
* @type {number}
|
|
193
|
+
*/
|
|
194
|
+
async detach() {
|
|
195
|
+
this._unobserve();
|
|
196
|
+
this._onUpdateCallbacks.clear();
|
|
197
|
+
|
|
198
|
+
await this._controller.delete();
|
|
199
|
+
|
|
200
|
+
const promises = this._states.map(state => state.detach());
|
|
201
|
+
await Promise.all(promises);
|
|
202
|
+
|
|
203
|
+
this._onDetachCallbacks.clear();
|
|
204
|
+
}
|
|
205
|
+
|
|
159
206
|
/**
|
|
160
207
|
* Execute the given function once for each states of the collection (see `Array.forEach`).
|
|
161
208
|
*
|
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
DELETE_SCHEMA,
|
|
18
18
|
} from './constants.js';
|
|
19
19
|
|
|
20
|
+
export const kCreateCollectionController = Symbol('BaseStateManager::createCollectionController');
|
|
21
|
+
export const kAttachInCollection = Symbol('BaseStateManager::attachInCollection');
|
|
22
|
+
|
|
20
23
|
/**
|
|
21
24
|
* @private
|
|
22
25
|
*/
|
|
@@ -176,12 +179,24 @@ class BaseStateManager {
|
|
|
176
179
|
*/
|
|
177
180
|
async create(schemaName, initValues = {}) {
|
|
178
181
|
return new Promise((resolve, reject) => {
|
|
179
|
-
const reqId = this._promiseStore.add(resolve, reject, 'create-
|
|
182
|
+
const reqId = this._promiseStore.add(resolve, reject, 'create-request');
|
|
180
183
|
const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
|
|
181
184
|
this.client.transport.emit(CREATE_REQUEST, reqId, schemaName, requireSchema, initValues);
|
|
182
185
|
});
|
|
183
186
|
}
|
|
184
187
|
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @private
|
|
191
|
+
*/
|
|
192
|
+
async [kCreateCollectionController](schemaName) {
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
const reqId = this._promiseStore.add(resolve, reject, 'create-collection-controller-request');
|
|
195
|
+
const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
|
|
196
|
+
this.client.transport.emit(CREATE_REQUEST, reqId, schemaName, requireSchema, {}, true);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
185
200
|
/**
|
|
186
201
|
* Attach to an existing `SharedState` instance.
|
|
187
202
|
*
|
|
@@ -198,13 +213,20 @@ class BaseStateManager {
|
|
|
198
213
|
*/
|
|
199
214
|
async attach(schemaName, stateId = null) {
|
|
200
215
|
return new Promise((resolve, reject) => {
|
|
201
|
-
// @todo - add a timeout
|
|
202
216
|
const reqId = this._promiseStore.add(resolve, reject, 'attach-request');
|
|
203
217
|
const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
|
|
204
218
|
this.client.transport.emit(ATTACH_REQUEST, reqId, schemaName, stateId, requireSchema);
|
|
205
219
|
});
|
|
206
220
|
}
|
|
207
221
|
|
|
222
|
+
async [kAttachInCollection](schemaName, stateId) {
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
const reqId = this._promiseStore.add(resolve, reject, 'attach-in-collection-request');
|
|
225
|
+
const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
|
|
226
|
+
this.client.transport.emit(ATTACH_REQUEST, reqId, schemaName, stateId, requireSchema, true);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
208
230
|
/**
|
|
209
231
|
* Observe all the `SharedState` instances that are created on the network.
|
|
210
232
|
* This can be usefull for clients with some controller role that might want to track
|
|
@@ -366,6 +388,7 @@ class BaseStateManager {
|
|
|
366
388
|
try {
|
|
367
389
|
await collection._init();
|
|
368
390
|
} catch (err) {
|
|
391
|
+
console.log(err.message)
|
|
369
392
|
throw new Error(`Cannot create collection, schema "${schemaName}" does not exists`);
|
|
370
393
|
}
|
|
371
394
|
|
|
@@ -1,32 +1,39 @@
|
|
|
1
1
|
import { idGenerator } from '@ircam/sc-utils';
|
|
2
2
|
|
|
3
|
-
const generateRequestId = idGenerator();
|
|
4
|
-
|
|
5
3
|
export default class PromiseStore {
|
|
6
4
|
constructor(name) {
|
|
7
5
|
this.name = name;
|
|
8
6
|
this.store = new Map();
|
|
7
|
+
this.generateRequestId = idGenerator();
|
|
9
8
|
}
|
|
10
9
|
|
|
11
10
|
add(resolve, reject, type) {
|
|
12
|
-
const reqId = generateRequestId.next().value;
|
|
11
|
+
const reqId = this.generateRequestId.next().value;
|
|
13
12
|
this.store.set(reqId, { resolve, reject, type });
|
|
14
13
|
|
|
15
14
|
return reqId;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
resolve(reqId, data) {
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
if (this.store.has(reqId)) {
|
|
19
|
+
const { resolve } = this.store.get(reqId);
|
|
20
|
+
this.store.delete(reqId);
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
resolve(data);
|
|
23
|
+
} else {
|
|
24
|
+
throw new Error(`[${this.name}] cannot resolve request id (${reqId}), id does not exist`);
|
|
25
|
+
}
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
reject(reqId, msg) {
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
if (this.store.has(reqId)) {
|
|
30
|
+
const { reject } = this.store.get(reqId);
|
|
31
|
+
this.store.delete(reqId);
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
reject(new Error(msg));
|
|
34
|
+
} else {
|
|
35
|
+
throw new Error(`[${this.name}] cannot reject request id (${reqId}), id does not exist`);
|
|
36
|
+
}
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
// reject all pendeing request
|
|
@@ -13,13 +13,14 @@ import {
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* The "real" state, this instance is kept private by the server.StateManager.
|
|
16
|
-
* It
|
|
16
|
+
* It can only be accessed through a SharedState proxy.
|
|
17
17
|
* @private
|
|
18
18
|
*/
|
|
19
19
|
class SharedStatePrivate {
|
|
20
20
|
constructor(id, schemaName, schema, manager, initValues = {}) {
|
|
21
21
|
this.id = id;
|
|
22
22
|
this.schemaName = schemaName;
|
|
23
|
+
this.isCollectionController = false;
|
|
23
24
|
|
|
24
25
|
this._manager = manager;
|
|
25
26
|
this._parameters = new ParameterBag(schema, initValues);
|
|
@@ -29,10 +30,16 @@ class SharedStatePrivate {
|
|
|
29
30
|
this._creatorId = null;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
_attachClient(remoteId, client, isOwner = false) {
|
|
33
|
-
|
|
33
|
+
_attachClient(remoteId, client, isOwner = false, inCollection = false) {
|
|
34
|
+
|
|
35
|
+
// isOwner && inCollection -> collection controller
|
|
36
|
+
// !isOwner && inCollection -> attached in collection
|
|
37
|
+
|
|
38
|
+
const clientInfos = { client, isOwner, inCollection };
|
|
39
|
+
this._attachedClients.set(remoteId, clientInfos);
|
|
34
40
|
|
|
35
41
|
if (isOwner) {
|
|
42
|
+
this.isCollectionController = inCollection;
|
|
36
43
|
this._creatorRemoteId = remoteId;
|
|
37
44
|
this._creatorId = client.id;
|
|
38
45
|
}
|
|
@@ -89,44 +96,97 @@ class SharedStatePrivate {
|
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
if (hasUpdates) {
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
99
|
+
// Collection Controller logic
|
|
100
|
+
if (isOwner && inCollection) {
|
|
101
|
+
// notify the requester back
|
|
102
|
+
// no need to check for attached clients as it is not observable
|
|
103
|
+
client.transport.emit(
|
|
104
|
+
`${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
|
|
105
|
+
reqId, filteredUpdates, context
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// loop through all private states
|
|
109
|
+
for (let [stateId, state] of this._manager._sharedStatePrivateById) {
|
|
110
|
+
// pick all states with same schema name and not this
|
|
111
|
+
if (state !== this && this.schemaName === state.schemaName) {
|
|
112
|
+
// notify all attached clients except those who belong to a collection
|
|
113
|
+
// i.e. !isOwner && inCollection, they will be notified by their own
|
|
114
|
+
// collection controller
|
|
115
|
+
for (let [remoteId, clientInfos] of state._attachedClients) {
|
|
116
|
+
const { client, isOwner, inCollection } = clientInfos;
|
|
117
|
+
|
|
118
|
+
if (!clientInfos.isOwner && clientInfos.inCollection) {
|
|
119
|
+
continue;
|
|
120
|
+
} else {
|
|
121
|
+
const peer = clientInfos.client;
|
|
122
|
+
|
|
123
|
+
peer.transport.emit(
|
|
124
|
+
`${UPDATE_NOTIFICATION}-${state.id}-${remoteId}`,
|
|
125
|
+
filteredUpdates, context
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
113
132
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
133
|
+
// Normal case
|
|
134
|
+
|
|
135
|
+
// We need to handle cases where:
|
|
136
|
+
// - client state (client.id: 2) sends a request
|
|
137
|
+
// - server attached state (client.id: -1) spot a problem and overrides the value
|
|
138
|
+
// We want the remote client (id: 2) to receive in the right order:
|
|
139
|
+
// * 1. the value it requested,
|
|
140
|
+
// * 2. the value overriden by the server-side attached state (id: -1)
|
|
141
|
+
//
|
|
142
|
+
// such problem is now better solved with the the upateHook system, none
|
|
143
|
+
// nonetheway we don't want to introduce inconsistencies here
|
|
144
|
+
//
|
|
145
|
+
// Then we propagate server-side last, because as the server transport
|
|
146
|
+
// is synchronous it can break ordering if a subscription function makes
|
|
147
|
+
// itself an update in reaction to an update. Propagating to server last
|
|
148
|
+
// alllows to maintain network messages order consistent.
|
|
149
|
+
|
|
150
|
+
// @note - remoteId correspond to unique remote state id
|
|
151
|
+
|
|
152
|
+
// propagate RESPONSE to the client that originates the request if not the server
|
|
153
|
+
if (client.id !== -1) {
|
|
154
|
+
client.transport.emit(
|
|
155
|
+
`${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
|
|
156
|
+
reqId, filteredUpdates, context
|
|
157
|
+
);
|
|
118
158
|
}
|
|
119
|
-
}
|
|
120
159
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
160
|
+
// propagate NOTIFICATION to all other attached clients except server
|
|
161
|
+
for (let [peerRemoteId, clientInfos] of this._attachedClients) {
|
|
162
|
+
const peer = clientInfos.client;
|
|
163
|
+
|
|
164
|
+
if (remoteId !== peerRemoteId && peer.id !== -1) {
|
|
165
|
+
peer.transport.emit(
|
|
166
|
+
`${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
|
|
167
|
+
filteredUpdates, context
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// propagate RESPONSE to server if it is the requester
|
|
173
|
+
if (client.id === -1) {
|
|
174
|
+
client.transport.emit(
|
|
175
|
+
`${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
|
|
176
|
+
reqId, filteredUpdates, context
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// propagate NOTIFICATION other attached state that belongs to server
|
|
181
|
+
for (let [peerRemoteId, clientInfos] of this._attachedClients) {
|
|
182
|
+
const peer = clientInfos.client;
|
|
125
183
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
184
|
+
if (remoteId !== peerRemoteId && peer.id === -1) {
|
|
185
|
+
peer.transport.emit(
|
|
186
|
+
`${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
|
|
187
|
+
filteredUpdates, context
|
|
188
|
+
);
|
|
189
|
+
}
|
|
130
190
|
}
|
|
131
191
|
}
|
|
132
192
|
} else {
|
|
@@ -149,14 +209,15 @@ class SharedStatePrivate {
|
|
|
149
209
|
if (isOwner) {
|
|
150
210
|
// delete only if creator
|
|
151
211
|
client.transport.addListener(`${DELETE_REQUEST}-${this.id}-${remoteId}`, (reqId) => {
|
|
152
|
-
this._manager.
|
|
212
|
+
this._manager._sharedStatePrivateById.delete(this.id);
|
|
153
213
|
// --------------------------------------------------------------------
|
|
154
214
|
// WARNING - MAKE SURE WE DON'T HAVE PROBLEM W/ THAT
|
|
155
215
|
// --------------------------------------------------------------------
|
|
156
216
|
// @todo - propagate server-side last, because if a subscription function sends a
|
|
157
217
|
// message to a client, network messages order are kept coherent
|
|
158
218
|
// this._subscriptions.forEach(func => func(updated));
|
|
159
|
-
for (let [remoteId,
|
|
219
|
+
for (let [remoteId, clientInfos] of this._attachedClients) {
|
|
220
|
+
const attached = clientInfos.client;
|
|
160
221
|
this._detachClient(remoteId, attached);
|
|
161
222
|
|
|
162
223
|
if (remoteId === this._creatorRemoteId) {
|
package/src/server/Sockets.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { Worker } from 'node:worker_threads';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
+
// import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
5
|
import querystring from 'querystring';
|
|
6
6
|
import { WebSocketServer } from 'ws';
|
|
7
7
|
import WebSocket from 'ws';
|
|
8
8
|
|
|
9
9
|
import Socket from './Socket.js';
|
|
10
|
+
import networkLatencyWorker from './audit-network-latency.worker.js';
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
// this crashes when bundling server to cjs module for Max, fallback to
|
|
13
|
+
// string worker for now
|
|
14
|
+
// const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Manager all {@link server.Socket} instances.
|
|
@@ -45,8 +48,9 @@ class Sockets {
|
|
|
45
48
|
*/
|
|
46
49
|
async start(server, config, onConnectionCallback) {
|
|
47
50
|
// init audit stuff for network latency estimation
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
// @see note about __dirname
|
|
52
|
+
// const workerPathname = path.join(__dirname, 'audit-network-latency.worker.js');
|
|
53
|
+
this._latencyStatsWorker = new Worker(networkLatencyWorker, { eval: true });
|
|
50
54
|
this._auditState = await server.getAuditState();
|
|
51
55
|
|
|
52
56
|
this._auditState.onUpdate(updates => {
|
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
const generateStateId = idGenerator();
|
|
29
29
|
const generateRemoteId = idGenerator();
|
|
30
30
|
|
|
31
|
+
const kIsObservableState = Symbol('StateManager::isObservableState');
|
|
32
|
+
|
|
31
33
|
/**
|
|
32
34
|
* @typedef {object} server.StateManager~schema
|
|
33
35
|
*
|
|
@@ -315,7 +317,7 @@ class StateManager extends BaseStateManager {
|
|
|
315
317
|
super(localClientId, localTransport);
|
|
316
318
|
|
|
317
319
|
this._clientByNodeId = new Map();
|
|
318
|
-
this.
|
|
320
|
+
this._sharedStatePrivateById = new Map();
|
|
319
321
|
this._schemas = new Map();
|
|
320
322
|
this._observers = new Set();
|
|
321
323
|
this._hooksBySchemaName = new Map(); // protected
|
|
@@ -323,6 +325,12 @@ class StateManager extends BaseStateManager {
|
|
|
323
325
|
this.addClient(localClientId, localTransport);
|
|
324
326
|
}
|
|
325
327
|
|
|
328
|
+
[kIsObservableState](state) {
|
|
329
|
+
const { schemaName, isCollectionController } = state;
|
|
330
|
+
// is observable only if not in private state and not a controller state
|
|
331
|
+
return !PRIVATE_STATES.includes(schemaName) && !isCollectionController;
|
|
332
|
+
}
|
|
333
|
+
|
|
326
334
|
/**
|
|
327
335
|
* Add a client to the manager.
|
|
328
336
|
*
|
|
@@ -342,90 +350,107 @@ class StateManager extends BaseStateManager {
|
|
|
342
350
|
// ---------------------------------------------
|
|
343
351
|
// CREATE
|
|
344
352
|
// ---------------------------------------------
|
|
345
|
-
client.transport.addListener(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
353
|
+
client.transport.addListener(
|
|
354
|
+
CREATE_REQUEST,
|
|
355
|
+
(reqId, schemaName, requireSchema, initValues = {}, inCollection = false) => {
|
|
356
|
+
if (this._schemas.has(schemaName)) {
|
|
357
|
+
try {
|
|
358
|
+
const schema = this._schemas.get(schemaName);
|
|
359
|
+
const stateId = generateStateId.next().value;
|
|
360
|
+
const remoteId = generateRemoteId.next().value;
|
|
361
|
+
const state = new SharedStatePrivate(stateId, schemaName, schema, this, initValues);
|
|
362
|
+
|
|
363
|
+
// attach client to the state as owner
|
|
364
|
+
const isOwner = true;
|
|
365
|
+
state._attachClient(remoteId, client, isOwner, inCollection);
|
|
366
|
+
|
|
367
|
+
this._sharedStatePrivateById.set(stateId, state);
|
|
368
|
+
|
|
369
|
+
const currentValues = state._parameters.getValues();
|
|
370
|
+
const schemaOption = requireSchema ? schema : null;
|
|
371
|
+
|
|
372
|
+
client.transport.emit(
|
|
373
|
+
CREATE_RESPONSE,
|
|
374
|
+
reqId, stateId, remoteId, schemaName, schemaOption, currentValues
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const isObservable = this[kIsObservableState](state);
|
|
378
|
+
|
|
379
|
+
if (isObservable) {
|
|
380
|
+
this._observers.forEach(observer => {
|
|
381
|
+
observer.transport.emit(OBSERVE_NOTIFICATION, schemaName, stateId, nodeId);
|
|
382
|
+
});
|
|
366
383
|
}
|
|
367
|
-
})
|
|
368
|
-
|
|
369
|
-
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const msg = `Cannot create state "${schemaName}", ${err.message}`;
|
|
386
|
+
console.error(msg);
|
|
387
|
+
|
|
388
|
+
client.transport.emit(CREATE_ERROR, reqId, msg);
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
const msg = `Cannot create state "${schemaName}", schema does not exists`;
|
|
370
392
|
console.error(msg);
|
|
371
393
|
|
|
372
394
|
client.transport.emit(CREATE_ERROR, reqId, msg);
|
|
373
395
|
}
|
|
374
|
-
} else {
|
|
375
|
-
const msg = `Cannot create state "${schemaName}", schema does not exists`;
|
|
376
|
-
console.error(msg);
|
|
377
|
-
|
|
378
|
-
client.transport.emit(CREATE_ERROR, reqId, msg);
|
|
379
396
|
}
|
|
380
|
-
|
|
397
|
+
);
|
|
381
398
|
|
|
382
399
|
// ---------------------------------------------
|
|
383
400
|
// ATTACH (when creator, is attached by default)
|
|
384
401
|
// ---------------------------------------------
|
|
385
|
-
client.transport.addListener(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
402
|
+
client.transport.addListener(
|
|
403
|
+
ATTACH_REQUEST,
|
|
404
|
+
(reqId, schemaName, stateId = null, requireSchema = true, inCollection = false) => {
|
|
405
|
+
if (this._schemas.has(schemaName)) {
|
|
406
|
+
let state = null;
|
|
407
|
+
|
|
408
|
+
if (stateId !== null && this._sharedStatePrivateById.has(stateId)) {
|
|
409
|
+
state = this._sharedStatePrivateById.get(stateId);
|
|
410
|
+
} else if (stateId === null) {
|
|
411
|
+
// if no `stateId` given, we try to find the first state with the given
|
|
412
|
+
// `schemaName` in the list, this allow a client to attach to a global
|
|
413
|
+
// state created by the server (or some persistant client) without
|
|
414
|
+
// having to know the `stateId` (e.g. some global state...)
|
|
415
|
+
for (let existingState of this._sharedStatePrivateById.values()) {
|
|
416
|
+
if (existingState.schemaName === schemaName) {
|
|
417
|
+
state = existingState;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
400
420
|
}
|
|
401
421
|
}
|
|
402
|
-
}
|
|
403
422
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
423
|
+
if (state !== null) {
|
|
424
|
+
// @note - we use a unique remote id to allow a client to attach
|
|
425
|
+
// several times to the same state.
|
|
426
|
+
// i.e. same state -> several remote attach on the same node
|
|
427
|
+
const remoteId = generateRemoteId.next().value;
|
|
428
|
+
const isOwner = false;
|
|
429
|
+
state._attachClient(remoteId, client, isOwner, inCollection);
|
|
430
|
+
|
|
431
|
+
const currentValues = state._parameters.getValues();
|
|
432
|
+
const schema = this._schemas.get(schemaName);
|
|
433
|
+
const schemaOption = requireSchema ? schema : null;
|
|
434
|
+
|
|
435
|
+
client.transport.emit(
|
|
436
|
+
ATTACH_RESPONSE,
|
|
437
|
+
reqId, state.id, remoteId, schemaName, schemaOption, currentValues
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
} else {
|
|
441
|
+
const msg = `Cannot attach, no existing state for schema "${schemaName}" with stateId: "${stateId}"`;
|
|
442
|
+
console.error(msg);
|
|
443
|
+
|
|
444
|
+
client.transport.emit(ATTACH_ERROR, reqId, msg);
|
|
445
|
+
}
|
|
416
446
|
} else {
|
|
417
|
-
const msg = `Cannot attach,
|
|
447
|
+
const msg = `Cannot attach, schema "${schemaName}" does not exists`;
|
|
418
448
|
console.error(msg);
|
|
419
449
|
|
|
420
450
|
client.transport.emit(ATTACH_ERROR, reqId, msg);
|
|
421
451
|
}
|
|
422
|
-
} else {
|
|
423
|
-
const msg = `Cannot attach, schema "${schemaName}" does not exists`;
|
|
424
|
-
console.error(msg);
|
|
425
|
-
|
|
426
|
-
client.transport.emit(ATTACH_ERROR, reqId, msg);
|
|
427
452
|
}
|
|
428
|
-
|
|
453
|
+
);
|
|
429
454
|
|
|
430
455
|
// ---------------------------------------------
|
|
431
456
|
// OBSERVE PEERS (be notified when a state is created, lazy)
|
|
@@ -434,11 +459,11 @@ class StateManager extends BaseStateManager {
|
|
|
434
459
|
if (observedSchemaName === null || this._schemas.has(observedSchemaName)) {
|
|
435
460
|
const statesInfos = [];
|
|
436
461
|
|
|
437
|
-
this.
|
|
438
|
-
const
|
|
462
|
+
this._sharedStatePrivateById.forEach(state => {
|
|
463
|
+
const isObservable = this[kIsObservableState](state);
|
|
439
464
|
|
|
440
|
-
|
|
441
|
-
|
|
465
|
+
if (isObservable) {
|
|
466
|
+
const { schemaName, id, _creatorId } = state;
|
|
442
467
|
statesInfos.push([schemaName, id, _creatorId]);
|
|
443
468
|
}
|
|
444
469
|
});
|
|
@@ -470,18 +495,22 @@ class StateManager extends BaseStateManager {
|
|
|
470
495
|
* @private
|
|
471
496
|
*/
|
|
472
497
|
removeClient(nodeId) {
|
|
473
|
-
for (let [_id, state] of this.
|
|
498
|
+
for (let [_id, state] of this._sharedStatePrivateById.entries()) {
|
|
474
499
|
let deleteState = false;
|
|
475
500
|
|
|
476
501
|
// define if the client is the creator of the state, in which case
|
|
477
502
|
// everybody must delete it
|
|
478
|
-
for (let [remoteId,
|
|
503
|
+
for (let [remoteId, clientInfos] of state._attachedClients) {
|
|
504
|
+
const attachedClient = clientInfos.client;
|
|
505
|
+
|
|
479
506
|
if (nodeId === attachedClient.id && remoteId === state._creatorRemoteId) {
|
|
480
507
|
deleteState = true;
|
|
481
508
|
}
|
|
482
509
|
}
|
|
483
510
|
|
|
484
|
-
for (let [remoteId,
|
|
511
|
+
for (let [remoteId, clientInfos] of state._attachedClients) {
|
|
512
|
+
const attachedClient = clientInfos.client;
|
|
513
|
+
|
|
485
514
|
if (nodeId === attachedClient.id) {
|
|
486
515
|
state._detachClient(remoteId, attachedClient);
|
|
487
516
|
}
|
|
@@ -492,7 +521,7 @@ class StateManager extends BaseStateManager {
|
|
|
492
521
|
attachedClient.transport.emit(`${DELETE_NOTIFICATION}-${state.id}-${remoteId}`);
|
|
493
522
|
}
|
|
494
523
|
|
|
495
|
-
this.
|
|
524
|
+
this._sharedStatePrivateById.delete(state.id);
|
|
496
525
|
}
|
|
497
526
|
}
|
|
498
527
|
}
|
|
@@ -559,14 +588,15 @@ class StateManager extends BaseStateManager {
|
|
|
559
588
|
*/
|
|
560
589
|
deleteSchema(schemaName) {
|
|
561
590
|
// @note: deleting schema
|
|
562
|
-
for (let [_id, state] of this.
|
|
591
|
+
for (let [_id, state] of this._sharedStatePrivateById) {
|
|
563
592
|
if (state.schemaName === schemaName) {
|
|
564
|
-
for (let [remoteId,
|
|
593
|
+
for (let [remoteId, clientInfos] of state._attachedClients) {
|
|
594
|
+
const attached = clientInfos.client;
|
|
565
595
|
state._detachClient(remoteId, attached);
|
|
566
596
|
attached.transport.emit(`${DELETE_NOTIFICATION}-${state.id}-${remoteId}`);
|
|
567
597
|
}
|
|
568
598
|
|
|
569
|
-
this.
|
|
599
|
+
this._sharedStatePrivateById.delete(state.id);
|
|
570
600
|
}
|
|
571
601
|
}
|
|
572
602
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// NOTICE: we use this syntax so that the server can be bundled to cjs
|
|
2
|
+
// with esbuild, so we can ship a cjs server bundle into Max.
|
|
3
|
+
// Should move back to regular esm module once Max has fixed its loader
|
|
4
|
+
export default `
|
|
5
|
+
const { parentPort } = require('node:worker_threads');
|
|
4
6
|
|
|
5
7
|
let stack = [];
|
|
6
8
|
let averageLatencyWindow = 5;
|
|
@@ -9,12 +11,21 @@ let averageLatencyPeriod = 2;
|
|
|
9
11
|
let intervalId = null;
|
|
10
12
|
let meanLatency = 0;
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
let getTime;
|
|
15
|
+
let inited = new Promise(async (resolve) => {
|
|
16
|
+
module = await import('@ircam/sc-utils');
|
|
17
|
+
getTime = module.getTime;
|
|
18
|
+
resolve(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
parentPort.on('message', async msg => {
|
|
13
22
|
switch (msg.type) {
|
|
14
23
|
case 'config': {
|
|
15
24
|
averageLatencyWindow = msg.value.averageLatencyWindow;
|
|
16
25
|
averageLatencyPeriod = msg.value.averageLatencyPeriod;
|
|
17
26
|
|
|
27
|
+
await inited;
|
|
28
|
+
|
|
18
29
|
// launch compute in its own loop so that the number of computation is
|
|
19
30
|
// decoupled from the number of connected clients
|
|
20
31
|
clearInterval(intervalId);
|
|
@@ -55,3 +66,4 @@ function computeAverageLatency() {
|
|
|
55
66
|
});
|
|
56
67
|
}
|
|
57
68
|
}
|
|
69
|
+
`;
|