@soundworks/core 4.0.0-alpha.16 → 4.0.0-alpha.18

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/CHANGELOG.md CHANGED
@@ -1,6 +1,10 @@
1
+ ## v4.0.0-alpha.17 - 16/12/2023
2
+
3
+ - Support bundling the server to cjs for Max externals
4
+
1
5
  ## v4.0.0-alpha.16 - 15/12/2023
2
6
 
3
- - improve StateCollection performances
7
+ - Improve StateCollection performances
4
8
 
5
9
  ## v4.0.0-alpha.15 - 13/12/2023
6
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soundworks/core",
3
- "version": "4.0.0-alpha.16",
3
+ "version": "4.0.0-alpha.18",
4
4
  "description": "Open-source creative coding framework for distributed applications based on Web technologies",
5
5
  "authors": [
6
6
  "Benjamin Matuszewski",
@@ -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) {
@@ -223,9 +223,16 @@ ${JSON.stringify(initValues, null, 2)}`);
223
223
  // remove listeners
224
224
  this._client.transport.removeAllListeners(`${UPDATE_RESPONSE}-${this.id}-${this.remoteId}`);
225
225
  this._client.transport.removeAllListeners(`${UPDATE_NOTIFICATION}-${this.id}-${this.remoteId}`);
226
- this._client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`);
226
+ this._client.transport.removeAllListeners(`${UPDATE_ABORT}-${this.id}-${this.remoteId}`);
227
227
  this._client.transport.removeAllListeners(`${DELETE_NOTIFICATION}-${this.id}-${this.remoteId}`);
228
- this._client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`);
228
+
229
+ if (this._isOwner) {
230
+ this._client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`);
231
+ this._client.transport.removeAllListeners(`${DELETE_ERROR}-${this.id}-${this.remoteId}`);
232
+ } else {
233
+ this._client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`);
234
+ this._client.transport.removeAllListeners(`${DETACH_ERROR}-${this.id}-${this.remoteId}`);
235
+ }
229
236
  }
230
237
 
231
238
  /** @private */
@@ -320,6 +327,10 @@ ${JSON.stringify(initValues, null, 2)}`);
320
327
  * const updates = await state.set({ myParam: Math.random() });
321
328
  */
