@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