@soundworks/core 4.0.0-alpha.14 → 4.0.0-alpha.15
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 +4 -0
- package/README.md +13 -28
- package/package.json +17 -23
- package/src/client/Context.js +10 -11
- package/src/client/SharedStateCollection.js +5 -2
- package/src/client/Socket.js +0 -1
- package/src/common/BasePlugin.js +1 -1
- package/src/common/BasePluginManager.js +1 -1
- package/src/common/BaseSharedState.js +31 -27
- package/src/common/BaseSharedStateCollection.js +19 -8
- package/src/common/BaseStateManager.js +173 -53
- package/src/common/ParameterBag.js +11 -7
- package/src/common/PromiseStore.js +41 -0
- package/src/common/SharedStatePrivate.js +24 -27
- package/src/common/constants.js +2 -1
- package/src/common/sockets-utils.js +3 -4
- package/src/server/Server.js +2 -3
- package/src/server/SharedStateCollection.js +5 -2
- package/src/server/Socket.js +1 -1
- package/src/server/StateManager.js +34 -22
- package/src/server/audit-network-latency.worker.js +1 -1
- package/src/server/crypto.js +6 -7
- package/src/common/promise-store.js +0 -25
- package/tsconfig.json +0 -21
package/CHANGELOG.md
ADDED
package/README.md
CHANGED
|
@@ -3,57 +3,38 @@
|
|
|
3
3
|
[](https://badge.fury.io/js/@soundworks%2Fcore)
|
|
4
4
|
[](http://commitizen.github.io/cz-cli/)
|
|
5
5
|
|
|
6
|
-

|
|
6
|
+

|
|
7
7
|
|
|
8
8
|
Open-source creative coding framework for distributed applications based on Web technologies.
|
|
9
9
|
|
|
10
10
|
Primarily focused on music, **soundworks** aims at supporting rapid development of real-time distributed applications using _JavaScript_. It provides abstractions to hide the complexity of the network and to foster very rapid-prototyping and trial-and-error workflows that are typical in artistic practices.
|
|
11
11
|
|
|
12
|
-
*__WARNING: The version 4 of `@soundworks/core` is under heavy development.__*
|
|
13
|
-
|
|
14
|
-
## Getting Started
|
|
15
|
-
|
|
16
|
-
The best and most simple way to start using **soundworks** is to use the `@soundworks/create` wizard.
|
|
17
|
-
|
|
18
|
-
```sh
|
|
19
|
-
npx @soundworks/create@latest
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-

|
|
23
|
-
|
|
24
|
-
See [https://soundworks.dev/tutorials/getting-started.html](https://soundworks.dev/tutorials/getting-started.html) for more informations on the wizard and how to start with **soundworks**.
|
|
25
|
-
|
|
26
|
-
<!--
|
|
27
12
|
## Documentation
|
|
28
13
|
|
|
29
14
|
- Guides and Tutorials: [https://soundworks.dev](https://soundworks.dev)
|
|
30
15
|
- API: [https://soundworks.dev/api](https://soundworks.dev/api)
|
|
31
|
-
-->
|
|
32
16
|
|
|
33
|
-
##
|
|
17
|
+
## Getting started
|
|
34
18
|
|
|
35
|
-
The
|
|
19
|
+
The best and most simple way to start using **soundworks** is to use the `@soundworks/create` wizard.
|
|
36
20
|
|
|
37
21
|
```sh
|
|
38
|
-
|
|
39
|
-
cd soundworks
|
|
40
|
-
git checkout v4
|
|
41
|
-
serve docs
|
|
22
|
+
npx @soundworks/create@latest
|
|
42
23
|
```
|
|
43
24
|
|
|
44
|
-
|
|
25
|
+

|
|
45
26
|
|
|
46
|
-
|
|
27
|
+
See [https://soundworks.dev/tutorials/getting-started.html](https://soundworks.dev/tutorials/getting-started.html) for more informations on the wizard and how to start using **soundworks**.
|
|
47
28
|
|
|
48
|
-
|
|
29
|
+
## Misc
|
|
49
30
|
|
|
50
|
-
|
|
31
|
+
### TypeScript support
|
|
51
32
|
|
|
52
33
|
Basic TypeScript support will be proposed in a (hopefully) near future.
|
|
53
34
|
|
|
54
35
|
However, for maintenance reasons, we aim at following the TC39 and W3C specifications as close as possible. Therefore, we will wait for the https://github.com/tc39/proposal-type-annotations proposal to reach stage 3 to update the source code in a more integrated manner.
|
|
55
36
|
|
|
56
|
-
|
|
37
|
+
### Manual install
|
|
57
38
|
|
|
58
39
|
Note that the `@soundworks/core` package is automatically installed when creating an application using the `@soundworks/create` wizard, so most of the time you should not have to install this package manually. See [https://soundworks.dev/guides/getting-started.html](https://soundworks.dev/guides/getting-started.html) for more informations on the **soundworks** wizard.
|
|
59
40
|
|
|
@@ -61,6 +42,10 @@ Note that the `@soundworks/core` package is automatically installed when creatin
|
|
|
61
42
|
npm install @soundworks/core
|
|
62
43
|
```
|
|
63
44
|
|
|
45
|
+
## Share with Us
|
|
46
|
+
|
|
47
|
+
If you made an application using **soundworks** please let us know here: https://github.com/collective-soundworks/soundworks/discussions/61
|
|
48
|
+
|
|
64
49
|
## Credits
|
|
65
50
|
|
|
66
51
|
[https://soundworks.dev/credits.html](https://soundworks.dev/credits.html)
|
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.15",
|
|
4
4
|
"description": "Open-source creative coding framework for distributed applications based on Web technologies",
|
|
5
5
|
"authors": [
|
|
6
6
|
"Benjamin Matuszewski",
|
|
@@ -43,51 +43,45 @@
|
|
|
43
43
|
"node": ">=16.0.0"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
|
-
"doc": "rm -Rf docs && jsdoc -c .jsdoc.json --verbose && cp -R assets docs/",
|
|
46
|
+
"doc": "rm -Rf docs && jsdoc -c .jsdoc.json --verbose && cp -R misc/assets docs/",
|
|
47
47
|
"lint": "npx eslint src",
|
|
48
|
-
"test": "mocha",
|
|
49
|
-
"
|
|
48
|
+
"test": "npx mocha",
|
|
49
|
+
"test:all": "npx mocha tests/*/*.spec.js",
|
|
50
50
|
"types": "rm -Rf types && tsc",
|
|
51
|
-
"
|
|
51
|
+
"postversion": "node ./misc/scripts/check-changelog.js"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@ircam/sc-
|
|
55
|
-
"@ircam/sc-utils": "^1.1.1",
|
|
54
|
+
"@ircam/sc-utils": "^1.3.2",
|
|
56
55
|
"chalk": "^5.3.0",
|
|
57
56
|
"columnify": "^1.6.0",
|
|
58
57
|
"compression": "^1.7.1",
|
|
59
|
-
"cross-fetch": "^3.1.8",
|
|
60
58
|
"express": "^4.18.1",
|
|
61
59
|
"fast-deep-equal": "^3.1.3",
|
|
62
60
|
"fast-text-encoding": "^1.0.0",
|
|
63
61
|
"isomorphic-ws": "^5.0.0",
|
|
64
|
-
"keyv": "^4.
|
|
65
|
-
"keyv-file": "^0.
|
|
66
|
-
"lodash
|
|
67
|
-
"lodash.merge": "^4.6.2",
|
|
62
|
+
"keyv": "^4.5.4",
|
|
63
|
+
"keyv-file": "^0.3.0",
|
|
64
|
+
"lodash": "^4.17.21",
|
|
68
65
|
"pem": "^1.14.8",
|
|
69
66
|
"template-literal": "^1.0.4",
|
|
70
|
-
"uuid": "^9.0.
|
|
71
|
-
"
|
|
72
|
-
"ws": "^8.12.0"
|
|
67
|
+
"uuid": "^9.0.1",
|
|
68
|
+
"ws": "^8.15.0"
|
|
73
69
|
},
|
|
74
70
|
"devDependencies": {
|
|
75
71
|
"@ircam/eslint-config": "^1.3.0",
|
|
76
|
-
"chai": "^4.3.
|
|
72
|
+
"chai": "^4.3.10",
|
|
77
73
|
"chai-shallow-deep-equal": "^1.4.6",
|
|
78
74
|
"commitizen": "^4.2.5",
|
|
79
75
|
"cz-conventional-changelog": "^3.3.0",
|
|
80
|
-
"docdash": "^2.0.
|
|
76
|
+
"docdash": "^2.0.2",
|
|
81
77
|
"dotenv": "^16.3.1",
|
|
82
|
-
"eslint": "^8.
|
|
83
|
-
"eslint-plugin-jsdoc": "^46.
|
|
84
|
-
"generate-changelog": "^1.8.0",
|
|
78
|
+
"eslint": "^8.55.0",
|
|
79
|
+
"eslint-plugin-jsdoc": "^46.9.0",
|
|
85
80
|
"jsdoc": "^4.0.0",
|
|
86
|
-
"markdown-toc": "^1.2.0",
|
|
87
81
|
"mocha": "^10.2.0",
|
|
88
|
-
"puppeteer": "^
|
|
82
|
+
"puppeteer": "^21.6.0",
|
|
89
83
|
"tcp-ping": "^0.1.1",
|
|
90
|
-
"typescript": "^5.
|
|
84
|
+
"typescript": "^5.3.3"
|
|
91
85
|
},
|
|
92
86
|
"optionalDependencies": {
|
|
93
87
|
"bufferutil": "^4.0.5",
|
package/src/client/Context.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Client from './Client.js';
|
|
2
|
+
import PromiseStore from '../common/PromiseStore.js';
|
|
2
3
|
import {
|
|
3
4
|
CONTEXT_ENTER_REQUEST,
|
|
4
5
|
CONTEXT_ENTER_RESPONSE,
|
|
@@ -7,11 +8,6 @@ import {
|
|
|
7
8
|
CONTEXT_EXIT_RESPONSE,
|
|
8
9
|
CONTEXT_EXIT_ERROR,
|
|
9
10
|
} from '../common/constants.js';
|
|
10
|
-
import {
|
|
11
|
-
storeRequestPromise,
|
|
12
|
-
resolveRequest,
|
|
13
|
-
rejectRequest,
|
|
14
|
-
} from '../common/promise-store.js';
|
|
15
11
|
|
|
16
12
|
/**
|
|
17
13
|
* Base class to extend in order to implement a new Context.
|
|
@@ -81,12 +77,15 @@ class Context {
|
|
|
81
77
|
*/
|
|
82
78
|
this.status = 'idle';
|
|
83
79
|
|
|
80
|
+
/** @private */
|
|
81
|
+
this._promiseStore = new PromiseStore(this.constructor.name);
|
|
82
|
+
|
|
84
83
|
this.client.socket.addListener(CONTEXT_ENTER_RESPONSE, (reqId, contextName) => {
|
|
85
84
|
if (contextName !== this.name) {
|
|
86
85
|
return;
|
|
87
86
|
}
|
|
88
87
|
|
|
89
|
-
|
|
88
|
+
this._promiseStore.resolve(reqId);
|
|
90
89
|
});
|
|
91
90
|
|
|
92
91
|
this.client.socket.addListener(CONTEXT_ENTER_ERROR, (reqId, contextName, msg) => {
|
|
@@ -94,7 +93,7 @@ class Context {
|
|
|
94
93
|
return;
|
|
95
94
|
}
|
|
96
95
|
|
|
97
|
-
|
|
96
|
+
this._promiseStore.reject(reqId, msg);
|
|
98
97
|
});
|
|
99
98
|
|
|
100
99
|
this.client.socket.addListener(CONTEXT_EXIT_RESPONSE, (reqId, contextName) => {
|
|
@@ -102,7 +101,7 @@ class Context {
|
|
|
102
101
|
return;
|
|
103
102
|
}
|
|
104
103
|
|
|
105
|
-
|
|
104
|
+
this._promiseStore.resolve(reqId);
|
|
106
105
|
});
|
|
107
106
|
|
|
108
107
|
this.client.socket.addListener(CONTEXT_EXIT_ERROR, (reqId, contextName, msg) => {
|
|
@@ -110,7 +109,7 @@ class Context {
|
|
|
110
109
|
return;
|
|
111
110
|
}
|
|
112
111
|
|
|
113
|
-
|
|
112
|
+
this._promiseStore.reject(reqId, msg);
|
|
114
113
|
});
|
|
115
114
|
|
|
116
115
|
this.client.contextManager.register(this);
|
|
@@ -206,7 +205,7 @@ class Context {
|
|
|
206
205
|
// we need the try/catch block to change the promise rejection into proper error
|
|
207
206
|
try {
|
|
208
207
|
await new Promise((resolve, reject) => {
|
|
209
|
-
const reqId =
|
|
208
|
+
const reqId = this._promiseStore.add(resolve, reject, 'enter-context');
|
|
210
209
|
this.client.socket.send(CONTEXT_ENTER_REQUEST, reqId, this.name);
|
|
211
210
|
});
|
|
212
211
|
} catch (err) {
|
|
@@ -240,7 +239,7 @@ class Context {
|
|
|
240
239
|
// we need the try/catch block to change the promise rejection into proper error
|
|
241
240
|
try {
|
|
242
241
|
await new Promise((resolve, reject) => {
|
|
243
|
-
const reqId =
|
|
242
|
+
const reqId = this._promiseStore.add(resolve, reject, 'exit-context');
|
|
244
243
|
this.client.socket.send(CONTEXT_EXIT_REQUEST, reqId, this.name);
|
|
245
244
|
});
|
|
246
245
|
} catch (err) {
|
|
@@ -15,8 +15,11 @@ import BaseSharedStateCollection from '../common/BaseSharedStateCollection.js';
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* The `SharedStateCollection` interface represent a collection of all states
|
|
18
|
-
* created from a given schema name on the network
|
|
19
|
-
*
|
|
18
|
+
* created from a given schema name on the network.
|
|
19
|
+
*
|
|
20
|
+
* It can optionnaly exclude the states created by the current node.
|
|
21
|
+
*
|
|
22
|
+
* See {@link client.StateManager#getCollection} for factory method API
|
|
20
23
|
*
|
|
21
24
|
* ```
|
|
22
25
|
* const collection = await client.stateManager.getCollection('my-schema');
|
package/src/client/Socket.js
CHANGED
package/src/common/BasePlugin.js
CHANGED
|
@@ -114,7 +114,7 @@ class BasePluginManager {
|
|
|
114
114
|
|
|
115
115
|
this.status = 'inited';
|
|
116
116
|
// instanciate all plugins
|
|
117
|
-
for (let [
|
|
117
|
+
for (let [_id, instance] of this._instances.entries()) {
|
|
118
118
|
instance.onStateChange(_values => this._propagateStateChange(instance));
|
|
119
119
|
}
|
|
120
120
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isPlainObject } from '@ircam/sc-utils';
|
|
2
2
|
import ParameterBag from './ParameterBag.js';
|
|
3
|
+
import PromiseStore from './PromiseStore.js';
|
|
3
4
|
import {
|
|
4
5
|
DELETE_REQUEST,
|
|
5
6
|
DELETE_RESPONSE,
|
|
@@ -13,11 +14,6 @@ import {
|
|
|
13
14
|
UPDATE_ABORT,
|
|
14
15
|
UPDATE_NOTIFICATION,
|
|
15
16
|
} from './constants.js';
|
|
16
|
-
import {
|
|
17
|
-
storeRequestPromise,
|
|
18
|
-
resolveRequest,
|
|
19
|
-
rejectRequest,
|
|
20
|
-
} from './promise-store.js';
|
|
21
17
|
|
|
22
18
|
class BaseSharedState {
|
|
23
19
|
constructor(id, remoteId, schemaName, schema, client, isOwner, manager, initValues = {}) {
|
|
@@ -33,8 +29,13 @@ class BaseSharedState {
|
|
|
33
29
|
this._client = client;
|
|
34
30
|
/** @private */
|
|
35
31
|
this._manager = manager;
|
|
36
|
-
|
|
32
|
+
/**
|
|
33
|
+
* true is the state has been detached or deleted
|
|
34
|
+
* @private
|
|
35
|
+
*/
|
|
37
36
|
this._detached = false;
|
|
37
|
+
/* @private */
|
|
38
|
+
this._promiseStore = new PromiseStore(this.constructor.name);
|
|
38
39
|
|
|
39
40
|
try {
|
|
40
41
|
/** @private */
|
|
@@ -56,13 +57,13 @@ ${JSON.stringify(initValues, null, 2)}`);
|
|
|
56
57
|
// add listener for state updates
|
|
57
58
|
this._client.transport.addListener(`${UPDATE_RESPONSE}-${this.id}-${this.remoteId}`, async (reqId, updates, context) => {
|
|
58
59
|
const updated = await this._commit(updates, context, true, true);
|
|
59
|
-
|
|
60
|
+
this._promiseStore.resolve(reqId, updated);
|
|
60
61
|
});
|
|
61
62
|
|
|
62
63
|
// retrieve values but do not propagate to subscriptions
|
|
63
64
|
this._client.transport.addListener(`${UPDATE_ABORT}-${this.id}-${this.remoteId}`, async (reqId, updates, context) => {
|
|
64
65
|
const updated = await this._commit(updates, context, false, true);
|
|
65
|
-
|
|
66
|
+
this._promiseStore.resolve(reqId, updated);
|
|
66
67
|
});
|
|
67
68
|
|
|
68
69
|
this._client.transport.addListener(`${UPDATE_NOTIFICATION}-${this.id}-${this.remoteId}`, async (updates, context) => {
|
|
@@ -101,7 +102,7 @@ ${JSON.stringify(initValues, null, 2)}`);
|
|
|
101
102
|
for (let callback of this._onDetachCallbacks) {
|
|
102
103
|
try {
|
|
103
104
|
await callback();
|
|
104
|
-
} catch(err) {
|
|
105
|
+
} catch (err) {
|
|
105
106
|
console.error(err.message);
|
|
106
107
|
}
|
|
107
108
|
}
|
|
@@ -112,7 +113,9 @@ ${JSON.stringify(initValues, null, 2)}`);
|
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
this.
|
|
116
|
+
this._onDetachCallbacks.clear();
|
|
117
|
+
this._onDeleteCallbacks.clear();
|
|
118
|
+
this._promiseStore.flush();
|
|
116
119
|
});
|
|
117
120
|
|
|
118
121
|
|
|
@@ -132,12 +135,14 @@ ${JSON.stringify(initValues, null, 2)}`);
|
|
|
132
135
|
await callback();
|
|
133
136
|
}
|
|
134
137
|
|
|
135
|
-
this.
|
|
136
|
-
|
|
138
|
+
this._onDetachCallbacks.clear();
|
|
139
|
+
this._onDeleteCallbacks.clear();
|
|
140
|
+
this._promiseStore.resolve(reqId, this);
|
|
141
|
+
this._promiseStore.flush();
|
|
137
142
|
});
|
|
138
143
|
|
|
139
144
|
this._client.transport.addListener(`${DELETE_ERROR}-${this.id}`, (reqId, msg) => {
|
|
140
|
-
|
|
145
|
+
this._promiseStore.reject(reqId, msg);
|
|
141
146
|
});
|
|
142
147
|
|
|
143
148
|
} else {
|
|
@@ -152,13 +157,18 @@ ${JSON.stringify(initValues, null, 2)}`);
|
|
|
152
157
|
await callback();
|
|
153
158
|
}
|
|
154
159
|
|
|
155
|
-
this.
|
|
156
|
-
|
|
160
|
+
this._onDetachCallbacks.clear();
|
|
161
|
+
this._onDeleteCallbacks.clear();
|
|
162
|
+
this._promiseStore.resolve(reqId, this);
|
|
163
|
+
this._promiseStore.flush();
|
|
157
164
|
});
|
|
158
165
|
|
|
159
166
|
// the state does not exists anymore in the server (should not happen)
|
|
160
167
|
this._client.transport.addListener(`${DETACH_ERROR}-${this.id}`, (reqId, msg) => {
|
|
161
|
-
|
|
168
|
+
this._onDetachCallbacks.clear();
|
|
169
|
+
this._onDeleteCallbacks.clear();
|
|
170
|
+
this._promiseStore.reject(reqId, msg);
|
|
171
|
+
this._promiseStore.flush();
|
|
162
172
|
});
|
|
163
173
|
}
|
|
164
174
|
}
|
|
@@ -200,13 +210,6 @@ ${JSON.stringify(initValues, null, 2)}`);
|
|
|
200
210
|
return this._isOwner;
|
|
201
211
|
}
|
|
202
212
|
|
|
203
|
-
/** @private */
|
|
204
|
-
_clearDetach() {
|
|
205
|
-
this._onDetachCallbacks.clear();
|
|
206
|
-
this._onDeleteCallbacks.clear();
|
|
207
|
-
this._detached = true;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
213
|
/** @private */
|
|
211
214
|
_clearTransport() {
|
|
212
215
|
// remove listeners
|
|
@@ -325,7 +328,7 @@ ${JSON.stringify(initValues, null, 2)}`);
|
|
|
325
328
|
for (let name in updates) {
|
|
326
329
|
// try to coerce value early, so that eventual errors are triggered early
|
|
327
330
|
// on the node requesting the update
|
|
328
|
-
|
|
331
|
+
this._parameters.coerceValue(name, updates[name]);
|
|
329
332
|
|
|
330
333
|
// @note: general idea...
|
|
331
334
|
// if immediate=true
|
|
@@ -358,7 +361,7 @@ ${JSON.stringify(initValues, null, 2)}`);
|
|
|
358
361
|
|
|
359
362
|
// go through server-side normal behavior
|
|
360
363
|
return new Promise((resolve, reject) => {
|
|
361
|
-
const reqId =
|
|
364
|
+
const reqId = this._promiseStore.add(resolve, reject, 'update-request');
|
|
362
365
|
this._client.transport.emit(`${UPDATE_REQUEST}-${this.id}-${this.remoteId}`, reqId, updates, context);
|
|
363
366
|
});
|
|
364
367
|
}
|
|
@@ -477,16 +480,17 @@ ${JSON.stringify(initValues, null, 2)}`);
|
|
|
477
480
|
throw new Error(`[SharedState] State "${this.schemaName} (${this.id})" already detached, cannot detach twice`);
|
|
478
481
|
}
|
|
479
482
|
|
|
483
|
+
this._detached = true; // mark detached early
|
|
480
484
|
this._onUpdateCallbacks.clear();
|
|
481
485
|
|
|
482
486
|
if (this._isOwner) {
|
|
483
487
|
return new Promise((resolve, reject) => {
|
|
484
|
-
const reqId =
|
|
488
|
+
const reqId = this._promiseStore.add(resolve, reject, 'delete-request');
|
|
485
489
|
this._client.transport.emit(`${DELETE_REQUEST}-${this.id}-${this.remoteId}`, reqId);
|
|
486
490
|
});
|
|
487
491
|
} else {
|
|
488
492
|
return new Promise((resolve, reject) => {
|
|
489
|
-
const reqId =
|
|
493
|
+
const reqId = this._promiseStore.add(resolve, reject, 'detach-request');
|
|
490
494
|
this._client.transport.emit(`${DETACH_REQUEST}-${this.id}-${this.remoteId}`, reqId);
|
|
491
495
|
});
|
|
492
496
|
}
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* @private
|
|
3
3
|
*/
|
|
4
4
|
class BaseSharedStateCollection {
|
|
5
|
-
constructor(stateManager, schemaName) {
|
|
5
|
+
constructor(stateManager, schemaName, options = {}) {
|
|
6
6
|
this._stateManager = stateManager;
|
|
7
7
|
this._schemaName = schemaName;
|
|
8
|
+
this._options = Object.assign({ excludeLocal: false }, options);
|
|
9
|
+
|
|
8
10
|
this._states = [];
|
|
11
|
+
|
|
9
12
|
this._onUpdateCallbacks = new Set();
|
|
10
13
|
this._onAttachCallbacks = new Set();
|
|
11
14
|
this._onDetachCallbacks = new Set();
|
|
@@ -13,7 +16,7 @@ class BaseSharedStateCollection {
|
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
async _init() {
|
|
16
|
-
this._unobserve = await this._stateManager.observe(this._schemaName, async (schemaName, stateId
|
|
19
|
+
this._unobserve = await this._stateManager.observe(this._schemaName, async (schemaName, stateId) => {
|
|
17
20
|
const state = await this._stateManager.attach(schemaName, stateId);
|
|
18
21
|
|
|
19
22
|
this._states.push(state);
|
|
@@ -32,28 +35,36 @@ class BaseSharedStateCollection {
|
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
this._onAttachCallbacks.forEach(callback => callback(state));
|
|
35
|
-
});
|
|
38
|
+
}, this._options);
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
39
|
-
* Size of the collection
|
|
42
|
+
* Size of the collection, alias `size`
|
|
40
43
|
* @type {number}
|
|
41
44
|
*/
|
|
42
45
|
get length() {
|
|
43
46
|
return this._states.length;
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Size of the collection, , alias `length`
|
|
51
|
+
* @type {number}
|
|
52
|
+
*/
|
|
53
|
+
get size() {
|
|
54
|
+
return this._states.length;
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
/**
|
|
47
58
|
* Detach from the collection, i.e. detach all underlying shared states.
|
|
48
59
|
* @type {number}
|
|
49
60
|
*/
|
|
50
61
|
async detach() {
|
|
51
62
|
this._unobserve();
|
|
63
|
+
this._onUpdateCallbacks.clear();
|
|
52
64
|
|
|
53
|
-
const promises =
|
|
65
|
+
const promises = this._states.map(state => state.detach());
|
|
54
66
|
await Promise.all(promises);
|
|
55
67
|
|
|
56
|
-
this._onUpdateCallbacks.clear();
|
|
57
68
|
this._onDetachCallbacks.clear();
|
|
58
69
|
}
|
|
59
70
|
|
|
@@ -212,8 +223,8 @@ class BaseSharedStateCollection {
|
|
|
212
223
|
} else {
|
|
213
224
|
return { value: this._states[index++], done: false };
|
|
214
225
|
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
226
|
+
},
|
|
227
|
+
};
|
|
217
228
|
}
|
|
218
229
|
}
|
|
219
230
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { isString, isFunction } from '@ircam/sc-utils';
|
|
1
|
+
import { isString, isFunction, isPlainObject } from '@ircam/sc-utils';
|
|
2
2
|
import SharedState from './BaseSharedState.js';
|
|
3
3
|
import SharedStateCollection from './BaseSharedStateCollection.js';
|
|
4
|
+
import PromiseStore from './PromiseStore.js';
|
|
4
5
|
import {
|
|
5
6
|
CREATE_REQUEST,
|
|
6
7
|
CREATE_RESPONSE,
|
|
@@ -10,15 +11,11 @@ import {
|
|
|
10
11
|
ATTACH_ERROR,
|
|
11
12
|
OBSERVE_REQUEST,
|
|
12
13
|
OBSERVE_RESPONSE,
|
|
14
|
+
OBSERVE_ERROR,
|
|
13
15
|
OBSERVE_NOTIFICATION,
|
|
14
16
|
UNOBSERVE_NOTIFICATION,
|
|
15
17
|
DELETE_SCHEMA,
|
|
16
18
|
} from './constants.js';
|
|
17
|
-
import {
|
|
18
|
-
storeRequestPromise,
|
|
19
|
-
resolveRequest,
|
|
20
|
-
rejectRequest,
|
|
21
|
-
} from './promise-store.js';
|
|
22
19
|
|
|
23
20
|
/**
|
|
24
21
|
* @private
|
|
@@ -32,10 +29,13 @@ class BaseStateManager {
|
|
|
32
29
|
constructor(id, transport) {
|
|
33
30
|
this.client = { id, transport };
|
|
34
31
|
|
|
35
|
-
this._statesById = new Map();
|
|
36
|
-
this.
|
|
37
|
-
|
|
38
|
-
this.
|
|
32
|
+
this._statesById = new Map(); // <id, state>
|
|
33
|
+
this._cachedSchemas = new Map(); // <shemaName, definition>
|
|
34
|
+
|
|
35
|
+
this._observeListeners = new Set(); // Set <[observedSchemaName, callback, options]>
|
|
36
|
+
this._observeRequestCallbacks = new Map(); // Map <reqId, [observedSchemaName, callback, options]>
|
|
37
|
+
|
|
38
|
+
this._promiseStore = new PromiseStore();
|
|
39
39
|
|
|
40
40
|
// ---------------------------------------------
|
|
41
41
|
// CREATE
|
|
@@ -53,11 +53,11 @@ class BaseStateManager {
|
|
|
53
53
|
const state = new SharedState(stateId, remoteId, schemaName, schema, this.client, true, this, initValues);
|
|
54
54
|
this._statesById.set(state.id, state);
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
this._promiseStore.resolve(reqId, state);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
this.client.transport.addListener(CREATE_ERROR, (reqId, msg) => {
|
|
60
|
-
|
|
60
|
+
this._promiseStore.reject(reqId, msg);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
// ---------------------------------------------
|
|
@@ -77,11 +77,11 @@ class BaseStateManager {
|
|
|
77
77
|
const state = new SharedState(stateId, remoteId, schemaName, schema, this.client, false, this, currentValues);
|
|
78
78
|
this._statesById.set(state.id, state);
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
this._promiseStore.resolve(reqId, state);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
this.client.transport.addListener(ATTACH_ERROR, (reqId, msg) => {
|
|
84
|
-
|
|
84
|
+
this._promiseStore.reject(reqId, msg);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
// ---------------------------------------------
|
|
@@ -90,11 +90,18 @@ class BaseStateManager {
|
|
|
90
90
|
this.client.transport.addListener(OBSERVE_RESPONSE, async (reqId, ...list) => {
|
|
91
91
|
// retrieve the callback that have been stored in observe to make sure
|
|
92
92
|
// we don't call another callback that may have been registered earlier.
|
|
93
|
-
const
|
|
93
|
+
const observeInfos = this._observeRequestCallbacks.get(reqId);
|
|
94
|
+
const [observedSchemaName, callback, options] = observeInfos;
|
|
95
|
+
|
|
96
|
+
// move observeInfos from `_observeRequestCallbacks` to `_observeListeners`
|
|
97
|
+
// to guarantee order of execution, @see not in `.observe`
|
|
94
98
|
this._observeRequestCallbacks.delete(reqId);
|
|
99
|
+
this._observeListeners.add(observeInfos);
|
|
95
100
|
|
|
96
101
|
const promises = list.map(([schemaName, stateId, nodeId]) => {
|
|
97
|
-
|
|
102
|
+
const filter = this._filterObserve(observedSchemaName, schemaName, nodeId, options);
|
|
103
|
+
|
|
104
|
+
if (!filter) {
|
|
98
105
|
return callback(schemaName, stateId, nodeId);
|
|
99
106
|
} else {
|
|
100
107
|
return Promise.resolve();
|
|
@@ -104,19 +111,29 @@ class BaseStateManager {
|
|
|
104
111
|
await Promise.all(promises);
|
|
105
112
|
|
|
106
113
|
const unsubscribe = () => {
|
|
107
|
-
this._observeListeners.delete(
|
|
108
|
-
|
|
114
|
+
this._observeListeners.delete(observeInfos);
|
|
115
|
+
|
|
116
|
+
// no more listeners, we can stop receiving notifications from the server
|
|
109
117
|
if (this._observeListeners.size === 0) {
|
|
110
118
|
this.client.transport.emit(UNOBSERVE_NOTIFICATION);
|
|
111
119
|
}
|
|
112
120
|
};
|
|
113
121
|
|
|
114
|
-
|
|
122
|
+
this._promiseStore.resolve(reqId, unsubscribe);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Observe error can occur if observed schema name does not exists
|
|
126
|
+
this.client.transport.addListener(OBSERVE_ERROR, (reqId, msg) => {
|
|
127
|
+
this._observeRequestCallbacks.delete(reqId);
|
|
128
|
+
this._promiseStore.reject(reqId, msg);
|
|
115
129
|
});
|
|
116
130
|
|
|
117
131
|
this.client.transport.addListener(OBSERVE_NOTIFICATION, (schemaName, stateId, nodeId) => {
|
|
118
|
-
this._observeListeners.forEach(
|
|
119
|
-
|
|
132
|
+
this._observeListeners.forEach(observeInfos => {
|
|
133
|
+
const [observedSchemaName, callback, options] = observeInfos;
|
|
134
|
+
const filter = this._filterObserve(observedSchemaName, schemaName, nodeId, options);
|
|
135
|
+
|
|
136
|
+
if (!filter) {
|
|
120
137
|
callback(schemaName, stateId, nodeId);
|
|
121
138
|
}
|
|
122
139
|
});
|
|
@@ -130,6 +147,21 @@ class BaseStateManager {
|
|
|
130
147
|
});
|
|
131
148
|
}
|
|
132
149
|
|
|
150
|
+
/** @private */
|
|
151
|
+
_filterObserve(observedSchemaName, schemaName, creatorId, options) {
|
|
152
|
+
let filter = true;
|
|
153
|
+
// schema name filter filer
|
|
154
|
+
if (observedSchemaName === null || observedSchemaName === schemaName) {
|
|
155
|
+
filter = false;
|
|
156
|
+
}
|
|
157
|
+
// filter state created by client if excludeLocal is true
|
|
158
|
+
if (options.excludeLocal === true && creatorId === this.client.id) {
|
|
159
|
+
filter = true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return filter;
|
|
163
|
+
}
|
|
164
|
+
|
|
133
165
|
/**
|
|
134
166
|
* Create a `SharedState` instance from a registered schema.
|
|
135
167
|
*
|
|
@@ -144,7 +176,7 @@ class BaseStateManager {
|
|
|
144
176
|
*/
|
|
145
177
|
async create(schemaName, initValues = {}) {
|
|
146
178
|
return new Promise((resolve, reject) => {
|
|
147
|
-
const reqId =
|
|
179
|
+
const reqId = this._promiseStore.add(resolve, reject, 'create-create');
|
|
148
180
|
const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
|
|
149
181
|
this.client.transport.emit(CREATE_REQUEST, reqId, schemaName, requireSchema, initValues);
|
|
150
182
|
});
|
|
@@ -167,7 +199,7 @@ class BaseStateManager {
|
|
|
167
199
|
async attach(schemaName, stateId = null) {
|
|
168
200
|
return new Promise((resolve, reject) => {
|
|
169
201
|
// @todo - add a timeout
|
|
170
|
-
const reqId =
|
|
202
|
+
const reqId = this._promiseStore.add(resolve, reject, 'attach-request');
|
|
171
203
|
const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
|
|
172
204
|
this.client.transport.emit(ATTACH_REQUEST, reqId, schemaName, stateId, requireSchema);
|
|
173
205
|
});
|
|
@@ -180,20 +212,31 @@ class BaseStateManager {
|
|
|
180
212
|
* control over them from a single point.
|
|
181
213
|
*
|
|
182
214
|
* Notes:
|
|
183
|
-
* - The
|
|
184
|
-
* the observe callback.
|
|
185
|
-
* -
|
|
186
|
-
*
|
|
215
|
+
* - The order of execution is not guaranted between nodes, i.e. an state attached
|
|
216
|
+
* in the `observe` callback could be created before the `async create` method resolves.
|
|
217
|
+
* - Filtering, i.e. `observedSchemaName` and `options.excludeLocal` are handled
|
|
218
|
+
* on the node side, the server just notify all state creation activity and
|
|
219
|
+
* the node executes the given callbacks according to the different filter rules.
|
|
220
|
+
* Such strategy allows to share the observe notifications between all observers.
|
|
187
221
|
*
|
|
188
|
-
*
|
|
222
|
+
* Alternative signatures:
|
|
223
|
+
* - .observe(callback)
|
|
224
|
+
* - .observe(schemaName, callback)
|
|
225
|
+
* - .observe(callback, options)
|
|
226
|
+
* - .observe(schemaName, callback, options)
|
|
227
|
+
*
|
|
228
|
+
* @param {string} [schemaName] - optionnal schema name to filter the observed
|
|
189
229
|
* states.
|
|
190
230
|
* @param {server.StateManager~ObserveCallback|client.StateManager~ObserveCallback}
|
|
191
231
|
* callback - Function to be called when a new state is created on the network.
|
|
232
|
+
* @param {object} options - Options.
|
|
233
|
+
* @param {boolean} [options.excludeLocal = false] - If set to true, exclude states
|
|
234
|
+
* created locallly, i.e. by the same node, from the collection.
|
|
192
235
|
* @returns {Promise<Function>} - Returns a Promise that resolves when the given
|
|
193
236
|
* callback as been executed on each existing states. The promise value is a
|
|
194
237
|
* function which allows to stop observing the states on the network.
|
|
195
238
|
* @example
|
|
196
|
-
* client.stateManager.observe(async (schemaName, stateId
|
|
239
|
+
* client.stateManager.observe(async (schemaName, stateId) => {
|
|
197
240
|
* if (schemaName === 'something') {
|
|
198
241
|
* const state = await this.client.stateManager.attach(schemaName, stateId);
|
|
199
242
|
* console.log(state.getValues());
|
|
@@ -204,50 +247,127 @@ class BaseStateManager {
|
|
|
204
247
|
// handle this way and the network overhead is very low for observe notifications:
|
|
205
248
|
// i.e. schemaName, stateId, nodeId
|
|
206
249
|
async observe(...args) {
|
|
207
|
-
|
|
250
|
+
|
|
251
|
+
const defaultOptions = {
|
|
252
|
+
excludeLocal: false,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
let observedSchemaName;
|
|
208
256
|
let callback;
|
|
257
|
+
let options;
|
|
209
258
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
259
|
+
switch (args.length) {
|
|
260
|
+
case 1: {
|
|
261
|
+
// variation: .observe(callback)
|
|
262
|
+
if (!isFunction(args[0])) {
|
|
263
|
+
throw new TypeError(`[stateManager] Invalid arguments, argument 1 should be a function"`);
|
|
264
|
+
}
|
|
213
265
|
|
|
214
|
-
|
|
215
|
-
|
|
266
|
+
observedSchemaName = null;
|
|
267
|
+
callback = args[0];
|
|
268
|
+
options = defaultOptions;
|
|
269
|
+
break;
|
|
216
270
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
271
|
+
case 2: {
|
|
272
|
+
// variation: .observe(schemaName, callback)
|
|
273
|
+
if (isString(args[0])) {
|
|
274
|
+
if (!isFunction(args[1])) {
|
|
275
|
+
throw new TypeError(`[stateManager] Invalid arguments, argument 2 should be a function"`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
observedSchemaName = args[0];
|
|
279
|
+
callback = args[1];
|
|
280
|
+
options = defaultOptions;
|
|
281
|
+
|
|
282
|
+
// variation: .observe(callback, options) API
|
|
283
|
+
} else if (isFunction(args[0])) {
|
|
284
|
+
if (!isPlainObject(args[1])) {
|
|
285
|
+
throw new TypeError(`[stateManager] Invalid arguments, argument 2 should be an object"`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
observedSchemaName = null;
|
|
289
|
+
callback = args[0];
|
|
290
|
+
options = Object.assign(defaultOptions, args[1]);
|
|
291
|
+
|
|
292
|
+
} else {
|
|
293
|
+
throw new Error(`[stateManager] Invalid signature, refer to the StateManager.observe documentation"`);
|
|
294
|
+
}
|
|
220
295
|
|
|
221
|
-
|
|
222
|
-
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case 3: {
|
|
299
|
+
// variation: .observe(schemaName, callback, options)
|
|
300
|
+
if (!isString(args[0])) {
|
|
301
|
+
throw new TypeError(`[stateManager] Invalid arguments, argument 1 should be a string"`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!isFunction(args[1])) {
|
|
305
|
+
throw new TypeError(`[stateManager] Invalid arguments, argument 2 should be a function"`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!isPlainObject(args[2])) {
|
|
309
|
+
throw new TypeError(`[stateManager] Invalid arguments, argument 2 should be an object"`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
observedSchemaName = args[0];
|
|
313
|
+
callback = args[1];
|
|
314
|
+
options = Object.assign(defaultOptions, args[2]);
|
|
315
|
+
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
// throw in all other cases
|
|
319
|
+
default: {
|
|
320
|
+
throw new Error(`[stateManager] Invalid signature, refer to the StateManager.observe documentation"`);
|
|
223
321
|
}
|
|
224
|
-
} else {
|
|
225
|
-
throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`);
|
|
226
322
|
}
|
|
227
323
|
|
|
228
324
|
// resend request to get updated list of states
|
|
229
325
|
return new Promise((resolve, reject) => {
|
|
230
|
-
const reqId =
|
|
326
|
+
const reqId = this._promiseStore.add(resolve, reject, 'observe-request');
|
|
231
327
|
// store the callback for execution on the response. the returned Promise
|
|
232
328
|
// is fullfiled once callback has been executed with each existing states
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
329
|
+
const observeInfos = [observedSchemaName, callback, options];
|
|
330
|
+
this._observeRequestCallbacks.set(reqId, observeInfos);
|
|
331
|
+
|
|
332
|
+
// NOTE: do not store in `_observeListeners` yet as it can produce race
|
|
333
|
+
// conditions, e.g.:
|
|
334
|
+
// ```
|
|
335
|
+
// await client.stateManager.observe(async (schemaName, stateId, nodeId) => {});
|
|
336
|
+
// // client now receives OBSERVE_NOTIFICATIONS
|
|
337
|
+
// await otherClient.stateManager.create('a');
|
|
338
|
+
// // second observer added in between
|
|
339
|
+
// client.stateManager.observe(async (schemaName, stateId, nodeId) => {});
|
|
340
|
+
// ````
|
|
341
|
+
// OBSERVE_NOTIFICATION is received before the OBSERVE_RESPONSE, then the
|
|
342
|
+
// second observer is called twice:
|
|
343
|
+
// - OBSERVE_RESPONSE 1 []
|
|
344
|
+
// - OBSERVE_NOTIFICATION [ 'a', 1, 0 ]
|
|
345
|
+
// - OBSERVE_NOTIFICATION [ 'a', 1, 0 ] // this should not happen
|
|
346
|
+
// - OBSERVE_RESPONSE 1 [ [ 'a', 1, 0 ] ]
|
|
347
|
+
//
|
|
348
|
+
// cf. unit test `observe should properly behave in race condition`
|
|
349
|
+
|
|
350
|
+
this.client.transport.emit(OBSERVE_REQUEST, reqId, observedSchemaName);
|
|
238
351
|
});
|
|
239
352
|
}
|
|
240
353
|
|
|
241
354
|
/**
|
|
242
|
-
* Returns a collection of all the states created from the schema name.
|
|
243
|
-
* the ones created by the current node.
|
|
355
|
+
* Returns a collection of all the states created from the schema name.
|
|
244
356
|
*
|
|
245
357
|
* @param {string} schemaName - Name of the schema.
|
|
358
|
+
* @param {object} options - Options.
|
|
359
|
+
* @param {boolean} [options.excludeLocal = false] - If set to true, exclude states
|
|
360
|
+
* created locallly, i.e. by the same node, from the collection.
|
|
246
361
|
* @returns {server.SharedStateCollection|client.SharedStateCollection}
|
|
247
362
|
*/
|
|
248
|
-
async getCollection(schemaName) {
|
|
249
|
-
const collection = new SharedStateCollection(this, schemaName);
|
|
250
|
-
|
|
363
|
+
async getCollection(schemaName, options) {
|
|
364
|
+
const collection = new SharedStateCollection(this, schemaName, options);
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
await collection._init();
|
|
368
|
+
} catch (err) {
|
|
369
|
+
throw new Error(`Cannot create collection, schema "${schemaName}" does not exists`);
|
|
370
|
+
}
|
|
251
371
|
|
|
252
372
|
return collection;
|
|
253
373
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import cloneDeep from 'lodash.
|
|
1
|
+
import cloneDeep from 'lodash/cloneDeep.js';
|
|
2
2
|
import equal from 'fast-deep-equal';
|
|
3
3
|
|
|
4
4
|
export const sharedOptions = {
|
|
@@ -394,30 +394,34 @@ class ParameterBag {
|
|
|
394
394
|
getSchema(name = null) {
|
|
395
395
|
if (name === null) {
|
|
396
396
|
return this._schema;
|
|
397
|
-
}
|
|
398
|
-
if (!this.has(name)) {
|
|
399
|
-
throw new ReferenceError(`[SharedState] Cannot get schema description of undefined parameter "${name}"`);
|
|
400
|
-
}
|
|
397
|
+
}
|
|
401
398
|
|
|
402
|
-
|
|
399
|
+
if (!this.has(name)) {
|
|
400
|
+
throw new ReferenceError(`[SharedState] Cannot get schema description of undefined parameter "${name}"`);
|
|
403
401
|
}
|
|
402
|
+
|
|
403
|
+
return this._schema[name];
|
|
404
404
|
}
|
|
405
405
|
|
|
406
406
|
// return the default value, if initValue has been given, return init values
|
|
407
407
|
getInitValues() {
|
|
408
408
|
const initValues = {};
|
|
409
|
+
|
|
409
410
|
for (let [name, def] of Object.entries(this._schema)) {
|
|
410
411
|
initValues[name] = def.initValue;
|
|
411
412
|
}
|
|
413
|
+
|
|
412
414
|
return initValues;
|
|
413
415
|
}
|
|
414
416
|
|
|
415
417
|
// return the default value, if initValue has been given, return init values
|
|
416
418
|
getDefaults() {
|
|
417
419
|
const defaults = {};
|
|
420
|
+
|
|
418
421
|
for (let [name, def] of Object.entries(this._schema)) {
|
|
419
|
-
defaults[name] = def.
|
|
422
|
+
defaults[name] = def.default;
|
|
420
423
|
}
|
|
424
|
+
|
|
421
425
|
return defaults;
|
|
422
426
|
}
|
|
423
427
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { idGenerator } from '@ircam/sc-utils';
|
|
2
|
+
|
|
3
|
+
const generateRequestId = idGenerator();
|
|
4
|
+
|
|
5
|
+
export default class PromiseStore {
|
|
6
|
+
constructor(name) {
|
|
7
|
+
this.name = name;
|
|
8
|
+
this.store = new Map();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
add(resolve, reject, type) {
|
|
12
|
+
const reqId = generateRequestId.next().value;
|
|
13
|
+
this.store.set(reqId, { resolve, reject, type });
|
|
14
|
+
|
|
15
|
+
return reqId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
resolve(reqId, data) {
|
|
19
|
+
const { resolve } = this.store.get(reqId);
|
|
20
|
+
this.store.delete(reqId);
|
|
21
|
+
|
|
22
|
+
resolve(data);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
reject(reqId, msg) {
|
|
26
|
+
const { reject } = this.store.get(reqId);
|
|
27
|
+
this.store.delete(reqId);
|
|
28
|
+
|
|
29
|
+
reject(new Error(msg));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// reject all pendeing request
|
|
33
|
+
flush() {
|
|
34
|
+
for (let [_reqId, entry] of this.store) {
|
|
35
|
+
const { reject, type } = entry;
|
|
36
|
+
reject(new Error(`[${this.name}] Discard promise "${type}", cannot resolve`));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.store.clear();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -89,49 +89,46 @@ class SharedStatePrivate {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
if (hasUpdates) {
|
|
92
|
-
//
|
|
93
|
-
// client
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
// is synchronous it can break ordering if a subscription function makes
|
|
97
|
-
// itself an update in reaction to an update, therefore network messages
|
|
98
|
-
// order would be broken,
|
|
99
|
-
|
|
100
|
-
// we need to handle cases where:
|
|
101
|
-
// client state (client.id: 2) sends a request
|
|
102
|
-
// server attached state (client.id: -1) spot a problem and overrides the value
|
|
103
|
-
// we want the remote client (id: 2) to receive in the right order:
|
|
92
|
+
// We need to handle cases where:
|
|
93
|
+
// - client state (client.id: 2) sends a request
|
|
94
|
+
// - server attached state (client.id: -1) spot a problem and overrides the value
|
|
95
|
+
// We want the remote client (id: 2) to receive in the right order:
|
|
104
96
|
// * 1. the value it requested,
|
|
105
97
|
// * 2. the value overriden by the server-side attached state (id: -1)
|
|
98
|
+
//
|
|
99
|
+
// such problem is now better solved with the the upateHook system, none
|
|
100
|
+
// nonetheway we don't want to introduce inconsistencies here
|
|
101
|
+
//
|
|
102
|
+
// Then we propagate server-side last, because as the server transport
|
|
103
|
+
// is synchronous it can break ordering if a subscription function makes
|
|
104
|
+
// itself an update in reaction to an update. Propagating to server last
|
|
105
|
+
// alllows to maintain network messages order consistent.
|
|
106
106
|
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
// @note - remoteId correspond to unique remote state id
|
|
108
|
+
|
|
109
|
+
// propagate RESPONSE to the client that originates the request if not the server
|
|
110
|
+
if (client.id !== -1) {
|
|
111
|
+
client.transport.emit(`${UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates, context);
|
|
112
|
+
}
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
// propagate NOTIFICATION to all other attached clients except server
|
|
115
|
+
for (let [peerRemoteId, peer] of this._attachedClients) {
|
|
116
116
|
if (remoteId !== peerRemoteId && peer.id !== -1) {
|
|
117
117
|
peer.transport.emit(`${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`, filteredUpdates, context);
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
if
|
|
121
|
+
// propagate RESPONSE to server if it is the requester
|
|
122
|
+
if (client.id === -1) {
|
|
122
123
|
client.transport.emit(`${UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates, context);
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
// propagate NOTIFICATION other attached state that belongs to server
|
|
127
|
+
for (let [peerRemoteId, peer] of this._attachedClients) {
|
|
127
128
|
if (remoteId !== peerRemoteId && peer.id === -1) {
|
|
128
129
|
peer.transport.emit(`${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`, filteredUpdates, context);
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
|
-
|
|
132
|
-
if (client.id === -1) {
|
|
133
|
-
client.transport.emit(`${UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates, context);
|
|
134
|
-
}
|
|
135
132
|
} else {
|
|
136
133
|
// propagate back to the requester that the update has been aborted
|
|
137
134
|
// ignore all other attached clients.
|
package/src/common/constants.js
CHANGED
|
@@ -21,6 +21,7 @@ export const DETACH_ERROR = 's:dt:err';
|
|
|
21
21
|
|
|
22
22
|
export const OBSERVE_REQUEST = 's:o:req';
|
|
23
23
|
export const OBSERVE_RESPONSE = 's:o:res';
|
|
24
|
+
export const OBSERVE_ERROR = 's:o:err';
|
|
24
25
|
export const OBSERVE_NOTIFICATION = 's:o:not';
|
|
25
26
|
|
|
26
27
|
export const UNOBSERVE_NOTIFICATION = 's:uo:not';
|
|
@@ -47,5 +48,5 @@ export const CLIENT_HANDSHAKE_RESPONSE = 'cl:h:res';
|
|
|
47
48
|
export const CLIENT_HANDSHAKE_ERROR = 'cl:h:err';
|
|
48
49
|
|
|
49
50
|
// audit state schema name
|
|
50
|
-
export const AUDIT_STATE_NAME = 's:
|
|
51
|
+
export const AUDIT_STATE_NAME = 'p:s:audit';
|
|
51
52
|
export const PRIVATE_STATES = [AUDIT_STATE_NAME];
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import 'fast-text-encoding';
|
|
2
|
-
import root from 'window-or-global';
|
|
3
2
|
|
|
4
|
-
const encoder = new
|
|
5
|
-
const decoder = new
|
|
3
|
+
const encoder = new TextEncoder('utf-8');
|
|
4
|
+
const decoder = new TextDecoder('utf-8');
|
|
6
5
|
|
|
7
6
|
const types = [
|
|
8
7
|
'Int8Array',
|
|
@@ -57,7 +56,7 @@ export function unpackBinaryMessage(buffer /* arraybuffer */) {
|
|
|
57
56
|
const channel = decoder.decode(channelBuffer);
|
|
58
57
|
const type = types[typeIndex];
|
|
59
58
|
// slice (copy) the underlying ArrayBuffer to create a clean TypedArray
|
|
60
|
-
const data = new
|
|
59
|
+
const data = new globalThis[type](buffer.slice(startOffset));
|
|
61
60
|
|
|
62
61
|
return [channel, data];
|
|
63
62
|
}
|
package/src/server/Server.js
CHANGED
|
@@ -5,14 +5,13 @@ import path from 'node:path';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { X509Certificate, createPrivateKey } from 'node:crypto';
|
|
7
7
|
|
|
8
|
-
import { getTime } from '@ircam/sc-
|
|
9
|
-
import { isPlainObject, idGenerator } from '@ircam/sc-utils';
|
|
8
|
+
import { isPlainObject, idGenerator, getTime } from '@ircam/sc-utils';
|
|
10
9
|
import chalk from 'chalk';
|
|
11
10
|
import compression from 'compression';
|
|
12
11
|
import express from 'express';
|
|
13
12
|
import Keyv from 'keyv';
|
|
14
13
|
import { KeyvFile } from 'keyv-file';
|
|
15
|
-
import merge from 'lodash.
|
|
14
|
+
import merge from 'lodash/merge.js';
|
|
16
15
|
import pem from 'pem';
|
|
17
16
|
import compile from 'template-literal';
|
|
18
17
|
|
|
@@ -15,8 +15,11 @@ import BaseSharedStateCollection from '../common/BaseSharedStateCollection.js';
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* The `SharedStateCollection` interface represent a collection of all states
|
|
18
|
-
* created from a given schema name on the network
|
|
19
|
-
*
|
|
18
|
+
* created from a given schema name on the network.
|
|
19
|
+
*
|
|
20
|
+
* It can optionnaly exclude the states created by the current node.
|
|
21
|
+
*
|
|
22
|
+
* See {@link server.StateManager#getCollection} for factory method API
|
|
20
23
|
*
|
|
21
24
|
* ```
|
|
22
25
|
* const collection = await server.stateManager.getCollection('my-schema');
|
package/src/server/Socket.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import EventEmitter from 'node:events';
|
|
2
2
|
|
|
3
3
|
import { idGenerator, isString, isPlainObject } from '@ircam/sc-utils';
|
|
4
|
-
import clonedeep from 'lodash.
|
|
4
|
+
import clonedeep from 'lodash/cloneDeep.js';
|
|
5
5
|
|
|
6
6
|
import BaseStateManager from '../common/BaseStateManager.js';
|
|
7
7
|
import ParameterBag from '../common/ParameterBag.js';
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
ATTACH_ERROR,
|
|
18
18
|
OBSERVE_REQUEST,
|
|
19
19
|
OBSERVE_RESPONSE,
|
|
20
|
+
OBSERVE_ERROR,
|
|
20
21
|
OBSERVE_NOTIFICATION,
|
|
21
22
|
UNOBSERVE_NOTIFICATION,
|
|
22
23
|
DELETE_SCHEMA,
|
|
@@ -359,17 +360,21 @@ class StateManager extends BaseStateManager {
|
|
|
359
360
|
client.transport.emit(CREATE_RESPONSE, reqId, stateId, remoteId, schemaName, sendedSchema, currentValues);
|
|
360
361
|
|
|
361
362
|
this._observers.forEach(observer => {
|
|
362
|
-
|
|
363
|
+
// prevent propagating infos about internal states
|
|
364
|
+
if (!PRIVATE_STATES.includes(schemaName)) {
|
|
363
365
|
observer.transport.emit(OBSERVE_NOTIFICATION, schemaName, stateId, nodeId);
|
|
364
366
|
}
|
|
365
367
|
});
|
|
366
368
|
} catch (err) {
|
|
367
|
-
|
|
368
|
-
console.error(
|
|
369
|
+
const msg = `Cannot create state "${schemaName}", ${err.message}`;
|
|
370
|
+
console.error(msg);
|
|
371
|
+
|
|
372
|
+
client.transport.emit(CREATE_ERROR, reqId, msg);
|
|
369
373
|
}
|
|
370
374
|
} else {
|
|
371
375
|
const msg = `Cannot create state "${schemaName}", schema does not exists`;
|
|
372
376
|
console.error(msg);
|
|
377
|
+
|
|
373
378
|
client.transport.emit(CREATE_ERROR, reqId, msg);
|
|
374
379
|
}
|
|
375
380
|
});
|
|
@@ -410,36 +415,43 @@ class StateManager extends BaseStateManager {
|
|
|
410
415
|
client.transport.emit(ATTACH_RESPONSE, reqId, state.id, remoteId, schemaName, sendedSchema, currentValues);
|
|
411
416
|
} else {
|
|
412
417
|
const msg = `Cannot attach, no existing state for schema "${schemaName}" with stateId: "${stateId}"`;
|
|
413
|
-
client.transport.emit(ATTACH_ERROR, reqId, msg);
|
|
414
418
|
console.error(msg);
|
|
419
|
+
|
|
420
|
+
client.transport.emit(ATTACH_ERROR, reqId, msg);
|
|
415
421
|
}
|
|
416
422
|
} else {
|
|
417
423
|
const msg = `Cannot attach, schema "${schemaName}" does not exists`;
|
|
418
|
-
client.transport.emit(ATTACH_ERROR, reqId, msg);
|
|
419
424
|
console.error(msg);
|
|
425
|
+
|
|
426
|
+
client.transport.emit(ATTACH_ERROR, reqId, msg);
|
|
420
427
|
}
|
|
421
428
|
});
|
|
422
429
|
|
|
423
430
|
// ---------------------------------------------
|
|
424
431
|
// OBSERVE PEERS (be notified when a state is created, lazy)
|
|
425
432
|
// ---------------------------------------------
|
|
426
|
-
client.transport.addListener(OBSERVE_REQUEST, reqId => {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
this._serverStatesById.forEach(state => {
|
|
430
|
-
const { schemaName, id, _creatorId } = state;
|
|
431
|
-
// only track application states
|
|
432
|
-
// (e.g. do not propagate infos about audit state)
|
|
433
|
-
if (!PRIVATE_STATES.includes(schemaName) && _creatorId !== client.id) {
|
|
434
|
-
statesInfos.push([schemaName, id, _creatorId]);
|
|
435
|
-
}
|
|
436
|
-
});
|
|
433
|
+
client.transport.addListener(OBSERVE_REQUEST, (reqId, observedSchemaName) => {
|
|
434
|
+
if (observedSchemaName === null || this._schemas.has(observedSchemaName)) {
|
|
435
|
+
const statesInfos = [];
|
|
437
436
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
437
|
+
this._serverStatesById.forEach(state => {
|
|
438
|
+
const { schemaName, id, _creatorId } = state;
|
|
439
|
+
|
|
440
|
+
// prevent propagating infos about internal states
|
|
441
|
+
if (!PRIVATE_STATES.includes(schemaName)) {
|
|
442
|
+
statesInfos.push([schemaName, id, _creatorId]);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
441
445
|
|
|
442
|
-
|
|
446
|
+
// add client to observers first because if some synchronous server side
|
|
447
|
+
// callback throws, the client would never be added to the list
|
|
448
|
+
this._observers.add(client);
|
|
449
|
+
|
|
450
|
+
client.transport.emit(OBSERVE_RESPONSE, reqId, ...statesInfos);
|
|
451
|
+
} else {
|
|
452
|
+
const msg = `Cannot observe, schema "${observedSchemaName}" does not exists`;
|
|
453
|
+
client.transport.emit(OBSERVE_ERROR, reqId, msg);
|
|
454
|
+
}
|
|
443
455
|
});
|
|
444
456
|
|
|
445
457
|
client.transport.addListener(UNOBSERVE_NOTIFICATION, () => {
|
|
@@ -554,7 +566,7 @@ class StateManager extends BaseStateManager {
|
|
|
554
566
|
attached.transport.emit(`${DELETE_NOTIFICATION}-${state.id}-${remoteId}`);
|
|
555
567
|
}
|
|
556
568
|
|
|
557
|
-
this._serverStatesById.delete(
|
|
569
|
+
this._serverStatesById.delete(state.id);
|
|
558
570
|
}
|
|
559
571
|
}
|
|
560
572
|
|
package/src/server/crypto.js
CHANGED
|
@@ -23,16 +23,15 @@ const encryptionIV = crypto
|
|
|
23
23
|
|
|
24
24
|
export function encryptData(obj) {
|
|
25
25
|
const data = JSON.stringify(obj);
|
|
26
|
-
const cipher = crypto.createCipheriv(encryptionMethod, key, encryptionIV)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
).toString('base64')
|
|
26
|
+
const cipher = crypto.createCipheriv(encryptionMethod, key, encryptionIV);
|
|
27
|
+
|
|
28
|
+
return Buffer.from(cipher.update(data, 'utf8', 'hex') + cipher.final('hex')).toString('base64');
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
export function decryptData(encryptedData) {
|
|
33
|
-
const buff = Buffer.from(encryptedData, 'base64')
|
|
34
|
-
const decipher = crypto.createDecipheriv(encryptionMethod, key, encryptionIV)
|
|
32
|
+
const buff = Buffer.from(encryptedData, 'base64');
|
|
33
|
+
const decipher = crypto.createDecipheriv(encryptionMethod, key, encryptionIV);
|
|
35
34
|
|
|
36
|
-
const decrypted = `${decipher.update(buff.toString('utf8'), 'hex', 'utf8')}${decipher.final('utf8')}
|
|
35
|
+
const decrypted = `${decipher.update(buff.toString('utf8'), 'hex', 'utf8')}${decipher.final('utf8')}`;
|
|
37
36
|
return JSON.parse(decrypted);
|
|
38
37
|
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { idGenerator } from '@ircam/sc-utils';
|
|
2
|
-
|
|
3
|
-
const generateRequestId = idGenerator();
|
|
4
|
-
const requestPromises = new Map();
|
|
5
|
-
|
|
6
|
-
export function storeRequestPromise(resolve, reject) {
|
|
7
|
-
const reqId = generateRequestId.next().value;
|
|
8
|
-
requestPromises.set(reqId, { resolve, reject });
|
|
9
|
-
|
|
10
|
-
return reqId;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function resolveRequest(reqId, data) {
|
|
14
|
-
const { resolve } = requestPromises.get(reqId);
|
|
15
|
-
requestPromises.delete(reqId);
|
|
16
|
-
|
|
17
|
-
resolve(data);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function rejectRequest(reqId, data) {
|
|
21
|
-
const { reject } = requestPromises.get(reqId);
|
|
22
|
-
requestPromises.delete(reqId);
|
|
23
|
-
|
|
24
|
-
reject(data);
|
|
25
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"include": ["src/**/*.js"],
|
|
3
|
-
|
|
4
|
-
"compilerOptions": {
|
|
5
|
-
// Tells TypeScript to read JS files, as
|
|
6
|
-
// normally they are ignored as source files
|
|
7
|
-
"allowJs": true,
|
|
8
|
-
// Generate d.ts files
|
|
9
|
-
"declaration": true,
|
|
10
|
-
// This compiler run should
|
|
11
|
-
// only output d.ts files
|
|
12
|
-
"emitDeclarationOnly": true,
|
|
13
|
-
// Types should go into this directory.
|
|
14
|
-
// Removing this would place the .d.ts files
|
|
15
|
-
// next to the .js files
|
|
16
|
-
"outDir": "types",
|
|
17
|
-
// go to js file when using IDE functions like
|
|
18
|
-
// "Go to Definition" in VSCode
|
|
19
|
-
"declarationMap": true
|
|
20
|
-
}
|
|
21
|
-
}
|