@soundworks/core 4.0.0-alpha.9 → 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 +14 -29
  3. package/package.json +24 -42
  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 +635 -145
  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 +552 -824
  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 -583
  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,28 +1,62 @@
1
+ import EventEmitter from 'node:events';
1
2
  import fs from 'node:fs';
2
- import http from 'node:http';
3
- import https from 'node:https';
4
3
  import path from 'node:path';
5
4
  import os from 'node:os';
6
- import { X509Certificate, createPrivateKey } from 'node:crypto';
7
5
 
8
- import { getTime } from '@ircam/sc-gettime';
9
- import { isPlainObject, idGenerator } from '@ircam/sc-utils';
6
+
7
+ import {
8
+ isPlainObject,
9
+ isString,
10
+ counter,
11
+ getTime,
12
+ } from '@ircam/sc-utils';
10
13
  import chalk from 'chalk';
11
- import compression from 'compression';
12
- import express from 'express';
13
14
  import Keyv from 'keyv';
14
15
  import { KeyvFile } from 'keyv-file';
15
- import merge from 'lodash.merge';
16
- import pem from 'pem';
17
- import compile from 'template-literal';
18
-
19
- import auditSchema from './audit-schema.js';
20
- import { encryptData, decryptData } from './crypto.js';
21
- import Client from './Client.js';
22
- import ContextManager from './ContextManager.js';
23
- import PluginManager from './PluginManager.js';
24
- import StateManager from './StateManager.js';
25
- import Sockets from './Sockets.js';
16
+ import merge from 'lodash/merge.js';
17
+
18
+ import auditClassDescription from './audit-state-class-description.js';
19
+ import {
20
+ encryptData,
21
+ decryptData,
22
+ } from './crypto.js';
23
+ import {
24
+ createHttpServer,
25
+ } from './create-http-server.js';
26
+ import ServerClient, {
27
+ kServerClientToken,
28
+ } from './ServerClient.js';
29
+ import ServerContextManager, {
30
+ kServerContextManagerStart,
31
+ kServerContextManagerStop,
32
+ kServerContextManagerAddClient,
33
+ kServerContextManagerRemoveClient,
34
+ } from './ServerContextManager.js';
35
+ import ServerPluginManager, {
36
+ kServerPluginManagerCheckRegisteredPlugins,
37
+ kServerPluginManagerAddClient,
38
+ kServerPluginManagerRemoveClient,
39
+ } from './ServerPluginManager.js';
40
+ import {
41
+ kPluginManagerStart,
42
+ kPluginManagerStop,
43
+ } from '../common/BasePluginManager.js';
44
+ import ServerStateManager, {
45
+ kServerStateManagerAddClient,
46
+ kServerStateManagerRemoveClient,
47
+ kServerStateManagerHasClient,
48
+ } from './ServerStateManager.js';
49
+ import {
50
+ kStateManagerInit,
51
+ } from '../common/BaseStateManager.js';
52
+ import {
53
+ kSocketClientId,
54
+ kSocketTerminate,
55
+ } from './ServerSocket.js';
56
+ import ServerSockets, {
57
+ kSocketsStart,
58
+ kSocketsStop,
59
+ } from './ServerSockets.js';
26
60
  import logger from '../common/logger.js';
