@nostr-dev-kit/blossom
Version: 
Blossom protocol support for NDK (Nostr Development Kit)
402 lines • 15.1 kB
JavaScript
import { NDKBlossomList, NDKKind, wrapEvent } from "@nostr-dev-kit/ndk";
import { fixUrl, getBlobUrlByHash } from "./healing/url-healing";
import { uploadFile } from "./upload/uploader";
import { DEFAULT_RETRY_OPTIONS } from "./utils/constants";
import { NDKBlossomAuthError, NDKBlossomError, NDKBlossomNotFoundError, NDKBlossomOptimizationError, NDKBlossomServerError, NDKBlossomUploadError, } from "./utils/errors";
import { checkBlobExists, fetchWithRetry } from "./utils/http";
import { CustomLogger, DebugLogger } from "./utils/logger";
import { defaultSHA256Calculator } from "./utils/sha256";
/**
 * NDKBlossom class for interacting with the Blossom protocol
 */
export class NDKBlossom {
    /**
     * Constructor for NDKBlossom
     * @param ndk NDK instance
     */
    constructor(ndk) {
        this.serverConfigs = new Map();
        this.debugMode = false;
        this.ndk = ndk;
        this.retryOptions = DEFAULT_RETRY_OPTIONS;
        this.logger = new DebugLogger();
        this.sha256Calculator = defaultSHA256Calculator;
    }
    /**
     * Enable or disable debug mode
     */
    set debug(value) {
        this.debugMode = value;
    }
    /**
     * Get debug mode status
     */
    get debug() {
        return this.debugMode;
    }
    /**
     * Set custom logger
     */
    set loggerFunction(logFn) {
        this.logger = new CustomLogger(logFn);
    }
    /**
     * Set a custom SHA256 calculator implementation
     * @param calculator Custom SHA256 calculator
     */
    setSHA256Calculator(calculator) {
        this.sha256Calculator = calculator;
    }
    /**
     * Get the current SHA256 calculator implementation
     * @returns Current SHA256 calculator
     */
    getSHA256Calculator() {
        return this.sha256Calculator;
    }
    set serverList(serverList) {
        this._serverList = serverList;
    }
    async getServerList(user) {
        if (this._serverList) {
            this.logger.debug(`Using cached server list with ${this._serverList.servers.length} servers`);
            return this._serverList;
        }
        user ?? (user = this.ndk.activeUser);
        if (!user) {
            this.logger.error("No user available to fetch server list");
            throw new NDKBlossomError("No user available to fetch server list", "NO_SIGNER");
        }
        this.logger.debug(`Fetching server list for user ${user.pubkey}`);
        const filter = { kinds: NDKBlossomList.kinds, authors: [user.pubkey] };
        const event = await this.ndk.fetchEvent(filter);
        if (!event) {
            this.logger.warn(`No blossom server list event found for user ${user.pubkey}`);
            return undefined;
        }
        this._serverList = wrapEvent(event);
        this.logger.debug(`Found server list with ${this._serverList.servers.length} servers: ${this._serverList.servers.join(", ")}`);
        return this._serverList;
    }
    /**
     * Uploads a file to a Blossom server
     * @param file The file to upload
     * @param options Upload options
     * @returns Image metadata
     */
    async upload(file, options = {}) {
        try {
            // Set up progress callback if specified
            if (this.onUploadProgress) {
                options.onProgress = (progress) => {
                    if (this.onUploadProgress) {
                        return this.onUploadProgress(progress, file, "unknown");
                    }
                    return "continue";
                };
            }
            // Set the SHA256 calculator if not provided in options
            if (!options.sha256Calculator) {
                options.sha256Calculator = this.getSHA256Calculator();
            }
            // Upload the file
            const result = await uploadFile(this, file, options);
            return result;
        }
        catch (error) {
            // Handle upload failures
            if (this.onUploadFailed && error instanceof Error) {
                this.onUploadFailed(error.message, error instanceof NDKBlossomUploadError ? error.serverUrl : undefined, file);
            }
            // Re-throw the error
            throw error;
        }
    }
    /**
     * Fixes a Blossom URL by finding an alternative server with the same blob
     * @param user The user whose servers to check
     * @param url The URL to fix
     * @returns A fixed URL pointing to a valid Blossom server
     */
    async fixUrl(user, url) {
        return fixUrl(this.ndk, user, url);
    }
    /**
     * Gets a blob from a URL
     * @param url The URL of the blob
     * @returns The blob response
     */
    async getBlob(url) {
        try {
            return await fetchWithRetry(url, {}, this.retryOptions);
        }
        catch (error) {
            throw new NDKBlossomNotFoundError(`Failed to fetch blob: ${error.message}`, "BLOB_NOT_FOUND", url, error);
        }
    }
    /**
     * Gets a blob by its hash from one of the user's servers
     * @param user The user whose servers to check
     * @param hash The hash of the blob
     * @returns The blob response
     */
    async getBlobByHash(user, hash) {
        // Get URL from hash
        const url = await getBlobUrlByHash(this.ndk, user, hash);
        // Get the blob
        return this.getBlob(url);
    }
    /**
     * Lists blobs for a user
     * @param user The user whose blobs to list
     * @returns Array of blob descriptors
     */
    async listBlobs(user) {
        // Get user's server list
        const serverList = await this.getServerList();
        let serverUrls = [];
        if (serverList)
            serverUrls = serverList.servers;
        if (serverUrls.length === 0) {
            this.logger.error(`No servers found for user ${user.pubkey}`);
            return [];
        }
        // Array to store all blobs
        const blobMap = new Map(); // Use hash as key to deduplicate
        // Try each server
        for (const serverUrl of serverUrls) {
            try {
                // Normalize server URL
                const baseUrl = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
                const url = `${baseUrl}/list/${user.pubkey}`;
                // Fetch blobs
                const response = await fetchWithRetry(url, {}, this.retryOptions);
                // Check if response is OK
                if (!response.ok) {
                    continue; // Skip this server
                }
                // Parse response
                const data = await response.json();
                // Process blobs
                if (Array.isArray(data)) {
                    for (const blob of data) {
                        // Convert to NDKImetaTag format
                        const imeta = {
                            url: blob.url,
                            size: blob.size?.toString(),
                            m: blob.mime_type,
                            x: blob.sha256,
                            dim: blob.width && blob.height ? `${blob.width}x${blob.height}` : undefined,
                            blurhash: blob.blurhash,
                            alt: blob.alt,
                        };
                        // Add to map using hash as key for deduplication
                        if (blob.sha256) {
                            blobMap.set(blob.sha256, imeta);
                        }
                    }
                }
            }
            catch (error) {
                // Log error and continue to next server
                this.logger.error(`Error listing blobs on server ${serverUrl}:`, error);
            }
        }
        // Convert map to array
        return Array.from(blobMap.values());
    }
    /**
     * Deletes a blob
     * @param hash The hash of the blob to delete
     * @returns True if successful
     */
    async deleteBlob(hash) {
        if (!this.ndk.signer) {
            throw new NDKBlossomAuthError("No signer available to delete blob", "NO_SIGNER");
        }
        // Get user's pubkey
        const pubkey = (await this.ndk.signer.user()).pubkey;
        // Get user's server list
        const filter = { kinds: [NDKKind.BlossomList], authors: [pubkey] };
        const event = await this.ndk.fetchEvent(filter);
        let serverUrls = [];
        if (event) {
            // Extract server URLs from tags
            serverUrls = event.tags
                .filter((tag) => tag[0] === "server" && tag[1])
                .map((tag) => tag[1]);
        }
        if (serverUrls.length === 0) {
            this.logger.error(`No servers found for user ${pubkey}`);
            return false;
        }
        // Flag to track success
        let success = false;
        // Try each server
        for (const serverUrl of serverUrls) {
            try {
                // Normalize server URL
                const baseUrl = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
                const url = `${baseUrl}/${hash}`;
                // Create authenticated request
                const options = await createAuthenticatedFetchOptions(this.ndk, "delete", {
                    sha256: hash,
                    content: `Delete blob ${hash}`,
                    fetchOptions: {
                        method: "DELETE",
                    },
                });
                // Send delete request
                const response = await fetchWithRetry(url, options, this.retryOptions);
                // Check if successful
                if (response.ok) {
                    success = true;
                }
            }
            catch (error) {
                // Log error and continue to next server
                this.logger.error(`Error deleting blob on server ${serverUrl}:`, error);
            }
        }
        return success;
    }
    /**
     * Checks if a server has a blob
     * @param serverUrl The URL of the server
     * @param hash The hash of the blob
     * @returns True if the server has the blob
     */
    async checkServerForBlob(serverUrl, hash) {
        return checkBlobExists(serverUrl, hash);
    }
    /**
     * Sets retry options for network operations
     * @param options Retry options
     */
    setRetryOptions(options) {
        this.retryOptions = {
            ...this.retryOptions,
            ...options,
        };
    }
    /**
     * Sets server-specific configuration
     * @param serverUrl The URL of the server
     * @param config Server configuration
     */
    setServerConfig(serverUrl, config) {
        this.serverConfigs.set(serverUrl, config);
    }
    /**
     * Gets an optimized version of a blob
     * @param url The URL of the blob
     * @param options Optimization options
     * @returns The optimized blob response
     */
    async getOptimizedBlob(url, options = {}) {
        try {
            // Parse the URL and extract relevant parts
            const urlObj = new URL(url);
            const baseUrl = `${urlObj.protocol}//${urlObj.host}`;
            const hash = urlObj.pathname.split("/").pop();
            if (!hash) {
                throw new NDKBlossomOptimizationError("Invalid URL, no hash found", "BLOB_NOT_FOUND", url);
            }
            // Construct the media URL
            let mediaUrl = `${baseUrl}/media/${hash}`;
            // Add optimization parameters as query string
            const params = new URLSearchParams();
            for (const [key, value] of Object.entries(options)) {
                if (value !== undefined) {
                    params.append(key, value.toString());
                }
            }
            if (params.toString()) {
                mediaUrl += `?${params.toString()}`;
            }
            // Fetch the optimized blob
            const response = await fetchWithRetry(mediaUrl, {}, this.retryOptions);
            if (!response.ok) {
                throw new NDKBlossomOptimizationError(`Failed to get optimized blob: ${response.status} ${response.statusText}`, "SERVER_REJECTED", url);
            }
            return response;
        }
        catch (error) {
            if (error instanceof NDKBlossomOptimizationError) {
                throw error;
            }
            throw new NDKBlossomOptimizationError(`Failed to get optimized blob: ${error.message}`, "SERVER_UNSUPPORTED", url, error);
        }
    }
    /**
     * Gets an optimized URL for a blob
     * @param url The URL of the blob
     * @param options Optimization options
     * @returns The optimized URL
     */
    async getOptimizedUrl(url, options = {}) {
        // Parse the URL and extract relevant parts
        const urlObj = new URL(url);
        const baseUrl = `${urlObj.protocol}//${urlObj.host}`;
        const hash = urlObj.pathname.split("/").pop();
        if (!hash) {
            throw new NDKBlossomOptimizationError("Invalid URL, no hash found", "BLOB_NOT_FOUND", url);
        }
        // Construct the media URL
        let mediaUrl = `${baseUrl}/media/${hash}`;
        // Add optimization parameters as query string
        const params = new URLSearchParams();
        for (const [key, value] of Object.entries(options)) {
            if (value !== undefined) {
                params.append(key, value.toString());
            }
        }
        if (params.toString()) {
            mediaUrl += `?${params.toString()}`;
        }
        return mediaUrl;
    }
    /**
     * Generates a srcset for responsive images
     * @param url The base URL of the image
     * @param sizes Array of size configurations
     * @returns A srcset string
     */
    generateSrcset(url, sizes) {
        const srcset = [];
        // Parse the URL and extract relevant parts
        const urlObj = new URL(url);
        const baseUrl = `${urlObj.protocol}//${urlObj.host}`;
        const hash = urlObj.pathname.split("/").pop();
        if (!hash) {
            return ""; // Invalid URL, can't generate srcset
        }
        // Generate a srcset entry for each size
        for (const size of sizes) {
            // Build the query parameters
            const params = new URLSearchParams();
            params.append("width", size.width.toString());
            if (size.format) {
                params.append("format", size.format);
            }
            // Build the URL
            const mediaUrl = `${baseUrl}/media/${hash}?${params.toString()}`;
            // Add to srcset
            srcset.push(`${mediaUrl} ${size.width}w`);
        }
        return srcset.join(", ");
    }
}
/**
 * Helper function to create authenticated fetch options
 */
async function createAuthenticatedFetchOptions(ndk, action, options = {}) {
    // Import the auth utility here to avoid circular dependencies
    const { createAuthenticatedFetchOptions: authFn } = await import("./utils/auth");
    return authFn(ndk, action, options);
}
// Export error types
export { NDKBlossomError, NDKBlossomUploadError, NDKBlossomServerError, NDKBlossomAuthError, NDKBlossomNotFoundError, NDKBlossomOptimizationError, };
// Export default
export default NDKBlossom;
//# sourceMappingURL=blossom.js.map