@soundworks/core 4.0.0-alpha.15 → 4.0.0-alpha.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.jsdoc ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "source": {
3
+ "include": [
4
+ "misc/assets/DOC.md",
5
+ "src/client",
6
+ "src/server",
7
+ "src/common"
8
+ ]
9
+ },
10
+ "plugins": [
11
+ "plugins/markdown"
12
+ ],
13
+ "opts": {
14
+ "projectTitle": "Soundworks",
15
+ "destination": "./docs",
16
+ "encoding": "utf8",
17
+ "template": "./node_modules/docdash"
18
+ },
19
+ "templates": {
20
+ "default": {
21
+ "useLongnameInNav": true
22
+ },
23
+ "footer": ""
24
+ },
25
+ "docdash": {
26
+ "sectionOrder": [
27
+ "Namespaces",
28
+ "Classes",
29
+ "Modules",
30
+ "Externals",
31
+ "Events",
32
+ "Mixins",
33
+ "Tutorials",
34
+ "Interfaces"
35
+ ],
36
+ "scripts": [
37
+ "assets/custom.css"
38
+ ],
39
+ "meta": {
40
+ "title": "soundworks",
41
+ "description": "Documentation of soundworks, a full-stack JavaScript framework for distributed WebAudio and multimedia applications"
42
+ },
43
+ "search": true,
44
+ "menu": {
45
+ "Website": {
46
+ "href":"http://soundworks.dev/",
47
+ "target":"_blank",
48
+ "class":"menu-item"
49
+ },
50
+ "Github": {
51
+ "href": "https://github.com/collective-soundworks/soundworks",
52
+ "target":"_blank",
53
+ "class":"menu-item"
54
+ }
55
+ }
56
+ }
57
+ }
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## v4.0.0-alpha.17 - 16/12/2023
2
+
3
+ - Support bundling the server to cjs for Max externals
4
+
5
+ ## v4.0.0-alpha.16 - 15/12/2023
6
+
7
+ - Improve StateCollection performances
8
+
1
9
  ## v4.0.0-alpha.15 - 13/12/2023
2
10
 
3
11
  - Fix several issues with shared states
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soundworks/core",
3
- "version": "4.0.0-alpha.15",
3
+ "version": "4.0.0-alpha.17",
4
4
  "description": "Open-source creative coding framework for distributed applications based on Web technologies",
5
5
  "authors": [
6
6
  "Benjamin Matuszewski",
@@ -43,7 +43,7 @@
43
43
  "node": ">=16.0.0"
44
44
  },
