@soundworks/core 4.0.0-alpha.28 → 4.0.0-alpha.29

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 (127) hide show
  1. package/.jsdoc +3 -1
  2. package/CHANGELOG.md +11 -5
  3. package/package.json +5 -5
  4. package/src/client/Client.js +78 -83
  5. package/src/client/{Context.js → ClientContext.js} +48 -38
  6. package/src/client/ClientContextManager.js +115 -0
  7. package/src/client/{Plugin.js → ClientPlugin.js} +22 -18
  8. package/src/client/{PluginManager.js → ClientPluginManager.js} +39 -35
  9. package/src/client/{Socket.js → ClientSocket.js} +54 -65
  10. package/src/client/{StateManager.js → ClientStateManager.js} +7 -8
  11. package/src/client/index.js +17 -41
  12. package/src/common/BasePlugin.js +56 -51
  13. package/src/common/BasePluginManager.js +175 -139
  14. package/src/common/BaseStateManager.js +316 -120
  15. package/src/common/BatchedTransport.js +24 -24
  16. package/src/common/ParameterBag.js +18 -6
  17. package/src/common/PromiseStore.js +1 -0
  18. package/src/common/{BaseSharedState.js → SharedState.js} +271 -199
  19. package/src/common/{BaseSharedStateCollection.js → SharedStateCollection.js} +92 -61
  20. package/src/common/logger.js +5 -0
  21. package/src/common/shared-state-types.js +76 -0
  22. package/src/common/version.js +1 -1
  23. package/src/server/Server.js +391 -354
  24. package/src/server/ServerClient.js +73 -0
  25. package/src/server/{Context.js → ServerContext.js} +85 -66
  26. package/src/server/{ContextManager.js → ServerContextManager.js} +98 -85
  27. package/src/server/{Plugin.js → ServerPlugin.js} +10 -24
  28. package/src/server/{PluginManager.js → ServerPluginManager.js} +76 -69
  29. package/src/server/{Socket.js → ServerSocket.js} +19 -18
  30. package/src/server/{Sockets.js → ServerSockets.js} +36 -27
  31. package/src/server/ServerStateManager.js +515 -0
  32. package/src/{common → server}/SharedStatePrivate.js +61 -26
  33. package/src/server/audit-schema.js +1 -1
  34. package/src/server/crypto.js +2 -0
  35. package/src/server/index.js +18 -21
  36. package/types/client/Client.d.ts +74 -95
  37. package/types/client/{Context.d.ts → ClientContext.d.ts} +25 -26
  38. package/types/client/{ContextManager.d.ts → ClientContextManager.d.ts} +19 -22
  39. package/types/client/{Plugin.d.ts → ClientPlugin.d.ts} +17 -20
  40. package/types/client/{PluginManager.d.ts → ClientPluginManager.d.ts} +13 -36
  41. package/types/client/ClientSocket.d.ts +74 -0
  42. package/types/client/{StateManager.d.ts → ClientStateManager.d.ts} +11 -15
  43. package/types/client/index.d.ts +26 -2
  44. package/types/common/BasePlugin.d.ts +52 -46
  45. package/types/common/BasePluginManager.d.ts +68 -44
  46. package/types/common/BaseStateManager.d.ts +207 -97
  47. package/types/common/BatchedTransport.d.ts +1 -5
  48. package/types/common/ParameterBag.d.ts +16 -18
  49. package/types/common/PromiseStore.d.ts +1 -1
  50. package/types/common/SharedState.d.ts +297 -0
  51. package/types/common/{BaseSharedStateCollection.d.ts → SharedStateCollection.d.ts} +34 -18
  52. package/types/common/constants.d.ts +0 -2
  53. package/types/common/logger.d.ts +2 -1
  54. package/types/common/shared-state-types.d.ts +83 -0
  55. package/types/common/sockets-utils.d.ts +0 -5
  56. package/types/common/version.d.ts +1 -2
  57. package/types/server/Server.d.ts +78 -132
  58. package/types/server/ServerClient.d.ts +45 -0
  59. package/types/server/{Context.d.ts → ServerContext.d.ts} +53 -43
  60. package/types/server/ServerContextManager.d.ts +80 -0
  61. package/types/server/{Plugin.d.ts → ServerPlugin.d.ts} +19 -33
  62. package/types/server/{PluginManager.d.ts → ServerPluginManager.d.ts} +39 -37
  63. package/types/server/ServerSocket.d.ts +85 -0
  64. package/types/server/ServerSockets.d.ts +50 -0
  65. package/types/server/ServerStateManager.d.ts +189 -0
  66. package/types/server/SharedStatePrivate.d.ts +21 -0
  67. package/types/server/audit-network-latency.worker.d.ts +1 -2
  68. package/types/server/audit-schema.d.ts +0 -1
  69. package/types/server/crypto.d.ts +2 -1
  70. package/types/server/index.d.ts +24 -2
  71. package/src/client/ContextManager.js +0 -111
  72. package/src/client/SharedState.js +0 -84
  73. package/src/client/SharedStateCollection.js +0 -38
  74. package/src/server/Client.js +0 -55
  75. package/src/server/SharedState.js +0 -85
  76. package/src/server/SharedStateCollection.js +0 -39
  77. package/src/server/StateManager.js +0 -696
  78. package/types/client/Client.d.ts.map +0 -1
  79. package/types/client/Context.d.ts.map +0 -1
  80. package/types/client/ContextManager.d.ts.map +0 -1
  81. package/types/client/Plugin.d.ts.map +0 -1
  82. package/types/client/PluginManager.d.ts.map +0 -1
  83. package/types/client/SharedState.d.ts +0 -85
  84. package/types/client/SharedState.d.ts.map +0 -1
  85. package/types/client/SharedStateCollection.d.ts +0 -41
  86. package/types/client/SharedStateCollection.d.ts.map +0 -1
  87. package/types/client/Socket.d.ts +0 -149
  88. package/types/client/Socket.d.ts.map +0 -1
  89. package/types/client/StateManager.d.ts.map +0 -1
  90. package/types/client/index.d.ts.map +0 -1
  91. package/types/common/BasePlugin.d.ts.map +0 -1
  92. package/types/common/BasePluginManager.d.ts.map +0 -1
  93. package/types/common/BaseSharedState.d.ts +0 -244
  94. package/types/common/BaseSharedState.d.ts.map +0 -1
  95. package/types/common/BaseSharedStateCollection.d.ts.map +0 -1
  96. package/types/common/BaseStateManager.d.ts.map +0 -1
  97. package/types/common/BatchedTransport.d.ts.map +0 -1
  98. package/types/common/ParameterBag.d.ts.map +0 -1
  99. package/types/common/PromiseStore.d.ts.map +0 -1
  100. package/types/common/SharedStatePrivate.d.ts +0 -21
  101. package/types/common/SharedStatePrivate.d.ts.map +0 -1
  102. package/types/common/constants.d.ts.map +0 -1
  103. package/types/common/logger.d.ts.map +0 -1
  104. package/types/common/sockets-utils.d.ts.map +0 -1
  105. package/types/common/version.d.ts.map +0 -1
  106. package/types/server/Client.d.ts +0 -45
  107. package/types/server/Client.d.ts.map +0 -1
  108. package/types/server/Context.d.ts.map +0 -1
  109. package/types/server/ContextManager.d.ts +0 -69
  110. package/types/server/ContextManager.d.ts.map +0 -1
  111. package/types/server/Plugin.d.ts.map +0 -1
  112. package/types/server/PluginManager.d.ts.map +0 -1
  113. package/types/server/Server.d.ts.map +0 -1
  114. package/types/server/SharedState.d.ts +0 -86
  115. package/types/server/SharedState.d.ts.map +0 -1
  116. package/types/server/SharedStateCollection.d.ts +0 -41
  117. package/types/server/SharedStateCollection.d.ts.map +0 -1
  118. package/types/server/Socket.d.ts +0 -164
  119. package/types/server/Socket.d.ts.map +0 -1
  120. package/types/server/Sockets.d.ts +0 -80
  121. package/types/server/Sockets.d.ts.map +0 -1
  122. package/types/server/StateManager.d.ts +0 -405
  123. package/types/server/StateManager.d.ts.map +0 -1
  124. package/types/server/audit-network-latency.worker.d.ts.map +0 -1
  125. package/types/server/audit-schema.d.ts.map +0 -1
  126. package/types/server/crypto.d.ts.map +0 -1
  127. package/types/server/index.d.ts.map +0 -1
