@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.
@@ -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 side entry point for a `soundworks` application.
104
+ * The `Server` class is the main entry point for the server-side of a soundworks
105
+ * application.
78
106
  *
79
- * This object hosts configuration informations, as well as methods to
80
- * initialize and start the application. It is also responsible for creating
81
- * the static file (http) server as well as the socket server.
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
- * @param {server.ServerConfig} config
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: { myClient: { target: 'node' } },
118
+ * clients: {
119
+ * player: { target: 'browser', default: true },
120
+ * controller: { target: 'browser' },
121
+ * thing: { target: 'node' }
122
+ * },
92
123
  * },
93
124
  * env: {
94
- * port: 8888,
125
+ * port: 8000,
95
126
  * },
96
127
  * });
97
- * await server.init();
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
- * Configuration informations.
107
- * Defaults to:
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
- * subfolder: '',
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
- * Router. Internally use polka.
165
- * (cf. {@link https://github.com/lukeed/polka})
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 serve-static)
247
+ // compression (must be set before express.static())
169
248
  this.router.use(compression());
170
249
 
171
250
  /**
172
- * Http(s) server instance. The node `http` or `https` module instance
173
- * (cf. {@link https://nodejs.org/api/http.html})
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
- * Key / value storage with Promise based Map API
179
- * basically a wrapper around kvey (cf. {@link https://github.com/lukechilds/keyv})
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
- * The {@link server.PluginManager} instance.
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
- * The {@link server.StateManager} instance.
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
- * The {@link server.ContextManager} instance.
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, will contain informations about the certificates
214
- * (self-signed, validity dates, etc.)
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
- * Current status of the server ['idle', 'inited', 'started']
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._applicationTemplateConfig = {
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
- // create audit state
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
- * Method to be called before `start` in the initialization lifecycle of the
244
- * soundworks server. Note that if `init()`` is not explicitely called, `start()`
245
- * will call it implicitely.
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
- * - starts all registered plugins
360
+ * - initialize all registered plugins
252
361
  *
253
- * After `await server.init()`, you can safely use the StateManager, as well
254
- * as any registered Plugins.
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
- this.router.use((req, res, next) => {
384
+ const ids = idGenerator();
276
385
 
277
- const isProtected = this.config.env.auth.clients
278
- .map(type => req.path.endsWith(`/${type}`))
279
- .reduce((acc, value) => acc || value, false);
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
- // -> access denied...
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._applicationTemplateConfig.templateEngine === null
425
- || this._applicationTemplateConfig.templatePath === null
426
- || this._applicationTemplateConfig.clientConfigFunction === null
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
- * Method to be called when `init` step is done in the initialization
468
- * lifecycle of the soundworks server. If `server.init()` has not been called
469
- * explicitely, `server.start()` will call it automatically.
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
- * - starts all registered contexts (context are automatically registered
473
- * when instantiated)
474
- * - start the web socket server
475
- * - launch the HTTP server on given port
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 connexions
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
- (role, socket) => this._onSocketConnection(role, socket),
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
- * Stop the server, close all existing WebSocket connections.
568
- * Mainly usefull for test.
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._applicationTemplateConfig;
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
- // http request
636
- router.get(route, (req, res) => {
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 nothin if client type was invalid
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
- // clean context manager, await before cleaning state manager
675
- await this.contextManager.removeClient(client);
676
- // remove client from pluginManager
677
- await this.pluginManager.removeClient(client);
678
- // clean state manager
679
- await this.stateManager.removeClient(client.id);
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
- const { id, uuid } = client;
727
- socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid });
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 out-of-the box with the soundworks-template
787
- * directory tree structure.
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
- setDefaultTemplateConfig() {
801
- this._applicationTemplateConfig = {
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(path.join('.build', 'public')));
1063
+ this.router.use('/build', express.static(buildDirectory));
822
1064
  }
823
1065
 
824
1066
  /**
825
- * Define your own template path, template engine, and clientConfig function.
826
- * This method is for very advanced use-cases and only be used if you know what
827
- * you are doing. As such its behavior could probably be improved a lot...
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
- setCustomTemplateConfig(options) {
832
- Object.assign(this._applicationTemplateConfig, options);
1072
+ setCustomApplicationTemplateOptions(options) {
1073
+ Object.assign(this._applicationTemplateOptions, options);
833
1074
  }
834
1075
 
835
1076
  /**
836
- * Get the global audit state of the application.
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;