UNPKG

askexperts

Version:

AskExperts SDK: build and use AI experts - ask them questions and pay with bitcoin on an open protocol

553 lines 20.7 kB
import { MessageType } from './interfaces.js'; import { generateUUID } from '../common/uuid.js'; import { createAuthToken } from '../common/auth.js'; import { debugDocstore, debugError } from '../common/debug.js'; /** * WebSocket client for DocStoreSQLiteServer * Implements the DocStoreClient interface */ export class DocStoreWebSocketClient { /** * Creates a new DocStoreWebSocketClient * @param options - Configuration options for the client */ constructor(options) { this.messageCallbacks = new Map(); this.subscriptionCallbacks = new Map(); this.docBuffers = new Map(); this.processingSubscriptions = new Set(); this.connected = false; this.authenticated = false; // Handle legacy constructor format (url string) let url; let privateKey; let token; let customWebSocket; if (typeof options === 'string') { // Legacy format: constructor(url, privateKey?, token?) url = options; // Note: We can't access the other parameters in this legacy format // This is just for backward compatibility with code that passes only the URL } else { // New format: constructor(options) url = options.url; privateKey = options.privateKey; token = options.token; customWebSocket = options.webSocket; } // Initialize connection promise this.connectPromise = new Promise((resolve, reject) => { this.connectResolve = resolve; this.connectReject = reject; }); // Store authentication info for later use this.privateKey = privateKey; this.token = token; this.url = url; // Create event handlers this.openHandler = async () => { debugDocstore('Connected to DocStoreSQLiteServer'); this.connected = true; // If authentication is needed, send auth message if (this.token || this.privateKey) { try { await this.sendAuthMessage(); } catch (error) { debugError('Authentication error:', error); this.connectReject(new Error('Authentication failed')); return; } } this.connectResolve(); }; this.messageHandler = (event) => { try { const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data); const message = JSON.parse(data); this.handleMessage(message); } catch (error) { debugError('Error parsing message:', error); } }; this.closeHandler = () => { debugDocstore('Disconnected from DocStoreSQLiteServer'); this.connected = false; }; this.errorHandler = (event) => { debugError('WebSocket error:', event); if (!this.connected) { this.connectReject(new Error('WebSocket connection error')); } }; // Connect to the WebSocket server if (customWebSocket) { // Use the provided WebSocket instance this.ws = customWebSocket; } else { // Create a new WebSocket instance this.ws = typeof globalThis.WebSocket !== 'undefined' ? new globalThis.WebSocket(url) : new WebSocket(url); } // Set up event handlers this.ws.onopen = this.openHandler; this.ws.onmessage = this.messageHandler; this.ws.onclose = this.closeHandler; this.ws.onerror = this.errorHandler; } /** * Send authentication message after connection * @returns Promise that resolves when authentication is successful */ async sendAuthMessage() { // Generate a unique message ID const id = generateUUID(); // Create headers object const headers = {}; // Add authorization header based on token or privateKey if (this.token) { headers['authorization'] = `Bearer ${this.token}`; } else if (this.privateKey) { const authToken = createAuthToken(this.privateKey, this.url, 'GET'); headers['authorization'] = authToken; } // Create a promise for the auth response const authPromise = new Promise((resolve, reject) => { // Set a timeout to reject the promise if no response is received const timeout = setTimeout(() => { this.messageCallbacks.delete(id); reject(new Error('Timeout waiting for authentication response')); }, 30000); // 30 second timeout // Set up the callback to resolve the promise this.messageCallbacks.set(id, (response) => { clearTimeout(timeout); if (response.error) { reject(new Error(`Authentication error: ${response.error.message}`)); } else { this.authenticated = true; debugDocstore('Authentication successful'); resolve(); } }); }); // Send the auth message const authMessage = JSON.stringify({ id, type: MessageType.AUTH, method: 'auth', params: { headers } }); this.ws.send(authMessage); // Wait for the auth response return authPromise; } /** * Wait for the connection to be established * @returns Promise that resolves when connected */ async waitForConnection() { return this.connectPromise; } /** * Handle incoming WebSocket messages * @param message - Parsed message */ handleMessage(message) { const { id, type, method, params, error } = message; // Handle different message types switch (type) { case MessageType.RESPONSE: // Handle response messages const callback = this.messageCallbacks.get(id); if (callback) { callback(message); this.messageCallbacks.delete(id); } break; case MessageType.DOCUMENT: // Handle document messages for subscriptions const subscriptionCallback = this.subscriptionCallbacks.get(id); if (subscriptionCallback) { if (params.eof) { // End of feed, enqueue undefined to signal end this.enqueueDoc(id, undefined); } else if (params.doc) { // Document received, enqueue it for processing this.enqueueDoc(id, params.doc); } } break; default: debugDocstore(`Unhandled message type: ${type}`); } } /** * Send a message to the server and wait for a response * @param type - Message type * @param method - Method name * @param params - Method parameters * @returns Promise that resolves with the response */ async sendAndWait(type, method, params) { // Wait for connection if not connected if (!this.connected) { await this.waitForConnection(); } // Make sure we're authenticated if authentication was required if ((this.token || this.privateKey) && !this.authenticated) { throw new Error('Not authenticated'); } // Generate a unique message ID const id = generateUUID(); // Create a promise for the response const responsePromise = new Promise((resolve, reject) => { // Set a timeout to reject the promise if no response is received const timeout = setTimeout(() => { this.messageCallbacks.delete(id); reject(new Error(`Timeout waiting for response to ${method}`)); }, 30000); // 30 second timeout // Set up the callback to resolve the promise this.messageCallbacks.set(id, (response) => { clearTimeout(timeout); if (response.error) { reject(new Error(`Error from server: ${response.error.message}`)); } else { resolve(response.params); } }); }); // Send the message const messageStr = JSON.stringify({ id, type, method, params }); this.ws.send(messageStr); // Wait for the response return responsePromise; } /** * Subscribe to documents in a docstore * @param options - Subscription options * @param onDoc - Callback function to handle each document * @returns Promise that resolves with a Subscription object to manage the subscription */ async subscribe(options, onDoc) { // Generate a unique subscription ID const subscriptionId = generateUUID(); // Store the callback this.subscriptionCallbacks.set(subscriptionId, onDoc); // Send the subscription message const subscriptionMessage = JSON.stringify({ id: subscriptionId, type: MessageType.SUBSCRIPTION, method: 'subscribe', params: options }); this.ws.send(subscriptionMessage); // Return a subscription object with a close method return Promise.resolve({ close: () => { // Send an end message to terminate the subscription const endMessage = JSON.stringify({ id: subscriptionId, type: MessageType.END, method: 'subscribe', params: {} }); this.ws.send(endMessage); // Remove the callback this.subscriptionCallbacks.delete(subscriptionId); } }); } /** * Prepare a document for serialization by converting Float32Array to regular arrays * @param doc - Document to prepare * @returns A serializable version of the document */ prepareDocForSerialization(doc) { // Create a deep copy of the document const result = { ...doc }; // Convert Float32Array embeddings to regular arrays if (doc.embeddings) { result.embeddings = doc.embeddings.map(embedding => embedding instanceof Float32Array ? Array.from(embedding) : embedding); } return result; } /** * Upsert a document in the store * @param doc - Document to upsert * @returns Promise that resolves when the operation is complete */ async upsert(doc) { // Prepare the document for serialization const serializedDoc = this.prepareDocForSerialization(doc); // Send the serialized document await this.sendAndWait(MessageType.REQUEST, 'upsert', { doc: serializedDoc }); } /** * Get a document by ID * @param docstore_id - ID of the docstore containing the document * @param doc_id - ID of the document to get * @returns Promise that resolves with the document if found, null otherwise */ async get(docstore_id, doc_id) { try { const response = await this.sendAndWait(MessageType.REQUEST, 'get', { docstore_id, doc_id }); return response.doc; } catch (error) { return null; } } /** * Delete a document from the store * @param docstore_id - ID of the docstore containing the document * @param doc_id - ID of the document to delete * @returns Promise that resolves with true if document existed and was deleted, false otherwise */ async delete(docstore_id, doc_id) { try { const response = await this.sendAndWait(MessageType.REQUEST, 'delete', { docstore_id, doc_id }); return response.success; } catch (error) { return false; } } /** * Create a new docstore if one with the given name doesn't exist * @param name - Name of the docstore to create * @param model - Name of the embeddings model * @param vector_size - Size of embedding vectors * @param options - Options for the model, defaults to empty string * @returns Promise that resolves with the ID of the created or existing docstore */ async createDocstore(name, model = "", vector_size = 0, options = "") { const response = await this.sendAndWait(MessageType.REQUEST, 'createDocstore', { name, model, vector_size, options }); return response.id; } /** * Get a docstore by ID * @param id - ID of the docstore to get * @returns Promise that resolves with the docstore if found, undefined otherwise */ async getDocstore(id) { try { const response = await this.sendAndWait(MessageType.REQUEST, 'getDocstore', { id }); return response.docstore; } catch (error) { return undefined; } } /** * List all docstores * @returns Promise that resolves with an array of docstore objects */ async listDocstores() { try { const response = await this.sendAndWait(MessageType.REQUEST, 'listDocstores', {}); return response.docstores; } catch (error) { return []; } } /** * List docstores by specific IDs * @param ids - Array of docstore IDs to retrieve * @returns Promise that resolves with an array of docstore objects */ async listDocStoresByIds(ids) { try { // The server will handle filtering by IDs based on the perms object // This is just a pass-through to the regular listDocstores method // The server will use the perms.listIds to filter the results const response = await this.sendAndWait(MessageType.REQUEST, 'listDocstores', {}); // If we have IDs, filter the results client-side as a fallback // (the server should already filter based on perms.listIds) if (ids.length > 0) { const idSet = new Set(ids); return response.docstores.filter((docstore) => idSet.has(docstore.id)); } return response.docstores; } catch (error) { return []; } } /** * List documents by specific IDs * @param docstore_id - ID of the docstore containing the documents * @param ids - Array of document IDs to retrieve * @returns Promise that resolves with an array of document objects */ async listDocsByIds(docstore_id, ids) { if (ids.length === 0) { return []; } try { // Create a subscription to get all documents and filter by ID const docs = []; // Create a set of IDs for efficient lookup const idSet = new Set(ids); // Create a subscription to get the documents const subscription = await this.subscribe({ docstore_id }, async (doc) => { if (doc && idSet.has(doc.id)) { docs.push(doc); } }); // Wait for the subscription to complete (EOF) await new Promise((resolve) => { const checkInterval = setInterval(() => { if (docs.length === ids.length) { clearInterval(checkInterval); subscription.close(); resolve(); } }, 100); // Set a timeout to prevent hanging setTimeout(() => { clearInterval(checkInterval); subscription.close(); resolve(); }, 10000); // 10 second timeout }); return docs; } catch (error) { return []; } } /** * Delete a docstore and all its documents * @param id - ID of the docstore to delete * @returns Promise that resolves with true if docstore existed and was deleted, false otherwise */ async deleteDocstore(id) { try { const response = await this.sendAndWait(MessageType.REQUEST, 'deleteDocstore', { id }); return response.success; } catch (error) { return false; } } /** * Count documents in a docstore * @param docstore_id - ID of the docstore to count documents for * @returns Promise that resolves with the number of documents in the docstore */ async countDocs(docstore_id) { try { const response = await this.sendAndWait(MessageType.REQUEST, 'countDocs', { docstore_id }); return response.count; } catch (error) { return 0; } } /** * Enqueue a document for processing by a subscription * @param subId - Subscription ID * @param doc - Document to process, or undefined to signal end of feed */ enqueueDoc(subId, doc) { // Initialize buffer if it doesn't exist if (!this.docBuffers.has(subId)) { this.docBuffers.set(subId, []); } // Get the buffer const buffer = this.docBuffers.get(subId); // Add the document to the buffer if (doc !== undefined) { buffer.push(doc); } else { // For EOF (undefined), we add a special marker // We'll use null as a marker for EOF since undefined can't be stored in arrays buffer.push(null); } // Start processing if not already processing if (!this.processingSubscriptions.has(subId)) { this.processDocs(subId); } } /** * Process documents for a subscription sequentially * @param subId - Subscription ID */ async processDocs(subId) { // Mark as processing this.processingSubscriptions.add(subId); try { // Get the buffer const buffer = this.docBuffers.get(subId); if (!buffer || buffer.length === 0) { // No documents to process this.processingSubscriptions.delete(subId); return; } // Get the callback const callback = this.subscriptionCallbacks.get(subId); if (!callback) { // No callback, clear the buffer this.docBuffers.delete(subId); this.processingSubscriptions.delete(subId); return; } // Process the first document const doc = buffer.shift(); // Check if it's the EOF marker (null) if (doc === null) { // Call with undefined to signal EOF await callback(undefined); } else { // Process the document await callback(doc); } // Continue processing if there are more documents if (buffer.length > 0) { // Process the next document this.processDocs(subId); } else { // No more documents, remove from processing set this.processingSubscriptions.delete(subId); } } catch (error) { debugError('Error processing document:', error); // Remove from processing set to allow retry this.processingSubscriptions.delete(subId); } } [Symbol.dispose]() { debugDocstore("DocStoreWebSocket client dispose"); this.ws.close(); this.subscriptionCallbacks.clear(); this.messageCallbacks.clear(); this.docBuffers.clear(); this.processingSubscriptions.clear(); // Clean up event handlers this.ws.onopen = null; this.ws.onmessage = null; this.ws.onclose = null; this.ws.onerror = null; } } //# sourceMappingURL=DocStoreWebSocketClient.js.map