vrpc 3.15.0 → 3.16.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vrpc",
3
- "version": "3.15.0",
3
+ "version": "3.16.1",
4
4
  "description": "Remote objects over MQTT: call classes in other programs as if they were local - Node.js, browser, C++, Python, Arduino",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -661,8 +661,15 @@ class VrpcAdapter {
661
661
  const [instanceId, ...args] = json.a
662
662
  VrpcAdapter._assertInstanceName(instanceId)
663
663
  const existing = VrpcAdapter._instances.get(instanceId)
664
- if (existing && existing.isIsolated) {
665
- throw new Error(`Instance '${instanceId}' exists as an isolated instance`)
664
+ if (existing) {
665
+ // an id is never re-homed: an isolated instance stays isolated
666
+ if (existing.isIsolated) {
667
+ throw new Error(`Instance '${instanceId}' exists as an isolated instance`)
668
+ }
669
+ // a create of a name that exists returns it unchanged: its class
670
+ // and arguments stay, and nothing announces it (no 'create' event)
671
+ json.r = instanceId
672
+ return existing.instance
666
673
  }
667
674
  instance = VrpcAdapter._create(className, instanceId, ...args)
668
675
  VrpcAdapter._instances.set(instanceId, {
@@ -1177,13 +1184,15 @@ class VrpcAdapter {
1177
1184
  }
1178
1185
 
1179
1186
  /**
1180
- * Answers a promise twice: at once with `r: '__p__<f>-<n>'`, and once it
1181
- * settled with a second answer under that id, `{ s, i, r }` or
1182
- * `{ s, i, e }`.
1187
+ * Answers a promise twice: at once with `r: '__p__<f>-<nonce>-<n>'`, and
1188
+ * once it settled with a second answer under that id, `{ s, i, r }` or
1189
+ * `{ s, i, e }`. A client waits for the second answer under the bare id,
1190
+ * whichever agent sends it: the nonce keeps the ids of two agents apart,
1191
+ * whose counters may well stand at the same value.
1183
1192
  */
1184
1193
  static _handlePromise (json, promise) {
1185
1194
  const cid = VrpcAdapter._correlationId++ % Number.MAX_SAFE_INTEGER
1186
- const id = `__p__${json.f}-${cid}`
1195
+ const id = `__p__${json.f}-${VrpcAdapter._promiseNonce}-${cid}`
1187
1196
  const { s } = json
1188
1197
  json.r = id
1189
1198
  promise.then(
@@ -1311,7 +1320,8 @@ class VrpcAdapter {
1311
1320
  /**
1312
1321
  * Event 'create'
1313
1322
  *
1314
- * Emitted on creation of shared instance
1323
+ * Emitted once an instance was created. A create of a name that exists
1324
+ * returns that instance and emits nothing.
1315
1325
  *
1316
1326
  * @event VrpcAdapter#create
1317
1327
  * @type {Object}
@@ -1391,6 +1401,8 @@ VrpcAdapter._functionRegistry = new Map()
1391
1401
  VrpcAdapter._instances = new Map()
1392
1402
  VrpcAdapter._emitter = new EventEmitter()
1393
1403
  VrpcAdapter._correlationId = 0
1404
+ // tells this process's promise ids from those of every other agent
1405
+ VrpcAdapter._promiseNonce = nanoid(8)
1394
1406
  // the event registrations of every context and their subscriptions
1395
1407
  VrpcAdapter._registrations = new Registrations({
1396
1408
  publish: message => VrpcAdapter._callback(message),
package/vrpc/VrpcAgent.js CHANGED
@@ -646,6 +646,9 @@ class VrpcAgent extends EventEmitter {
646
646
  ] of VrpcAdapter._instances.entries()) {
647
647
  batches.push(this._methodsTopic(className, instanceId))
648
648
  }
649
+ // what the broker retains of this agent's classes: an earlier run's
650
+ // class that is no longer offered is cleared (_sweepClassInfo)
651
+ batches.push(`${this._baseTopic}/+/__classInfoConcise__`)
649
652
  let pending = batches.length
650
653
  const announce = () => {
651
654
  // a connection that ended meanwhile announces nothing: the next
@@ -659,10 +662,6 @@ class VrpcAgent extends EventEmitter {
659
662
  this._serving = true
660
663
  this.emit('connect')
661
664
  }
662
- if (pending === 0) {
663
- announce()
664
- return
665
- }
666
665
  for (const topic of batches) {
667
666
  this._mqttSubscribe(topic, undefined, () => {
668
667
  pending -= 1
@@ -743,7 +742,38 @@ class VrpcAgent extends EventEmitter {
743
742
  return topics
744
743
  }
745
744
 
746
- _handleMessage (topic, data) {
745
+ /**
746
+ * A class info under this agent's own name. What the broker retained of
747
+ * a class this agent does not offer is a ghost of an earlier run - a
748
+ * class info only `end({ unregister: true })` would have cleared - and
749
+ * clients would list the class forever: it is cleared, in full and
750
+ * concise. Everything else on the topic - the agent's own announcements
751
+ * and clears, routed back live - passes without a word.
752
+ *
753
+ * @returns {Boolean} whether the message was a class info
754
+ * @private
755
+ */
756
+ _sweepClassInfo (topic, data, packet) {
757
+ const prefix = `${this._baseTopic}/`
758
+ if (!topic.startsWith(prefix)) return false
759
+ const tokens = topic.slice(prefix.length).split('/')
760
+ if (tokens.length !== 2) return false
761
+ const [className, info] = tokens
762
+ if (info !== '__classInfoConcise__' && info !== '__classInfo__') {
763
+ return false
764
+ }
765
+ const retained = !!packet && packet.retain
766
+ if (!retained || data.length === 0) return true
767
+ if (this._getClasses().includes(className)) return true
768
+ this._log.info(`Clearing the class info of ${className}, no longer offered`)
769
+ for (const name of ['__classInfo__', '__classInfoConcise__']) {
770
+ this._mqttPublish(`${prefix}${className}/${name}`, null, { retain: true })
771
+ }
772
+ return true
773
+ }
774
+
775
+ _handleMessage (topic, data, packet) {
776
+ if (this._sweepClassInfo(topic, data, packet)) return
747
777
  let json
748
778
  try {
749
779
  json = JSON.parse(data.toString())
@@ -69,7 +69,9 @@ class VrpcAdapter {
69
69
  * @param {Boolean} [options.withNew=true] If true, class will be constructed
70
70
  * using the `new` operator
71
71
  * @param {Object} [options.schema=null] If provided is used to validate ctor
72
- * parameters (only works if registered code reflects a single class)
72
+ * parameters (only works if registered code reflects a single class). The
73
+ * browser bundle (`browser/vrpc.js`) does not validate: it warns once and
74
+ * constructs with the arguments as given
73
75
  */
74
76
  static registerClass (
75
77
  Klass,
@@ -467,8 +469,15 @@ class VrpcAdapter {
467
469
  const [instanceId, ...args] = json.a
468
470
  VrpcAdapter._assertInstanceName(instanceId)
469
471
  const existing = VrpcAdapter._instances.get(instanceId)
470
- if (existing && existing.isIsolated) {
471
- throw new Error(`Instance '${instanceId}' exists as an isolated instance`)
472
+ if (existing) {
473
+ // an id is never re-homed: an isolated instance stays isolated
474
+ if (existing.isIsolated) {
475
+ throw new Error(`Instance '${instanceId}' exists as an isolated instance`)
476
+ }
477
+ // a create of a name that exists returns it unchanged: its class
478
+ // and arguments stay, and nothing announces it (no 'create' event)
479
+ json.r = instanceId
480
+ return existing.instance
472
481
  }
473
482
  instance = VrpcAdapter._create(className, instanceId, ...args)
474
483
  VrpcAdapter._instances.set(instanceId, {
@@ -912,6 +921,17 @@ class VrpcAdapter {
912
921
  }
913
922
 
914
923
  static _validate (schema, params) {
924
+ // the browser bundle leaves ajv out - a fifth of its size, for agents in
925
+ // a page, which are rare: there a schema is not checked
926
+ if (typeof Ajv !== 'function') {
927
+ if (!VrpcAdapter._warnedNoSchemas) {
928
+ VrpcAdapter._warnedNoSchemas = true
929
+ console.warn(
930
+ 'vrpc: schema validation is not available in the browser build, constructor arguments are not checked'
931
+ )
932
+ }
933
+ return
934
+ }
915
935
  if (!VrpcAdapter._ajv) {
916
936
  VrpcAdapter._ajv = new Ajv({ useDefaults: true })
917
937
  }
@@ -983,13 +1003,15 @@ class VrpcAdapter {
983
1003
  }
984
1004
 
985
1005
  /**
986
- * Answers a promise twice: at once with `r: '__p__<f>-<n>'`, and once it
987
- * settled with a second answer under that id, `{ s, i, r }` or
988
- * `{ s, i, e }`.
1006
+ * Answers a promise twice: at once with `r: '__p__<f>-<nonce>-<n>'`, and
1007
+ * once it settled with a second answer under that id, `{ s, i, r }` or
1008
+ * `{ s, i, e }`. A client waits for the second answer under the bare id,
1009
+ * whichever agent sends it: the nonce keeps the ids of two agents apart,
1010
+ * whose counters may well stand at the same value.
989
1011
  */
990
1012
  static _handlePromise (json, promise) {
991
1013
  const cid = VrpcAdapter._correlationId++ % Number.MAX_SAFE_INTEGER
992
- const id = `__p__${json.f}-${cid}`
1014
+ const id = `__p__${json.f}-${VrpcAdapter._promiseNonce}-${cid}`
993
1015
  const { s } = json
994
1016
  json.r = id
995
1017
  promise.then(
@@ -1117,7 +1139,8 @@ class VrpcAdapter {
1117
1139
  /**
1118
1140
  * Event 'create'
1119
1141
  *
1120
- * Emitted on creation of shared instance
1142
+ * Emitted once an instance was created. A create of a name that exists
1143
+ * returns that instance and emits nothing.
1121
1144
  *
1122
1145
  * @event VrpcAdapter#create
1123
1146
  * @type {Object}
@@ -1157,6 +1180,8 @@ VrpcAdapter._functionRegistry = new Map()
1157
1180
  VrpcAdapter._instances = new Map()
1158
1181
  VrpcAdapter._emitter = new EventEmitter()
1159
1182
  VrpcAdapter._correlationId = 0
1183
+ // tells this process's promise ids from those of every other agent
1184
+ VrpcAdapter._promiseNonce = nanoid(8)
1160
1185
  // the event registrations of every context and their subscriptions
1161
1186
  VrpcAdapter._registrations = new Registrations({
1162
1187
  publish: message => VrpcAdapter._callback(message),
@@ -478,6 +478,9 @@ class VrpcAgent extends EventEmitter {
478
478
  ] of VrpcAdapter._instances.entries()) {
479
479
  batches.push(this._methodsTopic(className, instanceId))
480
480
  }
481
+ // what the broker retains of this agent's classes: an earlier run's
482
+ // class that is no longer offered is cleared (_sweepClassInfo)
483
+ batches.push(`${this._baseTopic}/+/__classInfoConcise__`)
481
484
  let pending = batches.length
482
485
  const announce = () => {
483
486
  // a connection that ended meanwhile announces nothing: the next
@@ -490,10 +493,6 @@ class VrpcAgent extends EventEmitter {
490
493
  this._serving = true
491
494
  this.emit('connect')
492
495
  }
493
- if (pending === 0) {
494
- announce()
495
- return
496
- }
497
496
  for (const topic of batches) {
498
497
  this._mqttSubscribe(topic, undefined, () => {
499
498
  pending -= 1
@@ -566,7 +565,38 @@ class VrpcAgent extends EventEmitter {
566
565
  return topics
567
566
  }
568
567
 
569
- _handleMessage (topic, data) {
568
+ /**
569
+ * A class info under this agent's own name. What the broker retained of
570
+ * a class this agent does not offer is a ghost of an earlier run - a
571
+ * class info only `end({ unregister: true })` would have cleared - and
572
+ * clients would list the class forever: it is cleared, in full and
573
+ * concise. Everything else on the topic - the agent's own announcements
574
+ * and clears, routed back live - passes without a word.
575
+ *
576
+ * @returns {Boolean} whether the message was a class info
577
+ * @private
578
+ */
579
+ _sweepClassInfo (topic, data, packet) {
580
+ const prefix = `${this._baseTopic}/`
581
+ if (!topic.startsWith(prefix)) return false
582
+ const tokens = topic.slice(prefix.length).split('/')
583
+ if (tokens.length !== 2) return false
584
+ const [className, info] = tokens
585
+ if (info !== '__classInfoConcise__' && info !== '__classInfo__') {
586
+ return false
587
+ }
588
+ const retained = !!packet && packet.retain
589
+ if (!retained || data.length === 0) return true
590
+ if (this._getClasses().includes(className)) return true
591
+ this._log.info(`Clearing the class info of ${className}, no longer offered`)
592
+ for (const name of ['__classInfo__', '__classInfoConcise__']) {
593
+ this._mqttPublish(`${prefix}${className}/${name}`, null, { retain: true })
594
+ }
595
+ return true
596
+ }
597
+
598
+ _handleMessage (topic, data, packet) {
599
+ if (this._sweepClassInfo(topic, data, packet)) return
570
600
  let json
571
601
  try {
572
602
  json = JSON.parse(data.toString())
@@ -180,6 +180,9 @@ class VrpcClient extends EventEmitter {
180
180
  this._pendingWithdrawals = new Map()
181
181
  // the client's own inbox is subscribed (answers can arrive)
182
182
  this._inboxReady = false
183
+ // the answers this client waits for ({ agent, second, lose, forget }),
184
+ // lost when the path an answer would travel breaks (see _loseAnswers)
185
+ this._waiting = new Set()
183
186
  // labels callbacks carry towards the agent's list of registrations
184
187
  this._subscriberLabels = new WeakMap()
185
188
  if (log === 'console') {
@@ -323,6 +326,12 @@ class VrpcClient extends EventEmitter {
323
326
  this.emit('error', err)
324
327
  })
325
328
 
329
+ // a clean session keeps nothing for this connection while it is gone:
330
+ // an answer published meanwhile never arrives
331
+ this._client.on('close', () => {
332
+ this._loseAnswers(() => true, 'this client lost its connection')
333
+ })
334
+
326
335
  this._client.stream.on('error', err => {
327
336
  this.emit('error', err)
328
337
  })
@@ -377,7 +386,7 @@ class VrpcClient extends EventEmitter {
377
386
  }
378
387
  })
379
388
 
380
- this._client.on('message', (topic, message) => {
389
+ this._client.on('message', (topic, message, packet) => {
381
390
  if (message.length === 0) return
382
391
  try {
383
392
  const tokens = topic.split('/')
@@ -398,8 +407,21 @@ class VrpcClient extends EventEmitter {
398
407
  // the agent's protocol version (undefined before 3)
399
408
  this._agents[agent].v = v
400
409
  if (status === 'offline') {
410
+ this._loseAnswers(
411
+ w => w.second && w.agent === agent,
412
+ 'the agent went offline'
413
+ )
401
414
  this._loseAgent(agent, v)
402
415
  } else {
416
+ // an agent announces itself from a new connection only (a
417
+ // retained announcement is old news a subscription brings):
418
+ // the promises its former connection was to answer are gone
419
+ if (!packet.retain) {
420
+ this._loseAnswers(
421
+ w => w.second && w.agent === agent,
422
+ 'the agent connected anew'
423
+ )
424
+ }
403
425
  this._redeclareTo(agent)
404
426
  this._flushWithdrawals(agent)
405
427
  }
@@ -1400,6 +1422,7 @@ class VrpcClient extends EventEmitter {
1400
1422
  v: VRPC_PROTOCOL_VERSION
1401
1423
  })
1402
1424
  )
1425
+ this._loseAnswers(() => true, 'the client ended')
1403
1426
  this._eventEmitter.removeAllListeners()
1404
1427
  this.removeAllListeners()
1405
1428
  // the agents withdraw what this connection held: nothing to declare
@@ -1604,12 +1627,16 @@ class VrpcClient extends EventEmitter {
1604
1627
  await this._waitUntilClassIsOnline(agent, className)
1605
1628
  this._mqttPublish(topic, JSON.stringify(json))
1606
1629
  return new Promise((resolve, reject) => {
1607
- const msg = `Proxy creation for class "${className}" on agent "${agent}" and domain "${this._domain}" timed out (> ${this._timeout} ms)`
1630
+ const call = `Proxy creation for class "${className}" on agent "${agent}" and domain "${this._domain}"`
1608
1631
  const timer = setTimeout(() => {
1609
- this._eventEmitter.removeAllListeners(i)
1610
- reject(new Error(msg))
1632
+ forget()
1633
+ reject(new Error(`${call} timed out (> ${this._timeout} ms)`))
1611
1634
  }, this._timeout)
1612
- this._eventEmitter.once(i, ({ e, r }) => {
1635
+ const lose = reason => {
1636
+ clearTimeout(timer)
1637
+ reject(new Error(`${call} lost its answer: ${reason}`))
1638
+ }
1639
+ const forget = this._awaitAnswer(i, { agent, lose }, ({ e, r }) => {
1613
1640
  clearTimeout(timer)
1614
1641
  if (e) {
1615
1642
  // the agent answers a throwing constructor with { message, cause }
@@ -1813,27 +1840,33 @@ class VrpcClient extends EventEmitter {
1813
1840
 
1814
1841
  async _handleAgentAnswer ({ i, c, f, a }, agent) {
1815
1842
  return new Promise((resolve, reject) => {
1816
- const msg = `Function call "${c}::${f}()" on agent "${agent}" timed out (> ${this._timeout} ms)`
1843
+ const call = `Function call "${c}::${f}()" on agent "${agent}"`
1817
1844
  const timer = setTimeout(() => {
1818
- this._eventEmitter.removeAllListeners(i)
1819
- reject(new Error(msg))
1845
+ forget()
1846
+ reject(new Error(`${call} timed out (> ${this._timeout} ms)`))
1820
1847
  }, this._timeout)
1848
+ const lose = reason => {
1849
+ clearTimeout(timer)
1850
+ reject(new Error(`${call} lost its answer: ${reason}`))
1851
+ }
1821
1852
  // a protocol-4 agent that answers a declaring call without an error
1822
1853
  // holds every subscription the call carried
1823
1854
  const confirm = () => {
1824
1855
  const info = this._agents[agent]
1825
1856
  if (info && info.v >= 4) this._confirmSubscriptions(a)
1826
1857
  }
1827
- this._eventEmitter.once(i, data => {
1858
+ const forget = this._awaitAnswer(i, { agent, lose }, data => {
1828
1859
  clearTimeout(timer)
1829
1860
  if (data.e) {
1830
1861
  const { message, cause } = VrpcClient._prepareError(data.e)
1831
1862
  reject(new Error(`[vrpc ${agent}-${c}-${f}]: ${message}`, { cause }))
1832
1863
  } else {
1833
1864
  const ret = data.r
1834
- // Handle functions returning a promise
1865
+ // Handle functions returning a promise: the result follows when
1866
+ // it settles, however long that takes - unless the path it would
1867
+ // travel breaks first
1835
1868
  if (typeof ret === 'string' && ret.substring(0, 5) === '__p__') {
1836
- this._eventEmitter.once(ret, promiseData => {
1869
+ this._awaitAnswer(ret, { agent, lose, second: true }, promiseData => {
1837
1870
  if (promiseData.e) {
1838
1871
  const { message, cause } = VrpcClient._prepareError(
1839
1872
  promiseData.e
@@ -1855,6 +1888,44 @@ class VrpcClient extends EventEmitter {
1855
1888
  })
1856
1889
  }
1857
1890
 
1891
+ /**
1892
+ * Waits for the answer with id `id`.
1893
+ *
1894
+ * @param {String} id
1895
+ * @param {Object} waiter
1896
+ * @param {String} waiter.agent the agent that answers
1897
+ * @param {Function} waiter.lose called with a reason when the answer can
1898
+ * no longer arrive
1899
+ * @param {Boolean} [waiter.second] a promise's result, the second answer
1900
+ * to its call
1901
+ * @param {Function} onAnswer called with the answer
1902
+ * @returns {Function} stops waiting, without a call to `lose`
1903
+ */
1904
+ _awaitAnswer (id, { agent, lose, second = false }, onAnswer) {
1905
+ const waiter = { agent, lose, second }
1906
+ const listener = data => {
1907
+ this._waiting.delete(waiter)
1908
+ onAnswer(data)
1909
+ }
1910
+ waiter.forget = () => {
1911
+ this._waiting.delete(waiter)
1912
+ this._eventEmitter.removeListener(id, listener)
1913
+ }
1914
+ this._waiting.add(waiter)
1915
+ this._eventEmitter.once(id, listener)
1916
+ return waiter.forget
1917
+ }
1918
+
1919
+ // the answers that can no longer arrive: their calls reject at once, and
1920
+ // a late answer finds no listener
1921
+ _loseAnswers (selects, reason) {
1922
+ for (const waiter of Array.from(this._waiting)) {
1923
+ if (!selects(waiter)) continue
1924
+ waiter.forget()
1925
+ waiter.lose(reason)
1926
+ }
1927
+ }
1928
+
1858
1929
  static _prepareError (rpcError) {
1859
1930
  if (typeof rpcError === 'object') {
1860
1931
  return { message: rpcError.message, cause: rpcError.cause }