@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.
Files changed (144) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +16 -50
  3. package/package.json +26 -44
  4. package/src/client/Client.js +306 -256
  5. package/src/client/{Context.js → ClientContext.js} +63 -54
  6. package/src/client/ClientContextManager.js +115 -0
  7. package/src/client/{Plugin.js → ClientPlugin.js} +23 -19
  8. package/src/client/{PluginManager.js → ClientPluginManager.js} +43 -40
  9. package/src/client/ClientSocket.js +270 -0
  10. package/src/client/{StateManager.js → ClientStateManager.js} +13 -13
  11. package/src/client/index.js +36 -39
  12. package/src/common/BasePlugin.js +63 -58
  13. package/src/common/BasePluginManager.js +191 -142
  14. package/src/common/BaseStateManager.js +636 -143
  15. package/src/common/BatchedTransport.js +73 -0
  16. package/src/common/ParameterBag.js +90 -93
  17. package/src/common/PromiseStore.js +54 -0
  18. package/src/common/SharedState.js +821 -0
  19. package/src/common/SharedStateCollection.js +492 -0
  20. package/src/common/constants.js +14 -2
  21. package/src/common/logger.js +64 -68
  22. package/src/common/shared-state-types.js +82 -0
  23. package/src/common/sockets-utils.js +0 -64
  24. package/src/common/version.js +2 -0
  25. package/src/server/Server.js +559 -821
  26. package/src/server/ServerClient.js +74 -0
  27. package/src/server/{Context.js → ServerContext.js} +96 -77
  28. package/src/server/{ContextManager.js → ServerContextManager.js} +107 -87
  29. package/src/server/{Plugin.js → ServerPlugin.js} +30 -32
  30. package/src/server/ServerPluginManager.js +177 -0
  31. package/src/server/ServerSocket.js +256 -0
  32. package/src/server/ServerSockets.js +150 -0
  33. package/src/server/ServerStateManager.js +706 -0
  34. package/src/server/SharedStatePrivate.js +277 -0
  35. package/src/server/audit-network-latency.worker.js +19 -4
  36. package/src/server/{audit-schema.js → audit-state-class-description.js} +3 -2
  37. package/src/server/create-http-server.js +127 -0
  38. package/src/server/crypto.js +8 -7
  39. package/src/server/index.js +60 -17
  40. package/types/client/Client.d.ts +95 -70
  41. package/types/client/{Context.d.ts → ClientContext.d.ts} +59 -34
  42. package/types/client/{ContextManager.d.ts → ClientContextManager.d.ts} +19 -22
  43. package/types/client/ClientPlugin.d.ts +51 -0
  44. package/types/client/ClientPluginManager.d.ts +112 -0
  45. package/types/client/ClientSocket.d.ts +76 -0
  46. package/types/client/{StateManager.d.ts → ClientStateManager.d.ts} +18 -21
  47. package/types/client/index.d.ts +33 -6
  48. package/types/common/BasePlugin.d.ts +64 -53
  49. package/types/common/BasePluginManager.d.ts +84 -39
  50. package/types/common/BaseStateManager.d.ts +227 -49
  51. package/types/common/BatchedTransport.d.ts +17 -0
  52. package/types/common/ParameterBag.d.ts +73 -76
  53. package/types/common/PromiseStore.d.ts +11 -0
  54. package/types/common/SharedState.d.ts +291 -0
  55. package/types/common/SharedStateCollection.d.ts +311 -0
  56. package/types/common/constants.d.ts +10 -3
  57. package/types/common/logger.d.ts +8 -6
  58. package/types/common/shared-state-types.d.ts +95 -0
  59. package/types/common/sockets-utils.d.ts +0 -5
  60. package/types/common/version.d.ts +2 -0
  61. package/types/server/Server.d.ts +215 -136
  62. package/types/server/ServerClient.d.ts +45 -0
  63. package/types/server/ServerContext.d.ts +209 -0
  64. package/types/server/ServerContextManager.d.ts +75 -0
  65. package/types/server/ServerPlugin.d.ts +57 -0
  66. package/types/server/ServerPluginManager.d.ts +115 -0
  67. package/types/server/ServerSocket.d.ts +69 -0
  68. package/types/server/ServerSockets.d.ts +42 -0
  69. package/types/server/ServerStateManager.d.ts +284 -0
  70. package/types/server/SharedStatePrivate.d.ts +23 -0
  71. package/types/server/audit-network-latency.worker.d.ts +2 -2
  72. package/types/server/{audit-schema.d.ts → audit-state-class-description.d.ts} +8 -9
  73. package/types/server/create-http-server.d.ts +5 -0
  74. package/types/server/crypto.d.ts +4 -0
  75. package/types/server/index.d.ts +51 -3
  76. package/.eslintrc +0 -15
  77. package/.github/workflows/gh-pages.yml +0 -31
  78. package/.github/workflows/unit-tests.yml +0 -18
  79. package/src/client/ContextManager.js +0 -111
  80. package/src/client/SharedState.js +0 -84
  81. package/src/client/SharedStateCollection.js +0 -35
  82. package/src/client/Socket.js +0 -402
  83. package/src/common/BaseSharedState.js +0 -517
  84. package/src/common/BaseSharedStateCollection.js +0 -179
  85. package/src/common/SharedStatePrivate.js +0 -192
  86. package/src/common/promise-store.js +0 -25
  87. package/src/server/Client.js +0 -55
  88. package/src/server/PluginManager.js +0 -170
  89. package/src/server/SharedState.js +0 -85
  90. package/src/server/SharedStateCollection.js +0 -36
  91. package/src/server/Socket.js +0 -396
  92. package/src/server/Sockets.js +0 -224
  93. package/src/server/StateManager.js +0 -620
  94. package/tsconfig.json +0 -21
  95. package/types/client/Client.d.ts.map +0 -1
  96. package/types/client/Context.d.ts.map +0 -1
  97. package/types/client/ContextManager.d.ts.map +0 -1
  98. package/types/client/Plugin.d.ts +0 -53
  99. package/types/client/Plugin.d.ts.map +0 -1
  100. package/types/client/PluginManager.d.ts +0 -96
  101. package/types/client/PluginManager.d.ts.map +0 -1
  102. package/types/client/SharedState.d.ts +0 -85
  103. package/types/client/SharedState.d.ts.map +0 -1
  104. package/types/client/Socket.d.ts +0 -138
  105. package/types/client/Socket.d.ts.map +0 -1
  106. package/types/client/StateManager.d.ts.map +0 -1
  107. package/types/client/index.d.ts.map +0 -1
  108. package/types/common/BasePlugin.d.ts.map +0 -1
  109. package/types/common/BasePluginManager.d.ts.map +0 -1
  110. package/types/common/BaseSharedState.d.ts +0 -173
  111. package/types/common/BaseSharedState.d.ts.map +0 -1
  112. package/types/common/BaseStateManager.d.ts.map +0 -1
  113. package/types/common/ParameterBag.d.ts.map +0 -1
  114. package/types/common/SharedStatePrivate.d.ts +0 -20
  115. package/types/common/SharedStatePrivate.d.ts.map +0 -1
  116. package/types/common/constants.d.ts.map +0 -1
  117. package/types/common/logger.d.ts.map +0 -1
  118. package/types/common/promise-store.d.ts +0 -4
  119. package/types/common/promise-store.d.ts.map +0 -1
  120. package/types/common/sockets-utils.d.ts.map +0 -1
  121. package/types/common/utils.d.ts +0 -5
  122. package/types/common/utils.d.ts.map +0 -1
  123. package/types/server/Client.d.ts +0 -40
  124. package/types/server/Client.d.ts.map +0 -1
  125. package/types/server/Context.d.ts +0 -59
  126. package/types/server/Context.d.ts.map +0 -1
  127. package/types/server/ContextManager.d.ts +0 -69
  128. package/types/server/ContextManager.d.ts.map +0 -1
  129. package/types/server/Plugin.d.ts +0 -70
  130. package/types/server/Plugin.d.ts.map +0 -1
  131. package/types/server/PluginManager.d.ts +0 -91
  132. package/types/server/PluginManager.d.ts.map +0 -1
  133. package/types/server/Server.d.ts.map +0 -1
  134. package/types/server/SharedState.d.ts +0 -86
  135. package/types/server/SharedState.d.ts.map +0 -1
  136. package/types/server/Socket.d.ts +0 -128
  137. package/types/server/Socket.d.ts.map +0 -1
  138. package/types/server/Sockets.d.ts +0 -73
  139. package/types/server/Sockets.d.ts.map +0 -1
  140. package/types/server/StateManager.d.ts +0 -190
  141. package/types/server/StateManager.d.ts.map +0 -1
  142. package/types/server/audit-network-latency.worker.d.ts.map +0 -1
  143. package/types/server/audit-schema.d.ts.map +0 -1
  144. 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 informations on the available plugins.
