@tak-ps/node-tak 11.26.2 → 12.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.
package/index.ts CHANGED
@@ -1,30 +1,93 @@
1
- import EventEmitter from 'node:events';
2
1
  import type { Static } from '@sinclair/typebox';
3
- import tls from 'node:tls';
4
- import CoT, { CoTParser } from '@tak-ps/node-cot';
5
2
  import type { CoTOptions } from '@tak-ps/node-cot';
6
- import type { TLSSocket } from 'node:tls'
3
+ import CoT, { CoTParser } from '@tak-ps/node-cot';
4
+ import EventEmitter from 'node:events';
5
+ import type { TLSSocket } from 'node:tls';
6
+ import tls from 'node:tls';
7
7
 
8
8
  import TAKAPI from './lib/api.js';
9
9
  import { TAKAuth } from './lib/auth.js';
10
+ import { Queue } from './lib/utils/queue.js';
10
11
  export * from './lib/auth.js';
11
12
 
12
13
  /* eslint-disable no-control-regex */
13
14
  export const REGEX_CONTROL = /[\u000B-\u001F\u007F-\u009F]/g;
14
15
 
15
16
  // Match <event .../> or <event> but not <events>
16
- export const REGEX_EVENT = /(<event[ >][\s\S]*?<\/event>)([\s\S]*)/
17
+ export const REGEX_EVENT = /(<event[ >][\s\S]*?<\/event>)([\s\S]*)/;
17
18
 
18
19
  export interface PartialCoT {
19
20
  event: string;
20
21
  remainder: string;
21
22
  }
22
23
 
24
+ /**
25
+ * Configuration options for a TAK connection.
26
+ *
27
+ * Performance-related options control the write pipeline:
28
+ *
29
+ * ```
30
+ * write(cots) process()
31
+ * ─────────── ─────────
32
+ * for each CoT: while queue has items:
33
+ * serialize to XML ──push()──► Ring pop socketBatchSize items
34
+ * (returns false ◄──────── Buffer join into one string
35
+ * when full) (capacity = socket.write(batch)
36
+ * writeQueueSize) stop if backpressure
37
+ * when full:
38
+ * setImmediate() yield triggered by:
39
+ * (lets process() drain) - write() calling process()
40
+ * - socket 'drain' event
41
+ * ```
42
+ *
43
+ * @example High-throughput bulk ingestion
44
+ * ```typescript
45
+ * const tak = await TAK.connect(url, auth, {
46
+ * writeQueueSize: 50_000, // large buffer absorbs bursts
47
+ * socketBatchSize: 128, // 128 strings per socket.write()
48
+ * });
49
+ * ```
50
+ *
51
+ * @example Low-latency real-time streams
52
+ * ```typescript
53
+ * const tak = await TAK.connect(url, auth, {
54
+ * writeQueueSize: 400, // small buffer keeps memory minimal
55
+ * socketBatchSize: 10, // flush to socket every 10 items
56
+ * });
57
+ * ```
58
+ */
23
59
  export type TAKOptions = {
24
- id?: number | string,
25
- type?: string,
26
- cot?: CoTOptions,
27
- }
60
+ /** Unique connection identifier. Appears in log messages for debugging.
61
+ * Useful when running multiple TAK connections in a single process.
62
+ * @default crypto.randomUUID() */
63
+ id?: number | string;
64
+
65
+ /** Connection type label. Informational only — helps distinguish
66
+ * connections in logs when multiple are active.
67
+ * @default 'unknown' */
68
+ type?: string;
69
+
70
+ /** Options passed through to `@tak-ps/node-cot` for CoT parsing
71
+ * (e.g., on incoming `'cot'` events). Does not affect the write pipeline. */
72
+ cot?: CoTOptions;
73
+
74
+ /** Capacity of the ring buffer that sits between `write()` and `process()`.
75
+ * When the queue is full, `write()` yields via `setImmediate()` until
76
+ * `process()` drains space. Larger values allow more XML strings to be
77
+ * buffered, increasing throughput at the cost of higher peak memory.
78
+ * @default 10_000 */
79
+ writeQueueSize?: number;
80
+
81
+ /** How many pre-serialized XML strings are popped from the ring buffer
82
+ * and joined into a single `socket.write()` call in `process()`. Higher
83
+ * values reduce syscall overhead and improve TLS frame packing, but
84
+ * increase per-write latency and the size of each socket write.
85
+ * @default 64 */
86
+ socketBatchSize?: number;
87
+ };
88
+
89
+ const DEFAULT_WRITE_QUEUE_SIZE = 10_000;
90
+ const DEFAULT_SOCKET_BATCH_SIZE = 64;
28
91
 
