UNPKG

askexperts

Version:

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

377 lines 14.7 kB
/** * StreamReader implementation for NIP-173 (Streaming Over Nostr) */ import { getPublicKey } from "nostr-tools"; import { getCompression } from "./compression.js"; import { getEncryption } from "./encryption.js"; import { subscribeToRelays } from "../common/relay.js"; import { STREAM_CHUNK_KIND } from "./types.js"; import { debugStream, debugError } from "../common/debug.js"; /** * Default configuration values for StreamReader */ const DEFAULT_CONFIG = { maxChunks: 1000, maxResultSize: 10 * 1024 * 1024, // 10MB ttl: 60000, // 60 seconds }; /** * Error thrown when a stream operation fails */ export class StreamReaderError extends Error { constructor(code, message) { super(message); this.name = "StreamError"; this.code = code; } } /** * StreamReader for NIP-173 streams * Implements AsyncIterable to allow for iterating over chunks */ export class StreamReader { /** * Creates a new StreamReader * * @param metadata - Stream metadata * @param pool - SimplePool instance for relay communication * @param config - Configuration options * @param compression - Optional custom compression implementation */ constructor(metadata, pool, config = {}, compression, encryption) { this.buffer = new Map(); this.resultBuffer = []; this.nextRef = ""; this.totalSize = 0; this.chunkCount = 0; this.isDone = false; this.lastEventTime = 0; this.subscription = null; this.waitingResolvers = []; this.waitingRejecters = []; this.error = null; this.metadata = metadata; this.pool = pool; this.config = { ...DEFAULT_CONFIG, ...config }; this.compression = compression || getCompression(); this.encryption = encryption || getEncryption(); // Validate metadata if (!metadata.streamId) { throw new Error("Stream ID is required"); } if (!metadata.relays || metadata.relays.length === 0) { throw new Error("At least one relay is required"); } // Validate version if (metadata.version !== undefined && metadata.version !== "1") { throw new Error(`Unsupported protocol version: ${metadata.version}. Only version "1" is supported.`); } if (metadata.encryption === "") { throw new Error("Unspecified encryption"); } if (metadata.compression === "") { throw new Error("Unspecified compression"); } // Validate encryption requirements if (metadata.encryption !== "none") { if (!metadata.receiver_privkey) { throw new Error("Recipient private key (receiver_privkey) is required for decryption"); } if (!metadata.receiver_pubkey) { throw new Error("Recipient public key (receiver_pubkey) is required for decryption"); } // Validate that the receiver_pubkey matches the public key derived from receiver_privkey const derivedPubkey = getPublicKey(metadata.receiver_privkey); if (derivedPubkey !== metadata.receiver_pubkey) { throw new Error("Recipient public key (receiver_pubkey) does not match the key derived from receiver_privkey"); } } } /** * Starts the stream subscription * This is called automatically when iteration begins */ start() { if (this.subscription) { return; // Already started } // Create filter for the stream chunks const filter = { kinds: [STREAM_CHUNK_KIND], authors: [this.metadata.streamId], }; // Subscribe to the stream this.subscription = subscribeToRelays([filter], this.metadata.relays, this.pool, { onevent: (event) => this.handleEvent(event), }); // Set initial last event time this.lastEventTime = Date.now(); // Start TTL timer this.startTtlTimer(); } /** * Handles an incoming event * * @param event - The received event */ handleEvent(event) { // Update last event time this.lastEventTime = Date.now(); // Extract index from tags (still needed for validation and logging) const indexTag = event.tags.find((tag) => tag[0] === "i"); if (!indexTag || !indexTag[1]) { debugStream("Received chunk without index tag:", event); return; } const index = parseInt(indexTag[1], 10); if (isNaN(index)) { debugStream("Received chunk with invalid index:", indexTag[1]); return; } // Extract status from tags const statusTag = event.tags.find((tag) => tag[0] === "status"); if (!statusTag || !statusTag[1]) { debugStream("Received chunk without status tag:", event); return; } const status = statusTag[1]; // Handle error status if (status === "error") { try { const errorData = JSON.parse(event.content); this.setError(new StreamReaderError(errorData.code, errorData.message)); return; } catch (err) { this.setError(new StreamReaderError("parse_error", "Failed to parse error content")); return; } } // Check if we've exceeded max chunks if (this.chunkCount >= this.config.maxChunks) { this.setError(new StreamReaderError("max_chunks_exceeded", `Maximum number of chunks exceeded (${this.config.maxChunks})`)); return; } // Extract prev tag (empty string for first chunk) const prevTag = event.tags.find((tag) => tag[0] === "prev"); const prevRef = index && prevTag ? prevTag[1] : ""; if (index > 0 && !prevRef) { debugError(`Skipping chunk index ${index} without prev tag`); return; } // Store the event in the buffer using prev tag as key this.buffer.set(prevRef, event); this.chunkCount++; debugStream(`Received chunk index ${index} with prev='${prevRef}' payload size ${event.content.length} buffered count ${this.buffer.size} of total ${this.chunkCount}`); // Process any events that are now in sequence this.processBuffer(); } /** * Processes events from the buffer in sequence */ async processBuffer() { // Process events in order based on prev tag chain while (this.buffer.has(this.nextRef) && !this.error) { const event = this.buffer.get(this.nextRef); // Extract index for logging const indexTag = event.tags.find((tag) => tag[0] === "i"); const index = indexTag ? indexTag[1] : "unknown"; debugStream(`Processing chunk index ${index} with nextRef=${this.nextRef} payload ${event.content.length}`); // Remove from buffer this.buffer.delete(this.nextRef); // Extract status from tags const statusTag = event.tags.find((tag) => tag[0] === "status"); const status = statusTag?.[1]; // Process the chunk try { const chunk = await this.processChunk(event.content); // Check if we've exceeded max result size if (this.totalSize > this.config.maxResultSize) { this.setError(new StreamReaderError("max_size_exceeded", `Maximum result size exceeded (${this.config.maxResultSize} bytes)`)); return; } // Resolve waiting promises with the chunk const result = { value: chunk, done: false }; if (this.waitingResolvers.length > 0) { const resolve = this.waitingResolvers.shift(); resolve(result); } else { this.resultBuffer.push(result); } // If this was the last chunk if (status === "done") { this.isDone = true; this.close(); } } catch (err) { this.setError(err); } // Update nextRef to current event ID for chain continuation this.nextRef = event.id; } } /** * Processes a chunk by decompressing and decrypting it * * @param content - The chunk content * @returns Promise resolving to the processed chunk */ async processChunk(content) { try { // Implement the 'recv' logic from NIP-173 const isBinary = !!this.metadata.binary; const encType = this.metadata.encryption; const comprType = this.metadata.compression; // Following the pseudocode from NIP-173: // recv(data: string, binary: boolean, enc_type: string, compr_type: string): string | Uint8Array // Step 1: Determine if we need binary handling based on binary flag or compression const binaryOrCompr = isBinary || comprType !== "none"; // Step 2: Decode the data if needed (when no encryption but binary or compressed) let decodedData; if (encType === "none" && binaryOrCompr) { // str2bin - convert base64 string to binary try { decodedData = Buffer.from(content, "base64"); } catch (err) { throw new StreamReaderError("decode_failed", `Failed to decode base64 content: ${err instanceof Error ? err.message : String(err)}`); } } else { decodedData = content; } // Step 3: Decrypt the data if encryption is used let decryptedData; if (encType !== "none") { try { // Convert hex key to Uint8Array const recipientPrivkey = this.metadata.receiver_privkey; // Decrypt using the encryption interface decryptedData = await this.encryption.decrypt(decodedData, encType, binaryOrCompr, recipientPrivkey, this.metadata.streamId); } catch (err) { throw new StreamReaderError("decryption_failed", `Failed to decrypt chunk: ${err instanceof Error ? err.message : String(err)}`); } } else { decryptedData = decodedData; } // Step 4: Decompress the data if compression is used let result; if (comprType !== "none") { result = await this.compression.decompress(decryptedData, comprType, isBinary); } else { result = decryptedData; } // Update total size this.totalSize += result.length; return result; } catch (err) { debugError("Error processing chunk:", err); throw err instanceof Error ? err : new StreamReaderError("processing_error", String(err)); } } /** * Starts the TTL timer to detect stalled streams */ startTtlTimer() { if (this.config.ttl <= 0) { return; // TTL disabled } const checkTtl = () => { if (this.isDone || this.error) { return; // Stream is already done or has error } const now = Date.now(); const elapsed = now - this.lastEventTime; if (elapsed > this.config.ttl) { this.setError(new StreamReaderError("ttl_exceeded", `TTL exceeded (${this.config.ttl}ms) while waiting for next chunk in chain`)); return; } // Schedule next check setTimeout(checkTtl, Math.min(1000, this.config.ttl / 2)); }; // Start checking setTimeout(checkTtl, Math.min(1000, this.config.ttl / 2)); } /** * Sets an error and rejects any waiting promises * * @param err - The error */ setError(err) { if (this.error) { return; // Already have an error } this.error = err; this.close(); // Reject any waiting promises while (this.waitingRejecters.length > 0) { const reject = this.waitingRejecters.shift(); reject(err); } } /** * Closes the stream and releases resources */ close() { if (this.subscription) { this.subscription.close(); this.subscription = null; } // Clear buffer this.buffer.clear(); } /** * Implements AsyncIterable interface */ [Symbol.asyncIterator]() { // Start the subscription if not already started this.start(); return { next: async () => { // If we have an error, throw it if (this.error) { throw this.error; } // If the stream is done, return done if (this.isDone && !this.buffer.size && !this.resultBuffer.length) { // Resolve any waiting promises with done while (this.waitingResolvers.length > 0) { const resolve = this.waitingResolvers.shift(); resolve({ value: undefined, done: true }); } return { value: undefined, done: true }; } if (this.resultBuffer.length) { return this.resultBuffer.shift(); } // Wait for the next chunk return new Promise((resolve, reject) => { this.waitingResolvers.push(resolve); this.waitingRejecters.push(reject); }); }, return: async () => { this.close(); return { value: undefined, done: true }; }, throw: async (err) => { this.setError(err instanceof Error ? err : new Error(String(err))); this.close(); throw this.error; }, }; } [Symbol.dispose]() { this.close(); this.resultBuffer.length = 0; } } //# sourceMappingURL=StreamReader.js.map