/** * 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 * 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} 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} 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} 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} 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 }; }