14
+ * for more information on the available plugins.
28
15
  *
29
16
  * _Creating new plugins should be considered an advanced usage._
30
17
  *
31
- * @memberof server
32
- * @extends BasePlugin
18
+ * @extends {BasePlugin}
33
19
  * @inheritdoc
34
20
  */
35
- class Plugin extends BasePlugin {
21
+ class ServerPlugin extends BasePlugin {
22
+ #server = null;
23
+ #clients = new Set();
24
+
36
25
  /**
37
- * @param {server.Server} server - The soundworks server instance.
26
+ * @param {Server} server - The soundworks server instance.
38
27
  * @param {string} id - User defined id of the plugin as defined in
39
- * {@link server.PluginManager#register}.
28
+ * {@link ServerPluginManager#register}.
40
29
  */
41
30
  constructor(server, id) {
42
31
  super(id);
32
+ this.#server = server;
33
+ }
43
34
 
44
- /**
45
- * Instance of soundworks server.
46
- * @type {server.Server}
47
- * @see {@link server.Server}
48
- */
49
- this.server = server;
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
- /** @private */
52
- this.clients = new Set();
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 {server.Client} client
58
+ * @param {ServerClient} client
61
59
  */
62
60
  async addClient(client) {
63
- this.clients.add(client);
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 {server.Client} client
69
+ * @param {ServerClient} client
72
70
  */
73
71
  async removeClient(client) {
74
- this.clients.delete(client);
72
+ this.#clients.delete(client);
75
73
  }
76
74
  }
77
75
 
78
- export default Plugin;
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;