sen-ether-client 0.1.0

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.
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env node
2
+ import { once } from 'node:events';
3
+ import { EtherClient } from '../lib/client.js';
4
+ import { scan, scanTcpDiscoveryHub } from '../lib/discovery.js';
5
+ import { decodePropertyValues } from '../lib/values.js';
6
+
7
+ function parseArgs(argv) {
8
+ const options = {
9
+ timeout: 3000,
10
+ listen: 10000,
11
+ bus: 'scenario.control'
12
+ };
13
+
14
+ for (let i = 0; i < argv.length; i += 1) {
15
+ const arg = argv[i];
16
+ if (arg === '--timeout') {
17
+ options.timeout = Number(argv[++i]);
18
+ } else if (arg === '--listen') {
19
+ options.listen = Number(argv[++i]);
20
+ } else if (arg === '--group') {
21
+ options.group = argv[++i];
22
+ } else if (arg === '--port') {
23
+ options.port = Number(argv[++i]);
24
+ } else if (arg === '--interface') {
25
+ options.interfaceAddress = argv[++i];
26
+ } else if (arg === '--tcp-hub') {
27
+ options.tcpHub = argv[++i];
28
+ } else if (arg === '--session') {
29
+ options.session = argv[++i];
30
+ } else if (arg === '--app') {
31
+ options.app = argv[++i];
32
+ } else if (arg === '--bus') {
33
+ options.bus = argv[++i];
34
+ } else if (arg === '--query') {
35
+ options.query = argv[++i];
36
+ } else if (arg === '--force-bus') {
37
+ options.forceBus = true;
38
+ } else if (arg === '--help' || arg === '-h') {
39
+ options.help = true;
40
+ } else {
41
+ throw new Error(`unknown argument: ${arg}`);
42
+ }
43
+ }
44
+
45
+ return options;
46
+ }
47
+
48
+ function printHelp() {
49
+ console.log(`Usage: sen-ether-probe [options]
50
+
51
+ Options:
52
+ --timeout <ms> Discovery timeout. Default: 3000
53
+ --listen <ms> Time to listen after starting the interest. Default: 10000
54
+ --group <address> Discovery multicast group. Default: 239.255.0.44
55
+ --port <port> Discovery multicast port. Default: 60543
56
+ --interface <addr> Local interface address or interface name for multicast membership
57
+ --tcp-hub <host:port>
58
+ Use SEN TcpDiscoveryHub instead of multicast discovery
59
+ --session <name> SEN session name filter
60
+ --app <name> SEN appName substring filter
61
+ --bus <name> Bus to join. Default: scenario.control
62
+ --query <query> Interest query. Default: SELECT * FROM <bus>
63
+ --force-bus Join even if the remote process has not announced the bus
64
+ -h, --help Show this help
65
+
66
+ Examples:
67
+ sen-ether-probe --bus scenario.control
68
+ sen-ether-probe --tcp-hub 127.0.0.1:64222 --bus scenario.control
69
+ sen-ether-probe --bus world1.environment --query "SELECT * FROM world1.environment"
70
+
71
+ Environment:
72
+ SEN_ETHER_DISCOVERY_PORT Default multicast discovery port
73
+ `);
74
+ }
75
+
76
+ function parseHostPort(value) {
77
+ const text = String(value || '').trim();
78
+ const idx = text.lastIndexOf(':');
79
+ if (idx <= 0) {
80
+ throw new Error(`invalid --tcp-hub value, expected host:port: ${text}`);
81
+ }
82
+ const host = text.slice(0, idx);
83
+ const port = Number(text.slice(idx + 1));
84
+ if (!host || !Number.isInteger(port) || port <= 0) {
85
+ throw new Error(`invalid --tcp-hub value, expected host:port: ${text}`);
86
+ }
87
+ return { host, port };
88
+ }
89
+
90
+ function etherBusName(sessionName, bus) {
91
+ const session = String(sessionName || '').trim();
92
+ const text = String(bus || '').trim();
93
+ const prefix = `${session}.`;
94
+ return session && text.startsWith(prefix) ? text.slice(prefix.length) : text;
95
+ }
96
+
97
+ function queryBusName(sessionName, bus) {
98
+ const session = String(sessionName || '').trim();
99
+ const text = String(bus || '').trim();
100
+ return text.includes('.') || !session ? text : `${session}.${text}`;
101
+ }
102
+
103
+ function findTarget(processes, options) {
104
+ let candidates = processes;
105
+ if (options.session) {
106
+ candidates = candidates.filter(item => item.session?.name === options.session);
107
+ }
108
+ if (options.app) {
109
+ const app = String(options.app).toLowerCase();
110
+ candidates = candidates.filter(item => String(item.process?.appName || '').toLowerCase().includes(app));
111
+ }
112
+ if (!candidates.length) {
113
+ return null;
114
+ }
115
+ return candidates[0];
116
+ }
117
+
118
+ function wait(ms) {
119
+ return new Promise(resolve => setTimeout(resolve, ms));
120
+ }
121
+
122
+ async function waitForEvent(emitter, event, timeoutMs) {
123
+ const timeout = new Promise((_, reject) => {
124
+ setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), timeoutMs);
125
+ });
126
+ return await Promise.race([once(emitter, event), timeout]);
127
+ }
128
+
129
+ async function waitForRemoteBus(emitter, remoteBuses, busName, timeoutMs) {
130
+ if (remoteBuses.has(busName)) {
131
+ return;
132
+ }
133
+
134
+ await new Promise((resolve, reject) => {
135
+ const timeout = setTimeout(() => {
136
+ emitter.off('busJoined', onBusJoined);
137
+ const announced = [...remoteBuses].sort().join(', ') || '<none>';
138
+ reject(new Error(`remote process did not announce bus "${busName}" within ${timeoutMs}ms; announced: ${announced}`));
139
+ }, timeoutMs);
140
+
141
+ const onBusJoined = value => {
142
+ if (value.busName !== busName) {
143
+ return;
144
+ }
145
+ clearTimeout(timeout);
146
+ emitter.off('busJoined', onBusJoined);
147
+ resolve();
148
+ };
149
+
150
+ emitter.on('busJoined', onBusJoined);
151
+ });
152
+ }
153
+
154
+ function printProcess(item, prefix = '-') {
155
+ console.log(`${prefix} session=${item.session?.name || '<empty>'} app=${item.process?.appName || '<unknown>'} host=${item.process?.hostName || '<unknown>'}`);
156
+ for (const endpoint of item.endpoints ?? []) {
157
+ console.log(` endpoint=${endpoint.host}:${endpoint.port}`);
158
+ }
159
+ }
160
+
161
+ function printTypeSpec(response) {
162
+ const spec = response.spec;
163
+ const data = spec.data;
164
+ console.log(`[types] ${response.type} hash=${response.classHash ?? '<non-class>'} type=${spec.qualifiedName} kind=${data.type}`);
165
+
166
+ if (data.type !== 'ClassTypeSpec') {
167
+ return;
168
+ }
169
+
170
+ const classSpec = data.value;
171
+ console.log(` properties=${classSpec.properties.length} methods=${classSpec.methods.length} events=${classSpec.events.length} parents=${classSpec.parents.length}`);
172
+ for (const property of classSpec.properties) {
173
+ console.log(` property ${property.name}: ${property.type} ${property.category} ${property.transportMode}${property.checkedSet ? ' checkedSet' : ''}`);
174
+ }
175
+ for (const method of classSpec.methods) {
176
+ const args = method.args.map(arg => `${arg.name}: ${arg.type}`).join(', ');
177
+ console.log(` method ${method.name}(${args}) -> ${method.returnType || 'void'} ${method.constness} ${method.transportMode} ${method.propertyRelation}`);
178
+ }
179
+ for (const event of classSpec.events) {
180
+ const args = event.args.map(arg => `${arg.name}: ${arg.type}`).join(', ');
181
+ console.log(` event ${event.name}(${args}) ${event.transportMode}`);
182
+ }
183
+ }
184
+
185
+ function formatValue(value) {
186
+ if (typeof value === 'bigint') {
187
+ return `${value}n`;
188
+ }
189
+ if (value && typeof value === 'object') {
190
+ return JSON.stringify(value);
191
+ }
192
+ return String(value);
193
+ }
194
+
195
+ function classSpecForObject(object, typeRegistry) {
196
+ return object ? typeRegistry.get(object.className) : undefined;
197
+ }
198
+
199
+ function applyPropertyValues(objectState, propertyValues) {
200
+ for (const property of propertyValues) {
201
+ if (property.decoded && property.name) {
202
+ objectState.snapshot[property.name] = property.value;
203
+ }
204
+ }
205
+ }
206
+
207
+ function formatPropertyValues(propertyValues) {
208
+ return propertyValues
209
+ .map(update => {
210
+ if (!update.name) {
211
+ return `0x${update.id.toString(16).padStart(8, '0')}:${update.size}`;
212
+ }
213
+ if (!update.decoded) {
214
+ return `${update.name}<${update.type}>:${update.size}`;
215
+ }
216
+ return `${update.name}=${formatValue(update.value)}`;
217
+ })
218
+ .join(', ');
219
+ }
220
+
221
+ try {
222
+ const options = parseArgs(process.argv.slice(2));
223
+ if (options.help) {
224
+ printHelp();
225
+ process.exit(0);
226
+ }
227
+
228
+ console.log(`[scan] timeout=${options.timeout}ms${options.tcpHub ? ` tcpHub=${options.tcpHub}` : ''}`);
229
+ const processes = options.tcpHub
230
+ ? await scanTcpDiscoveryHub({ ...parseHostPort(options.tcpHub), timeout: options.timeout })
231
+ : await scan(options);
232
+ if (!processes.length) {
233
+ throw new Error('no SEN ether processes discovered');
234
+ }
235
+
236
+ console.log(`[scan] discovered ${processes.length} process(es)`);
237
+ for (const item of processes) {
238
+ printProcess(item);
239
+ }
240
+
241
+ const target = findTarget(processes, options);
242
+ if (!target) {
243
+ throw new Error('no discovered process matches the requested filters');
244
+ }
245
+
246
+ console.log('[target]');
247
+ printProcess(target, '*');
248
+
249
+ const bus = etherBusName(target.session.name, options.bus);
250
+ const query = options.query ?? `SELECT * FROM ${queryBusName(target.session.name, options.bus)}`;
251
+ if (bus !== options.bus) {
252
+ console.log(`[bus] normalized ${options.bus} -> ${bus} for ether session=${target.session.name}`);
253
+ }
254
+
255
+ const client = new EtherClient({
256
+ sessionName: target.session.name,
257
+ appName: 'sen-ether-probe'
258
+ });
259
+ const remoteBuses = new Set();
260
+ const requestedTypeHashes = new Set();
261
+ const objectsById = new Map();
262
+ const typeRegistry = new Map();
263
+ const stateRequestedObjectIds = new Set();
264
+
265
+ function requestReadyObjectStates() {
266
+ const requestsByInterest = new Map();
267
+ for (const object of objectsById.values()) {
268
+ if (stateRequestedObjectIds.has(object.id)) {
269
+ continue;
270
+ }
271
+ if (!classSpecForObject(object, typeRegistry)) {
272
+ continue;
273
+ }
274
+ stateRequestedObjectIds.add(object.id);
275
+ const ids = requestsByInterest.get(object.interestId) ?? [];
276
+ ids.push(object.id);
277
+ requestsByInterest.set(object.interestId, ids);
278
+ }
279
+
280
+ if (requestsByInterest.size) {
281
+ client.requestObjectStates(bus, [...requestsByInterest].map(([interestId, objectIds]) => ({ interestId, objectIds })));
282
+ }
283
+ }
284
+
285
+ client.on('remoteProcess', hello => {
286
+ console.log(`[ether] remote hello app=${hello.info.appName} session=${hello.info.sessionName}`);
287
+ });
288
+ client.on('ready', () => {
289
+ console.log('[ether] ready');
290
+ });
291
+ client.on('busJoined', value => {
292
+ remoteBuses.add(value.busName);
293
+ console.log(`[ether] remote bus joined name=${value.busName} id=${value.busId} participant=${value.participantId}`);
294
+ });
295
+ client.on('busParticipantReady', value => {
296
+ console.log(`[bus] participant ready bus=${value.busName} local=${value.participantId} remote=${value.remoteParticipantId}`);
297
+ });
298
+ client.on('interestStarted', value => {
299
+ console.log(`[bus] interest started id=${value.id} query=${value.query}`);
300
+ });
301
+ client.on('typesInfoRequested', value => {
302
+ console.log(`[types] requested ${value.requests.length} type spec(s): ${value.requests.join(', ')}`);
303
+ });
304
+ client.on('typesInfoResponse', event => {
305
+ console.log(`[types] response owner=${event.ownerId} count=${event.types.length}`);
306
+ const dependentTypeHashes = new Set();
307
+ for (const type of event.types) {
308
+ typeRegistry.set(type.spec.qualifiedName, type.spec);
309
+ for (const hash of type.dependentTypes ?? []) {
310
+ if (!requestedTypeHashes.has(hash)) {
311
+ requestedTypeHashes.add(hash);
312
+ dependentTypeHashes.add(hash);
313
+ }
314
+ }
315
+ printTypeSpec(type);
316
+ }
317
+ if (dependentTypeHashes.size) {
318
+ client.requestTypes(bus, dependentTypeHashes);
319
+ }
320
+ requestReadyObjectStates();
321
+ });
322
+ client.on('typesInfoRejection', event => {
323
+ console.warn(`[types] rejection owner=${event.ownerId}: ${event.rejections.join('; ')}`);
324
+ });
325
+ client.on('objectsPublished', event => {
326
+ let total = 0;
327
+ const newTypeHashes = new Set();
328
+ for (const discovery of event.discoveries ?? []) {
329
+ total += discovery.objects?.length ?? 0;
330
+ }
331
+ console.log(`[objects] published owner=${event.ownerId} discoveries=${event.discoveries?.length ?? 0} objects=${total}`);
332
+ for (const discovery of event.discoveries ?? []) {
333
+ for (const object of discovery.objects ?? []) {
334
+ objectsById.set(object.id, {
335
+ ...object,
336
+ interestId: discovery.interestId,
337
+ snapshot: {}
338
+ });
339
+ console.log(` interest=${discovery.interestId} id=${object.id} class=${object.className} name=${object.name} stateBytes=${object.state.length}`);
340
+ if (!requestedTypeHashes.has(object.typeHash)) {
341
+ requestedTypeHashes.add(object.typeHash);
342
+ newTypeHashes.add(object.typeHash);
343
+ }
344
+ }
345
+ }
346
+ if (newTypeHashes.size) {
347
+ client.requestTypes(bus, newTypeHashes);
348
+ }
349
+ requestReadyObjectStates();
350
+ });
351
+ client.on('objectsRemoved', event => {
352
+ console.log(`[objects] removed groups=${event.removals?.length ?? 0}`);
353
+ for (const removal of event.removals ?? []) {
354
+ for (const id of removal.ids ?? []) {
355
+ objectsById.delete(id);
356
+ stateRequestedObjectIds.delete(id);
357
+ }
358
+ }
359
+ });
360
+ client.on('objectsStateRequested', event => {
361
+ const count = event.requests.reduce((acc, request) => acc + request.objectIds.length, 0);
362
+ console.log(`[state] requested objects=${count}`);
363
+ });
364
+ client.on('objectsStateResponse', event => {
365
+ let count = 0;
366
+ for (const response of event.responses ?? []) {
367
+ for (const objectState of response.objectStates ?? []) {
368
+ count += 1;
369
+ const object = objectsById.get(objectState.id);
370
+ const classSpec = classSpecForObject(object, typeRegistry);
371
+ const values = decodePropertyValues(objectState.state, classSpec, typeRegistry);
372
+ if (object) {
373
+ object.lastStateTimestamp = objectState.timestamp;
374
+ applyPropertyValues(object, values);
375
+ }
376
+ console.log(`[state] object id=${objectState.id} properties=${values.length}${values.length ? ` [${formatPropertyValues(values)}]` : ''}`);
377
+ }
378
+ }
379
+ console.log(`[state] response owner=${event.ownerId} objects=${count}`);
380
+ });
381
+ client.on('runtimeObjectUpdate', event => {
382
+ const object = objectsById.get(event.update.objectId);
383
+ const classSpec = classSpecForObject(object, typeRegistry);
384
+ const values = decodePropertyValues(event.update.properties, classSpec, typeRegistry);
385
+ if (object) {
386
+ object.lastUpdateTimestamp = event.update.time;
387
+ applyPropertyValues(object, values);
388
+ }
389
+ const ids = formatPropertyValues(values);
390
+ console.log(`[runtime] object update id=${event.update.objectId} properties=${event.update.propertyUpdates.length} bytes=${event.update.properties.length}${ids ? ` [${ids}]` : ''}`);
391
+ });
392
+ client.on('runtimeEvents', event => {
393
+ console.log(`[runtime] events bytes=${event.payload.length}`);
394
+ });
395
+ client.on('error', error => {
396
+ console.error(`[error] ${error?.stack ?? error}`);
397
+ });
398
+
399
+ await client.connect(target);
400
+ await waitForEvent(client, 'ready', 3000);
401
+ if (!options.forceBus) {
402
+ await waitForRemoteBus(client, remoteBuses, bus, 3000).catch(error => {
403
+ client.close();
404
+ throw error;
405
+ });
406
+ }
407
+
408
+ client.joinBus(bus);
409
+ await waitForEvent(client, 'busParticipantReady', 5000).catch(error => {
410
+ console.warn(`[warn] ${error.message}; starting interest anyway`);
411
+ });
412
+ const interest = client.startInterest(bus, query);
413
+
414
+ console.log(`[listen] ${options.listen}ms`);
415
+ await wait(options.listen);
416
+ console.log(`[bus] stopping interest id=${interest.id}`);
417
+ client.stopInterest(bus, interest.id);
418
+ await wait(500);
419
+ console.log(`[bus] leaving bus=${bus}`);
420
+ client.leaveBus(bus);
421
+ await wait(500);
422
+ client.close();
423
+ } catch (error) {
424
+ console.error(error?.stack ?? String(error));
425
+ process.exit(1);
426
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import { scan, scanTcpDiscoveryHub } from '../lib/discovery.js';
3
+
4
+ function parseArgs(argv) {
5
+ const options = {};
6
+
7
+ for (let i = 0; i < argv.length; i += 1) {
8
+ const arg = argv[i];
9
+ if (arg === '--timeout') {
10
+ options.timeout = Number(argv[++i]);
11
+ } else if (arg === '--group') {
12
+ options.group = argv[++i];
13
+ } else if (arg === '--port') {
14
+ options.port = Number(argv[++i]);
15
+ } else if (arg === '--interface') {
16
+ options.interfaceAddress = argv[++i];
17
+ } else if (arg === '--bind') {
18
+ options.bindAddress = argv[++i];
19
+ } else if (arg === '--tcp-hub') {
20
+ options.tcpHub = argv[++i];
21
+ } else if (arg === '--help' || arg === '-h') {
22
+ options.help = true;
23
+ } else {
24
+ throw new Error(`unknown argument: ${arg}`);
25
+ }
26
+ }
27
+
28
+ return options;
29
+ }
30
+
31
+ function printHelp() {
32
+ console.log(`Usage: sen-ether-scan [options]
33
+
34
+ Options:
35
+ --timeout <ms> Scan duration. Default: 3000
36
+ --group <address> Discovery multicast group. Default: 239.255.0.44
37
+ --port <port> Discovery multicast port. Default: 60543
38
+ --interface <addr> Local interface address or interface name for multicast membership
39
+ --bind <addr> Local bind address. Default: multicast group on POSIX
40
+ --tcp-hub <host:port>
41
+ Use SEN TcpDiscoveryHub instead of multicast discovery
42
+ -h, --help Show this help
43
+
44
+ Environment:
45
+ SEN_ETHER_DISCOVERY_PORT Default multicast discovery port
46
+ `);
47
+ }
48
+
49
+ function parseHostPort(value) {
50
+ const text = String(value || '').trim();
51
+ const idx = text.lastIndexOf(':');
52
+ if (idx <= 0) {
53
+ throw new Error(`invalid --tcp-hub value, expected host:port: ${text}`);
54
+ }
55
+ const host = text.slice(0, idx);
56
+ const port = Number(text.slice(idx + 1));
57
+ if (!host || !Number.isInteger(port) || port <= 0) {
58
+ throw new Error(`invalid --tcp-hub value, expected host:port: ${text}`);
59
+ }
60
+ return { host, port };
61
+ }
62
+
63
+ try {
64
+ const options = parseArgs(process.argv.slice(2));
65
+ if (options.help) {
66
+ printHelp();
67
+ process.exit(0);
68
+ }
69
+
70
+ const processes = options.tcpHub
71
+ ? await scanTcpDiscoveryHub({ ...parseHostPort(options.tcpHub), timeout: options.timeout })
72
+ : await scan(options);
73
+ console.log(JSON.stringify(processes, null, 2));
74
+ } catch (error) {
75
+ console.error(error?.stack ?? String(error));
76
+ process.exit(1);
77
+ }
package/index.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Public sen-ether-client API.
3
+ *
4
+ * This package is a high-level JavaScript client for existing SEN kernels. It
5
+ * intentionally hides the ether codec, discovery frames and low-level transport
6
+ * classes from package consumers.
7
+ *
8
+ * @example
9
+ * import { Sen } from 'sen-ether-client';
10
+ *
11
+ * const sen = await Sen.connect();
12
+ *
13
+ * console.log(sen.listSessions());
14
+ * const hmi = await sen.session('hmi');
15
+ * console.log(hmi.listBuses());
16
+ *
17
+ * const diagnostics = await sen.interest('SELECT * FROM hmi.diagnostics');
18
+ * const world = await sen.interest('SELECT * FROM world1.environment');
19
+ * const probe = await diagnostics.waitFor('EtherProbe');
20
+ *
21
+ * probe.on('change:label', ({ value }) => console.log(value));
22
+ * console.log(await probe.get('label'));
23
+ * await probe.set('label', 'from-js');
24
+ * console.log(await probe.call('ping', ['hello']));
25
+ *
26
+ * await sen.close();
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} SenConnectOptions
31
+ * @property {string} [tcpHub] Optional SEN TCP discovery hub as `host:port`. If omitted, multicast discovery is used.
32
+ * @property {string} [session] Optional SEN session name. Omit it to let
33
+ * `interest(query)` connect to the session named in the query.
34
+ * @property {string} [app] Remote process appName substring filter.
35
+ * @property {number} [timeout=3000] Discovery and operation timeout in ms.
36
+ * @property {number} [discoverySettleMs=100] Discovery settle time after the first process is found.
37
+ * @property {number} [participantReadyTimeoutMs=1000] Short grace timeout for non-fatal bus participant acknowledgements.
38
+ * @property {boolean} [reconnect=true] Reconnect and restart interests after disconnection.
39
+ * @property {number} [reconnectDelayMs=500] Delay between reconnect attempts.
40
+ * @property {number} [maxReconnectAttempts=10] Maximum reconnect attempts.
41
+ * @property {boolean} [socketKeepAlive=true] Enable TCP keepalive on SEN ether connections.
42
+ * @property {number} [socketKeepAliveInitialDelayMs=1000] TCP keepalive initial delay.
43
+ * @property {number} [socketIdleTimeoutMs=0] Optional transport idle timeout in ms. `0` disables it.
44
+ * @property {string} [interfaceAddress] Local interface address or interface name for multicast discovery.
45
+ * @property {object} [target] Already discovered/direct SEN target.
46
+ */
47
+
48
+ /**
49
+ * @typedef {object} SenInterestOptions
50
+ * @property {string} [bus] Explicit bus name when it cannot be inferred from the query.
51
+ * @property {boolean} [forceBus=false] Join without waiting for the remote process to announce the bus.
52
+ * @property {number} [timeout] Operation timeout in ms.
53
+ * @property {string[]|string} [properties] Optional property names to decode and emit.
54
+ * @property {'individual'|'batch'|'both'} [changeMode='individual'] Change emission mode.
55
+ * @property {number} [batchIntervalMs=16] Batched change flush interval in ms.
56
+ * @property {number} [batchMaxSize=1000] Batched change flush size.
57
+ * @property {number} [maxQueuedChanges=10000] Batched change queue limit.
58
+ * @property {'drop-oldest'|'drop-newest'|'error'} [backpressure='drop-oldest'] Queue overflow policy.
59
+ * @property {boolean} [coalesce=false] Keep only latest queued change per object/property.
60
+ */
61
+
62
+ /**
63
+ * @typedef {object} SenListBusesOptions
64
+ * @property {boolean} [qualified=false] Return session-qualified bus names.
65
+ */
66
+
67
+ /**
68
+ * @typedef {string | number | ((object: SenRemoteObject) => boolean)} SenObjectSelector
69
+ */
70
+
71
+ export {
72
+ Sen,
73
+ SenInterest,
74
+ SenRemoteObject
75
+ } from './lib/sen.js';