@@ -0,0 +1,515 @@
1
+ import { idGenerator, isString, isPlainObject } from '@ircam/sc-utils';
2
+ import clonedeep from 'lodash/cloneDeep.js';
3
+
4
+ import BaseStateManager, {
5
+ kStateManagerInit,
6
+ } from '../common/BaseStateManager.js';
7
+ import BatchedTransport from '../common/BatchedTransport.js';
8
+ import ParameterBag from '../common/ParameterBag.js';
9
+ import {
10
+ CREATE_REQUEST,
11
+ CREATE_RESPONSE,
12
+ CREATE_ERROR,
13
+ DELETE_NOTIFICATION,
14
+ ATTACH_REQUEST,
15
+ ATTACH_RESPONSE,
16
+ ATTACH_ERROR,
17
+ OBSERVE_REQUEST,
18
+ OBSERVE_RESPONSE,
19
+ OBSERVE_ERROR,
20
+ OBSERVE_NOTIFICATION,
21
+ UNOBSERVE_NOTIFICATION,
22
+ DELETE_SCHEMA,
23
+ GET_SCHEMA_REQUEST,
24
+ GET_SCHEMA_RESPONSE,
25
+ GET_SCHEMA_ERROR,
26
+ PRIVATE_STATES,
27
+ } from '../common/constants.js';
28
+
29
+ import SharedStatePrivate, {
30
+ kSharedStatePrivateAttachClient,
31
+ kSharedStatePrivateDetachClient,
32
+ } from './SharedStatePrivate.js';
33
+
34
+
35
+ const generateStateId = idGenerator();
36
+ const generateRemoteId = idGenerator();
37
+
38
+ export const kServerStateManagerAddClient = Symbol('soundworks:server-state-manager-add-client');
39
+ export const kServerStateManagerRemoveClient = Symbol('soundworks:server-state-manager-remove-client');
40
+ export const kServerStateManagerDeletePrivateState = Symbol('soundworks:server-state-manager-delete-private-state');
41
+ export const kServerStateManagerGetHooks = Symbol('soundworks:server-state-manager-get-hooks');
42
+ // for testing purposes
43
+ export const kStateManagerClientsByNodeId = Symbol('soundworks:server-state-clients-by-node-id');
44
+
45
+
46
+ /**
47
+ * @callback serverStateManagerUpdateHook
48
+ * @async
49
+ *
50
+ * @param {object} updates - Update object as given on a set callback, or
51
+ * result of the previous hook
52
+ * @param {object} currentValues - Current values of the state.
53
+ * @param {object} [context=null] - Optionnal context passed by the creator
54
+ * of the update.
55
+ *
56
+ * @returns {object} The "real" updates to be applied on the state.
57
+ */
58
+
59
+ /**
60
+ * The `StateManager` allows to create new {@link SharedState}s, or attach
61
+ * to {@link SharedState}s created by other nodes (clients or server). It
62
+ * can also track all the {@link SharedState}s created by other nodes.
63
+ *
64
+ * An instance of `StateManager` is automatically created by the `soundworks.Server`
65
+ * at initialization (cf. {@link Server#stateManager}).
66
+ *
67
+ * Compared to the {@link ClientStateManager}, the `ServerStateManager` can also
68
+ * register and delete schemas, as well as register update hook that are executed when
69
+ * a state is updated.
70
+ *
71
+ * See {@link Server#stateManager}
72
+ *
73
+ * Tutorial: {@link https://soundworks.dev/guide/state-manager.html}
74
+ *
75
+ * ```
76
+ * // server-side
77
+ * import { Server } from '@soundworks/server/index.js';
78
+ *
79
+ * const server = new Server(config);
80
+ * // declare and register the schema of a shared state.
81
+ * server.stateManager.registerSchema('some-global-state', {
82
+ * myRandom: {
83
+ * type: 'float',
84
+ * default: 0,
85
+ * }
86
+ * });
87
+ *
88
+ * await server.start();
89
+ *
90
+ * // create a global state server-side
91
+ * const globalState = await server.stateManager.create('some-global-state');
92
+ * // listen and react to the changes made by the clients
93
+ * globalState.onUpdate(updates => console.log(updates));
94
+ * ```
95
+ *
96
+ * ```
97
+ * // client-side
98
+ * import { Client } from '@soundworks/client.index.js';
99
+ *
100
+ * const client = new Client(config);
101
+ * await client.start();
102
+ *
103
+ * // attach to the global state created by the server
104
+ * const globalState = await client.stateManager.attach('some-global-state');
105
+ *
106
+ * // update the value of a `myRandom` parameter every seconds
107
+ * setInterval(() => {
108
+ * globalState.set({ myRandom: Math.random() });
109
+ * }, 1000);
110
+ * ```
111
+ *
112
+ * @extends BaseStateManager
113
+ * @inheritdoc
114
+ * @hideconstructor
115
+ */
116
+ class ServerStateManager extends BaseStateManager {
117
+ #sharedStatePrivateById = new Map();
118
+ #schemas = new Map();
119
+ #observers = new Set();
120
+ #hooksBySchemaName = new Map(); // protected
121
+
122
+ constructor() {
123
+ super();
124
+ /** @private */
125
+ this[kStateManagerClientsByNodeId] = new Map();
126
+ }
127
+
128
+ #isObservableState(state) {
129
+ const { schemaName, isCollectionController } = state;
130
+ // is observable only if not in private state and not a controller state
131
+ return !PRIVATE_STATES.includes(schemaName) && !isCollectionController;
132
+ }
133
+
134
+ /** @private */
135
+ [kServerStateManagerDeletePrivateState](id) {
136
+ this.#sharedStatePrivateById.delete(id);
137
+ }
138
+
139
+ /** @private */
140
+ [kServerStateManagerGetHooks](schemaName) {
141
+ return this.#hooksBySchemaName.get(schemaName);
142
+ }
143
+
144
+ /** @private */
145
+ [kStateManagerInit](id, transport) {
146
+ super[kStateManagerInit](id, transport);
147
+ // add itself as client of the state manager server
148
+ this[kServerStateManagerAddClient](id, transport);
149
+ }
150
+
151
+ /**
152
+ * Add a client to the manager.
153
+ *
154
+ * This is automatically handled by the {@link Server} when a client connects.
155
+ *
156
+ * @param {number} nodeId - Id of the client node, as given in
157
+ * {@link client.StateManager}
158
+ * @param {object} transport - Transport mecanism to communicate with the
159
+ * client. Must implement a basic EventEmitter API.
160
+ *
161
+ * @private
162
+ */
163
+ [kServerStateManagerAddClient](nodeId, transport) {
164
+ const batchedTransport = new BatchedTransport(transport);
165
+ const client = {
166
+ id: nodeId,
167
+ transport: batchedTransport,
168
+ };
169
+
170
+ this[kStateManagerClientsByNodeId].set(nodeId, client);
171
+
172
+ // ---------------------------------------------
173
+ // CREATE
174
+ // ---------------------------------------------
175
+ client.transport.addListener(
176
+ CREATE_REQUEST,
177
+ (reqId, schemaName, requireSchema, initValues = {}) => {
178
+ if (this.#schemas.has(schemaName)) {
179
+ try {
180
+ const schema = this.#schemas.get(schemaName);
181
+ const stateId = generateStateId.next().value;
182
+ const remoteId = generateRemoteId.next().value;
183
+ const state = new SharedStatePrivate(stateId, schemaName, schema, this, initValues);
184
+
185
+ // attach client to the state as owner
186
+ const isOwner = true;
187
+ const filter = null;
188
+ state[kSharedStatePrivateAttachClient](remoteId, client, isOwner, filter);
189
+
190
+ this.#sharedStatePrivateById.set(stateId, state);
191
+
192
+ const currentValues = state.parameters.getValues();
193
+ const schemaOption = requireSchema ? schema : null;
194
+
195
+ client.transport.emit(
196
+ CREATE_RESPONSE,
197
+ reqId, stateId, remoteId, schemaName, schemaOption, currentValues,
198
+ );
199
+
200
+ const isObservable = this.#isObservableState(state);
201
+
202
+ if (isObservable) {
203
+ this.#observers.forEach(observer => {
204
+ observer.transport.emit(OBSERVE_NOTIFICATION, schemaName, stateId, nodeId);
205
+ });
206
+ }
207
+ } catch (err) {
208
+ const msg = `[stateManager] Cannot create state "${schemaName}", ${err.message}`;
209
+ console.error(msg);
210
+
211
+ client.transport.emit(CREATE_ERROR, reqId, msg);
212
+ }
213
+ } else {
214
+ const msg = `[stateManager] Cannot create state "${schemaName}", schema does not exists`;
215
+ console.error(msg);
216
+
217
+ client.transport.emit(CREATE_ERROR, reqId, msg);
218
+ }
219
+ },
220
+ );
221
+
222
+ // ---------------------------------------------
223
+ // ATTACH (when creator, is attached by default)
224
+ // ---------------------------------------------
225
+ client.transport.addListener(
226
+ ATTACH_REQUEST,
227
+ (reqId, schemaName, stateId = null, requireSchema = true, filter = null) => {
228
+ if (this.#schemas.has(schemaName)) {
229
+ let state = null;
230
+
231
+ if (stateId !== null && this.#sharedStatePrivateById.has(stateId)) {
232
+ state = this.#sharedStatePrivateById.get(stateId);
233
+ } else if (stateId === null) {
234
+ // if no `stateId` given, we try to find the first state with the given
235
+ // `schemaName` in the list, this allow a client to attach to a global
236
+ // state created by the server (or some persistant client) without
237
+ // having to know the `stateId` (e.g. some global state...)
238
+ for (let existingState of this.#sharedStatePrivateById.values()) {
239
+ if (existingState.schemaName === schemaName) {
240
+ state = existingState;
241
+ break;
242
+ }
243
+ }
244
+ }
245
+
246
+ if (state !== null) {
247
+ // @note - we use a unique remote id to allow a client to attach
248
+ // several times to the same state.
249
+ // i.e. same state -> several remote attach on the same node
250
+ const remoteId = generateRemoteId.next().value;
251
+ const isOwner = false;
252
+ const currentValues = state.parameters.getValues();
253
+ const schema = this.#schemas.get(schemaName);
254
+ const schemaOption = requireSchema ? schema : null;
255
+
256
+ // if filter given, check that all filter entries are valid schema keys
257
+ // @todo - improve error reportin: report invalid filters
258
+ if (filter !== null) {
259
+ const keys = Object.keys(schema);
260
+ const isValid = filter.reduce((acc, key) => acc && keys.includes(key), true);
261
+
262
+ if (!isValid) {
263
+ const msg = `[stateManager] Cannot attach, invalid filter (${filter.join(', ')}) for schema "${schemaName}"`;
264
+ console.error(msg);
265
+
266
+ return client.transport.emit(ATTACH_ERROR, reqId, msg);
267
+ }
268
+ }
269
+
270
+ state[kSharedStatePrivateAttachClient](remoteId, client, isOwner, filter);
271
+
272
+ client.transport.emit(
273
+ ATTACH_RESPONSE,
274
+ reqId, state.id, remoteId, schemaName, schemaOption, currentValues, filter,
275
+ );
276
+
277
+ } else {
278
+ const msg = `[stateManager] Cannot attach, no existing state for schema "${schemaName}" with stateId: "${stateId}"`;
279
+ console.error(msg);
280
+
281
+ client.transport.emit(ATTACH_ERROR, reqId, msg);
282
+ }
283
+ } else {
284
+ const msg = `[stateManager] Cannot attach, schema "${schemaName}" does not exists`;
285
+ console.error(msg);
286
+
287
+ client.transport.emit(ATTACH_ERROR, reqId, msg);
288
+ }
289
+ },
290
+ );
291
+
292
+ // ---------------------------------------------
293
+ // OBSERVE PEERS (be notified when a state is created, lazy)
294
+ // ---------------------------------------------
295
+ client.transport.addListener(OBSERVE_REQUEST, (reqId, observedSchemaName) => {
296
+ if (observedSchemaName === null || this.#schemas.has(observedSchemaName)) {
297
+ const statesInfos = [];
298
+
299
+ this.#sharedStatePrivateById.forEach(state => {
300
+ const isObservable = this.#isObservableState(state);
301
+
302
+ if (isObservable) {
303
+ const { schemaName, id, creatorId } = state;
304
+ statesInfos.push([schemaName, id, creatorId]);
305
+ }
306
+ });
307
+
308
+ // add client to observers first because if some synchronous server side
309
+ // callback throws, the client would never be added to the list
310
+ this.#observers.add(client);
311
+
312
+ client.transport.emit(OBSERVE_RESPONSE, reqId, ...statesInfos);
313
+ } else {
314
+ const msg = `[stateManager] Cannot observe, schema "${observedSchemaName}" does not exists`;
315
+ client.transport.emit(OBSERVE_ERROR, reqId, msg);
316
+ }
317
+ });
318
+
319
+ client.transport.addListener(UNOBSERVE_NOTIFICATION, () => {
320
+ this.#observers.delete(client);
321
+ });
322
+
323
+ // ---------------------------------------------
324
+ // GET SCHEMA
325
+ // ---------------------------------------------
326
+ client.transport.addListener(GET_SCHEMA_REQUEST, (reqId, schemaName) => {
327
+ if (this.#schemas.has(schemaName)) {
328
+ const schema = this.#schemas.get(schemaName);
329
+ client.transport.emit(GET_SCHEMA_RESPONSE, reqId, schemaName, schema);
330
+ } else {
331
+ const msg = `[stateManager] Cannot get schema, schema "${schemaName}" does not exists`;
332
+ client.transport.emit(GET_SCHEMA_ERROR, reqId, msg);
333
+ }
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Remove a client from the manager. Clean all created or attached states.
339
+ *
340
+ * This is automatically handled by the {@link Server} when a client disconnects.
341
+ *
342
+ * @param {number} nodeId - Id of the client node, as given in
343
+ * {@link client.StateManager}
344
+ *
345
+ * @private
346
+ */
347
+ [kServerStateManagerRemoveClient](nodeId) {
348
+ for (let [_id, state] of this.#sharedStatePrivateById.entries()) {
349
+ let deleteState = false;
350
+
351
+ // define if the client is the creator of the state, in which case
352
+ // everybody must delete it
353
+ for (let [remoteId, clientInfos] of state.attachedClients) {
354
+ const attachedClient = clientInfos.client;
355
+
356
+ if (nodeId === attachedClient.id && remoteId === state.creatorRemoteId) {
357
+ deleteState = true;
358
+ }
359
+ }
360
+
361
+ for (let [remoteId, clientInfos] of state.attachedClients) {
362
+ const attachedClient = clientInfos.client;
363
+
364
+ if (nodeId === attachedClient.id) {
365
+ state[kSharedStatePrivateDetachClient](remoteId, attachedClient);
366
+ }
367
+
368
+ if (deleteState) {
369
+ if (remoteId !== state.creatorRemoteId) {
370
+ // send notification to other attached nodes
371
+ attachedClient.transport.emit(`${DELETE_NOTIFICATION}-${state.id}-${remoteId}`);
372
+ }
373
+
374
+ this.#sharedStatePrivateById.delete(state.id);
375
+ }
376
+ }
377
+ }
378
+
379
+ // if is an observer, delete it
380
+ const client = this[kStateManagerClientsByNodeId].get(nodeId);
381
+ this.#observers.delete(client);
382
+ this[kStateManagerClientsByNodeId].delete(nodeId);
383
+ }
384
+
385
+ /**
386
+ * Define a class of data structure from which {@link SharedState} can be instanciated.
387
+ *
388
+ * _In a future revision, this method and its arguments will be renamed_
389
+ *
390
+ * @param {SharedStateClassName} schemaName - Name of the schema.
391
+ * @param {SharedStateSchema} schema - Data structure
392
+ * describing the states that will be created from this schema.
393
+ *
394
+ * @see {@link ServerStateManager#create}
395
+ * @see {@link ClientStateManager#create}
396
+ *
397
+ * @example
398
+ * server.stateManager.registerSchema('my-schema', {
399
+ * myBoolean: {
400
+ * type: 'boolean'
401
+ * default: false,
402
+ * },
403
+ * myFloat: {
404
+ * type: 'float'
405
+ * default: 0.1,
406
+ * min: -1,
407
+ * max: 1
408
+ * }
409
+ * })
410
+ */
411
+ registerSchema(schemaName, schema) {
412
+ if (!isString(schemaName)) {
413
+ throw new Error(`[stateManager.registerSchema] Invalid schema name "${schemaName}", should be a string`);
414
+ }
415
+
416
+ if (this.#schemas.has(schemaName)) {
417
+ throw new Error(`[stateManager.registerSchema] cannot register schema with name: "${schemaName}", schema name already exists`);
418
+ }
419
+
420
+ if (!isPlainObject(schema)) {
421
+ throw new Error(`[stateManager.registerSchema] Invalid schema, should be an object`);
422
+ }
423
+
424
+ ParameterBag.validateSchema(schema);
425
+
426
+ this.#schemas.set(schemaName, clonedeep(schema));
427
+ // create hooks list
428
+ this.#hooksBySchemaName.set(schemaName, new Set());
429
+ }
430
+
431
+ /**
432
+ * Delete a whole class of {@link ShareState}.
433
+ *
434
+ * All {@link SharedState} instance that belong to this class are deleted
435
+ * as well, triggering the `onDetach` and `onDelete` callbacks are called on
436
+ * the actual {@link SharedState} instances.
437
+ *
438
+ * _In a future revision, this method and its arguments will be renamed_
439
+ *
440
+ * @param {SharedStateClassName} schemaName - Name of the schema.
441
+ */
442
+ deleteSchema(schemaName) {
443
+ // @note: deleting schema
444
+ for (let [_, state] of this.#sharedStatePrivateById) {
445
+ if (state.schemaName === schemaName) {
446
+ for (let [remoteId, clientInfos] of state.attachedClients) {
447
+ const attached = clientInfos.client;
448
+ state[kSharedStatePrivateDetachClient](remoteId, attached);
449
+ attached.transport.emit(`${DELETE_NOTIFICATION}-${state.id}-${remoteId}`);
450
+ }
451
+
452
+ this.#sharedStatePrivateById.delete(state.id);
453
+ }
454
+ }
455
+
456
+ // clear schema cache of all connected clients
457
+ for (let client of this[kStateManagerClientsByNodeId].values()) {
458
+ client.transport.emit(`${DELETE_SCHEMA}`, schemaName);
459
+ }
460
+
461
+ this.#schemas.delete(schemaName);
462
+ // delete registered hooks
463
+ this.#hooksBySchemaName.delete(schemaName);
464
+ }
465
+
466
+ /**
467
+ * Register a function for a given schema (e.g. will be applied on all states
468
+ * created from this schema) that will be executed before the update values
469
+ * are propagated. For example, this could be used to implement a preset system
470
+ * where all the values of the state are updated from e.g. some data stored in
471
+ * filesystem while the consumer of the state only want to update the preset name.
472
+ *
473
+ * The hook is associated to every state of its kind (i.e. schemaName) and
474
+ * executed on every update (call of `set`). Note that the hooks are executed
475
+ * server-side regarless the node on which `set` has been called and before
476
+ * the "actual" update of the state (e.g. before the call of `onUpdate`).
477
+ *
478
+ * @param {string} schemaName - Kind of states on which applying the hook.
479
+ * @param {serverStateManagerUpdateHook} updateHook - Function
480
+ * called between the `set` call and the actual update.
481
+ *
482
+ * @returns {Fuction} deleteHook - Handler that deletes the hook when executed.
483
+ *
484
+ * @example
485
+ * server.stateManager.registerSchema('hooked', {
486
+ * value: { type: 'string', default: null, nullable: true },
487
+ * numUpdates: { type: 'integer', default: 0 },
488
+ * });
489
+ * server.stateManager.registerUpdateHook('hooked', updates => {
490
+ * return {
491
+ * ...updates
492
+ * numUpdates: currentValues.numUpdates + 1,
493
+ * };
494
+ * });
495
+ *
496
+ * const state = await server.stateManager.create('hooked');
497
+ *
498
+ * await state.set({ value: 'test' });
499
+ * const values = state.getValues();
500
+ * assert.deepEqual(result, { value: 'test', numUpdates: 1 });
501
+ */
502
+ registerUpdateHook(schemaName, updateHook) {
503
+ // throw error if schemaName has not been registered
504
+ if (!this.#schemas.has(schemaName)) {
505
+ throw new Error(`[stateManager.registerUpdateHook] cannot register update hook for schema name "${schemaName}", schema name does not exists`);
506
+ }
507
+
508
+ const hooks = this.#hooksBySchemaName.get(schemaName);
509
+ hooks.add(updateHook);
510
+
511
+ return () => hooks.delete(updateHook);
512
+ }
513
+ }
514
+
515
+ export default ServerStateManager;
@@ -10,12 +10,17 @@ import {
10
10
  UPDATE_ABORT,
11
11
  UPDATE_NOTIFICATION,
12
12
  } from '../common/constants.js';
13
+ import {
14
+ kServerStateManagerDeletePrivateState,
15
+ kServerStateManagerGetHooks,
16
+ } from '../server/ServerStateManager.js';
13
17
 
14
18
  /**
15
19
  * Filter update object according to filter list.
16
20
  * Return identity is filter is null
17
21
  * @param {object} updates
18
22
  * @param {array|null} filter
23
+ * @private
19
24
  */
20
25
  function filterUpdates(updates, filter) {
21
26
  if (filter === null) {
@@ -30,6 +35,9 @@ function filterUpdates(updates, filter) {
30
35
  }, {});
31
36
  }
32
37
 
38
+ export const kSharedStatePrivateAttachClient = Symbol('soundworks:shared-state-private-attach-client');
39
+ export const kSharedStatePrivateDetachClient = Symbol('soundworks:shared-state-private-detach-client');
40
+
33
41
  /**
34
42
  * The "real" state, this instance is kept private by the server.StateManager.
35
43
  * It can only be accessed through a SharedState proxy.
@@ -37,32 +45,59 @@ function filterUpdates(updates, filter) {
37
45
  * @private
38
46
  */
39
47
  class SharedStatePrivate {
48
+ #id = null;
49
+ #schemaName = null;
50
+ #manager = null;
51
+ #parameters = null;
52
+ #creatorId = null;
53
+ #creatorRemoteId = null;
54
+ #attachedClients = new Map();
55
+
40
56
  constructor(id, schemaName, schema, manager, initValues = {}) {
41
- this.id = id;
42
- this.schemaName = schemaName;
57
+ this.#id = id;
58
+ this.#schemaName = schemaName;
59
+ this.#manager = manager;
60
+ this.#parameters = new ParameterBag(schema, initValues);
61
+ }
62
+
63
+ get id() {
64
+ return this.#id;
65
+ }
43
66
 
44
- this._manager = manager;
45
- this._parameters = new ParameterBag(schema, initValues);
46
- this._attachedClients = new Map(); // other peers interested in watching / controlling the state
67
+ get schemaName() {
68
+ return this.#schemaName;
69
+ }
70
+
71
+ get creatorId() {
72
+ return this.#creatorId;
73
+ }
74
+
75
+ get creatorRemoteId() {
76
+ return this.#creatorRemoteId;
77
+ }
78
+
79
+ get attachedClients() {
80
+ return this.#attachedClients;
81
+ }
47
82
 
48
- this._creatorRemoteId = null;
49
- this._creatorId = null;
83
+ get parameters() {
84
+ return this.#parameters;
50
85
  }
51
86
 
52
- _attachClient(remoteId, client, isOwner, filter) {
87
+ [kSharedStatePrivateAttachClient](remoteId, client, isOwner, filter) {
53
88
  const clientInfos = { client, isOwner, filter };
54
- this._attachedClients.set(remoteId, clientInfos);
89
+ this.#attachedClients.set(remoteId, clientInfos);
55
90
 
56
91
  if (isOwner) {
57
- this._creatorRemoteId = remoteId;
58
- this._creatorId = client.id;
92
+ this.#creatorId = client.id;
93
+ this.#creatorRemoteId = remoteId;
59
94
  }
60
95
 
61
96
  // attach client listeners
62
97
  client.transport.addListener(`${UPDATE_REQUEST}-${this.id}-${remoteId}`, async (reqId, updates, context) => {
63
98
  // apply registered hooks
64
- const hooks = this._manager._hooksBySchemaName.get(this.schemaName);
65
- const values = this._parameters.getValues();
99
+ const hooks = this.#manager[kServerStateManagerGetHooks](this.schemaName);
100
+ const values = this.#parameters.getValues();
66
101
  let hookAborted = false;
67
102
 
68
103
  // cf. https://github.com/collective-soundworks/soundworks/issues/45
@@ -84,14 +119,14 @@ class SharedStatePrivate {
84
119
  let hasUpdates = false;
85
120
 
86
121
  for (let name in updates) {
87
- const [newValue, changed] = this._parameters.set(name, updates[name]);
122
+ const [newValue, changed] = this.#parameters.set(name, updates[name]);
88
123
  // if `filterChange` is set to `false` we don't check if the value
89
124
  // has been changed or not, it is always propagated to client states
90
- const { event, filterChange } = this._parameters.getSchema(name);
125
+ const { event, filterChange } = this.#parameters.getSchema(name);
91
126
 
92
127
  // if event type reset internal value to null
93
128
  if (event) {
94
- this._parameters.set(name, null);
129
+ this.#parameters.set(name, null);
95
130
  }
96
131
 
97
132
  if ((filterChange && changed) || !filterChange) {
@@ -130,7 +165,7 @@ class SharedStatePrivate {
130
165
  }
131
166
 
132
167
  // propagate NOTIFICATION to all attached clients except server
133
- for (let [peerRemoteId, clientInfos] of this._attachedClients) {
168
+ for (let [peerRemoteId, clientInfos] of this.#attachedClients) {
134
169
  const peer = clientInfos.client;
135
170
 
136
171
  if (remoteId !== peerRemoteId && peer.id !== -1) {
@@ -157,7 +192,7 @@ class SharedStatePrivate {
157
192
  }
158
193
 
159
194
  // propagate NOTIFICATION to other state attached on the server
160
- for (let [peerRemoteId, clientInfos] of this._attachedClients) {
195
+ for (let [peerRemoteId, clientInfos] of this.#attachedClients) {
161
196
  const peer = clientInfos.client;
162
197
 
163
198
  if (remoteId !== peerRemoteId && peer.id === -1) {
@@ -182,7 +217,7 @@ class SharedStatePrivate {
182
217
  const oldValues = {};
183
218
 
184
219
  for (let name in updates) {
185
- oldValues[name] = this._parameters.get(name);
220
+ oldValues[name] = this.#parameters.get(name);
186
221
  }
187
222
  // aborted by hook (updates have been overriden to {})
188
223
  client.transport.emit(`${UPDATE_ABORT}-${this.id}-${remoteId}`, reqId, oldValues, context);
@@ -192,18 +227,18 @@ class SharedStatePrivate {
192
227
  if (isOwner) {
193
228
  // delete only if creator
194
229
  client.transport.addListener(`${DELETE_REQUEST}-${this.id}-${remoteId}`, (reqId) => {
195
- this._manager._sharedStatePrivateById.delete(this.id);
230
+ this.#manager[kServerStateManagerDeletePrivateState](this.id);
196
231
  // --------------------------------------------------------------------
197
232
  // WARNING - MAKE SURE WE DON'T HAVE PROBLEM W/ THAT
198
233
  // --------------------------------------------------------------------
199
234
  // @todo - propagate server-side last, because if a subscription function sends a
200
235
  // message to a client, network messages order are kept coherent
201
236
  // this._subscriptions.forEach(func => func(updated));
202
- for (let [remoteId, clientInfos] of this._attachedClients) {
237
+ for (let [remoteId, clientInfos] of this.#attachedClients) {
203
238
  const attached = clientInfos.client;
204
- this._detachClient(remoteId, attached);
239
+ this[kSharedStatePrivateDetachClient](remoteId, attached);
205
240
 
206
- if (remoteId === this._creatorRemoteId) {
241
+ if (remoteId === this.#creatorRemoteId) {
207
242
  attached.transport.emit(`${DELETE_RESPONSE}-${this.id}-${remoteId}`, reqId);
208
243
  } else {
209
244
  attached.transport.emit(`${DELETE_NOTIFICATION}-${this.id}-${remoteId}`);
@@ -213,14 +248,14 @@ class SharedStatePrivate {
213
248
  } else {
214
249
  // detach only if not creator
215
250
  client.transport.addListener(`${DETACH_REQUEST}-${this.id}-${remoteId}`, (reqId) => {
216
- this._detachClient(remoteId, client);
251
+ this[kSharedStatePrivateDetachClient](remoteId, client);
217
252
  client.transport.emit(`${DETACH_RESPONSE}-${this.id}-${remoteId}`, reqId);
218
253
  });
219
254
  }
220
255
  }
221
256
 
222
- _detachClient(remoteId, client) {
223
- this._attachedClients.delete(remoteId);
257
+ [kSharedStatePrivateDetachClient](remoteId, client) {
258
+ this.#attachedClients.delete(remoteId);
224
259
  // delete listeners
225
260
  client.transport.removeAllListeners(`${UPDATE_REQUEST}-${this.id}-${remoteId}`);
226
261
  client.transport.removeAllListeners(`${DELETE_REQUEST}-${this.id}-${remoteId}`);