322
329
  async set(updates, context = null) {
330
+ if (this._detached) {
331
+ return;
332
+ }
333
+
323
334
  if (!isPlainObject(updates)) {
324
335
  throw new ReferenceError(`[SharedState] State "${this.schemaName}": state.set(updates[, context]) should receive an object as first parameter`);
325
336
  }
@@ -3,9 +3,7 @@ import {
3
3
  kAttachInCollection,
4
4
  } from './BaseStateManager.js';
5
5
 
6
- /**
7
- * @private
8
- */
6
+ /** @private */
9
7
  class BaseSharedStateCollection {
10
8
  constructor(stateManager, schemaName, options = {}) {
11
9
  this._stateManager = stateManager;
@@ -21,6 +19,7 @@ class BaseSharedStateCollection {
21
19
  this._unobserve = null;
22
20
  }
23
21
 
22
+ /** @private */
24
23
  async _init() {
25
24
  this._controller = await this._stateManager[kCreateCollectionController](this._schemaName);
26
25
  this._controller.onUpdate(async (updates, context) => {
@@ -1,6 +1,7 @@
1
1
  import { isString, isFunction, isPlainObject } from '@ircam/sc-utils';
2
2
  import SharedState from './BaseSharedState.js';
3
3
  import SharedStateCollection from './BaseSharedStateCollection.js';
4
+ import BatchedTransport from './BatchedTransport.js';
4
5
  import PromiseStore from './PromiseStore.js';
5
6
  import {
6
7
  CREATE_REQUEST,
@@ -30,7 +31,8 @@ class BaseStateManager {
30
31
  * Must implement a basic EventEmitter API.
31
32
  */
32
33
  constructor(id, transport) {
33
- this.client = { id, transport };
34
+ // proxy transport with BatchedTransport;
35
+ this.client = { id, transport: new BatchedTransport(transport) };
34
36
 
35
37
  this._statesById = new Map(); // <id, state>
36
38
  this._cachedSchemas = new Map(); // <shemaName, definition>
@@ -388,7 +390,7 @@ class BaseStateManager {
388
390
  try {
389
391
  await collection._init();
390
392
  } catch (err) {
391
- console.log(err.message)
393
+ console.log(err.message);
392
394
  throw new Error(`Cannot create collection, schema "${schemaName}" does not exists`);
393
395
  }
394
396
 
@@ -0,0 +1,66 @@
1
+ import { BATCHED_TRANSPORT_CHANNEL } from './constants.js';
2
+
3
+ /**
4
+ * This class proxies transports given the SharedStateManager to batch messages
5
+ * @private
6
+ */
7
+ class BatchedTransport {
8
+ constructor(transport) {
9
+ this._transport = transport;
10
+ this._stack = [];
11
+ this._listeners = new Map();
12
+ this._sending = false;
13
+
14
+ this._transport.addListener(BATCHED_TRANSPORT_CHANNEL, stack => {
15
+ stack.forEach(entry => {
16
+ const [channel, args] = entry;
17
+ // server side the transport is the same EventEmitter instance
18
+ // for both state manager server and client, so channel might not exist
19
+ // one side or the other.
20
+ if (this._listeners.has(channel)) {
21
+ const callbacks = this._listeners.get(channel);
22
+ callbacks.forEach(callback => callback(...args));
23
+ }
24
+ });
25
+ });
26
+ }
27
+
28
+ addListener(channel, callback) {
29
+ if (!this._listeners.has(channel)) {
30
+ this._listeners.set(channel, new Set());
31
+ }
32
+
33
+ const callbacks = this._listeners.get(channel);
34
+ callbacks.add(callback);
35
+ }
36
+
37
+ async emit(channel, ...args) {
38
+ this._stack.push([channel, args]);
39
+
40
+ if (!this._sending) {
41
+ this._sending = true;
42
+ this._sending = await false;
43
+ const stack = this._stack;
44
+ this._stack = [];
45
+ this._transport.emit(BATCHED_TRANSPORT_CHANNEL, stack);
46
+ }
47
+ }
48
+
49
+ removeListener(channel, callback) {
50
+ if (!this._listeners.has(channel)) {
51
+ const callbacks = this._listeners.get(channel);
52
+ callbacks.delete(callback);
53
+ }
54
+ }
55
+
56
+ removeAllListeners(channel = null) {
57
+ if (channel === null) {
58
+ this._listeners.clear();
59
+ } else if (this._listeners.has(channel)) {
60
+ const callbacks = this._listeners.get(channel);
61
+ callbacks.clear();
62
+ }
63
+ }
64
+ }
65
+
66
+ export default BatchedTransport;
@@ -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
@@ -70,16 +70,7 @@ class SharedStatePrivate {
70
70
  let hasUpdates = false;
71
71
 
72
72
  for (let name in updates) {
73
- // from v3.1.0 - the `filteredUpdates` check is made using 'fast-deep-equal'
74
- // cf. https://github.com/epoberezkin/fast-deep-equal
75
- // therefore unchanged objects are not considered changed
76
- // nor propagated anymore.
77
- // until v3.0.4 - we checked the `schema[name].type === 'any'`, to always consider
78
- // objects as dirty, because if the state is attached locally, we
79
- // compare the Object instances instead of their values.
80
- // @note - this should be made more robust but how?
81
73
  const [newValue, changed] = this._parameters.set(name, updates[name]);
82
-
83
74
  // if `filterChange` is set to `false` we don't check if the value
84
75
  // has been changed or not, it is always propagated to client states
85
76
  const { event, filterChange } = this._parameters.getSchema(name);
@@ -96,25 +87,23 @@ class SharedStatePrivate {
96
87
  }
97
88
 
98
89
  if (hasUpdates) {
99
- // Collection Controller logic
90
+
91
+ // Collection controller logic
100
92
  if (isOwner && inCollection) {
101
- // notify the requester back
102
- // no need to check for attached clients as it is not observable
93
+ // notify the collection controller back
103
94
  client.transport.emit(
104
95
  `${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
105
- reqId, filteredUpdates, context
96
+ reqId, filteredUpdates, context,
106
97
  );
107
98
 
108
99
  // loop through all private states
109
- for (let [stateId, state] of this._manager._sharedStatePrivateById) {
100
+ for (let [_stateId, state] of this._manager._sharedStatePrivateById) {
110
101
  // pick all states with same schema name and not this
111
102
  if (state !== this && this.schemaName === state.schemaName) {
112
103
  // notify all attached clients except those who belong to a collection
113
104
  // i.e. !isOwner && inCollection, they will be notified by their own
114
105
  // collection controller
115
106
  for (let [remoteId, clientInfos] of state._attachedClients) {
116
- const { client, isOwner, inCollection } = clientInfos;
117
-
118
107
  if (!clientInfos.isOwner && clientInfos.inCollection) {
119
108
  continue;
120
109
  } else {
@@ -122,7 +111,7 @@ class SharedStatePrivate {
122
111
 
123
112
  peer.transport.emit(
124
113
  `${UPDATE_NOTIFICATION}-${state.id}-${remoteId}`,
125
- filteredUpdates, context
114
+ filteredUpdates, context,
126
115
  );
127
116
  }
128
117
  }
@@ -131,7 +120,7 @@ class SharedStatePrivate {
131
120
  } else {
132
121
 
133
122
  // Normal case
134
-
123
+ //
135
124
  // We need to handle cases where:
136
125
  // - client state (client.id: 2) sends a request
137
126
  // - server attached state (client.id: -1) spot a problem and overrides the value
@@ -146,14 +135,14 @@ class SharedStatePrivate {
146
135
  // is synchronous it can break ordering if a subscription function makes
147
136
  // itself an update in reaction to an update. Propagating to server last
148
137
  // alllows to maintain network messages order consistent.
149
-
138
+ //
150
139
  // @note - remoteId correspond to unique remote state id
151
140
 
152
141
  // propagate RESPONSE to the client that originates the request if not the server
153
142
  if (client.id !== -1) {
154
143
  client.transport.emit(
155
144
  `${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
156
- reqId, filteredUpdates, context
145
+ reqId, filteredUpdates, context,
157
146
  );
158
147
  }
159
148
 
@@ -164,7 +153,7 @@ class SharedStatePrivate {
164
153
  if (remoteId !== peerRemoteId && peer.id !== -1) {
165
154
  peer.transport.emit(
166
155
  `${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
167
- filteredUpdates, context
156
+ filteredUpdates, context,
168
157
  );
169
158
  }
170
159
  }
@@ -173,7 +162,7 @@ class SharedStatePrivate {
173
162
  if (client.id === -1) {
174
163
  client.transport.emit(
175
164
  `${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
176
- reqId, filteredUpdates, context
165
+ reqId, filteredUpdates, context,
177
166
  );
178
167
  }
179
168
 
@@ -184,7 +173,7 @@ class SharedStatePrivate {
184
173
  if (remoteId !== peerRemoteId && peer.id === -1) {
185
174
  peer.transport.emit(
186
175
  `${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
187
- filteredUpdates, context
176
+ filteredUpdates, context,
188
177
  );
189
178
  }
190
179
  }
@@ -1,6 +1,9 @@
1
1
  // id of the server when owner of a state
2
2
  export const SERVER_ID = -1;
3
3
 
4
+ // batched transport channel
5
+ export const BATCHED_TRANSPORT_CHANNEL = 'b:t';
6
+
4
7
  // shared states channels
5
8
  export const CREATE_REQUEST = 's:c:req';
6
9
  export const CREATE_RESPONSE = 's:c:res';
@@ -1,14 +1,12 @@
1
1
  import { Worker } from 'node:worker_threads';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
-
5
2
  import querystring from 'querystring';
6
- import { WebSocketServer } from 'ws';
7
- import WebSocket from 'ws';
3
+ import { default as WebSocket, WebSocketServer } from 'ws';
8
4
 
9
5
  import Socket from './Socket.js';
6
+ import networkLatencyWorker from './audit-network-latency.worker.js';
10
7
 
11
- const __dirname = fileURLToPath(new URL('.', import.meta.url));
8
+ // this crashes when bundling server to cjs module for Max, fallback to cjs worker
9
+ // const __dirname = fileURLToPath(new URL('.', import.meta.url));
12
10
 
13
11
  /**
14
12
  * Manager all {@link server.Socket} instances.
@@ -45,8 +43,9 @@ class Sockets {
45
43
  */
46
44
  async start(server, config, onConnectionCallback) {
47
45
  // init audit stuff for network latency estimation
48
- const workerPathname = path.join(__dirname, 'audit-network-latency.worker.js');
49
- this._latencyStatsWorker = new Worker(workerPathname);
46
+ // @see note about __dirname
47
+ // const workerPathname = path.join(__dirname, 'audit-network-latency.worker.js');
48
+ this._latencyStatsWorker = new Worker(networkLatencyWorker, { eval: true });
50
49
  this._auditState = await server.getAuditState();
51
50
 
52
51
  this._auditState.onUpdate(updates => {
@@ -4,6 +4,7 @@ import { idGenerator, isString, isPlainObject } from '@ircam/sc-utils';
4
4
  import clonedeep from 'lodash/cloneDeep.js';
5
5
 
6
6
  import BaseStateManager from '../common/BaseStateManager.js';
7
+ import BatchedTransport from '../common/BatchedTransport.js';
7
8
  import ParameterBag from '../common/ParameterBag.js';
8
9
  import SharedStatePrivate from '../common/SharedStatePrivate.js';
9
10
  import {
@@ -344,6 +345,8 @@ class StateManager extends BaseStateManager {
344
345
  * @private
345
346
  */
346
347
  addClient(nodeId, transport) {
348
+ transport = new BatchedTransport(transport);
349
+
347
350
  const client = { id: nodeId, transport };
348
351
  this._clientByNodeId.set(nodeId, client);
349
352
 
@@ -360,7 +363,7 @@ class StateManager extends BaseStateManager {
360
363
  const remoteId = generateRemoteId.next().value;
361
364
  const state = new SharedStatePrivate(stateId, schemaName, schema, this, initValues);
362
365
 
363
- // attach client to the state as owner
366
+ // attach client to the state as owner
364
367
  const isOwner = true;
365
368
  state._attachClient(remoteId, client, isOwner, inCollection);
366
369
 
@@ -371,7 +374,7 @@ class StateManager extends BaseStateManager {
371
374
 
372
375
  client.transport.emit(
373
376
  CREATE_RESPONSE,
374
- reqId, stateId, remoteId, schemaName, schemaOption, currentValues
377
+ reqId, stateId, remoteId, schemaName, schemaOption, currentValues,
375
378
  );
376
379
 
377
380
  const isObservable = this[kIsObservableState](state);
@@ -393,7 +396,7 @@ class StateManager extends BaseStateManager {
393
396
 
394
397
  client.transport.emit(CREATE_ERROR, reqId, msg);
395
398
  }
396
- }
399
+ },
397
400
  );
398
401
 
399
402
  // ---------------------------------------------
@@ -434,7 +437,7 @@ class StateManager extends BaseStateManager {
434
437
 
435
438
  client.transport.emit(
436
439
  ATTACH_RESPONSE,
437
- reqId, state.id, remoteId, schemaName, schemaOption, currentValues
440
+ reqId, state.id, remoteId, schemaName, schemaOption, currentValues,
438
441
  );
439
442
 
440
443
  } else {
@@ -449,7 +452,7 @@ class StateManager extends BaseStateManager {
449
452
 
450
453
  client.transport.emit(ATTACH_ERROR, reqId, msg);
451
454
  }
452
- }
455
+ },
453
456
  );
454
457
 
455
458
  // ---------------------------------------------
@@ -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
+ `;