45
45
  "scripts": {
46
- "doc": "rm -Rf docs && jsdoc -c .jsdoc.json --verbose && cp -R misc/assets docs/",
46
+ "doc": "rm -Rf docs && jsdoc -c .jsdoc --verbose && cp -R misc/assets docs/",
47
47
  "lint": "npx eslint src",
48
48
  "test": "npx mocha",
49
49
  "test:all": "npx mocha tests/*/*.spec.js",
@@ -9,6 +9,9 @@ import {
9
9
  CONTEXT_EXIT_ERROR,
10
10
  } from '../common/constants.js';
11
11
 
12
+ // share between all context, as channels are common to all contexts
13
+ const promiseStore = new PromiseStore('Context');
14
+
12
15
  /**
13
16
  * Base class to extend in order to implement a new Context.
14
17
  *
@@ -77,15 +80,12 @@ class Context {
77
80
  */
78
81
  this.status = 'idle';
79
82
 
80
- /** @private */
81
- this._promiseStore = new PromiseStore(this.constructor.name);
82
-
83
83
  this.client.socket.addListener(CONTEXT_ENTER_RESPONSE, (reqId, contextName) => {
84
84
  if (contextName !== this.name) {
85
85
  return;
86
86
  }
87
87
 
88
- this._promiseStore.resolve(reqId);
88
+ promiseStore.resolve(reqId);
89
89
  });
90
90
 
91
91
  this.client.socket.addListener(CONTEXT_ENTER_ERROR, (reqId, contextName, msg) => {
@@ -93,7 +93,7 @@ class Context {
93
93
  return;
94
94
  }
95
95
 
96
- this._promiseStore.reject(reqId, msg);
96
+ promiseStore.reject(reqId, msg);
97
97
  });
98
98
 
99
99
  this.client.socket.addListener(CONTEXT_EXIT_RESPONSE, (reqId, contextName) => {
@@ -101,7 +101,7 @@ class Context {
101
101
  return;
102
102
  }
103
103
 
104
- this._promiseStore.resolve(reqId);
104
+ promiseStore.resolve(reqId);
105
105
  });
106
106
 
107
107
  this.client.socket.addListener(CONTEXT_EXIT_ERROR, (reqId, contextName, msg) => {
@@ -109,7 +109,7 @@ class Context {
109
109
  return;
110
110
  }
111
111
 
112
- this._promiseStore.reject(reqId, msg);
112
+ promiseStore.reject(reqId, msg);
113
113
  });
114
114
 
115
115
  this.client.contextManager.register(this);
@@ -205,7 +205,7 @@ class Context {
205
205
  // we need the try/catch block to change the promise rejection into proper error
206
206
  try {
207
207
  await new Promise((resolve, reject) => {
208
- const reqId = this._promiseStore.add(resolve, reject, 'enter-context');
208
+ const reqId = promiseStore.add(resolve, reject, 'enter-context');
209
209
  this.client.socket.send(CONTEXT_ENTER_REQUEST, reqId, this.name);
210
210
  });
211
211
  } catch (err) {
@@ -239,7 +239,7 @@ class Context {
239
239
  // we need the try/catch block to change the promise rejection into proper error
240
240
  try {
241
241
  await new Promise((resolve, reject) => {
242
- const reqId = this._promiseStore.add(resolve, reject, 'exit-context');
242
+ const reqId = promiseStore.add(resolve, reject, 'exit-context');
243
243
  this.client.socket.send(CONTEXT_EXIT_REQUEST, reqId, this.name);
244
244
  });
245
245
  } catch (err) {
@@ -15,6 +15,14 @@ import {
15
15
  UPDATE_NOTIFICATION,
16
16
  } from './constants.js';
17
17
 
18
+ // @todo - replace isOwner by `type`:
19
+ // enum {
20
+ // 'owner'
21
+ // 'attached'
22
+ // 'collection-controlller'
23
+ // 'collection-attached'
24
+ // }
25
+
18
26
  class BaseSharedState {
19
27
  constructor(id, remoteId, schemaName, schema, client, isOwner, manager, initValues = {}) {
20
28
  /** @private */
@@ -312,6 +320,10 @@ ${JSON.stringify(initValues, null, 2)}`);
312
320
  * const updates = await state.set({ myParam: Math.random() });
313
321
  */
314
322
  async set(updates, context = null) {
323
+ if (this._detached) {
324
+ return;
325
+ }
326
+
315
327
  if (!isPlainObject(updates)) {
316
328
  throw new ReferenceError(`[SharedState] State "${this.schemaName}": state.set(updates[, context]) should receive an object as first parameter`);
317
329
  }
@@ -429,12 +441,12 @@ ${JSON.stringify(initValues, null, 2)}`);
429
441
  }
430
442
 
431
443
  /**
432
- * Get the schema from which the state has been created.
444
+ * Definition of schema from which the state has been created.
433
445
  *
434
- * @param {string} [name=null] - If given, returns only the definition corresponding
435
- * to the given param name.
446
+ * @param {string} [name=null] - If given, returns only the definition
447
+ * corresponding to the given param name.
436
448
  * @throws Throws if `name` does not correspond to an existing field
437
- * of the state.
449
+ * of the schema.
438
450
  * @return {object}
439
451
  * @example
440
452
  * const schema = state.getSchema();
@@ -1,12 +1,16 @@
1
- /**
2
- * @private
3
- */
1
+ import {
2
+ kCreateCollectionController,
3
+ kAttachInCollection,
4
+ } from './BaseStateManager.js';
5
+
6
+ /** @private */
4
7
  class BaseSharedStateCollection {
5
8
  constructor(stateManager, schemaName, options = {}) {
6
9
  this._stateManager = stateManager;
7
10
  this._schemaName = schemaName;
8
11
  this._options = Object.assign({ excludeLocal: false }, options);
9
12
 
13
+ this._controller = null;
10
14
  this._states = [];
11
15
 
12
16
  this._onUpdateCallbacks = new Set();
@@ -15,10 +19,17 @@ class BaseSharedStateCollection {
15
19
  this._unobserve = null;
16
20
  }
17
21
 
22
+ /** @private */
18
23
  async _init() {
19
- this._unobserve = await this._stateManager.observe(this._schemaName, async (schemaName, stateId) => {
20
- const state = await this._stateManager.attach(schemaName, stateId);
24
+ this._controller = await this._stateManager[kCreateCollectionController](this._schemaName);
25
+ this._controller.onUpdate(async (updates, context) => {
26
+ for (let state of this._states) {
27
+ await state._commit(updates, context, true, false);
28
+ }
29
+ });
21
30
 
31
+ this._unobserve = await this._stateManager.observe(this._schemaName, async (schemaName, stateId) => {
32
+ const state = await this._stateManager[kAttachInCollection](schemaName, stateId);
22
33
  this._states.push(state);
23
34
 
24
35
  state.onDetach(() => {
@@ -55,17 +66,38 @@ class BaseSharedStateCollection {
55
66
  }
56
67
 
57
68
  /**
58
- * Detach from the collection, i.e. detach all underlying shared states.
59
- * @type {number}
69
+ * Name of the schema from which the collection has been created.
70
+ * @type {String}
71
+ * @readonly
60
72
  */
61
- async detach() {
62
- this._unobserve();
63
- this._onUpdateCallbacks.clear();
73
+ get schemaName() {
74
+ return this._controller.schemaName;
75
+ }
64
76
 
65
- const promises = this._states.map(state => state.detach());
66
- await Promise.all(promises);
77
+ /**
78
+ * Definition of schema from which the collection has been created.
79
+ *
80
+ * @param {string} [name=null] - If given, returns only the definition
81
+ * corresponding to the given param name.
82
+ * @throws Throws if `name` does not correspond to an existing field
83
+ * of the schema.
84
+ * @return {object}
85
+ * @example
86
+ * const schema = collection.getSchema();
87
+ */
88
+ getSchema(name = null) {
89
+ return this._controller.getSchema(name);
90
+ }
67
91
 
68
- this._onDetachCallbacks.clear();
92
+ /**
93
+ * Get the default values as declared in the schema.
94
+ *
95
+ * @return {object}
96
+ * @example
97
+ * const defaults = state.getDefaults();
98
+ */
99
+ getDefaults() {
100
+ return this._controller.getDefaults();
69
101
  }
70
102
 
71
103
  /**
@@ -94,8 +126,7 @@ class BaseSharedStateCollection {
94
126
  * current call and will be passed as third argument to all update listeners.
95
127
  */
96
128
  async set(updates, context = null) {
97
- const promises = this._states.map(state => state.set(updates, context));
98
- return Promise.all(promises);
129
+ return await this._controller.set(updates, context);
99
130
  }
100
131
 
101
132
  /**
@@ -156,6 +187,22 @@ class BaseSharedStateCollection {
156
187
  return () => this._onDetachCallbacks.delete(callback);
157
188
  }
158
189
 
190
+ /**
191
+ * Detach from the collection, i.e. detach all underlying shared states.
192
+ * @type {number}
193
+ */
194
+ async detach() {
195
+ this._unobserve();
196
+ this._onUpdateCallbacks.clear();
197
+
198
+ await this._controller.delete();
199
+
200
+ const promises = this._states.map(state => state.detach());
201
+ await Promise.all(promises);
202
+
203
+ this._onDetachCallbacks.clear();
204
+ }
205
+
159
206
  /**
160
207
  * Execute the given function once for each states of the collection (see `Array.forEach`).
161
208
  *
@@ -17,6 +17,9 @@ import {
17
17
  DELETE_SCHEMA,
18
18
  } from './constants.js';
19
19
 
20
+ export const kCreateCollectionController = Symbol('BaseStateManager::createCollectionController');
21
+ export const kAttachInCollection = Symbol('BaseStateManager::attachInCollection');
22
+
20
23
  /**
21
24
  * @private
22
25
  */
@@ -176,12 +179,24 @@ class BaseStateManager {
176
179
  */
177
180
  async create(schemaName, initValues = {}) {
178
181
  return new Promise((resolve, reject) => {
179
- const reqId = this._promiseStore.add(resolve, reject, 'create-create');
182
+ const reqId = this._promiseStore.add(resolve, reject, 'create-request');
180
183
  const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
181
184
  this.client.transport.emit(CREATE_REQUEST, reqId, schemaName, requireSchema, initValues);
182
185
  });
183
186
  }
184
187
 
188
+
189
+ /**
190
+ * @private
191
+ */
192
+ async [kCreateCollectionController](schemaName) {
193
+ return new Promise((resolve, reject) => {
194
+ const reqId = this._promiseStore.add(resolve, reject, 'create-collection-controller-request');
195
+ const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
196
+ this.client.transport.emit(CREATE_REQUEST, reqId, schemaName, requireSchema, {}, true);
197
+ });
198
+ }
199
+
185
200
  /**
186
201
  * Attach to an existing `SharedState` instance.
187
202
  *
@@ -198,13 +213,20 @@ class BaseStateManager {
198
213
  */
199
214
  async attach(schemaName, stateId = null) {
200
215
  return new Promise((resolve, reject) => {
201
- // @todo - add a timeout
202
216
  const reqId = this._promiseStore.add(resolve, reject, 'attach-request');
203
217
  const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
204
218
  this.client.transport.emit(ATTACH_REQUEST, reqId, schemaName, stateId, requireSchema);
205
219
  });
206
220
  }
207
221
 
222
+ async [kAttachInCollection](schemaName, stateId) {
223
+ return new Promise((resolve, reject) => {
224
+ const reqId = this._promiseStore.add(resolve, reject, 'attach-in-collection-request');
225
+ const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
226
+ this.client.transport.emit(ATTACH_REQUEST, reqId, schemaName, stateId, requireSchema, true);
227
+ });
228
+ }
229
+
208
230
  /**
209
231
  * Observe all the `SharedState` instances that are created on the network.
210
232
  * This can be usefull for clients with some controller role that might want to track
@@ -366,6 +388,7 @@ class BaseStateManager {
366
388
  try {
367
389
  await collection._init();
368
390
  } catch (err) {
391
+ console.log(err.message)
369
392
  throw new Error(`Cannot create collection, schema "${schemaName}" does not exists`);
370
393
  }
371
394
 
@@ -1,32 +1,39 @@
1
1
  import { idGenerator } from '@ircam/sc-utils';
2
2
 
3
- const generateRequestId = idGenerator();
4
-
5
3
  export default class PromiseStore {
6
4
  constructor(name) {
7
5
  this.name = name;
8
6
  this.store = new Map();
7
+ this.generateRequestId = idGenerator();
9
8
  }
10
9
 
11
10
  add(resolve, reject, type) {
12
- const reqId = generateRequestId.next().value;
11
+ const reqId = this.generateRequestId.next().value;
13
12
  this.store.set(reqId, { resolve, reject, type });
14
13
 
15
14
  return reqId;
16
15
  }
17
16
 
18
17
  resolve(reqId, data) {
19
- const { resolve } = this.store.get(reqId);
20
- this.store.delete(reqId);
18
+ if (this.store.has(reqId)) {
19
+ const { resolve } = this.store.get(reqId);
20
+ this.store.delete(reqId);
21
21
 
22
- resolve(data);
22
+ resolve(data);
23
+ } else {
24
+ throw new Error(`[${this.name}] cannot resolve request id (${reqId}), id does not exist`);
25
+ }
23
26
  }
24
27
 
25
28
  reject(reqId, msg) {
26
- const { reject } = this.store.get(reqId);
27
- this.store.delete(reqId);
29
+ if (this.store.has(reqId)) {
30
+ const { reject } = this.store.get(reqId);
31
+ this.store.delete(reqId);
28
32
 
29
- reject(new Error(msg));
33
+ reject(new Error(msg));
34
+ } else {
35
+ throw new Error(`[${this.name}] cannot reject request id (${reqId}), id does not exist`);
36
+ }
30
37
  }
31
38
 
32
39
  // reject all pendeing request
@@ -13,13 +13,14 @@ import {
13
13
 
14
14
  /**
15
15
  * The "real" state, this instance is kept private by the server.StateManager.
16
- * It cannot be accessed without a SharedState proxy
16
+ * It can only be accessed through a SharedState proxy.
17
17
  * @private
18
18
  */
19
19
  class SharedStatePrivate {
20
20
  constructor(id, schemaName, schema, manager, initValues = {}) {
21
21
  this.id = id;
22
22
  this.schemaName = schemaName;
23
+ this.isCollectionController = false;
23
24
 
24
25
  this._manager = manager;
25
26
  this._parameters = new ParameterBag(schema, initValues);
@@ -29,10 +30,16 @@ class SharedStatePrivate {
29
30
  this._creatorId = null;
30
31
  }
31
32
 
32
- _attachClient(remoteId, client, isOwner = false) {
33
- this._attachedClients.set(remoteId, client);
33
+ _attachClient(remoteId, client, isOwner = false, inCollection = false) {
34
+
35
+ // isOwner && inCollection -> collection controller
36
+ // !isOwner && inCollection -> attached in collection
37
+
38
+ const clientInfos = { client, isOwner, inCollection };
39
+ this._attachedClients.set(remoteId, clientInfos);
34
40
 
35
41
  if (isOwner) {
42
+ this.isCollectionController = inCollection;
36
43
  this._creatorRemoteId = remoteId;
37
44
  this._creatorId = client.id;
38
45
  }
@@ -89,44 +96,97 @@ class SharedStatePrivate {
89
96
  }
90
97
 
91
98
  if (hasUpdates) {
92
- // We need to handle cases where:
93
- // - client state (client.id: 2) sends a request
94
- // - server attached state (client.id: -1) spot a problem and overrides the value
95
- // We want the remote client (id: 2) to receive in the right order:
96
- // * 1. the value it requested,
97
- // * 2. the value overriden by the server-side attached state (id: -1)
98
- //
99
- // such problem is now better solved with the the upateHook system, none
100
- // nonetheway we don't want to introduce inconsistencies here
101
- //
102
- // Then we propagate server-side last, because as the server transport
103
- // is synchronous it can break ordering if a subscription function makes
104
- // itself an update in reaction to an update. Propagating to server last
105
- // alllows to maintain network messages order consistent.
106
-
107
- // @note - remoteId correspond to unique remote state id
108
-
109
- // propagate RESPONSE to the client that originates the request if not the server
110
- if (client.id !== -1) {
111
- client.transport.emit(`${UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates, context);
112
- }
99
+ // Collection Controller logic
100
+ if (isOwner && inCollection) {
101
+ // notify the requester back
102
+ // no need to check for attached clients as it is not observable
103
+ client.transport.emit(
104
+ `${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
105
+ reqId, filteredUpdates, context
106
+ );
107
+
108
+ // loop through all private states
109
+ for (let [stateId, state] of this._manager._sharedStatePrivateById) {
110
+ // pick all states with same schema name and not this
111
+ if (state !== this && this.schemaName === state.schemaName) {
112
+ // notify all attached clients except those who belong to a collection
113
+ // i.e. !isOwner && inCollection, they will be notified by their own
114
+ // collection controller
115
+ for (let [remoteId, clientInfos] of state._attachedClients) {
116
+ const { client, isOwner, inCollection } = clientInfos;
117
+
118
+ if (!clientInfos.isOwner && clientInfos.inCollection) {
119
+ continue;
120
+ } else {
121
+ const peer = clientInfos.client;
122
+
123
+ peer.transport.emit(
124
+ `${UPDATE_NOTIFICATION}-${state.id}-${remoteId}`,
125
+ filteredUpdates, context
126
+ );
127
+ }
128
+ }
129
+ }
130
+ }
131
+ } else {
113
132
 
114
- // propagate NOTIFICATION to all other attached clients except server
115
- for (let [peerRemoteId, peer] of this._attachedClients) {
116
- if (remoteId !== peerRemoteId && peer.id !== -1) {
117
- peer.transport.emit(`${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`, filteredUpdates, context);
133
+ // Normal case
134
+
135
+ // We need to handle cases where:
136
+ // - client state (client.id: 2) sends a request
137
+ // - server attached state (client.id: -1) spot a problem and overrides the value
138
+ // We want the remote client (id: 2) to receive in the right order:
139
+ // * 1. the value it requested,
140
+ // * 2. the value overriden by the server-side attached state (id: -1)
141
+ //
142
+ // such problem is now better solved with the the upateHook system, none
143
+ // nonetheway we don't want to introduce inconsistencies here
144
+ //
145
+ // Then we propagate server-side last, because as the server transport
146
+ // is synchronous it can break ordering if a subscription function makes
147
+ // itself an update in reaction to an update. Propagating to server last
148
+ // alllows to maintain network messages order consistent.
149
+
150
+ // @note - remoteId correspond to unique remote state id
151
+
152
+ // propagate RESPONSE to the client that originates the request if not the server
153
+ if (client.id !== -1) {
154
+ client.transport.emit(
155
+ `${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
156
+ reqId, filteredUpdates, context
157
+ );
118
158
  }
119
- }
120
159
 
121
- // propagate RESPONSE to server if it is the requester
122
- if (client.id === -1) {
123
- client.transport.emit(`${UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates, context);
124
- }
160
+ // propagate NOTIFICATION to all other attached clients except server
161
+ for (let [peerRemoteId, clientInfos] of this._attachedClients) {
162
+ const peer = clientInfos.client;
163
+
164
+ if (remoteId !== peerRemoteId && peer.id !== -1) {
165
+ peer.transport.emit(
166
+ `${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
167
+ filteredUpdates, context
168
+ );
169
+ }
170
+ }
171
+
172
+ // propagate RESPONSE to server if it is the requester
173
+ if (client.id === -1) {
174
+ client.transport.emit(
175
+ `${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
176
+ reqId, filteredUpdates, context
177
+ );
178
+ }
179
+
180
+ // propagate NOTIFICATION other attached state that belongs to server
181
+ for (let [peerRemoteId, clientInfos] of this._attachedClients) {
182
+ const peer = clientInfos.client;
125
183
 
126
- // propagate NOTIFICATION other attached state that belongs to server
127
- for (let [peerRemoteId, peer] of this._attachedClients) {
128
- if (remoteId !== peerRemoteId && peer.id === -1) {
129
- peer.transport.emit(`${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`, filteredUpdates, context);
184
+ if (remoteId !== peerRemoteId && peer.id === -1) {
185
+ peer.transport.emit(
186
+ `${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
187
+ filteredUpdates, context
188
+ );
189
+ }
130
190
  }
131
191
  }
132
192
  } else {
@@ -149,14 +209,15 @@ class SharedStatePrivate {
149
209
  if (isOwner) {
150
210
  // delete only if creator
151
211
  client.transport.addListener(`${DELETE_REQUEST}-${this.id}-${remoteId}`, (reqId) => {
152
- this._manager._serverStatesById.delete(this.id);
212
+ this._manager._sharedStatePrivateById.delete(this.id);
153
213
  // --------------------------------------------------------------------
154
214
  // WARNING - MAKE SURE WE DON'T HAVE PROBLEM W/ THAT
155
215
  // --------------------------------------------------------------------
156
216
  // @todo - propagate server-side last, because if a subscription function sends a
157
217
  // message to a client, network messages order are kept coherent
158
218
  // this._subscriptions.forEach(func => func(updated));
159
- for (let [remoteId, attached] of this._attachedClients.entries()) {
219
+ for (let [remoteId, clientInfos] of this._attachedClients) {
220
+ const attached = clientInfos.client;
160
221
  this._detachClient(remoteId, attached);
161
222
 
162
223
  if (remoteId === this._creatorRemoteId) {
@@ -1,14 +1,17 @@
1
1
  import { Worker } from 'node:worker_threads';
2
2
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
3
+ // import { fileURLToPath } from 'node:url';
4
4
 
5
5
  import querystring from 'querystring';
6
6
  import { WebSocketServer } from 'ws';
7
7
  import WebSocket from 'ws';
8
8
 
9
9
  import Socket from './Socket.js';
10
+ import networkLatencyWorker from './audit-network-latency.worker.js';
10
11
 
11
- const __dirname = fileURLToPath(new URL('.', import.meta.url));
12
+ // this crashes when bundling server to cjs module for Max, fallback to
13
+ // string worker for now
14
+ // const __dirname = fileURLToPath(new URL('.', import.meta.url));
12
15
 
13
16
  /**
14
17
  * Manager all {@link server.Socket} instances.
@@ -45,8 +48,9 @@ class Sockets {
45
48
  */
46
49
  async start(server, config, onConnectionCallback) {
47
50
  // init audit stuff for network latency estimation
48
- const workerPathname = path.join(__dirname, 'audit-network-latency.worker.js');
49
- this._latencyStatsWorker = new Worker(workerPathname);
51
+ // @see note about __dirname
52
+ // const workerPathname = path.join(__dirname, 'audit-network-latency.worker.js');
53
+ this._latencyStatsWorker = new Worker(networkLatencyWorker, { eval: true });
50
54
  this._auditState = await server.getAuditState();
51
55
 
52
56
  this._auditState.onUpdate(updates => {
@@ -28,6 +28,8 @@ import {
28
28
  const generateStateId = idGenerator();
29
29
  const generateRemoteId = idGenerator();
30
30
 
31
+ const kIsObservableState = Symbol('StateManager::isObservableState');
32
+
31
33
  /**
32
34
  * @typedef {object} server.StateManager~schema
33
35
  *
@@ -315,7 +317,7 @@ class StateManager extends BaseStateManager {
315
317
  super(localClientId, localTransport);
316
318
 
317
319
  this._clientByNodeId = new Map();
318
- this._serverStatesById = new Map();
320
+ this._sharedStatePrivateById = new Map();
319
321
  this._schemas = new Map();
320
322
  this._observers = new Set();
321
323
  this._hooksBySchemaName = new Map(); // protected
@@ -323,6 +325,12 @@ class StateManager extends BaseStateManager {
323
325
  this.addClient(localClientId, localTransport);
324
326
  }
325
327
 
328
+ [kIsObservableState](state) {
329
+ const { schemaName, isCollectionController } = state;
330
+ // is observable only if not in private state and not a controller state
331
+ return !PRIVATE_STATES.includes(schemaName) && !isCollectionController;
332
+ }
333
+
326
334
  /**
327
335
  * Add a client to the manager.
328
336
  *
@@ -342,90 +350,107 @@ class StateManager extends BaseStateManager {
342
350
  // ---------------------------------------------
343
351
  // CREATE
344
352
  // ---------------------------------------------
345
- client.transport.addListener(CREATE_REQUEST, (reqId, schemaName, requireSchema, initValues = {}) => {
346
- if (this._schemas.has(schemaName)) {
347
- try {
348
- const schema = this._schemas.get(schemaName);
349
- const stateId = generateStateId.next().value;
350
- const remoteId = generateRemoteId.next().value;
351
- // id, schemaName, schema, manager, initValues = {}
352
- const state = new SharedStatePrivate(stateId, schemaName, schema, this, initValues);
353
-
354
- state._attachClient(remoteId, client, true); // attach client to the state
355
- this._serverStatesById.set(stateId, state);
356
-
357
- const currentValues = state._parameters.getValues();
358
- const sendedSchema = requireSchema ? schema : null;
359
-
360
- client.transport.emit(CREATE_RESPONSE, reqId, stateId, remoteId, schemaName, sendedSchema, currentValues);
361
-
362
- this._observers.forEach(observer => {
363
- // prevent propagating infos about internal states
364
- if (!PRIVATE_STATES.includes(schemaName)) {
365
- observer.transport.emit(OBSERVE_NOTIFICATION, schemaName, stateId, nodeId);
353
+ client.transport.addListener(
354
+ CREATE_REQUEST,
355
+ (reqId, schemaName, requireSchema, initValues = {}, inCollection = false) => {
356
+ if (this._schemas.has(schemaName)) {
357
+ try {
358
+ const schema = this._schemas.get(schemaName);
359
+ const stateId = generateStateId.next().value;
360
+ const remoteId = generateRemoteId.next().value;
361
+ const state = new SharedStatePrivate(stateId, schemaName, schema, this, initValues);
362
+
363
+ // attach client to the state as owner
364
+ const isOwner = true;
365
+ state._attachClient(remoteId, client, isOwner, inCollection);
366
+
367
+ this._sharedStatePrivateById.set(stateId, state);
368
+
369
+ const currentValues = state._parameters.getValues();
370
+ const schemaOption = requireSchema ? schema : null;
371
+
372
+ client.transport.emit(
373
+ CREATE_RESPONSE,
374
+ reqId, stateId, remoteId, schemaName, schemaOption, currentValues
375
+ );
376
+
377
+ const isObservable = this[kIsObservableState](state);
378
+
379
+ if (isObservable) {
380
+ this._observers.forEach(observer => {
381
+ observer.transport.emit(OBSERVE_NOTIFICATION, schemaName, stateId, nodeId);
382
+ });
366
383
  }
367
- });
368
- } catch (err) {
369
- const msg = `Cannot create state "${schemaName}", ${err.message}`;
384
+ } catch (err) {
385
+ const msg = `Cannot create state "${schemaName}", ${err.message}`;
386
+ console.error(msg);
387
+
388
+ client.transport.emit(CREATE_ERROR, reqId, msg);
389
+ }
390
+ } else {
391
+ const msg = `Cannot create state "${schemaName}", schema does not exists`;
370
392
  console.error(msg);
371
393
 
372
394
  client.transport.emit(CREATE_ERROR, reqId, msg);
373
395
  }
374
- } else {
375
- const msg = `Cannot create state "${schemaName}", schema does not exists`;
376
- console.error(msg);
377
-
378
- client.transport.emit(CREATE_ERROR, reqId, msg);
379
396
  }
380
- });
397
+ );
381
398
 
382
399
  // ---------------------------------------------
383
400
  // ATTACH (when creator, is attached by default)
384
401
  // ---------------------------------------------
385
- client.transport.addListener(ATTACH_REQUEST, (reqId, schemaName, stateId = null, requireSchema = true) => {
386
- if (this._schemas.has(schemaName)) {
387
- let state = null;
388
-
389
- if (stateId !== null && this._serverStatesById.has(stateId)) {
390
- state = this._serverStatesById.get(stateId);
391
- } else if (stateId === null) {
392
- // if no `stateId` given, we try to find the first state with the given
393
- // `schemaName` in the list, this allow a client to attach to a global
394
- // state created by the server (or some persistant client) without
395
- // having to know the `stateId` (e.g. some global state...)
396
- for (let existingState of this._serverStatesById.values()) {
397
- if (existingState.schemaName === schemaName) {
398
- state = existingState;
399
- break;
402
+ client.transport.addListener(
403
+ ATTACH_REQUEST,
404
+ (reqId, schemaName, stateId = null, requireSchema = true, inCollection = false) => {
405
+ if (this._schemas.has(schemaName)) {
406
+ let state = null;
407
+
408
+ if (stateId !== null && this._sharedStatePrivateById.has(stateId)) {
409
+ state = this._sharedStatePrivateById.get(stateId);
410
+ } else if (stateId === null) {
411
+ // if no `stateId` given, we try to find the first state with the given
412
+ // `schemaName` in the list, this allow a client to attach to a global
413
+ // state created by the server (or some persistant client) without
414
+ // having to know the `stateId` (e.g. some global state...)
415
+ for (let existingState of this._sharedStatePrivateById.values()) {
416
+ if (existingState.schemaName === schemaName) {
417
+ state = existingState;
418
+ break;
419
+ }
400
420
  }
401
421
  }
402
- }
403
422
 
404
- if (state !== null) {
405
- // @note - we use a unique remote id to allow a client to attach
406
- // several times to the same state.
407
- // i.e. same state -> several remote attach on the same node
408
- const remoteId = generateRemoteId.next().value;
409
- const schema = this._schemas.get(schemaName);
410
- const currentValues = state._parameters.getValues();
411
-
412
- state._attachClient(remoteId, client, false);
413
- // send schema for client-side instanciation
414
- const sendedSchema = requireSchema ? schema : null;
415
- client.transport.emit(ATTACH_RESPONSE, reqId, state.id, remoteId, schemaName, sendedSchema, currentValues);
423
+ if (state !== null) {
424
+ // @note - we use a unique remote id to allow a client to attach
425
+ // several times to the same state.
426
+ // i.e. same state -> several remote attach on the same node
427
+ const remoteId = generateRemoteId.next().value;
428
+ const isOwner = false;
429
+ state._attachClient(remoteId, client, isOwner, inCollection);
430
+
431
+ const currentValues = state._parameters.getValues();
432
+ const schema = this._schemas.get(schemaName);
433
+ const schemaOption = requireSchema ? schema : null;
434
+
435
+ client.transport.emit(
436
+ ATTACH_RESPONSE,
437
+ reqId, state.id, remoteId, schemaName, schemaOption, currentValues
438
+ );
439
+
440
+ } else {
441
+ const msg = `Cannot attach, no existing state for schema "${schemaName}" with stateId: "${stateId}"`;
442
+ console.error(msg);
443
+
444
+ client.transport.emit(ATTACH_ERROR, reqId, msg);
445
+ }
416
446
  } else {
417
- const msg = `Cannot attach, no existing state for schema "${schemaName}" with stateId: "${stateId}"`;
447
+ const msg = `Cannot attach, schema "${schemaName}" does not exists`;
418
448
  console.error(msg);
419
449
 
420
450
  client.transport.emit(ATTACH_ERROR, reqId, msg);
421
451
  }
422
- } else {
423
- const msg = `Cannot attach, schema "${schemaName}" does not exists`;
424
- console.error(msg);
425
-
426
- client.transport.emit(ATTACH_ERROR, reqId, msg);
427
452
  }
428
- });
453
+ );
429
454
 
430
455
  // ---------------------------------------------
431
456
  // OBSERVE PEERS (be notified when a state is created, lazy)
@@ -434,11 +459,11 @@ class StateManager extends BaseStateManager {
434
459
  if (observedSchemaName === null || this._schemas.has(observedSchemaName)) {
435
460
  const statesInfos = [];
436
461
 
437
- this._serverStatesById.forEach(state => {
438
- const { schemaName, id, _creatorId } = state;
462
+ this._sharedStatePrivateById.forEach(state => {
463
+ const isObservable = this[kIsObservableState](state);
439
464
 
440
- // prevent propagating infos about internal states
441
- if (!PRIVATE_STATES.includes(schemaName)) {
465
+ if (isObservable) {
466
+ const { schemaName, id, _creatorId } = state;
442
467
  statesInfos.push([schemaName, id, _creatorId]);
443
468
  }
444
469
  });
@@ -470,18 +495,22 @@ class StateManager extends BaseStateManager {
470
495
  * @private
471
496
  */
472
497
  removeClient(nodeId) {
473
- for (let [_id, state] of this._serverStatesById.entries()) {
498
+ for (let [_id, state] of this._sharedStatePrivateById.entries()) {
474
499
  let deleteState = false;
475
500
 
476
501
  // define if the client is the creator of the state, in which case
477
502
  // everybody must delete it
478
- for (let [remoteId, attachedClient] of state._attachedClients.entries()) {
503
+ for (let [remoteId, clientInfos] of state._attachedClients) {
504
+ const attachedClient = clientInfos.client;
505
+
479
506
  if (nodeId === attachedClient.id && remoteId === state._creatorRemoteId) {
480
507
  deleteState = true;
481
508
  }
482
509
  }
483
510
 
484
- for (let [remoteId, attachedClient] of state._attachedClients.entries()) {
511
+ for (let [remoteId, clientInfos] of state._attachedClients) {
512
+ const attachedClient = clientInfos.client;
513
+
485
514
  if (nodeId === attachedClient.id) {
486
515
  state._detachClient(remoteId, attachedClient);
487
516
  }
@@ -492,7 +521,7 @@ class StateManager extends BaseStateManager {
492
521
  attachedClient.transport.emit(`${DELETE_NOTIFICATION}-${state.id}-${remoteId}`);
493
522
  }
494
523
 
495
- this._serverStatesById.delete(state.id);
524
+ this._sharedStatePrivateById.delete(state.id);
496
525
  }
497
526
  }
498
527
  }
@@ -559,14 +588,15 @@ class StateManager extends BaseStateManager {
559
588
  */
560
589
  deleteSchema(schemaName) {
561
590
  // @note: deleting schema
562
- for (let [_id, state] of this._serverStatesById.entries()) {
591
+ for (let [_id, state] of this._sharedStatePrivateById) {
563
592
  if (state.schemaName === schemaName) {
564
- for (let [remoteId, attached] of state._attachedClients.entries()) {
593
+ for (let [remoteId, clientInfos] of state._attachedClients) {
594
+ const attached = clientInfos.client;
565
595
  state._detachClient(remoteId, attached);
566
596
  attached.transport.emit(`${DELETE_NOTIFICATION}-${state.id}-${remoteId}`);
567
597
  }
568
598
 
569
- this._serverStatesById.delete(state.id);
599
+ this._sharedStatePrivateById.delete(state.id);
570
600
  }
571
601
  }
572
602
 
@@ -1,6 +1,8 @@
1
- import { parentPort } from 'node:worker_threads';
2
-
3
- import { getTime } from '@ircam/sc-utils';
1
+ // NOTICE: we use this syntax so that the server can be bundled to cjs
2
+ // with esbuild, so we can ship a cjs server bundle into Max.
3
+ // Should move back to regular esm module once Max has fixed its loader
4
+ export default `
5
+ const { parentPort } = require('node:worker_threads');
4
6
 
5
7
  let stack = [];
6
8
  let averageLatencyWindow = 5;
@@ -9,12 +11,21 @@ let averageLatencyPeriod = 2;
9
11
  let intervalId = null;
10
12
  let meanLatency = 0;
11
13
 
12
- parentPort.on('message', msg => {
14
+ let getTime;
15
+ let inited = new Promise(async (resolve) => {
16
+ module = await import('@ircam/sc-utils');
17
+ getTime = module.getTime;
18
+ resolve(true);
19
+ });
20
+
21
+ parentPort.on('message', async msg => {
13
22
  switch (msg.type) {
14
23
  case 'config': {
15
24
  averageLatencyWindow = msg.value.averageLatencyWindow;
16
25
  averageLatencyPeriod = msg.value.averageLatencyPeriod;
17
26
 
27
+ await inited;
28
+
18
29
  // launch compute in its own loop so that the number of computation is
19
30
  // decoupled from the number of connected clients
20
31
  clearInterval(intervalId);
@@ -55,3 +66,4 @@ function computeAverageLatency() {
55
66
  });
56
67
  }
57
68
  }
69
+ `;