@soundworks/core 4.0.0-alpha.1 → 4.0.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc +0 -1
- package/README.md +17 -26
- package/package.json +20 -21
- package/src/client/Client.js +48 -8
- package/src/client/Context.js +39 -12
- package/src/client/ContextManager.js +1 -1
- package/src/client/Plugin.js +2 -1
- package/src/client/PluginManager.js +27 -18
- package/src/client/SharedStateCollection.js +35 -0
- package/src/client/Socket.js +61 -48
- package/src/client/index.js +0 -33
- package/src/common/BasePlugin.js +30 -20
- package/src/common/BasePluginManager.js +44 -20
- package/src/common/BaseSharedState.js +151 -41
- package/src/common/BaseSharedStateCollection.js +191 -0
- package/src/common/BaseStateManager.js +73 -16
- package/src/common/ParameterBag.js +87 -217
- package/src/common/promise-store.js +1 -1
- package/src/server/Client.js +7 -2
- package/src/server/Context.js +162 -18
- package/src/server/ContextManager.js +8 -9
- package/src/server/Plugin.js +2 -1
- package/src/server/PluginManager.js +27 -7
- package/src/server/Server.js +428 -101
- package/src/server/SharedState.js +0 -1
- package/src/server/SharedStateCollection.js +36 -0
- package/src/server/Socket.js +105 -62
- package/src/server/Sockets.js +52 -26
- package/src/server/StateManager.js +238 -28
- package/src/server/crypto.js +38 -0
- package/src/server/index.js +0 -20
- package/src/common/utils.js +0 -22
package/src/server/Server.js
CHANGED
|
@@ -5,10 +5,11 @@ import path from 'node:path';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { X509Certificate, createPrivateKey } from 'node:crypto';
|
|
7
7
|
|
|
8
|
+
import { getTime } from '@ircam/sc-gettime';
|
|
9
|
+
import { isPlainObject, idGenerator } from '@ircam/sc-utils';
|
|
8
10
|
import chalk from 'chalk';
|
|
9
11
|
import compression from 'compression';
|
|
10
12
|
import express from 'express';
|
|
11
|
-
import isPlainObject from 'is-plain-obj';
|
|
12
13
|
import Keyv from 'keyv';
|
|
13
14
|
import { KeyvFile } from 'keyv-file';
|
|
14
15
|
import merge from 'lodash.merge';
|
|
@@ -16,6 +17,7 @@ import pem from 'pem';
|
|
|
16
17
|
import compile from 'template-literal';
|
|
17
18
|
|
|
18
19
|
import auditSchema from './audit-schema.js';
|
|
20
|
+
import { encryptData, decryptData } from './crypto.js';
|
|
19
21
|
import Client from './Client.js';
|
|
20
22
|
import ContextManager from './ContextManager.js';
|
|
21
23
|
import PluginManager from './PluginManager.js';
|
|
@@ -23,14 +25,37 @@ import StateManager from './StateManager.js';
|
|
|
23
25
|
import Sockets from './Sockets.js';
|
|
24
26
|
import logger from '../common/logger.js';
|
|
25
27
|
import {
|
|
28
|
+
SERVER_ID,
|
|
26
29
|
CLIENT_HANDSHAKE_REQUEST,
|
|
27
30
|
CLIENT_HANDSHAKE_RESPONSE,
|
|
28
31
|
CLIENT_HANDSHAKE_ERROR,
|
|
29
32
|
AUDIT_STATE_NAME,
|
|
30
33
|
} from '../common/constants.js';
|
|
31
34
|
|
|
35
|
+
|
|
32
36
|
let _dbNamespaces = new Set();
|
|
33
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
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** @private */
|
|
34
59
|
const DEFAULT_CONFIG = {
|
|
35
60
|
env: {
|
|
36
61
|
type: 'development',
|
|
@@ -52,6 +77,8 @@ const DEFAULT_CONFIG = {
|
|
|
52
77
|
},
|
|
53
78
|
};
|
|
54
79
|
|
|
80
|
+
const TOKEN_VALID_DURATION = 20; // sec
|
|
81
|
+
|
|
55
82
|
// set terminal title
|
|
56
83
|
/** @private */
|
|
57
84
|
function setTerminalTitle(server) {
|
|
@@ -74,52 +101,89 @@ function setTerminalTitle(server) {
|
|
|
74
101
|
}
|
|
75
102
|
|
|
76
103
|
/**
|
|
77
|
-
* Server
|
|
104
|
+
* The `Server` class is the main entry point for the server-side of a soundworks
|
|
105
|
+
* application.
|
|
78
106
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* @memberof server
|
|
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
|
|
110
|
+
* soundworks components.
|
|
84
111
|
*
|
|
85
|
-
*
|
|
86
|
-
* @example
|
|
112
|
+
* ```
|
|
87
113
|
* import { Server } from '@soundworks/core/server';
|
|
114
|
+
*
|
|
88
115
|
* const server = new Server({
|
|
89
116
|
* app: {
|
|
90
117
|
* name: 'my-example-app',
|
|
91
|
-
* clients: {
|
|
118
|
+
* clients: {
|
|
119
|
+
* player: { target: 'browser', default: true },
|
|
120
|
+
* controller: { target: 'browser' },
|
|
121
|
+
* thing: { target: 'node' }
|
|
122
|
+
* },
|
|
92
123
|
* },
|
|
93
124
|
* env: {
|
|
94
|
-
* port:
|
|
125
|
+
* port: 8000,
|
|
95
126
|
* },
|
|
96
127
|
* });
|
|
97
|
-
*
|
|
128
|
+
*
|
|
98
129
|
* await server.start();
|
|
130
|
+
* ```
|
|
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
|
|
99
149
|
*/
|
|
100
150
|
class Server {
|
|
151
|
+
/**
|
|
152
|
+
* @param {server.ServerConfig} config - Configuration object for the server.
|
|
153
|
+
* @throws
|
|
154
|
+
* - If `config.app.clients` is empty.
|
|
155
|
+
* - If a `node` client is defined but `config.env.serverAddress` is not defined.
|
|
156
|
+
* - if `config.env.useHttps` is `true` and `config.env.httpsInfos` is not `null`
|
|
157
|
+
* (which generates self signed certificated), `config.env.httpsInfos.cert` and
|
|
158
|
+
* `config.env.httpsInfos.key` should point to valid cert files.
|
|
159
|
+
*/
|
|
101
160
|
constructor(config) {
|
|
102
161
|
if (!isPlainObject(config)) {
|
|
103
162
|
throw new Error(`[soundworks:Server] Invalid argument for Server constructor, config should be an object`);
|
|
104
163
|
}
|
|
105
164
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* ```
|
|
165
|
+
* @description Given config object merged with the following defaults:
|
|
166
|
+
* @example
|
|
109
167
|
* {
|
|
110
168
|
* env: {
|
|
111
169
|
* type: 'development',
|
|
112
170
|
* port: 8000,
|
|
113
|
-
*
|
|
171
|
+
* serverAddress: null,
|
|
172
|
+
* subpath: '',
|
|
173
|
+
* websockets: {
|
|
174
|
+
* path: 'socket',
|
|
175
|
+
* pingInterval: 5000,
|
|
176
|
+
* },
|
|
114
177
|
* useHttps: false,
|
|
115
178
|
* httpsInfos: null,
|
|
179
|
+
* crossOriginIsolated: true,
|
|
180
|
+
* verbose: true,
|
|
116
181
|
* },
|
|
117
182
|
* app: {
|
|
118
183
|
* name: 'soundworks',
|
|
119
184
|
* clients: {},
|
|
120
|
-
* }
|
|
185
|
+
* }
|
|
121
186
|
* }
|
|
122
|
-
* ```
|
|
123
187
|
*/
|
|
124
188
|
this.config = merge({}, DEFAULT_CONFIG, config);
|
|
125
189
|
|
|
@@ -161,67 +225,92 @@ class Server {
|
|
|
161
225
|
}
|
|
162
226
|
|
|
163
227
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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')));
|
|
166
243
|
*/
|
|
244
|
+
// @note: we use express() instead of express.Router() because all 404 and
|
|
245
|
+
// error stuff is handled by default
|
|
167
246
|
this.router = express();
|
|
168
|
-
// compression (must be set before
|
|
247
|
+
// compression (must be set before express.static())
|
|
169
248
|
this.router.use(compression());
|
|
170
249
|
|
|
171
250
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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}
|
|
174
255
|
*/
|
|
175
256
|
this.httpServer = null;
|
|
176
257
|
|
|
177
258
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* @private
|
|
181
|
-
*/
|
|
182
|
-
this.db = this.createNamespacedDb('core');
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* The {@link server.Sockets} instance. A small wrapper around
|
|
186
|
-
* [`ws`](https://github.com/websockets/ws) server.
|
|
259
|
+
* Instance of the {@link server.Sockets} class.
|
|
260
|
+
*
|
|
187
261
|
* @see {@link server.Sockets}
|
|
188
262
|
* @type {server.Sockets}
|
|
189
263
|
*/
|
|
190
264
|
this.sockets = new Sockets();
|
|
191
265
|
|
|
192
266
|
/**
|
|
193
|
-
*
|
|
267
|
+
* Instance of the {@link server.PluginManager} class.
|
|
268
|
+
*
|
|
194
269
|
* @see {@link server.PluginManager}
|
|
195
270
|
* @type {server.PluginManager}
|
|
196
271
|
*/
|
|
197
272
|
this.pluginManager = new PluginManager(this);
|
|
198
273
|
|
|
199
274
|
/**
|
|
200
|
-
*
|
|
275
|
+
* Instance of the {@link server.StateManager} class.
|
|
276
|
+
*
|
|
201
277
|
* @see {@link server.StateManager}
|
|
202
278
|
* @type {server.StateManager}
|
|
203
279
|
*/
|
|
204
280
|
this.stateManager = new StateManager();
|
|
205
281
|
|
|
206
282
|
/**
|
|
207
|
-
*
|
|
283
|
+
* Instance of the {@link server.ContextManager} class.
|
|
208
284
|
*
|
|
285
|
+
* @see {@link server.ContextManager}
|
|
286
|
+
* @type {server.ContextManager}
|
|
209
287
|
*/
|
|
210
288
|
this.contextManager = new ContextManager(this);
|
|
211
289
|
|
|
212
290
|
/**
|
|
213
|
-
* If https is required,
|
|
214
|
-
*
|
|
291
|
+
* If `https` is required, hold informations about the certificates, e.g. if
|
|
292
|
+
* self-signed, the dates of validity of the certificates, etc.
|
|
215
293
|
*/
|
|
216
294
|
this.httpsInfos = null;
|
|
217
295
|
|
|
218
296
|
/**
|
|
219
|
-
*
|
|
297
|
+
* Status of the server, 'idle', 'inited', 'started' or 'errored'.
|
|
298
|
+
*
|
|
299
|
+
* @type {string}
|
|
220
300
|
*/
|
|
221
301
|
this.status = 'idle';
|
|
222
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
|
+
|
|
223
312
|
/** @private */
|
|
224
|
-
this.
|
|
313
|
+
this._applicationTemplateOptions = {
|
|
225
314
|
templateEngine: null,
|
|
226
315
|
templatePath: null,
|
|
227
316
|
clientConfigFunction: null,
|
|
@@ -230,9 +319,17 @@ class Server {
|
|
|
230
319
|
/** @private */
|
|
231
320
|
this._onStatusChangeCallbacks = new Set();
|
|
232
321
|
/** @private */
|
|
322
|
+
this._onClientConnectCallbacks = new Set();
|
|
323
|
+
/** @private */
|
|
324
|
+
this._onClientDisconnectCallbacks = new Set();
|
|
325
|
+
/** @private */
|
|
233
326
|
this._auditState = null;
|
|
327
|
+
/** @private */
|
|
328
|
+
this._pendingConnectionTokens = new Set();
|
|
329
|
+
/** @private */
|
|
330
|
+
this._trustedClients = new Set();
|
|
234
331
|
|
|
235
|
-
//
|
|
332
|
+
// register audit state schema
|
|
236
333
|
this.stateManager.registerSchema(AUDIT_STATE_NAME, auditSchema);
|
|
237
334
|
|
|
238
335
|
logger.configure(this.config.env.verbose);
|
|
@@ -240,18 +337,30 @@ class Server {
|
|
|
240
337
|
}
|
|
241
338
|
|
|
242
339
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
340
|
+
* Id of the server, a constant set to -1
|
|
341
|
+
* @type {Number}
|
|
342
|
+
* @readonly
|
|
343
|
+
*/
|
|
344
|
+
get id() {
|
|
345
|
+
return SERVER_ID;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The `init` method is part of the initialization lifecycle of the `soundworks`
|
|
350
|
+
* server. Most of the time, the `init` method will be implicitly called by the
|
|
351
|
+
* {@link server.Server#start} method.
|
|
352
|
+
*
|
|
353
|
+
* In some situations you might want to call this method manually, in such cases
|
|
354
|
+
* the method should be called before the {@link server.Server#start} method.
|
|
246
355
|
*
|
|
247
356
|
* What it does:
|
|
248
357
|
* - create the audit state
|
|
249
358
|
* - prepapre http(s) server and routing according to the informations
|
|
250
359
|
* declared in `config.app.clients`
|
|
251
|
-
* -
|
|
360
|
+
* - initialize all registered plugins
|
|
252
361
|
*
|
|
253
|
-
* After `await server.init()
|
|
254
|
-
*
|
|
362
|
+
* After `await server.init()` is fulfilled, the {@link server.Server#stateManager}
|
|
363
|
+
* and all registered plugins can be safely used.
|
|
255
364
|
*
|
|
256
365
|
* @example
|
|
257
366
|
* const server = new Server(config);
|
|
@@ -272,11 +381,24 @@ class Server {
|
|
|
272
381
|
|
|
273
382
|
// basic http authentication
|
|
274
383
|
if (this.config.env.auth) {
|
|
275
|
-
|
|
384
|
+
const ids = idGenerator();
|
|
276
385
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
386
|
+
const soundworksAuth = (req, res, next) => {
|
|
387
|
+
let role = null;
|
|
388
|
+
|
|
389
|
+
for (let [_role, config] of Object.entries(this.config.app.clients)) {
|
|
390
|
+
if (req.path === config.route) {
|
|
391
|
+
role = _role;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// route that are not client entry points just pass through the middleware
|
|
396
|
+
if (role === null) {
|
|
397
|
+
next();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const isProtected = this.isProtected(role);
|
|
280
402
|
|
|
281
403
|
if (isProtected) {
|
|
282
404
|
// authentication middleware
|
|
@@ -288,20 +410,39 @@ class Server {
|
|
|
288
410
|
// verify login and password are set and correct
|
|
289
411
|
if (login && password && login === auth.login && password === auth.password) {
|
|
290
412
|
// -> access granted...
|
|
413
|
+
// generate token for web socket to check connections
|
|
414
|
+
const id = ids.next().value;
|
|
415
|
+
const ip = req.ip;
|
|
416
|
+
const time = getTime();
|
|
417
|
+
const token = { id, ip, time };
|
|
418
|
+
const encryptedToken = encryptData(token);
|
|
419
|
+
|
|
420
|
+
this._pendingConnectionTokens.add(encryptedToken);
|
|
421
|
+
|
|
422
|
+
setTimeout(() => {
|
|
423
|
+
this._pendingConnectionTokens.delete(encryptedToken);
|
|
424
|
+
}, TOKEN_VALID_DURATION * 1000);
|
|
425
|
+
|
|
426
|
+
// pass to the response object to be send to the client
|
|
427
|
+
res.swToken = encryptedToken;
|
|
428
|
+
|
|
291
429
|
return next();
|
|
292
430
|
}
|
|
293
431
|
|
|
294
|
-
//
|
|
432
|
+
// show login / password modal
|
|
295
433
|
res.writeHead(401, {
|
|
296
434
|
'WWW-Authenticate':'Basic',
|
|
297
435
|
'Content-Type':'text/plain',
|
|
298
436
|
});
|
|
437
|
+
|
|
299
438
|
res.end('Authentication required.');
|
|
300
439
|
} else {
|
|
301
|
-
// route not protected
|
|
440
|
+
// route is not protected
|
|
302
441
|
return next();
|
|
303
442
|
}
|
|
304
|
-
}
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
this.router.use(soundworksAuth);
|
|
305
446
|
}
|
|
306
447
|
|
|
307
448
|
// start http server
|
|
@@ -326,7 +467,7 @@ class Server {
|
|
|
326
467
|
// this fails with self-signed certificates for whatever reason...
|
|
327
468
|
try {
|
|
328
469
|
x509 = new X509Certificate(cert);
|
|
329
|
-
} catch(err) {
|
|
470
|
+
} catch (err) {
|
|
330
471
|
this._dispatchStatus('errored');
|
|
331
472
|
throw new Error(`[soundworks:Server] Invalid https cert file`);
|
|
332
473
|
}
|
|
@@ -338,7 +479,7 @@ class Server {
|
|
|
338
479
|
this._dispatchStatus('errored');
|
|
339
480
|
throw new Error(`[soundworks:Server] Invalid https key file`);
|
|
340
481
|
}
|
|
341
|
-
} catch(err) {
|
|
482
|
+
} catch (err) {
|
|
342
483
|
this._dispatchStatus('errored');
|
|
343
484
|
throw new Error(`[soundworks:Server] Invalid https key file`);
|
|
344
485
|
}
|
|
@@ -362,7 +503,7 @@ class Server {
|
|
|
362
503
|
};
|
|
363
504
|
|
|
364
505
|
this.httpServer = https.createServer({ key, cert }, this.router);
|
|
365
|
-
} catch(err) {
|
|
506
|
+
} catch (err) {
|
|
366
507
|
logger.error(`
|
|
367
508
|
Invalid certificate files, please check your:
|
|
368
509
|
- key file: ${httpsInfos.key}
|
|
@@ -421,9 +562,9 @@ Invalid certificate files, please check your:
|
|
|
421
562
|
}
|
|
422
563
|
|
|
423
564
|
if (!nodeOnly) {
|
|
424
|
-
if (this.
|
|
425
|
-
|| this.
|
|
426
|
-
|| this.
|
|
565
|
+
if (this._applicationTemplateOptions.templateEngine === null
|
|
566
|
+
|| this._applicationTemplateOptions.templatePath === null
|
|
567
|
+
|| this._applicationTemplateOptions.clientConfigFunction === null
|
|
427
568
|
) {
|
|
428
569
|
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()`');
|
|
429
570
|
}
|
|
@@ -464,17 +605,23 @@ Invalid certificate files, please check your:
|
|
|
464
605
|
}
|
|
465
606
|
|
|
466
607
|
/**
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
608
|
+
* The `start` method is part of the initialization lifecycle of the `soundworks`
|
|
609
|
+
* server. The `start` method will implicitly call the {@link server.Server#init}
|
|
610
|
+
* method if it has not been called manually.
|
|
470
611
|
*
|
|
471
612
|
* What it does:
|
|
472
|
-
* -
|
|
473
|
-
*
|
|
474
|
-
* - start
|
|
475
|
-
*
|
|
613
|
+
* - implicitely call {@link server.Server#init} if not done manually
|
|
614
|
+
* - launch the HTTP and WebSocket servers
|
|
615
|
+
* - start all created contexts. To this end, you will have to call `server.init`
|
|
616
|
+
* manually and instantiate the contexts between `server.init()` and `server.start()`
|
|
476
617
|
*
|
|
477
|
-
* After `await server.start()` the server is ready to accept incoming
|
|
618
|
+
* After `await server.start()` the server is ready to accept incoming connections
|
|
619
|
+
*
|
|
620
|
+
* @example
|
|
621
|
+
* import { Server } from '@soundworks/core/server.js'
|
|
622
|
+
*
|
|
623
|
+
* const server = new Server(config);
|
|
624
|
+
* await server.start();
|
|
478
625
|
*/
|
|
479
626
|
async start() {
|
|
480
627
|
if (this.status === 'idle') {
|
|
@@ -500,7 +647,7 @@ Invalid certificate files, please check your:
|
|
|
500
647
|
await this.sockets.start(
|
|
501
648
|
this,
|
|
502
649
|
this.config.env.websockets,
|
|
503
|
-
(
|
|
650
|
+
(...args) => this._onSocketConnection(...args),
|
|
504
651
|
);
|
|
505
652
|
|
|
506
653
|
// ------------------------------------------------------------
|
|
@@ -557,15 +704,31 @@ Invalid certificate files, please check your:
|
|
|
557
704
|
|
|
558
705
|
await this._dispatchStatus('started');
|
|
559
706
|
|
|
707
|
+
if (this.config.env.type === 'development') {
|
|
708
|
+
logger.log(`\n> press "${chalk.bold('Ctrl + C')}" to exit`);
|
|
709
|
+
}
|
|
710
|
+
|
|
560
711
|
resolve();
|
|
561
712
|
});
|
|
562
713
|
});
|
|
563
714
|
}
|
|
564
715
|
|
|
565
|
-
// @todo - handle gracefull close of the server (but define what it means first...)
|
|
566
716
|
/**
|
|
567
|
-
*
|
|
568
|
-
*
|
|
717
|
+
* Stops all started contexts, plugins, close all the socket connections and
|
|
718
|
+
* the http(s) server.
|
|
719
|
+
*
|
|
720
|
+
* In most situations, you might not need to call this method. However, it can
|
|
721
|
+
* be usefull for unit testing or similar situations where you want to create
|
|
722
|
+
* and delete several servers in the same process.
|
|
723
|
+
*
|
|
724
|
+
* @example
|
|
725
|
+
* import { Server } from '@soundworks/core/server.js'
|
|
726
|
+
*
|
|
727
|
+
* const server = new Server(config);
|
|
728
|
+
* await server.start();
|
|
729
|
+
*
|
|
730
|
+
* await new Promise(resolve => setTimeout(resolve, 1000));
|
|
731
|
+
* await server.stop();
|
|
569
732
|
*/
|
|
570
733
|
async stop() {
|
|
571
734
|
if (this.status !== 'started') {
|
|
@@ -603,12 +766,14 @@ Invalid certificate files, please check your:
|
|
|
603
766
|
route += `${role}`;
|
|
604
767
|
}
|
|
605
768
|
|
|
769
|
+
this.config.app.clients[role].route = route;
|
|
770
|
+
|
|
606
771
|
// define template filename: `${role}.html` or `default.html`
|
|
607
772
|
const {
|
|
608
773
|
templatePath,
|
|
609
774
|
templateEngine,
|
|
610
775
|
clientConfigFunction,
|
|
611
|
-
} = this.
|
|
776
|
+
} = this._applicationTemplateOptions;
|
|
612
777
|
|
|
613
778
|
const clientTmpl = path.join(templatePath, `${role}.tmpl`);
|
|
614
779
|
const defaultTmpl = path.join(templatePath, `default.tmpl`);
|
|
@@ -619,7 +784,7 @@ Invalid certificate files, please check your:
|
|
|
619
784
|
try {
|
|
620
785
|
const stats = fs.statSync(clientTmpl);
|
|
621
786
|
template = stats.isFile() ? clientTmpl : defaultTmpl;
|
|
622
|
-
} catch(err) {
|
|
787
|
+
} catch (err) {
|
|
623
788
|
template = defaultTmpl;
|
|
624
789
|
}
|
|
625
790
|
|
|
@@ -627,15 +792,21 @@ Invalid certificate files, please check your:
|
|
|
627
792
|
|
|
628
793
|
try {
|
|
629
794
|
tmplString = fs.readFileSync(template, 'utf8');
|
|
630
|
-
} catch(err) {
|
|
795
|
+
} catch (err) {
|
|
631
796
|
throw new Error(`[soundworks:Server] html template file "${template}" not found`);
|
|
632
797
|
}
|
|
633
798
|
|
|
634
799
|
const tmpl = templateEngine.compile(tmplString);
|
|
635
|
-
|
|
636
|
-
|
|
800
|
+
|
|
801
|
+
const soundworksClientHandler = (req, res) => {
|
|
637
802
|
const data = clientConfigFunction(role, this.config, req);
|
|
638
803
|
|
|
804
|
+
// if the client has gone through the connection middleware (add succedeed),
|
|
805
|
+
// add the token to the data object
|
|
806
|
+
if (res.swToken) {
|
|
807
|
+
data.token = res.swToken;
|
|
808
|
+
}
|
|
809
|
+
|
|
639
810
|
// CORS / COOP / COEP headers for `crossOriginIsolated pages,
|
|
640
811
|
// enables `sharedArrayBuffers` and high precision timers
|
|
641
812
|
// cf. https://web.dev/why-coop-coep/
|
|
@@ -649,34 +820,78 @@ Invalid certificate files, please check your:
|
|
|
649
820
|
|
|
650
821
|
const appIndex = tmpl(data);
|
|
651
822
|
res.end(appIndex);
|
|
652
|
-
}
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
// http request
|
|
826
|
+
router.get(route, soundworksClientHandler);
|
|
653
827
|
|
|
654
828
|
// return route infos for logging on server start
|
|
655
829
|
return route;
|
|
656
830
|
}
|
|
657
831
|
|
|
832
|
+
onClientConnect(callback) {
|
|
833
|
+
this._onClientConnectCallbacks.add(callback);
|
|
834
|
+
|
|
835
|
+
return () => this._onClientConnectCallbacks.delete(callback);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
onClientDisconnect(callback) {
|
|
839
|
+
this._onClientDisconnectCallbacks.add(callback);
|
|
840
|
+
|
|
841
|
+
return () => this._onClientDisconnectCallbacks.delete(callback);
|
|
842
|
+
}
|
|
843
|
+
|
|
658
844
|
/**
|
|
659
845
|
* Socket connection callback.
|
|
660
846
|
* @private
|
|
661
847
|
*/
|
|
662
|
-
_onSocketConnection(role, socket) {
|
|
848
|
+
_onSocketConnection(role, socket, connectionToken) {
|
|
663
849
|
const client = new Client(role, socket);
|
|
664
850
|
const roles = Object.keys(this.config.app.clients);
|
|
665
851
|
|
|
852
|
+
// this has been validated
|
|
853
|
+
if (this.isProtected(role) && this.isValidConnectionToken(connectionToken)) {
|
|
854
|
+
const { ip } = decryptData(connectionToken);
|
|
855
|
+
const newData = {
|
|
856
|
+
ip: ip,
|
|
857
|
+
id: client.id,
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
const newToken = encryptData(newData);
|
|
861
|
+
|
|
862
|
+
client.token = newToken;
|
|
863
|
+
|
|
864
|
+
this._pendingConnectionTokens.delete(connectionToken);
|
|
865
|
+
this._trustedClients.add(client);
|
|
866
|
+
}
|
|
867
|
+
|
|
666
868
|
socket.addListener('close', async () => {
|
|
667
|
-
// do
|
|
869
|
+
// do nothing if client role is invalid
|
|
668
870
|
if (roles.includes(role)) {
|
|
669
871
|
// decrement audit state counter
|
|
670
872
|
const numClients = this._auditState.get('numClients');
|
|
671
873
|
numClients[role] -= 1;
|
|
672
874
|
this._auditState.set({ numClients });
|
|
673
875
|
|
|
674
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
876
|
+
// delete token
|
|
877
|
+
if (this._trustedClients.has(client)) {
|
|
878
|
+
this._trustedClients.delete(client);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// if something goes wrong here, the 'close' event is called again and
|
|
882
|
+
// again and again... let's just log the error and terminate the socket
|
|
883
|
+
try {
|
|
884
|
+
// clean context manager, await before cleaning state manager
|
|
885
|
+
await this.contextManager.removeClient(client);
|
|
886
|
+
// remove client from pluginManager
|
|
887
|
+
await this.pluginManager.removeClient(client);
|
|
888
|
+
// clean state manager
|
|
889
|
+
await this.stateManager.removeClient(client.id);
|
|
890
|
+
|
|
891
|
+
this._onClientDisconnectCallbacks.forEach(callback => callback(client));
|
|
892
|
+
} catch (err) {
|
|
893
|
+
console.error(err);
|
|
894
|
+
}
|
|
680
895
|
}
|
|
681
896
|
|
|
682
897
|
// clean sockets
|
|
@@ -698,7 +913,7 @@ Invalid certificate files, please check your:
|
|
|
698
913
|
|
|
699
914
|
try {
|
|
700
915
|
this.pluginManager.checkRegisteredPlugins(registeredPlugins);
|
|
701
|
-
} catch(err) {
|
|
916
|
+
} catch (err) {
|
|
702
917
|
socket.send(CLIENT_HANDSHAKE_ERROR, {
|
|
703
918
|
type: 'invalid-plugin-list',
|
|
704
919
|
message: err.message,
|
|
@@ -723,8 +938,10 @@ Invalid certificate files, please check your:
|
|
|
723
938
|
// add client to context manager
|
|
724
939
|
await this.contextManager.addClient(client);
|
|
725
940
|
|
|
726
|
-
|
|
727
|
-
|
|
941
|
+
this._onClientConnectCallbacks.forEach(callback => callback(client));
|
|
942
|
+
|
|
943
|
+
const { id, uuid, token } = client;
|
|
944
|
+
socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid, token });
|
|
728
945
|
});
|
|
729
946
|
}
|
|
730
947
|
|
|
@@ -783,8 +1000,8 @@ Invalid certificate files, please check your:
|
|
|
783
1000
|
}
|
|
784
1001
|
|
|
785
1002
|
/**
|
|
786
|
-
* Configure the server to work
|
|
787
|
-
*
|
|
1003
|
+
* Configure the server to work _out-of-the-box_ within the soundworks application
|
|
1004
|
+
* template provided by `@soundworks/create.
|
|
788
1005
|
*
|
|
789
1006
|
* - uses [template-literal](https://www.npmjs.com/package/template-literal) package
|
|
790
1007
|
* as html templateEngine
|
|
@@ -796,9 +1013,33 @@ Invalid certificate files, please check your:
|
|
|
796
1013
|
* Also expose two public directory:
|
|
797
1014
|
* - the `public` directory which is exposed behind the root path
|
|
798
1015
|
* - the `./.build/public` directory which is exposed behind the `build` path
|
|
1016
|
+
*
|
|
1017
|
+
* _Note: except in very rare cases (so rare that they are quite difficult to imagine),
|
|
1018
|
+
* you should rely on these defaults._
|
|
799
1019
|
*/
|
|
800
|
-
|
|
801
|
-
|
|
1020
|
+
useDefaultApplicationTemplate() {
|
|
1021
|
+
const buildDirectory = path.join('.build', 'public');
|
|
1022
|
+
|
|
1023
|
+
const useMinifiedFile = {};
|
|
1024
|
+
const roles = Object.keys(this.config.app.clients);
|
|
1025
|
+
|
|
1026
|
+
roles.forEach(role => {
|
|
1027
|
+
if (this.config.env.type === 'production') {
|
|
1028
|
+
// check if minified file exists
|
|
1029
|
+
const minifiedFilePath = path.join(buildDirectory, `${role}.min.js`);
|
|
1030
|
+
|
|
1031
|
+
if (fs.existsSync(minifiedFilePath)) {
|
|
1032
|
+
useMinifiedFile[role] = true;
|
|
1033
|
+
} else {
|
|
1034
|
+
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)`));
|
|
1035
|
+
useMinifiedFile[role] = false;
|
|
1036
|
+
}
|
|
1037
|
+
} else {
|
|
1038
|
+
useMinifiedFile[role] = false;
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
this._applicationTemplateOptions = {
|
|
802
1043
|
templateEngine: { compile },
|
|
803
1044
|
templatePath: path.join('.build', 'server', 'tmpl'),
|
|
804
1045
|
clientConfigFunction: (role, config, _httpRequest) => {
|
|
@@ -812,30 +1053,41 @@ Invalid certificate files, please check your:
|
|
|
812
1053
|
type: config.env.type,
|
|
813
1054
|
websockets: config.env.websockets,
|
|
814
1055
|
subpath: config.env.subpath,
|
|
1056
|
+
useMinifiedFile: useMinifiedFile[role],
|
|
815
1057
|
},
|
|
816
1058
|
};
|
|
817
1059
|
},
|
|
818
1060
|
};
|
|
819
1061
|
|
|
820
1062
|
this.router.use(express.static('public'));
|
|
821
|
-
this.router.use('/build', express.static(
|
|
1063
|
+
this.router.use('/build', express.static(buildDirectory));
|
|
822
1064
|
}
|
|
823
1065
|
|
|
824
1066
|
/**
|
|
825
|
-
* Define
|
|
826
|
-
* This method is for very advanced use-cases and
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
* If you end up using this, please contact me to explain your use-case :)
|
|
1067
|
+
* Define custom template path, template engine, and clientConfig function.
|
|
1068
|
+
* This method is proposed for very advanced use-cases and should very probably
|
|
1069
|
+
* be improved. If you consider using this for some reason, please get in touch
|
|
1070
|
+
* first to explain your use-case :)
|
|
830
1071
|
*/
|
|
831
|
-
|
|
832
|
-
Object.assign(this.
|
|
1072
|
+
setCustomApplicationTemplateOptions(options) {
|
|
1073
|
+
Object.assign(this._applicationTemplateOptions, options);
|
|
833
1074
|
}
|
|
834
1075
|
|
|
835
1076
|
/**
|
|
836
|
-
*
|
|
1077
|
+
* Attach and retrieve the global audit state of the application.
|
|
1078
|
+
*
|
|
1079
|
+
* The audit state is a {@link server.SharedState} instance that keeps track of
|
|
1080
|
+
* global informations about the application such as, the number of connected
|
|
1081
|
+
* clients, network latency estimation, etc.
|
|
1082
|
+
*
|
|
1083
|
+
* The audit state is created by the server on start up.
|
|
837
1084
|
*
|
|
1085
|
+
* @returns {Promise<server.SharedState>}
|
|
838
1086
|
* @throws Will throw if called before `server.init()`
|
|
1087
|
+
* @see {@link server.SharedState}
|
|
1088
|
+
* @example
|
|
1089
|
+
* const auditState = await server.getAuditState();
|
|
1090
|
+
* auditState.onUpdate(() => console.log(auditState.getValues()), true);
|
|
839
1091
|
*/
|
|
840
1092
|
async getAuditState() {
|
|
841
1093
|
if (this.status === 'idle') {
|
|
@@ -844,6 +1096,81 @@ Invalid certificate files, please check your:
|
|
|
844
1096
|
|
|
845
1097
|
return this._auditState;
|
|
846
1098
|
}
|
|
1099
|
+
|
|
1100
|
+
/** @private */
|
|
1101
|
+
isProtected(role) {
|
|
1102
|
+
if (this.config.env.auth && Array.isArray(this.config.env.auth.clients)) {
|
|
1103
|
+
return this.config.env.auth.clients.includes(role);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
return false;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** @private */
|
|
1110
|
+
isValidConnectionToken(token) {
|
|
1111
|
+
// token should be in pending token list
|
|
1112
|
+
if (!this._pendingConnectionTokens.has(token)) {
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// check the token is not too old
|
|
1117
|
+
const data = decryptData(token);
|
|
1118
|
+
const now = getTime();
|
|
1119
|
+
|
|
1120
|
+
// token is valid only for 30 seconds (this is arbitrary)
|
|
1121
|
+
if (now > data.time + TOKEN_VALID_DURATION) {
|
|
1122
|
+
// delete the token, is too old
|
|
1123
|
+
this._pendingConnectionTokens.delete(token);
|
|
1124
|
+
return false;
|
|
1125
|
+
} else {
|
|
1126
|
+
return true;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Check if the given client is trusted, i.e. config.env.type == 'production'
|
|
1132
|
+
* and the client is protected behind a password.
|
|
1133
|
+
*
|
|
1134
|
+
* @param {server.Client} client - Client to be tested
|
|
1135
|
+
* @returns {Boolean}
|
|
1136
|
+
*/
|
|
1137
|
+
isTrustedClient(client) {
|
|
1138
|
+
if (this.config.env.type !== 'production') {
|
|
1139
|
+
return true;
|
|
1140
|
+
} else {
|
|
1141
|
+
return this._trustedClients.has(client);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Check if the token from a client is trusted, i.e. config.env.type == 'production'
|
|
1147
|
+
* and the client is protected behind a password.
|
|
1148
|
+
*
|
|
1149
|
+
* @param {Number} clientId - Id of the client
|
|
1150
|
+
* @param {Number} clientIp - Ip of the client
|
|
1151
|
+
* @param {String} token - Token to be tested
|
|
1152
|
+
* @returns {Boolean}
|
|
1153
|
+
*/
|
|
1154
|
+
// for stateless interactions, e.g. POST files
|
|
1155
|
+
isTrustedToken(clientId, clientIp, token) {
|
|
1156
|
+
if (this.config.env.type !== 'production') {
|
|
1157
|
+
return true;
|
|
1158
|
+
} else {
|
|
1159
|
+
for (let client of this._trustedClients) {
|
|
1160
|
+
if (client.id === clientId && client.token === token) {
|
|
1161
|
+
// check that given token is consistent with client ip and id
|
|
1162
|
+
const { id, ip } = decryptData(client.token);
|
|
1163
|
+
|
|
1164
|
+
if (clientId === id && clientIp === ip) {
|
|
1165
|
+
return true;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
return false;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
847
1174
|
}
|
|
848
1175
|
|
|
849
1176
|
export default Server;
|