sen-ether-client 0.1.0 → 0.1.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.
Files changed (5) hide show
  1. package/API.md +16 -1
  2. package/README.md +12 -0
  3. package/index.js +11 -1
  4. package/lib/sen.js +411 -51
  5. package/package.json +1 -1
package/API.md CHANGED
@@ -45,14 +45,21 @@ Connection options:
45
45
  - `timeout`: discovery and operation timeout in ms.
46
46
  - `discoverySettleMs`: discovery settle time after the first process is found.
47
47
  Defaults to `100`.
48
+ - `busDiscoverySettleMs`: max wait after a lightweight session connection while
49
+ bus announcements arrive. Defaults to at least `300`.
48
50
  - `reconnect`: whether to reconnect and restart interests.
49
51
  - `reconnectDelayMs`: delay between reconnect attempts.
50
- - `maxReconnectAttempts`: maximum reconnect attempts.
52
+ - `maxReconnectAttempts`: maximum reconnect attempts. Defaults to `0`, which
53
+ means unlimited retries.
51
54
  - `participantReadyTimeoutMs`: short non-fatal grace timeout for bus
52
55
  participant acknowledgements. Defaults to `1000`.
53
56
  - `socketKeepAlive`: enable TCP keepalive. Defaults to `true`.
54
57
  - `socketIdleTimeoutMs`: optional TCP idle timeout. Defaults to `0` because
55
58
  valid SEN connections can be quiet on TCP while bus data flows separately.
59
+ - `presenceTimeoutMs`: close and reconnect when the connected SEN process stops
60
+ announcing ether presence beams. Defaults to `5000`; set `0` to disable.
61
+ - `presenceCheckIntervalMs`: presence watchdog check interval. Defaults to
62
+ `1000`.
56
63
 
57
64
  `Sen.connect()` uses multicast discovery. `sen-ether-client` reads this SEN environment
58
65
  variable as its multicast default:
@@ -104,6 +111,7 @@ Main methods:
104
111
  - `await sen.connect(options)`
105
112
  - `await sen.interest(query, options)`
106
113
  - `await sen.session(name)`
114
+ - `await sen.discoverBuses(options)`
107
115
  - `sen.listSessions()`
108
116
  - `sen.listBuses(options)`
109
117
  - `await sen.bus(name, options)`
@@ -117,6 +125,9 @@ Session and bus navigation:
117
125
  ```js
118
126
  const sen = await Sen.connect();
119
127
 
128
+ console.log(await sen.discoverBuses());
129
+ // [{ session: 'hmi', bus: 'diagnostics', qualified: 'hmi.diagnostics' }]
130
+
120
131
  for (const sessionName of sen.listSessions()) {
121
132
  const session = await sen.session(sessionName);
122
133
  console.log(sessionName, session.listBuses());
@@ -125,6 +136,10 @@ for (const sessionName of sen.listSessions()) {
125
136
  const diagnostics = await sen.session('hmi').then(hmi => hmi.bus('diagnostics'));
126
137
  ```
127
138
 
139
+ `discoverBuses()` does not create interests and does not join any SEN bus. It
140
+ does open a lightweight process connection per discovered session, because SEN
141
+ presence beams announce sessions/processes but not the bus list.
142
+
128
143
  Main events:
129
144
 
130
145
  - `connect`
package/README.md CHANGED
@@ -84,6 +84,11 @@ const sen = await Sen.connect({
84
84
  });
85
85
  ```
86
86
 
87
+ Connected sessions are monitored through SEN ether presence beams. If the
88
+ remote process stops announcing itself for `presenceTimeoutMs` milliseconds
89
+ (default `5000`), the client closes the stale connection and restarts the
90
+ configured interests.
91
+
87
92
  `sen-ether-client` can work with several SEN sessions from the same client. The session
88
93
  is inferred from the query:
89
94
 
@@ -98,6 +103,8 @@ You can also navigate explicitly through sessions and buses:
98
103
  const sen = await Sen.connect();
99
104
 
100
105
  console.log(sen.listSessions());
106
+ console.log(await sen.discoverBuses());
107
+ // [{ session: 'hmi', bus: 'diagnostics', qualified: 'hmi.diagnostics' }]
101
108
 
102
109
  const hmi = await sen.session('hmi');
103
110
  console.log(hmi.listBuses());
@@ -106,6 +113,11 @@ const diagnostics = await hmi.bus('diagnostics');
106
113
  const probe = await diagnostics.waitFor('EtherProbe');
107
114
  ```
108
115
 
116
+ `discoverBuses()` does not create interests and does not join any SEN bus. It
117
+ uses discovery to find sessions and opens lightweight process connections only
118
+ to read bus announcements. If buses are not announced immediately after the
119
+ process connection, it waits up to `busDiscoverySettleMs` milliseconds.
120
+
109
121
  You can also connect to one explicit session:
