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.
package/lib/client.js ADDED
@@ -0,0 +1,634 @@
1
+ import dgram from 'node:dgram';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { EventEmitter } from 'node:events';
4
+ import net from 'node:net';
5
+ import os from 'node:os';
6
+ import process from 'node:process';
7
+ import {
8
+ decodeBusMessage,
9
+ decodeConfirmedBusFrame,
10
+ encodeBusControlMessage,
11
+ encodeConfirmedBusFrame,
12
+ encodeRuntimeMethodCall
13
+ } from './bus.js';
14
+ import {
15
+ decodeEtherControlMessage,
16
+ decodeProcessTcpHeader,
17
+ encodeEtherControlMessage,
18
+ encodeProcessTcpFrame,
19
+ ETHER_PROTOCOL_VERSION,
20
+ KERNEL_PROTOCOL_VERSION,
21
+ PROCESS_MESSAGE_CATEGORY
22
+ } from './codec.js';
23
+ import { crc32 } from './crc32.js';
24
+
25
+ const LINUX_OS_KIND = 1;
26
+ const X64_CPU_ARCH = 1;
27
+
28
+ function randomUInt32() {
29
+ return randomBytes(4).readUInt32LE(0);
30
+ }
31
+
32
+ function detectOsKind() {
33
+ switch (process.platform) {
34
+ case 'win32':
35
+ return 0;
36
+ case 'linux':
37
+ return 1;
38
+ case 'android':
39
+ return 2;
40
+ case 'darwin':
41
+ return 3;
42
+ default:
43
+ return LINUX_OS_KIND;
44
+ }
45
+ }
46
+
47
+ function detectCpuArch() {
48
+ switch (process.arch) {
49
+ case 'x64':
50
+ return 1;
51
+ case 'arm64':
52
+ return 12;
53
+ case 'arm':
54
+ return 8;
55
+ default:
56
+ return X64_CPU_ARCH;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Create a ProcessInfo compatible with sen::kernel::getOwnProcessInfo.
62
+ *
63
+ * @param {object} options
64
+ * @param {string} options.sessionName
65
+ * @param {string} [options.appName]
66
+ * @param {string} [options.hostName]
67
+ * @param {number} [options.hostId]
68
+ * @param {number} [options.processId]
69
+ */
70
+ export function createProcessInfo(options) {
71
+ const hostName = options.hostName ?? os.hostname();
72
+ const sessionName = options.sessionName ?? '';
73
+ return {
74
+ hostId: options.hostId ?? crc32(hostName),
75
+ processId: options.processId ?? randomUInt32(),
76
+ sessionId: crc32(sessionName),
77
+ sessionName,
78
+ appName: options.appName ?? 'sen-ether-client',
79
+ hostName,
80
+ osKindCode: options.osKindCode ?? detectOsKind(),
81
+ osName: options.osName ?? `${os.type()} ${os.release()}`,
82
+ cpuArchCode: options.cpuArchCode ?? detectCpuArch()
83
+ };
84
+ }
85
+
86
+ export function validateRemoteHello(hello, options, processInfo) {
87
+ if (hello.info.sessionId !== processInfo.sessionId) {
88
+ throw new Error(
89
+ `remote SEN session mismatch: expected ${processInfo.sessionName} (${processInfo.sessionId}), ` +
90
+ `got ${hello.info.sessionName} (${hello.info.sessionId})`
91
+ );
92
+ }
93
+
94
+ if (hello.version.kernel !== options.kernelProtocolVersion) {
95
+ throw new Error(
96
+ `remote SEN kernel protocol ${hello.version.kernel} is incompatible with ${options.kernelProtocolVersion}`
97
+ );
98
+ }
99
+
100
+ if (hello.version.ether !== options.etherProtocolVersion) {
101
+ throw new Error(
102
+ `remote SEN ether protocol ${hello.version.ether} is incompatible with ${options.etherProtocolVersion}`
103
+ );
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Minimal SEN ether process connection.
109
+ *
110
+ * Events:
111
+ * - `remoteProcess`: remote Hello received.
112
+ * - `ready`: remote Ready received.
113
+ * - `controlMessage`: decoded ether ControlMessage.
114
+ * - `busJoined` / `busLeft`: remote process joined/left a bus.
115
+ * - `busFrame`: raw process-level bus frame payload, not decoded yet.
116
+ * - `close`, `error`.
117
+ */
118
+ export class EtherClient extends EventEmitter {
119
+ /**
120
+ * @param {object} options
121
+ * @param {string} options.sessionName SEN session name. Must match the remote kernel.
122
+ * @param {string} [options.appName]
123
+ * @param {number} [options.kernelProtocolVersion]
124
+ * @param {number} [options.etherProtocolVersion]
125
+ * @param {boolean} [options.socketKeepAlive]
126
+ * @param {number} [options.socketKeepAliveInitialDelayMs]
127
+ * @param {number} [options.socketIdleTimeoutMs]
128
+ */
129
+ constructor(options) {
130
+ super();
131
+ if (!options?.sessionName) {
132
+ throw new TypeError('EtherClient requires options.sessionName');
133
+ }
134
+
135
+ this.options = {
136
+ appName: 'sen-ether-client',
137
+ kernelProtocolVersion: KERNEL_PROTOCOL_VERSION,
138
+ etherProtocolVersion: ETHER_PROTOCOL_VERSION,
139
+ socketKeepAlive: true,
140
+ socketKeepAliveInitialDelayMs: 1000,
141
+ socketIdleTimeoutMs: 0,
142
+ ...options
143
+ };
144
+ this.processInfo = createProcessInfo(this.options);
145
+ this.socket = undefined;
146
+ this.udpSocket = undefined;
147
+ this.receiveBuffer = Buffer.alloc(0);
148
+ this.remoteProcessInfo = undefined;
149
+ this.ready = false;
150
+ this.buses = new Map();
151
+ }
152
+
153
+ /**
154
+ * Connect to one endpoint from a SessionPresenceBeam process entry.
155
+ *
156
+ * @param {{ endpoints?: Array<{ host: string, port: number }>, info?: object } | { host: string, port: number }} target
157
+ */
158
+ async connect(target) {
159
+ const endpoint = target.host ? target : target.endpoints?.[0];
160
+ if (!endpoint) {
161
+ throw new TypeError('SEN ether target must contain host/port or at least one endpoint');
162
+ }
163
+
164
+ this.udpSocket = dgram.createSocket('udp4');
165
+ this.udpSocket.on('message', message => this.#onBusFrame(message));
166
+ this.udpSocket.on('error', error => this.emit('error', error));
167
+ await new Promise((resolve, reject) => {
168
+ const onError = error => {
169
+ this.udpSocket?.off('listening', onListening);
170
+ reject(error);
171
+ };
172
+ const onListening = () => {
173
+ this.udpSocket?.off('error', onError);
174
+ resolve();
175
+ };
176
+ this.udpSocket.once('error', onError);
177
+ this.udpSocket.once('listening', onListening);
178
+ this.udpSocket.bind(0);
179
+ });
180
+
181
+ this.socket = net.createConnection({ host: endpoint.host, port: endpoint.port });
182
+ this.socket.on('data', chunk => this.#onTcpData(chunk));
183
+ this.socket.on('close', hadError => {
184
+ this.ready = false;
185
+ this.emit('close', hadError);
186
+ });
187
+
188
+ await new Promise((resolve, reject) => {
189
+ const onError = error => {
190
+ this.socket?.off('connect', onConnect);
191
+ reject(error);
192
+ };
193
+ const onConnect = () => {
194
+ this.socket?.off('error', onError);
195
+ this.socket?.on('error', error => this.emit('error', error));
196
+ this.#configureTcpSocket();
197
+ try {
198
+ this.#sendHello();
199
+ resolve();
200
+ } catch (error) {
201
+ reject(error);
202
+ }
203
+ };
204
+ this.socket.once('error', onError);
205
+ this.socket.once('connect', onConnect);
206
+ });
207
+
208
+ return this;
209
+ }
210
+
211
+ #configureTcpSocket() {
212
+ if (!this.socket) {
213
+ return;
214
+ }
215
+ if (this.options.socketKeepAlive !== false) {
216
+ this.socket.setKeepAlive(true, this.options.socketKeepAliveInitialDelayMs ?? 1000);
217
+ }
218
+ if (this.options.socketIdleTimeoutMs > 0) {
219
+ this.socket.setTimeout(this.options.socketIdleTimeoutMs, () => {
220
+ const error = new Error(`SEN ether TCP socket idle timeout after ${this.options.socketIdleTimeoutMs}ms`);
221
+ error.code = 'SEN_TCP_IDLE_TIMEOUT';
222
+ this.socket?.destroy(error);
223
+ });
224
+ }
225
+ }
226
+
227
+ async close() {
228
+ const socket = this.socket;
229
+ this.socket = undefined;
230
+ const udpSocket = this.udpSocket;
231
+ this.udpSocket = undefined;
232
+
233
+ const closing = [];
234
+ if (socket && !socket.destroyed) {
235
+ closing.push(new Promise(resolve => {
236
+ socket.once('close', resolve);
237
+ socket.destroy();
238
+ }));
239
+ }
240
+ if (udpSocket) {
241
+ closing.push(new Promise(resolve => {
242
+ udpSocket.close(resolve);
243
+ }));
244
+ }
245
+
246
+ await Promise.allSettled(closing);
247
+ }
248
+
249
+ /**
250
+ * Announce a JS participant on a SEN bus.
251
+ *
252
+ * @param {string} busName
253
+ * @param {{ participantId?: number }} [options]
254
+ */
255
+ joinBus(busName, options = {}) {
256
+ if (!this.socket) {
257
+ throw new Error('EtherClient is not connected');
258
+ }
259
+
260
+ const busId = crc32(busName);
261
+ const participantId = options.participantId ?? randomUInt32();
262
+ const bus = {
263
+ busName,
264
+ busId,
265
+ participantId,
266
+ readyRemoteParticipants: new Set(),
267
+ interests: new Map()
268
+ };
269
+
270
+ this.buses.set(busId, bus);
271
+ this.#sendControlPayload(encodeEtherControlMessage({
272
+ type: 'BusJoined',
273
+ value: {
274
+ participantId,
275
+ busId,
276
+ busName
277
+ }
278
+ }));
279
+
280
+ this.emit('busJoinedLocal', { busName, busId, participantId });
281
+ return { busName, busId, participantId };
282
+ }
283
+
284
+ /**
285
+ * Start a SEN object interest on a joined bus.
286
+ *
287
+ * The query syntax is SEN's native Interest query string. Returned object
288
+ * state buffers are intentionally left raw until type-spec decoding is added.
289
+ *
290
+ * @param {string | number} bus Bus name or bus id.
291
+ * @param {string} query
292
+ * @param {{ id?: number }} [options]
293
+ */
294
+ startInterest(bus, query, options = {}) {
295
+ const busState = this.#getBus(bus);
296
+ const id = options.id ?? crc32(query);
297
+ this.#sendBusControl(busState, busState.participantId, {
298
+ type: 'InterestStarted',
299
+ value: { query, id }
300
+ });
301
+ busState.interests.set(id, { id, query });
302
+ this.emit('interestStarted', { busName: busState.busName, busId: busState.busId, id, query });
303
+ return { busName: busState.busName, busId: busState.busId, id, query };
304
+ }
305
+
306
+ /**
307
+ * Stop a previously started interest.
308
+ *
309
+ * @param {string | number} bus Bus name or bus id.
310
+ * @param {number} id
311
+ */
312
+ stopInterest(bus, id) {
313
+ const busState = this.#getBus(bus);
314
+ busState.interests.delete(id);
315
+ this.#sendBusControl(busState, busState.participantId, {
316
+ type: 'InterestStopped',
317
+ value: { id }
318
+ });
319
+ }
320
+
321
+ /**
322
+ * Request SEN type specs for the given remote object type hashes.
323
+ *
324
+ * @param {string | number} bus Bus name or bus id.
325
+ * @param {Iterable<number>} typeHashes
326
+ */
327
+ requestTypes(bus, typeHashes) {
328
+ const busState = this.#getBus(bus);
329
+ const requests = [...new Set([...typeHashes].map(value => value >>> 0))];
330
+ if (!requests.length) {
331
+ return { busName: busState.busName, busId: busState.busId, requests };
332
+ }
333
+
334
+ this.#sendBusControl(busState, busState.participantId, {
335
+ type: 'TypesInfoRequest',
336
+ value: {
337
+ ownerId: busState.participantId,
338
+ requests
339
+ }
340
+ });
341
+ this.emit('typesInfoRequested', { busName: busState.busName, busId: busState.busId, requests });
342
+ return { busName: busState.busName, busId: busState.busId, requests };
343
+ }
344
+
345
+ /**
346
+ * Request current dynamic state for already published remote objects.
347
+ *
348
+ * @param {string | number} bus Bus name or bus id.
349
+ * @param {Array<{ interestId: number, objectIds: Array<number> }>} requests
350
+ */
351
+ requestObjectStates(bus, requests) {
352
+ const busState = this.#getBus(bus);
353
+ const normalized = requests
354
+ .map(request => ({
355
+ interestId: request.interestId >>> 0,
356
+ objectIds: [...new Set((request.objectIds ?? []).map(value => value >>> 0))]
357
+ }))
358
+ .filter(request => request.objectIds.length);
359
+
360
+ if (!normalized.length) {
361
+ return { busName: busState.busName, busId: busState.busId, requests: normalized };
362
+ }
363
+
364
+ this.#sendBusControl(busState, busState.participantId, {
365
+ type: 'ObjectsStateRequest',
366
+ value: {
367
+ ownerId: busState.participantId,
368
+ requests: normalized
369
+ }
370
+ });
371
+ this.emit('objectsStateRequested', { busName: busState.busName, busId: busState.busId, requests: normalized });
372
+ return { busName: busState.busName, busId: busState.busId, requests: normalized };
373
+ }
374
+
375
+ /**
376
+ * Send a runtime method call to a remote participant on a joined bus.
377
+ *
378
+ * @param {string | number} bus Bus name or bus id.
379
+ * @param {object} call
380
+ * @param {number} call.to Remote participant/object owner id.
381
+ * @param {number} call.objectId Remote object id.
382
+ * @param {number} call.methodId SEN method member hash.
383
+ * @param {number} call.ticketId Local call id.
384
+ * @param {boolean} [call.confirmed]
385
+ * @param {Buffer | Uint8Array | ArrayBuffer} [call.argumentsBuffer]
386
+ */
387
+ sendRuntimeMethodCall(bus, call) {
388
+ const busState = this.#getBus(bus);
389
+ const message = encodeRuntimeMethodCall({
390
+ ownerId: busState.participantId,
391
+ objectId: call.objectId,
392
+ methodId: call.methodId,
393
+ ticketId: call.ticketId,
394
+ confirmed: call.confirmed,
395
+ argumentsBuffer: call.argumentsBuffer
396
+ });
397
+ this.#sendBusMessage(busState, call.to, message);
398
+ this.emit('runtimeMethodCallSent', {
399
+ busName: busState.busName,
400
+ busId: busState.busId,
401
+ to: call.to,
402
+ objectId: call.objectId,
403
+ methodId: call.methodId,
404
+ ticketId: call.ticketId,
405
+ confirmed: Boolean(call.confirmed)
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Announce that the local JS participant leaves a SEN bus.
411
+ *
412
+ * @param {string | number} bus Bus name or bus id.
413
+ */
414
+ leaveBus(bus) {
415
+ const busState = this.#getBus(bus);
416
+ for (const id of Array.from(busState.interests.keys())) {
417
+ this.stopInterest(busState.busId, id);
418
+ }
419
+
420
+ this.#sendControlPayload(encodeEtherControlMessage({
421
+ type: 'BusLeft',
422
+ value: {
423
+ participantId: busState.participantId,
424
+ busId: busState.busId,
425
+ busName: busState.busName
426
+ }
427
+ }));
428
+ this.buses.delete(busState.busId);
429
+ this.emit('busLeftLocal', {
430
+ busName: busState.busName,
431
+ busId: busState.busId,
432
+ participantId: busState.participantId
433
+ });
434
+ }
435
+
436
+ #sendHello() {
437
+ const udpPort = this.udpSocket?.address()?.port;
438
+ const payload = encodeEtherControlMessage({
439
+ type: 'Hello',
440
+ value: {
441
+ info: this.processInfo,
442
+ udpPort,
443
+ version: {
444
+ kernel: this.options.kernelProtocolVersion,
445
+ ether: this.options.etherProtocolVersion
446
+ }
447
+ }
448
+ });
449
+ this.#sendControlPayload(payload);
450
+ }
451
+
452
+ #sendReady() {
453
+ this.#sendControlPayload(encodeEtherControlMessage({ type: 'Ready' }));
454
+ }
455
+
456
+ #sendControlPayload(payload) {
457
+ const socket = this.#writableSocket();
458
+ socket.write(encodeProcessTcpFrame(PROCESS_MESSAGE_CATEGORY.controlMessage, payload), error => {
459
+ if (error) {
460
+ this.emit('error', error);
461
+ }
462
+ });
463
+ }
464
+
465
+ #onTcpData(chunk) {
466
+ this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk]);
467
+
468
+ while (this.receiveBuffer.length >= 5) {
469
+ const header = decodeProcessTcpHeader(this.receiveBuffer);
470
+ const frameSize = 5 + header.payloadSize;
471
+ if (this.receiveBuffer.length < frameSize) {
472
+ return;
473
+ }
474
+
475
+ const payload = this.receiveBuffer.subarray(5, frameSize);
476
+ this.receiveBuffer = this.receiveBuffer.subarray(frameSize);
477
+ this.#onFrame(header.category, payload);
478
+ }
479
+ }
480
+
481
+ #onFrame(category, payload) {
482
+ if (category === PROCESS_MESSAGE_CATEGORY.controlMessage) {
483
+ const message = decodeEtherControlMessage(payload);
484
+ this.emit('controlMessage', message);
485
+ this.#onControlMessage(message);
486
+ return;
487
+ }
488
+
489
+ if (category === PROCESS_MESSAGE_CATEGORY.busMessage) {
490
+ this.#onBusFrame(payload);
491
+ return;
492
+ }
493
+
494
+ this.emit('error', new RangeError(`unknown SEN process frame category: ${category}`));
495
+ }
496
+
497
+ #onControlMessage(message) {
498
+ switch (message.type) {
499
+ case 'Hello':
500
+ this.#onHello(message.value);
501
+ break;
502
+ case 'Ready':
503
+ this.ready = true;
504
+ this.emit('ready', this.remoteProcessInfo);
505
+ break;
506
+ case 'BusJoined':
507
+ this.emit('busJoined', message.value);
508
+ break;
509
+ case 'BusLeft':
510
+ this.emit('busLeft', message.value);
511
+ break;
512
+ default:
513
+ this.emit('error', new RangeError(`unknown SEN ether control message: ${message.type}`));
514
+ }
515
+ }
516
+
517
+ #onHello(hello) {
518
+ try {
519
+ validateRemoteHello(hello, this.options, this.processInfo);
520
+ } catch (error) {
521
+ this.socket?.destroy(error);
522
+ return;
523
+ }
524
+
525
+ this.remoteProcessInfo = hello.info;
526
+ this.emit('remoteProcess', hello);
527
+ try {
528
+ this.#sendReady();
529
+ } catch (error) {
530
+ this.emit('error', error);
531
+ }
532
+ }
533
+
534
+ #onBusFrame(payload) {
535
+ const frame = decodeConfirmedBusFrame(payload);
536
+ const busMessage = decodeBusMessage(frame.message);
537
+ this.emit('busFrame', { ...frame, busMessage });
538
+
539
+ if (busMessage.categoryName !== 'controlMessage') {
540
+ this.emit(busMessage.categoryName, { ...frame, ...busMessage });
541
+ return;
542
+ }
543
+
544
+ const busState = this.buses.get(frame.busId);
545
+ const control = busMessage.control;
546
+ this.emit('busControlMessage', { ...frame, control });
547
+
548
+ if (!busState) {
549
+ return;
550
+ }
551
+
552
+ switch (control.type) {
553
+ case 'RemoteParticipantReady':
554
+ this.#onRemoteParticipantReady(busState, frame, control.value);
555
+ break;
556
+ case 'ObjectsPublished':
557
+ this.emit('objectsPublished', { bus: busState, ...control.value });
558
+ break;
559
+ case 'ObjectsRemoved':
560
+ this.emit('objectsRemoved', { bus: busState, ...control.value });
561
+ break;
562
+ case 'ObjectsStateResponse':
563
+ this.emit('objectsStateResponse', { bus: busState, ...control.value });
564
+ break;
565
+ case 'TypesInfoResponse':
566
+ this.emit('typesInfoResponse', { bus: busState, ...control.value });
567
+ break;
568
+ case 'TypesInfoRejection':
569
+ this.emit('typesInfoRejection', { bus: busState, ...control.value });
570
+ break;
571
+ default:
572
+ break;
573
+ }
574
+ }
575
+
576
+ #onRemoteParticipantReady(busState, frame, value) {
577
+ if (value.id !== busState.participantId) {
578
+ return;
579
+ }
580
+
581
+ const remoteParticipantId = frame.to;
582
+ if (!busState.readyRemoteParticipants.has(remoteParticipantId)) {
583
+ busState.readyRemoteParticipants.add(remoteParticipantId);
584
+ this.#sendBusControl(busState, busState.participantId, {
585
+ type: 'RemoteParticipantReady',
586
+ value: { id: remoteParticipantId }
587
+ });
588
+ this.emit('busParticipantReady', {
589
+ busName: busState.busName,
590
+ busId: busState.busId,
591
+ participantId: busState.participantId,
592
+ remoteParticipantId
593
+ });
594
+ }
595
+ }
596
+
597
+ #sendBusControl(busState, to, message) {
598
+ const busPayload = encodeBusControlMessage(message);
599
+ this.#sendBusMessage(busState, to, busPayload);
600
+ }
601
+
602
+ #sendBusMessage(busState, to, busPayload) {
603
+ const socket = this.#writableSocket();
604
+ const processBusPayload = encodeConfirmedBusFrame({
605
+ to,
606
+ busId: busState.busId,
607
+ message: busPayload
608
+ });
609
+ socket.write(encodeProcessTcpFrame(PROCESS_MESSAGE_CATEGORY.busMessage, processBusPayload), error => {
610
+ if (error) {
611
+ this.emit('error', error);
612
+ }
613
+ });
614
+ }
615
+
616
+ #writableSocket() {
617
+ if (!this.socket || this.socket.destroyed || !this.socket.writable) {
618
+ const error = new Error('SEN ether TCP socket is not writable');
619
+ error.code = 'SEN_TCP_NOT_WRITABLE';
620
+ this.emit('error', error);
621
+ throw error;
622
+ }
623
+ return this.socket;
624
+ }
625
+
626
+ #getBus(bus) {
627
+ const busId = typeof bus === 'number' ? bus : crc32(bus);
628
+ const busState = this.buses.get(busId);
629
+ if (!busState) {
630
+ throw new Error(`SEN bus is not joined: ${bus}`);
631
+ }
632
+ return busState;
633
+ }
634
+ }