@nostr-dev-kit/ndk
Version:
NDK - Nostr Development Kit
1,522 lines (1,500 loc) • 311 kB
JavaScript
// 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