UNPKG

@nostr-dev-kit/ndk

Version:

NDK - Nostr Development Kit

1,333 lines (1,320 loc) 359 kB
// src/types.ts var NdkNutzapStatus = /* @__PURE__ */ ((NdkNutzapStatus2) => { NdkNutzapStatus2["INITIAL"] = "initial"; NdkNutzapStatus2["PROCESSING"] = "processing"; NdkNutzapStatus2["REDEEMED"] = "redeemed"; NdkNutzapStatus2["SPENT"] = "spent"; NdkNutzapStatus2["MISSING_PRIVKEY"] = "missing_privkey"; NdkNutzapStatus2["TEMPORARY_ERROR"] = "temporary_error"; NdkNutzapStatus2["PERMANENT_ERROR"] = "permanent_error"; NdkNutzapStatus2["INVALID_NUTZAP"] = "invalid_nutzap"; return NdkNutzapStatus2; })(NdkNutzapStatus || {}); // src/events/kinds/index.ts var NDKKind = /* @__PURE__ */ ((NDKKind2) => { NDKKind2[NDKKind2["Metadata"] = 0] = "Metadata"; NDKKind2[NDKKind2["Text"] = 1] = "Text"; NDKKind2[NDKKind2["RecommendRelay"] = 2] = "RecommendRelay"; NDKKind2[NDKKind2["Contacts"] = 3] = "Contacts"; NDKKind2[NDKKind2["EncryptedDirectMessage"] = 4] = "EncryptedDirectMessage"; NDKKind2[NDKKind2["EventDeletion"] = 5] = "EventDeletion"; NDKKind2[NDKKind2["Repost"] = 6] = "Repost"; NDKKind2[NDKKind2["Reaction"] = 7] = "Reaction"; NDKKind2[NDKKind2["BadgeAward"] = 8] = "BadgeAward"; NDKKind2[NDKKind2["GroupChat"] = 9] = "GroupChat"; NDKKind2[NDKKind2["GroupNote"] = 11] = "GroupNote"; NDKKind2[NDKKind2["GroupReply"] = 12] = "GroupReply"; NDKKind2[NDKKind2["GiftWrapSeal"] = 13] = "GiftWrapSeal"; NDKKind2[NDKKind2["PrivateDirectMessage"] = 14] = "PrivateDirectMessage"; NDKKind2[NDKKind2["Image"] = 20] = "Image"; NDKKind2[NDKKind2["Video"] = 21] = "Video"; NDKKind2[NDKKind2["ShortVideo"] = 22] = "ShortVideo"; NDKKind2[NDKKind2["Story"] = 23] = "Story"; NDKKind2[NDKKind2["Vanish"] = 62] = "Vanish"; NDKKind2[NDKKind2["CashuWalletBackup"] = 375] = "CashuWalletBackup"; NDKKind2[NDKKind2["GiftWrap"] = 1059] = "GiftWrap"; NDKKind2[NDKKind2["GenericRepost"] = 16] = "GenericRepost"; NDKKind2[NDKKind2["ChannelCreation"] = 40] = "ChannelCreation"; NDKKind2[NDKKind2["ChannelMetadata"] = 41] = "ChannelMetadata"; NDKKind2[NDKKind2["ChannelMessage"] = 42] = "ChannelMessage"; NDKKind2[NDKKind2["ChannelHideMessage"] = 43] = "ChannelHideMessage"; NDKKind2[NDKKind2["ChannelMuteUser"] = 44] = "ChannelMuteUser"; NDKKind2[NDKKind2["WikiMergeRequest"] = 818] = "WikiMergeRequest"; NDKKind2[NDKKind2["GenericReply"] = 1111] = "GenericReply"; NDKKind2[NDKKind2["Media"] = 1063] = "Media"; NDKKind2[NDKKind2["DraftCheckpoint"] = 1234] = "DraftCheckpoint"; NDKKind2[NDKKind2["Task"] = 1934] = "Task"; NDKKind2[NDKKind2["Report"] = 1984] = "Report"; NDKKind2[NDKKind2["Label"] = 1985] = "Label"; NDKKind2[NDKKind2["DVMReqTextExtraction"] = 5e3] = "DVMReqTextExtraction"; NDKKind2[NDKKind2["DVMReqTextSummarization"] = 5001] = "DVMReqTextSummarization"; NDKKind2[NDKKind2["DVMReqTextTranslation"] = 5002] = "DVMReqTextTranslation"; NDKKind2[NDKKind2["DVMReqTextGeneration"] = 5050] = "DVMReqTextGeneration"; NDKKind2[NDKKind2["DVMReqImageGeneration"] = 5100] = "DVMReqImageGeneration"; NDKKind2[NDKKind2["DVMReqTextToSpeech"] = 5250] = "DVMReqTextToSpeech"; NDKKind2[NDKKind2["DVMReqDiscoveryNostrContent"] = 5300] = "DVMReqDiscoveryNostrContent"; NDKKind2[NDKKind2["DVMReqDiscoveryNostrPeople"] = 5301] = "DVMReqDiscoveryNostrPeople"; NDKKind2[NDKKind2["DVMReqTimestamping"] = 5900] = "DVMReqTimestamping"; NDKKind2[NDKKind2["DVMEventSchedule"] = 5905] = "DVMEventSchedule"; NDKKind2[NDKKind2["DVMJobFeedback"] = 7e3] = "DVMJobFeedback"; NDKKind2[NDKKind2["Subscribe"] = 7001] = "Subscribe"; NDKKind2[NDKKind2["Unsubscribe"] = 7002] = "Unsubscribe"; NDKKind2[NDKKind2["SubscriptionReceipt"] = 7003] = "SubscriptionReceipt"; NDKKind2[NDKKind2["CashuReserve"] = 7373] = "CashuReserve"; NDKKind2[NDKKind2["CashuQuote"] = 7374] = "CashuQuote"; NDKKind2[NDKKind2["CashuToken"] = 7375] = "CashuToken"; NDKKind2[NDKKind2["CashuWalletTx"] = 7376] = "CashuWalletTx"; NDKKind2[NDKKind2["GroupAdminAddUser"] = 9e3] = "GroupAdminAddUser"; NDKKind2[NDKKind2["GroupAdminRemoveUser"] = 9001] = "GroupAdminRemoveUser"; NDKKind2[NDKKind2["GroupAdminEditMetadata"] = 9002] = "GroupAdminEditMetadata"; NDKKind2[NDKKind2["GroupAdminEditStatus"] = 9006] = "GroupAdminEditStatus"; NDKKind2[NDKKind2["GroupAdminCreateGroup"] = 9007] = "GroupAdminCreateGroup"; NDKKind2[NDKKind2["GroupAdminRequestJoin"] = 9021] = "GroupAdminRequestJoin"; NDKKind2[NDKKind2["MuteList"] = 1e4] = "MuteList"; NDKKind2[NDKKind2["PinList"] = 10001] = "PinList"; NDKKind2[NDKKind2["RelayList"] = 10002] = "RelayList"; NDKKind2[NDKKind2["BookmarkList"] = 10003] = "BookmarkList"; NDKKind2[NDKKind2["CommunityList"] = 10004] = "CommunityList"; NDKKind2[NDKKind2["PublicChatList"] = 10005] = "PublicChatList"; NDKKind2[NDKKind2["BlockRelayList"] = 10006] = "BlockRelayList"; NDKKind2[NDKKind2["SearchRelayList"] = 10007] = "SearchRelayList"; NDKKind2[NDKKind2["SimpleGroupList"] = 10009] = "SimpleGroupList"; NDKKind2[NDKKind2["InterestList"] = 10015] = "InterestList"; NDKKind2[NDKKind2["CashuMintList"] = 10019] = "CashuMintList"; NDKKind2[NDKKind2["EmojiList"] = 10030] = "EmojiList"; NDKKind2[NDKKind2["DirectMessageReceiveRelayList"] = 10050] = "DirectMessageReceiveRelayList"; NDKKind2[NDKKind2["BlossomList"] = 10063] = "BlossomList"; NDKKind2[NDKKind2["NostrWaletConnectInfo"] = 13194] = "NostrWaletConnectInfo"; NDKKind2[NDKKind2["TierList"] = 17e3] = "TierList"; NDKKind2[NDKKind2["CashuWallet"] = 17375] = "CashuWallet"; NDKKind2[NDKKind2["FollowSet"] = 3e4] = "FollowSet"; NDKKind2[NDKKind2["CategorizedPeopleList"] = 3e4 /* FollowSet */] = "CategorizedPeopleList"; NDKKind2[NDKKind2["CategorizedBookmarkList"] = 30001] = "CategorizedBookmarkList"; NDKKind2[NDKKind2["RelaySet"] = 30002] = "RelaySet"; NDKKind2[NDKKind2["CategorizedRelayList"] = 30002 /* RelaySet */] = "CategorizedRelayList"; NDKKind2[NDKKind2["BookmarkSet"] = 30003] = "BookmarkSet"; NDKKind2[NDKKind2["CurationSet"] = 30004] = "CurationSet"; NDKKind2[NDKKind2["ArticleCurationSet"] = 30004] = "ArticleCurationSet"; NDKKind2[NDKKind2["VideoCurationSet"] = 30005] = "VideoCurationSet"; NDKKind2[NDKKind2["ImageCurationSet"] = 30006] = "ImageCurationSet"; NDKKind2[NDKKind2["InterestSet"] = 30015] = "InterestSet"; NDKKind2[NDKKind2["InterestsList"] = 30015 /* InterestSet */] = "InterestsList"; NDKKind2[NDKKind2["ProjectTemplate"] = 30717] = "ProjectTemplate"; NDKKind2[NDKKind2["EmojiSet"] = 30030] = "EmojiSet"; NDKKind2[NDKKind2["ModularArticle"] = 30040] = "ModularArticle"; NDKKind2[NDKKind2["ModularArticleItem"] = 30041] = "ModularArticleItem"; NDKKind2[NDKKind2["Wiki"] = 30818] = "Wiki"; NDKKind2[NDKKind2["Draft"] = 31234] = "Draft"; NDKKind2[NDKKind2["Project"] = 31933] = "Project"; NDKKind2[NDKKind2["SubscriptionTier"] = 37001] = "SubscriptionTier"; NDKKind2[NDKKind2["EcashMintRecommendation"] = 38e3] = "EcashMintRecommendation"; NDKKind2[NDKKind2["HighlightSet"] = 39802] = "HighlightSet"; NDKKind2[NDKKind2["CategorizedHighlightList"] = 39802 /* HighlightSet */] = "CategorizedHighlightList"; NDKKind2[NDKKind2["Nutzap"] = 9321] = "Nutzap"; NDKKind2[NDKKind2["ZapRequest"] = 9734] = "ZapRequest"; NDKKind2[NDKKind2["Zap"] = 9735] = "Zap"; NDKKind2[NDKKind2["Highlight"] = 9802] = "Highlight"; NDKKind2[NDKKind2["ClientAuth"] = 22242] = "ClientAuth"; NDKKind2[NDKKind2["NostrWalletConnectReq"] = 23194] = "NostrWalletConnectReq"; NDKKind2[NDKKind2["NostrWalletConnectRes"] = 23195] = "NostrWalletConnectRes"; NDKKind2[NDKKind2["NostrConnect"] = 24133] = "NostrConnect"; NDKKind2[NDKKind2["BlossomUpload"] = 24242] = "BlossomUpload"; NDKKind2[NDKKind2["HttpAuth"] = 27235] = "HttpAuth"; NDKKind2[NDKKind2["ProfileBadge"] = 30008] = "ProfileBadge"; NDKKind2[NDKKind2["BadgeDefinition"] = 30009] = "BadgeDefinition"; NDKKind2[NDKKind2["MarketStall"] = 30017] = "MarketStall"; NDKKind2[NDKKind2["MarketProduct"] = 30018] = "MarketProduct"; NDKKind2[NDKKind2["Article"] = 30023] = "Article"; NDKKind2[NDKKind2["AppSpecificData"] = 30078] = "AppSpecificData"; NDKKind2[NDKKind2["Classified"] = 30402] = "Classified"; NDKKind2[NDKKind2["HorizontalVideo"] = 34235] = "HorizontalVideo"; NDKKind2[NDKKind2["VerticalVideo"] = 34236] = "VerticalVideo"; NDKKind2[NDKKind2["LegacyCashuWallet"] = 37375] = "LegacyCashuWallet"; NDKKind2[NDKKind2["GroupMetadata"] = 39e3] = "GroupMetadata"; NDKKind2[NDKKind2["GroupAdmins"] = 39001] = "GroupAdmins"; NDKKind2[NDKKind2["GroupMembers"] = 39002] = "GroupMembers"; NDKKind2[NDKKind2["FollowPack"] = 39089] = "FollowPack"; NDKKind2[NDKKind2["MediaFollowPack"] = 39092] = "MediaFollowPack"; NDKKind2[NDKKind2["AppRecommendation"] = 31989] = "AppRecommendation"; NDKKind2[NDKKind2["AppHandler"] = 31990] = "AppHandler"; return NDKKind2; })(NDKKind || {}); var NDKListKinds = [ 1e4 /* MuteList */, 10001 /* PinList */, 10002 /* RelayList */, 10003 /* BookmarkList */, 10004 /* CommunityList */, 10005 /* PublicChatList */, 10006 /* BlockRelayList */, 10007 /* SearchRelayList */, 10015 /* InterestList */, 10030 /* EmojiList */, 10050 /* DirectMessageReceiveRelayList */, 3e4 /* FollowSet */, 30003 /* BookmarkSet */, 30001 /* CategorizedBookmarkList */, // Backwards compatibility 30002 /* RelaySet */, 30004 /* ArticleCurationSet */, 30005 /* VideoCurationSet */, 30015 /* InterestSet */, 30030 /* EmojiSet */, 39802 /* HighlightSet */ ]; // src/events/index.ts import { EventEmitter as EventEmitter2 } from "tseep"; // src/relay/sets/calculate.ts import createDebug from "debug"; // src/outbox/write.ts function getRelaysForSync(ndk, author, type = "write") { if (!ndk.outboxTracker) return void 0; const item = ndk.outboxTracker.data.get(author); if (!item) return void 0; if (type === "write") { return item.writeRelays; } return item.readRelays; } async function getWriteRelaysFor(ndk, author, type = "write") { if (!ndk.outboxTracker) return void 0; if (!ndk.outboxTracker.data.has(author)) { await ndk.outboxTracker.trackUsers([author]); } return getRelaysForSync(ndk, author, type); } // src/outbox/relay-ranking.ts function getTopRelaysForAuthors(ndk, authors) { const relaysWithCount = /* @__PURE__ */ new Map(); authors.forEach((author) => { const writeRelays = getRelaysForSync(ndk, author); if (writeRelays) { writeRelays.forEach((relay) => { const count = relaysWithCount.get(relay) || 0; relaysWithCount.set(relay, count + 1); }); } }); const sortedRelays = Array.from(relaysWithCount.entries()).sort((a, b) => b[1] - a[1]); return sortedRelays.map((entry) => entry[0]); } // src/outbox/index.ts function getAllRelaysForAllPubkeys(ndk, pubkeys, type = "read") { const pubkeysToRelays = /* @__PURE__ */ new Map(); const authorsMissingRelays = /* @__PURE__ */ new Set(); pubkeys.forEach((pubkey) => { const relays = getRelaysForSync(ndk, pubkey, type); if (relays && relays.size > 0) { relays.forEach((relay) => { const pubkeysInRelay = pubkeysToRelays.get(relay) || /* @__PURE__ */ new Set(); pubkeysInRelay.add(pubkey); }); pubkeysToRelays.set(pubkey, relays); } else { authorsMissingRelays.add(pubkey); } }); return { pubkeysToRelays, authorsMissingRelays }; } function chooseRelayCombinationForPubkeys(ndk, pubkeys, type, { count, preferredRelays } = {}) { count ??= 2; preferredRelays ??= /* @__PURE__ */ new Set(); const pool = ndk.pool; const connectedRelays = pool.connectedRelays(); connectedRelays.forEach((relay) => { preferredRelays?.add(relay.url); }); const relayToAuthorsMap = /* @__PURE__ */ new Map(); const { pubkeysToRelays, authorsMissingRelays } = getAllRelaysForAllPubkeys(ndk, pubkeys, type); const sortedRelays = getTopRelaysForAuthors(ndk, pubkeys); const addAuthorToRelay = (author, relay) => { const authorsInRelay = relayToAuthorsMap.get(relay) || []; authorsInRelay.push(author); relayToAuthorsMap.set(relay, authorsInRelay); }; for (const [author, authorRelays] of pubkeysToRelays.entries()) { let missingRelayCount = count; for (const relay of connectedRelays) { if (authorRelays.has(relay.url)) { addAuthorToRelay(author, relay.url); missingRelayCount--; } } for (const authorRelay of authorRelays) { if (relayToAuthorsMap.has(authorRelay)) { addAuthorToRelay(author, authorRelay); missingRelayCount--; } } if (missingRelayCount <= 0) continue; for (const relay of sortedRelays) { if (missingRelayCount <= 0) break; if (authorRelays.has(relay)) { addAuthorToRelay(author, relay); missingRelayCount--; } } } for (const author of authorsMissingRelays) { pool.permanentAndConnectedRelays().forEach((relay) => { const authorsInRelay = relayToAuthorsMap.get(relay.url) || []; authorsInRelay.push(author); relayToAuthorsMap.set(relay.url, authorsInRelay); }); } return relayToAuthorsMap; } // src/outbox/read/with-authors.ts function getRelaysForFilterWithAuthors(ndk, authors, relayGoalPerAuthor = 2) { return chooseRelayCombinationForPubkeys(ndk, authors, "write", { count: relayGoalPerAuthor }); } // src/utils/normalize-url.ts function tryNormalizeRelayUrl(url) { try { return normalizeRelayUrl(url); } catch { return void 0; } } function normalizeRelayUrl(url) { let r = normalizeUrl(url, { stripAuthentication: false, stripWWW: false, stripHash: true }); if (!r.endsWith("/")) { r += "/"; } return r; } function normalize(urls) { const normalized = /* @__PURE__ */ new Set(); for (const url of urls) { try { normalized.add(normalizeRelayUrl(url)); } catch { } } return Array.from(normalized); } var DATA_URL_DEFAULT_MIME_TYPE = "text/plain"; var DATA_URL_DEFAULT_CHARSET = "us-ascii"; var testParameter = (name, filters) => filters.some((filter) => filter instanceof RegExp ? filter.test(name) : filter === name); var supportedProtocols = /* @__PURE__ */ new Set(["https:", "http:", "file:"]); var hasCustomProtocol = (urlString) => { try { const { protocol } = new URL(urlString); return protocol.endsWith(":") && !protocol.includes(".") && !supportedProtocols.has(protocol); } catch { return false; } }; var normalizeDataURL = (urlString, { stripHash }) => { const match = /^data:(?<type>[^,]*?),(?<data>[^#]*?)(?:#(?<hash>.*))?$/.exec(urlString); if (!match) { throw new Error(`Invalid URL: ${urlString}`); } const type = match.groups?.type ?? ""; const data = match.groups?.data ?? ""; let hash = match.groups?.hash ?? ""; const mediaType = type.split(";"); hash = stripHash ? "" : hash; let isBase64 = false; if (mediaType[mediaType.length - 1] === "base64") { mediaType.pop(); isBase64 = true; } const mimeType = mediaType.shift()?.toLowerCase() ?? ""; const attributes = mediaType.map((attribute) => { let [key, value = ""] = attribute.split("=").map((string) => string.trim()); if (key === "charset") { value = value.toLowerCase(); if (value === DATA_URL_DEFAULT_CHARSET) { return ""; } } return `${key}${value ? `=${value}` : ""}`; }).filter(Boolean); const normalizedMediaType = [...attributes]; if (isBase64) { normalizedMediaType.push("base64"); } if (normalizedMediaType.length > 0 || mimeType && mimeType !== DATA_URL_DEFAULT_MIME_TYPE) { normalizedMediaType.unshift(mimeType); } return `data:${normalizedMediaType.join(";")},${isBase64 ? data.trim() : data}${hash ? `#${hash}` : ""}`; }; function normalizeUrl(urlString, options = {}) { options = { defaultProtocol: "http", normalizeProtocol: true, forceHttp: false, forceHttps: false, stripAuthentication: true, stripHash: false, stripTextFragment: true, stripWWW: true, removeQueryParameters: [/^utm_\w+/i], removeTrailingSlash: true, removeSingleSlash: true, removeDirectoryIndex: false, removeExplicitPort: false, sortQueryParameters: true, ...options }; if (typeof options.defaultProtocol === "string" && !options.defaultProtocol.endsWith(":")) { options.defaultProtocol = `${options.defaultProtocol}:`; } urlString = urlString.trim(); if (/^data:/i.test(urlString)) { return normalizeDataURL(urlString, options); } if (hasCustomProtocol(urlString)) { return urlString; } const hasRelativeProtocol = urlString.startsWith("//"); const isRelativeUrl = !hasRelativeProtocol && /^\.*\//.test(urlString); if (!isRelativeUrl) { urlString = urlString.replace(/^(?!(?:\w+:)?\/\/)|^\/\//, options.defaultProtocol); } const urlObject = new URL(urlString); urlObject.hostname = urlObject.hostname.toLowerCase(); if (options.forceHttp && options.forceHttps) { throw new Error("The `forceHttp` and `forceHttps` options cannot be used together"); } if (options.forceHttp && urlObject.protocol === "https:") { urlObject.protocol = "http:"; } if (options.forceHttps && urlObject.protocol === "http:") { urlObject.protocol = "https:"; } if (options.stripAuthentication) { urlObject.username = ""; urlObject.password = ""; } if (options.stripHash) { urlObject.hash = ""; } else if (options.stripTextFragment) { urlObject.hash = urlObject.hash.replace(/#?:~:text.*?$/i, ""); } if (urlObject.pathname) { const protocolRegex = /\b[a-z][a-z\d+\-.]{1,50}:\/\//g; let lastIndex = 0; let result = ""; for (; ; ) { const match = protocolRegex.exec(urlObject.pathname); if (!match) { break; } const protocol = match[0]; const protocolAtIndex = match.index; const intermediate = urlObject.pathname.slice(lastIndex, protocolAtIndex); result += intermediate.replace(/\/{2,}/g, "/"); result += protocol; lastIndex = protocolAtIndex + protocol.length; } const remnant = urlObject.pathname.slice(lastIndex, urlObject.pathname.length); result += remnant.replace(/\/{2,}/g, "/"); urlObject.pathname = result; } if (urlObject.pathname) { try { urlObject.pathname = decodeURI(urlObject.pathname); } catch { } } if (options.removeDirectoryIndex === true) { options.removeDirectoryIndex = [/^index\.[a-z]+$/]; } if (Array.isArray(options.removeDirectoryIndex) && options.removeDirectoryIndex.length > 0) { let pathComponents = urlObject.pathname.split("/"); const lastComponent = pathComponents[pathComponents.length - 1]; if (testParameter(lastComponent, options.removeDirectoryIndex)) { pathComponents = pathComponents.slice(0, -1); urlObject.pathname = `${pathComponents.slice(1).join("/")}/`; } } if (urlObject.hostname) { urlObject.hostname = urlObject.hostname.replace(/\.$/, ""); if (options.stripWWW && /^www\.(?!www\.)[a-z\-\d]{1,63}\.[a-z.\-\d]{2,63}$/.test(urlObject.hostname)) { urlObject.hostname = urlObject.hostname.replace(/^www\./, ""); } } if (Array.isArray(options.removeQueryParameters)) { for (const key of [...urlObject.searchParams.keys()]) { if (testParameter(key, options.removeQueryParameters)) { urlObject.searchParams.delete(key); } } } if (!Array.isArray(options.keepQueryParameters) && options.removeQueryParameters === true) { urlObject.search = ""; } if (Array.isArray(options.keepQueryParameters) && options.keepQueryParameters.length > 0) { for (const key of [...urlObject.searchParams.keys()]) { if (!testParameter(key, options.keepQueryParameters)) { urlObject.searchParams.delete(key); } } } if (options.sortQueryParameters) { urlObject.searchParams.sort(); try { urlObject.search = decodeURIComponent(urlObject.search); } catch { } } if (options.removeTrailingSlash) { urlObject.pathname = urlObject.pathname.replace(/\/$/, ""); } if (options.removeExplicitPort && urlObject.port) { urlObject.port = ""; } const oldUrlString = urlString; urlString = urlObject.toString(); if (!options.removeSingleSlash && urlObject.pathname === "/" && !oldUrlString.endsWith("/") && urlObject.hash === "") { urlString = urlString.replace(/\/$/, ""); } if ((options.removeTrailingSlash || urlObject.pathname === "/") && urlObject.hash === "" && options.removeSingleSlash) { urlString = urlString.replace(/\/$/, ""); } if (hasRelativeProtocol && !options.normalizeProtocol) { urlString = urlString.replace(/^http:\/\//, "//"); } if (options.stripProtocol) { urlString = urlString.replace(/^(?:https?:)?\/\//, ""); } return urlString; } // src/relay/index.ts import debug2 from "debug"; import { EventEmitter } from "tseep"; // src/relay/connectivity.ts var MAX_RECONNECT_ATTEMPTS = 5; var FLAPPING_THRESHOLD_MS = 1e3; var NDKRelayConnectivity = class { ndkRelay; ws; _status; timeoutMs; connectedAt; _connectionStats = { attempts: 0, success: 0, durations: [] }; debug; netDebug; connectTimeout; reconnectTimeout; ndk; openSubs = /* @__PURE__ */ new Map(); openCountRequests = /* @__PURE__ */ new Map(); openEventPublishes = /* @__PURE__ */ new Map(); serial = 0; baseEoseTimeout = 4400; constructor(ndkRelay, ndk) { this.ndkRelay = ndkRelay; this._status = 1 /* DISCONNECTED */; const rand = Math.floor(Math.random() * 1e3); this.debug = this.ndkRelay.debug.extend(`connectivity${rand}`); this.ndk = ndk; } /** * Connects to the NDK relay and handles the connection lifecycle. * * This method attempts to establish a WebSocket connection to the NDK relay specified in the `ndkRelay` object. * If the connection is successful, it updates the connection statistics, sets the connection status to `CONNECTED`, * and emits `connect` and `ready` events on the `ndkRelay` object. * * If the connection attempt fails, it handles the error by either initiating a reconnection attempt or emitting a * `delayed-connect` event on the `ndkRelay` object, depending on the `reconnect` parameter. * * @param timeoutMs - The timeout in milliseconds for the connection attempt. If not provided, the default timeout from the `ndkRelay` object is used. * @param reconnect - Indicates whether a reconnection should be attempted if the connection fails. Defaults to `true`. * @returns A Promise that resolves when the connection is established, or rejects if the connection fails. */ async connect(timeoutMs, reconnect = true) { if (this._status !== 2 /* RECONNECTING */ && this._status !== 1 /* DISCONNECTED */) { this.debug( "Relay requested to be connected but was in state %s or it had a reconnect timeout", this._status ); return; } if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = void 0; } if (this.connectTimeout) { clearTimeout(this.connectTimeout); this.connectTimeout = void 0; } timeoutMs ??= this.timeoutMs; if (!this.timeoutMs && timeoutMs) this.timeoutMs = timeoutMs; if (this.timeoutMs) this.connectTimeout = setTimeout( () => this.onConnectionError(reconnect), this.timeoutMs ); try { this.updateConnectionStats.attempt(); if (this._status === 1 /* DISCONNECTED */) this._status = 4 /* CONNECTING */; else this._status = 2 /* RECONNECTING */; this.ws = new WebSocket(this.ndkRelay.url); this.ws.onopen = this.onConnect.bind(this); this.ws.onclose = this.onDisconnect.bind(this); this.ws.onmessage = this.onMessage.bind(this); this.ws.onerror = this.onError.bind(this); } catch (e) { this.debug(`Failed to connect to ${this.ndkRelay.url}`, e); this._status = 1 /* DISCONNECTED */; if (reconnect) this.handleReconnection(); else this.ndkRelay.emit("delayed-connect", 2 * 24 * 60 * 60 * 1e3); throw e; } } /** * Disconnects the WebSocket connection to the NDK relay. * This method sets the connection status to `NDKRelayStatus.DISCONNECTING`, * attempts to close the WebSocket connection, and sets the status to * `NDKRelayStatus.DISCONNECTED` if the disconnect operation fails. */ disconnect() { this._status = 0 /* DISCONNECTING */; try { this.ws?.close(); } catch (e) { this.debug("Failed to disconnect", e); this._status = 1 /* DISCONNECTED */; } } /** * Handles the error that occurred when attempting to connect to the NDK relay. * If `reconnect` is `true`, this method will initiate a reconnection attempt. * Otherwise, it will emit a `delayed-connect` event on the `ndkRelay` object, * indicating that a reconnection should be attempted after a delay. * * @param reconnect - Indicates whether a reconnection should be attempted. */ onConnectionError(reconnect) { this.debug(`Error connecting to ${this.ndkRelay.url}`, this.timeoutMs); if (reconnect && !this.reconnectTimeout) { this.handleReconnection(); } } /** * Handles the connection event when the WebSocket connection is established. * This method is called when the WebSocket connection is successfully opened. * It clears any existing connection and reconnection timeouts, updates the connection statistics, * sets the connection status to `CONNECTED`, and emits `connect` and `ready` events on the `ndkRelay` object. */ onConnect() { this.netDebug?.("connected", this.ndkRelay); if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = void 0; } if (this.connectTimeout) { clearTimeout(this.connectTimeout); this.connectTimeout = void 0; } this.updateConnectionStats.connected(); this._status = 5 /* CONNECTED */; this.ndkRelay.emit("connect"); this.ndkRelay.emit("ready"); } /** * Handles the disconnection event when the WebSocket connection is closed. * This method is called when the WebSocket connection is successfully closed. * It updates the connection statistics, sets the connection status to `DISCONNECTED`, * initiates a reconnection attempt if we didn't disconnect ourselves, * and emits a `disconnect` event on the `ndkRelay` object. */ onDisconnect() { this.netDebug?.("disconnected", this.ndkRelay); this.updateConnectionStats.disconnected(); if (this._status === 5 /* CONNECTED */) { this.handleReconnection(); } this._status = 1 /* DISCONNECTED */; this.ndkRelay.emit("disconnect"); } /** * Handles incoming messages from the NDK relay WebSocket connection. * This method is called whenever a message is received from the relay. * It parses the message data and dispatches the appropriate handling logic based on the message type. * * @param event - The MessageEvent containing the received message data. */ onMessage(event) { this.netDebug?.(event.data, this.ndkRelay, "recv"); try { const data = JSON.parse(event.data); const [cmd, id, ..._rest] = data; switch (cmd) { case "EVENT": { const so = this.openSubs.get(id); const event2 = data[2]; if (!so) { this.debug(`Received event for unknown subscription ${id}`); return; } so.onevent(event2); return; } case "COUNT": { const payload = data[2]; const cr = this.openCountRequests.get(id); if (cr) { cr.resolve(payload.count); this.openCountRequests.delete(id); } return; } case "EOSE": { const so = this.openSubs.get(id); if (!so) return; so.oneose(id); return; } case "OK": { const ok = data[2]; const reason = data[3]; const ep = this.openEventPublishes.get(id); const firstEp = ep?.pop(); if (!ep || !firstEp) { this.debug("Received OK for unknown event publish", id); return; } if (ok) firstEp.resolve(reason); else firstEp.reject(new Error(reason)); if (ep.length === 0) { this.openEventPublishes.delete(id); } else { this.openEventPublishes.set(id, ep); } return; } case "CLOSED": { const so = this.openSubs.get(id); if (!so) return; so.onclosed(data[2]); return; } case "NOTICE": this.onNotice(data[1]); return; case "AUTH": { this.onAuthRequested(data[1]); return; } } } catch (error) { this.debug( `Error parsing message from ${this.ndkRelay.url}: ${error.message}`, error?.stack ); return; } } /** * Handles an authentication request from the NDK relay. * * If an authentication policy is configured, it will be used to authenticate the connection. * Otherwise, the `auth` event will be emitted to allow the application to handle the authentication. * * @param challenge - The authentication challenge provided by the NDK relay. */ async onAuthRequested(challenge) { const authPolicy = this.ndkRelay.authPolicy ?? this.ndk?.relayAuthDefaultPolicy; this.debug("Relay requested authentication", { havePolicy: !!authPolicy }); if (this._status === 7 /* AUTHENTICATING */) { this.debug("Already authenticating, ignoring"); return; } this._status = 6 /* AUTH_REQUESTED */; if (authPolicy) { if (this._status >= 5 /* CONNECTED */) { this._status = 7 /* AUTHENTICATING */; let res; try { res = await authPolicy(this.ndkRelay, challenge); } catch (e) { this.debug("Authentication policy threw an error", e); res = false; } this.debug("Authentication policy returned", !!res); if (res instanceof NDKEvent || res === true) { if (res instanceof NDKEvent) { await this.auth(res); } const authenticate = async () => { if (this._status >= 5 /* CONNECTED */ && this._status < 8 /* AUTHENTICATED */) { const event = new NDKEvent(this.ndk); event.kind = 22242 /* ClientAuth */; event.tags = [ ["relay", this.ndkRelay.url], ["challenge", challenge] ]; await event.sign(); this.auth(event).then(() => { this._status = 8 /* AUTHENTICATED */; this.ndkRelay.emit("authed"); this.debug("Authentication successful"); }).catch((e) => { this._status = 6 /* AUTH_REQUESTED */; this.ndkRelay.emit("auth:failed", e); this.debug("Authentication failed", e); }); } else { this.debug( "Authentication failed, it changed status, status is %d", this._status ); } }; if (res === true) { if (!this.ndk?.signer) { this.debug("No signer available for authentication localhost"); this.ndk?.once("signer:ready", authenticate); } else { authenticate().catch((e) => { console.error("Error authenticating", e); }); } } this._status = 5 /* CONNECTED */; this.ndkRelay.emit("authed"); } } } else { this.ndkRelay.emit("auth", challenge); } } /** * Handles errors that occur on the WebSocket connection to the relay. * @param error - The error or event that occurred. */ onError(error) { this.debug(`WebSocket error on ${this.ndkRelay.url}:`, error); } /** * Gets the current status of the NDK relay connection. * @returns {NDKRelayStatus} The current status of the NDK relay connection. */ get status() { return this._status; } /** * Checks if the NDK relay connection is currently available. * @returns {boolean} `true` if the relay connection is in the `CONNECTED` status, `false` otherwise. */ isAvailable() { return this._status === 5 /* CONNECTED */; } /** * Checks if the NDK relay connection is flapping, which means the connection is rapidly * disconnecting and reconnecting. This is determined by analyzing the durations of the * last three connection attempts. If the standard deviation of the durations is less * than 1000 milliseconds, the connection is considered to be flapping. * * @returns {boolean} `true` if the connection is flapping, `false` otherwise. */ isFlapping() { const durations = this._connectionStats.durations; if (durations.length % 3 !== 0) return false; const sum = durations.reduce((a, b) => a + b, 0); const avg = sum / durations.length; const variance = durations.map((x) => (x - avg) ** 2).reduce((a, b) => a + b, 0) / durations.length; const stdDev = Math.sqrt(variance); const isFlapping = stdDev < FLAPPING_THRESHOLD_MS; return isFlapping; } /** * Handles a notice received from the NDK relay. * If the notice indicates the relay is complaining (e.g. "too many" or "maximum"), * the method disconnects from the relay and attempts to reconnect after a 2-second delay. * A debug message is logged with the relay URL and the notice text. * The "notice" event is emitted on the ndkRelay instance with the notice text. * * @param notice - The notice text received from the NDK relay. */ async onNotice(notice) { this.ndkRelay.emit("notice", notice); } /** * Attempts to reconnect to the NDK relay after a connection is lost. * This function is called recursively to handle multiple reconnection attempts. * It checks if the relay is flapping and emits a "flapping" event if so. * It then calculates a delay before the next reconnection attempt based on the number of previous attempts. * The function sets a timeout to execute the next reconnection attempt after the calculated delay. * If the maximum number of reconnection attempts is reached, a debug message is logged. * * @param attempt - The current attempt number (default is 0). */ handleReconnection(attempt = 0) { if (this.reconnectTimeout) return; if (this.isFlapping()) { this.ndkRelay.emit("flapping", this._connectionStats); this._status = 3 /* FLAPPING */; return; } const reconnectDelay = this.connectedAt ? Math.max(0, 6e4 - (Date.now() - this.connectedAt)) : 5e3 * (this._connectionStats.attempts + 1); this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = void 0; this._status = 2 /* RECONNECTING */; this.connect().catch((_err) => { if (attempt < MAX_RECONNECT_ATTEMPTS) { setTimeout( () => { this.handleReconnection(attempt + 1); }, 1e3 * (attempt + 1) ^ 4 ); } else { this.debug("Reconnect failed"); } }); }, reconnectDelay); this.ndkRelay.emit("delayed-connect", reconnectDelay); this.debug("Reconnecting in", reconnectDelay); this._connectionStats.nextReconnectAt = Date.now() + reconnectDelay; } /** * Sends a message to the NDK relay if the connection is in the CONNECTED state and the WebSocket is open. * If the connection is not in the CONNECTED state or the WebSocket is not open, logs a debug message and throws an error. * * @param message - The message to send to the NDK relay. * @throws {Error} If attempting to send on a closed relay connection. */ async send(message) { if (this._status >= 5 /* CONNECTED */ && this.ws?.readyState === WebSocket.OPEN) { this.ws?.send(message); this.netDebug?.(message, this.ndkRelay, "send"); } else { this.debug( `Not connected to ${this.ndkRelay.url} (%d), not sending message ${message}`, this._status ); } } /** * Authenticates the NDK event by sending it to the NDK relay and returning a promise that resolves with the result. * * @param event - The NDK event to authenticate. * @returns A promise that resolves with the authentication result. */ async auth(event) { const ret = new Promise((resolve, reject) => { const val = this.openEventPublishes.get(event.id) ?? []; val.push({ resolve, reject }); this.openEventPublishes.set(event.id, val); }); this.send(`["AUTH",${JSON.stringify(event.rawEvent())}]`); return ret; } /** * Publishes an NDK event to the relay and returns a promise that resolves with the result. * * @param event - The NDK event to publish. * @returns A promise that resolves with the result of the event publication. * @throws {Error} If attempting to publish on a closed relay connection. */ async publish(event) { const ret = new Promise((resolve, reject) => { const val = this.openEventPublishes.get(event.id) ?? []; if (val.length > 0) { console.warn( `Duplicate event publishing detected, you are publishing event ${event.id} twice` ); } val.push({ resolve, reject }); this.openEventPublishes.set(event.id, val); }); this.send(`["EVENT",${JSON.stringify(event)}]`); return ret; } /** * Counts the number of events that match the provided filters. * * @param filters - The filters to apply to the count request. * @param params - An optional object containing a custom id for the count request. * @returns A promise that resolves with the number of matching events. * @throws {Error} If attempting to send the count request on a closed relay connection. */ async count(filters, params) { this.serial++; const id = params?.id || `count:${this.serial}`; const ret = new Promise((resolve, reject) => { this.openCountRequests.set(id, { resolve, reject }); }); this.send(`["COUNT","${id}",${JSON.stringify(filters).substring(1)}`); return ret; } close(subId, reason) { this.send(`["CLOSE","${subId}"]`); const sub = this.openSubs.get(subId); this.openSubs.delete(subId); if (sub) sub.onclose(reason); } /** * Subscribes to the NDK relay with the provided filters and parameters. * * @param filters - The filters to apply to the subscription. * @param params - The subscription parameters, including an optional custom id. * @returns A new NDKRelaySubscription instance. */ req(relaySub) { `${this.send(`["REQ","${relaySub.subId}",${JSON.stringify(relaySub.executeFilters).substring(1)}`)}]`; this.openSubs.set(relaySub.subId, relaySub); } /** * Utility functions to update the connection stats. */ updateConnectionStats = { connected: () => { this._connectionStats.success++; this._connectionStats.connectedAt = Date.now(); }, disconnected: () => { if (this._connectionStats.connectedAt) { this._connectionStats.durations.push( Date.now() - this._connectionStats.connectedAt ); if (this._connectionStats.durations.length > 100) { this._connectionStats.durations.shift(); } } this._connectionStats.connectedAt = void 0; }, attempt: () => { this._connectionStats.attempts++; this._connectionStats.connectedAt = Date.now(); } }; /** Returns the connection stats. */ get connectionStats() { return this._connectionStats; } /** Returns the relay URL */ get url() { return this.ndkRelay.url; } get connected() { return this._status >= 5 /* CONNECTED */ && this.ws?.readyState === WebSocket.OPEN; } }; // src/relay/publisher.ts var NDKRelayPublisher = class { ndkRelay; debug; constructor(ndkRelay) { this.ndkRelay = ndkRelay; this.debug = ndkRelay.debug.extend("publisher"); } /** * Published an event to the relay; if the relay is not connected, it will * wait for the relay to connect before publishing the event. * * If the relay does not connect within the timeout, the publish operation * will fail. * @param event The event to publish * @param timeoutMs The timeout for the publish operation in milliseconds * @returns A promise that resolves when the event has been published or rejects if the operation times out */ async publish(event, timeoutMs = 2500) { let timeout; const publishConnected = () => { return new Promise((resolve, reject) => { try { this.publishEvent(event).then((_result) => { this.ndkRelay.emit("published", event); event.emit("relay:published", this.ndkRelay); resolve(true); }).catch(reject); } catch (err) { reject(err); } }); }; const timeoutPromise = new Promise((_, reject) => { timeout = setTimeout(() => { timeout = void 0; reject(new Error(`Timeout: ${timeoutMs}ms`)); }, timeoutMs); }); const onConnectHandler = () => { publishConnected().then((result) => connectResolve(result)).catch((err) => connectReject(err)); }; let connectResolve; let connectReject; const onError = (err) => { this.ndkRelay.debug("Publish failed", err, event.id); this.ndkRelay.emit("publish:failed", event, err); event.emit("relay:publish:failed", this.ndkRelay, err); throw err; }; const onFinally = () => { if (timeout) clearTimeout(timeout); this.ndkRelay.removeListener("connect", onConnectHandler); }; if (this.ndkRelay.status >= 5 /* CONNECTED */) { return Promise.race([publishConnected(), timeoutPromise]).catch(onError).finally(onFinally); } if (this.ndkRelay.status <= 1 /* DISCONNECTED */) { console.warn( "Relay is disconnected, trying to connect to publish an event", this.ndkRelay.url ); this.ndkRelay.connect(); } else { console.warn( "Relay not connected, waiting for connection to publish an event", this.ndkRelay.url ); } return Promise.race([ new Promise((resolve, reject) => { connectResolve = resolve; connectReject = reject; this.ndkRelay.on("connect", onConnectHandler); }), timeoutPromise ]).catch(onError).finally(onFinally); } async publishEvent(event) { return this.ndkRelay.connectivity.publish(event.rawEvent()); } }; // src/subscription/grouping.ts function filterFingerprint(filters, closeOnEose) { const elements = []; for (const filter of filters) { const keys = Object.entries(filter || {}).map(([key, values]) => { if (["since", "until"].includes(key)) { return `${key}:${values}`; } return key; }).sort().join("-"); elements.push(keys); } let id = closeOnEose ? "+" : ""; id += elements.join("|"); return id; } function mergeFilters(filters) { const result = []; const lastResult = {}; filters.filter((f) => !!f.limit).forEach((filterWithLimit) => result.push(filterWithLimit)); filters = filters.filter((f) => !f.limit); if (filters.length === 0) return result; filters.forEach((filter) => { Object.entries(filter).forEach(([key, value]) => { if (Array.isArray(value)) { if (lastResult[key] === void 0) { lastResult[key] = [...value]; } else { lastResult[key] = Array.from(/* @__PURE__ */ new Set([...lastResult[key], ...value])); } } else { lastResult[key] = value; } }); }); return [...result, lastResult]; } // src/relay/subscription.ts var NDKRelaySubscription = class { fingerprint; items = /* @__PURE__ */ new Map(); topSubManager; debug; /** * Tracks the status of this REQ. */ status = 0 /* INITIAL */; onClose; relay; /** * Whether this subscription has reached EOSE. */ eosed = false; /** * Timeout at which this subscription will * start executing. */ executionTimer; /** * Track the time at which this subscription will fire. */ fireTime; /** * The delay type that the current fireTime was calculated with. */ delayType; /** * The filters that have been executed. */ executeFilters; id = Math.random().toString(36).substring(7); /** * * @param fingerprint The fingerprint of this subscription. */ constructor(relay, fingerprint, topSubManager) { this.relay = relay; this.topSubManager = topSubManager; this.debug = relay.debug.extend(`sub[${this.id}]`); this.fingerprint = fingerprint || Math.random().toString(36).substring(7); } _subId; get subId() { if (this._subId) return this._subId; this._subId = this.fingerprint.slice(0, 15); return this._subId; } subIdParts = /* @__PURE__ */ new Set(); addSubIdPart(part) { this.subIdParts.add(part); } addItem(subscription, filters) { this.debug("Adding item", { filters, internalId: subscription.internalId, status: this.status, fingerprint: this.fingerprint, id: this.subId, items: this.items, itemsSize: this.items.size }); if (this.items.has(subscription.internalId)) return; subscription.on("close", this.removeItem.bind(this, subscription)); this.items.set(subscription.internalId, { subscription, filters }); if (this.status !== 3 /* RUNNING */) { if (subscription.subId && (!this._subId || this._subId.length < 48)) { if (this.status === 0 /* INITIAL */ || this.status === 1 /* PENDING */) { this.addSubIdPart(subscription.subId); } } } switch (this.status) { case 0 /* INITIAL */: this.evaluateExecutionPlan(subscription); break; case 3 /* RUNNING */: break; case 1 /* PENDING */: this.evaluateExecutionPlan(subscription); break; case 4 /* CLOSED */: this.debug( "Subscription is closed, cannot add new items %o (%o)", subscription, filters ); throw new Error("Cannot add new items to a closed subscription"); } } /** * A subscription has been closed, remove it from the list of items. * @param subscription */ removeItem(subscription) { this.items.delete(subscription.internalId); if (this.items.size === 0) { if (!this.eosed) return; this.close(); this.cleanup(); } } close() { if (this.status === 4 /* CLOSED */) return; const prevStatus = this.status; this.status = 4 /* CLOSED */; if (prevStatus === 3 /* RUNNING */) { try { this.relay.close(this.subId); } catch (e) { this.debug("Error closing subscription", e, this); } } else { this.debug("Subscription wanted to close but it wasn't running, this is probably ok", { subId: this.subId, prevStatus, sub: this }); } this.cleanup(); } cleanup() { if (this.executionTimer) clearTimeout(this.executionTimer); this.relay.off("ready", this.executeOnRelayReady); this.relay.off("authed", this.reExecuteAfterAuth); if (this.onClose) this.onClose(this); } evaluateExecutionPlan(subscription) { if (!subscription.isGroupable()) { this.status = 1 /* PENDING */; this.execute(); return; } if (subscription.filters.find((filter) => !!filter.limit)) { this.executeFilters = this.compileFilters(); if (this.executeFilters.length >= 10) { this.status = 1 /* PENDING */; this.execute(); return; } } const delay = subscription.groupableDelay; const delayType = subscription.groupableDelayType; if (!delay) throw new Error("Cannot group a subscription without a delay"); if (this.status === 0 /* INITIAL */) { this.schedule(delay, delayType); } else { const existingDelayType = this.delayType; const timeUntilFire = this.fireTime - Date.now(); if (existingDelayType === "at-least" && delayType === "at-least") { if (timeUntilFire < delay) { if (this.executionTimer) clearTimeout(this.executionTimer); this.schedule(delay, delayType); } } else if (existingDelayType === "at-least" && delayType === "at-most") { if (timeUntilFire > delay) { if (this.executionTimer) clearTimeout(this.executionTimer); this.schedule(delay, delayType); } } else if (existingDelayType === "at-most" && delayType === "at-most") { if (timeUntilFire > delay) { if (this.executionTimer) clearTimeout(this.executionTimer); this.schedule(delay, delayType); } } else if (existingDelayType === "at-most" && delayType === "at-least") { if (timeUntilFire > delay) { if (this.executionTimer) clearTimeout(this.executionTimer); this.schedule(delay, delayType); } } else { throw new Error(`Unknown delay type combination ${existingDelayType} ${delayType}`); } } } schedule(delay, delayType) { this.status = 1 /* PENDING */; const currentTime = Date.now(); this.fireTime = currentTime + delay; this.delayType = delayType; const timer = setTimeout(this.execute.bind(this), delay); if (delayType === "at-least") { this.executionTimer = timer; } } executeOnRelayReady = () => { if (this.status !== 2 /* WAITING */) return; if (this.items.size === 0) { this.debug( "No items to execute; this relay was probably too slow to respond and the