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