UNPKG

@nostr-dev-kit/ndk

Version:

NDK - Nostr Development Kit. Includes AI Guardrails to catch common mistakes during development.

1,486 lines (1,437 loc) 430 kB
var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default")); // src/ndk/index.ts import debug8 from "debug"; import { nip19 as nip198 } from "nostr-tools"; import { EventEmitter as EventEmitter9 } from "tseep"; // src/ai-guardrails/event/signing.ts function checkMissingKind(event, error) { if (event.kind === void 0 || event.kind === null) { error( "event-missing-kind", `Cannot sign event without 'kind'. \u{1F4E6} Event data: \u2022 content: ${event.content ? `"${event.content.substring(0, 50)}${event.content.length > 50 ? "..." : ""}"` : "(empty)"} \u2022 tags: ${event.tags.length} tag${event.tags.length !== 1 ? "s" : ""} \u2022 kind: ${event.kind} \u274C Set event.kind before signing.`, "Example: event.kind = 1; // for text note", false // Fatal error - cannot be disabled ); } } function checkContentIsObject(event, error) { if (typeof event.content === "object") { const contentPreview = JSON.stringify(event.content, null, 2).substring(0, 200); error( "event-content-is-object", `Event content is an object. Content must be a string. \u{1F4E6} Your content (${typeof event.content}): ${contentPreview}${JSON.stringify(event.content).length > 200 ? "..." : ""} \u274C event.content = { ... } // WRONG \u2705 event.content = JSON.stringify({ ... }) // CORRECT`, "Use JSON.stringify() for structured data: event.content = JSON.stringify(data)", false // Fatal error - cannot be disabled ); } } function checkCreatedAtMilliseconds(event, error) { if (event.created_at && event.created_at > 1e10) { const correctValue = Math.floor(event.created_at / 1e3); const dateString = new Date(event.created_at).toISOString(); error( "event-created-at-milliseconds", `Event created_at is in milliseconds, not seconds. \u{1F4E6} Your value: \u2022 created_at: ${event.created_at} \u274C \u2022 Interpreted as: ${dateString} \u2022 Should be: ${correctValue} \u2705 Nostr timestamps MUST be in seconds since Unix epoch.`, "Use Math.floor(Date.now() / 1000) instead of Date.now()", false // Fatal error - cannot be disabled ); } } function checkInvalidPTags(event, error) { const pTags = event.getMatchingTags("p"); pTags.forEach((tag, idx) => { if (tag[1] && !/^[0-9a-f]{64}$/i.test(tag[1])) { const tagPreview = JSON.stringify(tag); error( "tag-invalid-p-tag", `p-tag[${idx}] has invalid pubkey. \u{1F4E6} Your tag: ${tagPreview} \u274C Invalid value: "${tag[1]}" \u2022 Length: ${tag[1].length} (expected 64) \u2022 Format: ${tag[1].startsWith("npub") ? "bech32 (npub)" : "unknown"} p-tags MUST contain 64-character hex pubkeys.`, tag[1].startsWith("npub") ? "Use ndkUser.pubkey instead of npub:\n \u2705 event.tags.push(['p', ndkUser.pubkey])\n \u274C event.tags.push(['p', 'npub1...'])" : "p-tags must contain valid hex pubkeys (64 characters, 0-9a-f)", false // Fatal error - cannot be disabled ); } }); } function checkInvalidETags(event, error) { const eTags = event.getMatchingTags("e"); eTags.forEach((tag, idx) => { if (tag[1] && !/^[0-9a-f]{64}$/i.test(tag[1])) { const tagPreview = JSON.stringify(tag); const isBech32 = tag[1].startsWith("note") || tag[1].startsWith("nevent"); error( "tag-invalid-e-tag", `e-tag[${idx}] has invalid event ID. \u{1F4E6} Your tag: ${tagPreview} \u274C Invalid value: "${tag[1]}" \u2022 Length: ${tag[1].length} (expected 64) \u2022 Format: ${isBech32 ? "bech32 (note/nevent)" : "unknown"} e-tags MUST contain 64-character hex event IDs.`, isBech32 ? "Use event.id instead of bech32:\n \u2705 event.tags.push(['e', referencedEvent.id])\n \u274C event.tags.push(['e', 'note1...'])" : "e-tags must contain valid hex event IDs (64 characters, 0-9a-f)", false // Fatal error - cannot be disabled ); } }); } function checkManualReplyMarkers(event, warn, replyEvents) { if (event.kind !== 1) return; if (replyEvents.has(event)) return; const eTagsWithMarkers = event.tags.filter((tag) => tag[0] === "e" && (tag[3] === "reply" || tag[3] === "root")); if (eTagsWithMarkers.length > 0) { const tagList = eTagsWithMarkers.map((tag, idx) => ` ${idx + 1}. ${JSON.stringify(tag)}`).join("\n"); warn( "event-manual-reply-markers", `Event has ${eTagsWithMarkers.length} e-tag(s) with manual reply/root markers. \u{1F4E6} Your tags with markers: ${tagList} \u26A0\uFE0F Manual reply markers detected! This will cause incorrect threading.`, `Reply events MUST be created using .reply(): \u2705 CORRECT: const replyEvent = originalEvent.reply(); replyEvent.content = 'good point!'; await replyEvent.publish(); \u274C WRONG: event.tags.push(['e', eventId, '', 'reply']); NDK handles all reply threading automatically - never add reply/root markers manually.` ); } } function checkHashtagsWithPrefix(event, error) { const tTags = event.getMatchingTags("t"); tTags.forEach((tag, idx) => { if (tag[1] && tag[1].startsWith("#")) { const tagPreview = JSON.stringify(tag); error( "tag-hashtag-with-prefix", `t-tag[${idx}] contains hashtag with # prefix. \u{1F4E6} Your tag: ${tagPreview} \u274C Invalid value: "${tag[1]}" Hashtag tags should NOT include the # symbol.`, `Remove the # prefix from hashtag tags: \u2705 event.tags.push(['t', 'nostr']) \u274C event.tags.push(['t', '#nostr'])`, false // Fatal error - cannot be disabled ); } }); } function checkReplaceableWithOldTimestamp(event, warn) { if (event.kind === void 0 || event.kind === null || !event.created_at) return; if (!event.isReplaceable()) return; const nowSeconds = Math.floor(Date.now() / 1e3); const ageSeconds = nowSeconds - event.created_at; const TEN_SECONDS = 10; if (ageSeconds > TEN_SECONDS) { const ageMinutes = Math.floor(ageSeconds / 60); const ageDescription = ageMinutes > 0 ? `${ageMinutes} minute${ageMinutes !== 1 ? "s" : ""}` : `${ageSeconds} seconds`; warn( "event-replaceable-old-timestamp", `Publishing a replaceable event with an old created_at timestamp. \u{1F4E6} Event details: \u2022 kind: ${event.kind} (replaceable) \u2022 created_at: ${event.created_at} \u2022 age: ${ageDescription} old \u2022 current time: ${nowSeconds} \u26A0\uFE0F This is wrong and will be rejected by relays.`, `For replaceable events, use publishReplaceable(): \u2705 CORRECT: await event.publishReplaceable(); // Automatically updates created_at to now \u274C WRONG: await event.publish(); // Uses old created_at` ); } } function signing(event, error, warn, replyEvents) { checkMissingKind(event, error); checkContentIsObject(event, error); checkCreatedAtMilliseconds(event, error); checkInvalidPTags(event, error); checkInvalidETags(event, error); checkHashtagsWithPrefix(event, error); checkManualReplyMarkers(event, warn, replyEvents); } function publishing(event, warn) { checkReplaceableWithOldTimestamp(event, warn); } // src/ai-guardrails/ndk/fetch-events.ts function isNip33Pattern(filters) { const filterArray = Array.isArray(filters) ? filters : [filters]; if (filterArray.length !== 1) return false; const filter = filterArray[0]; return filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length === 1 && filter.authors && Array.isArray(filter.authors) && filter.authors.length === 1 && filter["#d"] && Array.isArray(filter["#d"]) && filter["#d"].length === 1; } function isReplaceableEventFilter(filters) { const filterArray = Array.isArray(filters) ? filters : [filters]; if (filterArray.length === 0) { return false; } return filterArray.every((filter) => { if (!filter.kinds || !Array.isArray(filter.kinds) || filter.kinds.length === 0) { return false; } if (!filter.authors || !Array.isArray(filter.authors) || filter.authors.length === 0) { return false; } const allKindsReplaceable = filter.kinds.every((kind) => { return kind === 0 || kind === 3 || kind >= 1e4 && kind <= 19999; }); return allKindsReplaceable; }); } function formatFilter(filter) { const formatted = JSON.stringify(filter, null, 2); return formatted.split("\n").map((line, idx) => idx === 0 ? line : ` ${line}`).join("\n"); } function fetchingEvents(filters, opts, warn, shouldWarnRatio, incrementCount) { incrementCount(); if (opts?.cacheUsage === "ONLY_CACHE") { return; } const filterArray = Array.isArray(filters) ? filters : [filters]; const formattedFilters = filterArray.map(formatFilter).join("\n\n ---\n\n "); if (isNip33Pattern(filters)) { const filter = filterArray[0]; warn( "fetch-events-usage", "For fetching a NIP-33 addressable event, use fetchEvent() with the naddr directly.\n\n\u{1F4E6} Your filter:\n " + formattedFilters + ` \u274C BAD: const decoded = nip19.decode(naddr); const events = await ndk.fetchEvents({ kinds: [decoded.data.kind], authors: [decoded.data.pubkey], "#d": [decoded.data.identifier] }); const event = Array.from(events)[0]; \u2705 GOOD: const event = await ndk.fetchEvent(naddr); \u2705 GOOD: const event = await ndk.fetchEvent('naddr1...'); fetchEvent() handles naddr decoding automatically and returns the event directly.` ); } else if (isReplaceableEventFilter(filters)) { return; } else { if (!shouldWarnRatio()) { return; } let filterAnalysis = ""; const hasLimit = filterArray.some((f) => f.limit !== void 0); const totalKinds = new Set(filterArray.flatMap((f) => f.kinds || [])).size; const totalAuthors = new Set(filterArray.flatMap((f) => f.authors || [])).size; if (hasLimit) { const maxLimit = Math.max(...filterArray.map((f) => f.limit || 0)); filterAnalysis += ` \u2022 Limit: ${maxLimit} event${maxLimit !== 1 ? "s" : ""}`; } if (totalKinds > 0) { filterAnalysis += ` \u2022 Kinds: ${totalKinds} type${totalKinds !== 1 ? "s" : ""}`; } if (totalAuthors > 0) { filterAnalysis += ` \u2022 Authors: ${totalAuthors} author${totalAuthors !== 1 ? "s" : ""}`; } warn( "fetch-events-usage", "fetchEvents() is a BLOCKING operation that waits for EOSE.\nIn most cases, you should use subscribe() instead.\n\n\u{1F4E6} Your filter" + (filterArray.length > 1 ? "s" : "") + ":\n " + formattedFilters + (filterAnalysis ? "\n\n\u{1F4CA} Filter analysis:" + filterAnalysis : "") + "\n\n \u274C BAD: const events = await ndk.fetchEvents(filter);\n \u2705 GOOD: ndk.subscribe(filter, { onEvent: (e) => ... });\n\nOnly use fetchEvents() when you MUST block until data arrives.", "For one-time queries, use fetchEvent() instead of fetchEvents() when expecting a single result." ); } } // src/ai-guardrails/types.ts var GuardrailCheckId = { // NDK lifecycle NDK_NO_CACHE: "ndk-no-cache", // Filter-related FILTER_BECH32_IN_ARRAY: "filter-bech32-in-array", FILTER_INVALID_HEX: "filter-invalid-hex", FILTER_ONLY_LIMIT: "filter-only-limit", FILTER_LARGE_LIMIT: "filter-large-limit", FILTER_EMPTY: "filter-empty", FILTER_SINCE_AFTER_UNTIL: "filter-since-after-until", FILTER_INVALID_A_TAG: "filter-invalid-a-tag", FILTER_HASHTAG_WITH_PREFIX: "filter-hashtag-with-prefix", // fetchEvents anti-pattern FETCH_EVENTS_USAGE: "fetch-events-usage", // Event construction EVENT_MISSING_KIND: "event-missing-kind", EVENT_PARAM_REPLACEABLE_NO_DTAG: "event-param-replaceable-no-dtag", EVENT_CREATED_AT_MILLISECONDS: "event-created-at-milliseconds", EVENT_NO_NDK_INSTANCE: "event-no-ndk-instance", EVENT_CONTENT_IS_OBJECT: "event-content-is-object", EVENT_MODIFIED_AFTER_SIGNING: "event-modified-after-signing", EVENT_MANUAL_REPLY_MARKERS: "event-manual-reply-markers", // Tag construction TAG_E_FOR_PARAM_REPLACEABLE: "tag-e-for-param-replaceable", TAG_BECH32_VALUE: "tag-bech32-value", TAG_DUPLICATE: "tag-duplicate", TAG_INVALID_P_TAG: "tag-invalid-p-tag", TAG_INVALID_E_TAG: "tag-invalid-e-tag", TAG_HASHTAG_WITH_PREFIX: "tag-hashtag-with-prefix", // Subscription SUBSCRIBE_NOT_STARTED: "subscribe-not-started", SUBSCRIBE_CLOSE_ON_EOSE_NO_HANDLER: "subscribe-close-on-eose-no-handler", SUBSCRIBE_PASSED_EVENT_NOT_FILTER: "subscribe-passed-event-not-filter", SUBSCRIBE_AWAITED: "subscribe-awaited", // Relay RELAY_INVALID_URL: "relay-invalid-url", RELAY_HTTP_INSTEAD_OF_WS: "relay-http-instead-of-ws", RELAY_NO_ERROR_HANDLERS: "relay-no-error-handlers", // Validation VALIDATION_PUBKEY_IS_NPUB: "validation-pubkey-is-npub", VALIDATION_PUBKEY_WRONG_LENGTH: "validation-pubkey-wrong-length", VALIDATION_EVENT_ID_IS_BECH32: "validation-event-id-is-bech32", VALIDATION_EVENT_ID_WRONG_LENGTH: "validation-event-id-wrong-length" }; // src/ai-guardrails/ndk.ts function checkCachePresence(ndk, shouldCheck) { if (!shouldCheck(GuardrailCheckId.NDK_NO_CACHE)) return; setTimeout(() => { if (!ndk.cacheAdapter) { const isBrowser = typeof window !== "undefined"; const suggestion = isBrowser ? "Consider using @nostr-dev-kit/ndk-cache-dexie or @nostr-dev-kit/ndk-cache-sqlite-wasm" : "Consider using @nostr-dev-kit/ndk-cache-redis or @nostr-dev-kit/ndk-cache-sqlite"; const message = ` \u{1F916} AI_GUARDRAILS WARNING: NDK initialized without a cache adapter. Apps perform significantly better with caching. \u{1F4A1} ${suggestion} \u{1F507} To disable this check: ndk.aiGuardrails.skip('${GuardrailCheckId.NDK_NO_CACHE}') or set: ndk.aiGuardrails = { skip: new Set(['${GuardrailCheckId.NDK_NO_CACHE}']) }`; console.warn(message); } }, 2500); } // src/ai-guardrails/index.ts var AIGuardrails = class { enabled = false; skipSet = /* @__PURE__ */ new Set(); extensions = /* @__PURE__ */ new Map(); _nextCallDisabled = null; _replyEvents = /* @__PURE__ */ new WeakSet(); _fetchEventsCount = 0; _subscribeCount = 0; constructor(mode = false) { this.setMode(mode); } /** * Register an extension namespace with custom guardrail hooks. * This allows external packages to add their own guardrails. * * @example * ```typescript * // In NDKSvelte package: * ndk.aiGuardrails.register('ndkSvelte', { * constructing: (params) => { * if (!params.session) { * warn('ndksvelte-no-session', 'NDKSvelte instantiated without session parameter...'); * } * } * }); * * // In NDKSvelte constructor: * this.ndk.aiGuardrails?.ndkSvelte?.constructing(params); * ``` */ register(namespace, hooks) { if (this.extensions.has(namespace)) { console.warn(`AIGuardrails: Extension '${namespace}' already registered, overwriting`); } const wrappedHooks = {}; for (const [key, fn] of Object.entries(hooks)) { if (typeof fn === "function") { wrappedHooks[key] = (...args) => { if (!this.enabled) return; fn(...args, this.shouldCheck.bind(this), this.error.bind(this), this.warn.bind(this)); }; } } this.extensions.set(namespace, wrappedHooks); this[namespace] = wrappedHooks; } /** * Set the guardrails mode. */ setMode(mode) { if (typeof mode === "boolean") { this.enabled = mode; this.skipSet.clear(); } else if (mode && typeof mode === "object") { this.enabled = true; this.skipSet = mode.skip || /* @__PURE__ */ new Set(); } } /** * Check if guardrails are enabled at all. */ isEnabled() { return this.enabled; } /** * Check if a specific guardrail check should run. */ shouldCheck(id) { if (!this.enabled) return false; if (this.skipSet.has(id)) return false; if (this._nextCallDisabled === "all") return false; if (this._nextCallDisabled && this._nextCallDisabled.has(id)) return false; return true; } /** * Disable a specific guardrail check. */ skip(id) { this.skipSet.add(id); } /** * Re-enable a specific guardrail check. */ enable(id) { this.skipSet.delete(id); } /** * Get all currently skipped guardrails. */ getSkipped() { return Array.from(this.skipSet); } /** * Capture the current _nextCallDisabled set and clear it atomically. * This is used by hook methods to handle one-time guardrail disabling. */ captureAndClearNextCallDisabled() { const captured = this._nextCallDisabled; this._nextCallDisabled = null; return captured; } /** * Increment fetchEvents call counter for ratio tracking. */ incrementFetchEventsCount() { this._fetchEventsCount++; } /** * Increment subscribe call counter for ratio tracking. */ incrementSubscribeCount() { this._subscribeCount++; } /** * Check if fetchEvents usage ratio exceeds the threshold. * Returns true if more than 50% of calls are fetchEvents AND total calls > 6. */ shouldWarnAboutFetchEventsRatio() { const totalCalls = this._fetchEventsCount + this._subscribeCount; if (totalCalls <= 6) { return false; } const ratio = this._fetchEventsCount / totalCalls; return ratio > 0.5; } /** * Throw an error if the check should run. * Also logs to console.error in case the throw gets swallowed. * @param canDisable - If false, this is a fatal error that cannot be disabled (default: true) */ error(id, message, hint, canDisable = true) { if (!this.shouldCheck(id)) return; const fullMessage = this.formatMessage(id, "ERROR", message, hint, canDisable); console.error(fullMessage); throw new Error(fullMessage); } /** * Throw a warning if the check should run. * Also logs to console.error in case the throw gets swallowed. * Warnings can always be disabled. */ warn(id, message, hint) { if (!this.shouldCheck(id)) return; const fullMessage = this.formatMessage(id, "WARNING", message, hint, true); console.error(fullMessage); throw new Error(fullMessage); } /** * Format a guardrail message with helpful metadata. */ formatMessage(id, level, message, hint, canDisable = true) { let output = ` \u{1F916} AI_GUARDRAILS ${level}: ${message}`; if (hint) { output += ` \u{1F4A1} ${hint}`; } if (canDisable) { output += ` \u{1F507} To disable this check: ndk.guardrailOff('${id}').yourMethod() // For one call`; output += ` ndk.aiGuardrails.skip('${id}') // Permanently`; output += ` or set: ndk.aiGuardrails = { skip: new Set(['${id}']) }`; } return output; } // ============================================================================ // Hook Methods - Type-safe, domain-organized insertion points // ============================================================================ /** * Called when NDK instance is created. * Checks for cache presence and other initialization concerns. */ ndkInstantiated(ndk) { if (!this.enabled) return; checkCachePresence(ndk, this.shouldCheck.bind(this)); } /** * NDK-related guardrails */ ndk = { /** * Called when fetchEvents is about to be called */ fetchingEvents: (filters, opts) => { if (!this.enabled) return; fetchingEvents( filters, opts, this.warn.bind(this), this.shouldWarnAboutFetchEventsRatio.bind(this), this.incrementFetchEventsCount.bind(this) ); } }; /** * Event-related guardrails */ event = { /** * Called when an event is about to be signed */ signing: (event) => { if (!this.enabled) return; signing(event, this.error.bind(this), this.warn.bind(this), this._replyEvents); }, /** * Called before an event is published */ publishing: (event) => { if (!this.enabled) return; publishing(event, this.warn.bind(this)); }, /** * Called when an event is received from a relay */ received: (_event, _relay) => { if (!this.enabled) return; }, /** * Called when a reply event is being created via .reply() * This allows guardrails to track legitimate reply events */ creatingReply: (event) => { if (!this.enabled) return; this._replyEvents.add(event); } }; /** * Subscription-related guardrails */ subscription = { /** * Called when a subscription is created */ created: (_filters, _opts) => { if (!this.enabled) return; this.incrementSubscribeCount(); } }; /** * Relay-related guardrails */ relay = { /** * Called when a relay connection is established */ connected: (_relay) => { if (!this.enabled) return; } }; }; // src/events/dedup.ts function dedup(event1, event2) { if (event1.created_at > event2.created_at) { return event1; } return event2; } // 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; const addedRelaysForAuthor = /* @__PURE__ */ new Set(); for (const relay of connectedRelays) { if (authorRelays.has(relay.url)) { addAuthorToRelay(author, relay.url); addedRelaysForAuthor.add(relay.url); missingRelayCount--; } } for (const authorRelay of authorRelays) { if (addedRelaysForAuthor.has(authorRelay)) continue; if (relayToAuthorsMap.has(authorRelay)) { addAuthorToRelay(author, authorRelay); addedRelaysForAuthor.add(authorRelay); missingRelayCount--; } } if (missingRelayCount <= 0) continue; for (const relay of sortedRelays) { if (missingRelayCount <= 0) break; if (addedRelaysForAuthor.has(relay)) continue; if (authorRelays.has(relay)) { addAuthorToRelay(author, relay); addedRelaysForAuthor.add(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/count/index.ts var HLL_REGISTER_COUNT = 256; var NDKCountHll = class _NDKCountHll { /** * The 256 uint8 registers used for HLL estimation */ registers; constructor(registers) { if (registers) { if (registers.length !== HLL_REGISTER_COUNT) { throw new Error(`HLL must have exactly ${HLL_REGISTER_COUNT} registers, got ${registers.length}`); } this.registers = registers; } else { this.registers = new Uint8Array(HLL_REGISTER_COUNT); } } /** * Creates an NDKCountHll from a hex-encoded string (512 characters). * Each register is a uint8 value encoded as 2 hex characters. * * @param hex - The hex string (512 characters = 256 bytes) * @returns A new NDKCountHll instance * @throws Error if the hex string is invalid */ static fromHex(hex) { if (hex.length !== HLL_REGISTER_COUNT * 2) { throw new Error(`HLL hex string must be ${HLL_REGISTER_COUNT * 2} characters, got ${hex.length}`); } const registers = new Uint8Array(HLL_REGISTER_COUNT); for (let i = 0; i < HLL_REGISTER_COUNT; i++) { registers[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); } return new _NDKCountHll(registers); } /** * Converts the HLL registers to a hex-encoded string. * * @returns The hex string representation (512 characters) */ toHex() { return Array.from(this.registers).map((v) => v.toString(16).padStart(2, "0")).join(""); } /** * Merges this HLL with another HLL by taking the maximum value for each register. * This is the standard HLL merge operation that allows combining counts * from multiple relays without double-counting. * * @param other - The other HLL to merge with * @returns A new NDKCountHll with the merged registers */ merge(other) { const merged = new Uint8Array(HLL_REGISTER_COUNT); for (let i = 0; i < HLL_REGISTER_COUNT; i++) { merged[i] = Math.max(this.registers[i], other.registers[i]); } return new _NDKCountHll(merged); } /** * Merges multiple HLLs by taking the maximum value for each register. * * @param hlls - Array of HLLs to merge * @returns A new NDKCountHll with the merged registers */ static merge(hlls) { if (hlls.length === 0) { return new _NDKCountHll(); } const merged = new Uint8Array(HLL_REGISTER_COUNT); for (let i = 0; i < HLL_REGISTER_COUNT; i++) { merged[i] = Math.max(...hlls.map((hll) => hll.registers[i])); } return new _NDKCountHll(merged); } /** * Estimates the cardinality (unique count) using the HyperLogLog algorithm. * * Uses the standard HLL formula with bias correction for small and large cardinalities. * * @returns The estimated unique count */ estimate() { const m = HLL_REGISTER_COUNT; const alpha = 0.7213 / (1 + 1.079 / m); let sum = 0; let zeros = 0; for (let i = 0; i < m; i++) { sum += Math.pow(2, -this.registers[i]); if (this.registers[i] === 0) { zeros++; } } let estimate = alpha * m * m / sum; if (estimate <= 2.5 * m && zeros > 0) { estimate = m * Math.log(m / zeros); } return Math.round(estimate); } /** * Checks if this HLL is empty (all registers are zero). * * @returns True if all registers are zero */ isEmpty() { return this.registers.every((v) => v === 0); } /** * Creates a copy of this HLL. * * @returns A new NDKCountHll with the same register values */ clone() { return new _NDKCountHll(new Uint8Array(this.registers)); } }; // src/relay/index.ts import debug2 from "debug"; import { EventEmitter } from "tseep"; // src/relay/keepalive.ts var NDKRelayKeepalive = class { /** * @param timeout - Time in milliseconds to wait before considering connection stale (default 30s) * @param onSilenceDetected - Callback when silence is detected */ constructor(timeout = 3e4, onSilenceDetected) { this.onSilenceDetected = onSilenceDetected; this.timeout = timeout; } lastActivity = Date.now(); timer; timeout; isRunning = false; /** * Records activity from the relay, resetting the silence timer */ recordActivity() { this.lastActivity = Date.now(); if (this.isRunning) { this.resetTimer(); } } /** * Starts monitoring for relay silence */ start() { if (this.isRunning) return; this.isRunning = true; this.lastActivity = Date.now(); this.resetTimer(); } /** * Stops monitoring for relay silence */ stop() { this.isRunning = false; if (this.timer) { clearTimeout(this.timer); this.timer = void 0; } } resetTimer() { if (this.timer) { clearTimeout(this.timer); } this.timer = setTimeout(() => { const silenceTime = Date.now() - this.lastActivity; if (silenceTime >= this.timeout) { this.onSilenceDetected(); } else { const remainingTime = this.timeout - silenceTime; this.timer = setTimeout(() => { this.onSilenceDetected(); }, remainingTime); } }, this.timeout); } }; async function probeRelayConnection(relay) { const probeId = `probe-${Math.random().toString(36).substring(7)}`; return new Promise((resolve) => { let responded = false; const timeout = setTimeout(() => { if (!responded) { responded = true; relay.send(["CLOSE", probeId]); resolve(false); } }, 5e3); const handler = () => { if (!responded) { responded = true; clearTimeout(timeout); relay.send(["CLOSE", probeId]); resolve(true); } }; relay.once("message", handler); relay.send([ "REQ", probeId, { kinds: [99999], limit: 0 } ]); }); } // src/relay/connectivity.ts 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(); pendingAuthPublishes = /* @__PURE__ */ new Map(); serial = 0; baseEoseTimeout = 4400; // Keepalive and monitoring keepalive; wsStateMonitor; sleepDetector; lastSleepCheck = Date.now(); lastMessageSent = Date.now(); wasIdle = false; 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; this.setupMonitoring(); } /** * Sets up keepalive, WebSocket state monitoring, and sleep detection */ setupMonitoring() { this.keepalive = new NDKRelayKeepalive(12e4, async () => { this.debug("Relay silence detected, probing connection"); const isAlive = await probeRelayConnection({ send: (msg) => this.send(JSON.stringify(msg)), once: (event, handler) => { const messageHandler = (e) => { try { const data = JSON.parse(e.data); if (data[0] === "EOSE" || data[0] === "EVENT" || data[0] === "NOTICE") { handler(); this.ws?.removeEventListener("message", messageHandler); } } catch { } }; this.ws?.addEventListener("message", messageHandler); } }); if (!isAlive) { this.debug("Probe failed, connection is stale"); this.handleStaleConnection(); } }); this.wsStateMonitor = setInterval(() => { if (this._status === 5 /* CONNECTED */) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { this.debug("WebSocket died silently, reconnecting"); this.handleStaleConnection(); } } }, 5e3); this.sleepDetector = setInterval(() => { const now = Date.now(); const elapsed = now - this.lastSleepCheck; if (elapsed > 15e3) { this.debug(`Detected possible sleep/wake (${elapsed}ms gap)`); this.handlePossibleWake(); } this.lastSleepCheck = now; }, 1e4); } /** * Handles detection of a stale connection by cleaning up and triggering reconnection. */ handleStaleConnection() { this.wasIdle = true; this.keepalive?.stop(); if (this.ws) { try { this.ws.close(); } catch (e) { } this.ws = void 0; } this._status = 1 /* DISCONNECTED */; this.ndkRelay.emit("disconnect"); this.handleReconnection(); } /** * Handles possible system wake event */ handlePossibleWake() { this.debug("System wake detected, checking all connections"); this.wasIdle = true; if (this._status >= 5 /* CONNECTED */) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { this.handleStaleConnection(); } else { probeRelayConnection({ send: (msg) => this.send(JSON.stringify(msg)), once: (event, handler) => { const messageHandler = (e) => { try { const data = JSON.parse(e.data); if (data[0] === "EOSE" || data[0] === "EVENT" || data[0] === "NOTICE") { handler(); this.ws?.removeEventListener("message", messageHandler); } } catch { } }; this.ws?.addEventListener("message", messageHandler); } }).then((isAlive) => { if (!isAlive) { this.handleStaleConnection(); } }); } } } /** * Resets the reconnection state for system-wide events * Used by NDKPool when detecting system sleep/wake */ resetReconnectionState() { this.wasIdle = true; if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = void 0; } } /** * 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.ws && this.ws.readyState !== WebSocket.OPEN && this.ws.readyState !== WebSocket.CONNECTING) { this.debug("Cleaning up stale WebSocket connection"); try { this.ws.close(); } catch (e) { } this.ws = void 0; this._status = 1 /* DISCONNECTED */; } if (this._status !== 2 /* RECONNECTING */ && this._status !== 1 /* DISCONNECTED */ || this.reconnectTimeout) { 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 */; this.keepalive?.stop(); if (this.wsStateMonitor) { clearInterval(this.wsStateMonitor); this.wsStateMonitor = void 0; } if (this.sleepDetector) { clearInterval(this.sleepDetector); this.sleepDetector = void 0; } 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.keepalive?.start(); this.wasIdle = false; 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(); this.keepalive?.stop(); this.clearPendingPublishes(new Error(`Relay ${this.ndkRelay.url} 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"); this.keepalive?.recordActivity(); try { const data = JSON.parse(event.data); const [cmd, id, ..._rest] = data; const handler = this.ndkRelay.getProtocolHandler(cmd); if (handler) { handler(thi