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,439 @@
1
+ import dgram from 'node:dgram';
2
+ import { EventEmitter } from 'node:events';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import { decodeSessionPresenceBeam } from './codec.js';
6
+
7
+ export const DEFAULT_DISCOVERY_GROUP = '239.255.0.44';
8
+ export const DEFAULT_DISCOVERY_PORT = 60543;
9
+ export const DEFAULT_SCAN_TIMEOUT_MS = 3000;
10
+ export const TCP_DISCOVERY_BEAM_SIZE = 508;
11
+
12
+ function processKey(beam) {
13
+ const { hostId, processId, sessionId } = beam.info;
14
+ return `${hostId}:${processId}:${sessionId}`;
15
+ }
16
+
17
+ function discoveryPortFromEnv() {
18
+ const value = process.env.SEN_ETHER_DISCOVERY_PORT;
19
+ if (!value) {
20
+ return undefined;
21
+ }
22
+
23
+ const port = Number(value);
24
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
25
+ throw new Error(`invalid SEN discovery port in environment: ${value}`);
26
+ }
27
+ return port;
28
+ }
29
+
30
+ function resolveInterfaceAddress(value) {
31
+ const text = String(value || '').trim();
32
+ if (!text) {
33
+ return undefined;
34
+ }
35
+
36
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(text)) {
37
+ return text;
38
+ }
39
+
40
+ const interfaces = os.networkInterfaces();
41
+ const candidates = interfaces[text];
42
+ if (!candidates) {
43
+ return text;
44
+ }
45
+
46
+ const ipv4 = candidates.find(item => (item.family === 'IPv4' || item.family === 4) && !item.internal);
47
+ if (!ipv4) {
48
+ throw new Error(`network interface "${text}" has no non-internal IPv4 address`);
49
+ }
50
+ return ipv4.address;
51
+ }
52
+
53
+ function normalizeTcpRemote(socket) {
54
+ return {
55
+ address: socket.remoteAddress,
56
+ family: socket.remoteFamily,
57
+ port: socket.remotePort,
58
+ transport: 'tcp'
59
+ };
60
+ }
61
+
62
+ function normalizeBeam(beam, remote) {
63
+ return {
64
+ key: processKey(beam),
65
+ protocolVersion: beam.protocolVersion,
66
+ session: {
67
+ id: beam.info.sessionId,
68
+ name: beam.info.sessionName
69
+ },
70
+ process: {
71
+ hostId: beam.info.hostId,
72
+ processId: beam.info.processId,
73
+ appName: beam.info.appName,
74
+ hostName: beam.info.hostName,
75
+ osKind: beam.info.osKind,
76
+ osName: beam.info.osName,
77
+ cpuArch: beam.info.cpuArch
78
+ },
79
+ endpoints: beam.endpoints,
80
+ beamPeriodMs: beam.beamPeriodMs,
81
+ remote,
82
+ firstSeen: Date.now(),
83
+ lastSeen: Date.now()
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Passive SEN ether multicast discovery scanner.
89
+ *
90
+ * It listens for sen.components.ether.SessionPresenceBeam messages. It does
91
+ * not join SEN buses or create interests.
92
+ */
93
+ export class EtherDiscoveryScanner extends EventEmitter {
94
+ /**
95
+ * @param {object} [options]
96
+ * @param {string} [options.group]
97
+ * @param {number} [options.port]
98
+ * @param {string} [options.interfaceAddress]
99
+ * @param {string} [options.bindAddress]
100
+ */
101
+ constructor(options = {}) {
102
+ super();
103
+ this.group = options.group ?? DEFAULT_DISCOVERY_GROUP;
104
+ this.port = options.port ?? discoveryPortFromEnv() ?? DEFAULT_DISCOVERY_PORT;
105
+ this.interfaceAddress = resolveInterfaceAddress(options.interfaceAddress);
106
+ this.bindAddress = options.bindAddress ?? (process.platform === 'win32' ? undefined : this.group);
107
+ this.socket = null;
108
+ this.processes = new Map();
109
+ }
110
+
111
+ async start() {
112
+ if (this.socket) {
113
+ return this;
114
+ }
115
+
116
+ const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
117
+ this.socket = socket;
118
+
119
+ socket.on('message', (message, remote) => {
120
+ try {
121
+ const beam = decodeSessionPresenceBeam(message);
122
+ const key = processKey(beam);
123
+ const current = this.processes.get(key);
124
+
125
+ if (current) {
126
+ current.lastSeen = Date.now();
127
+ current.remote = remote;
128
+ current.endpoints = beam.endpoints;
129
+ this.emit('beam', current);
130
+ return;
131
+ }
132
+
133
+ const discovered = normalizeBeam(beam, remote);
134
+ this.processes.set(key, discovered);
135
+ this.emit('process', discovered);
136
+ this.emit('beam', discovered);
137
+ } catch (error) {
138
+ this.emit('decodeError', error, message, remote);
139
+ }
140
+ });
141
+
142
+ socket.on('error', error => this.emit('error', error));
143
+
144
+ await new Promise((resolve, reject) => {
145
+ const onError = error => {
146
+ socket.off('listening', onListening);
147
+ reject(error);
148
+ };
149
+ const onListening = () => {
150
+ socket.off('error', onError);
151
+ try {
152
+ socket.addMembership(this.group, this.interfaceAddress);
153
+ socket.setMulticastLoopback(true);
154
+ } catch (error) {
155
+ reject(error);
156
+ return;
157
+ }
158
+ resolve();
159
+ };
160
+
161
+ socket.once('error', onError);
162
+ socket.once('listening', onListening);
163
+ socket.bind(this.port, this.bindAddress);
164
+ });
165
+
166
+ return this;
167
+ }
168
+
169
+ async stop() {
170
+ const socket = this.socket;
171
+ this.socket = null;
172
+
173
+ if (!socket) {
174
+ return;
175
+ }
176
+
177
+ await new Promise(resolve => {
178
+ socket.close(resolve);
179
+ });
180
+ }
181
+
182
+ listProcesses() {
183
+ return [...this.processes.values()].sort((a, b) => {
184
+ const sessionCmp = a.session.name.localeCompare(b.session.name);
185
+ if (sessionCmp !== 0) {
186
+ return sessionCmp;
187
+ }
188
+ return a.process.appName.localeCompare(b.process.appName);
189
+ });
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Passive SEN ether TCP discovery-hub scanner.
195
+ *
196
+ * SEN's TcpDiscoveryHub forwards fixed-size beam buffers between connected
197
+ * clients. This scanner does not announce itself; it only listens for beams.
198
+ */
199
+ export class TcpDiscoveryHubScanner extends EventEmitter {
200
+ /**
201
+ * @param {object} [options]
202
+ * @param {string} [options.host]
203
+ * @param {number} [options.port]
204
+ */
205
+ constructor(options = {}) {
206
+ super();
207
+ this.host = options.host ?? '127.0.0.1';
208
+ this.port = options.port ?? 64222;
209
+ this.socket = null;
210
+ this.receiveBuffer = Buffer.alloc(0);
211
+ this.processes = new Map();
212
+ }
213
+
214
+ async start() {
215
+ if (this.socket) {
216
+ return this;
217
+ }
218
+
219
+ const socket = net.createConnection({ host: this.host, port: this.port });
220
+ this.socket = socket;
221
+
222
+ socket.on('data', chunk => this.#onData(chunk));
223
+ socket.on('close', hadError => {
224
+ this.socket = null;
225
+ this.emit('close', hadError);
226
+ });
227
+
228
+ await new Promise((resolve, reject) => {
229
+ const onError = error => {
230
+ socket.off('connect', onConnect);
231
+ reject(error);
232
+ };
233
+ const onConnect = () => {
234
+ socket.off('error', onError);
235
+ socket.on('error', error => this.emit('error', error));
236
+ resolve();
237
+ };
238
+ socket.once('error', onError);
239
+ socket.once('connect', onConnect);
240
+ });
241
+
242
+ return this;
243
+ }
244
+
245
+ async stop() {
246
+ const socket = this.socket;
247
+ this.socket = null;
248
+
249
+ if (!socket) {
250
+ return;
251
+ }
252
+
253
+ await new Promise(resolve => {
254
+ socket.once('close', resolve);
255
+ socket.destroy();
256
+ });
257
+ }
258
+
259
+ listProcesses() {
260
+ return [...this.processes.values()].sort((a, b) => {
261
+ const sessionCmp = a.session.name.localeCompare(b.session.name);
262
+ if (sessionCmp !== 0) {
263
+ return sessionCmp;
264
+ }
265
+ return a.process.appName.localeCompare(b.process.appName);
266
+ });
267
+ }
268
+
269
+ #onData(chunk) {
270
+ this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk]);
271
+
272
+ while (this.receiveBuffer.length >= TCP_DISCOVERY_BEAM_SIZE) {
273
+ const message = this.receiveBuffer.subarray(0, TCP_DISCOVERY_BEAM_SIZE);
274
+ this.receiveBuffer = this.receiveBuffer.subarray(TCP_DISCOVERY_BEAM_SIZE);
275
+ this.#onBeamBuffer(message);
276
+ }
277
+ }
278
+
279
+ #onBeamBuffer(message) {
280
+ try {
281
+ const beam = decodeSessionPresenceBeam(message);
282
+ const key = processKey(beam);
283
+ const current = this.processes.get(key);
284
+
285
+ if (current) {
286
+ current.lastSeen = Date.now();
287
+ current.remote = normalizeTcpRemote(this.socket);
288
+ current.endpoints = beam.endpoints;
289
+ this.emit('beam', current);
290
+ return;
291
+ }
292
+
293
+ const discovered = normalizeBeam(beam, normalizeTcpRemote(this.socket));
294
+ this.processes.set(key, discovered);
295
+ this.emit('process', discovered);
296
+ this.emit('beam', discovered);
297
+ } catch (error) {
298
+ this.emit('decodeError', error, message, normalizeTcpRemote(this.socket));
299
+ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Scan visible SEN ether multicast presence beams.
305
+ *
306
+ * @param {object} [options]
307
+ * @param {number} [options.timeout]
308
+ * @param {number} [options.settleMs] Time to keep collecting beams after the first discovered process.
309
+ * @param {AbortSignal} [options.signal]
310
+ * @returns {Promise<Array<object>>}
311
+ */
312
+ export async function scan(options = {}) {
313
+ const timeout = options.timeout ?? DEFAULT_SCAN_TIMEOUT_MS;
314
+ const settleMs = options.settleMs ?? 100;
315
+ const scanner = new EtherDiscoveryScanner(options);
316
+
317
+ if (options.signal?.aborted) {
318
+ throw options.signal.reason ?? new Error('scan aborted');
319
+ }
320
+
321
+ let timeoutId;
322
+ let settleTimeoutId;
323
+ let onScannerError;
324
+ let onProcess;
325
+ const abortPromise = new Promise((_, reject) => {
326
+ if (!options.signal) {
327
+ return;
328
+ }
329
+ options.signal.addEventListener(
330
+ 'abort',
331
+ () => reject(options.signal.reason ?? new Error('scan aborted')),
332
+ { once: true }
333
+ );
334
+ });
335
+
336
+ try {
337
+ await scanner.start();
338
+ const scannerErrorPromise = new Promise((_, reject) => {
339
+ onScannerError = error => reject(error);
340
+ scanner.on('error', onScannerError);
341
+ });
342
+ const discoveryPromise = new Promise(resolve => {
343
+ timeoutId = setTimeout(resolve, timeout);
344
+ onProcess = () => {
345
+ if (settleTimeoutId) {
346
+ return;
347
+ }
348
+ settleTimeoutId = setTimeout(resolve, settleMs);
349
+ };
350
+ scanner.on('process', onProcess);
351
+ });
352
+ await Promise.race([
353
+ discoveryPromise,
354
+ scannerErrorPromise,
355
+ abortPromise
356
+ ]);
357
+ return scanner.listProcesses();
358
+ } finally {
359
+ if (onScannerError) {
360
+ scanner.off('error', onScannerError);
361
+ }
362
+ if (onProcess) {
363
+ scanner.off('process', onProcess);
364
+ }
365
+ clearTimeout(timeoutId);
366
+ clearTimeout(settleTimeoutId);
367
+ await scanner.stop();
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Scan visible SEN ether TCP discovery hub beams.
373
+ *
374
+ * @param {object} [options]
375
+ * @param {string} [options.host]
376
+ * @param {number} [options.port]
377
+ * @param {number} [options.timeout]
378
+ * @param {number} [options.settleMs] Time to keep collecting beams after the first discovered process.
379
+ * @param {AbortSignal} [options.signal]
380
+ * @returns {Promise<Array<object>>}
381
+ */
382
+ export async function scanTcpDiscoveryHub(options = {}) {
383
+ const timeout = options.timeout ?? DEFAULT_SCAN_TIMEOUT_MS;
384
+ const settleMs = options.settleMs ?? 100;
385
+ const scanner = new TcpDiscoveryHubScanner(options);
386
+
387
+ if (options.signal?.aborted) {
388
+ throw options.signal.reason ?? new Error('scan aborted');
389
+ }
390
+
391
+ let timeoutId;
392
+ let settleTimeoutId;
393
+ let onScannerError;
394
+ let onProcess;
395
+ const abortPromise = new Promise((_, reject) => {
396
+ if (!options.signal) {
397
+ return;
398
+ }
399
+ options.signal.addEventListener(
400
+ 'abort',
401
+ () => reject(options.signal.reason ?? new Error('scan aborted')),
402
+ { once: true }
403
+ );
404
+ });
405
+
406
+ try {
407
+ await scanner.start();
408
+ const scannerErrorPromise = new Promise((_, reject) => {
409
+ onScannerError = error => reject(error);
410
+ scanner.on('error', onScannerError);
411
+ });
412
+ const discoveryPromise = new Promise(resolve => {
413
+ timeoutId = setTimeout(resolve, timeout);
414
+ onProcess = () => {
415
+ if (settleTimeoutId) {
416
+ return;
417
+ }
418
+ settleTimeoutId = setTimeout(resolve, settleMs);
419
+ };
420
+ scanner.on('process', onProcess);
421
+ });
422
+ await Promise.race([
423
+ discoveryPromise,
424
+ scannerErrorPromise,
425
+ abortPromise
426
+ ]);
427
+ return scanner.listProcesses();
428
+ } finally {
429
+ if (onScannerError) {
430
+ scanner.off('error', onScannerError);
431
+ }
432
+ if (onProcess) {
433
+ scanner.off('process', onProcess);
434
+ }
435
+ clearTimeout(timeoutId);
436
+ clearTimeout(settleTimeoutId);
437
+ await scanner.stop();
438
+ }
439
+ }
package/lib/hash32.js ADDED
@@ -0,0 +1,40 @@
1
+ export const HASH_SEED = 23835769;
2
+ export const PROPERTY_HASH_SEED = 19830715;
3
+ export const METHOD_HASH_SEED = 93580253;
4
+ export const EVENT_HASH_SEED = 12125807;
5
+
6
+ const FNV1A_OFFSET_BASIS = 0x811c9dc5;
7
+ const FNV1A_PRIME = 0x01000193;
8
+ const HASH_COMBINE_MAGIC = 0x9e3779b9;
9
+
10
+ export function fnv1aString(value) {
11
+ let hash = FNV1A_OFFSET_BASIS;
12
+ for (const byte of Buffer.from(String(value))) {
13
+ hash ^= byte;
14
+ hash = Math.imul(hash, FNV1A_PRIME) >>> 0;
15
+ }
16
+ return hash >>> 0;
17
+ }
18
+
19
+ export function hashCombine(seed, ...values) {
20
+ let result = seed >>> 0;
21
+ for (const value of values) {
22
+ const hashed = typeof value === 'number' ? value >>> 0 : fnv1aString(value);
23
+ result = (result ^ (
24
+ (hashed + HASH_COMBINE_MAGIC + ((result << 6) >>> 0) + (result >>> 2)) >>> 0
25
+ )) >>> 0;
26
+ }
27
+ return result >>> 0;
28
+ }
29
+
30
+ export function propertyHash(name) {
31
+ return hashCombine(PROPERTY_HASH_SEED, name);
32
+ }
33
+
34
+ export function methodHash(name) {
35
+ return hashCombine(METHOD_HASH_SEED, name);
36
+ }
37
+
38
+ export function eventHash(name) {
39
+ return hashCombine(EVENT_HASH_SEED, name);
40
+ }
@@ -0,0 +1,157 @@
1
+ // Generated by scripts/generate-protocol.mjs from resources/protocol.
2
+ // Do not edit by hand.
3
+
4
+ export const KERNEL_PROTOCOL_VERSION = 9;
5
+ export const ETHER_PROTOCOL_VERSION = 2;
6
+
7
+ export const ETHER_CONTROL_MESSAGE = Object.freeze([
8
+ "Hello",
9
+ "Ready",
10
+ "BusJoined",
11
+ "BusLeft"
12
+ ]);
13
+ export const ETHER_CONTROL_MESSAGE_KEY = Object.freeze({
14
+ "Hello": 0,
15
+ "Ready": 1,
16
+ "BusJoined": 2,
17
+ "BusLeft": 3
18
+ });
19
+
20
+ export const KERNEL_CONTROL_MESSAGE = Object.freeze([
21
+ "RemoteParticipantReady",
22
+ "InterestStarted",
23
+ "InterestStopped",
24
+ "ObjectsPublished",
25
+ "ObjectsRemoved",
26
+ "PublicationRejection",
27
+ "ObjectsStateRequest",
28
+ "ObjectsStateResponse",
29
+ "TypesInfoRequest",
30
+ "TypesInfoResponse",
31
+ "TypesInfoRejection"
32
+ ]);
33
+ export const KERNEL_CONTROL_MESSAGE_KEY = Object.freeze({
34
+ "RemoteParticipantReady": 0,
35
+ "InterestStarted": 1,
36
+ "InterestStopped": 2,
37
+ "ObjectsPublished": 3,
38
+ "ObjectsRemoved": 4,
39
+ "PublicationRejection": 5,
40
+ "ObjectsStateRequest": 6,
41
+ "ObjectsStateResponse": 7,
42
+ "TypesInfoRequest": 8,
43
+ "TypesInfoResponse": 9,
44
+ "TypesInfoRejection": 10
45
+ });
46
+
47
+ export const TYPE_SPEC_RESPONSE = Object.freeze([
48
+ "ClassSpecResponse",
49
+ "NonClassSpecResponse"
50
+ ]);
51
+
52
+ export const OS_KIND = Object.freeze([
53
+ "windowsOs",
54
+ "linuxOs",
55
+ "androidOs",
56
+ "appleOs",
57
+ "unixOs",
58
+ "posixOs",
59
+ "otherOs"
60
+ ]);
61
+ export const CPU_ARCH = Object.freeze([
62
+ "x86",
63
+ "x64",
64
+ "arm2",
65
+ "arm3",
66
+ "arm4T",
67
+ "arm5",
68
+ "arm6T2",
69
+ "arm6",
70
+ "arm7",
71
+ "arm7a",
72
+ "arm7r",
73
+ "arm7s",
74
+ "arm64",
75
+ "mips",
76
+ "superH",
77
+ "ppc",
78
+ "ppc64",
79
+ "sparc",
80
+ "m68k",
81
+ "otherArch"
82
+ ]);
83
+ export const UNIT_CATEGORY = Object.freeze([
84
+ "length",
85
+ "mass",
86
+ "time",
87
+ "angle",
88
+ "temperature",
89
+ "frequency",
90
+ "velocity",
91
+ "angularVelocity",
92
+ "acceleration",
93
+ "angularAcceleration",
94
+ "density",
95
+ "pressure",
96
+ "area",
97
+ "force",
98
+ "torque"
99
+ ]);
100
+
101
+ export const INTEGRAL_TYPE = Object.freeze([
102
+ "uint8Type",
103
+ "int16Type",
104
+ "uint16Type",
105
+ "int32Type",
106
+ "uint32Type",
107
+ "int64Type",
108
+ "uint64Type"
109
+ ]);
110
+ export const REAL_TYPE = Object.freeze([
111
+ "float32Type",
112
+ "float64Type"
113
+ ]);
114
+ export const NUMERIC_TYPE = Object.freeze([
115
+ "IntegralType",
116
+ "RealType"
117
+ ]);
118
+ export const BASIC_TYPE = Object.freeze([
119
+ "booleanType",
120
+ "stringType",
121
+ "durationType",
122
+ "timestampType"
123
+ ]);
124
+ export const BUILT_IN_TYPE = Object.freeze([
125
+ "NumericType",
126
+ "BasicType"
127
+ ]);
128
+ export const TRANSPORT_MODE = Object.freeze([
129
+ "unicast",
130
+ "multicast",
131
+ "confirmed"
132
+ ]);
133
+ export const METHOD_CONSTNESS = Object.freeze([
134
+ "constant",
135
+ "nonConstant"
136
+ ]);
137
+ export const PROPERTY_RELATION = Object.freeze([
138
+ "nonPropertyRelated",
139
+ "propertyGetter",
140
+ "propertySetter"
141
+ ]);
142
+ export const PROPERTY_CATEGORY = Object.freeze([
143
+ "staticRW",
144
+ "staticRO",
145
+ "dynamicRW",
146
+ "dynamicRO"
147
+ ]);
148
+ export const CUSTOM_TYPE_DATA = Object.freeze([
149
+ "EnumTypeSpec",
150
+ "QuantityTypeSpec",
151
+ "SequenceTypeSpec",
152
+ "StructTypeSpec",
153
+ "VariantTypeSpec",
154
+ "AliasTypeSpec",
155
+ "OptionalTypeSpec",
156
+ "ClassTypeSpec"
157
+ ]);