@soundworks/core 3.6.0 → 4.0.0-alpha.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 (125) hide show
  1. package/.eslintrc +16 -0
  2. package/LICENSE +1 -1
  3. package/README.md +32 -30
  4. package/package.json +57 -26
  5. package/src/client/Client.js +391 -0
  6. package/src/client/Context.js +225 -0
  7. package/src/client/ContextManager.js +111 -0
  8. package/src/client/Plugin.js +66 -0
  9. package/src/client/PluginManager.js +123 -0
  10. package/src/client/SharedState.js +84 -0
  11. package/src/client/Socket.js +389 -0
  12. package/src/client/StateManager.js +67 -0
  13. package/src/client/index.js +77 -0
  14. package/src/common/BasePlugin.js +163 -0
  15. package/src/common/BasePluginManager.js +227 -0
  16. package/src/common/BaseSharedState.js +473 -0
  17. package/src/common/BaseStateManager.js +200 -0
  18. package/{common → src/common}/ParameterBag.js +82 -122
  19. package/{common → src/common}/SharedStatePrivate.js +56 -64
  20. package/src/common/constants.js +51 -0
  21. package/src/common/logger.js +154 -0
  22. package/src/common/promise-store.js +25 -0
  23. package/src/common/sockets-utils.js +73 -0
  24. package/src/common/utils.js +22 -0
  25. package/src/server/Client.js +50 -0
  26. package/src/server/Context.js +99 -0
  27. package/src/server/ContextManager.js +266 -0
  28. package/src/server/Plugin.js +77 -0
  29. package/src/server/PluginManager.js +150 -0
  30. package/src/server/Server.js +849 -0
  31. package/src/server/SharedState.js +86 -0
  32. package/{server → src/server}/Socket.js +119 -94
  33. package/{server → src/server}/Sockets.js +73 -61
  34. package/{common/SharedStateManagerServer.js → src/server/StateManager.js} +183 -131
  35. package/src/server/audit-network-latency.worker.js +57 -0
  36. package/src/server/audit-schema.js +42 -0
  37. package/src/server/index.js +45 -0
  38. package/tsconfig.json +21 -0
  39. package/types/client/Client.d.ts +194 -0
  40. package/types/client/Client.d.ts.map +1 -0
  41. package/types/client/Context.d.ts +139 -0
  42. package/types/client/Context.d.ts.map +1 -0
  43. package/types/client/ContextManager.d.ts +45 -0
  44. package/types/client/ContextManager.d.ts.map +1 -0
  45. package/types/client/Plugin.d.ts +53 -0
  46. package/types/client/Plugin.d.ts.map +1 -0
  47. package/types/client/PluginManager.d.ts +96 -0
  48. package/types/client/PluginManager.d.ts.map +1 -0
  49. package/types/client/SharedState.d.ts +85 -0
  50. package/types/client/SharedState.d.ts.map +1 -0
  51. package/types/client/Socket.d.ts +138 -0
  52. package/types/client/Socket.d.ts.map +1 -0
  53. package/types/client/StateManager.d.ts +72 -0
  54. package/types/client/StateManager.d.ts.map +1 -0
  55. package/types/client/index.d.ts +11 -0
  56. package/types/client/index.d.ts.map +1 -0
  57. package/types/common/BasePlugin.d.ts +145 -0
  58. package/types/common/BasePlugin.d.ts.map +1 -0
  59. package/types/common/BasePluginManager.d.ts +88 -0
  60. package/types/common/BasePluginManager.d.ts.map +1 -0
  61. package/types/common/BaseSharedState.d.ts +173 -0
  62. package/types/common/BaseSharedState.d.ts.map +1 -0
  63. package/types/common/BaseStateManager.d.ts +70 -0
  64. package/types/common/BaseStateManager.d.ts.map +1 -0
  65. package/types/common/ParameterBag.d.ts +143 -0
  66. package/types/common/ParameterBag.d.ts.map +1 -0
  67. package/types/common/SharedStatePrivate.d.ts +20 -0
  68. package/types/common/SharedStatePrivate.d.ts.map +1 -0
  69. package/types/common/constants.d.ts +35 -0
  70. package/types/common/constants.d.ts.map +1 -0
  71. package/types/common/logger.d.ts +16 -0
  72. package/types/common/logger.d.ts.map +1 -0
  73. package/types/common/promise-store.d.ts +4 -0
  74. package/types/common/promise-store.d.ts.map +1 -0
  75. package/types/common/sockets-utils.d.ts +9 -0
  76. package/types/common/sockets-utils.d.ts.map +1 -0
  77. package/types/common/utils.d.ts +5 -0
  78. package/types/common/utils.d.ts.map +1 -0
  79. package/types/server/Client.d.ts +40 -0
  80. package/types/server/Client.d.ts.map +1 -0
  81. package/types/server/Context.d.ts +59 -0
  82. package/types/server/Context.d.ts.map +1 -0
  83. package/types/server/ContextManager.d.ts +69 -0
  84. package/types/server/ContextManager.d.ts.map +1 -0
  85. package/types/server/Plugin.d.ts +70 -0
  86. package/types/server/Plugin.d.ts.map +1 -0
  87. package/types/server/PluginManager.d.ts +91 -0
  88. package/types/server/PluginManager.d.ts.map +1 -0
  89. package/types/server/Server.d.ts +223 -0
  90. package/types/server/Server.d.ts.map +1 -0
  91. package/types/server/SharedState.d.ts +86 -0
  92. package/types/server/SharedState.d.ts.map +1 -0
  93. package/types/server/Socket.d.ts +128 -0
  94. package/types/server/Socket.d.ts.map +1 -0
  95. package/types/server/Sockets.d.ts +73 -0
  96. package/types/server/Sockets.d.ts.map +1 -0
  97. package/types/server/StateManager.d.ts +190 -0
  98. package/types/server/StateManager.d.ts.map +1 -0
  99. package/types/server/audit-network-latency.worker.d.ts +2 -0
  100. package/types/server/audit-network-latency.worker.d.ts.map +1 -0
  101. package/types/server/audit-schema.d.ts +27 -0
  102. package/types/server/audit-schema.d.ts.map +1 -0
  103. package/types/server/index.d.ts +7 -0
  104. package/types/server/index.d.ts.map +1 -0
  105. package/client/AbstractExperience.js +0 -54
  106. package/client/AbstractPlugin.js +0 -230
  107. package/client/Client.js +0 -190
  108. package/client/PluginManager.js +0 -267
  109. package/client/Socket.js +0 -338
  110. package/client/index.js +0 -48
  111. package/client.js +0 -28
  112. package/common/SharedState.js +0 -480
  113. package/common/SharedStateManagerClient.js +0 -194
  114. package/common/Signal.js +0 -40
  115. package/common/SignalAll.js +0 -44
  116. package/common/shared-state-utils.js +0 -90
  117. package/common/sockets-encoder-decoder.js +0 -73
  118. package/server/AbstractExperience.js +0 -126
  119. package/server/AbstractPlugin.js +0 -211
  120. package/server/Client.js +0 -70
  121. package/server/PluginManager.js +0 -177
  122. package/server/Server.js +0 -746
  123. package/server/index.js +0 -49
  124. package/server/utils/logger.js +0 -134
  125. package/server.js +0 -28
