@soundworks/core 4.0.0-alpha.7 → 5.0.0
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 +102 -0
- package/README.md +16 -50
- package/package.json +26 -44
- package/src/client/Client.js +306 -256
- package/src/client/{Context.js → ClientContext.js} +63 -54
- package/src/client/ClientContextManager.js +115 -0
- package/src/client/{Plugin.js → ClientPlugin.js} +23 -19
- package/src/client/{PluginManager.js → ClientPluginManager.js} +43 -40
- package/src/client/ClientSocket.js +270 -0
- package/src/client/{StateManager.js → ClientStateManager.js} +13 -13
- package/src/client/index.js +36 -39
- package/src/common/BasePlugin.js +63 -58
- package/src/common/BasePluginManager.js +191 -142
- package/src/common/BaseStateManager.js +636 -143
- package/src/common/BatchedTransport.js +73 -0
- package/src/common/ParameterBag.js +90 -93
- package/src/common/PromiseStore.js +54 -0
- package/src/common/SharedState.js +821 -0
- package/src/common/SharedStateCollection.js +492 -0
- package/src/common/constants.js +14 -2
- package/src/common/logger.js +64 -68
- package/src/common/shared-state-types.js +82 -0
- package/src/common/sockets-utils.js +0 -64
- package/src/common/version.js +2 -0
- package/src/server/Server.js +559 -821
- package/src/server/ServerClient.js +74 -0
- package/src/server/{Context.js → ServerContext.js} +96 -77
- package/src/server/{ContextManager.js → ServerContextManager.js} +107 -87
- package/src/server/{Plugin.js → ServerPlugin.js} +30 -32
- package/src/server/ServerPluginManager.js +177 -0
- package/src/server/ServerSocket.js +256 -0
- package/src/server/ServerSockets.js +150 -0
- package/src/server/ServerStateManager.js +706 -0
- package/src/server/SharedStatePrivate.js +277 -0
- package/src/server/audit-network-latency.worker.js +19 -4
- package/src/server/{audit-schema.js → audit-state-class-description.js} +3 -2
- package/src/server/create-http-server.js +127 -0
- package/src/server/crypto.js +8 -7
- package/src/server/index.js +60 -17
- package/types/client/Client.d.ts +95 -70
- package/types/client/{Context.d.ts → ClientContext.d.ts} +59 -34
- package/types/client/{ContextManager.d.ts → ClientContextManager.d.ts} +19 -22
- package/types/client/ClientPlugin.d.ts +51 -0
- package/types/client/ClientPluginManager.d.ts +112 -0
- package/types/client/ClientSocket.d.ts +76 -0
- package/types/client/{StateManager.d.ts → ClientStateManager.d.ts} +18 -21
- package/types/client/index.d.ts +33 -6
- package/types/common/BasePlugin.d.ts +64 -53
- package/types/common/BasePluginManager.d.ts +84 -39
- package/types/common/BaseStateManager.d.ts +227 -49
- package/types/common/BatchedTransport.d.ts +17 -0
- package/types/common/ParameterBag.d.ts +73 -76
- package/types/common/PromiseStore.d.ts +11 -0
- package/types/common/SharedState.d.ts +291 -0
- package/types/common/SharedStateCollection.d.ts +311 -0
- package/types/common/constants.d.ts +10 -3
- package/types/common/logger.d.ts +8 -6
- package/types/common/shared-state-types.d.ts +95 -0
- package/types/common/sockets-utils.d.ts +0 -5
- package/types/common/version.d.ts +2 -0
- package/types/server/Server.d.ts +215 -136
- package/types/server/ServerClient.d.ts +45 -0
- package/types/server/ServerContext.d.ts +209 -0
- package/types/server/ServerContextManager.d.ts +75 -0
- package/types/server/ServerPlugin.d.ts +57 -0
- package/types/server/ServerPluginManager.d.ts +115 -0
- package/types/server/ServerSocket.d.ts +69 -0
- package/types/server/ServerSockets.d.ts +42 -0
- package/types/server/ServerStateManager.d.ts +284 -0
- package/types/server/SharedStatePrivate.d.ts +23 -0
- package/types/server/audit-network-latency.worker.d.ts +2 -2
- package/types/server/{audit-schema.d.ts → audit-state-class-description.d.ts} +8 -9
- package/types/server/create-http-server.d.ts +5 -0
- package/types/server/crypto.d.ts +4 -0
- package/types/server/index.d.ts +51 -3
- package/.eslintrc +0 -15
- package/.github/workflows/gh-pages.yml +0 -31
- package/.github/workflows/unit-tests.yml +0 -18
- package/src/client/ContextManager.js +0 -111
- package/src/client/SharedState.js +0 -84
- package/src/client/SharedStateCollection.js +0 -35
- package/src/client/Socket.js +0 -402
- package/src/common/BaseSharedState.js +0 -517
- package/src/common/BaseSharedStateCollection.js +0 -179
- package/src/common/SharedStatePrivate.js +0 -192
- package/src/common/promise-store.js +0 -25
- package/src/server/Client.js +0 -55
- package/src/server/PluginManager.js +0 -170
- package/src/server/SharedState.js +0 -85
- package/src/server/SharedStateCollection.js +0 -36
- package/src/server/Socket.js +0 -396
- package/src/server/Sockets.js +0 -224
- package/src/server/StateManager.js +0 -620
- package/tsconfig.json +0 -21
- package/types/client/Client.d.ts.map +0 -1
- package/types/client/Context.d.ts.map +0 -1
- package/types/client/ContextManager.d.ts.map +0 -1
- package/types/client/Plugin.d.ts +0 -53
- package/types/client/Plugin.d.ts.map +0 -1
- package/types/client/PluginManager.d.ts +0 -96
- package/types/client/PluginManager.d.ts.map +0 -1
- package/types/client/SharedState.d.ts +0 -85
- package/types/client/SharedState.d.ts.map +0 -1
- package/types/client/Socket.d.ts +0 -138
- package/types/client/Socket.d.ts.map +0 -1
- package/types/client/StateManager.d.ts.map +0 -1
- package/types/client/index.d.ts.map +0 -1
- package/types/common/BasePlugin.d.ts.map +0 -1
- package/types/common/BasePluginManager.d.ts.map +0 -1
- package/types/common/BaseSharedState.d.ts +0 -173
- package/types/common/BaseSharedState.d.ts.map +0 -1
- package/types/common/BaseStateManager.d.ts.map +0 -1
- package/types/common/ParameterBag.d.ts.map +0 -1
- package/types/common/SharedStatePrivate.d.ts +0 -20
- package/types/common/SharedStatePrivate.d.ts.map +0 -1
- package/types/common/constants.d.ts.map +0 -1
- package/types/common/logger.d.ts.map +0 -1
- package/types/common/promise-store.d.ts +0 -4
- package/types/common/promise-store.d.ts.map +0 -1
- package/types/common/sockets-utils.d.ts.map +0 -1
- package/types/common/utils.d.ts +0 -5
- package/types/common/utils.d.ts.map +0 -1
- package/types/server/Client.d.ts +0 -40
- package/types/server/Client.d.ts.map +0 -1
- package/types/server/Context.d.ts +0 -59
- package/types/server/Context.d.ts.map +0 -1
- package/types/server/ContextManager.d.ts +0 -69
- package/types/server/ContextManager.d.ts.map +0 -1
- package/types/server/Plugin.d.ts +0 -70
- package/types/server/Plugin.d.ts.map +0 -1
- package/types/server/PluginManager.d.ts +0 -91
- package/types/server/PluginManager.d.ts.map +0 -1
- package/types/server/Server.d.ts.map +0 -1
- package/types/server/SharedState.d.ts +0 -86
- package/types/server/SharedState.d.ts.map +0 -1
- package/types/server/Socket.d.ts +0 -128
- package/types/server/Socket.d.ts.map +0 -1
- package/types/server/Sockets.d.ts +0 -73
- package/types/server/Sockets.d.ts.map +0 -1
- package/types/server/StateManager.d.ts +0 -190
- package/types/server/StateManager.d.ts.map +0 -1
- package/types/server/audit-network-latency.worker.d.ts.map +0 -1
- package/types/server/audit-schema.d.ts.map +0 -1
- package/types/server/index.d.ts.map +0 -1
|
@@ -1,18 +1,5 @@
|
|
|
1
1
|
import BasePlugin from '../common/BasePlugin.js';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Callback executed when the plugin state is updated.
|
|
5
|
-
*
|
|
6
|
-
* @callback server.Plugin~onStateChangeCallback
|
|
7
|
-
* @param {server.Plugin#state} state - Current state of the plugin.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Delete the registered {@link server.Plugin~onStateChangeCallback}.
|
|
12
|
-
*
|
|
13
|
-
* @callback server.Plugin~deleteOnStateChangeCallback
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
3
|
/**
|
|
17
4
|
* Base class to extend in order to create the server-side counterpart of a
|
|
18
5
|
* `soundworks` plugin.
|
|
@@ -24,32 +11,43 @@ import BasePlugin from '../common/BasePlugin.js';
|
|
|
24
11
|
* have both a client-side and a server-side part.
|
|
25
12
|
*
|
|
26
13
|
* See [https://soundworks.dev/guide/ecosystem](https://soundworks.dev/guide/ecosystem)
|
|
27
|
-
* for more
|
|
14
|
+
* for more information on the available plugins.
|
|
28
15
|
*
|
|
29
16
|
* _Creating new plugins should be considered an advanced usage._
|
|
30
17
|
*
|
|
31
|
-
* @
|
|
32
|
-
* @extends BasePlugin
|
|
18
|
+
* @extends {BasePlugin}
|
|
33
19
|
* @inheritdoc
|
|
34
20
|
*/
|
|
35
|
-
class
|
|
21
|
+
class ServerPlugin extends BasePlugin {
|
|
22
|
+
#server = null;
|
|
23
|
+
#clients = new Set();
|
|
24
|
+
|
|
36
25
|
/**
|
|
37
|
-
* @param {
|
|
26
|
+
* @param {Server} server - The soundworks server instance.
|
|
38
27
|
* @param {string} id - User defined id of the plugin as defined in
|
|
39
|
-
* {@link
|
|
28
|
+
* {@link ServerPluginManager#register}.
|
|
40
29
|
*/
|
|
41
30
|
constructor(server, id) {
|
|
42
31
|
super(id);
|
|
32
|
+
this.#server = server;
|
|
33
|
+
}
|
|
43
34
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Instance of soundworks server.
|
|
37
|
+
* @type {Server}
|
|
38
|
+
* @see {@link Server}
|
|
39
|
+
*/
|
|
40
|
+
get server() {
|
|
41
|
+
return this.#server;
|
|
42
|
+
}
|
|
50
43
|
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Set of the clients registered in the plugin.
|
|
46
|
+
* @type {Set<ServerClient>}
|
|
47
|
+
* @see {@link ServerClient}
|
|
48
|
+
*/
|
|
49
|
+
get clients() {
|
|
50
|
+
return this.#clients;
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
/**
|
|
@@ -57,10 +55,10 @@ class Plugin extends BasePlugin {
|
|
|
57
55
|
* connects to the application. Override this method if you need to perform
|
|
58
56
|
* some particular logic (e.g. creating a shared state) for each clients.
|
|
59
57
|
*
|
|
60
|
-
* @param {
|
|
58
|
+
* @param {ServerClient} client
|
|
61
59
|
*/
|
|
62
60
|
async addClient(client) {
|
|
63
|
-
this
|
|
61
|
+
this.#clients.add(client);
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
/**
|
|
@@ -68,11 +66,11 @@ class Plugin extends BasePlugin {
|
|
|
68
66
|
* disconnects from the application. Override this method if you need to perform
|
|
69
67
|
* some particular logic (e.g. creating a shared state) for each clients.
|
|
70
68
|
*
|
|
71
|
-
* @param {
|
|
69
|
+
* @param {ServerClient} client
|
|
72
70
|
*/
|
|
73
71
|
async removeClient(client) {
|
|
74
|
-
this
|
|
72
|
+
this.#clients.delete(client);
|
|
75
73
|
}
|
|
76
74
|
}
|
|
77
75
|
|
|
78
|
-
export default
|
|
76
|
+
export default ServerPlugin;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import BasePluginManager, {
|
|
2
|
+
kPluginManagerInstances,
|
|
3
|
+
} from '../common/BasePluginManager.js';
|
|
4
|
+
import ServerPlugin from './ServerPlugin.js';
|
|
5
|
+
import Server from './Server.js';
|
|
6
|
+
|
|
7
|
+
export const kServerPluginManagerCheckRegisteredPlugins = Symbol('soundworks:server-plugin-manager-check-registered-plugins');
|
|
8
|
+
export const kServerPluginManagerAddClient = Symbol('soundworks:server-plugin-manager-add-client');
|
|
9
|
+
export const kServerPluginManagerRemoveClient = Symbol('soundworks:server-plugin-manager-remove-client');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The `PluginManager` allows to register and retrieve `soundworks` plugins.
|
|
13
|
+
*
|
|
14
|
+
* Plugins should always be registered both client-side and server-side,
|
|
15
|
+
* and before {@link Client#start} or {@link Server#start}
|
|
16
|
+
* to be properly initialized.
|
|
17
|
+
*
|
|
18
|
+
* In some situations, you might want to register the same plugin factory several times
|
|
19
|
+
* using different ids (e.g. for watching several parts of the file system, etc.).
|
|
20
|
+
*
|
|
21
|
+
* Refer to the plugins' documentation for more precise examples, and the specific API
|
|
22
|
+
* they expose. See [https://soundworks.dev/guide/ecosystem](https://soundworks.dev/guide/ecosystem)
|
|
23
|
+
* for more information on the available plugins.
|
|
24
|
+
*
|
|
25
|
+
* ```
|
|
26
|
+
* // client-side
|
|
27
|
+
* import { Client } from '@soundworks/core/client.js';
|
|
28
|
+
* import syncPlugin from '@soundworks/plugin-sync/client.js';
|
|
29
|
+
*
|
|
30
|
+
* const client = new Client(config);
|
|
31
|
+
* // register the plugin before `client.start()`
|
|
32
|
+
* client.pluginManager.register('sync', syncPlugin);
|
|
33
|
+
*
|
|
34
|
+
* await client.start();
|
|
35
|
+
*
|
|
36
|
+
* const sync = await client.pluginManager.get('sync');
|
|
37
|
+
*
|
|
38
|
+
* setInterval(() => {
|
|
39
|
+
* // log the estimated global synced clock alongside the local clock.
|
|
40
|
+
* console.log(sync.getSyncTime(), sync.getLocalTime());
|
|
41
|
+
* }, 1000);
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* ```
|
|
45
|
+
* // server-side
|
|
46
|
+
* import { Server } from '@soundworks/core/server.js';
|
|
47
|
+
* import syncPlugin from '@soundworks/plugin-sync/server.js';
|
|
48
|
+
*
|
|
49
|
+
* const server = new Server(config);
|
|
50
|
+
* // register the plugin before `server.start()`
|
|
51
|
+
* server.pluginManager.register('sync', syncPlugin);
|
|
52
|
+
*
|
|
53
|
+
* await server.start();
|
|
54
|
+
*
|
|
55
|
+
* const sync = await server.pluginManager.get('sync');
|
|
56
|
+
*
|
|
57
|
+
* setInterval(() => {
|
|
58
|
+
* // log the estimated global synced clock alongside the local clock.
|
|
59
|
+
* console.log(sync.getSyncTime(), sync.getLocalTime());
|
|
60
|
+
* }, 1000);
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @extends BasePluginManager
|
|
64
|
+
* @inheritdoc
|
|
65
|
+
* @template T
|
|
66
|
+
*/
|
|
67
|
+
class ServerPluginManager extends BasePluginManager {
|
|
68
|
+
/** @hideconstructor */
|
|
69
|
+
constructor(server) {
|
|
70
|
+
if (!(server instanceof Server)) {
|
|
71
|
+
throw new TypeError(`Cannot construct 'ServerPluginManager': Argument 1 must be an instance of Server`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
super(server);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @private */
|
|
78
|
+
[kServerPluginManagerCheckRegisteredPlugins](registeredPlugins) {
|
|
79
|
+
let missingPlugins = [];
|
|
80
|
+
|
|
81
|
+
for (let id of registeredPlugins) {
|
|
82
|
+
if (!this[kPluginManagerInstances].has(id)) {
|
|
83
|
+
missingPlugins.push(id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (missingPlugins.length > 0) {
|
|
88
|
+
throw new DOMException(`Invalid 'ServerPluginManager' internal state: The following plugins registered on the client-side: [${missingPlugins.join(', ')}] have not been registered on the server-side. Plugins registered on the server-side are: [${Array.from(this[kPluginManagerInstances].keys()).join(', ')}].`, 'InvalidStateError');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @private */
|
|
93
|
+
async [kServerPluginManagerAddClient](client, registeredPlugins = []) {
|
|
94
|
+
let promises = [];
|
|
95
|
+
|
|
96
|
+
for (let pluginName of registeredPlugins) {
|
|
97
|
+
const plugin = await this.get(pluginName);
|
|
98
|
+
const promise = plugin.addClient(client);
|
|
99
|
+
promises.push(promise);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await Promise.all(promises);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @private */
|
|
106
|
+
async [kServerPluginManagerRemoveClient](client) {
|
|
107
|
+
let promises = [];
|
|
108
|
+
|
|
109
|
+
for (let plugin of this[kPluginManagerInstances].values()) {
|
|
110
|
+
if (plugin.clients.has(client)) {
|
|
111
|
+
promises.push(plugin.removeClient(client));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await Promise.all(promises);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Register a plugin into the manager.
|
|
120
|
+
*
|
|
121
|
+
* _A plugin must always be registered both on client-side and on server-side_
|
|
122
|
+
*
|
|
123
|
+
* Refer to the plugin documentation to check its options and proper way of
|
|
124
|
+
* registering it.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} id - Unique id of the plugin. Enables the registration of the
|
|
127
|
+
* same plugin factory under different ids.
|
|
128
|
+
* @param {T<ServerPlugin>} ctor - The server-side Class of the plugin.
|
|
129
|
+
* @param {object} [options={}] - Options to configure the plugin.
|
|
130
|
+
* @param {array} [deps=[]] - List of plugins' names the plugin depends on, i.e.
|
|
131
|
+
* the plugin initialization will start only after the plugins it depends on are
|
|
132
|
+
* fully started themselves.
|
|
133
|
+
* @see {@link ClientPluginManager#register}
|
|
134
|
+
* @see {@link ServerPluginManager#register}
|
|
135
|
+
* @example
|
|
136
|
+
* // client-side
|
|
137
|
+
* client.pluginManager.register('user-defined-id', pluginFactory);
|
|
138
|
+
* // server-side
|
|
139
|
+
* server.pluginManager.register('user-defined-id', pluginFactory);
|
|
140
|
+
*/
|
|
141
|
+
register(id, ctor, options = {}, deps = []) {
|
|
142
|
+
// Note that other arguments are checked on the BasePluginManager
|
|
143
|
+
if (!ctor || !(ctor.prototype instanceof ServerPlugin)) {
|
|
144
|
+
throw new TypeError(`Cannot execute 'register' on ServerPluginManager (id: ${id}): argument 2 must be a class that extends 'ServerPlugin'`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
super.register(id, ctor, options, deps);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Retrieve a fully started instance of a registered plugin.
|
|
152
|
+
*
|
|
153
|
+
* Be aware that the `get` method resolves only when the plugin is fully 'started',
|
|
154
|
+
* which is what we want 99.99% of the time. As such, and to prevent the application
|
|
155
|
+
* to be stuck in some kind of Promise dead lock, this method will throw if
|
|
156
|
+
* used before `server.init()` (or `server.start()`).
|
|
157
|
+
*
|
|
158
|
+
* To handle the remaining 0.01% cases and access plugin instances during their
|
|
159
|
+
* initialization (e.g. to display initialization screen, etc.), you should rely
|
|
160
|
+
* on the `onStateChange` method.
|
|
161
|
+
*
|
|
162
|
+
* _Note: the async API is designed to enable the dynamic creation of plugins
|
|
163
|
+
* (hopefully without breaking changes) in a future release._
|
|
164
|
+
*
|
|
165
|
+
* @param {ServerPlugin#id} id - Id of the plugin as defined when registered.
|
|
166
|
+
* @returns {Promise<T<ServerPlugin>>}
|
|
167
|
+
*/
|
|
168
|
+
async get(id) {
|
|
169
|
+
if (this.status !== 'started') {
|
|
170
|
+
throw new DOMException(`Cannot execute 'get' on ServerPluginManager: 'Server#init' has not been called yet`, 'NotSupportedError');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return super.getUnsafe(id);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export default ServerPluginManager;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { getTime } from '@ircam/sc-utils';
|
|
2
|
+
import {
|
|
3
|
+
PING_INTERVAL,
|
|
4
|
+
PING_MESSAGE,
|
|
5
|
+
PONG_MESSAGE,
|
|
6
|
+
} from '../common/constants.js';
|
|
7
|
+
import {
|
|
8
|
+
packStringMessage,
|
|
9
|
+
unpackStringMessage,
|
|
10
|
+
} from '../common/sockets-utils.js';
|
|
11
|
+
import {
|
|
12
|
+
kSocketsLatencyStatsWorker,
|
|
13
|
+
kSocketsDebugPreventHeartBeat,
|
|
14
|
+
kSocketsDeleteSocket,
|
|
15
|
+
} from './ServerSockets.js';
|
|
16
|
+
|
|
17
|
+
export const kSocketClientId = Symbol('soundworks:socket-client-id');
|
|
18
|
+
export const kSocketTerminate = Symbol('soundworks:socket-terminate');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Simple publish / subscribe wrapper built on top of the
|
|
22
|
+
* [ws](https://github.com/websockets/ws) library.
|
|
23
|
+
*
|
|
24
|
+
* An instance of {@link ServerSocket} is automatically created per client
|
|
25
|
+
* when it connects (see {@link SeverClient#socket}).
|
|
26
|
+
*
|
|
27
|
+
* _Important: In most cases, you should consider using a {@link SharedState}
|
|
28
|
+
* rather than directly using the Socket instance._
|
|
29
|
+
*
|
|
30
|
+
* @hideconstructor
|
|
31
|
+
*/
|
|
32
|
+
class ServerSocket {
|
|
33
|
+
#socket = null;
|
|
34
|
+
#sockets = null;
|
|
35
|
+
#listeners = new Map();
|
|
36
|
+
#heartbeatId = null;
|
|
37
|
+
|
|
38
|
+
constructor(ws, sockets) {
|
|
39
|
+
/**
|
|
40
|
+
* `ws` socket instance configured with `binaryType=blob` (string)
|
|
41
|
+
* @type {object}
|
|
42
|
+
*/
|
|
43
|
+
this.#socket = ws;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reference to the sockets object, is mainly dedicated to allow
|
|
47
|
+
* broadcasting from a given socket instance.
|
|
48
|
+
*
|
|
49
|
+
* @type {ServerSockets}
|
|
50
|
+
* @example
|
|
51
|
+
* socket.sockets.broadcast('my-room', this, 'update-value', 1);
|
|
52
|
+
*/
|
|
53
|
+
this.#sockets = sockets;
|
|
54
|
+
|
|
55
|
+
// @todo - review
|
|
56
|
+
const msg = {
|
|
57
|
+
type: 'add-measurement',
|
|
58
|
+
value: {
|
|
59
|
+
ping: 0,
|
|
60
|
+
pong: 0,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
let heartbeatMissed = 0;
|
|
65
|
+
// heartbeat system (run only on string socket), adapted from:
|
|
66
|
+
// https://github.com/websockets/ws#how-to-detect-and-close-broken-connections
|
|
67
|
+
this.#socket.addEventListener('message', e => {
|
|
68
|
+
if (e.data === PONG_MESSAGE) {
|
|
69
|
+
heartbeatMissed = 0;
|
|
70
|
+
|
|
71
|
+
msg.value.pong = getTime();
|
|
72
|
+
this.#sockets[kSocketsLatencyStatsWorker].postMessage(msg);
|
|
73
|
+
// do not propagate ping / pong messages
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const [channel, args] = unpackStringMessage(e.data);
|
|
78
|
+
this.#dispatchEvent(channel, ...args);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this.#heartbeatId = setInterval(() => {
|
|
82
|
+
// we didn't receive the pong message
|
|
83
|
+
if (heartbeatMissed > 0) {
|
|
84
|
+
// Emit a 'close' event to go trough all the disconnection pipeline
|
|
85
|
+
//
|
|
86
|
+
// @note - this seems to create false positive disconnections when
|
|
87
|
+
// client is busy, e.g. when loading large sound files so let's just warn
|
|
88
|
+
// until we gather more feedback
|
|
89
|
+
// cf. https://making.close.com/posts/reliable-websockets/
|
|
90
|
+
console.warn(`ClientSocket (id: ${this[kSocketClientId]}) did not respond to ping message in time (missed: ${heartbeatMissed}, interval: ${PING_INTERVAL})`);
|
|
91
|
+
// this.#dispatchEvent('close');
|
|
92
|
+
// return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
heartbeatMissed += 1;
|
|
96
|
+
msg.value.ping = getTime();
|
|
97
|
+
|
|
98
|
+
this.#socket.send(PING_MESSAGE);
|
|
99
|
+
}, PING_INTERVAL);
|
|
100
|
+
|
|
101
|
+
// for testing purpose
|
|
102
|
+
if (this.#sockets[kSocketsDebugPreventHeartBeat] === true) {
|
|
103
|
+
clearInterval(this.#heartbeatId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// propagate "native" events
|
|
107
|
+
[
|
|
108
|
+
'close',
|
|
109
|
+
'error',
|
|
110
|
+
'message',
|
|
111
|
+
'open',
|
|
112
|
+
'ping',
|
|
113
|
+
'pong',
|
|
114
|
+
'unexpected-response',
|
|
115
|
+
'upgrade',
|
|
116
|
+
].forEach(eventName => {
|
|
117
|
+
this.#socket.addEventListener(eventName, e => {
|
|
118
|
+
this.#dispatchEvent(eventName, e.data);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Dispatch an event to the listeners of the given channel.
|
|
125
|
+
* @param {string} channel - Channel name.
|
|
126
|
+
* @param {...*} args - Content of the message.
|
|
127
|
+
*/
|
|
128
|
+
#dispatchEvent(channel, ...args) {
|
|
129
|
+
if (this.#listeners.has(channel)) {
|
|
130
|
+
const callbacks = this.#listeners.get(channel);
|
|
131
|
+
callbacks.forEach(callback => callback(...args));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Removes all listeners and immediately close the web socket.
|
|
137
|
+
*
|
|
138
|
+
* Is automatically called when socket is closed on the client side or when
|
|
139
|
+
* Server is stopped
|
|
140
|
+
*
|
|
141
|
+
* @private
|
|
142
|
+
*/
|
|
143
|
+
[kSocketTerminate]() {
|
|
144
|
+
// clear ping / pong interval
|
|
145
|
+
clearInterval(this.#heartbeatId);
|
|
146
|
+
// remove socket from all rooms
|
|
147
|
+
this.#sockets[kSocketsDeleteSocket](this);
|
|
148
|
+
// clear references to sockets
|
|
149
|
+
this.#sockets = null;
|
|
150
|
+
// clear all listeners
|
|
151
|
+
this.#listeners.clear();
|
|
152
|
+
// clear native listeners
|
|
153
|
+
[
|
|
154
|
+
'close',
|
|
155
|
+
'error',
|
|
156
|
+
'message',
|
|
157
|
+
'open',
|
|
158
|
+
'ping',
|
|
159
|
+
'pong',
|
|
160
|
+
'unexpected-response',
|
|
161
|
+
'upgrade',
|
|
162
|
+
].forEach(eventName => this.#socket.removeAllListeners(eventName));
|
|
163
|
+
// terminate the socket
|
|
164
|
+
this.#socket.terminate();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Reference to the @link{ServerSockets} instance.
|
|
169
|
+
*/
|
|
170
|
+
get sockets() {
|
|
171
|
+
return this.#sockets;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Ready state of the underlying socket instance.
|
|
176
|
+
*
|
|
177
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState}
|
|
178
|
+
* @type {number}
|
|
179
|
+
*/
|
|
180
|
+
get readyState() {
|
|
181
|
+
return this.#socket.readyState;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Send messages with JSON compatible data types on a given channel.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} channel - Channel name.
|
|
188
|
+
* @param {...*} args - Payload of the message. As many arguments as needed, of
|
|
189
|
+
* JSON compatible data types (i.e. string, number, boolean, object, array and null).
|
|
190
|
+
*/
|
|
191
|
+
send(channel, ...args) {
|
|
192
|
+
const msg = packStringMessage(channel, ...args);
|
|
193
|
+
|
|
194
|
+
// ## Status codes:
|
|
195
|
+
// CONNECTING = 0; Socket has been created. The connection is not yet open.
|
|
196
|
+
// OPEN = 1; The connection is open and ready to communicate.
|
|
197
|
+
// CLOSING = 2; The connection is in the process of closing.
|
|
198
|
+
// CLOSED = 3; The connection is closed or couldn't be opened.
|
|
199
|
+
if (this.#socket.readyState === 1) {
|
|
200
|
+
this.#socket.send(msg, (err) => {
|
|
201
|
+
if (err) {
|
|
202
|
+
console.error(`ServerSocket failed to send message:`, channel, args, err.message);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Listen messages with JSON compatible data types on a given channel.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} channel - Channel name.
|
|
212
|
+
* @param {Function} callback - Callback to execute when a message is received.
|
|
213
|
+
* Arguments of the callback function will match the arguments sent using the
|
|
214
|
+
* {@link ServerSocket#send} method.
|
|
215
|
+
*/
|
|
216
|
+
addListener(channel, callback) {
|
|
217
|
+
if (!this.#listeners.has(channel)) {
|
|
218
|
+
this.#listeners.set(channel, new Set());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const callbacks = this.#listeners.get(channel);
|
|
222
|
+
callbacks.add(callback);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Remove a listener of messages with JSON compatible data types from a given channel.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} channel - Channel name.
|
|
229
|
+
* @param {Function} callback - Callback to remove.
|
|
230
|
+
*/
|
|
231
|
+
removeListener(channel, callback) {
|
|
232
|
+
if (this.#listeners.has(channel)) {
|
|
233
|
+
const callbacks = this.#listeners.get(channel);
|
|
234
|
+
callbacks.delete(callback);
|
|
235
|
+
|
|
236
|
+
if (callbacks.size === 0) {
|
|
237
|
+
this.#listeners.delete(channel);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Remove all listeners of messages with JSON compatible data types.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} channel - Channel name.
|
|
246
|
+
*/
|
|
247
|
+
removeAllListeners(channel = null) {
|
|
248
|
+
if (channel === null) {
|
|
249
|
+
this.#listeners.clear();
|
|
250
|
+
} else if (this.#listeners.has(channel)) {
|
|
251
|
+
this.#listeners.delete(channel);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default ServerSocket;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import querystring from 'node:querystring';
|
|
2
|
+
import {
|
|
3
|
+
Worker,
|
|
4
|
+
} from 'node:worker_threads';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
WebSocketServer,
|
|
8
|
+
} from 'ws';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
kServerOnSocketConnection,
|
|
12
|
+
kServerIsValidConnectionToken,
|
|
13
|
+
} from './Server.js';
|
|
14
|
+
import ServerSocket, {
|
|
15
|
+
kSocketTerminate,
|
|
16
|
+
} from './ServerSocket.js';
|
|
17
|
+
|
|
18
|
+
// @note - fs.readFileSync creates some cwd() issues...
|
|
19
|
+
import networkLatencyWorker from './audit-network-latency.worker.js';
|
|
20
|
+
|
|
21
|
+
export const kSocketsStart = Symbol('soundworks:sockets-start');
|
|
22
|
+
export const kSocketsStop = Symbol('soundworks:sockets-stop');
|
|
23
|
+
|
|
24
|
+
export const kSocketsDeleteSocket = Symbol('soundworks:sockets-delete-socket');
|
|
25
|
+
export const kSocketsLatencyStatsWorker = Symbol('soundworks:sockets-latency-stats-worker');
|
|
26
|
+
export const kSocketsDebugPreventHeartBeat = Symbol('soundworks:sockets-debug-prevent-heartbeat');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Manage all {@link ServerSocket} instances.
|
|
30
|
+
*
|
|
31
|
+
* _Important: In most cases, you should consider using a {@link SharedState}
|
|
32
|
+
* rather than directly using the ServerSocket instance._
|
|
33
|
+
*/
|
|
34
|
+
class ServerSockets {
|
|
35
|
+
#server = null;
|
|
36
|
+
#config = null;
|
|
37
|
+
#wsServer = null;
|
|
38
|
+
#sockets = new Set();
|
|
39
|
+
|
|
40
|
+
constructor(server, config) {
|
|
41
|
+
this.#server = server;
|
|
42
|
+
this.#config = config;
|
|
43
|
+
|
|
44
|
+
this[kSocketsLatencyStatsWorker] = null;
|
|
45
|
+
this[kSocketsDebugPreventHeartBeat] = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Initialize sockets, all sockets are added to two rooms by default:
|
|
50
|
+
* - to the room corresponding to the client `role`
|
|
51
|
+
* - to the '*' room that holds all connected sockets
|
|
52
|
+
* @private
|
|
53
|
+
*/
|
|
54
|
+
async [kSocketsStart]() {
|
|
55
|
+
// Audit for network latency estimation, the worker is written in cjs so that we
|
|
56
|
+
// can make builds for Max, move back to modules once Max support modules
|
|
57
|
+
this[kSocketsLatencyStatsWorker] = new Worker(networkLatencyWorker, { eval: true });
|
|
58
|
+
|
|
59
|
+
const auditState = await this.#server.getAuditState();
|
|
60
|
+
|
|
61
|
+
auditState.onUpdate(updates => {
|
|
62
|
+
if ('averageNetworkLatencyWindow' in updates || 'averageNetworkLatencyPeriod' in updates) {
|
|
63
|
+
this[kSocketsLatencyStatsWorker].postMessage({
|
|
64
|
+
type: 'config',
|
|
65
|
+
value: {
|
|
66
|
+
averageLatencyWindow: updates.averageNetworkLatencyWindow,
|
|
67
|
+
averageLatencyPeriod: updates.averageNetworkLatencyPeriod,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}, true);
|
|
72
|
+
|
|
73
|
+
this[kSocketsLatencyStatsWorker].on('message', msg => {
|
|
74
|
+
if (msg.type === 'computed-average-latency') {
|
|
75
|
+
auditState.set({ averageNetworkLatency: msg.value });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Init ws server
|
|
80
|
+
this.#wsServer = new WebSocketServer({
|
|
81
|
+
noServer: true,
|
|
82
|
+
path: `/${this.#config.path}`,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
this.#wsServer.on('connection', (ws, req) => {
|
|
86
|
+
const { role, token } = querystring.parse(req.url.split('?')[1]);
|
|
87
|
+
const socket = new ServerSocket(ws, this);
|
|
88
|
+
|
|
89
|
+
this.#sockets.add(socket);
|
|
90
|
+
this.#server[kServerOnSocketConnection](role, socket, token);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Prevent socket with protected role to connect if token is invalid
|
|
94
|
+
this.#server.httpServer.on('upgrade', async (req, socket, head) => {
|
|
95
|
+
const { role, token } = querystring.parse(req.url.split('?')[1]);
|
|
96
|
+
|
|
97
|
+
if (this.#server.isProtectedClientRole(role)) {
|
|
98
|
+
// we don't have any IP in the upgrade request object,
|
|
99
|
+
// so we just check the connection token is pending and valid
|
|
100
|
+
if (!this.#server[kServerIsValidConnectionToken](token)) {
|
|
101
|
+
socket.destroy('not allowed');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.#wsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
106
|
+
this.#wsServer.emit('connection', ws, req);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Terminate all existing sockets.
|
|
113
|
+
* @private
|
|
114
|
+
*/
|
|
115
|
+
[kSocketsStop]() {
|
|
116
|
+
// terminate stat worker thread
|
|
117
|
+
this[kSocketsLatencyStatsWorker].terminate();
|
|
118
|
+
// terminate sockets
|
|
119
|
+
this.#sockets.forEach(socket => socket[kSocketTerminate]());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove given socket from all rooms.
|
|
124
|
+
* @private
|
|
125
|
+
*/
|
|
126
|
+
[kSocketsDeleteSocket](socket) {
|
|
127
|
+
this.#sockets.delete(socket);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @private */
|
|
131
|
+
entries() {
|
|
132
|
+
return this.#sockets.entries();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @private */
|
|
136
|
+
keys() {
|
|
137
|
+
return this.#sockets.keys();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @private */
|
|
141
|
+
values() {
|
|
142
|
+
return this.#sockets.values();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
forEach(func) {
|
|
146
|
+
return this.#sockets.forEach(func);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export default ServerSockets;
|