UNPKG

@nostr-dev-kit/ndk

Version:

NDK - Nostr Development Kit

1,522 lines (1,500 loc) 311 kB
// test/mocks/relay-mock.ts import { EventEmitter as EventEmitter3 } from "events"; // src/relay/index.ts import debug2 from "debug"; import { EventEmitter as EventEmitter2 } from "tseep"; // 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/events/index.ts import { EventEmitter } 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/relay/sets/index.ts var NDKPublishError = class extends Error { errors; publishedToRelays; /** * Intended relay set where the publishing was intended to happen. */ intendedRelaySet; constructor(message, errors, publishedToRelays, intendedRelaySet) { super(message); this.errors = errors; this.publishedToRelays = publishedToRelays; this.intendedRelaySet = intendedRelaySet; } get relayErrors() { const errors = []; for (const [relay, err] of this.errors) { errors.push(`${relay.url}: ${err}`); } return errors.join("\n"); } }; var NDKRelaySet = class _NDKRelaySet { relays; debug; ndk; pool; constructor(relays, ndk, pool) { this.relays = relays; this.ndk = ndk; this.pool = pool ?? ndk.pool; this.debug = ndk.debug.extend("relayset"); } /** * Adds a relay to this set. */ addRelay(relay) { this.relays.add(relay); } get relayUrls() { return Array.from(this.relays).map((r) => r.url); } /** * Creates a relay set from a list of relay URLs. * * If no connection to the relay is found in the pool it will temporarily * connect to it. * * @param relayUrls - list of relay URLs to include in this set * @param ndk * @param connect - whether to connect to the relay immediately if it was already in the pool but not connected * @returns NDKRelaySet */ static fromRelayUrls(relayUrls, ndk, connect = true, pool) { pool = pool ?? ndk.pool; if (!pool) throw new Error("No pool provided"); const relays = /* @__PURE__ */ new Set(); for (const url of relayUrls) { const relay = pool.relays.get(normalizeRelayUrl(url)); if (relay) { if (relay.status < 5 /* CONNECTED */ && connect) { relay.connect(); } relays.add(relay); } else { const temporaryRelay = new NDKRelay(normalizeRelayUrl(url), ndk?.relayAuthDefaultPolicy, ndk); pool.useTemporaryRelay(temporaryRelay, void 0, `requested from fromRelayUrls ${relayUrls}`); relays.add(temporaryRelay); } } return new _NDKRelaySet(new Set(relays), ndk, pool); } /** * Publish an event to all relays in this relay set. * * This method implements a robust mechanism for publishing events to multiple relays with * built-in handling for race conditions, timeouts, and partial failures. The implementation * uses a dual-tracking mechanism to ensure accurate reporting of which relays successfully * received an event. * * Key aspects of this implementation: * * 1. DUAL-TRACKING MECHANISM: * - Promise-based tracking: Records successes/failures from the promises returned by relay.publish() * - Event-based tracking: Listens for 'relay:published' events that indicate successful publishing * This approach ensures we don't miss successful publishes even if there are subsequent errors in * the promise chain. * * 2. RACE CONDITION HANDLING: * - If a relay emits a success event but later fails in the promise chain, we still count it as a success * - If a relay times out after successfully publishing, we still count it as a success * - All relay operations happen in parallel, with proper tracking regardless of completion order * * 3. TIMEOUT MANAGEMENT: * - Individual timeouts for each relay operation * - Proper cleanup of timeouts to prevent memory leaks * - Clear timeout error reporting * * 4. ERROR HANDLING: * - Detailed tracking of specific errors for each failed relay * - Special handling for ephemeral events (which don't expect acknowledgement) * - RequiredRelayCount parameter to control the minimum success threshold * * @param event Event to publish * @param timeoutMs Timeout in milliseconds for each relay publish operation * @param requiredRelayCount The minimum number of relays we expect the event to be published to * @returns A set of relays the event was published to * @throws {NDKPublishError} If the event could not be published to at least `requiredRelayCount` relays * @example * ```typescript * const relaySet = new NDKRelaySet(new Set([relay1, relay2]), ndk); * const publishedToRelays = await relaySet.publish(event); * // publishedToRelays can contain relay1, relay2, both, or none * // depending on which relays the event was successfully published to * if (publishedToRelays.size > 0) { * console.log("Event published to at least one relay"); * } * ``` */ async publish(event, timeoutMs, requiredRelayCount = 1) { const publishedToRelays = /* @__PURE__ */ new Set(); const errors = /* @__PURE__ */ new Map(); const isEphemeral2 = event.isEphemeral(); event.publishStatus = "pending"; const relayPublishedHandler = (relay) => { publishedToRelays.add(relay); }; event.on("relay:published", relayPublishedHandler); try { const promises = Array.from(this.relays).map((relay) => { return new Promise((resolve) => { const timeoutId = timeoutMs ? setTimeout(() => { if (!publishedToRelays.has(relay)) { errors.set(relay, new Error(`Publish timeout after ${timeoutMs}ms`)); resolve(false); } }, timeoutMs) : null; relay.publish(event, timeoutMs).then((success) => { if (timeoutId) clearTimeout(timeoutId); if (success) { publishedToRelays.add(relay); resolve(true); } else { resolve(false); } }).catch((err) => { if (timeoutId) clearTimeout(timeoutId); if (!isEphemeral2) { errors.set(relay, err); } resolve(false); }); }); }); await Promise.all(promises); if (publishedToRelays.size < requiredRelayCount) { if (!isEphemeral2) { const error = new NDKPublishError( "Not enough relays received the event (" + publishedToRelays.size + " published, " + requiredRelayCount + " required)", errors, publishedToRelays, this ); event.publishStatus = "error"; event.publishError = error; this.ndk?.emit("event:publish-failed", event, error, this.relayUrls); throw error; } } else { event.publishStatus = "success"; event.emit("published", { relaySet: this, publishedToRelays }); } return publishedToRelays; } finally { event.off("relay:published", relayPublishedHandler); } } get size() { return this.relays.size; } }; // src/relay/sets/calculate.ts var d = createDebug("ndk:outbox:calculate"); async function calculateRelaySetFromEvent(ndk, event, requiredRelayCount) { const relays = /* @__PURE__ */ new Set(); const authorWriteRelays = await getWriteRelaysFor(ndk, event.pubkey); if (authorWriteRelays) { authorWriteRelays.forEach((relayUrl) => { const relay = ndk.pool?.getRelay(relayUrl); if (relay) relays.add(relay); }); } let relayHints = event.tags.filter((tag) => ["a", "e"].includes(tag[0])).map((tag) => tag[2]).filter((url) => url?.startsWith("wss://")).filter((url) => { try { new URL(url); return true; } catch { return false; } }).map((url) => normalizeRelayUrl(url)); relayHints = Array.from(new Set(relayHints)).slice(0, 5); relayHints.forEach((relayUrl) => { const relay = ndk.pool?.getRelay(relayUrl, true, true); if (relay) { d("Adding relay hint %s", relayUrl); relays.add(relay); } }); const pTags = event.getMatchingTags("p").map((tag) => tag[1]); if (pTags.length < 5) { const pTaggedRelays = Array.from( chooseRelayCombinationForPubkeys(ndk, pTags, "read", { preferredRelays: new Set(authorWriteRelays) }).keys() ); pTaggedRelays.forEach((relayUrl) => { const relay = ndk.pool?.getRelay(relayUrl, false, true); if (relay) { d("Adding p-tagged relay %s", relayUrl); relays.add(relay); } }); } else { d("Too many p-tags to consider %d", pTags.length); } ndk.pool?.permanentAndConnectedRelays().forEach((relay) => relays.add(relay)); if (requiredRelayCount && relays.size < requiredRelayCount) { const explicitRelays = ndk.explicitRelayUrls?.filter((url) => !Array.from(relays).some((r) => r.url === url)).slice(0, requiredRelayCount - relays.size); explicitRelays?.forEach((url) => { const relay = ndk.pool?.getRelay(url, false, true); if (relay) { d("Adding explicit relay %s", url); relays.add(relay); } }); } return new NDKRelaySet(relays, ndk); } function calculateRelaySetsFromFilter(ndk, filters, pool) { const result = /* @__PURE__ */ new Map(); const authors = /* @__PURE__ */ new Set(); filters.forEach((filter) => { if (filter.authors) { filter.authors.forEach((author) => authors.add(author)); } }); if (authors.size > 0) { const authorToRelaysMap = getRelaysForFilterWithAuthors(ndk, Array.from(authors)); for (const relayUrl of authorToRelaysMap.keys()) { result.set(relayUrl, []); } for (const filter of filters) { if (filter.authors) { for (const [relayUrl, authors2] of authorToRelaysMap.entries()) { const authorFilterAndRelayPubkeyIntersection = filter.authors.filter( (author) => authors2.includes(author) ); result.set(relayUrl, [ ...result.get(relayUrl), { ...filter, // Overwrite authors sent to this relay with the authors that were // present in the filter and are also present in the relay authors: authorFilterAndRelayPubkeyIntersection } ]); } } else { for (const relayUrl of authorToRelaysMap.keys()) { result.set(relayUrl, [...result.get(relayUrl), filter]); } } } } else { if (ndk.explicitRelayUrls) { ndk.explicitRelayUrls.forEach((relayUrl) => { result.set(relayUrl, filters); }); } } if (result.size === 0) { pool.permanentAndConnectedRelays().slice(0, 5).forEach((relay) => { result.set(relay.url, filters); }); } return result; } function calculateRelaySetsFromFilters(ndk, filters, pool) { const a = calculateRelaySetsFromFilter(ndk, filters, pool); return a; } // src/events/content-tagger.ts import { nip19 } from "nostr-tools"; function mergeTags(tags1, tags2) { const tagMap = /* @__PURE__ */ new Map(); const generateKey = (tag) => tag.join(","); const isContained = (smaller, larger) => { return smaller.every((value, index) => value === larger[index]); }; const processTag = (tag) => { for (const [key, existingTag] of tagMap) { if (isContained(existingTag, tag) || isContained(tag, existingTag)) { if (tag.length >= existingTag.length) { tagMap.set(key, tag); } return; } } tagMap.set(generateKey(tag), tag); }; tags1.concat(tags2).forEach(processTag); return Array.from(tagMap.values()); } var hashtagRegex = /(?<=\s|^)(#[^\s!@#$%^&*()=+./,[{\]};:'"?><]+)/g; function generateHashtags(content) { const hashtags = content.match(hashtagRegex); const tagIds = /* @__PURE__ */ new Set(); const tag = /* @__PURE__ */ new Set(); if (hashtags) { for (const hashtag of hashtags) { if (tagIds.has(hashtag.slice(1))) continue; tag.add(hashtag.slice(1)); tagIds.add(hashtag.slice(1)); } } return Array.from(tag); } async function generateContentTags(content, tags = [], opts, ctx) { if (opts?.skipContentTagging) { return { content, tags }; } const tagRegex = /(@|nostr:)(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]+/g; const promises = []; const addTagIfNew = (t) => { if (!tags.find((t2) => ["q", t[0]].includes(t2[0]) && t2[1] === t[1])) { tags.push(t); } }; content = content.replace(tagRegex, (tag) => { try { const entity = tag.split(/(@|nostr:)/)[2]; const { type, data } = nip19.decode(entity); let t; if (opts?.filters) { const shouldInclude = !opts.filters.includeTypes || opts.filters.includeTypes.includes(type); const shouldExclude = opts.filters.excludeTypes?.includes(type); if (!shouldInclude || shouldExclude) { return tag; } } switch (type) { case "npub": if (opts?.pTags !== false) { t = ["p", data]; } break; case "nprofile": if (opts?.pTags !== false) { t = ["p", data.pubkey]; } break; case "note": promises.push( new Promise(async (resolve) => { const relay = await maybeGetEventRelayUrl(entity); addTagIfNew(["q", data, relay]); resolve(); }) ); break; case "nevent": promises.push( new Promise(async (resolve) => { const { id, author } = data; let { relays } = data; if (!relays || relays.length === 0) { relays = [await maybeGetEventRelayUrl(entity)]; } addTagIfNew(["q", id, relays[0]]); if (author && opts?.pTags !== false && opts?.pTagOnQTags !== false) addTagIfNew(["p", author]); resolve(); }) ); break; case "naddr": promises.push( new Promise(async (resolve) => { const id = [data.kind, data.pubkey, data.identifier].join(":"); let relays = data.relays ?? []; if (relays.length === 0) { relays = [await maybeGetEventRelayUrl(entity)]; } addTagIfNew(["q", id, relays[0]]); if (opts?.pTags !== false && opts?.pTagOnQTags !== false && opts?.pTagOnATags !== false) addTagIfNew(["p", data.pubkey]); resolve(); }) ); break; default: return tag; } if (t) addTagIfNew(t); return `nostr:${entity}`; } catch (_error) { return tag; } }); await Promise.all(promises); if (!opts?.filters?.excludeTypes?.includes("hashtag")) { const newTags = generateHashtags(content).map((hashtag) => ["t", hashtag]); tags = mergeTags(tags, newTags); } if (opts?.pTags !== false && opts?.copyPTagsFromTarget && ctx) { const pTags = ctx.getMatchingTags("p"); for (const pTag of pTags) { if (!tags.find((t) => t[0] === "p" && t[1] === pTag[1])) { tags.push(pTag); } } } return { content, tags }; } async function maybeGetEventRelayUrl(_nip19Id) { return ""; } // src/events/encryption.ts async function encrypt(recipient, signer, scheme = "nip44") { let encrypted; if (!this.ndk) throw new Error("No NDK instance found!"); let currentSigner = signer; if (!currentSigner) { this.ndk.assertSigner(); currentSigner = this.ndk.signer; } if (!currentSigner) throw new Error("no NDK signer"); const currentRecipient = recipient || (() => { const pTags = this.getMatchingTags("p"); if (pTags.length !== 1) { throw new Error("No recipient could be determined and no explicit recipient was provided"); } return this.ndk.getUser({ pubkey: pTags[0][1] }); })(); if (scheme === "nip44" && await isEncryptionEnabled(currentSigner, "nip44")) { encrypted = await currentSigner.encrypt(currentRecipient, this.content, "nip44"); } if ((!encrypted || scheme === "nip04") && await isEncryptionEnabled(currentSigner, "nip04")) { encrypted = await currentSigner.encrypt(currentRecipient, this.content, "nip04"); } if (!encrypted) throw new Error("Failed to encrypt event."); this.content = encrypted; } async function decrypt(sender, signer, scheme) { if (this.ndk?.cacheAdapter?.getDecryptedEvent) { let cachedEvent = null; if (typeof this.ndk.cacheAdapter.getDecryptedEvent === "function") { cachedEvent = this.ndk.cacheAdapter.getDecryptedEvent(this.id); } if (cachedEvent) { this.content = cachedEvent.content; return; } } let decrypted; if (!this.ndk) throw new Error("No NDK instance found!"); let currentSigner = signer; if (!currentSigner) { this.ndk.assertSigner(); currentSigner = this.ndk.signer; } if (!currentSigner) throw new Error("no NDK signer"); const currentSender = sender || this.author; if (!currentSender) throw new Error("No sender provided and no author available"); const currentScheme = scheme || (this.content.match(/\\?iv=/) ? "nip04" : "nip44"); if ((currentScheme === "nip04" || this.kind === 4) && await isEncryptionEnabled(currentSigner, "nip04") && this.content.search("\\?iv=")) { decrypted = await currentSigner.decrypt(currentSender, this.content, "nip04"); } if (!decrypted && currentScheme === "nip44" && await isEncryptionEnabled(currentSigner, "nip44")) { decrypted = await currentSigner.decrypt(currentSender, this.content, "nip44"); } if (!decrypted) throw new Error("Failed to decrypt event."); this.content = decrypted; if (this.ndk?.cacheAdapter?.addDecryptedEvent) { this.ndk.cacheAdapter.addDecryptedEvent(this); } } async function isEncryptionEnabled(signer, scheme) { if (!signer.encryptionEnabled) return false; if (!scheme) return true; return Boolean(await signer.encryptionEnabled(scheme)); } // src/thread/index.ts function eventHasETagMarkers(event) { for (const tag of event.tags) { if (tag[0] === "e" && (tag[3] ?? "").length > 0) return true; } return false; } function getRootTag(event, searchTag) { searchTag ??= event.tagType(); const rootEventTag = event.tags.find(isTagRootTag); if (!rootEventTag) { if (eventHasETagMarkers(event)) return; const matchingTags = event.getMatchingTags(searchTag); if (matchingTags.length < 3) return matchingTags[0]; } return rootEventTag; } var nip22RootTags = /* @__PURE__ */ new Set(["A", "E", "I"]); var nip22ReplyTags = /* @__PURE__ */ new Set(["a", "e", "i"]); function getReplyTag(event, searchTag) { if (event.kind === 1111 /* GenericReply */) { let replyTag2; for (const tag of event.tags) { if (nip22RootTags.has(tag[0])) replyTag2 = tag; else if (nip22ReplyTags.has(tag[0])) { replyTag2 = tag; break; } } return replyTag2; } searchTag ??= event.tagType(); let hasMarkers = false; let replyTag; for (const tag of event.tags) { if (tag[0] !== searchTag) continue; if ((tag[3] ?? "").length > 0) hasMarkers = true; if (hasMarkers && tag[3] === "reply") return tag; if (hasMarkers && tag[3] === "root") replyTag = tag; if (!hasMarkers) replyTag = tag; } return replyTag; } function isTagRootTag(tag) { return tag[0] === "E" || tag[3] === "root"; } // src/events/fetch-tagged-event.ts async function fetchTaggedEvent(tag, marker) { if (!this.ndk) throw new Error("NDK instance not found"); const t = this.getMatchingTags(tag, marker); if (t.length === 0) return void 0; const [_, id, hint] = t[0]; let relay = hint !== "" ? this.ndk.pool.getRelay(hint) : void 0; const event = await this.ndk.fetchEvent(id, {}, relay); return event; } async function fetchRootEvent(subOpts) { if (!this.ndk) throw new Error("NDK instance not found"); const rootTag = getRootTag(this); if (!rootTag) return void 0; return this.ndk.fetchEventFromTag(rootTag, this, subOpts); } async function fetchReplyEvent(subOpts) { if (!this.ndk) throw new Error("NDK instance not found"); const replyTag = getReplyTag(this); if (!replyTag) return void 0; return this.ndk.fetchEventFromTag(replyTag, this, subOpts); } // src/events/kind.ts function isReplaceable() { if (this.kind === void 0) throw new Error("Kind not set"); return [0, 3].includes(this.kind) || this.kind >= 1e4 && this.kind < 2e4 || this.kind >= 3e4 && this.kind < 4e4; } function isEphemeral() { if (this.kind === void 0) throw new Error("Kind not set"); return this.kind >= 2e4 && this.kind < 3e4; } function isParamReplaceable() { if (this.kind === void 0) throw new Error("Kind not set"); return this.kind >= 3e4 && this.kind < 4e4; } // src/events/nip19.ts import { nip19 as nip192 } from "nostr-tools"; var DEFAULT_RELAY_COUNT = 2; function encode(maxRelayCount = DEFAULT_RELAY_COUNT) { let relays = []; if (this.onRelays.length > 0) { relays = this.onRelays.map((relay) => relay.url); } else if (this.relay) { relays = [this.relay.url]; } if (relays.length > maxRelayCount) { relays = relays.slice(0, maxRelayCount); } if (this.isParamReplaceable()) { return nip192.naddrEncode({ kind: this.kind, pubkey: this.pubkey, identifier: this.replaceableDTag(), relays }); } if (relays.length > 0) { return nip192.neventEncode({ id: this.tagId(), relays, author: this.pubkey }); } return nip192.noteEncode(this.tagId()); } // src/events/repost.ts async function repost(publish = true, signer) { if (!signer && publish) { if (!this.ndk) throw new Error("No NDK instance found"); this.ndk.assertSigner(); signer = this.ndk.signer; } const e = new NDKEvent(this.ndk, { kind: getKind(this) }); if (!this.isProtected) e.content = JSON.stringify(this.rawEvent()); e.tag(this); if (this.kind !== 1 /* Text */) { e.tags.push(["k", `${this.kind}`]); } if (signer) await e.sign(signer); if (publish) await e.publish(); return e; } function getKind(event) { if (event.kind === 1) { return 6 /* Repost */; } return 16 /* GenericRepost */; } // src/events/serializer.ts function serialize(includeSig = false, includeId = false) { const payload = [0, this.pubkey, this.created_at, this.kind, this.tags, this.content]; if (includeSig) payload.push(this.sig); if (includeId) payload.push(this.id); return JSON.stringify(payload); } function deserialize(serializedEvent) { const eventArray = JSON.parse(serializedEvent); const ret = { pubkey: eventArray[1], created_at: eventArray[2], kind: eventArray[3], tags: eventArray[4], content: eventArray[5] }; if (eventArray.length >= 7) { const first = eventArray[6]; const second = eventArray[7]; if (first && first.length === 128) { ret.sig = first; if (second && second.length === 64) { ret.id = second; } } else if (first && first.length === 64) { ret.id = first; if (second && second.length === 128) { ret.sig = second; } } } return ret; } // src/events/validation.ts import { schnorr } from "@noble/curves/secp256k1"; import { sha256 } from "@noble/hashes/sha256"; import { bytesToHex } from "@noble/hashes/utils"; import { LRUCache } from "typescript-lru-cache"; // src/events/signature.ts var worker; var processingQueue = {}; function signatureVerificationInit(w) { worker = w; worker.onmessage = (msg) => { const [eventId, result] = msg.data; const record = processingQueue[eventId]; if (!record) { console.error("No record found for event", eventId); return; } delete processingQueue[eventId]; for (const resolve of record.resolves) { resolve(result); } }; } async function verifySignatureAsync(event, _persist, relay) { const ndkInstance = event.ndk; const start = Date.now(); let result; if (ndkInstance.signatureVerificationFunction) { console.log("[NDK-CORE] Using custom signature verification function async"); result = await ndkInstance.signatureVerificationFunction(event); console.log("Custom signature verification result", event.id, { result }); } else { console.log("Using worker-based signature verification async"); result = await new Promise((resolve) => { const serialized = event.serialize(); let enqueue = false; if (!processingQueue[event.id]) { processingQueue[event.id] = { event, resolves: [], relay }; enqueue = true; } processingQueue[event.id].resolves.push(resolve); if (!enqueue) return; worker?.postMessage({ serialized, id: event.id, sig: event.sig, pubkey: event.pubkey }); }); } ndkInstance.signatureVerificationTimeMs += Date.now() - start; return result; } // src/events/validation.ts var PUBKEY_REGEX = /^[a-f0-9]{64}$/; function validate() { if (typeof this.kind !== "number") return false; if (typeof this.content !== "string") return false; if (typeof this.created_at !== "number") return false; if (typeof this.pubkey !== "string") return false; if (!this.pubkey.match(PUBKEY_REGEX)) return false; if (!Array.isArray(this.tags)) return false; for (let i = 0; i < this.tags.length; i++) { const tag = this.tags[i]; if (!Array.isArray(tag)) return false; for (let j = 0; j < tag.length; j++) { if (typeof tag[j] === "object") return false; } } return true; } var verifiedSignatures = new LRUCache({ maxSize: 1e3, entryExpirationTimeInMS: 6e4 }); function verifySignature(persist) { if (typeof this.signatureVerified === "boolean") return this.signatureVerified; const prevVerification = verifiedSignatures.get(this.id); if (prevVerification !== null) { this.signatureVerified = !!prevVerification; return this.signatureVerified; } try { if (this.ndk?.asyncSigVerification) { verifySignatureAsync(this, persist, this.relay).then((result) => { if (persist) { this.signatureVerified = result; if (result) verifiedSignatures.set(this.id, this.sig); } if (!result) { if (this.relay) { this.ndk?.reportInvalidSignature(this, this.relay); } else { this.ndk?.reportInvalidSignature(this); } verifiedSignatures.set(this.id, false); } }).catch((err) => { console.error("signature verification error", this.id, err); }); } else { const hash = sha256(new TextEncoder().encode(this.serialize())); const res = schnorr.verify(this.sig, hash, this.pubkey); if (res) verifiedSignatures.set(this.id, this.sig); else verifiedSignatures.set(this.id, false); this.signatureVerified = res; return res; } } catch (_err) { this.signatureVerified = false; return false; } } function getEventHash() { return getEventHashFromSerializedEvent(this.serialize()); } function getEventHashFromSerializedEvent(serializedEvent) { const eventHash = sha256(new TextEncoder().encode(serializedEvent)); return bytesToHex(eventHash); } // src/events/index.ts var skipClientTagOnKinds = /* @__PURE__ */ new Set([ 0 /* Metadata */, 4 /* EncryptedDirectMessage */, 1059 /* GiftWrap */, 13 /* GiftWrapSeal */, 3 /* Contacts */, 9734 /* ZapRequest */, 5 /* EventDeletion */ ]); var NDKEvent = class _NDKEvent extends EventEmitter { ndk; created_at; content = ""; tags = []; kind; id = ""; sig; pubkey = ""; signatureVerified; _author = void 0; /** * The relay that this event was first received from. */ relay; /** * The relays that this event was received from and/or successfully published to. */ get onRelays() { let res = []; if (!this.ndk) { if (this.relay) res.push(this.relay); } else { res = this.ndk.subManager.seenEvents.get(this.id) || []; } return res; } /** * The status of the publish operation. */ publishStatus = "success"; publishError; constructor(ndk, event) { super(); this.ndk = ndk; this.created_at = event?.created_at; this.content = event?.content || ""; this.tags = event?.tags || []; this.id = event?.id || ""; this.sig = event?.sig; this.pubkey = event?.pubkey || ""; this.kind = event?.kind; if (event instanceof _NDKEvent) { if (this.relay) { this.relay = event.relay; this.ndk?.subManager.seenEvent(event.id, this.relay); } this.publishStatus = event.publishStatus; this.publishError = event.publishError; } } /** * Deserialize an NDKEvent from a serialized payload. * @param ndk * @param event * @returns */ static deserialize(ndk, event) { return new _NDKEvent(ndk, deserialize(event)); } /** * Returns the event as is. */ rawEvent() { return { created_at: this.created_at, content: this.content, tags: this.tags, kind: this.kind, pubkey: this.pubkey, id: this.id, sig: this.sig }; } set author(user) { this.pubkey = user.pubkey; this._author = user; this._author.ndk ??= this.ndk; } /** * Returns an NDKUser for the author of the event. */ get author() { if (this._author) return this._author; if (!this.ndk) throw new Error("No NDK instance found"); const user = this.ndk.getUser({ pubkey: this.pubkey }); this._author = user; return user; } /** * NIP-73 tagging of external entities * @param entity to be tagged * @param type of the entity * @param markerUrl to be used as the marker URL * * @example * ```typescript * event.tagExternal("https://example.com/article/123#nostr", "url"); * event.tags => [["i", "https://example.com/123"], ["k", "https://example.com"]] * ``` * * @example tag a podcast:item:guid * ```typescript * event.tagExternal("e32b4890-b9ea-4aef-a0bf-54b787833dc5", "podcast:item:guid"); * event.tags => [["i", "podcast:item:guid:e32b4890-b9ea-4aef-a0bf-54b787833dc5"], ["k", "podcast:item:guid"]] * ``` * * @see https://github.com/nostr-protocol/nips/blob/master/73.md */ tagExternal(entity, type, markerUrl) { const iTag = ["i"]; const kTag = ["k"]; switch (type) { case "url": { const url = new URL(entity); url.hash = ""; iTag.push(url.toString()); kTag.push(`${url.protocol}//${url.host}`); break; } case "hashtag": iTag.push(`#${entity.toLowerCase()}`); kTag.push("#"); break; case "geohash": iTag.push(`geo:${entity.toLowerCase()}`); kTag.push("geo"); break; case "isbn": iTag.push(`isbn:${entity.replace(/-/g, "")}`); kTag.push("isbn"); break; case "podcast:guid": iTag.push(`podcast:guid:${entity}`); kTag.push("podcast:guid"); break; case "podcast:item:guid": iTag.push(`podcast:item:guid:${entity}`); kTag.push("podcast:item:guid"); break; case "podcast:publisher:guid": iTag.push(`podcast:publisher:guid:${entity}`); kTag.push("podcast:publisher:guid"); break; case "isan": iTag.push(`isan:${entity.split("-").slice(0, 4).join("-")}`); kTag.push("isan"); break; case "doi": iTag.push(`doi:${entity.toLowerCase()}`); kTag.push("doi"); break; default: throw new Error(`Unsupported NIP-73 entity type: ${type}`); } if (markerUrl) { iTag.push(markerUrl); } this.tags.push(iTag); this.tags.push(kTag); } /** * Tag a user with an optional marker. * @param target What is to be tagged. Can be an NDKUser, NDKEvent, or an NDKTag. * @param marker The marker to use in the tag. * @param skipAuthorTag Whether to explicitly skip adding the author tag of the event. * @param forceTag Force a specific tag to be used instead of the default "e" or "a" tag. * @param opts Optional content tagging options to control p tag behavior. * @example * ```typescript * reply.tag(opEvent, "reply"); * // reply.tags => [["e", <id>, <relay>, "reply"]] * ``` */ tag(target, marker, skipAuthorTag, forceTag, opts) { let tags = []; const isNDKUser = target.fetchProfile !== void 0; if (isNDKUser) { forceTag ??= "p"; if (forceTag === "p" && opts?.pTags === false) { return; } const tag = [forceTag, target.pubkey]; if (marker) tag.push(...["", marker]); tags.push(tag); } else if (target instanceof _NDKEvent) { const event = target; skipAuthorTag ??= event?.pubkey === this.pubkey; tags = event.referenceTags(marker, skipAuthorTag, forceTag, opts); if (opts?.pTags !== false) { for (const pTag of event.getMatchingTags("p")) { if (pTag[1] === this.pubkey) continue; if (this.tags.find((t) => t[0] === "p" && t[1] === pTag[1])) continue; this.tags.push(["p", pTag[1]]); } } } else if (Array.isArray(target)) { tags = [target]; } else { throw new Error("Invalid argument", target); } this.tags = mergeTags(this.tags, tags); } /** * Return a NostrEvent object, trying to fill in missing fields * when possible, adding tags when necessary. * @param pubkey {string} The pubkey of the user who the event belongs to. * @param opts {ContentTaggingOptions} Options for content tagging. * @returns {Promise<NostrEvent>} A promise that resolves to a NostrEvent. */ async toNostrEvent(pubkey, opts) { if (!pubkey && this.pubkey === "") { const user = await this.ndk?.signer?.user(); this.pubkey = user?.pubkey || ""; } if (!this.created_at) { this.created_at = Math.floor(Date.now() / 1e3); } const { content, tags } = await this.generateTags(opts); this.content = content || ""; this.tags = tags; try { this.id = this.getEventHash(); } catch (_e) { } return this.rawEvent(); } serialize = serialize.bind(this); getEventHash = getEventHash.bind(this); validate = validate.bind(this); verifySignature = verifySignature.bind(this); /** * Is this event replaceable (whether parameterized or not)? * * This will return true for kind 0, 3, 10k-20k and 30k-40k */ isReplaceable = isReplaceable.bind(this); isEphemeral = isEphemeral.bind(this); isDvm = () => this.kind && this.kind >= 5e3 && this.kind <= 7e3; /** * Is this event parameterized replaceable? * * This will return true for kind 30k-40k */ isParamReplaceable = isParamReplaceable.bind(this); /** * Encodes a bech32 id. * * @param relays {string[]} The relays to encode in the id * @returns {string} - Encoded naddr, note or nevent. */ encode = encode.bind(this); encrypt = encrypt.bind(this); decrypt = decrypt.bind(this); /** * Get all tags with the given name * @param tagName {string} The name of the tag to search for * @returns {NDKTag[]} An array of the matching tags */ getMatchingTags(tagName, marker) { const t = this.tags.filter((tag) => tag[0] === tagName); if (marker === void 0) return t; return t.filter((tag) => tag[3] === marker); } /** * Check if the event has a tag with the given name * @param tagName * @param marker * @returns */ hasTag(tagName, marker) { return this.tags.some((tag) => tag[0] === tagName && (!marker || tag[3] === marker)); } /** * Get the first tag with the given name * @param tagName Tag name to search for * @returns The value of the first tag with the given name, or undefined if no such tag exists */ tagValue(tagName, marker) { const tags = this.getMatchingTags(tagName, marker); if (tags.length === 0) return void 0; return tags[0][1]; } /** * Gets the NIP-31 "alt" tag of the event. */ get alt() { return this.tagValue("alt"); } /** * Sets the NIP-31 "alt" tag of the event. Use this to set an alt tag so * clients that don't handle a particular event kind can display something * useful for users. */ set alt(alt) { this.removeTag("alt"); if (alt) this.tags.push(["alt", alt]); } /** * Gets the NIP-33 "d" tag of the event. */ get dTag() { return this.tagValue("d"); } /** * Sets the NIP-33 "d" tag of the event. */ set dTag(value) { this.removeTag("d"); if (value) this.tags.push(["d", value]); } /** * Remove all tags with the given name (e.g. "d", "a", "p") * @param tagName Tag name(s) to search for and remove * @param marker Optional marker to check for too * * @example * Remove a tags with a "defer" marker * ```typescript * event.tags = [ * ["a", "....", "defer"], * ["a", "....", "no-defer"], * ] * * event.removeTag("a", "defer"); * * // event.tags => [["a", "....", "no-defer"]] * * @returns {void} */ removeTag(tagName, marker) { const tagNames = Array.isArray(tagName) ? tagName : [tagName]; this.tags = this.tags.filter((tag) => { const include = tagNames.includes(tag[0]); const hasMarker = marker ? tag[3] === marker : true; return !(include && hasMarker); }); } /** * Replace a tag with a new value. If not found, it will be added. * @param tag The tag to replace. * @param value The new value for the tag. */ replaceTag(tag) { this.removeTag(tag[0]); this.tags.push(tag); } /** * Sign the event if a signer is present. * * It will generate tags. * Repleacable events will have their created_at field set to the current time. * @param signer {NDKSigner} The NDKSigner to use to sign the event * @param opts {ContentTaggingOptions} Options for content tagging. * @returns {Promise<string>} A Promise that resolves to the signature of the signed event. */ async sign(signer, opts) { if (!signer) { this.ndk?.assertSigner(); signer = this.ndk?.signer; } else { this.author = await signer.user(); } const nostrEvent = await this.toNostrEvent(void 0, opts); this.sig = await signer.sign(nostrEvent); return this.sig; } /** * * @param relaySet * @param timeoutMs * @param requiredRelayCount * @returns */ async publishReplaceable(relaySet, timeoutMs, requiredRelayCount) { this.id = ""; this.created_at = Math.floor(Date.now() / 1e3); this.sig = ""; return this.publish(relaySet, timeoutMs, requiredRelayCount); } /** * Attempt to sign and then publish an NDKEvent to a g