@@ -0,0 +1,849 @@
1
+ import fs from 'node:fs';
2
+ import http from 'node:http';
3
+ import https from 'node:https';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { X509Certificate, createPrivateKey } from 'node:crypto';
7
+
8
+ import chalk from 'chalk';
9
+ import compression from 'compression';
10
+ import express from 'express';
11
+ import isPlainObject from 'is-plain-obj';
12
+ import Keyv from 'keyv';
13
+ import { KeyvFile } from 'keyv-file';
14
+ import merge from 'lodash.merge';
15
+ import pem from 'pem';
16
+ import compile from 'template-literal';
17
+
18
+ import auditSchema from './audit-schema.js';
19
+ import Client from './Client.js';
20
+ import ContextManager from './ContextManager.js';
21
+ import PluginManager from './PluginManager.js';
22
+ import StateManager from './StateManager.js';
23
+ import Sockets from './Sockets.js';
24
+ import logger from '../common/logger.js';
25
+ import {
26
+ CLIENT_HANDSHAKE_REQUEST,
27
+ CLIENT_HANDSHAKE_RESPONSE,
28
+ CLIENT_HANDSHAKE_ERROR,
29
+ AUDIT_STATE_NAME,
30
+ } from '../common/constants.js';
31
+
32
+ let _dbNamespaces = new Set();
33
+
34
+ const DEFAULT_CONFIG = {
35
+ env: {
36
+ type: 'development',
37
+ port: 8000,
38
+ serverAddress: null,
39
+ subpath: '',
40
+ websockets: {
41
+ path: 'socket',
42
+ pingInterval: 5000,
43
+ },
44
+ useHttps: false,
45
+ httpsInfos: null,
46
+ crossOriginIsolated: true,
47
+ verbose: true,
48
+ },
49
+ app: {
50
+ name: 'soundworks',
51
+ clients: {},
52
+ },
53
+ };
54
+
55
+ // set terminal title
56
+ /** @private */
57
+ function setTerminalTitle(server) {
58
+ let title = '';
59
+
60
+ if (server._auditState !== null) {
61
+ const numClients = server._auditState.get('numClients');
62
+ let numClientStrings = [];
63
+ for (let name in numClients) {
64
+ numClientStrings.push(`${name}: ${numClients[name]}`);
65
+ }
66
+
67
+ title = `${server.config.app.name} | ${numClientStrings.join(' - ')}`;
68
+ } else {
69
+ title = `${server.config.app.name}`;
70
+ }
71
+
72
+ const msg = String.fromCharCode(27) + ']0;' + title + String.fromCharCode(7);
73
+ process.stdout.write(msg);
74
+ }
75
+
76
+ /**
77
+ * Server side entry point for a `soundworks` application.
78
+ *
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
84
+ *
85
+ * @param {server.ServerConfig} config
86
+ * @example
87
+ * import { Server } from '@soundworks/core/server';
88
+ * const server = new Server({
89
+ * app: {
90
+ * name: 'my-example-app',
91
+ * clients: { myClient: { target: 'node' } },
92
+ * },
93
+ * env: {
94
+ * port: 8888,
95
+ * },
96
+ * });
97
+ * await server.init();
98
+ * await server.start();
99
+ */
100
+ class Server {
101
+ constructor(config) {
102
+ if (!isPlainObject(config)) {
103
+ throw new Error(`[soundworks:Server] Invalid argument for Server constructor, config should be an object`);
104
+ }
105
+ /**
106
+ * Configuration informations.
107
+ * Defaults to:
108
+ * ```
109
+ * {
110
+ * env: {
111
+ * type: 'development',
112
+ * port: 8000,
113
+ * subfolder: '',
114
+ * useHttps: false,
115
+ * httpsInfos: null,
116
+ * },
117
+ * app: {
118
+ * name: 'soundworks',
119
+ * clients: {},
120
+ * },
121
+ * }
122
+ * ```
123
+ */
124
+ this.config = merge({}, DEFAULT_CONFIG, config);
125
+
126
+ // parse config
127
+ if (Object.keys(this.config.app.clients).length === 0) {
128
+ throw new Error(`[soundworks:Server] Invalid "app.clients" config, at least one client should be declared`);
129
+ }
130
+
131
+ // if a node client is defined, serverAddress should be defined
132
+ let hasNodeClient = false;
133
+ for (let name in this.config.app.clients) {
134
+ if (this.config.app.clients[name].target === 'node') {
135
+ hasNodeClient = true;
136
+ }
137
+ }
138
+
139
+ if (hasNodeClient && this.config.env.serverAddress === null) {
140
+ throw new Error(`[soundworks:Server] Invalid "env.serverAddress" config, is mandatory when a node client target is defined`);
141
+ }
142
+
143
+ if (this.config.env.useHttps && this.config.env.httpsInfos !== null) {
144
+ const httpsInfos = this.config.env.httpsInfos;
145
+
146
+ if (!isPlainObject(this.config.env.httpsInfos)) {
147
+ throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, should be null or object { cert, key }`);
148
+ }
149
+
150
+ if (!('cert' in httpsInfos) || !('key' in httpsInfos)) {
151
+ throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, should contain both "cert" and "key" entries`);
152
+ }
153
+ // @todo - move that to constructor
154
+ if (!fs.existsSync(httpsInfos.cert)) {
155
+ throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, "cert" file not found`);
156
+ }
157
+
158
+ if (!fs.existsSync(httpsInfos.key)) {
159
+ throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, "key" file not found`);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Router. Internally use polka.
165
+ * (cf. {@link https://github.com/lukeed/polka})
166
+ */
167
+ this.router = express();
168
+ // compression (must be set before serve-static)
169
+ this.router.use(compression());
170
+
171
+ /**
172
+ * Http(s) server instance. The node `http` or `https` module instance
173
+ * (cf. {@link https://nodejs.org/api/http.html})
174
+ */
175
+ this.httpServer = null;
176
+
177
+ /**
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.
187
+ * @see {@link server.Sockets}
188
+ * @type {server.Sockets}
189
+ */
190
+ this.sockets = new Sockets();
191
+
192
+ /**
193
+ * The {@link server.PluginManager} instance.
194
+ * @see {@link server.PluginManager}
195
+ * @type {server.PluginManager}
196
+ */
197
+ this.pluginManager = new PluginManager(this);
198
+
199
+ /**
200
+ * The {@link server.StateManager} instance.
201
+ * @see {@link server.StateManager}
202
+ * @type {server.StateManager}
203
+ */
204
+ this.stateManager = new StateManager();
205
+
206
+ /**
207
+ * The {@link server.ContextManager} instance.
208
+ *
209
+ */
210
+ this.contextManager = new ContextManager(this);
211
+
212
+ /**
213
+ * If https is required, will contain informations about the certificates
214
+ * (self-signed, validity dates, etc.)
215
+ */
216
+ this.httpsInfos = null;
217
+
218
+ /**
219
+ * Current status of the server ['idle', 'inited', 'started']
220
+ */
221
+ this.status = 'idle';
222
+
223
+ /** @private */
224
+ this._applicationTemplateConfig = {
225
+ templateEngine: null,
226
+ templatePath: null,
227
+ clientConfigFunction: null,
228
+ };
229
+
230
+ /** @private */
231
+ this._onStatusChangeCallbacks = new Set();
232
+ /** @private */
233
+ this._auditState = null;
234
+
235
+ // create audit state
236
+ this.stateManager.registerSchema(AUDIT_STATE_NAME, auditSchema);
237
+
238
+ logger.configure(this.config.env.verbose);
239
+ setTerminalTitle(this);
240
+ }
241
+
242
+ /**
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.
246
+ *
247
+ * What it does:
248
+ * - create the audit state
249
+ * - prepapre http(s) server and routing according to the informations
250
+ * declared in `config.app.clients`
251
+ * - starts all registered plugins
252
+ *
253
+ * After `await server.init()`, you can safely use the StateManager, as well
254
+ * as any registered Plugins.
255
+ *
256
+ * @example
257
+ * const server = new Server(config);
258
+ * await server.init();
259
+ * await server.start();
260
+ * // or implicitly called by start
261
+ * const server = new Server(config);
262
+ * await server.start(); // init is called implicitely
263
+ */
264
+ async init() {
265
+ const numClients = {};
266
+ for (let name in this.config.app.clients) {
267
+ numClients[name] = 0;
268
+ }
269
+ /** @private */
270
+ this._auditState = await this.stateManager.create(AUDIT_STATE_NAME, { numClients });
271
+ this._auditState.onUpdate(() => setTerminalTitle(this));
272
+
273
+ // basic http authentication
274
+ if (this.config.env.auth) {
275
+ this.router.use((req, res, next) => {
276
+
277
+ const isProtected = this.config.env.auth.clients
278
+ .map(type => req.path.endsWith(`/${type}`))
279
+ .reduce((acc, value) => acc || value, false);
280
+
281
+ if (isProtected) {
282
+ // authentication middleware
283
+ const auth = this.config.env.auth;
284
+ // parse login and password from headers
285
+ const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
286
+ const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':');
287
+
288
+ // verify login and password are set and correct
289
+ if (login && password && login === auth.login && password === auth.password) {
290
+ // -> access granted...
291
+ return next();
292
+ }
293
+
294
+ // -> access denied...
295
+ res.writeHead(401, {
296
+ 'WWW-Authenticate':'Basic',
297
+ 'Content-Type':'text/plain',
298
+ });
299
+ res.end('Authentication required.');
300
+ } else {
301
+ // route not protected
302
+ return next();
303
+ }
304
+ });
305
+ }
306
+
307
+ // start http server
308
+ const useHttps = this.config.env.useHttps || false;
309
+
310
+ // ------------------------------------------------------------
311
+ // create HTTP(S) SERVER
312
+ // ------------------------------------------------------------
313
+ if (!useHttps) {
314
+ this.httpServer = http.createServer(this.router);
315
+ } else {
316
+ const httpsInfos = this.config.env.httpsInfos;
317
+
318
+ // if certs have been given in config
319
+ if (httpsInfos !== null) {
320
+ try {
321
+ // existance of file is checked in contructor
322
+ let cert = fs.readFileSync(httpsInfos.cert);
323
+ let key = fs.readFileSync(httpsInfos.key);
324
+
325
+ let x509 = null;
326
+ // this fails with self-signed certificates for whatever reason...
327
+ try {
328
+ x509 = new X509Certificate(cert);
329
+ } catch(err) {
330
+ this._dispatchStatus('errored');
331
+ throw new Error(`[soundworks:Server] Invalid https cert file`);
332
+ }
333
+
334
+ try {
335
+ const keyObj = createPrivateKey(key);
336
+
337
+ if (!x509.checkPrivateKey(keyObj)) {
338
+ this._dispatchStatus('errored');
339
+ throw new Error(`[soundworks:Server] Invalid https key file`);
340
+ }
341
+ } catch(err) {
342
+ this._dispatchStatus('errored');
343
+ throw new Error(`[soundworks:Server] Invalid https key file`);
344
+ }
345
+
346
+ // check is certificate is still valid
347
+ const now = Date.now();
348
+ const certExpire = Date.parse(x509.validTo);
349
+ const isValid = now < certExpire;
350
+
351
+ const diff = certExpire - now;
352
+ const daysRemaining = Math.round(diff / 1000 / 60 / 60 / 24);
353
+
354
+ this.httpsInfos = {
355
+ selfSigned: false,
356
+ CN: x509.subject.split('=')[1],
357
+ altNames: x509.subjectAltName.split(',').map(e => e.trim().split(':')[1]),
358
+ validFrom: x509.validFrom,
359
+ validTo: x509.validTo,
360
+ isValid: isValid,
361
+ daysRemaining: daysRemaining,
362
+ };
363
+
364
+ this.httpServer = https.createServer({ key, cert }, this.router);
365
+ } catch(err) {
366
+ logger.error(`
367
+ Invalid certificate files, please check your:
368
+ - key file: ${httpsInfos.key}
369
+ - cert file: ${httpsInfos.cert}
370
+ `);
371
+
372
+ this._dispatchStatus('errored');
373
+ throw err;
374
+ }
375
+ } else {
376
+ // generate certs
377
+ // --------------------------------------------------------
378
+ const cert = await this.db.get('httpsCert');
379
+ const key = await this.db.get('httpsKey');
380
+
381
+ if (key && cert) {
382
+ this.httpsInfos = { selfSigned: true };
383
+ this.httpServer = https.createServer({ cert, key }, this.router);
384
+ } else {
385
+ this.httpServer = await new Promise((resolve, reject) => {
386
+ // generate certificate on the fly (for development purposes)
387
+ pem.createCertificate({ days: 1, selfSigned: true }, async (err, keys) => {
388
+ if (err) {
389
+ logger.error(err.stack);
390
+ this._dispatchStatus('errored');
391
+
392
+ reject(err);
393
+ return;
394
+ }
395
+
396
+ const cert = keys.certificate;
397
+ const key = keys.serviceKey;
398
+
399
+ this.httpsInfos = { selfSigned: true };
400
+ // we store the generated cert so that we don't have to re-accept
401
+ // the cert each time the server restarts in development
402
+ await this.db.set('httpsCert', cert);
403
+ await this.db.set('httpsKey', key);
404
+
405
+ const httpsServer = https.createServer({ cert, key }, this.router);
406
+
407
+ resolve(httpsServer);
408
+ });
409
+ });
410
+ }
411
+ }
412
+ }
413
+
414
+ let nodeOnly = true;
415
+ // do not throw if no browser clients are defined, very usefull for
416
+ // cleaning tests in particular
417
+ for (let role in this.config.app.clients) {
418
+ if (this.config.app.clients[role].target === 'browser') {
419
+ nodeOnly = false;
420
+ }
421
+ }
422
+
423
+ if (!nodeOnly) {
424
+ if (this._applicationTemplateConfig.templateEngine === null
425
+ || this._applicationTemplateConfig.templatePath === null
426
+ || this._applicationTemplateConfig.clientConfigFunction === null
427
+ ) {
428
+ 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
+ }
430
+ }
431
+
432
+ // ------------------------------------------------------------
433
+ // INIT ROUTING
434
+ // ------------------------------------------------------------
435
+ logger.title(`configured clients and routing`);
436
+
437
+ const routes = [];
438
+ const clientsConfig = [];
439
+
440
+ for (let role in this.config.app.clients) {
441
+ const config = Object.assign({}, this.config.app.clients[role]);
442
+ config.role = role;
443
+ clientsConfig.push(config);
444
+ }
445
+
446
+ // sort default client last to open the route at the end
447
+ clientsConfig
448
+ .sort(a => a.default === true ? 1 : -1)
449
+ .forEach(config => {
450
+ const path = this._openClientRoute(this.router, config);
451
+ routes.push({ role: config.role, path });
452
+ });
453
+
454
+ logger.clientConfigAndRouting(routes, this.config);
455
+
456
+ // ------------------------------------------------------------
457
+ // START PLUGIN MANAGER
458
+ // ------------------------------------------------------------
459
+ await this.pluginManager.start();
460
+
461
+ await this._dispatchStatus('inited');
462
+
463
+ return Promise.resolve();
464
+ }
465
+
466
+ /**
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.
470
+ *
471
+ * 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
476
+ *
477
+ * After `await server.start()` the server is ready to accept incoming connexions
478
+ */
479
+ async start() {
480
+ if (this.status === 'idle') {
481
+ await this.init();
482
+ }
483
+
484
+ if (this.status === 'started') {
485
+ throw new Error(`[soundworks:Server] Cannot call "server.start()" twice`);
486
+ }
487
+
488
+ if (this.status !== 'inited') {
489
+ throw new Error(`[soundworks:Server] Cannot "server.start()" before "server.init()"`);
490
+ }
491
+
492
+ // ------------------------------------------------------------
493
+ // START CONTEXT MANAGER
494
+ // ------------------------------------------------------------
495
+ await this.contextManager.start();
496
+
497
+ // ------------------------------------------------------------
498
+ // START SOCKET SERVER
499
+ // ------------------------------------------------------------
500
+ await this.sockets.start(
501
+ this,
502
+ this.config.env.websockets,
503
+ (role, socket) => this._onSocketConnection(role, socket),
504
+ );
505
+
506
+ // ------------------------------------------------------------
507
+ // START HTTP SERVER
508
+ // ------------------------------------------------------------
509
+ return new Promise(resolve => {
510
+ const port = this.config.env.port;
511
+ const useHttps = this.config.env.useHttps || false;
512
+ const protocol = useHttps ? 'https' : 'http';
513
+ const ifaces = os.networkInterfaces();
514
+
515
+ this.httpServer.listen(port, async () => {
516
+ logger.title(`${protocol} server listening on`);
517
+
518
+ Object.keys(ifaces).forEach(dev => {
519
+ ifaces[dev].forEach(details => {
520
+ if (details.family === 'IPv4') {
521
+ logger.ip(protocol, details.address, port);
522
+ }
523
+ });
524
+ });
525
+
526
+ if (this.httpsInfos !== null) {
527
+ logger.title(`https certificates infos`);
528
+
529
+ // this.httpsInfos.selfSigned = true;
530
+ if (this.httpsInfos.selfSigned) {
531
+ logger.log(` self-signed: ${this.httpsInfos.selfSigned ? 'true' : 'false'}`);
532
+ logger.log(chalk.yellow` > INVALID CERTIFICATE (self-signed)`);
533
+
534
+ } else {
535
+ logger.log(` valid from: ${this.httpsInfos.validFrom}`);
536
+ logger.log(` valid to: ${this.httpsInfos.validTo}`);
537
+
538
+ // this.httpsInfos.isValid = false; // for testing
539
+ if (!this.httpsInfos.isValid) {
540
+ logger.error(chalk.red` -------------------------------------------`);
541
+ logger.error(chalk.red` > INVALID CERTIFICATE`);
542
+ logger.error(chalk.red` i.e. you pretend to be safe but you are not`);
543
+ logger.error(chalk.red` -------------------------------------------`);
544
+ } else {
545
+ // this.httpsInfos.daysRemaining = 2; // for testing
546
+ if (this.httpsInfos.daysRemaining < 5) {
547
+ logger.log(chalk.red` > CERTIFICATE IS VALID... BUT ONLY ${this.httpsInfos.daysRemaining} DAYS LEFT, PLEASE CONSIDER UPDATING YOUR CERTS!`);
548
+ } else if (this.httpsInfos.daysRemaining < 15) {
549
+ logger.log(chalk.yellow` > CERTIFICATE IS VALID - only ${this.httpsInfos.daysRemaining} days left, be careful...`);
550
+ } else {
551
+ logger.log(chalk.green` > CERTIFICATE IS VALID (${this.httpsInfos.daysRemaining} days left)`);
552
+ }
553
+ }
554
+
555
+ }
556
+ }
557
+
558
+ await this._dispatchStatus('started');
559
+
560
+ resolve();
561
+ });
562
+ });
563
+ }
564
+
565
+ // @todo - handle gracefull close of the server (but define what it means first...)
566
+ /**
567
+ * Stop the server, close all existing WebSocket connections.
568
+ * Mainly usefull for test.
569
+ */
570
+ async stop() {
571
+ if (this.status !== 'started') {
572
+ throw new Error(`[soundworks:Server] Cannot stop() before start()`);
573
+ }
574
+
575
+ await this.contextManager.stop();
576
+ await this.pluginManager.stop();
577
+
578
+ this.sockets.terminate();
579
+ this.httpServer.close(err => {
580
+ if (err) {
581
+ throw new Error(err.message);
582
+ }
583
+ });
584
+
585
+ await this._dispatchStatus('stopped');
586
+ }
587
+
588
+ /**
589
+ * Open the route for a given client.
590
+ * @private
591
+ */
592
+ _openClientRoute(router, config) {
593
+ const { role, target } = config;
594
+ const isDefault = (config.default === true);
595
+ // only browser targets need a route
596
+ if (target === 'node') {
597
+ return;
598
+ }
599
+
600
+ let route = '/';
601
+
602
+ if (!isDefault) {
603
+ route += `${role}`;
604
+ }
605
+
606
+ // define template filename: `${role}.html` or `default.html`
607
+ const {
608
+ templatePath,
609
+ templateEngine,
610
+ clientConfigFunction,
611
+ } = this._applicationTemplateConfig;
612
+
613
+ const clientTmpl = path.join(templatePath, `${role}.tmpl`);
614
+ const defaultTmpl = path.join(templatePath, `default.tmpl`);
615
+
616
+ // make it sync
617
+ let template;
618
+
619
+ try {
620
+ const stats = fs.statSync(clientTmpl);
621
+ template = stats.isFile() ? clientTmpl : defaultTmpl;
622
+ } catch(err) {
623
+ template = defaultTmpl;
624
+ }
625
+
626
+ let tmplString;
627
+
628
+ try {
629
+ tmplString = fs.readFileSync(template, 'utf8');
630
+ } catch(err) {
631
+ throw new Error(`[soundworks:Server] html template file "${template}" not found`);
632
+ }
633
+
634
+ const tmpl = templateEngine.compile(tmplString);
635
+ // http request
636
+ router.get(route, (req, res) => {
637
+ const data = clientConfigFunction(role, this.config, req);
638
+
639
+ // CORS / COOP / COEP headers for `crossOriginIsolated pages,
640
+ // enables `sharedArrayBuffers` and high precision timers
641
+ // cf. https://web.dev/why-coop-coep/
642
+ if (this.config.env.crossOriginIsolated) {
643
+ res.writeHead(200, {
644
+ 'Cross-Origin-Resource-Policy': 'same-origin',
645
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
646
+ 'Cross-Origin-Opener-Policy': 'same-origin',
647
+ });
648
+ }
649
+
650
+ const appIndex = tmpl(data);
651
+ res.end(appIndex);
652
+ });
653
+
654
+ // return route infos for logging on server start
655
+ return route;
656
+ }
657
+
658
+ /**
659
+ * Socket connection callback.
660
+ * @private
661
+ */
662
+ _onSocketConnection(role, socket) {
663
+ const client = new Client(role, socket);
664
+ const roles = Object.keys(this.config.app.clients);
665
+
666
+ socket.addListener('close', async () => {
667
+ // do nothin if client type was invalid
668
+ if (roles.includes(role)) {
669
+ // decrement audit state counter
670
+ const numClients = this._auditState.get('numClients');
671
+ numClients[role] -= 1;
672
+ this._auditState.set({ numClients });
673
+
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);
680
+ }
681
+
682
+ // clean sockets
683
+ socket.terminate();
684
+ });
685
+
686
+ socket.addListener(CLIENT_HANDSHAKE_REQUEST, async payload => {
687
+ const { role, registeredPlugins } = payload;
688
+
689
+ if (!roles.includes(role)) {
690
+ console.error(`[soundworks.Server] A client with invalid type ("${role}") attempted to connect`);
691
+
692
+ socket.send(CLIENT_HANDSHAKE_ERROR, {
693
+ type: 'invalid-client-type',
694
+ message: `Invalid client type, please check server configuration (valid client types are: ${roles.join(', ')})`,
695
+ });
696
+ return;
697
+ }
698
+
699
+ try {
700
+ this.pluginManager.checkRegisteredPlugins(registeredPlugins);
701
+ } catch(err) {
702
+ socket.send(CLIENT_HANDSHAKE_ERROR, {
703
+ type: 'invalid-plugin-list',
704
+ message: err.message,
705
+ });
706
+ return;
707
+ }
708
+
709
+ // increment audit state
710
+ const numClients = this._auditState.get('numClients');
711
+ numClients[role] += 1;
712
+ this._auditState.set({ numClients });
713
+
714
+ // add client to state manager
715
+ await this.stateManager.addClient(client.id, {
716
+ emit: client.socket.send.bind(client.socket),
717
+ addListener: client.socket.addListener.bind(client.socket),
718
+ removeAllListeners: client.socket.removeAllListeners.bind(client.socket),
719
+ });
720
+ // add client to plugin manager
721
+ // server-side, all plugins are active for the lifetime of the client
722
+ await this.pluginManager.addClient(client, registeredPlugins);
723
+ // add client to context manager
724
+ await this.contextManager.addClient(client);
725
+
726
+ const { id, uuid } = client;
727
+ socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid });
728
+ });
729
+ }
730
+
731
+ /**
732
+ * Create namespaced databases for core and plugins
733
+ * (kind of experimental API do not expose in doc for now)
734
+ *
735
+ * @note - introduced in v3.1.0-beta.1
736
+ * @note - used by core and plugin-audio-streams
737
+ * @private
738
+ */
739
+ createNamespacedDb(namespace = null) {
740
+ if (namespace === null || !(typeof namespace === 'string')) {
741
+ throw new Error(`[soundworks:Server] Invalid namespace for ".createNamespacedDb(namespace)", namespace is mandatory and should be a string`);
742
+ }
743
+
744
+ if (_dbNamespaces.has(namespace)) {
745
+ throw new Error(`[soundworks:Server] Invalid namespace for ".createNamespacedDb(namespace)", namespace "${namespace}" already exists`);
746
+ }
747
+
748
+ // KeyvFile uses fs-extra.outputFile internally so we don't need to create
749
+ // the directory, it will be lazily created if something is written in the db
750
+ // @see https://github.com/zaaack/keyv-file/blob/52502077c78226b3d69a615c80b88e53be096979/index.ts#L157
751
+ const filename = path.join(process.cwd(), '.data', `soundworks-${namespace}.db`);
752
+ // @note - keyv-file doesn't seems to works
753
+ const store = new KeyvFile({ filename });
754
+ const db = new Keyv({ namespace, store });
755
+ db.on('error', err => logger.error(`[soundworks:Server] db ${namespace} error: ${err}`));
756
+
757
+ return db;
758
+ }
759
+
760
+ onStatusChange(callback) {
761
+ this._onStatusChangeCallbacks.add(callback);
762
+
763
+ return () => this._onStatusChangeCallbacks.delete(callback);
764
+ }
765
+
766
+ /** @private */
767
+ async _dispatchStatus(status) {
768
+ this.status = status;
769
+
770
+ // if launched in a child process, forward status to parent process
771
+ if (process.send !== undefined) {
772
+ process.send(`soundworks:server:${status}`);
773
+ }
774
+
775
+ // execute all callbacks in parallel
776
+ const promises = [];
777
+
778
+ for (let callback of this._onStatusChangeCallbacks) {
779
+ promises.push(callback(status));
780
+ }
781
+
782
+ await Promise.all(promises);
783
+ }
784
+
785
+ /**
786
+ * Configure the server to work out-of-the box with the soundworks-template
787
+ * directory tree structure.
788
+ *
789
+ * - uses [template-literal](https://www.npmjs.com/package/template-literal) package
790
+ * as html templateEngine
791
+ * - define `.build/server/tmpl` as the directory in which html template can be
792
+ * found
793
+ * - define the `clientConfigFunction` function that return client compliant
794
+ * config object to be injected in the html template.
795
+ *
796
+ * Also expose two public directory:
797
+ * - the `public` directory which is exposed behind the root path
798
+ * - the `./.build/public` directory which is exposed behind the `build` path
799
+ */
800
+ setDefaultTemplateConfig() {
801
+ this._applicationTemplateConfig = {
802
+ templateEngine: { compile },
803
+ templatePath: path.join('.build', 'server', 'tmpl'),
804
+ clientConfigFunction: (role, config, _httpRequest) => {
805
+ return {
806
+ role: role,
807
+ app: {
808
+ name: config.app.name,
809
+ author: config.app.author,
810
+ },
811
+ env: {
812
+ type: config.env.type,
813
+ websockets: config.env.websockets,
814
+ subpath: config.env.subpath,
815
+ },
816
+ };
817
+ },
818
+ };
819
+
820
+ this.router.use(express.static('public'));
821
+ this.router.use('/build', express.static(path.join('.build', 'public')));
822
+ }
823
+
824
+ /**
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 :)
830
+ */
831
+ setCustomTemplateConfig(options) {
832
+ Object.assign(this._applicationTemplateConfig, options);
833
+ }
834
+
835
+ /**
836
+ * Get the global audit state of the application.
837
+ *
838
+ * @throws Will throw if called before `server.init()`
839
+ */
840
+ async getAuditState() {
841
+ if (this.status === 'idle') {
842
+ throw new Error(`[soundworks.Server] Cannot access audit state before init`);
843
+ }
844
+
845
+ return this._auditState;
846
+ }
847
+ }
848
+
849
+ export default Server;