110
122
 
111
123
  ```js
package/index.js CHANGED
@@ -34,13 +34,16 @@
34
34
  * @property {string} [app] Remote process appName substring filter.
35
35
  * @property {number} [timeout=3000] Discovery and operation timeout in ms.
36
36
  * @property {number} [discoverySettleMs=100] Discovery settle time after the first process is found.
37
+ * @property {number} [busDiscoverySettleMs=300] Max wait after lightweight session connect before reading bus announcements.
37
38
  * @property {number} [participantReadyTimeoutMs=1000] Short grace timeout for non-fatal bus participant acknowledgements.
38
39
  * @property {boolean} [reconnect=true] Reconnect and restart interests after disconnection.
39
40
  * @property {number} [reconnectDelayMs=500] Delay between reconnect attempts.
40
- * @property {number} [maxReconnectAttempts=10] Maximum reconnect attempts.
41
+ * @property {number} [maxReconnectAttempts=0] Maximum reconnect attempts. `0` means unlimited.
41
42
  * @property {boolean} [socketKeepAlive=true] Enable TCP keepalive on SEN ether connections.
42
43
  * @property {number} [socketKeepAliveInitialDelayMs=1000] TCP keepalive initial delay.
43
44
  * @property {number} [socketIdleTimeoutMs=0] Optional transport idle timeout in ms. `0` disables it.
45
+ * @property {number} [presenceTimeoutMs=5000] Close and reconnect when the connected SEN process stops announcing presence beams. `0` disables it.
46
+ * @property {number} [presenceCheckIntervalMs=1000] Presence watchdog check interval in ms.
44
47
  * @property {string} [interfaceAddress] Local interface address or interface name for multicast discovery.
45
48
  * @property {object} [target] Already discovered/direct SEN target.
46
49
  */
@@ -64,6 +67,13 @@
64
67
  * @property {boolean} [qualified=false] Return session-qualified bus names.
65
68
  */
66
69
 
70
+ /**
71
+ * @typedef {object} SenBusSummary
72
+ * @property {string} session SEN session name.
73
+ * @property {string} bus Bus name local to the session.
74
+ * @property {string} qualified Session-qualified bus name usable in `SELECT * FROM <qualified>`.
75
+ */
76
+
67
77
  /**
68
78
  * @typedef {string | number | ((object: SenRemoteObject) => boolean)} SenObjectSelector
69
79
  */
package/lib/sen.js CHANGED
@@ -2,7 +2,7 @@ import { once } from 'node:events';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { decodePropertyValues, decodeValue, encodeArguments, decodeArguments } from './values.js';
4
4
  import { EtherClient } from './client.js';
5
- import { scan, scanTcpDiscoveryHub } from './discovery.js';
5
+ import { EtherDiscoveryScanner, TcpDiscoveryHubScanner, scan, scanTcpDiscoveryHub } from './discovery.js';
6
6
  import { methodHash } from './hash32.js';
7
7
 
8
8
  function wait(ms) {
@@ -48,15 +48,24 @@ function queryBusName(sessionName, bus) {
48
48
  return text.includes('.') || !session ? text : `${session}.${text}`;
49
49
  }
50
50
 
51
- function findTarget(processes, options) {
52
- let candidates = processes;
51
+ function targetSessionName(target) {
52
+ return target?.session?.name ?? target?.info?.sessionName ?? '';
53
+ }
54
+
55
+ function filterTargets(processes, options = {}) {
56
+ let candidates = [...processes];
53
57
  if (options.session) {
54
- candidates = candidates.filter(item => item.session?.name === options.session);
58
+ candidates = candidates.filter(item => targetSessionName(item) === options.session);
55
59
  }
56
60
  if (options.app) {
57
61
  const app = String(options.app).toLowerCase();
58
62
  candidates = candidates.filter(item => String(item.process?.appName || '').toLowerCase().includes(app));
59
63
  }
64
+ return candidates;
65
+ }
66
+
67
+ function findTarget(processes, options) {
68
+ const candidates = filterTargets(processes, options);
60
69
  if (!candidates.length) {
61
70
  return null;
62
71
  }
@@ -107,6 +116,19 @@ function sessionNameFromBusName(busName) {
107
116
  return idx > 0 ? text.slice(0, idx) : '';
108
117
  }
109
118
 
119
+ function busSummary(sessionName, busName) {
120
+ const session = String(sessionName || '').trim();
121
+ const bus = etherBusName(session, busName);
122
+ if (!session || !bus) {
123
+ return null;
124
+ }
125
+ return {
126
+ session,
127
+ bus,
128
+ qualified: queryBusName(session, bus)
129
+ };
130
+ }
131
+
110
132
  function selectorDescription(selector) {
111
133
  return typeof selector === 'function' ? '<predicate>' : String(selector);
112
134
  }
@@ -129,6 +151,24 @@ function normalizeTimestampNs(value) {
129
151
  return typeof value === 'bigint' ? value : BigInt(value);
130
152
  }
131
153
 
154
+ function stateRequestKey(interestId, objectId) {
155
+ return `${interestId >>> 0}:${objectId >>> 0}`;
156
+ }
157
+
158
+ async function waitForSessionBuses(session, timeoutMs) {
159
+ let buses = session.listBuses();
160
+ if (buses.length || timeoutMs <= 0) {
161
+ return buses;
162
+ }
163
+
164
+ const deadline = Date.now() + timeoutMs;
165
+ while (!buses.length && Date.now() < deadline) {
166
+ await wait(Math.min(50, Math.max(1, deadline - Date.now())));
167
+ buses = session.listBuses();
168
+ }
169
+ return buses;
170
+ }
171
+
132
172
  class ChangeBatcher {
133
173
  constructor(interest, options = {}) {
134
174
  this.interest = interest;
@@ -251,19 +291,40 @@ export class Sen extends EventEmitter {
251
291
  return await sen.connect(options);
252
292
  }
253
293
 
294
+ /**
295
+ * Discover visible SEN buses without creating interests or joining buses.
296
+ *
297
+ * SEN discovery beams expose sessions/processes. Bus names are announced only
298
+ * after a lightweight process connection, so this method connects to each
299
+ * discovered session long enough to read its remote bus announcements.
300
+ *
301
+ * @param {object} [options]
302
+ * @returns {Promise<Array<{session:string,bus:string,qualified:string}>>}
303
+ */
304
+ static async discoverBuses(options = {}) {
305
+ const sen = new Sen(options);
306
+ try {
307
+ return await sen.discoverBuses(options);
308
+ } finally {
309
+ await sen.close().catch(() => {});
310
+ }
311
+ }
312
+
254
313
  constructor(options = {}) {
255
314
  super();
256
315
  this.options = {
257
316
  appName: 'sen-ether-client',
258
317
  reconnect: true,
259
318
  reconnectDelayMs: 500,
260
- maxReconnectAttempts: 10,
319
+ maxReconnectAttempts: 0,
261
320
  timeout: 3000,
262
321
  discoverySettleMs: 100,
263
322
  participantReadyTimeoutMs: 1000,
264
323
  socketKeepAlive: true,
265
324
  socketKeepAliveInitialDelayMs: 1000,
266
325
  socketIdleTimeoutMs: 0,
326
+ presenceTimeoutMs: 5000,
327
+ presenceCheckIntervalMs: 1000,
267
328
  ...options
268
329
  };
269
330
  this.target = undefined;
@@ -271,9 +332,13 @@ export class Sen extends EventEmitter {
271
332
  this.connectOptions = undefined;
272
333
  this.manualClose = false;
273
334
  this.reconnecting = false;
335
+ this.presenceScanner = undefined;
336
+ this.presenceTimer = undefined;
337
+ this.presenceLastSeen = 0;
274
338
  this.remoteBuses = new Set();
275
339
  this.buses = new Map();
276
340
  this.sessions = new Map();
341
+ this.targets = [];
277
342
  this.targetsBySession = new Map();
278
343
  }
279
344
 
@@ -298,8 +363,9 @@ export class Sen extends EventEmitter {
298
363
  if (!targets.length) {
299
364
  throw new Error('no SEN ether processes discovered');
300
365
  }
366
+ this.targets = targets;
301
367
  for (const target of targets) {
302
- const sessionName = target.session?.name ?? target.info?.sessionName;
368
+ const sessionName = targetSessionName(target);
303
369
  if (sessionName && !this.targetsBySession.has(sessionName)) {
304
370
  this.targetsBySession.set(sessionName, target);
305
371
  }
@@ -324,6 +390,12 @@ export class Sen extends EventEmitter {
324
390
  if (!sessionName) {
325
391
  throw new Error('cannot connect without a SEN session name');
326
392
  }
393
+ if (!this.targets.includes(target)) {
394
+ this.targets.push(target);
395
+ }
396
+ if (!this.targetsBySession.has(sessionName)) {
397
+ this.targetsBySession.set(sessionName, target);
398
+ }
327
399
 
328
400
  const client = new EtherClient({
329
401
  sessionName,
@@ -336,10 +408,20 @@ export class Sen extends EventEmitter {
336
408
  this.target = target;
337
409
  this.#wireClient(client);
338
410
 
339
- await client.connect(target);
340
- await waitForEvent(client, 'ready', config.timeout ?? 3000);
341
- this.emit('connect', { target, sessionName });
342
- return this;
411
+ try {
412
+ await client.connect(target);
413
+ await waitForEvent(client, 'ready', config.timeout ?? 3000);
414
+ this.#startPresenceWatchdog(target, config);
415
+ this.emit('connect', { target, sessionName });
416
+ return this;
417
+ } catch (error) {
418
+ await client.close().catch(closeError => this.emit('warning', closeError));
419
+ if (this.client === client) {
420
+ this.client = undefined;
421
+ this.target = undefined;
422
+ }
423
+ throw error;
424
+ }
343
425
  }
344
426
 
345
427
  /**
@@ -501,9 +583,10 @@ export class Sen extends EventEmitter {
501
583
  }
502
584
 
503
585
  return [...new Set([
586
+ ...this.targets.map(targetSessionName),
504
587
  ...this.targetsBySession.keys(),
505
588
  ...this.sessions.keys()
506
- ])].sort();
589
+ ].filter(Boolean))].sort();
507
590
  }
508
591
 
509
592
  listBuses(options = {}) {
@@ -519,6 +602,104 @@ export class Sen extends EventEmitter {
519
602
  .map(busName => options.qualified ? queryBusName(sessionName, busName) : busName);
520
603
  }
521
604
 
605
+ /**
606
+ * Discover visible SEN buses without creating interests or joining buses.
607
+ *
608
+ * @param {object} [options]
609
+ * @param {string} [options.session] Optional session filter.
610
+ * @param {number} [options.busDiscoverySettleMs] Delay after lightweight session connect before reading announced buses.
611
+ * @returns {Promise<Array<{session:string,bus:string,qualified:string}>>}
612
+ */
613
+ async discoverBuses(options = {}) {
614
+ const config = { ...this.options, ...options };
615
+ const settleMs = Math.max(0, Number(config.busDiscoverySettleMs ?? Math.max(config.discoverySettleMs ?? 100, 1000)) || 0);
616
+
617
+ if (this.client) {
618
+ const sessionName = this.target?.session?.name ?? this.client.processInfo.sessionName;
619
+ return (await waitForSessionBuses(this, settleMs))
620
+ .map(busName => busSummary(sessionName, busName))
621
+ .filter(Boolean)
622
+ .sort((a, b) => a.qualified.localeCompare(b.qualified));
623
+ }
624
+
625
+ if (!this.targets.length && !this.sessions.size) {
626
+ const targets = await this.#discoverTargets(config);
627
+ if (!targets.length) {
628
+ throw new Error('no SEN ether processes discovered');
629
+ }
630
+ this.targets = targets;
631
+ for (const target of targets) {
632
+ const sessionName = targetSessionName(target);
633
+ if (sessionName && !this.targetsBySession.has(sessionName)) {
634
+ this.targetsBySession.set(sessionName, target);
635
+ }
636
+ }
637
+ }
638
+
639
+ const summaries = new Map();
640
+ const addBus = (sessionName, busName) => {
641
+ const summary = busSummary(sessionName, busName);
642
+ if (summary) {
643
+ summaries.set(summary.qualified, summary);
644
+ }
645
+ };
646
+
647
+ for (const session of this.sessions.values()) {
648
+ const sessionName = session.target?.session?.name ?? session.client?.processInfo?.sessionName;
649
+ for (const busName of await waitForSessionBuses(session, settleMs)) {
650
+ addBus(sessionName, busName);
651
+ }
652
+ }
653
+
654
+ const targets = filterTargets(this.targets, config);
655
+ const discoveries = targets.map(async target => {
656
+ const sessionName = targetSessionName(target);
657
+ if (!sessionName) {
658
+ return;
659
+ }
660
+
661
+ const session = new Sen({
662
+ ...config,
663
+ session: sessionName,
664
+ reconnect: false
665
+ });
666
+ session.on('warning', error => this.emit('warning', error));
667
+ session.on('error', error => this.emit('warning', error));
668
+ try {
669
+ await session.connect({
670
+ ...config,
671
+ session: sessionName,
672
+ target,
673
+ reconnect: false
674
+ });
675
+ for (const busName of await waitForSessionBuses(session, settleMs)) {
676
+ addBus(sessionName, busName);
677
+ }
678
+ } finally {
679
+ await session.close().catch(error => this.emit('warning', error));
680
+ }
681
+ });
682
+
683
+ const results = await Promise.allSettled(discoveries);
684
+ const failures = [];
685
+ for (const result of results) {
686
+ if (result.status === 'rejected') {
687
+ failures.push(result.reason);
688
+ this.emit('warning', result.reason);
689
+ }
690
+ }
691
+
692
+ if (!summaries.size && targets.length && failures.length === targets.length) {
693
+ const sessions = [...new Set(targets.map(targetSessionName).filter(Boolean))].join(', ');
694
+ const error = new Error(`could not read SEN bus announcements from any discovered target${sessions ? ` in sessions: ${sessions}` : ''}`);
695
+ error.code = 'SEN_BUS_DISCOVERY_FAILED';
696
+ error.cause = failures[0];
697
+ throw error;
698
+ }
699
+
700
+ return [...summaries.values()].sort((a, b) => a.qualified.localeCompare(b.qualified));
701
+ }
702
+
522
703
  objects() {
523
704
  if (!this.client) {
524
705
  return [...this.sessions.values()].flatMap(session => session.objects());
@@ -561,13 +742,14 @@ export class Sen extends EventEmitter {
561
742
  async close() {
562
743
  this.manualClose = true;
563
744
  for (const session of this.sessions.values()) {
564
- await session.close();
745
+ await session.close().catch(error => this.emit('warning', error));
565
746
  }
566
747
  for (const bus of this.buses.values()) {
567
748
  bus.close();
568
749
  }
750
+ this.#stopPresenceWatchdog();
569
751
  await wait(50);
570
- await this.client?.close();
752
+ await this.client?.close().catch(error => this.emit('warning', error));
571
753
  this.client = undefined;
572
754
  this.sessions.clear();
573
755
  this.buses.clear();
@@ -675,9 +857,10 @@ export class Sen extends EventEmitter {
675
857
  this.emit('error', error);
676
858
  });
677
859
  client.on('close', hadError => {
860
+ this.#stopPresenceWatchdog();
678
861
  this.emit('close', hadError);
679
862
  if (!this.manualClose && this.options.reconnect !== false) {
680
- this.#reconnect().catch(error => this.emit('error', error));
863
+ this.#reconnect().catch(error => this.emit('warning', error));
681
864
  }
682
865
  });
683
866
  }
@@ -688,12 +871,15 @@ export class Sen extends EventEmitter {
688
871
  }
689
872
 
690
873
  this.reconnecting = true;
874
+ this.#stopPresenceWatchdog();
691
875
  await this.client?.close().catch(error => this.emit('warning', error));
692
876
  this.emit('reconnecting');
693
- const maxAttempts = this.connectOptions.maxReconnectAttempts ?? this.options.maxReconnectAttempts ?? 10;
877
+ const configuredMaxAttempts = this.connectOptions.maxReconnectAttempts ?? this.options.maxReconnectAttempts ?? 0;
878
+ const maxAttempts = Number(configuredMaxAttempts);
879
+ const unlimited = !Number.isFinite(maxAttempts) || maxAttempts <= 0;
694
880
  const delayMs = this.connectOptions.reconnectDelayMs ?? this.options.reconnectDelayMs ?? 500;
695
881
 
696
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
882
+ for (let attempt = 1; unlimited || attempt <= maxAttempts; attempt += 1) {
697
883
  let client;
698
884
  try {
699
885
  await wait(delayMs);
@@ -727,6 +913,7 @@ export class Sen extends EventEmitter {
727
913
 
728
914
  await client.connect(target);
729
915
  await waitForEvent(client, 'ready', config.timeout ?? 3000);
916
+ this.#startPresenceWatchdog(target, config);
730
917
 
731
918
  for (const bus of this.buses.values()) {
732
919
  await bus.rejoin(config.timeout ?? 3000);
@@ -737,6 +924,10 @@ export class Sen extends EventEmitter {
737
924
  return;
738
925
  } catch (error) {
739
926
  await client?.close().catch(closeError => this.emit('warning', closeError));
927
+ if (this.client === client) {
928
+ this.client = undefined;
929
+ this.target = undefined;
930
+ }
740
931
  if (this.manualClose) {
741
932
  this.reconnecting = false;
742
933
  return;
@@ -754,6 +945,74 @@ export class Sen extends EventEmitter {
754
945
  throw new Error(`failed to reconnect SEN ether after ${maxAttempts} attempt(s)`);
755
946
  }
756
947
 
948
+ #startPresenceWatchdog(target, config) {
949
+ this.#stopPresenceWatchdog();
950
+ const key = target?.key;
951
+ if (!key) {
952
+ return;
953
+ }
954
+
955
+ const timeoutMs = Number(config.presenceTimeoutMs ?? this.options.presenceTimeoutMs ?? 0);
956
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
957
+ return;
958
+ }
959
+
960
+ const intervalMs = Math.max(
961
+ 250,
962
+ Number(config.presenceCheckIntervalMs ?? this.options.presenceCheckIntervalMs ?? 1000) || 1000
963
+ );
964
+
965
+ const scanner = config.tcpHub
966
+ ? new TcpDiscoveryHubScanner(parseHostPort(config.tcpHub))
967
+ : new EtherDiscoveryScanner(config);
968
+
969
+ this.presenceScanner = scanner;
970
+ this.presenceLastSeen = Date.now();
971
+
972
+ scanner.on('beam', process => {
973
+ if (process.key === key) {
974
+ this.presenceLastSeen = Date.now();
975
+ }
976
+ });
977
+ scanner.on('error', error => this.emit('warning', error));
978
+ scanner.on('close', hadError => {
979
+ if (!this.manualClose && !this.reconnecting) {
980
+ this.emit('warning', new Error(`SEN ether discovery watchdog closed${hadError ? ' with error' : ''}`));
981
+ }
982
+ });
983
+ scanner.start().catch(error => this.emit('warning', error));
984
+
985
+ this.presenceTimer = setInterval(() => {
986
+ if (this.manualClose || this.reconnecting || !this.client) {
987
+ return;
988
+ }
989
+ const elapsedMs = Date.now() - this.presenceLastSeen;
990
+ if (elapsedMs <= timeoutMs) {
991
+ return;
992
+ }
993
+ const error = new Error(`SEN ether presence timeout after ${elapsedMs}ms without beam from ${key}`);
994
+ error.code = 'SEN_PRESENCE_TIMEOUT';
995
+ this.emit('warning', error);
996
+ this.client.socket?.destroy(error);
997
+ }, intervalMs);
998
+ this.presenceTimer.unref?.();
999
+ }
1000
+
1001
+ #stopPresenceWatchdog() {
1002
+ if (this.presenceTimer) {
1003
+ clearInterval(this.presenceTimer);
1004
+ this.presenceTimer = undefined;
1005
+ }
1006
+
1007
+ const scanner = this.presenceScanner;
1008
+ this.presenceScanner = undefined;
1009
+ this.presenceLastSeen = 0;
1010
+ if (scanner) {
1011
+ scanner.removeAllListeners();
1012
+ scanner.stop().catch(error => this.emit('warning', error));
1013
+ }
1014
+ }
1015
+
757
1016
  #busForEvent(event) {
758
1017
  if (event.bus?.busName) {
759
1018
  return this.buses.get(event.bus.busName);
@@ -814,18 +1073,29 @@ export class SenBus extends EventEmitter {
814
1073
  const interestId = typeof id === 'object' ? id.id : id;
815
1074
  this.sen.client.stopInterest(this.name, interestId);
816
1075
  const interest = this.interests.get(interestId);
1076
+ this.#detachInterestObjects(interestId, interest);
817
1077
  this.interests.delete(interestId);
818
1078
  interest?.closeLocal();
819
1079
  interest?.emit('close');
820
1080
  }
821
1081
 
822
1082
  close() {
823
- for (const interest of this.interests.values()) {
824
- interest.closeLocal();
825
- this.sen.client.stopInterest(this.name, interest.id);
1083
+ for (const interest of [...this.interests.values()]) {
1084
+ try {
1085
+ this.stopInterest(interest.id);
1086
+ } catch (error) {
1087
+ this.#detachInterestObjects(interest.id, interest);
1088
+ this.interests.delete(interest.id);
1089
+ interest.closeLocal();
1090
+ interest.emit('close');
1091
+ this.sen.emit('warning', error);
1092
+ }
1093
+ }
1094
+ try {
1095
+ this.sen.client.leaveBus(this.name);
1096
+ } catch (error) {
1097
+ this.sen.emit('warning', error);
826
1098
  }
827
- this.interests.clear();
828
- this.sen.client.leaveBus(this.name);
829
1099
  }
830
1100
 
831
1101
  prepareReconnect() {
@@ -884,24 +1154,36 @@ export class SenBus extends EventEmitter {
884
1154
  const newTypeHashes = new Set();
885
1155
  for (const discovery of event.discoveries ?? []) {
886
1156
  for (const info of discovery.objects ?? []) {
887
- const object = new SenRemoteObject(this, {
888
- ...info,
889
- ownerId: event.ownerId,
890
- interestId: discovery.interestId
891
- });
892
- this.objectsById.set(object.id, object);
1157
+ let object = this.objectsById.get(info.id);
1158
+ const isNewObject = !object;
1159
+ if (!object) {
1160
+ object = new SenRemoteObject(this, {
1161
+ ...info,
1162
+ ownerId: event.ownerId,
1163
+ interestId: discovery.interestId
1164
+ });
1165
+ this.objectsById.set(object.id, object);
1166
+ } else {
1167
+ object.attachInterest(discovery.interestId);
1168
+ object.updateDiscoveryInfo({
1169
+ ...info,
1170
+ ownerId: event.ownerId
1171
+ });
1172
+ }
893
1173
  const interest = this.interests.get(discovery.interestId);
894
1174
  interest?.objectsById.set(object.id, object);
895
1175
  if (info.state?.length) {
896
- object.applyState(info.state, 'state', info.time);
1176
+ object.applyState(info.state, 'state', info.time, { interestId: discovery.interestId });
897
1177
  }
898
1178
  if (!this.requestedTypeHashes.has(info.typeHash)) {
899
1179
  this.requestedTypeHashes.add(info.typeHash);
900
1180
  newTypeHashes.add(info.typeHash);
901
1181
  }
902
1182
  interest?.emit('object', object);
903
- this.emit('object', object);
904
- this.sen.emit('object', object);
1183
+ if (isNewObject) {
1184
+ this.emit('object', object);
1185
+ this.sen.emit('object', object);
1186
+ }
905
1187
  }
906
1188
  }
907
1189
  if (newTypeHashes.size) {
@@ -914,20 +1196,47 @@ export class SenBus extends EventEmitter {
914
1196
  for (const removal of event.removals ?? []) {
915
1197
  for (const id of removal.ids ?? []) {
916
1198
  const object = this.objectsById.get(id);
917
- this.objectsById.delete(id);
918
- this.stateRequestedObjectIds.delete(id);
919
1199
  const interest = this.interests.get(removal.interestId);
920
1200
  interest?.objectsById.delete(id);
1201
+ this.stateRequestedObjectIds.delete(stateRequestKey(removal.interestId, id));
921
1202
  if (object) {
922
- object.emit('remove');
1203
+ object.detachInterest(removal.interestId);
1204
+ object.emit('remove', { interestId: removal.interestId });
923
1205
  interest?.emit('remove', object);
924
- this.emit('remove', object);
925
- this.sen.emit('remove', object);
1206
+ if (object.interestIds.size === 0) {
1207
+ this.objectsById.delete(id);
1208
+ this.requestedTypeHashes.delete(object.typeHash);
1209
+ this.emit('remove', object);
1210
+ this.sen.emit('remove', object);
1211
+ }
926
1212
  }
927
1213
  }
928
1214
  }
929
1215
  }
930
1216
 
1217
+ #detachInterestObjects(interestId, interest) {
1218
+ const normalizedInterestId = interestId >>> 0;
1219
+ const keyPrefix = `${normalizedInterestId}:`;
1220
+ for (const key of [...this.stateRequestedObjectIds]) {
1221
+ if (key.startsWith(keyPrefix)) {
1222
+ this.stateRequestedObjectIds.delete(key);
1223
+ }
1224
+ }
1225
+
1226
+ if (!interest) {
1227
+ return;
1228
+ }
1229
+
1230
+ for (const object of interest.objectsById.values()) {
1231
+ object.detachInterest(normalizedInterestId);
1232
+ if (object.interestIds.size === 0) {
1233
+ this.objectsById.delete(object.id);
1234
+ this.requestedTypeHashes.delete(object.typeHash);
1235
+ }
1236
+ }
1237
+ interest.objectsById.clear();
1238
+ }
1239
+
931
1240
  handleTypesInfoResponse(event) {
932
1241
  const dependentTypeHashes = new Set();
933
1242
  for (const type of event.types ?? []) {
@@ -962,7 +1271,7 @@ export class SenBus extends EventEmitter {
962
1271
  if (!object) {
963
1272
  continue;
964
1273
  }
965
- object.applyState(state.state, 'state', state.timestamp);
1274
+ object.applyState(state.state, 'state', state.timestamp, { interestId: response.interestId });
966
1275
  }
967
1276
  }
968
1277
  }
@@ -1042,14 +1351,20 @@ export class SenBus extends EventEmitter {
1042
1351
 
1043
1352
  #requestReadyObjectStates() {
1044
1353
  const requestsByInterest = new Map();
1045
- for (const object of this.objectsById.values()) {
1046
- if (this.stateRequestedObjectIds.has(object.id) || !object.spec) {
1047
- continue;
1354
+ for (const interest of this.interests.values()) {
1355
+ for (const object of interest.objectsById.values()) {
1356
+ if (!object.spec) {
1357
+ continue;
1358
+ }
1359
+ const key = stateRequestKey(interest.id, object.id);
1360
+ if (this.stateRequestedObjectIds.has(key)) {
1361
+ continue;
1362
+ }
1363
+ this.stateRequestedObjectIds.add(key);
1364
+ const ids = requestsByInterest.get(interest.id) ?? [];
1365
+ ids.push(object.id);
1366
+ requestsByInterest.set(interest.id, ids);
1048
1367
  }
1049
- this.stateRequestedObjectIds.add(object.id);
1050
- const ids = requestsByInterest.get(object.interestId) ?? [];
1051
- ids.push(object.id);
1052
- requestsByInterest.set(object.interestId, ids);
1053
1368
  }
1054
1369
 
1055
1370
  if (requestsByInterest.size) {
@@ -1066,7 +1381,8 @@ export class SenBus extends EventEmitter {
1066
1381
  object.applyState(
1067
1382
  object.pendingState.buffer,
1068
1383
  object.pendingState.source,
1069
- object.pendingState.timestampNs
1384
+ object.pendingState.timestampNs,
1385
+ { interestId: object.pendingState.interestId }
1070
1386
  );
1071
1387
  }
1072
1388
  }
@@ -1164,6 +1480,10 @@ export class SenRemoteObject extends EventEmitter {
1164
1480
  this.typeHash = info.typeHash;
1165
1481
  this.ownerId = info.ownerId;
1166
1482
  this.interestId = info.interestId;
1483
+ this.interestIds = new Set();
1484
+ if (info.interestId !== undefined) {
1485
+ this.interestIds.add(info.interestId);
1486
+ }
1167
1487
  this.snapshot = {};
1168
1488
  this.spec = undefined;
1169
1489
  this.pendingState = undefined;
@@ -1186,6 +1506,29 @@ export class SenRemoteObject extends EventEmitter {
1186
1506
  return this.name === selector || this.className === selector || String(this.id) === String(selector);
1187
1507
  }
1188
1508
 
1509
+ attachInterest(interestId) {
1510
+ if (interestId !== undefined) {
1511
+ this.interestIds.add(interestId);
1512
+ this.interestId = interestId;
1513
+ }
1514
+ }
1515
+
1516
+ detachInterest(interestId) {
1517
+ if (interestId !== undefined) {
1518
+ this.interestIds.delete(interestId);
1519
+ if (this.interestId === interestId) {
1520
+ this.interestId = this.interestIds.values().next().value;
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ updateDiscoveryInfo(info) {
1526
+ this.name = info.name ?? this.name;
1527
+ this.className = info.className ?? this.className;
1528
+ this.typeHash = info.typeHash ?? this.typeHash;
1529
+ this.ownerId = info.ownerId ?? this.ownerId;
1530
+ }
1531
+
1189
1532
  property(name) {
1190
1533
  return findByName(collectClassMembers(this.spec, this.bus.typeRegistry, 'properties'), name);
1191
1534
  }
@@ -1259,17 +1602,18 @@ export class SenRemoteObject extends EventEmitter {
1259
1602
  return await this.bus.callObjectMethod(this, method, args, options);
1260
1603
  }
1261
1604
 
1262
- applyState(buffer, source, timestamp) {
1605
+ applyState(buffer, source, timestamp, options = {}) {
1263
1606
  const timestampNs = normalizeTimestampNs(timestamp);
1264
1607
  this.#rememberObjectTimestamp(source, timestampNs);
1265
1608
 
1266
1609
  if (!this.spec) {
1267
- this.pendingState = { buffer, source, timestampNs };
1610
+ this.pendingState = { buffer, source, timestampNs, interestId: options.interestId };
1268
1611
  return;
1269
1612
  }
1270
1613
 
1271
- const interest = this.bus.interests.get(this.interestId);
1272
- const values = decodePropertyValues(buffer, this.spec, this.bus.typeRegistry, interest?.decodeOptions());
1614
+ const interests = this.#targetInterests(options.interestId);
1615
+ const decodeInterest = options.interestId !== undefined || interests.length === 1 ? interests[0] : undefined;
1616
+ const values = decodePropertyValues(buffer, this.spec, this.bus.typeRegistry, decodeInterest?.decodeOptions());
1273
1617
  let complete = true;
1274
1618
  for (const value of values) {
1275
1619
  if (!value.decoded) {
@@ -1292,9 +1636,10 @@ export class SenRemoteObject extends EventEmitter {
1292
1636
  previous,
1293
1637
  property: value.property
1294
1638
  };
1295
- if (interest) {
1639
+ for (const interest of interests) {
1296
1640
  interest.publishChange(change);
1297
- } else {
1641
+ }
1642
+ if (!interests.length) {
1298
1643
  this.emit('change', change);
1299
1644
  this.emit(`change:${value.name}`, change);
1300
1645
  this.bus.emit('change', change);
@@ -1302,7 +1647,22 @@ export class SenRemoteObject extends EventEmitter {
1302
1647
  }
1303
1648
  }
1304
1649
 
1305
- this.pendingState = complete ? undefined : { buffer, source, timestampNs };
1650
+ this.pendingState = complete ? undefined : { buffer, source, timestampNs, interestId: options.interestId };
1651
+ }
1652
+
1653
+ #targetInterests(interestId) {
1654
+ if (interestId !== undefined) {
1655
+ const interest = this.bus.interests.get(interestId);
1656
+ return interest ? [interest] : [];
1657
+ }
1658
+
1659
+ const interests = [];
1660
+ for (const interest of this.bus.interests.values()) {
1661
+ if (interest.objectsById.has(this.id)) {
1662
+ interests.push(interest);
1663
+ }
1664
+ }
1665
+ return interests;
1306
1666
  }
1307
1667
 
1308
1668
  #rememberObjectTimestamp(source, timestampNs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sen-ether-client",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pure JavaScript SEN client for existing kernels over ether",
5
5
  "senCompatibility": {
6
6
  "kernelProtocolVersion": 9,