@tak-ps/node-tak
Version:
Lightweight JavaScript library for communicating with TAK Server
498 lines (434 loc) • 17.9 kB
text/typescript
import type { Static } from '@sinclair/typebox';
import type { CoTOptions } from '@tak-ps/node-cot';
import CoT, { CoTParser } from '@tak-ps/node-cot';
import EventEmitter from 'node:events';
import type { TLSSocket } from 'node:tls';
import tls from 'node:tls';
import TAKAPI from './lib/api.js';
import { TAKAuth } from './lib/auth.js';
import { Queue } from './lib/utils/queue.js';
export * from './lib/auth.js';
/* eslint-disable no-control-regex */
export const REGEX_CONTROL = /[\u000B-\u001F\u007F-\u009F]/g;
// Match <event .../> or <event> but not <events>
export const REGEX_EVENT = /(<event[ >][\s\S]*?<\/event>)([\s\S]*)/;
export interface PartialCoT {
event: string;
remainder: string;
}
/**
* Configuration options for a TAK connection.
*
* Performance-related options control the write pipeline:
*
* ```
* write(cots) process()
* ─────────── ─────────
* for each CoT: while queue has items:
* serialize to XML ──push()──► Ring pop socketBatchSize items
* (returns false ◄──────── Buffer join into one string
* when full) (capacity = socket.write(batch)
* writeQueueSize) stop if backpressure
* when full:
* setImmediate() yield triggered by:
* (lets process() drain) - write() calling process()
* - socket 'drain' event
* ```
*
* @example High-throughput bulk ingestion
* ```typescript
* const tak = await TAK.connect(url, auth, {
* writeQueueSize: 50_000, // large buffer absorbs bursts
* socketBatchSize: 128, // 128 strings per socket.write()
* });
* ```
*
* @example Low-latency real-time streams
* ```typescript
* const tak = await TAK.connect(url, auth, {
* writeQueueSize: 400, // small buffer keeps memory minimal
* socketBatchSize: 10, // flush to socket every 10 items
* });
* ```
*/
export type TAKOptions = {
/** Unique connection identifier. Appears in log messages for debugging.
* Useful when running multiple TAK connections in a single process.
* @default crypto.randomUUID() */
id?: number | string;
/** Connection type label. Informational only — helps distinguish
* connections in logs when multiple are active.
* @default 'unknown' */
type?: string;
/** Options passed through to `@tak-ps/node-cot` for CoT parsing
* (e.g., on incoming `'cot'` events). Does not affect the write pipeline. */
cot?: CoTOptions;
/** Capacity of the ring buffer that sits between `write()` and `process()`.
* When the queue is full, `write()` yields via `setImmediate()` until
* `process()` drains space. Larger values allow more XML strings to be
* buffered, increasing throughput at the cost of higher peak memory.
* @default 10_000 */
writeQueueSize?: number;
/** How many pre-serialized XML strings are popped from the ring buffer
* and joined into a single `socket.write()` call in `process()`. Higher
* values reduce syscall overhead and improve TLS frame packing, but
* increase per-write latency and the size of each socket write.
* @default 64 */
socketBatchSize?: number;
};
export type WriteOptions = {
/** Remove any existing flow tags and replace them with a fresh
* `NodeCoT-*` entry before serializing to XML.
* Useful when re-submitting CoTs back to TAK Server over 8089.
* @default false */
stripFlow?: boolean;
};
const DEFAULT_WRITE_QUEUE_SIZE = 10_000;
const DEFAULT_SOCKET_BATCH_SIZE = 64;
function cloneCoT(cot: CoT): CoT {
const cloned = new CoT(JSON.parse(JSON.stringify(cot.raw)));
cloned.metadata = JSON.parse(JSON.stringify(cot.metadata));
cloned.path = cot.path;
return cloned;
}
export default class TAK extends EventEmitter {
id: number | string;
type: string;
url: URL;
auth: Static<typeof TAKAuth>;
open: boolean;
destroyed: boolean;
writing: boolean;
writeQueueSize: number;
socketBatchSize: number;
cotOptions: CoTOptions;
pingInterval?: ReturnType<typeof setTimeout>;
client?: TLSSocket;
version?: string;
// Hybrid pipeline:
// write() serializes CoTs upfront into a bounded ring buffer of XML strings.
// process() drains the ring buffer to the socket, driven by drain events.
// Fully caller-safe: CoT objects can be mutated/GC'd after write() returns.
queue: Queue<string>;
/**
* @param url - Full URL of Streaming COT Endpoint IE: "https://ops.cotak.gov:8089"
* @param auth - TAK Certificate Pair
* @param opts - Options Object
* @param opts.id - When using multiple connections in a script, allows a unique ID per connection
* @param opts.type - When using multiple connections in a script, allows specifying a script provided connection type
*/
constructor(url: URL, auth: Static<typeof TAKAuth>, opts: TAKOptions = {}) {
super();
if (!opts) opts = {};
this.id = opts.id || crypto.randomUUID();
this.type = opts.type || 'unknown';
this.url = url;
this.auth = auth;
this.writing = false;
this.writeQueueSize = opts.writeQueueSize || DEFAULT_WRITE_QUEUE_SIZE;
this.socketBatchSize =
opts.socketBatchSize || DEFAULT_SOCKET_BATCH_SIZE;
this.cotOptions = opts.cot || {};
this.open = false;
this.destroyed = false;
this.queue = new Queue<string>(this.writeQueueSize);
}
static async connect(
url: URL,
auth: Static<typeof TAKAuth>,
opts: TAKOptions = {},
): Promise<TAK> {
const tak = new TAK(url, auth, opts);
if (url.protocol === 'ssl:') {
if (!tak.auth.cert) throw new Error('auth.cert required');
if (!tak.auth.key) throw new Error('auth.key required');
return await tak.connect_ssl();
} else {
throw new Error('Unknown TAK Server Protocol');
}
}
connect_ssl(): Promise<TAK> {
return new Promise((resolve) => {
this.destroyed = false;
this.open = false;
// Capture the socket in a local variable so that event handlers
// registered on *this* socket can detect when they are stale
// (i.e. a reconnect has already created a newer socket) and
// bail out early. Without this guard, the old socket's delayed
// `close` event fires AFTER connect_ssl() sets this.destroyed=false
// for the new connection and incorrectly calls this.destroy(),
// killing the newly-created socket with no retry triggered.
const client = tls.connect({
host: this.url.hostname,
port: parseInt(this.url.port),
rejectUnauthorized: this.auth.rejectUnauthorized ?? false,
cert: this.auth.cert,
key: this.auth.key,
passphrase: this.auth.passphrase,
ca: this.auth.ca,
});
this.client = client;
client.setNoDelay();
client.on('connect', () => {
if (client !== this.client) return;
console.error(
`ok - ${this.id} @ connect:${this.client ? this.client.authorized : 'NO CLIENT'} - ${this.client ? this.client.authorizationError : 'NO CLIENT'}`,
);
});
client.on('secureConnect', () => {
if (client !== this.client) return;
console.error(
`ok - ${this.id} @ secure:${this.client ? this.client.authorized : 'NO CLIENT'} - ${this.client ? this.client.authorizationError : 'NO CLIENT'}`,
);
this.emit('secureConnect');
this.ping();
});
let buff = '';
client
.on('data', async (data: Buffer) => {
if (client !== this.client) return;
// Eventually Parse ProtoBuf
buff = buff + data.toString();
let result = TAK.findCoT(buff);
while (result && result.event) {
try {
const cot = CoTParser.from_xml(
result.event,
this.cotOptions,
);
if (
cot.raw.event._attributes.type === 't-x-c-t-r'
) {
this.open = true;
this.emit('ping');
} else if (
cot.raw.event._attributes.type ===
't-x-takp-v' &&
cot.raw.event.detail &&
cot.raw.event.detail.TakControl &&
cot.raw.event.detail.TakControl
.TakServerVersionInfo &&
cot.raw.event.detail.TakControl
.TakServerVersionInfo._attributes
) {
this.version =
cot.raw.event.detail.TakControl.TakServerVersionInfo._attributes.serverVersion;
} else {
this.emit('cot', cot);
}
} catch (e) {
console.error('Error parsing', e, data.toString());
}
buff = result.remainder;
result = TAK.findCoT(buff);
}
})
.on('timeout', () => {
if (client !== this.client) return;
this.emit('timeout');
})
.on('error', (err: Error) => {
if (client !== this.client) return;
console.error(`[socket] error:`, err.message);
this.emit('error', err);
})
.on('end', () => {
if (client !== this.client) return;
this.open = false;
this.emit('end');
// After emitting 'end', a reconnect triggered synchronously
// by a listener may have already replaced this.client with
// a fresh socket and reset this.destroyed to false.
// Re-check socket identity so we don't destroy the
// newly-created socket.
if (client === this.client && !this.destroyed) {
this.destroy();
}
})
.on('close', () => {
if (client !== this.client) return;
if (!this.destroyed) {
this.destroy();
// Emit 'close' so consumers can trigger a retry when
// the socket closes without a preceding 'end' event
// (e.g. TCP RST where only error+close fires).
this.emit('close');
}
})
.on('drain', () => {
if (client !== this.client) return;
this.process();
});
this.pingInterval = setInterval(() => {
this.ping();
}, 5000);
return resolve(this);
});
}
async reconnect(): Promise<void> {
if (this.destroyed) {
await this.connect_ssl();
} else {
this.destroy();
await this.connect_ssl();
}
}
destroy(): void {
this.destroyed = true;
if (this.client) {
this.client.destroy();
this.client.removeAllListeners();
this.client = undefined;
}
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = undefined;
}
// Unblock any flush() waiters
this.emit('_flushed');
}
async ping(): Promise<void> {
this.write([CoT.ping()]);
}
/**
* Drain the queue to the socket.
*
* Pops pre-serialized XML strings from the ring buffer, batches them
* (up to `socketBatchSize` per call), and writes to the socket. Runs
* synchronously in a single event loop tick until the socket signals
* backpressure or the queue is empty.
*
* Called when the socket signals readiness:
* - `'drain'` event (socket buffer cleared, ready for more)
* - After `write()` enqueues new items
*
* Emits `'_flushed'` when the queue drains to zero, waking any
* pending `flush()` calls.
*/
process(): void {
if (this.writing) return;
if (!this.client || this.destroyed) return;
this.writing = true;
try {
while (this.queue.length > 0) {
if (this.destroyed || !this.client) break;
if (this.client.writableNeedDrain) break;
const batchCount = Math.min(
this.socketBatchSize,
this.queue.length,
);
const parts: string[] = new Array(batchCount);
for (let i = 0; i < batchCount; i++) {
const xml = this.queue.pop();
if (!xml) break;
parts[i] = xml;
}
const ok = this.client.write(parts.join('\n') + '\n');
if (!ok) break;
}
} catch (err) {
this.destroy();
this.emit('error', err);
} finally {
this.writing = false;
// Safety net: if a drain event fired while writing=true (and was
// therefore ignored), re-check. If the socket has capacity, reschedule
// on the next event loop turn so I/O callbacks can run first.
if (
this.queue.length > 0 &&
!this.destroyed &&
this.client &&
!this.client.writableNeedDrain
) {
setImmediate(() => this.process());
}
if (this.queue.length === 0) {
this.emit('_flushed');
}
}
}
/**
* Write CoTs to the TAK connection.
*
* Serializes each CoT to XML upfront and stores the string in a bounded
* ring buffer. Fully caller-safe: CoT objects can be mutated or GC'd
* immediately after this returns.
* Resolves when all items are queued (not when sent over the wire).
* Use flush() to wait for delivery.
*
* @param cots Array of CoT objects to send
*/
async write(cots: CoT[], opts: WriteOptions = {}): Promise<void> {
for (let i = 0; i < cots.length; ) {
if (this.destroyed) return;
// Serialize upfront and push XML strings into the ring buffer
while (
i < cots.length &&
this.queue.push(CoTParser.to_xml(
opts.stripFlow ? cloneCoT(cots[i]) : cots[i],
opts.stripFlow ? { resetFlow: true } : undefined,
))
) {
i++;
}
// Kick process to start draining
this.process();
// Queue full — yield to let process() drain via I/O callbacks,
// then retry on the next event loop turn.
if (i < cots.length) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
}
}
/**
* Wait until all queued CoTs have been flushed to the socket.
*
* write() is a fast "enqueue" — it returns once items are in the queue,
* NOT once they've been sent over the wire.
*
* Resolves immediately if nothing is queued.
* Rejects if the connection is destroyed before flush completes.
*/
async flush(): Promise<void> {
if (this.queue.length === 0 && !this.writing) return;
return new Promise<void>((resolve, reject) => {
const check = () => {
if (this.destroyed) {
cleanup();
reject(
new Error(
'connection destroyed before flush completed',
),
);
} else if (this.queue.length === 0 && !this.writing) {
cleanup();
resolve();
}
};
const cleanup = () => {
this.removeListener('_flushed', check);
};
this.on('_flushed', check);
check();
});
}
write_xml(body: string): void {
this.queue.push(body);
if (this.queue.length > 0 && !this.writing) {
this.process();
}
}
// https://github.com/vidterra/multitak/blob/main/app/lib/helper.js#L4
static findCoT(str: string): null | PartialCoT {
str = str.replace(REGEX_CONTROL, '');
const match = str.match(REGEX_EVENT); // find first CoT
if (!match) return null;
return {
event: match[1],
remainder: match[2],
};
}
}
export * from './lib/api.js';
export { CommandOutputFormat } from './lib/commands.js';
export { CoT, TAKAPI };