29
92
  export default class TAK extends EventEmitter {
30
93
  id: number | string;
@@ -33,8 +96,9 @@ export default class TAK extends EventEmitter {
33
96
  auth: Static<typeof TAKAuth>;
34
97
  open: boolean;
35
98
  destroyed: boolean;
36
- queue: string[];
37
99
  writing: boolean;
100
+ writeQueueSize: number;
101
+ socketBatchSize: number;
38
102
 
39
103
  cotOptions: CoTOptions;
40
104
 
@@ -42,6 +106,12 @@ export default class TAK extends EventEmitter {
42
106
  client?: TLSSocket;
43
107
  version?: string;
44
108
 
109
+ // Hybrid pipeline:
110
+ // write() serializes CoTs upfront into a bounded ring buffer of XML strings.
111
+ // process() drains the ring buffer to the socket, driven by drain events.
112
+ // Fully caller-safe: CoT objects can be mutated/GC'd after write() returns.
113
+ queue: Queue<string>;
114
+
45
115
  /**
46
116
  * @param url - Full URL of Streaming COT Endpoint IE: "https://ops.cotak.gov:8089"
47
117
  * @param auth - TAK Certificate Pair
@@ -49,11 +119,7 @@ export default class TAK extends EventEmitter {
49
119
  * @param opts.id - When using multiple connections in a script, allows a unique ID per connection
50
120
  * @param opts.type - When using multiple connections in a script, allows specifying a script provided connection type
51
121
  */
52
- constructor(
53
- url: URL,
54
- auth: Static<typeof TAKAuth>,
55
- opts: TAKOptions = {}
56
- ) {
122
+ constructor(url: URL, auth: Static<typeof TAKAuth>, opts: TAKOptions = {}) {
57
123
  super();
58
124
 
59
125
  if (!opts) opts = {};
@@ -65,19 +131,21 @@ export default class TAK extends EventEmitter {
65
131
  this.auth = auth;
66
132
 
67
133
  this.writing = false;
134
+ this.writeQueueSize = opts.writeQueueSize || DEFAULT_WRITE_QUEUE_SIZE;
135
+ this.socketBatchSize =
136
+ opts.socketBatchSize || DEFAULT_SOCKET_BATCH_SIZE;
68
137
 
69
138
  this.cotOptions = opts.cot || {};
70
139
 
71
140
  this.open = false;
72
141
  this.destroyed = false;
73
-
74
- this.queue = [];
142
+ this.queue = new Queue<string>(this.writeQueueSize);
75
143
  }
76
144
 
77
145
  static async connect(
78
146
  url: URL,
79
147
  auth: Static<typeof TAKAuth>,
80
- opts: TAKOptions = {}
148
+ opts: TAKOptions = {},
81
149
  ): Promise<TAK> {
82
150
  const tak = new TAK(url, auth, opts);
83
151
 
@@ -107,55 +175,84 @@ export default class TAK extends EventEmitter {
107
175
  this.client.setNoDelay();
108
176
 
109
177
  this.client.on('connect', () => {
110
- console.error(`ok - ${this.id} @ connect:${this.client ? this.client.authorized : 'NO CLIENT'} - ${this.client ? this.client.authorizationError : 'NO CLIENT'}`);
178
+ console.error(
179
+ `ok - ${this.id} @ connect:${this.client ? this.client.authorized : 'NO CLIENT'} - ${this.client ? this.client.authorizationError : 'NO CLIENT'}`,
180
+ );
111
181
  });
112
182
 
113
183
  this.client.on('secureConnect', () => {
114
- console.error(`ok - ${this.id} @ secure:${this.client ? this.client.authorized : 'NO CLIENT'} - ${this.client ? this.client.authorizationError : 'NO CLIENT'}`);
115
- this.emit('secureConnect')
184
+ console.error(
185
+ `ok - ${this.id} @ secure:${this.client ? this.client.authorized : 'NO CLIENT'} - ${this.client ? this.client.authorizationError : 'NO CLIENT'}`,
186
+ );
187
+ this.emit('secureConnect');
116
188
  this.ping();
117
189
  });
118
190
 
119
191
  let buff = '';
120
- this.client.on('data', async (data: Buffer) => {
121
- // Eventually Parse ProtoBuf
122
- buff = buff + data.toString();
123
-
124
- let result = TAK.findCoT(buff);
125
- while (result && result.event) {
126
- try {
127
- const cot = await CoTParser.from_xml(result.event, this.cotOptions);
128
-
129
- if (cot.raw.event._attributes.type === 't-x-c-t-r') {
130
- this.open = true;
131
- this.emit('ping');
132
- } else if (
133
- cot.raw.event._attributes.type === 't-x-takp-v'
134
- && cot.raw.event.detail
135
- && cot.raw.event.detail.TakControl
136
- && cot.raw.event.detail.TakControl.TakServerVersionInfo
137
- && cot.raw.event.detail.TakControl.TakServerVersionInfo._attributes
138
- ) {
139
- this.version = cot.raw.event.detail.TakControl.TakServerVersionInfo._attributes.serverVersion;
140
- } else {
141
- this.emit('cot', cot);
192
+ this.client
193
+ .on('data', async (data: Buffer) => {
194
+ // Eventually Parse ProtoBuf
195
+ buff = buff + data.toString();
196
+
197
+ let result = TAK.findCoT(buff);
198
+ while (result && result.event) {
199
+ try {
200
+ const cot = CoTParser.from_xml(
201
+ result.event,
202
+ this.cotOptions,
203
+ );
204
+
205
+ if (
206
+ cot.raw.event._attributes.type === 't-x-c-t-r'
207
+ ) {
208
+ this.open = true;
209
+ this.emit('ping');
210
+ } else if (
211
+ cot.raw.event._attributes.type ===
212
+ 't-x-takp-v' &&
213
+ cot.raw.event.detail &&
214
+ cot.raw.event.detail.TakControl &&
215
+ cot.raw.event.detail.TakControl
216
+ .TakServerVersionInfo &&
217
+ cot.raw.event.detail.TakControl
218
+ .TakServerVersionInfo._attributes
219
+ ) {
220
+ this.version =
221
+ cot.raw.event.detail.TakControl.TakServerVersionInfo._attributes.serverVersion;
222
+ } else {
223
+ this.emit('cot', cot);
224
+ }
225
+ } catch (e) {
226
+ console.error('Error parsing', e, data.toString());
142
227
  }
143
- } catch (e) {
144
- console.error('Error parsing', e, data.toString());
145
- }
146
228
 
147
- buff = result.remainder;
229
+ buff = result.remainder;
148
230
 
149
- result = TAK.findCoT(buff);
150
- }
151
- }).on('timeout', () => {
152
- this.emit('timeout');
153
- }).on('error', (err: Error) => {
154
- this.emit('error', err);
155
- }).on('end', () => {
156
- this.open = false;
157
- this.emit('end');
158
- });
231
+ result = TAK.findCoT(buff);
232
+ }
233
+ })
234
+ .on('timeout', () => {
235
+ this.emit('timeout');
236
+ })
237
+ .on('error', (err: Error) => {
238
+ console.error(`[socket] error:`, err.message);
239
+ this.emit('error', err);
240
+ })
241
+ .on('end', () => {
242
+ this.open = false;
243
+ this.emit('end');
244
+ if (!this.destroyed) {
245
+ this.destroy();
246
+ }
247
+ })
248
+ .on('close', () => {
249
+ if (!this.destroyed) {
250
+ this.destroy();
251
+ }
252
+ })
253
+ .on('drain', () => {
254
+ this.process();
255
+ });
159
256
 
160
257
  this.pingInterval = setInterval(() => {
161
258
  this.ping();
@@ -176,75 +273,166 @@ export default class TAK extends EventEmitter {
176
273
 
177
274
  destroy(): void {
178
275
  this.destroyed = true;
276
+
179
277
  if (this.client) {
180
278
  this.client.destroy();
181
279
  }
182
280
 
183
281
  if (this.pingInterval) {
184
- clearInterval(this.pingInterval)
282
+ clearInterval(this.pingInterval);
185
283
  this.pingInterval = undefined;
186
284
  }
285
+
286
+ // Unblock any flush() waiters
287
+ this.emit('_flushed');
187
288
  }
188
289
 
189
290
  async ping(): Promise<void> {
190
291
  this.write([CoT.ping()]);
191
292
  }
192
293
 
193
- writer(body: string): Promise<boolean> {
194
- return new Promise((resolve, reject) => {
195
- if (!this.client) return reject(new Error('A Connection Client must first be created before it can be written'));
196
-
197
- const res: boolean = this.client.write(body + '\n', () => {
198
- return resolve(res)
199
- });
200
- });
201
- }
202
-
203
- async process(): Promise<void> {
294
+ /**
295
+ * Drain the queue to the socket.
296
+ *
297
+ * Pops pre-serialized XML strings from the ring buffer, batches them
298
+ * (up to `socketBatchSize` per call), and writes to the socket. Runs
299
+ * synchronously in a single event loop tick until the socket signals
300
+ * backpressure or the queue is empty.
301
+ *
302
+ * Called when the socket signals readiness:
303
+ * - `'drain'` event (socket buffer cleared, ready for more)
304
+ * - After `write()` enqueues new items
305
+ *
306
+ * Emits `'_flushed'` when the queue drains to zero, waking any
307
+ * pending `flush()` calls.
308
+ */
309
+ process(): void {
310
+ if (this.writing) return;
311
+ if (!this.client || this.destroyed) return;
204
312
  this.writing = true;
205
- while (this.queue.length) {
206
- const body = this.queue.shift()
207
- if (!body) continue;
208
- await this.writer(body);
209
- }
210
313
 
211
- await this.writer('');
314
+ try {
315
+ while (this.queue.length > 0) {
316
+ if (this.destroyed || !this.client) break;
317
+ if (this.client.writableNeedDrain) break;
318
+
319
+ const batchCount = Math.min(
320
+ this.socketBatchSize,
321
+ this.queue.length,
322
+ );
323
+ const parts: string[] = new Array(batchCount);
324
+ for (let i = 0; i < batchCount; i++) {
325
+ const xml = this.queue.pop();
326
+ if (!xml) break;
327
+ parts[i] = xml;
328
+ }
212
329
 
213
- if (this.queue.length) {
214
- process.nextTick(() => {
215
- this.process();
216
- });
217
- } else {
330
+ const ok = this.client.write(parts.join('\n') + '\n');
331
+ if (!ok) break;
332
+ }
333
+ } catch (err) {
334
+ this.destroy();
335
+ this.emit('error', err);
336
+ } finally {
218
337
  this.writing = false;
338
+
339
+ // Safety net: if a drain event fired while writing=true (and was
340
+ // therefore ignored), re-check. If the socket has capacity, reschedule
341
+ // on the next event loop turn so I/O callbacks can run first.
342
+ if (
343
+ this.queue.length > 0 &&
344
+ !this.destroyed &&
345
+ this.client &&
346
+ !this.client.writableNeedDrain
347
+ ) {
348
+ setImmediate(() => this.process());
349
+ }
350
+
351
+ if (this.queue.length === 0) {
352
+ this.emit('_flushed');
353
+ }
219
354
  }
220
355
  }
221
356
 
222
357
  /**
223
- * Write a CoT to the TAK Connection
358
+ * Write CoTs to the TAK connection.
359
+ *
360
+ * Serializes each CoT to XML upfront and stores the string in a bounded
361
+ * ring buffer. Fully caller-safe: CoT objects can be mutated or GC'd
362
+ * immediately after this returns.
363
+ * Resolves when all items are queued (not when sent over the wire).
364
+ * Use flush() to wait for delivery.
224
365
  *
225
- * @param {CoT} cots CoT Object
366
+ * @param cots Array of CoT objects to send
226
367
  */
227
368
  async write(cots: CoT[]): Promise<void> {
228
- for (const cot of cots) {
229
- this.queue.push(await CoTParser.to_xml(cot));
230
- }
231
-
232
- if (this.queue.length && !this.writing) {
369
+ for (let i = 0; i < cots.length; ) {
370
+ if (this.destroyed) return;
371
+
372
+ // Serialize upfront and push XML strings into the ring buffer
373
+ while (
374
+ i < cots.length &&
375
+ this.queue.push(CoTParser.to_xml(cots[i]))
376
+ ) {
377
+ i++;
378
+ }
379
+
380
+ // Kick process to start draining
233
381
  this.process();
382
+
383
+ // Queue full — yield to let process() drain via I/O callbacks,
384
+ // then retry on the next event loop turn.
385
+ if (i < cots.length) {
386
+ await new Promise<void>((resolve) => setImmediate(resolve));
387
+ }
234
388
  }
235
389
  }
236
390
 
391
+ /**
392
+ * Wait until all queued CoTs have been flushed to the socket.
393
+ *
394
+ * write() is a fast "enqueue" — it returns once items are in the queue,
395
+ * NOT once they've been sent over the wire.
396
+ *
397
+ * Resolves immediately if nothing is queued.
398
+ * Rejects if the connection is destroyed before flush completes.
399
+ */
400
+ async flush(): Promise<void> {
401
+ if (this.queue.length === 0 && !this.writing) return;
402
+
403
+ return new Promise<void>((resolve, reject) => {
404
+ const check = () => {
405
+ if (this.destroyed) {
406
+ cleanup();
407
+ reject(
408
+ new Error(
409
+ 'connection destroyed before flush completed',
410
+ ),
411
+ );
412
+ } else if (this.queue.length === 0 && !this.writing) {
413
+ cleanup();
414
+ resolve();
415
+ }
416
+ };
417
+ const cleanup = () => {
418
+ this.removeListener('_flushed', check);
419
+ };
420
+ this.on('_flushed', check);
421
+ check();
422
+ });
423
+ }
424
+
237
425
  write_xml(body: string): void {
238
426
  this.queue.push(body);
239
427
 
240
- if (this.queue.length && !this.writing) {
428
+ if (this.queue.length > 0 && !this.writing) {
241
429
  this.process();
242
430
  }
243
431
  }
244
432
 
245
433
  // https://github.com/vidterra/multitak/blob/main/app/lib/helper.js#L4
246
434
  static findCoT(str: string): null | PartialCoT {
247
- str = str.replace(REGEX_CONTROL, "");
435
+ str = str.replace(REGEX_CONTROL, '');
248
436
 
249
437
  const match = str.match(REGEX_EVENT); // find first CoT
250
438
  if (!match) return null;
@@ -258,7 +446,4 @@ export default class TAK extends EventEmitter {
258
446
 
259
447
  export * from './lib/api.js';
260
448
  export { CommandOutputFormat } from './lib/commands.js';
261
- export {
262
- TAKAPI,
263
- CoT,
264
- }
449
+ export { CoT, TAKAPI };
package/lib/api/groups.ts CHANGED
@@ -14,7 +14,8 @@ export const Group = Type.Object({
14
14
  })
15
15
 
16
16
  export const GroupListInput = Type.Object({
17
- useCache: Type.Optional(Type.Boolean())
17
+ useCache: Type.Optional(Type.Boolean()),
18
+ sendLatestSA: Type.Optional(Type.Boolean())
18
19
  })
19
20
 
20
21
  export const TAKList_Group = TAKList(Group);
@@ -22,7 +23,7 @@ export const TAKList_Group = TAKList(Group);
22
23
  export default class GroupCommands extends Commands {
23
24
  schema = {
24
25
  list: {
25
- description: 'List Missions',
26
+ description: 'List Groups',
26
27
  params: Type.Object({}),
27
28
  query: Type.Object({}),
28
29
  formats: [ CommandOutputFormat.JSON ]
@@ -0,0 +1,53 @@
1
+ // A fixed-size Ring Buffer (Circular Queue) for zero-allocation operations.
2
+ export class Queue<T> {
3
+ private buffer: (T | undefined)[];
4
+ private capacity: number;
5
+ private head: number;
6
+ private tail: number;
7
+ private _length: number;
8
+
9
+ constructor(capacity: number = 10000) {
10
+ this.capacity = capacity;
11
+ this.buffer = new Array(capacity);
12
+ this.head = 0;
13
+ this.tail = 0;
14
+ this._length = 0;
15
+ }
16
+
17
+ // Add item to the queue. Returns false if full.
18
+ push(item: T): boolean {
19
+ if (this._length >= this.capacity) {
20
+ return false;
21
+ }
22
+
23
+ this.buffer[this.tail] = item;
24
+ this.tail = (this.tail + 1) % this.capacity;
25
+ this._length++;
26
+ return true;
27
+ }
28
+
29
+ // Peek at the next item
30
+ peek(): T | undefined {
31
+ if (this._length === 0) return undefined;
32
+ return this.buffer[this.head];
33
+ }
34
+
35
+ pop(): T | undefined {
36
+ if (this._length === 0) return undefined;
37
+
38
+ const item = this.buffer[this.head];
39
+ this.buffer[this.head] = undefined; // Clear reference for GC
40
+ this.head = (this.head + 1) % this.capacity;
41
+ this._length--;
42
+
43
+ return item;
44
+ }
45
+
46
+ get length(): number {
47
+ return this._length;
48
+ }
49
+
50
+ get isFull(): boolean {
51
+ return this._length === this.capacity;
52
+ }
53
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tak-ps/node-tak",
3
3
  "type": "module",
4
- "version": "11.26.2",
4
+ "version": "12.0.0",
5
5
  "description": "Lightweight JavaScript library for communicating with TAK Server",
6
6
  "author": "Nick Ingalls <nick@ingalls.ca>",
7
7
  "main": "dist/index.js",