@soundworks/core 4.0.0-alpha.19 → 4.0.0-alpha.20
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 +6 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/client/Socket.js +44 -35
- package/src/common/BaseSharedStateCollection.js +16 -19
- package/src/common/BaseStateManager.js +43 -24
- package/src/common/ParameterBag.js +0 -1
- package/src/common/SharedStatePrivate.js +47 -85
- package/src/common/constants.js +9 -0
- package/src/server/Server.js +11 -7
- package/src/server/Socket.js +49 -48
- package/src/server/StateManager.js +25 -9
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ npx @soundworks/create@latest
|
|
|
24
24
|
|
|
25
25
|

|
|
26
26
|
|
|
27
|
-
See [https://soundworks.dev/tutorials/getting-started.html](https://soundworks.dev/tutorials/getting-started.html) for more
|
|
27
|
+
See [https://soundworks.dev/tutorials/getting-started.html](https://soundworks.dev/tutorials/getting-started.html) for more information on the wizard and how to start using **soundworks**.
|
|
28
28
|
|
|
29
29
|
## Misc
|
|
30
30
|
|
|
@@ -36,7 +36,7 @@ However, for maintenance reasons, we aim at following the TC39 and W3C specifica
|
|
|
36
36
|
|
|
37
37
|
### Manual install
|
|
38
38
|
|
|
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
|
|
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 information on the **soundworks** wizard.
|
|
40
40
|
|
|
41
41
|
```
|
|
42
42
|
npm install @soundworks/core
|
package/package.json
CHANGED
package/src/client/Socket.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { isBrowser } from '@ircam/sc-utils';
|
|
2
2
|
import WebSocket from 'isomorphic-ws';
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
PING_INTERVAL,
|
|
6
|
+
PING_LATENCY_TOLERANCE,
|
|
7
|
+
PING_MESSAGE,
|
|
8
|
+
PONG_MESSAGE,
|
|
9
|
+
} from '../common/constants.js';
|
|
10
|
+
import logger from '../common/logger.js';
|
|
4
11
|
import {
|
|
5
12
|
packBinaryMessage,
|
|
6
13
|
unpackBinaryMessage,
|
|
7
14
|
packStringMessage,
|
|
8
15
|
unpackStringMessage,
|
|
9
16
|
} from '../common/sockets-utils.js';
|
|
10
|
-
import logger from '../common/logger.js';
|
|
11
17
|
|
|
12
18
|
// WebSocket events:
|
|
13
19
|
//
|
|
@@ -64,6 +70,8 @@ class Socket {
|
|
|
64
70
|
this._stringListeners = new Map();
|
|
65
71
|
/** @private */
|
|
66
72
|
this._binaryListeners = new Map();
|
|
73
|
+
/** @private */
|
|
74
|
+
this._heartbeatId = null;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
/**
|
|
@@ -130,20 +138,51 @@ class Socket {
|
|
|
130
138
|
ws.addEventListener('open', connectEvent => {
|
|
131
139
|
// parse incoming messages for pubsub
|
|
132
140
|
this.ws = ws;
|
|
141
|
+
// ping / pong to check connection
|
|
142
|
+
const heartbeat = () => {
|
|
143
|
+
clearTimeout(this._heartbeatId);
|
|
144
|
+
|
|
145
|
+
this._heartbeatId = setTimeout(() => {
|
|
146
|
+
this.terminate();
|
|
147
|
+
}, PING_INTERVAL + PING_LATENCY_TOLERANCE);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
heartbeat();
|
|
151
|
+
|
|
133
152
|
this.ws.addEventListener('message', e => {
|
|
153
|
+
if (e.data === PING_MESSAGE) {
|
|
154
|
+
heartbeat();
|
|
155
|
+
this.ws.send(PONG_MESSAGE);
|
|
156
|
+
// do not propagate ping / pong messages
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
134
160
|
const [channel, args] = unpackStringMessage(e.data);
|
|
135
161
|
this._emit(false, channel, ...args);
|
|
136
162
|
});
|
|
137
163
|
|
|
138
164
|
// broadcast all `WebSocket` native events
|
|
139
|
-
|
|
140
|
-
this.
|
|
141
|
-
|
|
142
|
-
|
|
165
|
+
this.ws.addEventListener('close', (e) => {
|
|
166
|
+
clearTimeout(this._heartbeatId);
|
|
167
|
+
this._emit(false, 'close', e);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
this.ws.addEventListener('error', (e) => {
|
|
171
|
+
clearTimeout(this._heartbeatId);
|
|
172
|
+
this._emit(false, 'error', e);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
this.ws.addEventListener('upgrade', (e) => {
|
|
176
|
+
this._emit(false, 'upgrade', e);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
this.ws.addEventListener('message', (e) => {
|
|
180
|
+
this._emit(false, 'message', e);
|
|
143
181
|
});
|
|
144
182
|
|
|
145
183
|
// forward open event
|
|
146
184
|
this._emit(false, 'open', connectEvent);
|
|
185
|
+
|
|
147
186
|
// continue with raw socket
|
|
148
187
|
resolve();
|
|
149
188
|
});
|
|
@@ -173,36 +212,6 @@ class Socket {
|
|
|
173
212
|
trySocket();
|
|
174
213
|
});
|
|
175
214
|
|
|
176
|
-
// @todo - review/fix
|
|
177
|
-
// - the `ws.on` method only exists on node implementation, and the 'ping'
|
|
178
|
-
// message is not received on addEventListener
|
|
179
|
-
// - there seems to be no way to access the ping event in browsers...
|
|
180
|
-
//
|
|
181
|
-
// let pingTimeoutId = null;
|
|
182
|
-
// const pingInterval = config.env.websockets.pingInterval;
|
|
183
|
-
// // detect broken connection
|
|
184
|
-
// // cf. https://github.com/websockets/ws#how-to-detect-and-close-broken-connections
|
|
185
|
-
// const heartbeat = () => {
|
|
186
|
-
// try {
|
|
187
|
-
// console.log('ping received');
|
|
188
|
-
// clearTimeout(pingTimeoutId);
|
|
189
|
-
|
|
190
|
-
// // pingTimeoutId = setTimeout(() => {
|
|
191
|
-
// // console.log('terminate');
|
|
192
|
-
// // this.terminate();
|
|
193
|
-
// // }, pingInterval + 2000);
|
|
194
|
-
// } catch (err) {
|
|
195
|
-
// console.error(err);
|
|
196
|
-
// }
|
|
197
|
-
// };
|
|
198
|
-
//
|
|
199
|
-
// this.ws.on('ping', heartbeat);
|
|
200
|
-
// this.ws.addEventListener('close', () => {
|
|
201
|
-
// clearTimeout(pingTimeoutId);
|
|
202
|
-
// });
|
|
203
|
-
|
|
204
|
-
// heartbeat();
|
|
205
|
-
|
|
206
215
|
// ----------------------------------------------------------
|
|
207
216
|
// init binary socket
|
|
208
217
|
// ----------------------------------------------------------
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
kCreateCollectionController,
|
|
3
|
-
kAttachInCollection,
|
|
4
|
-
} from './BaseStateManager.js';
|
|
5
|
-
|
|
6
1
|
/** @private */
|
|
7
2
|
class BaseSharedStateCollection {
|
|
8
3
|
constructor(stateManager, schemaName, options = {}) {
|
|
@@ -10,7 +5,7 @@ class BaseSharedStateCollection {
|
|
|
10
5
|
this._schemaName = schemaName;
|
|
11
6
|
this._options = Object.assign({ excludeLocal: false }, options);
|
|
12
7
|
|
|
13
|
-
this.
|
|
8
|
+
this._schema = null;
|
|
14
9
|
this._states = [];
|
|
15
10
|
|
|
16
11
|
this._onUpdateCallbacks = new Set();
|
|
@@ -21,15 +16,10 @@ class BaseSharedStateCollection {
|
|
|
21
16
|
|
|
22
17
|
/** @private */
|
|
23
18
|
async _init() {
|
|
24
|
-
this.
|
|
25
|
-
this._controller.onUpdate(async (updates, context) => {
|
|
26
|
-
for (let state of this._states) {
|
|
27
|
-
await state._commit(updates, context, true, false);
|
|
28
|
-
}
|
|
29
|
-
});
|
|
19
|
+
this._schema = await this._stateManager.getSchema(this._schemaName);
|
|
30
20
|
|
|
31
21
|
this._unobserve = await this._stateManager.observe(this._schemaName, async (schemaName, stateId) => {
|
|
32
|
-
const state = await this._stateManager
|
|
22
|
+
const state = await this._stateManager.attach(schemaName, stateId);
|
|
33
23
|
this._states.push(state);
|
|
34
24
|
|
|
35
25
|
state.onDetach(() => {
|
|
@@ -52,6 +42,7 @@ class BaseSharedStateCollection {
|
|
|
52
42
|
/**
|
|
53
43
|
* Size of the collection, alias `size`
|
|
54
44
|
* @type {number}
|
|
45
|
+
* @readonly
|
|
55
46
|
*/
|
|
56
47
|
get length() {
|
|
57
48
|
return this._states.length;
|
|
@@ -60,6 +51,7 @@ class BaseSharedStateCollection {
|
|
|
60
51
|
/**
|
|
61
52
|
* Size of the collection, , alias `length`
|
|
62
53
|
* @type {number}
|
|
54
|
+
* @readonly
|
|
63
55
|
*/
|
|
64
56
|
get size() {
|
|
65
57
|
return this._states.length;
|
|
@@ -71,7 +63,7 @@ class BaseSharedStateCollection {
|
|
|
71
63
|
* @readonly
|
|
72
64
|
*/
|
|
73
65
|
get schemaName() {
|
|
74
|
-
return this.
|
|
66
|
+
return this._schemaName;
|
|
75
67
|
}
|
|
76
68
|
|
|
77
69
|
/**
|
|
@@ -86,7 +78,11 @@ class BaseSharedStateCollection {
|
|
|
86
78
|
* const schema = collection.getSchema();
|
|
87
79
|
*/
|
|
88
80
|
getSchema(name = null) {
|
|
89
|
-
|
|
81
|
+
if (name) {
|
|
82
|
+
return this._schema[name];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return this._schema;
|
|
90
86
|
}
|
|
91
87
|
|
|
92
88
|
/**
|
|
@@ -97,7 +93,11 @@ class BaseSharedStateCollection {
|
|
|
97
93
|
* const defaults = state.getDefaults();
|
|
98
94
|
*/
|
|
99
95
|
getDefaults() {
|
|
100
|
-
|
|
96
|
+
const defaults = {};
|
|
97
|
+
for (let name in this._schema) {
|
|
98
|
+
defaults[name] = this._schema[name].default;
|
|
99
|
+
}
|
|
100
|
+
return defaults;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
@@ -130,7 +130,6 @@ class BaseSharedStateCollection {
|
|
|
130
130
|
// to be cleaned soon
|
|
131
131
|
const promises = this._states.map(state => state.set(updates, context));
|
|
132
132
|
return Promise.all(promises);
|
|
133
|
-
// return await this._controller.set(updates, context);
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
/**
|
|
@@ -199,8 +198,6 @@ class BaseSharedStateCollection {
|
|
|
199
198
|
this._unobserve();
|
|
200
199
|
this._onUpdateCallbacks.clear();
|
|
201
200
|
|
|
202
|
-
await this._controller.delete();
|
|
203
|
-
|
|
204
201
|
const promises = this._states.map(state => state.detach());
|
|
205
202
|
await Promise.all(promises);
|
|
206
203
|
|
|
@@ -2,6 +2,7 @@ import { isString, isFunction, isPlainObject } from '@ircam/sc-utils';
|
|
|
2
2
|
import SharedState from './BaseSharedState.js';
|
|
3
3
|
import SharedStateCollection from './BaseSharedStateCollection.js';
|
|
4
4
|
import BatchedTransport from './BatchedTransport.js';
|
|
5
|
+
import ParameterBag from './ParameterBag.js';
|
|
5
6
|
import PromiseStore from './PromiseStore.js';
|
|
6
7
|
import {
|
|
7
8
|
CREATE_REQUEST,
|
|
@@ -16,11 +17,11 @@ import {
|
|
|
16
17
|
OBSERVE_NOTIFICATION,
|
|
17
18
|
UNOBSERVE_NOTIFICATION,
|
|
18
19
|
DELETE_SCHEMA,
|
|
20
|
+
GET_SCHEMA_REQUEST,
|
|
21
|
+
GET_SCHEMA_RESPONSE,
|
|
22
|
+
GET_SCHEMA_ERROR,
|
|
19
23
|
} from './constants.js';
|
|
20
24
|
|
|
21
|
-
export const kCreateCollectionController = Symbol('BaseStateManager::createCollectionController');
|
|
22
|
-
export const kAttachInCollection = Symbol('BaseStateManager::attachInCollection');
|
|
23
|
-
|
|
24
25
|
/**
|
|
25
26
|
* @private
|
|
26
27
|
*/
|
|
@@ -150,6 +151,22 @@ class BaseStateManager {
|
|
|
150
151
|
this.client.transport.addListener(DELETE_SCHEMA, schemaName => {
|
|
151
152
|
this._cachedSchemas.delete(schemaName);
|
|
152
153
|
});
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------
|
|
156
|
+
// GET SCHEMA
|
|
157
|
+
// ---------------------------------------------
|
|
158
|
+
this.client.transport.addListener(GET_SCHEMA_RESPONSE, (reqId, schemaName, schema) => {
|
|
159
|
+
if (!this._cachedSchemas.has(schemaName)) {
|
|
160
|
+
this._cachedSchemas.set(schemaName, schema);
|
|
161
|
+
}
|
|
162
|
+
// return a populated schema
|
|
163
|
+
const parameterBag = new ParameterBag(schema);
|
|
164
|
+
this._promiseStore.resolve(reqId, parameterBag.getSchema());
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
this.client.transport.addListener(GET_SCHEMA_ERROR, (reqId, msg) => {
|
|
168
|
+
this._promiseStore.reject(reqId, msg);
|
|
169
|
+
});
|
|
153
170
|
}
|
|
154
171
|
|
|
155
172
|
/** @private */
|
|
@@ -167,6 +184,28 @@ class BaseStateManager {
|
|
|
167
184
|
return filter;
|
|
168
185
|
}
|
|
169
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Return the schema from a given registered schema name
|
|
189
|
+
*
|
|
190
|
+
* @param {String} schemaName - Name of the schema as given on registration
|
|
191
|
+
* (cf. ServerStateManager)
|
|
192
|
+
* @example
|
|
193
|
+
* const schema = await client.stateManager.getSchema('my-schema');
|
|
194
|
+
*/
|
|
195
|
+
async getSchema(schemaName) {
|
|
196
|
+
if (this._cachedSchemas.has(schemaName)) {
|
|
197
|
+
const schema = this._cachedSchemas.get(schemaName);
|
|
198
|
+
// return a populated schema
|
|
199
|
+
const parameterBag = new ParameterBag(schema);
|
|
200
|
+
return parameterBag.getSchema();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
const reqId = this._promiseStore.add(resolve, reject, 'get-schema');
|
|
205
|
+
this.client.transport.emit(GET_SCHEMA_REQUEST, reqId, schemaName);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
170
209
|
/**
|
|
171
210
|
* Create a `SharedState` instance from a registered schema.
|
|
172
211
|
*
|
|
@@ -187,18 +226,6 @@ class BaseStateManager {
|
|
|
187
226
|
});
|
|
188
227
|
}
|
|
189
228
|
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* @private
|
|
193
|
-
*/
|
|
194
|
-
async [kCreateCollectionController](schemaName) {
|
|
195
|
-
return new Promise((resolve, reject) => {
|
|
196
|
-
const reqId = this._promiseStore.add(resolve, reject, 'create-collection-controller-request');
|
|
197
|
-
const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
|
|
198
|
-
this.client.transport.emit(CREATE_REQUEST, reqId, schemaName, requireSchema, {}, true);
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
229
|
/**
|
|
203
230
|
* Attach to an existing `SharedState` instance.
|
|
204
231
|
*
|
|
@@ -221,14 +248,6 @@ class BaseStateManager {
|
|
|
221
248
|
});
|
|
222
249
|
}
|
|
223
250
|
|
|
224
|
-
async [kAttachInCollection](schemaName, stateId) {
|
|
225
|
-
return new Promise((resolve, reject) => {
|
|
226
|
-
const reqId = this._promiseStore.add(resolve, reject, 'attach-in-collection-request');
|
|
227
|
-
const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
|
|
228
|
-
this.client.transport.emit(ATTACH_REQUEST, reqId, schemaName, stateId, requireSchema, true);
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
|
|
232
251
|
/**
|
|
233
252
|
* Observe all the `SharedState` instances that are created on the network.
|
|
234
253
|
* This can be usefull for clients with some controller role that might want to track
|
|
@@ -391,7 +410,7 @@ class BaseStateManager {
|
|
|
391
410
|
await collection._init();
|
|
392
411
|
} catch (err) {
|
|
393
412
|
console.log(err.message);
|
|
394
|
-
throw new Error(`Cannot create collection, schema "${schemaName}" does not exists`);
|
|
413
|
+
throw new Error(`[stateManager] Cannot create collection, schema "${schemaName}" does not exists`);
|
|
395
414
|
}
|
|
396
415
|
|
|
397
416
|
return collection;
|
|
@@ -14,13 +14,13 @@ import {
|
|
|
14
14
|
/**
|
|
15
15
|
* The "real" state, this instance is kept private by the server.StateManager.
|
|
16
16
|
* It can only be accessed through a SharedState proxy.
|
|
17
|
+
*
|
|
17
18
|
* @private
|
|
18
19
|
*/
|
|
19
20
|
class SharedStatePrivate {
|
|
20
21
|
constructor(id, schemaName, schema, manager, initValues = {}) {
|
|
21
22
|
this.id = id;
|
|
22
23
|
this.schemaName = schemaName;
|
|
23
|
-
this.isCollectionController = false;
|
|
24
24
|
|
|
25
25
|
this._manager = manager;
|
|
26
26
|
this._parameters = new ParameterBag(schema, initValues);
|
|
@@ -30,16 +30,11 @@ class SharedStatePrivate {
|
|
|
30
30
|
this._creatorId = null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
_attachClient(remoteId, client, isOwner = false
|
|
34
|
-
|
|
35
|
-
// isOwner && inCollection -> collection controller
|
|
36
|
-
// !isOwner && inCollection -> attached in collection
|
|
37
|
-
|
|
38
|
-
const clientInfos = { client, isOwner, inCollection };
|
|
33
|
+
_attachClient(remoteId, client, isOwner = false) {
|
|
34
|
+
const clientInfos = { client, isOwner };
|
|
39
35
|
this._attachedClients.set(remoteId, clientInfos);
|
|
40
36
|
|
|
41
37
|
if (isOwner) {
|
|
42
|
-
this.isCollectionController = inCollection;
|
|
43
38
|
this._creatorRemoteId = remoteId;
|
|
44
39
|
this._creatorId = client.id;
|
|
45
40
|
}
|
|
@@ -87,96 +82,63 @@ class SharedStatePrivate {
|
|
|
87
82
|
}
|
|
88
83
|
|
|
89
84
|
if (hasUpdates) {
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
85
|
+
// Normal case
|
|
86
|
+
//
|
|
87
|
+
// We need to handle cases where:
|
|
88
|
+
// - client state (client.id: 2) sends a request
|
|
89
|
+
// - server attached state (client.id: -1) spot a problem and overrides the value
|
|
90
|
+
// We want the remote client (id: 2) to receive in the right order:
|
|
91
|
+
// * 1. the value it requested,
|
|
92
|
+
// * 2. the value overriden by the server-side attached state (id: -1)
|
|
93
|
+
//
|
|
94
|
+
// such problem is now better solved with the the upateHook system, none
|
|
95
|
+
// nonetheway we don't want to introduce inconsistencies here
|
|
96
|
+
//
|
|
97
|
+
// Then we propagate server-side last, because as the server transport
|
|
98
|
+
// is synchronous it can break ordering if a subscription function makes
|
|
99
|
+
// itself an update in reaction to an update. Propagating to server last
|
|
100
|
+
// alllows to maintain network messages order consistent.
|
|
101
|
+
//
|
|
102
|
+
// @note - remoteId correspond to unique remote state id
|
|
103
|
+
|
|
104
|
+
// propagate RESPONSE to the client that originates the request if not the server
|
|
105
|
+
if (client.id !== -1) {
|
|
94
106
|
client.transport.emit(
|
|
95
107
|
`${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
|
|
96
108
|
reqId, filteredUpdates, context,
|
|
97
109
|
);
|
|
110
|
+
}
|
|
98
111
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (state !== this && this.schemaName === state.schemaName) {
|
|
103
|
-
// notify all attached clients except those who belong to a collection
|
|
104
|
-
// i.e. !isOwner && inCollection, they will be notified by their own
|
|
105
|
-
// collection controller
|
|
106
|
-
for (let [remoteId, clientInfos] of state._attachedClients) {
|
|
107
|
-
if (!clientInfos.isOwner && clientInfos.inCollection) {
|
|
108
|
-
continue;
|
|
109
|
-
} else {
|
|
110
|
-
const peer = clientInfos.client;
|
|
111
|
-
|
|
112
|
-
peer.transport.emit(
|
|
113
|
-
`${UPDATE_NOTIFICATION}-${state.id}-${remoteId}`,
|
|
114
|
-
filteredUpdates, context,
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
} else {
|
|
112
|
+
// propagate NOTIFICATION to all other attached clients except server
|
|
113
|
+
for (let [peerRemoteId, clientInfos] of this._attachedClients) {
|
|
114
|
+
const peer = clientInfos.client;
|
|
121
115
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// - server attached state (client.id: -1) spot a problem and overrides the value
|
|
127
|
-
// We want the remote client (id: 2) to receive in the right order:
|
|
128
|
-
// * 1. the value it requested,
|
|
129
|
-
// * 2. the value overriden by the server-side attached state (id: -1)
|
|
130
|
-
//
|
|
131
|
-
// such problem is now better solved with the the upateHook system, none
|
|
132
|
-
// nonetheway we don't want to introduce inconsistencies here
|
|
133
|
-
//
|
|
134
|
-
// Then we propagate server-side last, because as the server transport
|
|
135
|
-
// is synchronous it can break ordering if a subscription function makes
|
|
136
|
-
// itself an update in reaction to an update. Propagating to server last
|
|
137
|
-
// alllows to maintain network messages order consistent.
|
|
138
|
-
//
|
|
139
|
-
// @note - remoteId correspond to unique remote state id
|
|
140
|
-
|
|
141
|
-
// propagate RESPONSE to the client that originates the request if not the server
|
|
142
|
-
if (client.id !== -1) {
|
|
143
|
-
client.transport.emit(
|
|
144
|
-
`${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
|
|
145
|
-
reqId, filteredUpdates, context,
|
|
116
|
+
if (remoteId !== peerRemoteId && peer.id !== -1) {
|
|
117
|
+
peer.transport.emit(
|
|
118
|
+
`${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
|
|
119
|
+
filteredUpdates, context,
|
|
146
120
|
);
|
|
147
121
|
}
|
|
122
|
+
}
|
|
148
123
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
124
|
+
// propagate RESPONSE to server if it is the requester
|
|
125
|
+
if (client.id === -1) {
|
|
126
|
+
client.transport.emit(
|
|
127
|
+
`${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
|
|
128
|
+
reqId, filteredUpdates, context,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
152
131
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
filteredUpdates, context,
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
132
|
+
// propagate NOTIFICATION other attached state that belongs to server
|
|
133
|
+
for (let [peerRemoteId, clientInfos] of this._attachedClients) {
|
|
134
|
+
const peer = clientInfos.client;
|
|
160
135
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
reqId, filteredUpdates, context,
|
|
136
|
+
if (remoteId !== peerRemoteId && peer.id === -1) {
|
|
137
|
+
peer.transport.emit(
|
|
138
|
+
`${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
|
|
139
|
+
filteredUpdates, context,
|
|
166
140
|
);
|
|
167
141
|
}
|
|
168
|
-
|
|
169
|
-
// propagate NOTIFICATION other attached state that belongs to server
|
|
170
|
-
for (let [peerRemoteId, clientInfos] of this._attachedClients) {
|
|
171
|
-
const peer = clientInfos.client;
|
|
172
|
-
|
|
173
|
-
if (remoteId !== peerRemoteId && peer.id === -1) {
|
|
174
|
-
peer.transport.emit(
|
|
175
|
-
`${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
|
|
176
|
-
filteredUpdates, context,
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
142
|
}
|
|
181
143
|
} else {
|
|
182
144
|
// propagate back to the requester that the update has been aborted
|
package/src/common/constants.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// id of the server when owner of a state
|
|
2
2
|
export const SERVER_ID = -1;
|
|
3
3
|
|
|
4
|
+
export const PING_INTERVAL = 10 * 1000;
|
|
5
|
+
export const PING_LATENCY_TOLERANCE = 1000;
|
|
6
|
+
export const PING_MESSAGE = 'h:ping';
|
|
7
|
+
export const PONG_MESSAGE = 'h:pong';
|
|
8
|
+
|
|
4
9
|
// batched transport channel
|
|
5
10
|
export const BATCHED_TRANSPORT_CHANNEL = 'b:t';
|
|
6
11
|
|
|
@@ -36,6 +41,10 @@ export const UPDATE_NOTIFICATION = 's:u:not';
|
|
|
36
41
|
|
|
37
42
|
export const DELETE_SCHEMA = 's:d:s';
|
|
38
43
|
|
|
44
|
+
export const GET_SCHEMA_REQUEST = 's:s:req';
|
|
45
|
+
export const GET_SCHEMA_RESPONSE = 's:s:res';
|
|
46
|
+
export const GET_SCHEMA_ERROR = 's:s:err';
|
|
47
|
+
|
|
39
48
|
// context channels
|
|
40
49
|
export const CONTEXT_ENTER_REQUEST = 'c:en:req';
|
|
41
50
|
export const CONTEXT_ENTER_RESPONSE = 'c:en:res';
|
package/src/server/Server.js
CHANGED
|
@@ -9,6 +9,7 @@ import { isPlainObject, idGenerator, getTime } from '@ircam/sc-utils';
|
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import compression from 'compression';
|
|
11
11
|
import express from 'express';
|
|
12
|
+
import equal from 'fast-deep-equal';
|
|
12
13
|
import Keyv from 'keyv';
|
|
13
14
|
import { KeyvFile } from 'keyv-file';
|
|
14
15
|
import merge from 'lodash/merge.js';
|
|
@@ -214,11 +215,11 @@ class Server {
|
|
|
214
215
|
throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, should contain both "cert" and "key" entries`);
|
|
215
216
|
}
|
|
216
217
|
// @todo - move that to constructor
|
|
217
|
-
if (!fs.existsSync(httpsInfos.cert)) {
|
|
218
|
+
if (httpsInfos.cert !== null && !fs.existsSync(httpsInfos.cert)) {
|
|
218
219
|
throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, "cert" file not found`);
|
|
219
220
|
}
|
|
220
221
|
|
|
221
|
-
if (!fs.existsSync(httpsInfos.key)) {
|
|
222
|
+
if (httpsInfos.key !== null && !fs.existsSync(httpsInfos.key)) {
|
|
222
223
|
throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, "key" file not found`);
|
|
223
224
|
}
|
|
224
225
|
}
|
|
@@ -444,19 +445,22 @@ class Server {
|
|
|
444
445
|
this.router.use(soundworksAuth);
|
|
445
446
|
}
|
|
446
447
|
|
|
447
|
-
// start http server
|
|
448
|
-
const useHttps = this.config.env.useHttps || false;
|
|
449
|
-
|
|
450
448
|
// ------------------------------------------------------------
|
|
451
449
|
// create HTTP(S) SERVER
|
|
452
450
|
// ------------------------------------------------------------
|
|
451
|
+
const useHttps = this.config.env.useHttps || false;
|
|
452
|
+
|
|
453
453
|
if (!useHttps) {
|
|
454
454
|
this.httpServer = http.createServer(this.router);
|
|
455
455
|
} else {
|
|
456
456
|
const httpsInfos = this.config.env.httpsInfos;
|
|
457
|
+
let useSelfSigned = false;
|
|
458
|
+
|
|
459
|
+
if (!httpsInfos || equal(httpsInfos, { cert: null, key: null })) {
|
|
460
|
+
useSelfSigned = true;
|
|
461
|
+
}
|
|
457
462
|
|
|
458
|
-
|
|
459
|
-
if (httpsInfos !== null) {
|
|
463
|
+
if (!useSelfSigned) {
|
|
460
464
|
try {
|
|
461
465
|
// existance of file is checked in contructor
|
|
462
466
|
let cert = fs.readFileSync(httpsInfos.cert);
|
package/src/server/Socket.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { getTime } from '@ircam/sc-utils';
|
|
2
|
+
import {
|
|
3
|
+
PING_INTERVAL,
|
|
4
|
+
PING_MESSAGE,
|
|
5
|
+
PONG_MESSAGE,
|
|
6
|
+
} from '../common/constants.js';
|
|
2
7
|
import {
|
|
3
8
|
packBinaryMessage,
|
|
4
9
|
unpackBinaryMessage,
|
|
@@ -32,17 +37,7 @@ import {
|
|
|
32
37
|
* @hideconstructor
|
|
33
38
|
*/
|
|
34
39
|
class Socket {
|
|
35
|
-
constructor(ws, binaryWs, rooms, sockets
|
|
36
|
-
/**
|
|
37
|
-
* Configuration object
|
|
38
|
-
*
|
|
39
|
-
* @type {object}
|
|
40
|
-
*/
|
|
41
|
-
this.config = {
|
|
42
|
-
pingInterval: 5 * 1000,
|
|
43
|
-
...options,
|
|
44
|
-
};
|
|
45
|
-
|
|
40
|
+
constructor(ws, binaryWs, rooms, sockets) {
|
|
46
41
|
/**
|
|
47
42
|
* `ws` socket instance configured with `binaryType=blob` (string)
|
|
48
43
|
*
|
|
@@ -81,16 +76,53 @@ class Socket {
|
|
|
81
76
|
this._stringListeners = new Map();
|
|
82
77
|
/** @private */
|
|
83
78
|
this._binaryListeners = new Map();
|
|
79
|
+
/** @private */
|
|
80
|
+
this._heartbeatId = null;
|
|
81
|
+
|
|
82
|
+
// heartbeat system (run only on string socket), adapted from:
|
|
83
|
+
// https://github.com/websockets/ws#how-to-detect-and-close-broken-connections
|
|
84
|
+
/** @private */
|
|
85
|
+
this._isAlive = true;
|
|
86
|
+
let msg = {
|
|
87
|
+
type: 'add-measurement',
|
|
88
|
+
value: {
|
|
89
|
+
ping: 0,
|
|
90
|
+
pong: 0,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
84
93
|
|
|
85
94
|
// ----------------------------------------------------------
|
|
86
|
-
//
|
|
95
|
+
// String socket
|
|
96
|
+
// implements ping/pong behavior
|
|
87
97
|
// ----------------------------------------------------------
|
|
88
98
|
this.ws.addEventListener('message', e => {
|
|
99
|
+
if (e.data === PONG_MESSAGE) {
|
|
100
|
+
this._isAlive = true;
|
|
101
|
+
|
|
102
|
+
msg.value.pong = getTime();
|
|
103
|
+
this.sockets._latencyStatsWorker.postMessage(msg);
|
|
104
|
+
// do not propagate ping / pong messages
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
89
108
|
const [channel, args] = unpackStringMessage(e.data);
|
|
90
109
|
this._emit(false, channel, ...args);
|
|
91
110
|
});
|
|
92
111
|
|
|
93
|
-
|
|
112
|
+
this._heartbeatId = setInterval(() => {
|
|
113
|
+
if (this._isAlive === false) {
|
|
114
|
+
// emit a 'close' event to go trough all the disconnection pipeline
|
|
115
|
+
this._emit(false, 'close');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this._isAlive = false;
|
|
120
|
+
msg.value.ping = getTime();
|
|
121
|
+
|
|
122
|
+
this.ws.send(PING_MESSAGE);
|
|
123
|
+
}, PING_INTERVAL);
|
|
124
|
+
|
|
125
|
+
// broadcast all "native" events
|
|
94
126
|
[
|
|
95
127
|
'close',
|
|
96
128
|
'error',
|
|
@@ -107,14 +139,14 @@ class Socket {
|
|
|
107
139
|
});
|
|
108
140
|
|
|
109
141
|
// ----------------------------------------------------------
|
|
110
|
-
//
|
|
142
|
+
// Binary socket
|
|
111
143
|
// ----------------------------------------------------------
|
|
112
144
|
this.binaryWs.addEventListener('message', e => {
|
|
113
145
|
const [channel, data] = unpackBinaryMessage(e.data);
|
|
114
146
|
this._emit(true, channel, data);
|
|
115
147
|
});
|
|
116
148
|
|
|
117
|
-
// broadcast all
|
|
149
|
+
// broadcast all "native" events
|
|
118
150
|
[
|
|
119
151
|
'close',
|
|
120
152
|
'error',
|
|
@@ -129,38 +161,6 @@ class Socket {
|
|
|
129
161
|
this._emit(true, eventName, e.data);
|
|
130
162
|
});
|
|
131
163
|
});
|
|
132
|
-
|
|
133
|
-
// heartbeat system (run only on string socket), adapted from:
|
|
134
|
-
// https://github.com/websockets/ws#how-to-detect-and-close-broken-connections
|
|
135
|
-
this._isAlive = true;
|
|
136
|
-
let msg = {
|
|
137
|
-
type: 'add-measurement',
|
|
138
|
-
value: {
|
|
139
|
-
ping: 0,
|
|
140
|
-
pong: 0,
|
|
141
|
-
},
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
// heartbeat system, only on "regular" socket
|
|
145
|
-
this.ws.on('pong', () => {
|
|
146
|
-
this._isAlive = true;
|
|
147
|
-
|
|
148
|
-
msg.value.pong = getTime();
|
|
149
|
-
this.sockets._latencyStatsWorker.postMessage(msg);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
this._intervalId = setInterval(() => {
|
|
153
|
-
if (this._isAlive === false) {
|
|
154
|
-
// emit a 'close' event to go trough all the disconnection pipeline
|
|
155
|
-
this._emit(false, 'close');
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
this._isAlive = false;
|
|
160
|
-
msg.value.ping = getTime();
|
|
161
|
-
|
|
162
|
-
this.ws.ping();
|
|
163
|
-
}, this.config.pingInterval);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
/**
|
|
@@ -171,7 +171,8 @@ class Socket {
|
|
|
171
171
|
*/
|
|
172
172
|
terminate() {
|
|
173
173
|
// clear ping/pong check
|
|
174
|
-
clearInterval(this.
|
|
174
|
+
clearInterval(this._heartbeatId);
|
|
175
|
+
|
|
175
176
|
// clean rooms
|
|
176
177
|
for (let [_key, room] of this.rooms) {
|
|
177
178
|
room.delete(this);
|
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
OBSERVE_NOTIFICATION,
|
|
23
23
|
UNOBSERVE_NOTIFICATION,
|
|
24
24
|
DELETE_SCHEMA,
|
|
25
|
+
GET_SCHEMA_REQUEST,
|
|
26
|
+
GET_SCHEMA_RESPONSE,
|
|
27
|
+
GET_SCHEMA_ERROR,
|
|
25
28
|
PRIVATE_STATES,
|
|
26
29
|
} from '../common/constants.js';
|
|
27
30
|
|
|
@@ -355,7 +358,7 @@ class StateManager extends BaseStateManager {
|
|
|
355
358
|
// ---------------------------------------------
|
|
356
359
|
client.transport.addListener(
|
|
357
360
|
CREATE_REQUEST,
|
|
358
|
-
(reqId, schemaName, requireSchema, initValues = {}
|
|
361
|
+
(reqId, schemaName, requireSchema, initValues = {}) => {
|
|
359
362
|
if (this._schemas.has(schemaName)) {
|
|
360
363
|
try {
|
|
361
364
|
const schema = this._schemas.get(schemaName);
|
|
@@ -365,7 +368,7 @@ class StateManager extends BaseStateManager {
|
|
|
365
368
|
|
|
366
369
|
// attach client to the state as owner
|
|
367
370
|
const isOwner = true;
|
|
368
|
-
state._attachClient(remoteId, client, isOwner
|
|
371
|
+
state._attachClient(remoteId, client, isOwner);
|
|
369
372
|
|
|
370
373
|
this._sharedStatePrivateById.set(stateId, state);
|
|
371
374
|
|
|
@@ -385,13 +388,13 @@ class StateManager extends BaseStateManager {
|
|
|
385
388
|
});
|
|
386
389
|
}
|
|
387
390
|
} catch (err) {
|
|
388
|
-
const msg = `Cannot create state "${schemaName}", ${err.message}`;
|
|
391
|
+
const msg = `[stateManager] Cannot create state "${schemaName}", ${err.message}`;
|
|
389
392
|
console.error(msg);
|
|
390
393
|
|
|
391
394
|
client.transport.emit(CREATE_ERROR, reqId, msg);
|
|
392
395
|
}
|
|
393
396
|
} else {
|
|
394
|
-
const msg = `Cannot create state "${schemaName}", schema does not exists`;
|
|
397
|
+
const msg = `[stateManager] Cannot create state "${schemaName}", schema does not exists`;
|
|
395
398
|
console.error(msg);
|
|
396
399
|
|
|
397
400
|
client.transport.emit(CREATE_ERROR, reqId, msg);
|
|
@@ -404,7 +407,7 @@ class StateManager extends BaseStateManager {
|
|
|
404
407
|
// ---------------------------------------------
|
|
405
408
|
client.transport.addListener(
|
|
406
409
|
ATTACH_REQUEST,
|
|
407
|
-
(reqId, schemaName, stateId = null, requireSchema = true
|
|
410
|
+
(reqId, schemaName, stateId = null, requireSchema = true) => {
|
|
408
411
|
if (this._schemas.has(schemaName)) {
|
|
409
412
|
let state = null;
|
|
410
413
|
|
|
@@ -429,7 +432,7 @@ class StateManager extends BaseStateManager {
|
|
|
429
432
|
// i.e. same state -> several remote attach on the same node
|
|
430
433
|
const remoteId = generateRemoteId.next().value;
|
|
431
434
|
const isOwner = false;
|
|
432
|
-
state._attachClient(remoteId, client, isOwner
|
|
435
|
+
state._attachClient(remoteId, client, isOwner);
|
|
433
436
|
|
|
434
437
|
const currentValues = state._parameters.getValues();
|
|
435
438
|
const schema = this._schemas.get(schemaName);
|
|
@@ -441,13 +444,13 @@ class StateManager extends BaseStateManager {
|
|
|
441
444
|
);
|
|
442
445
|
|
|
443
446
|
} else {
|
|
444
|
-
const msg = `Cannot attach, no existing state for schema "${schemaName}" with stateId: "${stateId}"`;
|
|
447
|
+
const msg = `[stateManager] Cannot attach, no existing state for schema "${schemaName}" with stateId: "${stateId}"`;
|
|
445
448
|
console.error(msg);
|
|
446
449
|
|
|
447
450
|
client.transport.emit(ATTACH_ERROR, reqId, msg);
|
|
448
451
|
}
|
|
449
452
|
} else {
|
|
450
|
-
const msg = `Cannot attach, schema "${schemaName}" does not exists`;
|
|
453
|
+
const msg = `[stateManager] Cannot attach, schema "${schemaName}" does not exists`;
|
|
451
454
|
console.error(msg);
|
|
452
455
|
|
|
453
456
|
client.transport.emit(ATTACH_ERROR, reqId, msg);
|
|
@@ -477,7 +480,7 @@ class StateManager extends BaseStateManager {
|
|
|
477
480
|
|
|
478
481
|
client.transport.emit(OBSERVE_RESPONSE, reqId, ...statesInfos);
|
|
479
482
|
} else {
|
|
480
|
-
const msg = `Cannot observe, schema "${observedSchemaName}" does not exists`;
|
|
483
|
+
const msg = `[stateManager] Cannot observe, schema "${observedSchemaName}" does not exists`;
|
|
481
484
|
client.transport.emit(OBSERVE_ERROR, reqId, msg);
|
|
482
485
|
}
|
|
483
486
|
});
|
|
@@ -485,6 +488,19 @@ class StateManager extends BaseStateManager {
|
|
|
485
488
|
client.transport.addListener(UNOBSERVE_NOTIFICATION, () => {
|
|
486
489
|
this._observers.delete(client);
|
|
487
490
|
});
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------
|
|
493
|
+
// GET SCHEMA
|
|
494
|
+
// ---------------------------------------------
|
|
495
|
+
client.transport.addListener(GET_SCHEMA_REQUEST, (reqId, schemaName) => {
|
|
496
|
+
if (this._schemas.has(schemaName)) {
|
|
497
|
+
const schema = this._schemas.get(schemaName);
|
|
498
|
+
client.transport.emit(GET_SCHEMA_RESPONSE, reqId, schemaName, schema);
|
|
499
|
+
} else {
|
|
500
|
+
const msg = `[stateManager] Cannot get schema, schema "${schemaName}" does not exists`;
|
|
501
|
+
client.transport.emit(GET_SCHEMA_ERROR, reqId, msg);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
488
504
|
}
|
|
489
505
|
|
|
490
506
|
/**
|