Files
NATSBridge/src/natbridge.js
2026-03-05 11:00:46 +07:00

719 lines
26 KiB
JavaScript

/**
* NATSBridge - Cross-Platform Bi-Directional Data Bridge
* JavaScript Implementation (Node.js and Browser)
*
* This module provides functionality for sending and receiving data across network boundaries
* using NATS as the message bus, with support for both direct payload transport and
* URL-based transport for larger payloads.
*
* File Server Handler Architecture:
* The system uses handler functions to abstract file server operations, allowing support
* for different file server implementations (e.g., Plik, AWS S3, custom HTTP server).
*
* Handler Function Signatures:
*
* ```javascript
* // Upload handler - uploads data to file server and returns URL
* // The handler is passed to smartsend as fileserver_upload_handler parameter
* // It receives: (fileserver_url, dataname, data)
* // Returns: Promise<{ status, uploadid, fileid, url }>
* fileserver_upload_handler(fileserver_url, dataname, data)
*
* // Download handler - fetches data from file server URL with exponential backoff
* // The handler is passed to smartreceive as fileserver_download_handler parameter
* // It receives: (url, max_retries, base_delay, max_delay, correlation_id)
* // Returns: Promise<Uint8Array>
* fileserver_download_handler(url, max_retries, base_delay, max_delay, correlation_id)
* ```
*
* Multi-Payload Support (Standard API):
* The system uses a standardized list-of-tuples format for all payload operations.
* Even when sending a single payload, the user must wrap it in a list.
*
* API Standard:
*
* ```javascript
* // Input format for smartsend (always a list of tuples with type info)
* [[dataname1, data1, type1], [dataname2, data2, type2], ...]
*
* // Output format for smartreceive (returns a dictionary with payloads field containing list of tuples)
* {
* "correlation_id": "...",
* "msg_id": "...",
* "timestamp": "...",
* "send_to": "...",
* "msg_purpose": "...",
* "sender_name": "...",
* "sender_id": "...",
* "receiver_name": "...",
* "receiver_id": "...",
* "reply_to": "...",
* "reply_to_msg_id": "...",
* "broker_url": "...",
* "metadata": {...},
* "payloads": [[dataname1, data1, type1], [dataname2, data2, type2], ...]
* }
* ```
*
* Supported types: "text", "dictionary", "table", "image", "audio", "video", "binary"
*/
const nats = typeof require !== 'undefined' ? require('nats') : null;
const { v4: uuidv4 } = typeof require !== 'undefined' ? require('uuid') : null;
const fetch = typeof require !== 'undefined' ? require('node-fetch') : (typeof globalThis !== 'undefined' ? globalThis.fetch : undefined);
const arrow = typeof require !== 'undefined' ? require('apache-arrow') : null;
/**
* Default configuration values
*/
const DEFAULT_SIZE_THRESHOLD = 1_000_000; // 1MB - threshold for switching from direct to link transport
const DEFAULT_BROKER_URL = 'nats://localhost:4222'; // Default NATS server URL
const DEFAULT_FILESERVER_URL = 'http://localhost:8080'; // Default HTTP file server URL
/**
* Generate a UUID v4
* @returns {string} UUID string
*/
function generateUUID() {
if (uuidv4) {
return uuidv4();
}
// Fallback UUID generation for environments without uuid package
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* Log a trace message with correlation ID and timestamp
* @param {string} correlation_id - Correlation ID for tracing
* @param {string} message - Message content to log
*/
function logTrace(correlation_id, message) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [Correlation: ${correlation_id}] ${message}`);
}
/**
* Serialize data according to specified format
* @param {any} data - Data to serialize
* @param {string} payload_type - Target format: "text", "dictionary", "table", "image", "audio", "video", "binary"
* @returns {Promise<Uint8Array>} Binary representation of the serialized data
*/
async function serializeData(data, payload_type) {
if (payload_type === 'text') {
if (typeof data === 'string') {
return new TextEncoder().encode(data);
} else {
throw new Error('Text data must be a string');
}
} else if (payload_type === 'dictionary') {
const jsonStr = JSON.stringify(data);
return new TextEncoder().encode(jsonStr);
} else if (payload_type === 'table') {
// Use Apache Arrow for table serialization
if (!arrow) {
throw new Error('apache-arrow not available. Install with: npm install apache-arrow');
}
// Convert array of objects to Arrow Table
if (!Array.isArray(data)) {
throw new Error('Table data must be an array of objects');
}
// Build schema from first row if not provided
const schemaFields = [];
if (data.length > 0) {
for (const key in data[0]) {
const value = data[0][key];
let arrowType;
if (typeof value === 'number') {
arrowType = arrow.float64;
} else if (typeof value === 'boolean') {
arrowType = arrow.bool;
} else if (value instanceof Date) {
arrowType = arrow.string; // Date as string
} else {
arrowType = arrow.string;
}
schemaFields.push(new arrow.Field(key, arrowType));
}
}
const schema = new arrow.Schema(schemaFields);
// Convert data to Arrow RecordBatch
const arrays = {};
for (const field of schema.fields) {
const name = field.name;
const type = field.type;
if (type instanceof arrow.Float64) {
arrays[name] = new arrow.Float64Array(data.length);
for (let i = 0; i < data.length; i++) {
arrays[name][i] = data[i][name] || 0;
}
} else if (type instanceof arrow.Boolean) {
arrays[name] = new arrow.BooleanArray(data.length);
for (let i = 0; i < data.length; i++) {
arrays[name][i] = data[i][name] || false;
}
} else {
// String type
const values = data.map(row => String(row[name] ?? ''));
const offsets = new Int32Array(values.length + 1);
let offset = 0;
for (let i = 0; i < values.length; i++) {
offsets[i + 1] = offset += values[i].length;
}
const buffer = new Uint8Array(offsets[values.length]);
for (let i = 0; i < values.length; i++) {
const encoder = new TextEncoder();
const bytes = encoder.encode(values[i]);
buffer.set(bytes, offsets[i]);
}
arrays[name] = new arrow.StringArray(
new arrow.DataView(new arrow.Buffer(buffer), 0, offsets[values.length]),
new arrow.Buffer(offsets.buffer, 0, offsets.length * 4),
0,
data.length
);
}
}
const recordBatch = arrow.RecordBatch.fromArrays(schema, arrays, data.length);
// Write to IPC format
const buffer = arrow.tableFromBatches([recordBatch]).toBuffer();
return new Uint8Array(buffer);
} else if (payload_type === 'image') {
if (data instanceof Uint8Array || data instanceof ArrayBuffer || Buffer.isBuffer(data)) {
return data instanceof Uint8Array ? data : new Uint8Array(data);
} else {
throw new Error('Image data must be Uint8Array or ArrayBuffer');
}
} else if (payload_type === 'audio') {
if (data instanceof Uint8Array || data instanceof ArrayBuffer || Buffer.isBuffer(data)) {
return data instanceof Uint8Array ? data : new Uint8Array(data);
} else {
throw new Error('Audio data must be Uint8Array or ArrayBuffer');
}
} else if (payload_type === 'video') {
if (data instanceof Uint8Array || data instanceof ArrayBuffer || Buffer.isBuffer(data)) {
return data instanceof Uint8Array ? data : new Uint8Array(data);
} else {
throw new Error('Video data must be Uint8Array or ArrayBuffer');
}
} else if (payload_type === 'binary') {
if (data instanceof Uint8Array || data instanceof ArrayBuffer || Buffer.isBuffer(data)) {
return data instanceof Uint8Array ? data : new Uint8Array(data);
} else {
throw new Error('Binary data must be Uint8Array or ArrayBuffer');
}
} else {
throw new Error(`Unknown payload_type: ${payload_type}`);
}
}
/**
* Deserialize bytes to data based on type
* @param {Uint8Array} data - Serialized data as bytes
* @param {string} payload_type - Data type
* @param {string} correlation_id - Correlation ID for logging
* @returns {Promise<any>} Deserialized data
*/
async function deserializeData(data, payload_type, correlation_id) {
if (payload_type === 'text') {
return new TextDecoder().decode(data);
} else if (payload_type === 'dictionary') {
const jsonStr = new TextDecoder().decode(data);
return JSON.parse(jsonStr);
} else if (payload_type === 'table') {
// Use Apache Arrow for table deserialization
if (!arrow) {
throw new Error('apache-arrow not available. Install with: npm install apache-arrow');
}
// Read Arrow IPC format
const buffer = arrow.Buffer.wrap(data.buffer, data.byteOffset, data.byteLength);
const table = arrow.tableFromRawBytes(buffer);
// Convert to array of objects for consistency with the API
const result = [];
const numRows = table.numRows;
for (let i = 0; i < numRows; i++) {
const row = {};
for (const colName of table.columnNames) {
const column = table.getColumn(colName);
row[colName] = column.get(i);
}
result.push(row);
}
return result;
} else if (payload_type === 'image') {
return data;
} else if (payload_type === 'audio') {
return data;
} else if (payload_type === 'video') {
return data;
} else if (payload_type === 'binary') {
return data;
} else {
throw new Error(`Unknown payload_type: ${payload_type}`);
}
}
/**
* Fetch data from URL with exponential backoff
* @param {string} url - URL to fetch from
* @param {number} max_retries - Maximum retry attempts
* @param {number} base_delay - Initial delay in milliseconds
* @param {number} max_delay - Maximum delay in milliseconds
* @param {string} correlation_id - Correlation ID for logging
* @returns {Promise<Uint8Array>} Fetched data as bytes
*/
async function fetchWithBackoff(url, max_retries, base_delay, max_delay, correlation_id) {
let delay = base_delay;
for (let attempt = 1; attempt <= max_retries; attempt++) {
try {
const response = await fetch(url);
if (response.status === 200) {
logTrace(correlation_id, `Successfully fetched data from ${url} on attempt ${attempt}`);
const arrayBuffer = await response.arrayBuffer();
return new Uint8Array(arrayBuffer);
} else {
throw new Error(`Failed to fetch: ${response.status}`);
}
} catch (e) {
logTrace(correlation_id, `Attempt ${attempt} failed: ${e.constructor.name}`);
if (attempt < max_retries) {
await new Promise(resolve => setTimeout(resolve, delay));
delay = Math.min(delay * 2, max_delay);
}
}
}
throw new Error(`Failed to fetch data after ${max_retries} attempts`);
}
/**
* Upload a single file to a plik server using one-shot mode
* @param {string} file_server_url - Base URL of the plik server
* @param {string} dataname - Name of the file being uploaded
* @param {Uint8Array} data - Raw byte data of the file content
* @returns {Promise<{ status, uploadid, fileid, url }>} Upload result
*/
async function plikOneshotUpload(file_server_url, dataname, data) {
// Get upload id
const url_getUploadID = `${file_server_url}/upload`;
const headers = { 'Content-Type': 'application/json' };
const body = JSON.stringify({ OneShot: true });
const http_response = await fetch(url_getUploadID, {
method: 'POST',
headers,
body
});
const response_json = await http_response.json();
const uploadid = response_json.id;
const uploadtoken = response_json.uploadToken;
// Upload file
const url_upload = `${file_server_url}/file/${uploadid}`;
const form = new FormData();
const blob = new Blob([data]);
form.append('file', blob, dataname);
const upload_headers = {
'X-UploadToken': uploadtoken
};
const upload_response = await fetch(url_upload, {
method: 'POST',
headers: upload_headers,
body: form
});
const upload_json = await upload_response.json();
const fileid = upload_json.id;
const url = `${file_server_url}/file/${uploadid}/${fileid}/${dataname}`;
return {
status: upload_response.status,
uploadid,
fileid,
url
};
}
/**
* Publish message to NATS
* @param {string|object} broker_url_or_conn - NATS server URL or pre-existing connection
* @param {string} subject - NATS subject to publish to
* @param {string} message - JSON message to publish
* @param {string} correlation_id - Correlation ID for tracing and logging
*/
async function publishMessage(broker_url_or_conn, subject, message, correlation_id) {
if (broker_url_or_conn instanceof Object && broker_url_or_conn.publish) {
// Pre-existing connection
try {
await broker_url_or_conn.publish(subject, message);
logTrace(correlation_id, `Message published to ${subject}`);
} finally {
// Note: In a real implementation, you might want to drain/close the connection
}
} else {
// URL-based - create new connection
if (!nats) {
throw new Error('nats package not available. Install with: npm install nats');
}
const conn = await nats.connect(broker_url_or_conn);
try {
await conn.publish(subject, message);
logTrace(correlation_id, `Message published to ${subject}`);
} finally {
conn.close();
}
}
}
/**
* Build message envelope from payloads and metadata
* @param {string} subject - NATS subject
* @param {Array} payloads - Array of payload objects
* @param {Object} options - Envelope options
* @returns {Object} Message envelope
*/
function buildEnvelope(subject, payloads, options) {
return {
correlation_id: options.correlation_id || generateUUID(),
msg_id: options.msg_id || generateUUID(),
timestamp: new Date().toISOString(),
send_to: subject,
msg_purpose: options.msg_purpose || '',
sender_name: options.sender_name || '',
sender_id: options.sender_id || generateUUID(),
receiver_name: options.receiver_name || '',
receiver_id: options.receiver_id || '',
reply_to: options.reply_to || '',
reply_to_msg_id: options.reply_to_msg_id || '',
broker_url: options.broker_url || DEFAULT_BROKER_URL,
metadata: options.metadata || {},
payloads
};
}
/**
* Convert data to base64 string
* @param {Uint8Array} buffer - Data buffer
* @returns {string} Base64 encoded string
*/
function bufferToBase64(buffer) {
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(buffer)) {
return buffer.toString('base64');
}
// For browser/Node Uint8Array
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Convert base64 string to Uint8Array
* @param {string} base64 - Base64 encoded string
* @returns {Uint8Array} Decoded bytes
*/
function base64ToBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* smartsend - Send data either directly via NATS or via a fileserver URL, depending on payload size
*
* This function intelligently routes data delivery based on payload size relative to a threshold.
* If the serialized payload is smaller than size_threshold, it encodes the data as Base64 and publishes directly over NATS.
* Otherwise, it uploads the data to a fileserver and publishes only the download URL over NATS.
*
* @param {string} subject - NATS subject to publish the message to
* @param {Array} data - Array of [dataname, data, type] tuples to send
* @param {Object} options - Optional configuration
* @param {string} [options.broker_url=DEFAULT_BROKER_URL] - URL of the NATS server
* @param {string} [options.fileserver_url=DEFAULT_FILESERVER_URL] - URL of the HTTP file server for large payloads
* @param {Function} [options.fileserver_upload_handler=plikOneshotUpload] - Function to handle fileserver uploads
* @param {number} [options.size_threshold=DEFAULT_SIZE_THRESHOLD] - Threshold in bytes separating direct vs link transport
* @param {string} [options.correlation_id] - Correlation ID for tracing (auto-generated if not provided)
* @param {string} [options.msg_purpose="chat"] - Purpose of the message
* @param {string} [options.sender_name="NATSBridge"] - Name of the sender
* @param {string} [options.receiver_name=""] - Name of the receiver
* @param {string} [options.receiver_id=""] - UUID of the receiver
* @param {string} [options.reply_to=""] - Topic to reply to
* @param {string} [options.reply_to_msg_id=""] - Message ID this message is replying to
* @param {boolean} [options.is_publish=true] - Whether to automatically publish the message to NATS
* @param {object} [options.nats_connection] - Pre-existing NATS connection
* @param {string} [options.msg_id] - Message ID (auto-generated if not provided)
* @param {string} [options.sender_id] - Sender ID (auto-generated if not provided)
* @returns {Promise<[Object, string]>} Promise resolving to [envelope, env_json_str]
*/
async function smartsend(subject, data, options = {}) {
const {
broker_url = DEFAULT_BROKER_URL,
fileserver_url = DEFAULT_FILESERVER_URL,
fileserver_upload_handler = plikOneshotUpload,
size_threshold = DEFAULT_SIZE_THRESHOLD,
correlation_id = generateUUID(),
msg_purpose = 'chat',
sender_name = 'NATSBridge',
receiver_name = '',
receiver_id = '',
reply_to = '',
reply_to_msg_id = '',
is_publish = true,
nats_connection = null,
msg_id = generateUUID(),
sender_id = generateUUID()
} = options;
logTrace(correlation_id, `Starting smartsend for subject: ${subject}`);
// Process each payload in the list
const payloads = [];
for (const [dataname, payloadData, payloadType] of data) {
const payloadBytes = await serializeData(payloadData, payloadType);
const payloadSize = payloadBytes.byteLength;
logTrace(correlation_id, `Serialized payload '${dataname}' (payload_type: ${payloadType}) size: ${payloadSize} bytes`);
// Decision: Direct vs Link
if (payloadSize < size_threshold) {
// Direct path - Base64 encode and send via NATS
const payloadB64 = bufferToBase64(payloadBytes);
logTrace(correlation_id, `Using direct transport for ${payloadSize} bytes`);
payloads.push({
id: generateUUID(),
dataname,
payload_type: payloadType,
transport: 'direct',
encoding: 'base64',
size: payloadSize,
data: payloadB64,
metadata: { payload_bytes: payloadSize }
});
} else {
// Link path - Upload to HTTP server, send URL via NATS
logTrace(correlation_id, 'Using link transport, uploading to fileserver');
const response = await fileserver_upload_handler(fileserver_url, dataname, payloadBytes);
if (response.status !== 200) {
throw new Error(`Failed to upload data to fileserver: ${response.status}`);
}
logTrace(correlation_id, `Uploaded to URL: ${response.url}`);
payloads.push({
id: generateUUID(),
dataname,
payload_type: payloadType,
transport: 'link',
encoding: 'none',
size: payloadSize,
data: response.url,
metadata: {}
});
}
}
// Build envelope
const env = buildEnvelope(subject, payloads, {
correlation_id,
msg_id,
msg_purpose,
sender_name,
sender_id,
receiver_name,
receiver_id,
reply_to,
reply_to_msg_id,
broker_url
});
const env_json_str = JSON.stringify(env);
if (is_publish) {
if (nats_connection) {
await publishMessage(nats_connection, subject, env_json_str, correlation_id);
} else {
await publishMessage(broker_url, subject, env_json_str, correlation_id);
}
}
return [env, env_json_str];
}
/**
* smartreceive - Receive and process messages from NATS
*
* This function processes incoming NATS messages, handling both direct transport
* (base64 decoded payloads) and link transport (URL-based payloads).
* It deserializes the data based on the transport type and returns the result.
*
* @param {Object} msg - NATS message object
* @param {Object} options - Optional configuration
* @param {Function} [options.fileserver_download_handler=fetchWithBackoff] - Function to handle downloading data from file server URLs
* @param {number} [options.max_retries=5] - Maximum retry attempts for fetching URL
* @param {number} [options.base_delay=100] - Initial delay for exponential backoff in ms
* @param {number} [options.max_delay=5000] - Maximum delay for exponential backoff in ms
* @returns {Promise<Object>} Promise resolving to envelope object with deserialized payloads
*/
async function smartreceive(msg, options = {}) {
const {
fileserver_download_handler = fetchWithBackoff,
max_retries = 5,
base_delay = 100,
max_delay = 5000
} = options;
// Parse the JSON envelope
const payload = typeof msg.payload === 'string' ? msg.payload : new TextDecoder().decode(msg.payload);
const env_json_obj = JSON.parse(payload);
logTrace(env_json_obj.correlation_id, 'Processing received message');
// Process all payloads in the envelope
const payloads_list = [];
for (const payload of env_json_obj.payloads) {
const transport = payload.transport;
const dataname = payload.dataname;
if (transport === 'direct') {
logTrace(env_json_obj.correlation_id, `Direct transport - decoding payload '${dataname}'`);
// Extract base64 payload from the payload
const payload_b64 = payload.data;
// Decode Base64 payload
const payload_bytes = base64ToBuffer(payload_b64);
// Deserialize based on type
const data_type = payload.payload_type;
const data = await deserializeData(payload_bytes, data_type, env_json_obj.correlation_id);
payloads_list.push([dataname, data, data_type]);
} else if (transport === 'link') {
// Extract download URL from the payload
const url = payload.data;
logTrace(env_json_obj.correlation_id, `Link transport - fetching '${dataname}' from URL: ${url}`);
// Fetch with exponential backoff using the download handler
const downloaded_data = await fileserver_download_handler(
url,
max_retries,
base_delay,
max_delay,
env_json_obj.correlation_id
);
// Deserialize based on type
const data_type = payload.payload_type;
const data = await deserializeData(downloaded_data, data_type, env_json_obj.correlation_id);
payloads_list.push([dataname, data, data_type]);
} else {
throw new Error(`Unknown transport type for payload '${dataname}': ${transport}`);
}
}
env_json_obj.payloads = payloads_list;
return env_json_obj;
}
/**
* NATS Client wrapper for managing connections
*/
class NATSClient {
constructor(url) {
this.url = url;
this.connection = null;
}
async connect() {
if (!nats) {
throw new Error('nats package not available. Install with: npm install nats');
}
this.connection = await nats.connect({ servers: this.url });
return this.connection;
}
async publish(subject, message) {
if (!this.connection) {
await this.connect();
}
await this.connection.publish(subject, message);
}
async close() {
if (this.connection) {
this.connection.close();
}
}
}
// Export for Node.js
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
NATSClient,
smartsend,
smartreceive,
plikOneshotUpload,
fetchWithBackoff,
serializeData,
deserializeData,
publishMessage,
logTrace,
generateUUID,
DEFAULT_SIZE_THRESHOLD,
DEFAULT_BROKER_URL,
DEFAULT_FILESERVER_URL
};
}
// Export for browser (global scope)
if (typeof window !== 'undefined') {
window.NATSBridge = {
smartsend,
smartreceive,
plikOneshotUpload,
fetchWithBackoff,
serializeData,
deserializeData,
publishMessage,
logTrace,
generateUUID,
NATSClient,
DEFAULT_SIZE_THRESHOLD,
DEFAULT_BROKER_URL,
DEFAULT_FILESERVER_URL
};
}