node-red-contrib-ax25 1.0.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.
Files changed (55) hide show
  1. package/.eslintignore +5 -0
  2. package/.prettierignore +7 -0
  3. package/ARCHITECTURE.md +174 -0
  4. package/CONTEXT.md +90 -0
  5. package/MESSAGES.md +314 -0
  6. package/README.md +317 -0
  7. package/examples/beacons.json +130 -0
  8. package/examples/beacons.png +0 -0
  9. package/examples/bye_subflow.json +107 -0
  10. package/examples/bye_subflow.png +0 -0
  11. package/examples/delete_all_my_messages.json +491 -0
  12. package/examples/delete_all_my_messages.png +0 -0
  13. package/examples/get_message_list_subflow.json +129 -0
  14. package/examples/get_message_list_subflow.png +0 -0
  15. package/examples/send_message_subflow.json +367 -0
  16. package/examples/send_message_subflow.png +0 -0
  17. package/examples/send_test_message.json +643 -0
  18. package/examples/send_test_message.png +0 -0
  19. package/jsconfig.json +37 -0
  20. package/lib/agwpe-client-transport.js +99 -0
  21. package/lib/agwpe-frame-builder.js +176 -0
  22. package/lib/agwpe-frame-pretty.js +107 -0
  23. package/lib/ax25-codec.js +382 -0
  24. package/lib/frame-router.js +95 -0
  25. package/lib/frame-segmentation.js +53 -0
  26. package/lib/message-utils.js +59 -0
  27. package/lib/runtime-store.js +94 -0
  28. package/lib/session-registry.js +142 -0
  29. package/local/buffer_compare.json +135 -0
  30. package/local/debug-d-frame.js +84 -0
  31. package/local/raw-out-test.json +128 -0
  32. package/nodes/agwpe-client.html +70 -0
  33. package/nodes/agwpe-client.js +771 -0
  34. package/nodes/agwpe-client.js.bak +871 -0
  35. package/nodes/connect.html +128 -0
  36. package/nodes/connect.js +450 -0
  37. package/nodes/decode.html +83 -0
  38. package/nodes/decode.js +56 -0
  39. package/nodes/disconnect.html +55 -0
  40. package/nodes/disconnect.js +47 -0
  41. package/nodes/encode.html +117 -0
  42. package/nodes/encode.js +164 -0
  43. package/nodes/monitor-in.html +48 -0
  44. package/nodes/monitor-in.js +42 -0
  45. package/nodes/raw-in.html +50 -0
  46. package/nodes/raw-in.js +72 -0
  47. package/nodes/raw-out.html +76 -0
  48. package/nodes/raw-out.js +144 -0
  49. package/nodes/send.html +91 -0
  50. package/nodes/send.js +373 -0
  51. package/nodes/ui-in.html +64 -0
  52. package/nodes/ui-in.js +68 -0
  53. package/nodes/ui-out.html +80 -0
  54. package/nodes/ui-out.js +133 -0
  55. package/package.json +47 -0