27
61
  import {
28
62
  SERVER_ID,
@@ -31,43 +65,19 @@ import {
31
65
  CLIENT_HANDSHAKE_ERROR,
32
66
  AUDIT_STATE_NAME,
33
67
  } from '../common/constants.js';
68
+ import VERSION from '../common/version.js';
34
69
 
35
-
36
- let _dbNamespaces = new Set();
37
-
38
- /**
39
- * Configuration object for the server.
40
- *
41
- * @typedef ServerConfig
42
- * @memberof server
43
- * @type {object}
44
- * @property {object} [app] - Application configration object.
45
- * @property {object} app.clients - Definition of the application clients.
46
- * @property {string} [app.name=''] - Name of the application.
47
- * @property {string} [app.author=''] - Name of the author.
48
- * @property {object} [env] - Environment configration object.
49
- * @property {boolean} env.port - Port on which the server is listening.
50
- * @property {boolean} env.useHttps - Define is the server run in http or in https.
51
- * @property {boolean} [env.httpsInfos={}] - Path to cert files for https.
52
- * @property {boolean} env.serverAddress - Domain name or IP of the server.
53
- * Mandatory if node clients are defined
54
- * @property {string} [env.websockets={}] - Configuration options for websockets.
55
- * @property {string} [env.subpath=''] - If running behind a proxy, path to the application.
56
- */
70
+ const dbNamespaces = new Set();
57
71
 
58
72
  /** @private */
59
73
  const DEFAULT_CONFIG = {
60
74
  env: {
61
75
  type: 'development',
62
76
  port: 8000,
63
- serverAddress: null,
64
- subpath: '',
65
- websockets: {
66
- path: 'socket',
67
- pingInterval: 5000,
68
- },
77
+ serverAddress: '',
69
78
  useHttps: false,
70
79
  httpsInfos: null,
80
+ baseUrl: '',
71
81
  crossOriginIsolated: true,
72
82
  verbose: true,
73
83
  },
@@ -77,36 +87,19 @@ const DEFAULT_CONFIG = {
77
87
  },
78
88
  };
79
89
 
80
- const TOKEN_VALID_DURATION = 20; // sec
81
-
82
- // set terminal title
83
- /** @private */
84
- function setTerminalTitle(server) {
85
- let title = '';
86
-
87
- if (server._auditState !== null) {
88
- const numClients = server._auditState.get('numClients');
89
- let numClientStrings = [];
90
- for (let name in numClients) {
91
- numClientStrings.push(`${name}: ${numClients[name]}`);
92
- }
90
+ const TOKEN_VALID_DURATION = 10; // sec
93
91
 
94
- title = `${server.config.app.name} | ${numClientStrings.join(' - ')}`;
95
- } else {
96
- title = `${server.config.app.name}`;
97
- }
98
-
99
- const msg = String.fromCharCode(27) + ']0;' + title + String.fromCharCode(7);
100
- process.stdout.write(msg);
101
- }
92
+ export const kServerOnSocketConnection = Symbol('soundworks:server-on-socket-connection');
93
+ export const kServerIsValidConnectionToken = Symbol('soundworks:server-is-valid-connection-token');
94
+ // protected and not private for testing purposes
95
+ export const kServerOnStatusChangeCallbacks = Symbol('soundworks:server-on-status-change-callbacks');
102
96
 
103
97
  /**
104
- * The `Server` class is the main entry point for the server-side of a soundworks
105
- * application.
98
+ * The `Server` class is the main entry point for soundworks server-side project.
106
99
  *
107
- * The `Server` instance allows to access soundworks components such as {@link server.StateManager},
108
- * {@link server.PluginManager},{@link server.Socket} or {@link server.ContextManager}.
109
- * Its is also responsible for handling the initialization lifecycles of the different
100
+ * The `Server` instance allows to access soundworks components such as {@link ServerStateManager},
101
+ * {@link ServerPluginManager}, {@link ServerSocket} or {@link ServerContextManager}.
102
+ * Its is also responsible for handling the initialization lifecycle of the different
110
103
  * soundworks components.
111
104
  *
112
105
  * ```
@@ -116,9 +109,9 @@ function setTerminalTitle(server) {
116
109
  * app: {
117
110
  * name: 'my-example-app',
118
111
  * clients: {
119
- * player: { target: 'browser', default: true },
120
- * controller: { target: 'browser' },
121
- * thing: { target: 'node' }
112
+ * player: { runtime: 'browser', default: true },
113
+ * controller: { runtime: 'browser' },
114
+ * thing: { runtime: 'node' }
122
115
  * },
123
116
  * },
124
117
  * env: {
@@ -128,28 +121,32 @@ function setTerminalTitle(server) {
128
121
  *
129
122
  * await server.start();
130
123
  * ```
131
- *
132
- * According to the clients definitions provided in `config.app.clients`, the
133
- * server will automatically create a dedicated route for each browser client role.
134
- * For example, given the config object of the example above that defines two
135
- * different client roles for browser targets (i.e. `player` and `controller`):
136
- *
137
- * ```
138
- * config.app.clients = {
139
- * player: { target: 'browser', default: true },
140
- * controller: { target: 'browser' },
141
- * }
142
- * ```
143
- *
144
- * The server will listen to the following URLs:
145
- * - `http://127.0.0.1:8000/` for the `player` role, which is defined as the default client.
146
- * - `http://127.0.0.1:8000/controller` for the `controller` role.
147
- *
148
- * @memberof server
149
124
  */
150
125
  class Server {
126
+ #config = null;
127
+ #version = null;
128
+ #status = null;
129
+ #router = null;
130
+ #httpServer = null;
131
+ #db = null;
132
+
133
+ #sockets = null;
134
+ #pluginManager = null;
135
+ #stateManager = null;
136
+ #contextManager = null;
137
+
138
+ #onClientConnectCallbacks = new Set();
139
+ #onClientDisconnectCallbacks = new Set();
140
+ #auditState = null;
141
+ #tokenIdGenerator = counter();
142
+ #pendingConnectionTokens = new Set();
143
+ #trustedClients = new Set();
144
+
145
+ // for backward compatibility
146
+ #useDefaultApplicationTemplate = false;
147
+
151
148
  /**
152
- * @param {server.ServerConfig} config - Configuration object for the server.
149
+ * @param {ServerConfig} config - Configuration object for the server.
153
150
  * @throws
154
151
  * - If `config.app.clients` is empty.
155
152
  * - If a `node` client is defined but `config.env.serverAddress` is not defined.
@@ -159,182 +156,130 @@ class Server {
159
156
  */
160
157
  constructor(config) {
161
158
  if (!isPlainObject(config)) {
162
- throw new Error(`[soundworks:Server] Invalid argument for Server constructor, config should be an object`);
159
+ throw new TypeError(`Cannot construct 'Server': Parameter 1 must be an object`);
160
+ }
161
+
162
+ config = merge({}, DEFAULT_CONFIG, config);
163
+
164
+ // ---------------------------------------------------------------------
165
+ // Deprecation checks for config
166
+ // ---------------------------------------------------------------------
167
+
168
+ // `target` renamed to `runtime`
169
+ for (let role in config.app.clients) {
170
+ const clientConfig = config.app.clients[role];
171
+
172
+ if (clientConfig.target) {
173
+ logger.deprecated('ClientDescription#target', 'ClientDescription#runtime (or run `npx soundworks --upgrade-config` to upgrade your config files)', '4.0.0-alpha.29');
174
+ clientConfig.runtime = clientConfig.target;
175
+ delete clientConfig.target;
176
+ }
177
+ }
178
+
179
+ // `env.subpath` to `env.baseUrl`
180
+ if ('subpath' in config.env) {
181
+ logger.deprecated('ServerConfig#subpath', 'ServerConfig#baseUrl (or run `npx soundworks --upgrade-config` to upgrade your config files)', '4.0.0-alpha.29');
182
+ config.env.baseUrl = config.env.subpath;
183
+ delete config.env.subpath;
163
184
  }
164
- /**
165
- * @description Given config object merged with the following defaults:
166
- * @example
167
- * {
168
- * env: {
169
- * type: 'development',
170
- * port: 8000,
171
- * serverAddress: null,
172
- * subpath: '',
173
- * websockets: {
174
- * path: 'socket',
175
- * pingInterval: 5000,
176
- * },
177
- * useHttps: false,
178
- * httpsInfos: null,
179
- * crossOriginIsolated: true,
180
- * verbose: true,
181
- * },
182
- * app: {
183
- * name: 'soundworks',
184
- * clients: {},
185
- * }
186
- * }
187
- */
188
- this.config = merge({}, DEFAULT_CONFIG, config);
189
-
190
- // parse config
191
- if (Object.keys(this.config.app.clients).length === 0) {
192
- throw new Error(`[soundworks:Server] Invalid "app.clients" config, at least one client should be declared`);
185
+
186
+ // ---------------------------------------------------------------------
187
+ // ---------------------------------------------------------------------
188
+
189
+ if (Object.keys(config.app.clients).length === 0) {
190
+ throw new DOMException(`Cannot construct 'Server': At least one ClientDescription must be declared in 'config.app.clients'`, 'NotSupportedError');
193
191
  }
194
192
 
195
- // if a node client is defined, serverAddress should be defined
196
- let hasNodeClient = false;
197
- for (let name in this.config.app.clients) {
198
- if (this.config.app.clients[name].target === 'node') {
199
- hasNodeClient = true;
193
+ for (let name in config.app.clients) {
194
+ // runtime property is mandatory
195
+ if (!['node', 'browser'].includes(config.app.clients[name].runtime)) {
196
+ throw new TypeError(`Cannot construct 'Server': Invalid 'ClientDescription' for client '${name}': 'runtime' property must be either 'node' or 'browser'`);
200
197
  }
201
198
  }
202
199
 
203
- if (hasNodeClient && this.config.env.serverAddress === null) {
204
- throw new Error(`[soundworks:Server] Invalid "env.serverAddress" config, is mandatory when a node client target is defined`);
200
+ // @peeka - remove this check
201
+ // [2024-05-29] Override default `config.env.serverAddress`` provided from
202
+ // template `loadConfig` to '' so that browser clients can default to
203
+ // window.location.hostname and node clients to `127.0.0.1`
204
+ if (process.env.ENV === undefined && config.env.serverAddress === '127.0.0.1') {
205
+ config.env.serverAddress = '';
205
206
  }
206
207
 
207
- if (this.config.env.useHttps && this.config.env.httpsInfos !== null) {
208
- const httpsInfos = this.config.env.httpsInfos;
208
+ if (config.env.useHttps && config.env.httpsInfos !== null) {
209
+ const httpsInfos = config.env.httpsInfos;
209
210
 
210
- if (!isPlainObject(this.config.env.httpsInfos)) {
211
- throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, should be null or object { cert, key }`);
211
+ if (!isPlainObject(config.env.httpsInfos)) {
212
+ throw new TypeError(`Cannot construct 'Server': Invalid 'ServerEnvConfig': 'httpsInfos' must be an object: { cert, key }`);
212
213
  }
213
214
 
214
215
  if (!('cert' in httpsInfos) || !('key' in httpsInfos)) {
215
- throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, should contain both "cert" and "key" entries`);
216
+ throw new TypeError(`Cannot construct 'Server': Invalid 'ServerEnvConfig': 'httpsInfos' must contain both "cert" and "key" entries`);
216
217
  }
217
- // @todo - move that to constructor
218
- if (!fs.existsSync(httpsInfos.cert)) {
219
- throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, "cert" file not found`);
218
+
219
+ if (httpsInfos.cert !== null && !fs.existsSync(httpsInfos.cert)) {
220
+ throw new DOMException(`Cannot construct 'Server': Invalid 'ServerEnvConfig': 'httpsInfos.cert' file not found`, 'NotFoundError');
220
221
  }
221
222
 
222
- if (!fs.existsSync(httpsInfos.key)) {
223
- throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, "key" file not found`);
223
+ if (httpsInfos.key !== null && !fs.existsSync(httpsInfos.key)) {
224
+ throw new DOMException(`Cannot construct 'Server': Invalid 'ServerEnvConfig': 'httpsInfos.key' file not found`, 'NotFoundError');
224
225
  }
225
226
  }
226
227
 
227
- /**
228
- * Instance of the express router.
229
- *
230
- * The router can be used to open new route, for example to expose a directory
231
- * of static assets (in default soundworks applications only the `public` is exposed).
232
- *
233
- * @see {@link https://github.com/expressjs/express}
234
- * @example
235
- * import { Server } from '@soundworks/core/server.js';
236
- * import express from 'express';
237
- *
238
- * // create the soundworks server instance
239
- * const server = new Server(config);
240
- *
241
- * // expose assets located in the `soundfiles` directory on the network
242
- * server.router.use('/soundfiles', express.static('soundfiles')));
243
- */
244
- // @note: we use express() instead of express.Router() because all 404 and
245
- // error stuff is handled by default
246
- this.router = express();
247
- // compression (must be set before express.static())
248
- this.router.use(compression());
249
-
250
- /**
251
- * Raw Node.js `http` or `https` instance
252
- *
253
- * @see {@link https://nodejs.org/api/http.html}
254
- * @see {@link https://nodejs.org/api/https.html}
255
- */
256
- this.httpServer = null;
257
-
258
- /**
259
- * Instance of the {@link server.Sockets} class.
260
- *
261
- * @see {@link server.Sockets}
262
- * @type {server.Sockets}
263
- */
264
- this.sockets = new Sockets();
265
-
266
- /**
267
- * Instance of the {@link server.PluginManager} class.
268
- *
269
- * @see {@link server.PluginManager}
270
- * @type {server.PluginManager}
271
- */
272
- this.pluginManager = new PluginManager(this);
273
-
274
- /**
275
- * Instance of the {@link server.StateManager} class.
276
- *
277
- * @see {@link server.StateManager}
278
- * @type {server.StateManager}
279
- */
280
- this.stateManager = new StateManager();
281
-
282
- /**
283
- * Instance of the {@link server.ContextManager} class.
284
- *
285
- * @see {@link server.ContextManager}
286
- * @type {server.ContextManager}
287
- */
288
- this.contextManager = new ContextManager(this);
289
-
290
- /**
291
- * If `https` is required, hold informations about the certificates, e.g. if
292
- * self-signed, the dates of validity of the certificates, etc.
293
- */
294
- this.httpsInfos = null;
295
-
296
- /**
297
- * Status of the server, 'idle', 'inited', 'started' or 'errored'.
298
- *
299
- * @type {string}
300
- */
301
- this.status = 'idle';
302
-
303
- /**
304
- * Simple key / value database with Promise based Map API store on filesystem,
305
- * basically a tiny wrapper around the `kvey` package.
306
- *
307
- * @private
308
- * @see {@link https://github.com/lukechilds/keyv}
309
- */
310
- this.db = this.createNamespacedDb('core');
311
-
228
+ // private
229
+ this.#config = config;
230
+ this.#version = VERSION;
231
+ this.#sockets = new ServerSockets(this, { path: 'socket' });
232
+ this.#pluginManager = new ServerPluginManager(this);
233
+ this.#stateManager = new ServerStateManager();
234
+ this.#contextManager = new ServerContextManager(this);
235
+ this.#status = 'idle';
236
+ this.#db = this.createNamespacedDb('core');
312
237
  /** @private */
313
- this._applicationTemplateOptions = {
314
- templateEngine: null,
315
- templatePath: null,
316
- clientConfigFunction: null,
317
- };
318
-
319
- /** @private */
320
- this._onStatusChangeCallbacks = new Set();
321
- /** @private */
322
- this._auditState = null;
323
- /** @private */
324
- this._pendingConnectionTokens = new Set();
325
- /** @private */
326
- this._trustedClients = new Set();
238
+ this[kServerOnStatusChangeCallbacks] = new Set();
327
239
 
328
240
  // register audit state schema
329
- this.stateManager.registerSchema(AUDIT_STATE_NAME, auditSchema);
241
+ this.#stateManager.defineClass(AUDIT_STATE_NAME, auditClassDescription);
242
+
243
+ logger.configure(this.#config.env.verbose);
244
+ }
330
245
 
331
- logger.configure(this.config.env.verbose);
332
- setTerminalTitle(this);
246
+ /**
247
+ * Given config object merged with the following defaults:
248
+ * @example
249
+ * {
250
+ * env: {
251
+ * type: 'development',
252
+ * port: 8000,
253
+ * serverAddress: null,
254
+ * baseUrl: '',
255
+ * useHttps: false,
256
+ * httpsInfos: null,
257
+ * crossOriginIsolated: true,
258
+ * verbose: true,
259
+ * },
260
+ * app: {
261
+ * name: 'soundworks',
262
+ * clients: {},
263
+ * }
264
+ * }
265
+ * @type {ServerConfig}
266
+ */
267
+ get config() {
268
+ return this.#config;
333
269
  }
334
270
 
335
271
  /**
336
- * Id of the server, a constant set to -1
337
- * @type {Number}
272
+ * Package version.
273
+ *
274
+ * @type {string}
275
+ */
276
+ get version() {
277
+ return this.#version;
278
+ }
279
+
280
+ /**
281
+ * Id of the server, a constant set to `-1`
282
+ * @type {number}
338
283
  * @readonly
339
284
  */
340
285
  get id() {
@@ -342,271 +287,248 @@ class Server {
342
287
  }
343
288
 
344
289
  /**
345
- * The `init` method is part of the initialization lifecycle of the `soundworks`
346
- * server. Most of the time, the `init` method will be implicitly called by the
347
- * {@link server.Server#start} method.
290
+ * Status of the server.
348
291
  *
349
- * In some situations you might want to call this method manually, in such cases
350
- * the method should be called before the {@link server.Server#start} method.
351
- *
352
- * What it does:
353
- * - create the audit state
354
- * - prepapre http(s) server and routing according to the informations
355
- * declared in `config.app.clients`
356
- * - initialize all registered plugins
292
+ * @type {'idle'|'inited'|'started'|'errored'}
293
+ */
294
+ get status() {
295
+ return this.#status;
296
+ }
297
+
298
+ /**
299
+ * Instance of the router if any.
357
300
  *
358
- * After `await server.init()` is fulfilled, the {@link server.Server#stateManager}
359
- * and all registered plugins can be safely used.
301
+ * The router can be used to open new route, for example to expose a directory
302
+ * of static assets (in default soundworks applications only the `public` is exposed).
360
303
  *
361
304
  * @example
362
- * const server = new Server(config);
363
- * await server.init();
364
- * await server.start();
365
- * // or implicitly called by start
366
- * const server = new Server(config);
367
- * await server.start(); // init is called implicitely
305
+ * import { Server } from '@soundworks/core/server.js';
306
+ * import { loadConfig, configureHttpRouter } from '@soundworks/helpers/server.js';
307
+ *
308
+ * // create the server instance
309
+ * const server = new Server(loadConfig());
310
+ * // configure the express router provided by the helpers
311
+ * configureHttpRouter(server);
312
+ *
313
+ * // expose assets located in the `soundfiles` directory on the network
314
+ * server.router.use('/soundfiles', express.static('soundfiles')));
368
315
  */
369
- async init() {
370
- const numClients = {};
371
- for (let name in this.config.app.clients) {
372
- numClients[name] = 0;
373
- }
374
- /** @private */
375
- this._auditState = await this.stateManager.create(AUDIT_STATE_NAME, { numClients });
376
- this._auditState.onUpdate(() => setTerminalTitle(this));
377
-
378
- // basic http authentication
379
- if (this.config.env.auth) {
380
- const ids = idGenerator();
381
-
382
- const soundworksAuth = (req, res, next) => {
383
- let role = null;
316
+ get router() {
317
+ return this.#router;
318
+ }
384
319
 
385
- for (let [_role, config] of Object.entries(this.config.app.clients)) {
386
- if (req.path === config.route) {
387
- role = _role;
388
- }
389
- }
320
+ set router(router) {
321
+ this.#router = router;
390
322
 
391
- // route that are not client entry points just pass through the middleware
392
- if (role === null) {
393
- next();
394
- return;
323
+ if (this.httpServer) {
324
+ this.httpServer.on('request', router);
325
+ } else {
326
+ // register router on HTTP server when ready
327
+ this.onStatusChange(status => {
328
+ if (status === 'http-server-ready') {
329
+ this.httpServer.on('request', router);
395
330
  }
331
+ });
332
+ }
333
+ }
396
334
 
397
- const isProtected = this.isProtected(role);
398
-
399
- if (isProtected) {
400
- // authentication middleware
401
- const auth = this.config.env.auth;
402
- // parse login and password from headers
403
- const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
404
- const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':');
405
-
406
- // verify login and password are set and correct
407
- if (login && password && login === auth.login && password === auth.password) {
408
- // -> access granted...
409
- // generate token for web socket to check connections
410
- const id = ids.next().value;
411
- const ip = req.ip;
412
- const time = getTime();
413
- const token = { id, ip, time };
414
- const encryptedToken = encryptData(token);
335
+ /**
336
+ * Instance of the Node.js `http.Server` or `https.Server`
337
+ *
338
+ * @see {@link https://nodejs.org/api/http.html#class-httpserver}
339
+ * @see {@link https://nodejs.org/api/https.html#class-httpsserver}
340
+ */
341
+ get httpServer() {
342
+ return this.#httpServer;
343
+ }
415
344
 
416
- this._pendingConnectionTokens.add(encryptedToken);
345
+ /**
346
+ * Simple key / value filesystem database with Promise based Map API.
347
+ *
348
+ * Basically a tiny wrapper around the {@link https://github.com/lukechilds/keyv} package.
349
+ */
350
+ get db() {
351
+ return this.#db;
352
+ }
417
353
 
418
- setTimeout(() => {
419
- this._pendingConnectionTokens.delete(encryptedToken);
420
- }, TOKEN_VALID_DURATION * 1000);
354
+ /**
355
+ * Instance of the {@link ServerSockets} class.
356
+ *
357
+ * @type {ServerSockets}
358
+ */
359
+ get sockets() {
360
+ return this.#sockets;
361
+ }
421
362
 
422
- // pass to the response object to be send to the client
423
- res.swToken = encryptedToken;
363
+ /**
364
+ * Instance of the {@link ServerPluginManager} class.
365
+ *
366
+ * @type {ServerPluginManager}
367
+ */
368
+ get pluginManager() {
369
+ return this.#pluginManager;
370
+ }
424
371
 
425
- return next();
426
- }
372
+ /**
373
+ * Instance of the {@link ServerStateManager} class.
374
+ *
375
+ * @type {ServerStateManager}
376
+ */
377
+ get stateManager() {
378
+ return this.#stateManager;
379
+ }
427
380
 
428
- // show login / password modal
429
- res.writeHead(401, {
430
- 'WWW-Authenticate':'Basic',
431
- 'Content-Type':'text/plain',
432
- });
381
+ /**
382
+ * Instance of the {@link ServerContextManager} class.
383
+ *
384
+ * @type {ServerContextManager}
385
+ */
386
+ get contextManager() {
387
+ return this.#contextManager;
388
+ }
433
389
 
434
- res.end('Authentication required.');
435
- } else {
436
- // route is not protected
437
- return next();
438
- }
439
- };
390
+ /** @private */
391
+ async #dispatchStatus(status) {
392
+ this.#status = status;
440
393
 
441
- this.router.use(soundworksAuth);
394
+ // if launched in a child process, forward status to parent process
395
+ if (process.send !== undefined) {
396
+ process.send(`soundworks:server:${status}`);
442
397
  }
443
398
 
444
- // start http server
445
- const useHttps = this.config.env.useHttps || false;
446
-
447
- // ------------------------------------------------------------
448
- // create HTTP(S) SERVER
449
- // ------------------------------------------------------------
450
- if (!useHttps) {
451
- this.httpServer = http.createServer(this.router);
452
- } else {
453
- const httpsInfos = this.config.env.httpsInfos;
399
+ // execute all callbacks in parallel
400
+ const promises = [];
454
401
 
455
- // if certs have been given in config
456
- if (httpsInfos !== null) {
457
- try {
458
- // existance of file is checked in contructor
459
- let cert = fs.readFileSync(httpsInfos.cert);
460
- let key = fs.readFileSync(httpsInfos.key);
461
-
462
- let x509 = null;
463
- // this fails with self-signed certificates for whatever reason...
464
- try {
465
- x509 = new X509Certificate(cert);
466
- } catch (err) {
467
- this._dispatchStatus('errored');
468
- throw new Error(`[soundworks:Server] Invalid https cert file`);
469
- }
402
+ for (let callback of this[kServerOnStatusChangeCallbacks]) {
403
+ promises.push(callback(status));
404
+ }
470
405
 
471
- try {
472
- const keyObj = createPrivateKey(key);
406
+ await Promise.all(promises);
407
+ }
473
408
 
474
- if (!x509.checkPrivateKey(keyObj)) {
475
- this._dispatchStatus('errored');
476
- throw new Error(`[soundworks:Server] Invalid https key file`);
477
- }
478
- } catch (err) {
479
- this._dispatchStatus('errored');
480
- throw new Error(`[soundworks:Server] Invalid https key file`);
481
- }
409
+ /**
410
+ * Register a callback to execute when status change.
411
+ *
412
+ * Status are dispatched in the following order:
413
+ * - 'http-server-ready'
414
+ * - 'inited'
415
+ * - 'started'
416
+ * - 'stopped'
417
+ * during the lifecycle of the server. If an error occurs the 'errored' status is propagated.
418
+ *
419
+ * @param {function} callback
420
+ */
421
+ onStatusChange(callback) {
422
+ this[kServerOnStatusChangeCallbacks].add(callback);
423
+ return () => this[kServerOnStatusChangeCallbacks].delete(callback);
424
+ }
482
425
 
483
- // check is certificate is still valid
484
- const now = Date.now();
485
- const certExpire = Date.parse(x509.validTo);
486
- const isValid = now < certExpire;
487
-
488
- const diff = certExpire - now;
489
- const daysRemaining = Math.round(diff / 1000 / 60 / 60 / 24);
490
-
491
- this.httpsInfos = {
492
- selfSigned: false,
493
- CN: x509.subject.split('=')[1],
494
- altNames: x509.subjectAltName.split(',').map(e => e.trim().split(':')[1]),
495
- validFrom: x509.validFrom,
496
- validTo: x509.validTo,
497
- isValid: isValid,
498
- daysRemaining: daysRemaining,
499
- };
500
-
501
- this.httpServer = https.createServer({ key, cert }, this.router);
502
- } catch (err) {
503
- logger.error(`
504
- Invalid certificate files, please check your:
505
- - key file: ${httpsInfos.key}
506
- - cert file: ${httpsInfos.cert}
507
- `);
508
-
509
- this._dispatchStatus('errored');
510
- throw err;
511
- }
512
- } else {
513
- // generate certs
514
- // --------------------------------------------------------
515
- const cert = await this.db.get('httpsCert');
516
- const key = await this.db.get('httpsKey');
517
-
518
- if (key && cert) {
519
- this.httpsInfos = { selfSigned: true };
520
- this.httpServer = https.createServer({ cert, key }, this.router);
521
- } else {
522
- this.httpServer = await new Promise((resolve, reject) => {
523
- // generate certificate on the fly (for development purposes)
524
- pem.createCertificate({ days: 1, selfSigned: true }, async (err, keys) => {
525
- if (err) {
526
- logger.error(err.stack);
527
- this._dispatchStatus('errored');
528
-
529
- reject(err);
530
- return;
531
- }
532
-
533
- const cert = keys.certificate;
534
- const key = keys.serviceKey;
535
-
536
- this.httpsInfos = { selfSigned: true };
537
- // we store the generated cert so that we don't have to re-accept
538
- // the cert each time the server restarts in development
539
- await this.db.set('httpsCert', cert);
540
- await this.db.set('httpsKey', key);
541
-
542
- const httpsServer = https.createServer({ cert, key }, this.router);
543
-
544
- resolve(httpsServer);
545
- });
546
- });
547
- }
548
- }
426
+ /**
427
+ * Attach and retrieve the global audit state of the application.
428
+ *
429
+ * The audit state is a {@link SharedState} instance that keeps track of
430
+ * global information about the application such as, the number of connected
431
+ * clients, network latency estimation, etc.
432
+ *
433
+ * The audit state is created by the server on start up.
434
+ *
435
+ * @returns {Promise<SharedState>}
436
+ * @throws Will throw if called before `server.init()`
437
+ *
438
+ * @example
439
+ * const auditState = await server.getAuditState();
440
+ * auditState.onUpdate(() => console.log(auditState.getValues()), true);
441
+ */
442
+ async getAuditState() {
443
+ if (this.#status === 'idle') {
444
+ throw new DOMException(`Cannot execute 'getAuditState' on Server: 'init' must be called first`, 'InvalidAccessError');
549
445
  }
550
446
 
551
- let nodeOnly = true;
552
- // do not throw if no browser clients are defined, very usefull for
553
- // cleaning tests in particular
554
- for (let role in this.config.app.clients) {
555
- if (this.config.app.clients[role].target === 'browser') {
556
- nodeOnly = false;
557
- }
558
- }
447
+ return this.#auditState;
448
+ }
559
449
 
560
- if (!nodeOnly) {
561
- if (this._applicationTemplateOptions.templateEngine === null
562
- || this._applicationTemplateOptions.templatePath === null
563
- || this._applicationTemplateOptions.clientConfigFunction === null
564
- ) {
565
- throw new Error('[soundworks:Server] A browser client has been found in "config.app.clients" but configuration for html templating is missing. You should probably call `server.setDefaultTemplateConfig()` if you use the soundworks-template and/or refer (at your own risks) to the documentation of `setCustomTemplateConfig()`');
566
- }
450
+ /**
451
+ * The `init` method is part of the initialization lifecycle of the `soundworks`
452
+ * server. Most of the time, the `init` method will be implicitly called by the
453
+ * {@link Server#start} method.
454
+ *
455
+ * In some situations you might want to call this method manually, in such cases
456
+ * the method should be called before the {@link Server#start} method.
457
+ *
458
+ * What it does:
459
+ * 1) Create the audit state
460
+ * 2) Create the HTTP(s) server
461
+ * 3) Initialize registered plugins
462
+ *
463
+ * Between steps 2 and 3, the 'http-server-ready' event status is dispatched so
464
+ * that consumer code can register its router before plugin initialization:
465
+ * ```js
466
+ * server.onStatusChange(status => {
467
+ * if (status === 'http-server-ready') {
468
+ * server.httpServer.on('request', router);
469
+ * }
470
+ * });
471
+ * ```
472
+ *
473
+ * After `await server.init()` is fulfilled, the {@link Server#stateManager}
474
+ * and all registered plugins can be safely used.
475
+ *
476
+ * @example
477
+ * const server = new Server(config);
478
+ * await server.init();
479
+ * await server.start();
480
+ * // or implicitly called by start
481
+ * const server = new Server(config);
482
+ * await server.start(); // init is called implicitly
483
+ */
484
+ async init() {
485
+ if (this.#status !== 'idle') {
486
+ throw new DOMException(`Cannot execute 'init' on Server: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
567
487
  }
568
488
 
569
- // ------------------------------------------------------------
570
- // INIT ROUTING
571
- // ------------------------------------------------------------
572
- logger.title(`configured clients and routing`);
489
+ // init `ServerStateManager` and global "audit" state
490
+ this.#stateManager[kStateManagerInit](SERVER_ID, new EventEmitter());
573
491
 
574
- const routes = [];
575
- const clientsConfig = [];
576
-
577
- for (let role in this.config.app.clients) {
578
- const config = Object.assign({}, this.config.app.clients[role]);
579
- config.role = role;
580
- clientsConfig.push(config);
492
+ const numClients = {};
493
+ for (let name in this.#config.app.clients) {
494
+ numClients[name] = 0;
581
495
  }
582
496
 
583
- // sort default client last to open the route at the end
584
- clientsConfig
585
- .sort(a => a.default === true ? 1 : -1)
586
- .forEach(config => {
587
- const path = this._openClientRoute(this.router, config);
588
- routes.push({ role: config.role, path });
589
- });
590
-
591
- logger.clientConfigAndRouting(routes, this.config);
497
+ this.#auditState = await this.#stateManager.create(AUDIT_STATE_NAME, { numClients });
592
498
 
593
- // ------------------------------------------------------------
594
- // START PLUGIN MANAGER
595
- // ------------------------------------------------------------
596
- await this.pluginManager.start();
499
+ // backward compatibility for `useDefaultApplicationTemplate`
500
+ if (this.#useDefaultApplicationTemplate === true) {
501
+ try {
502
+ const { configureHttpRouter } = await import('@soundworks/helpers/server.js');
503
+ configureHttpRouter(this);
504
+ } catch (err) {
505
+ logger.warn('Could not apply patch for deprecated `useDefaultApplicationTemplate` method. Please use `configureHttpRouter` from helpers instead.');
506
+ throw err;
507
+ }
508
+ }
597
509
 
598
- await this._dispatchStatus('inited');
510
+ // create HTTP(S) SERVER
511
+ try {
512
+ this.#httpServer = await createHttpServer(this);
513
+ await this.#dispatchStatus('http-server-ready');
514
+ } catch (err) {
515
+ logger.error(err.message);
516
+ await this.#dispatchStatus('errored');
517
+ throw err;
518
+ }
599
519
 
600
- return Promise.resolve();
520
+ // start `ServerPluginManager`
521
+ await this.#pluginManager[kPluginManagerStart]();
522
+ await this.#dispatchStatus('inited');
601
523
  }
602
524
 
603
525
  /**
604
526
  * The `start` method is part of the initialization lifecycle of the `soundworks`
605
- * server. The `start` method will implicitly call the {@link server.Server#init}
527
+ * server. The `start` method will implicitly call the {@link Server#init}
606
528
  * method if it has not been called manually.
607
529
  *
608
530
  * What it does:
609
- * - implicitely call {@link server.Server#init} if not done manually
531
+ * - implicitly call {@link Server#init} if not done manually
610
532
  * - launch the HTTP and WebSocket servers
611
533
  * - start all created contexts. To this end, you will have to call `server.init`
612
534
  * manually and instantiate the contexts between `server.init()` and `server.start()`
@@ -620,87 +542,39 @@ Invalid certificate files, please check your:
620
542
  * await server.start();
621
543
  */
622
544
  async start() {
623
- if (this.status === 'idle') {
545
+ // lazily call init for convenience
546
+ if (this.#status === 'idle') {
624
547
  await this.init();
625
548
  }
626
549
 
627
- if (this.status === 'started') {
628
- throw new Error(`[soundworks:Server] Cannot call "server.start()" twice`);
550
+ if (this.#status !== 'inited') {
551
+ throw new DOMException(`Cannot execute 'start' on Server: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
629
552
  }
630
553
 
631
- if (this.status !== 'inited') {
632
- throw new Error(`[soundworks:Server] Cannot "server.start()" before "server.init()"`);
633
- }
634
-
635
- // ------------------------------------------------------------
636
- // START CONTEXT MANAGER
637
- // ------------------------------------------------------------
638
- await this.contextManager.start();
639
-
640
- // ------------------------------------------------------------
641
- // START SOCKET SERVER
642
- // ------------------------------------------------------------
643
- await this.sockets.start(
644
- this,
645
- this.config.env.websockets,
646
- (...args) => this._onSocketConnection(...args),
647
- );
648
-
649
- // ------------------------------------------------------------
650
- // START HTTP SERVER
651
- // ------------------------------------------------------------
554
+ // state `ServerContextManager`
555
+ await this.#contextManager[kServerContextManagerStart]();
556
+ // start `SocketServer`
557
+ await this.#sockets[kSocketsStart]();
558
+ // start httpServer
652
559
  return new Promise(resolve => {
653
- const port = this.config.env.port;
654
- const useHttps = this.config.env.useHttps || false;
655
- const protocol = useHttps ? 'https' : 'http';
656
- const ifaces = os.networkInterfaces();
560
+ const port = this.#config.env.port;
561
+ const protocol = this.#config.env.useHttps ? 'https' : 'http';
562
+ const interfaces = os.networkInterfaces();
657
563
 
658
- this.httpServer.listen(port, async () => {
564
+ this.#httpServer.listen(port, async () => {
659
565
  logger.title(`${protocol} server listening on`);
660
566
 
661
- Object.keys(ifaces).forEach(dev => {
662
- ifaces[dev].forEach(details => {
567
+ Object.keys(interfaces).forEach(dev => {
568
+ interfaces[dev].forEach(details => {
663
569
  if (details.family === 'IPv4') {
664
570
  logger.ip(protocol, details.address, port);
665
571
  }
666
572
  });
667
573
  });
668
574
 
669
- if (this.httpsInfos !== null) {
670
- logger.title(`https certificates infos`);
671
-
672
- // this.httpsInfos.selfSigned = true;
673
- if (this.httpsInfos.selfSigned) {
674
- logger.log(` self-signed: ${this.httpsInfos.selfSigned ? 'true' : 'false'}`);
675
- logger.log(chalk.yellow` > INVALID CERTIFICATE (self-signed)`);
676
-
677
- } else {
678
- logger.log(` valid from: ${this.httpsInfos.validFrom}`);
679
- logger.log(` valid to: ${this.httpsInfos.validTo}`);
680
-
681
- // this.httpsInfos.isValid = false; // for testing
682
- if (!this.httpsInfos.isValid) {
683
- logger.error(chalk.red` -------------------------------------------`);
684
- logger.error(chalk.red` > INVALID CERTIFICATE`);
685
- logger.error(chalk.red` i.e. you pretend to be safe but you are not`);
686
- logger.error(chalk.red` -------------------------------------------`);
687
- } else {
688
- // this.httpsInfos.daysRemaining = 2; // for testing
689
- if (this.httpsInfos.daysRemaining < 5) {
690
- logger.log(chalk.red` > CERTIFICATE IS VALID... BUT ONLY ${this.httpsInfos.daysRemaining} DAYS LEFT, PLEASE CONSIDER UPDATING YOUR CERTS!`);
691
- } else if (this.httpsInfos.daysRemaining < 15) {
692
- logger.log(chalk.yellow` > CERTIFICATE IS VALID - only ${this.httpsInfos.daysRemaining} days left, be careful...`);
693
- } else {
694
- logger.log(chalk.green` > CERTIFICATE IS VALID (${this.httpsInfos.daysRemaining} days left)`);
695
- }
696
- }
697
-
698
- }
699
- }
700
-
701
- await this._dispatchStatus('started');
575
+ await this.#dispatchStatus('started');
702
576
 
703
- if (this.config.env.type === 'development') {
577
+ if (this.#config.env.type === 'development') {
704
578
  logger.log(`\n> press "${chalk.bold('Ctrl + C')}" to exit`);
705
579
  }
706
580
 
@@ -714,7 +588,7 @@ Invalid certificate files, please check your:
714
588
  * the http(s) server.
715
589
  *
716
590
  * In most situations, you might not need to call this method. However, it can
717
- * be usefull for unit testing or similar situations where you want to create
591
+ * be useful for unit testing or similar situations where you want to create
718
592
  * and delete several servers in the same process.
719
593
  *
720
594
  * @example
@@ -727,167 +601,107 @@ Invalid certificate files, please check your:
727
601
  * await server.stop();
728
602
  */
729
603
  async stop() {
730
- if (this.status !== 'started') {
731
- throw new Error(`[soundworks:Server] Cannot stop() before start()`);
604
+ if (this.#status !== 'started') {
605
+ throw new DOMException(`Cannot execute 'stop' on Server: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError');
732
606
  }
733
607
 
734
- await this.contextManager.stop();
735
- await this.pluginManager.stop();
608
+ await this.#contextManager[kServerContextManagerStop]();
609
+ await this.#pluginManager[kPluginManagerStop]();
610
+
611
+ this.#sockets[kSocketsStop]();
736
612
 
737
- this.sockets.terminate();
738
- this.httpServer.close(err => {
613
+ this.#httpServer.close(err => {
739
614
  if (err) {
740
615
  throw new Error(err.message);
741
616
  }
742
617
  });
743
618
 
744
- await this._dispatchStatus('stopped');
619
+ await this.#dispatchStatus('stopped');
745
620
  }
746
621
 
747
- /**
748
- * Open the route for a given client.
749
- * @private
750
- */
751
- _openClientRoute(router, config) {
752
- const { role, target } = config;
753
- const isDefault = (config.default === true);
754
- // only browser targets need a route
755
- if (target === 'node') {
756
- return;
757
- }
758
-
759
- let route = '/';
760
-
761
- if (!isDefault) {
762
- route += `${role}`;
763
- }
764
-
765
- this.config.app.clients[role].route = route;
766
-
767
- // define template filename: `${role}.html` or `default.html`
768
- const {
769
- templatePath,
770
- templateEngine,
771
- clientConfigFunction,
772
- } = this._applicationTemplateOptions;
773
-
774
- const clientTmpl = path.join(templatePath, `${role}.tmpl`);
775
- const defaultTmpl = path.join(templatePath, `default.tmpl`);
776
-
777
- // make it sync
778
- let template;
779
-
780
- try {
781
- const stats = fs.statSync(clientTmpl);
782
- template = stats.isFile() ? clientTmpl : defaultTmpl;
783
- } catch (err) {
784
- template = defaultTmpl;
785
- }
786
-
787
- let tmplString;
788
-
789
- try {
790
- tmplString = fs.readFileSync(template, 'utf8');
791
- } catch (err) {
792
- throw new Error(`[soundworks:Server] html template file "${template}" not found`);
793
- }
794
-
795
- const tmpl = templateEngine.compile(tmplString);
796
-
797
- const soundworksClientHandler = (req, res) => {
798
- const data = clientConfigFunction(role, this.config, req);
799
-
800
- // if the client has gone through the connection middleware (add succedeed),
801
- // add the token to the data object
802
- if (res.swToken) {
803
- data.token = res.swToken;
804
- }
805
-
806
- // CORS / COOP / COEP headers for `crossOriginIsolated pages,
807
- // enables `sharedArrayBuffers` and high precision timers
808
- // cf. https://web.dev/why-coop-coep/
809
- if (this.config.env.crossOriginIsolated) {
810
- res.writeHead(200, {
811
- 'Cross-Origin-Resource-Policy': 'same-origin',
812
- 'Cross-Origin-Embedder-Policy': 'require-corp',
813
- 'Cross-Origin-Opener-Policy': 'same-origin',
814
- });
815
- }
816
-
817
- const appIndex = tmpl(data);
818
- res.end(appIndex);
819
- };
820
-
821
- // http request
822
- router.get(route, soundworksClientHandler);
622
+ onClientConnect(callback) {
623
+ this.#onClientConnectCallbacks.add(callback);
624
+ return () => this.#onClientConnectCallbacks.delete(callback);
625
+ }
823
626
 
824
- // return route infos for logging on server start
825
- return route;
627
+ onClientDisconnect(callback) {
628
+ this.#onClientDisconnectCallbacks.add(callback);
629
+ return () => this.#onClientDisconnectCallbacks.delete(callback);
826
630
  }
827
631
 
828
632
  /**
829
633
  * Socket connection callback.
830
634
  * @private
831
635
  */
832
- _onSocketConnection(role, socket, connectionToken) {
833
- const client = new Client(role, socket);
834
- const roles = Object.keys(this.config.app.clients);
636
+ [kServerOnSocketConnection](role, socket, connectionToken) {
637
+ const client = new ServerClient(role, socket);
638
+ socket[kSocketClientId] = client.id;
639
+ const roles = Object.keys(this.#config.app.clients);
835
640
 
836
641
  // this has been validated
837
- if (this.isProtected(role) && this.isValidConnectionToken(connectionToken)) {
642
+ if (this.isProtectedClientRole(role) && this[kServerIsValidConnectionToken](connectionToken)) {
838
643
  const { ip } = decryptData(connectionToken);
839
- const newData = {
840
- ip: ip,
841
- id: client.id,
842
- };
843
-
644
+ const newData = { ip, id: client.id };
844
645
  const newToken = encryptData(newData);
845
646
 
846
- client.token = newToken;
647
+ client[kServerClientToken] = newToken;
847
648
 
848
- this._pendingConnectionTokens.delete(connectionToken);
849
- this._trustedClients.add(client);
649
+ this.#pendingConnectionTokens.delete(connectionToken);
650
+ this.#trustedClients.add(client);
850
651
  }
851
652
 
852
653
  socket.addListener('close', async () => {
853
- // do nothing if client role is invalid
854
- if (roles.includes(role)) {
654
+ // cleanup if role is valid and client finished handshake
655
+ if (roles.includes(role) && this.#stateManager[kServerStateManagerHasClient](client.id)) {
855
656
  // decrement audit state counter
856
- const numClients = this._auditState.get('numClients');
657
+ const numClients = this.#auditState.get('numClients');
857
658
  numClients[role] -= 1;
858
- this._auditState.set({ numClients });
659
+ this.#auditState.set({ numClients });
859
660
 
860
661
  // delete token
861
- if (this._trustedClients.has(client)) {
862
- this._trustedClients.delete(client);
662
+ if (this.#trustedClients.has(client)) {
663
+ this.#trustedClients.delete(client);
664
+ }
665
+
666
+ // if something goes wrong here, the 'close' event is called again and
667
+ // again and again... let's just log the error and terminate the socket
668
+ try {
669
+ // clean context manager, await before cleaning state manager
670
+ await this.#contextManager[kServerContextManagerRemoveClient](client);
671
+ // remove client from pluginManager
672
+ await this.#pluginManager[kServerPluginManagerRemoveClient](client);
673
+ // clean state manager
674
+ await this.#stateManager[kServerStateManagerRemoveClient](client.id);
675
+
676
+ this.#onClientDisconnectCallbacks.forEach(callback => callback(client));
677
+ } catch (err) {
678
+ console.error(err);
863
679
  }
864
- // clean context manager, await before cleaning state manager
865
- await this.contextManager.removeClient(client);
866
- // remove client from pluginManager
867
- await this.pluginManager.removeClient(client);
868
- // clean state manager
869
- await this.stateManager.removeClient(client.id);
870
680
  }
871
681
 
872
682
  // clean sockets
873
- socket.terminate();
683
+ socket[kSocketTerminate]();
874
684
  });
875
685
 
876
686
  socket.addListener(CLIENT_HANDSHAKE_REQUEST, async payload => {
877
- const { role, registeredPlugins } = payload;
687
+ const { role, version, registeredPlugins } = payload;
878
688
 
879
689
  if (!roles.includes(role)) {
880
- console.error(`[soundworks.Server] A client with invalid type ("${role}") attempted to connect`);
690
+ console.error(`A client with undefined role ("${role}") attempted to connect`);
881
691
 
882
692
  socket.send(CLIENT_HANDSHAKE_ERROR, {
883
693
  type: 'invalid-client-type',
884
- message: `Invalid client type, please check server configuration (valid client types are: ${roles.join(', ')})`,
694
+ message: `Invalid client role, please check server configuration (valid client roles are: ${roles.join(', ')})`,
885
695
  });
886
696
  return;
887
697
  }
888
698
 
699
+ if (version !== this.#version) {
700
+ logger.warnVersionDiscrepancies(role, version, this.#version);
701
+ }
702
+
889
703
  try {
890
- this.pluginManager.checkRegisteredPlugins(registeredPlugins);
704
+ this.#pluginManager[kServerPluginManagerCheckRegisteredPlugins](registeredPlugins);
891
705
  } catch (err) {
892
706
  socket.send(CLIENT_HANDSHAKE_ERROR, {
893
707
  type: 'invalid-plugin-list',
@@ -897,192 +711,71 @@ Invalid certificate files, please check your:
897
711
  }
898
712
 
899
713
  // increment audit state
900
- const numClients = this._auditState.get('numClients');
714
+ const numClients = this.#auditState.get('numClients');
901
715
  numClients[role] += 1;
902
- this._auditState.set({ numClients });
716
+ this.#auditState.set({ numClients });
903
717
 
904
- // add client to state manager
905
- await this.stateManager.addClient(client.id, {
718
+ const transport = {
906
719
  emit: client.socket.send.bind(client.socket),
907
720
  addListener: client.socket.addListener.bind(client.socket),
908
721
  removeAllListeners: client.socket.removeAllListeners.bind(client.socket),
909
- });
722
+ };
723
+ // add client to state manager
724
+ await this.#stateManager[kServerStateManagerAddClient](client.id, transport);
910
725
  // add client to plugin manager
911
726
  // server-side, all plugins are active for the lifetime of the client
912
- await this.pluginManager.addClient(client, registeredPlugins);
727
+ await this.#pluginManager[kServerPluginManagerAddClient](client, registeredPlugins);
913
728
  // add client to context manager
914
- await this.contextManager.addClient(client);
915
-
916
- const { id, uuid, token } = client;
917
- socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid, token });
918
- });
919
- }
920
-
921
- /**
922
- * Create namespaced databases for core and plugins
923
- * (kind of experimental API do not expose in doc for now)
924
- *
925
- * @note - introduced in v3.1.0-beta.1
926
- * @note - used by core and plugin-audio-streams
927
- * @private
928
- */
929
- createNamespacedDb(namespace = null) {
930
- if (namespace === null || !(typeof namespace === 'string')) {
931
- throw new Error(`[soundworks:Server] Invalid namespace for ".createNamespacedDb(namespace)", namespace is mandatory and should be a string`);
932
- }
729
+ await this.#contextManager[kServerContextManagerAddClient](client);
933
730
 
934
- if (_dbNamespaces.has(namespace)) {
935
- throw new Error(`[soundworks:Server] Invalid namespace for ".createNamespacedDb(namespace)", namespace "${namespace}" already exists`);
936
- }
731
+ this.#onClientConnectCallbacks.forEach(callback => callback(client));
937
732
 
938
- // KeyvFile uses fs-extra.outputFile internally so we don't need to create
939
- // the directory, it will be lazily created if something is written in the db
940
- // @see https://github.com/zaaack/keyv-file/blob/52502077c78226b3d69a615c80b88e53be096979/index.ts#L157
941
- const filename = path.join(process.cwd(), '.data', `soundworks-${namespace}.db`);
942
- // @note - keyv-file doesn't seems to works
943
- const store = new KeyvFile({ filename });
944
- const db = new Keyv({ namespace, store });
945
- db.on('error', err => logger.error(`[soundworks:Server] db ${namespace} error: ${err}`));
946
-
947
- return db;
948
- }
949
-
950
- onStatusChange(callback) {
951
- this._onStatusChangeCallbacks.add(callback);
952
-
953
- return () => this._onStatusChangeCallbacks.delete(callback);
733
+ socket.send(CLIENT_HANDSHAKE_RESPONSE, {
734
+ id: client.id,
735
+ uuid: client.uuid,
736
+ token: client[kServerClientToken],
737
+ version: this.#version,
738
+ });
739
+ });
954
740
  }
955
741
 
742
+ // make public
956
743
  /** @private */
957
- async _dispatchStatus(status) {
958
- this.status = status;
959
-
960
- // if launched in a child process, forward status to parent process
961
- if (process.send !== undefined) {
962
- process.send(`soundworks:server:${status}`);
744
+ isProtectedClientRole(role) {
745
+ if (this.#config.env.auth && Array.isArray(this.#config.env.auth.clients)) {
746
+ return this.#config.env.auth.clients.includes(role);
963
747
  }
964
748
 
965
- // execute all callbacks in parallel
966
- const promises = [];
967
-
968
- for (let callback of this._onStatusChangeCallbacks) {
969
- promises.push(callback(status));
970
- }
971
-
972
- await Promise.all(promises);
749
+ return false;
973
750
  }
974
751
 
975
752
  /**
976
- * Configure the server to work _out-of-the-box_ within the soundworks application
977
- * template provided by `@soundworks/create.
978
- *
979
- * - uses [template-literal](https://www.npmjs.com/package/template-literal) package
980
- * as html templateEngine
981
- * - define `.build/server/tmpl` as the directory in which html template can be
982
- * found
983
- * - define the `clientConfigFunction` function that return client compliant
984
- * config object to be injected in the html template.
985
- *
986
- * Also expose two public directory:
987
- * - the `public` directory which is exposed behind the root path
988
- * - the `./.build/public` directory which is exposed behind the `build` path
753
+ * Generate a token to secure client connection.
989
754
  *
990
- * _Note: except in very rare cases (so rare that they are quite difficult to imagine),
991
- * you should rely on these defaults._
755
+ * The token should be passed to the client-side `Client` config object, it will
756
+ * be internally used to check the WebSocket connection and reject it if the
757
+ * token is invalid.
992
758
  */
993
- useDefaultApplicationTemplate() {
994
- const buildDirectory = path.join('.build', 'public');
759
+ generateAuthToken(req) {
760
+ const id = this.#tokenIdGenerator;
761
+ const ip = req.ip;
762
+ const time = getTime();
763
+ const token = { id, ip, time };
764
+ const encryptedToken = encryptData(token);
995
765
 
996
- const useMinifiedFile = {};
997
- const roles = Object.keys(this.config.app.clients);
766
+ this.#pendingConnectionTokens.add(encryptedToken);
998
767
 
999
- roles.forEach(role => {
1000
- if (this.config.env.type === 'production') {
1001
- // check if minified file exists
1002
- const minifiedFilePath = path.join(buildDirectory, `${role}.min.js`);
768
+ setTimeout(() => {
769
+ this.#pendingConnectionTokens.delete(encryptedToken);
770
+ }, TOKEN_VALID_DURATION * 1000);
1003
771
 
1004
- if (fs.existsSync(minifiedFilePath)) {
1005
- useMinifiedFile[role] = true;
1006
- } else {
1007
- console.log(chalk.yellow(` > Minified file not found for client "${role}", falling back to normal build file (use \`npm run build:production && npm start\` to use minified files)`));
1008
- useMinifiedFile[role] = false;
1009
- }
1010
- } else {
1011
- useMinifiedFile[role] = false;
1012
- }
1013
- });
1014
-
1015
- this._applicationTemplateOptions = {
1016
- templateEngine: { compile },
1017
- templatePath: path.join('.build', 'server', 'tmpl'),
1018
- clientConfigFunction: (role, config, _httpRequest) => {
1019
- return {
1020
- role: role,
1021
- app: {
1022
- name: config.app.name,
1023
- author: config.app.author,
1024
- },
1025
- env: {
1026
- type: config.env.type,
1027
- websockets: config.env.websockets,
1028
- subpath: config.env.subpath,
1029
- useMinifiedFile: useMinifiedFile[role],
1030
- },
1031
- };
1032
- },
1033
- };
1034
-
1035
- this.router.use(express.static('public'));
1036
- this.router.use('/build', express.static(buildDirectory));
1037
- }
1038
-
1039
- /**
1040
- * Define custom template path, template engine, and clientConfig function.
1041
- * This method is proposed for very advanced use-cases and should very probably
1042
- * be improved. If you consider using this for some reason, please get in touch
1043
- * first to explain your use-case :)
1044
- */
1045
- setCustomApplicationTemplateOptions(options) {
1046
- Object.assign(this._applicationTemplateOptions, options);
1047
- }
1048
-
1049
- /**
1050
- * Attach and retrieve the global audit state of the application.
1051
- *
1052
- * The audit state is a {@link server.SharedState} instance that keeps track of
1053
- * global informations about the application such as, the number of connected
1054
- * clients, network latency estimation, etc.
1055
- *
1056
- * The audit state is created by the server on start up.
1057
- *
1058
- * @returns {Promise<server.SharedState>}
1059
- * @throws Will throw if called before `server.init()`
1060
- * @see {@link server.SharedState}
1061
- * @example
1062
- * const auditState = await server.getAuditState();
1063
- * auditState.onUpdate(() => console.log(auditState.getValues()), true);
1064
- */
1065
- async getAuditState() {
1066
- if (this.status === 'idle') {
1067
- throw new Error(`[soundworks.Server] Cannot access audit state before init`);
1068
- }
1069
-
1070
- return this._auditState;
1071
- }
1072
-
1073
- /** @private */
1074
- isProtected(role) {
1075
- if (this.config.env.auth && Array.isArray(this.config.env.auth.clients)) {
1076
- return this.config.env.auth.clients.includes(role);
1077
- }
1078
-
1079
- return false;
772
+ return encryptedToken;
1080
773
  }
1081
774
 
1082
775
  /** @private */
1083
- isValidConnectionToken(token) {
776
+ [kServerIsValidConnectionToken](token) {
1084
777
  // token should be in pending token list
1085
- if (!this._pendingConnectionTokens.has(token)) {
778
+ if (!this.#pendingConnectionTokens.has(token)) {
1086
779
  return false;
1087
780
  }
1088
781
 
@@ -1090,10 +783,8 @@ Invalid certificate files, please check your:
1090
783
  const data = decryptData(token);
1091
784
  const now = getTime();
1092
785
 
1093
- // token is valid only for 30 seconds (this is arbitrary)
1094
786
  if (now > data.time + TOKEN_VALID_DURATION) {
1095
- // delete the token, is too old
1096
- this._pendingConnectionTokens.delete(token);
787
+ this.#pendingConnectionTokens.delete(token);
1097
788
  return false;
1098
789
  } else {
1099
790
  return true;
@@ -1104,14 +795,14 @@ Invalid certificate files, please check your:
1104
795
  * Check if the given client is trusted, i.e. config.env.type == 'production'
1105
796
  * and the client is protected behind a password.
1106
797
  *
1107
- * @param {server.Client} client - Client to be tested
1108
- * @returns {Boolean}
798
+ * @param {ServerClient} client - Client to be tested
799
+ * @returns {boolean}
1109
800
  */
1110
801
  isTrustedClient(client) {
1111
- if (this.config.env.type !== 'production') {
802
+ if (this.#config.env.type !== 'production') {
1112
803
  return true;
1113
804
  } else {
1114
- return this._trustedClients.has(client);
805
+ return this.#trustedClients.has(client);
1115
806
  }
1116
807
  }
1117
808
 
@@ -1119,20 +810,20 @@ Invalid certificate files, please check your:
1119
810
  * Check if the token from a client is trusted, i.e. config.env.type == 'production'
1120
811
  * and the client is protected behind a password.
1121
812
  *
1122
- * @param {Number} clientId - Id of the client
1123
- * @param {Number} clientIp - Ip of the client
1124
- * @param {String} token - Token to be tested
1125
- * @returns {Boolean}
813
+ * @param {number} clientId - Id of the client
814
+ * @param {string} clientIp - Ip of the client
815
+ * @param {string} token - Token to be tested
816
+ * @returns {boolean}
1126
817
  */
1127
818
  // for stateless interactions, e.g. POST files
1128
819
  isTrustedToken(clientId, clientIp, token) {
1129
- if (this.config.env.type !== 'production') {
820
+ if (this.#config.env.type !== 'production') {
1130
821
  return true;
1131
822
  } else {
1132
- for (let client of this._trustedClients) {
1133
- if (client.id === clientId && client.token === token) {
823
+ for (let client of this.#trustedClients) {
824
+ if (client.id === clientId && client[kServerClientToken] === token) {
1134
825
  // check that given token is consistent with client ip and id
1135
- const { id, ip } = decryptData(client.token);
826
+ const { id, ip } = decryptData(client[kServerClientToken]);
1136
827
 
1137
828
  if (clientId === id && clientIp === ip) {
1138
829
  return true;
@@ -1144,6 +835,43 @@ Invalid certificate files, please check your:
1144
835
  return false;
1145
836
  }
1146
837
  }
838
+
839
+ /**
840
+ * Create namespaced databases for core and plugins
841
+ * (kind of experimental API do not expose in doc for now)
842
+ *
843
+ * @note - introduced in v3.1.0-beta.1
844
+ * @note - used by core and plugin-audio-streams
845
+ * @private
846
+ */
847
+ createNamespacedDb(namespace = null) {
848
+ if (!isString(namespace)) {
849
+ throw new TypeError(`Cannot execute "createNamespacedDb(namespace)" on Server: argument 1 must be a string`);
850
+ }
851
+
852
+ if (dbNamespaces.has(namespace)) {
853
+ throw new DOMException(`Cannot execute "createNamespacedDb(namespace)" on Server: namespace "${namespace}" already exists`, 'NotSupportedError');
854
+ }
855
+
856
+ // KeyvFile uses fs-extra.outputFile internally so we don't need to create
857
+ // the directory, it will be lazily created if something is written in the db
858
+ // @see https://github.com/zaaack/keyv-file/blob/52502077c78226b3d69a615c80b88e53be096979/index.ts#L157
859
+ const filename = path.join(process.cwd(), '.data', `soundworks-${namespace}.db`);
860
+ // @note - keyv-file doesn't seems to works
861
+ const store = new KeyvFile({ filename });
862
+ const db = new Keyv({ namespace, store });
863
+ db.on('error', err => logger.error(`[soundworks:Server] db ${namespace} error: ${err}`));
864
+
865
+ return db;
866
+ }
867
+
868
+ /**
869
+ * @deprecated
870
+ */
871
+ useDefaultApplicationTemplate() {
872
+ logger.deprecated('Server#useDefaultApplicationTemplate', '`configureHttpRouter(server)` from the `@soundworks/helpers/server.js` package', '4.0.0-alpha.29');
873
+ this.#useDefaultApplicationTemplate = true;
874
+ }
1147
875
  }
1148
876
 
1149
877
  export default Server;