@@ -0,0 +1,382 @@
1
+ "use strict";
2
+
3
+ function classifyControl(control) {
4
+ if ((control & 0x01) === 0) {
5
+ return "I";
6
+ }
7
+ if ((control & 0x03) === 0x01) {
8
+ return "S";
9
+ }
10
+ return "U";
11
+ }
12
+
13
+ function decodeAddress(buffer) {
14
+ return buffer.toString("ascii");
15
+ }
16
+
17
+ function parseCallsign(input) {
18
+ const raw = String(input || "").trim().toUpperCase();
19
+ if (!raw) {
20
+ throw new Error("INVALID_CALLSIGN");
21
+ }
22
+
23
+ const parts = raw.split("-");
24
+ const call = String(parts[0] || "").trim();
25
+ if (!call) {
26
+ throw new Error("INVALID_CALLSIGN");
27
+ }
28
+
29
+ const ssid = parts.length > 1 ? Number.parseInt(parts[1], 10) : 0;
30
+ if (!Number.isInteger(ssid) || ssid < 0 || ssid > 15) {
31
+ throw new Error("INVALID_SSID");
32
+ }
33
+
34
+ return {
35
+ call,
36
+ ssid
37
+ };
38
+ }
39
+
40
+ function encodeAx25Address(callsign, options) {
41
+ const opts = options || {};
42
+ const parsed = parseCallsign(callsign);
43
+ const call = parsed.call.slice(0, 6).padEnd(6, " ");
44
+
45
+ const out = Buffer.alloc(7);
46
+ for (let i = 0; i < 6; i++) {
47
+ out[i] = call.charCodeAt(i) << 1;
48
+ }
49
+
50
+ // Base AX.25 SSID field keeps reserved bits set.
51
+ let ssidByte = 0x60 | ((parsed.ssid & 0x0f) << 1);
52
+ if (opts.hasBeenRepeated) {
53
+ ssidByte |= 0x80;
54
+ }
55
+ if (opts.isLast) {
56
+ ssidByte |= 0x01;
57
+ }
58
+ out[6] = ssidByte;
59
+
60
+ return out;
61
+ }
62
+
63
+ function normalizeVia(value) {
64
+ if (value === undefined || value === null || value === "") {
65
+ return [];
66
+ }
67
+
68
+ if (typeof value === "string") {
69
+ return [{ callsign: value, hasBeenRepeated: false }];
70
+ }
71
+
72
+ if (!Array.isArray(value)) {
73
+ throw new Error("ENCODE_INVALID_VIA");
74
+ }
75
+
76
+ return value.map(function (entry) {
77
+ if (typeof entry === "string") {
78
+ return {
79
+ callsign: entry,
80
+ hasBeenRepeated: false
81
+ };
82
+ }
83
+
84
+ if (!entry || typeof entry !== "object") {
85
+ throw new Error("ENCODE_INVALID_VIA_ENTRY");
86
+ }
87
+
88
+ if (!entry.callsign) {
89
+ throw new Error("ENCODE_VIA_CALLSIGN_REQUIRED");
90
+ }
91
+
92
+ return {
93
+ callsign: entry.callsign,
94
+ hasBeenRepeated: Boolean(entry.hasBeenRepeated)
95
+ };
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Decode one AX.25 wire address field (7 bytes).
101
+ *
102
+ * Layout:
103
+ * - bytes 0..5: callsign characters shifted left by 1
104
+ * - byte 6: SSID/flags
105
+ */
106
+ function decodeAx25Address(buffer) {
107
+ if (buffer.length < 7) {
108
+ throw new Error("INVALID_ADDRESS_LEN");
109
+ }
110
+
111
+ let call = "";
112
+ for (let i = 0; i < 6; i++) {
113
+ call += String.fromCharCode(buffer[i] >> 1);
114
+ }
115
+ call = call.trim();
116
+
117
+ const ssidByte = buffer[6];
118
+ const ssid = (ssidByte >> 1) & 0x0f;
119
+ const hasBeenRepeated = (ssidByte & 0x80) !== 0;
120
+ const isLast = (ssidByte & 0x01) !== 0;
121
+
122
+ const callsign = ssid > 0 ? `${call}-${ssid}` : call;
123
+ return {
124
+ callsign,
125
+ call,
126
+ ssid,
127
+ hasBeenRepeated,
128
+ isLast,
129
+ raw: Buffer.from(buffer)
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Try to detect if buffer is AX.25 wire format or codec format.
135
+ * Codec format starts with short destination length.
136
+ * AX.25 wire address first byte is shifted callsign char, usually >= 0x60.
137
+ * Some AGWPE K payloads may include a leading 0x00 before address bytes.
138
+ */
139
+ function isWireFormat(buffer) {
140
+ if (buffer.length < 15) {
141
+ return false;
142
+ }
143
+
144
+ const firstByte = buffer.readUInt8(0);
145
+
146
+ // Legacy compact codec format starts with short destination length.
147
+ if (firstByte > 0 && firstByte <= 10) {
148
+ return false;
149
+ }
150
+
151
+ // Standard AX.25 shifted chars are typically >= 0x60.
152
+ if (firstByte >= 0x60) {
153
+ return true;
154
+ }
155
+
156
+ // AGWPE K payloads are sometimes observed with a leading 0x00 byte.
157
+ if (firstByte === 0x00 && buffer.length >= 16 && buffer.readUInt8(1) >= 0x60) {
158
+ return true;
159
+ }
160
+
161
+ return false;
162
+ }
163
+
164
+ function hasPidField(control) {
165
+ // I-frames always carry PID.
166
+ if ((control & 0x01) === 0) {
167
+ return true;
168
+ }
169
+
170
+ // U-frames: UI (0x03 with optional P/F bit 0x10) carries PID.
171
+ if ((control & 0x03) === 0x03) {
172
+ return (control & 0xef) === 0x03;
173
+ }
174
+
175
+ // S-frames do not carry PID.
176
+ return false;
177
+ }
178
+
179
+ function parseAddressChain(buffer, offset) {
180
+ const addresses = [];
181
+ let cursor = offset;
182
+
183
+ while (cursor + 7 <= buffer.length) {
184
+ const decoded = decodeAx25Address(buffer.subarray(cursor, cursor + 7));
185
+ addresses.push(decoded);
186
+ cursor += 7;
187
+ if (decoded.isLast) {
188
+ break;
189
+ }
190
+ }
191
+
192
+ if (addresses.length < 2) {
193
+ throw new Error("AX25_ADDRESS_CHAIN_TOO_SHORT");
194
+ }
195
+ if (!addresses[addresses.length - 1].isLast) {
196
+ throw new Error("AX25_ADDRESS_CHAIN_NOT_TERMINATED");
197
+ }
198
+
199
+ return {
200
+ addresses,
201
+ nextOffset: cursor
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Decode real AX.25 wire format frame.
207
+ * Structure:
208
+ * - destination (7)
209
+ * - source (7)
210
+ * - 0..N via addresses (7 each)
211
+ * - control (1)
212
+ * - optional PID (1)
213
+ * - information field
214
+ */
215
+ function decodeWireAx25(raw) {
216
+ const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw || []);
217
+ if (buffer.length < 15) {
218
+ throw new Error("AX25_FRAME_TOO_SHORT");
219
+ }
220
+
221
+ // Accept optional leading non-address byte seen in some AGWPE K payloads.
222
+ const startOffset = buffer[0] >= 0x60 ? 0 : (buffer[0] === 0x00 && buffer[1] >= 0x60 ? 1 : 0);
223
+
224
+ const chain = parseAddressChain(buffer, startOffset);
225
+ if (chain.nextOffset >= buffer.length) {
226
+ throw new Error("AX25_MISSING_CONTROL");
227
+ }
228
+
229
+ const control = buffer.readUInt8(chain.nextOffset);
230
+ let cursor = chain.nextOffset + 1;
231
+
232
+ let pid = null;
233
+ if (hasPidField(control)) {
234
+ if (cursor >= buffer.length) {
235
+ throw new Error("AX25_MISSING_PID");
236
+ }
237
+ pid = buffer.readUInt8(cursor);
238
+ cursor += 1;
239
+ }
240
+
241
+ const destinationAddr = chain.addresses[0];
242
+ const sourceAddr = chain.addresses[1];
243
+ const destination = destinationAddr.callsign;
244
+ const source = sourceAddr.callsign;
245
+ const destinationHasBeenRepeated = destinationAddr.hasBeenRepeated;
246
+ const sourceHasBeenRepeated = sourceAddr.hasBeenRepeated;
247
+ const via = chain.addresses.slice(2).map(function (entry) {
248
+ return {
249
+ callsign: entry.callsign,
250
+ hasBeenRepeated: entry.hasBeenRepeated
251
+ };
252
+ });
253
+
254
+ const payload = buffer.subarray(cursor);
255
+
256
+ return {
257
+ source,
258
+ destination,
259
+ sourceHasBeenRepeated,
260
+ destinationHasBeenRepeated,
261
+ via,
262
+ control,
263
+ pid,
264
+ frameType: classifyControl(control),
265
+ payload
266
+ };
267
+ }
268
+
269
+ function encode(frame) {
270
+ if (!frame || !frame.source || !frame.destination || frame.control === undefined) {
271
+ throw new Error("ENCODE_INVALID_INPUT");
272
+ }
273
+
274
+ const controlValue = Number(frame.control);
275
+ if (!Number.isInteger(controlValue) || controlValue < 0 || controlValue > 255) {
276
+ throw new Error("ENCODE_INVALID_CONTROL");
277
+ }
278
+
279
+ const via = normalizeVia(frame.via);
280
+
281
+ const addresses = [];
282
+ addresses.push(
283
+ encodeAx25Address(frame.destination, {
284
+ isLast: false,
285
+ hasBeenRepeated: Boolean(frame.destinationHasBeenRepeated)
286
+ })
287
+ );
288
+
289
+ // Source is last only when there are no via.
290
+ addresses.push(
291
+ encodeAx25Address(frame.source, {
292
+ isLast: via.length === 0,
293
+ hasBeenRepeated: Boolean(frame.sourceHasBeenRepeated)
294
+ })
295
+ );
296
+
297
+ for (let i = 0; i < via.length; i++) {
298
+ const viaEntry = via[i];
299
+ addresses.push(
300
+ encodeAx25Address(viaEntry.callsign, {
301
+ isLast: i === via.length - 1,
302
+ hasBeenRepeated: viaEntry.hasBeenRepeated
303
+ })
304
+ );
305
+ }
306
+
307
+ const control = Buffer.from([controlValue]);
308
+ const payload = Buffer.isBuffer(frame.payload)
309
+ ? frame.payload
310
+ : Array.isArray(frame.payload)
311
+ ? Buffer.from(frame.payload)
312
+ : Buffer.from(frame.payload || "", "utf8");
313
+
314
+ const parts = addresses.concat([control]);
315
+ if (hasPidField(controlValue)) {
316
+ const pidValue = frame.pid === undefined || frame.pid === null ? 0xf0 : Number(frame.pid);
317
+ if (!Number.isInteger(pidValue) || pidValue < 0 || pidValue > 255) {
318
+ throw new Error("ENCODE_INVALID_PID");
319
+ }
320
+ parts.push(Buffer.from([pidValue]));
321
+ }
322
+ parts.push(payload);
323
+
324
+ return Buffer.concat(parts);
325
+ }
326
+
327
+ function decode(raw) {
328
+ const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw || []);
329
+
330
+ // Auto-detect format: codec format vs wire format
331
+ if (isWireFormat(buffer)) {
332
+ return decodeWireAx25(buffer);
333
+ }
334
+
335
+ // Codec format (length-prefixed)
336
+ if (buffer.length < 4) {
337
+ throw new Error("DECODE_TOO_SHORT");
338
+ }
339
+
340
+ const destinationLen = buffer.readUInt8(0);
341
+ const destinationStart = 1;
342
+ const destinationEnd = destinationStart + destinationLen;
343
+ const sourceLenPos = destinationEnd;
344
+
345
+ if (buffer.length < sourceLenPos + 1) {
346
+ throw new Error("DECODE_INVALID_DEST");
347
+ }
348
+
349
+ const sourceLen = buffer.readUInt8(sourceLenPos);
350
+ const sourceStart = sourceLenPos + 1;
351
+ const sourceEnd = sourceStart + sourceLen;
352
+ const controlPos = sourceEnd;
353
+ const pidPos = controlPos + 1;
354
+ const payloadPos = pidPos + 1;
355
+
356
+ if (buffer.length < payloadPos) {
357
+ throw new Error("DECODE_INVALID_SOURCE");
358
+ }
359
+
360
+ const destination = decodeAddress(buffer.subarray(destinationStart, destinationEnd));
361
+ const source = decodeAddress(buffer.subarray(sourceStart, sourceEnd));
362
+ const control = buffer.readUInt8(controlPos);
363
+ const pid = buffer.readUInt8(pidPos);
364
+ const payload = buffer.subarray(payloadPos);
365
+
366
+ return {
367
+ source,
368
+ destination,
369
+ control,
370
+ pid,
371
+ frameType: classifyControl(control),
372
+ payload
373
+ };
374
+ }
375
+
376
+ module.exports = {
377
+ encode,
378
+ decode,
379
+ decodeWireAx25,
380
+ decodeAx25Address,
381
+ classifyControl
382
+ };
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+
3
+ const EventEmitter = require("events");
4
+ const { prettyPrintAgwpeFrame } = require("./agwpe-frame-pretty");
5
+
6
+ class FrameRouter extends EventEmitter {
7
+ constructor(logger) {
8
+ super();
9
+ this.logger = logger || function () {};
10
+ this.instances = new Map();
11
+ }
12
+
13
+ registerInstance(instanceId, handlers) {
14
+ this.instances.set(instanceId, handlers || {});
15
+ }
16
+
17
+ unregisterInstance(instanceId) {
18
+ this.instances.delete(instanceId);
19
+ }
20
+
21
+ route(instanceId, frame) {
22
+ const handlers = this.instances.get(instanceId);
23
+ if (!handlers) {
24
+ this.logger(`Frame routing failed: no handlers for instanceId ${instanceId}`);
25
+ return false;
26
+ }
27
+
28
+ this.logger(`${prettyPrintAgwpeFrame(frame, { direction: "route" })} | instanceId=${instanceId}`);
29
+
30
+ if (
31
+ frame.kind === "connected-data" &&
32
+ typeof handlers.onConnectedData === "function"
33
+ ) {
34
+ this.logger(`Frame routing: connected-data to ${instanceId}`);
35
+ handlers.onConnectedData(frame);
36
+ return true;
37
+ }
38
+
39
+ if (
40
+ frame.kind === "connected" &&
41
+ frame.sessionId &&
42
+ typeof handlers.onConnectedBySession === "function"
43
+ ) {
44
+ this.logger(`Frame routing: connected(sessionId: ${frame.sessionId}) to ${instanceId}`);
45
+ handlers.onConnectedBySession(frame.sessionId, frame);
46
+ return true;
47
+ }
48
+
49
+ if (frame.kind === "connected" && typeof handlers.onConnected === "function") {
50
+ this.logger(`Frame routing: connected to ${instanceId}`);
51
+ handlers.onConnected(frame);
52
+ return true;
53
+ }
54
+
55
+ if (frame.kind === "disconnected" && typeof handlers.onDisconnected === "function") {
56
+ this.logger(`Frame routing: disconnected to ${instanceId}`);
57
+ handlers.onDisconnected(frame);
58
+ return true;
59
+ }
60
+
61
+ if (frame.kind === "ui" && typeof handlers.onUi === "function") {
62
+ this.logger(`Frame routing: ui to ${instanceId}`);
63
+ handlers.onUi(frame);
64
+ return true;
65
+ }
66
+
67
+ if (frame.kind === "monitor" && typeof handlers.onMonitor === "function") {
68
+ this.logger(`Frame routing: monitor to ${instanceId}`);
69
+ handlers.onMonitor(frame);
70
+ return true;
71
+ }
72
+
73
+ if (frame.kind === "raw" && typeof handlers.onRaw === "function") {
74
+ this.logger(`Frame routing: raw to ${instanceId}`);
75
+ handlers.onRaw(frame);
76
+ return true;
77
+ }
78
+
79
+ if (frame.kind === "outstanding-response" && typeof handlers.onOutstandingResponse === "function") {
80
+ this.logger(`Frame routing: outstanding-response to ${instanceId}`);
81
+ handlers.onOutstandingResponse(frame);
82
+ return true;
83
+ }
84
+
85
+ if (frame.kind === "lifecycle" && typeof handlers.onLifecycle === "function") {
86
+ this.logger(`Frame routing: lifecycle to ${instanceId}`);
87
+ handlers.onLifecycle(frame);
88
+ return true;
89
+ }
90
+
91
+ return false;
92
+ }
93
+ }
94
+
95
+ module.exports = FrameRouter;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+
3
+ const DEFAULT_CHUNK_SIZE = 255;
4
+
5
+ function toBuffer(payload) {
6
+ if (Buffer.isBuffer(payload)) {
7
+ return payload;
8
+ }
9
+ if (typeof payload === "string") {
10
+ return Buffer.from(payload, "utf8");
11
+ }
12
+ if (payload === undefined || payload === null) {
13
+ return Buffer.alloc(0);
14
+ }
15
+ throw new TypeError("payload must be a string or Buffer");
16
+ }
17
+
18
+ function splitPayload(payload, chunkSize) {
19
+ const size = Number.isInteger(chunkSize) && chunkSize > 0 ? chunkSize : DEFAULT_CHUNK_SIZE;
20
+ const buffer = toBuffer(payload);
21
+
22
+ if (buffer.length === 0) {
23
+ return [Buffer.alloc(0)];
24
+ }
25
+
26
+ const chunks = [];
27
+ for (let index = 0; index < buffer.length; index += size) {
28
+ chunks.push(buffer.subarray(index, index + size));
29
+ }
30
+ return chunks;
31
+ }
32
+
33
+ function buildChunkMetadata(payload, options) {
34
+ const opts = options || {};
35
+ const messageId = opts.messageId;
36
+ const chunks = splitPayload(payload, opts.chunkSize);
37
+ const chunkCount = chunks.length;
38
+
39
+ return chunks.map(function (chunk, chunkIndex) {
40
+ return {
41
+ messageId,
42
+ chunkIndex,
43
+ chunkCount,
44
+ payload: chunk
45
+ };
46
+ });
47
+ }
48
+
49
+ module.exports = {
50
+ DEFAULT_CHUNK_SIZE,
51
+ splitPayload,
52
+ buildChunkMetadata
53
+ };
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+
5
+ function nowTimestamp() {
6
+ return new Date().toISOString();
7
+ }
8
+
9
+ function makeMessageId(prefix) {
10
+ const head = prefix || "msg";
11
+ return head + "-" + crypto.randomUUID();
12
+ }
13
+
14
+ function okEnvelope(fields) {
15
+ return Object.assign(
16
+ {
17
+ timestamp: nowTimestamp(),
18
+ status: "ok"
19
+ },
20
+ fields || {}
21
+ );
22
+ }
23
+
24
+ function errorEnvelope(errorCode, errorText, fields) {
25
+ return Object.assign(
26
+ {
27
+ timestamp: nowTimestamp(),
28
+ status: "error",
29
+ errorCode,
30
+ errorText
31
+ },
32
+ fields || {}
33
+ );
34
+ }
35
+
36
+ function chunkEnvelope(fields) {
37
+ const data = Object.assign({ timestamp: nowTimestamp() }, fields || {});
38
+ if (!Number.isInteger(data.chunkIndex) || data.chunkIndex < 0) {
39
+ throw new Error("chunkIndex must be a non-negative integer");
40
+ }
41
+ if (!Number.isInteger(data.chunkCount) || data.chunkCount < 1) {
42
+ throw new Error("chunkCount must be a positive integer");
43
+ }
44
+ if (data.chunkIndex >= data.chunkCount) {
45
+ throw new Error("chunkIndex must be less than chunkCount");
46
+ }
47
+ if (!data.messageId) {
48
+ throw new Error("messageId is required");
49
+ }
50
+ return data;
51
+ }
52
+
53
+ module.exports = {
54
+ nowTimestamp,
55
+ makeMessageId,
56
+ okEnvelope,
57
+ errorEnvelope,
58
+ chunkEnvelope
59
+ };
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+
3
+ const EventEmitter = require("events");
4
+ const SessionRegistry = require("./session-registry");
5
+ const FrameRouter = require("./frame-router");
6
+
7
+ // Global bus: all instance buses forward conn-data/conn-lifecycle/conn-timeout-set here
8
+ // so nodes without a fixed config.client (e.g. Send) can subscribe once and receive
9
+ // events from any agwpe-client instance.
10
+ const globalBus = new EventEmitter();
11
+ globalBus.setMaxListeners(0);
12
+
13
+ // sessionIndex maps sessionId → instanceId so Send can derive the correct context
14
+ // from just a sessionId, without needing config.client on the node.
15
+ const sessionIndex = new Map();
16
+
17
+ function indexSession(sessionId, instanceId) {
18
+ sessionIndex.set(sessionId, instanceId);
19
+ }
20
+
21
+ function unindexSession(sessionId) {
22
+ sessionIndex.delete(sessionId);
23
+ }
24
+
25
+ function instanceIdForSession(sessionId) {
26
+ return sessionIndex.get(sessionId) || null;
27
+ }
28
+
29
+ // Instance map for direct id-based lookup
30
+ const instances = new Map();
31
+
32
+ function createInstance(instanceId, logger) {
33
+ const log = typeof logger === "function" ? logger : function () {};
34
+ const bus = new EventEmitter();
35
+ bus.setMaxListeners(0);
36
+ const context = {
37
+ instanceId,
38
+ state: "disconnected",
39
+ monitorEnabled: false,
40
+ rawEnabled: false,
41
+ rawWireEnabled: false,
42
+ auth: null,
43
+ callsigns: [],
44
+ host: null,
45
+ port: null,
46
+ transport: null,
47
+ bus,
48
+ registry: new SessionRegistry(),
49
+ router: new FrameRouter(log),
50
+ logger: log
51
+ };
52
+ instances.set(instanceId, context);
53
+
54
+ // Forward instance bus events to the global bus so Send nodes subscribed to
55
+ // globalBus receive events from all instances, not just their configured one.
56
+ ["conn-data", "conn-lifecycle", "conn-timeout-set"].forEach(function (evtName) {
57
+ bus.on(evtName, function (evt) { globalBus.emit(evtName, evt); });
58
+ });
59
+
60
+ return context;
61
+ }
62
+
63
+ function getInstance(instanceId) {
64
+ return instances.get(instanceId) || null;
65
+ }
66
+
67
+ function ensureInstance(instanceId) {
68
+ let inst = getInstance(instanceId);
69
+ if (!inst) {
70
+ inst = createInstance(instanceId);
71
+ instances.set(instanceId, inst);
72
+ }
73
+ return inst;
74
+ }
75
+
76
+ function removeInstance(instanceId) {
77
+ instances.delete(instanceId);
78
+ }
79
+
80
+ function getAllInstances() {
81
+ return Array.from(instances.values());
82
+ }
83
+
84
+ module.exports = {
85
+ createInstance,
86
+ getInstance,
87
+ ensureInstance,
88
+ removeInstance,
89
+ getAllInstances,
90
+ globalBus,
91
+ indexSession,
92
+ unindexSession,
93
+ instanceIdForSession
94
+ };