Merge pull request #2260 from PR0M3TH3AN/refactor/nostr-client-managers-15317011398012543442

Refactor NostrClient into specialized managers
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-08 15:52:16 -05:00
commit 2e0d86eb50
7 files changed
+2776 -3237

No files matched your search

+146 -3236
View File
File diff suppressed because it is too large. Load diff
+963
View File
@@ -0,0 +1,963 @@
import {
DEFAULT_RELAY_URLS,
RELAY_URLS,
ensureNostrTools,
resolveSimplePoolConstructor,
shimLegacySimplePoolMethods,
nostrToolsBootstrapFailure,
} from "../toolkit.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { isDevMode } from "../../config.js";
import { sanitizeRelayList } from "../nip46Client.js";
import {
logCountTimeoutCleanupFailure,
logRelayCountFailure,
} from "../countDiagnostics.js";
const RELAY_CONNECT_TIMEOUT_MS = 5000;
const RELAY_RECONNECT_BASE_DELAY_MS = 2000;
const RELAY_RECONNECT_MAX_DELAY_MS = 60000;
const RELAY_RECONNECT_MAX_ATTEMPTS = 5;
const RELAY_BACKOFF_BASE_DELAY_MS = 1000;
const RELAY_BACKOFF_MAX_DELAY_MS = 8000;
const RELAY_CIRCUIT_BREAKER_THRESHOLD = 3;
const RELAY_CIRCUIT_BREAKER_COOLDOWN_MS = 10 * 60 * 1000;
const RELAY_FAILURE_WINDOW_MS = 5 * 60 * 1000;
const RELAY_FAILURE_WINDOW_THRESHOLD = 3;
const RELAY_SUMMARY_LOG_INTERVAL_MS = 30000;
function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
const resolvedTimeout = Number(timeoutMs);
const effectiveTimeout =
Number.isFinite(resolvedTimeout) && resolvedTimeout > 0
? Math.floor(resolvedTimeout)
: 4000;
let timeoutId = null;
let settled = false;
return new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
if (settled) {
return;
}
settled = true;
if (typeof onTimeout === "function") {
try {
onTimeout();
} catch (cleanupError) {
logCountTimeoutCleanupFailure(cleanupError);
}
}
reject(new Error(message));
}, effectiveTimeout);
Promise.resolve(promise)
.then((value) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(value);
})
.catch((error) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
reject(error);
});
});
}
export class ConnectionManager {
constructor() {
this.pool = null;
this.poolPromise = null;
this.relays = sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
this.readRelays = Array.from(this.relays);
this.writeRelays = Array.from(this.relays);
this.countUnsupportedRelays = new Set();
this.unreachableRelays = new Set();
this.relayBackoff = new Map();
this.relayFailureCounts = new Map();
this.relayFailureWindows = new Map();
this.relayCircuitBreakers = new Map();
this.relaySummaryLogTimestamps = new Map();
this.relayReconnectTimer = null;
this.relayReconnectAttempt = 0;
this.countRequestCounter = 0;
}
async ensurePool() {
if (this.pool) {
return this.pool;
}
if (this.poolPromise) {
return this.poolPromise;
}
const tools = await ensureNostrTools();
const SimplePool = resolveSimplePoolConstructor(tools);
if (typeof SimplePool !== "function") {
if (tools && typeof tools === "object") {
const availableKeys = Object.keys(tools).join(", ");
devLogger.warn(
"[nostr] NostrTools helpers did not expose SimplePool. Available keys:",
availableKeys
);
} else {
userLogger.warn(
"[nostr] NostrTools helpers were unavailable. Check that nostr-tools bundles can load on this domain."
);
}
if (nostrToolsBootstrapFailure) {
userLogger.warn(
"[nostr] nostr-tools bootstrap failure details:",
nostrToolsBootstrapFailure
);
}
const error = new Error(
"NostrTools SimplePool is unavailable. Verify that nostr-tools resources can load on this domain."
);
error.code = "nostr-simplepool-unavailable";
if (nostrToolsBootstrapFailure) {
error.bootstrapFailure = nostrToolsBootstrapFailure;
}
this.poolPromise = null;
throw error;
}
const creation = Promise.resolve().then(() => {
const instance = new SimplePool();
if (typeof instance.ensureRelay === "function") {
const originalEnsureRelay = instance.ensureRelay.bind(instance);
instance.ensureRelay = async (url) => {
const relay = await originalEnsureRelay(url);
if (relay && typeof relay.setMaxListeners === "function") {
try {
relay.setMaxListeners(200);
} catch (error) {
// ignore
}
}
return relay;
};
} else {
devLogger.warn(
"[nostr] SimplePool.ensureRelay missing; max listeners patch skipped."
);
}
shimLegacySimplePoolMethods(instance);
this.pool = instance;
return instance;
});
const shared = creation
.then((instance) => {
this.poolPromise = Promise.resolve(instance);
return instance;
})
.catch((error) => {
this.poolPromise = null;
throw error;
});
this.poolPromise = shared;
return shared;
}
async connectToRelays() {
const relayTargets = this.getHealthyRelays(this.relays);
if (!relayTargets.length) {
return [];
}
const results = await Promise.all(
relayTargets.map(
(url) =>
new Promise((resolve) => {
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
const timeout = setTimeout(() => {
sub.unsub();
resolve({ url, success: false });
}, RELAY_CONNECT_TIMEOUT_MS);
const succeed = () => {
clearTimeout(timeout);
sub.unsub();
resolve({ url, success: true });
};
sub.on("event", succeed);
sub.on("eose", succeed);
})
)
);
for (const result of results) {
if (result.success) {
this.clearRelayBackoff(result.url);
} else {
this.markRelayUnreachable(result.url, 60000, {
reason: "connect-timeout",
});
if (isDevMode) {
devLogger.warn(`[nostr] Marked relay as unreachable: ${result.url}`);
}
}
}
return results;
}
resolveRelayReconnectDelayMs(attempt) {
const safeAttempt = Number.isFinite(attempt) ? Math.max(0, attempt) : 0;
const computed = RELAY_RECONNECT_BASE_DELAY_MS * Math.pow(2, safeAttempt);
return Math.min(RELAY_RECONNECT_MAX_DELAY_MS, computed);
}
resetRelayReconnectState() {
this.relayReconnectAttempt = 0;
if (this.relayReconnectTimer) {
clearTimeout(this.relayReconnectTimer);
this.relayReconnectTimer = null;
}
}
scheduleRelayReconnect({ reason = "retry" } = {}) {
if (this.relayReconnectTimer) {
return;
}
if (this.relayReconnectAttempt >= RELAY_RECONNECT_MAX_ATTEMPTS) {
if (isDevMode) {
devLogger.debug(
"[nostr] Relay reconnect attempts exhausted.",
{
attempts: this.relayReconnectAttempt,
reason,
},
);
}
return;
}
const delayMs = this.resolveRelayReconnectDelayMs(this.relayReconnectAttempt);
const attemptNumber = this.relayReconnectAttempt + 1;
this.relayReconnectAttempt = attemptNumber;
if (isDevMode) {
devLogger.debug("[nostr] Scheduling relay reconnect attempt.", {
attempt: attemptNumber,
delayMs,
reason,
});
}
this.relayReconnectTimer = setTimeout(async () => {
this.relayReconnectTimer = null;
try {
const results = await this.connectToRelays();
const successful = results.some((result) => result.success);
if (successful) {
this.resetRelayReconnectState();
return;
}
} catch (error) {
devLogger.warn("[nostr] Relay reconnect attempt failed:", error);
}
this.scheduleRelayReconnect({ reason: "reconnect-failed" });
}, delayMs);
}
logRelaySummary({
key,
level = "warn",
message,
payload,
} = {}) {
if (!key || !message) {
return;
}
const now = Date.now();
const lastLogged = this.relaySummaryLogTimestamps.get(key) || 0;
if (now - lastLogged < RELAY_SUMMARY_LOG_INTERVAL_MS) {
return;
}
this.relaySummaryLogTimestamps.set(key, now);
const channel = isDevMode ? devLogger : userLogger;
const logFn =
typeof channel[level] === "function" ? channel[level] : channel.warn;
if (payload) {
logFn(`[nostr] ${message}`, payload);
} else {
logFn(`[nostr] ${message}`);
}
}
resolveRelayBackoffMs(failureCount, ttlOverride) {
const baseMs = RELAY_BACKOFF_BASE_DELAY_MS;
const maxMs = RELAY_BACKOFF_MAX_DELAY_MS;
const computed = Math.min(
maxMs,
baseMs * Math.pow(2, Math.max(0, failureCount - 1))
);
const override = Number(ttlOverride);
if (Number.isFinite(override) && override > 0) {
return Math.min(computed, Math.floor(override));
}
return computed;
}
clearRelayBackoff(url) {
if (!url || typeof url !== "string") {
return;
}
const normalized = url.trim();
if (!normalized) {
return;
}
const hadBackoffState =
this.relayBackoff.has(normalized) ||
this.unreachableRelays.has(normalized) ||
this.relayFailureCounts.has(normalized) ||
this.relayCircuitBreakers.has(normalized);
this.relayBackoff.delete(normalized);
this.relayFailureCounts.delete(normalized);
this.relayFailureWindows.delete(normalized);
this.unreachableRelays.delete(normalized);
this.relayCircuitBreakers.delete(normalized);
if (hadBackoffState) {
this.logRelaySummary({
key: `relay-recovered:${normalized}`,
level: "info",
message: "Relay recovered; backoff cleared.",
payload: { relay: normalized },
});
}
}
recordRelayFailureWindow(url) {
if (!url || typeof url !== "string") {
return 0;
}
const normalized = url.trim();
if (!normalized) {
return 0;
}
const now = Date.now();
const cutoff = now - RELAY_FAILURE_WINDOW_MS;
const history = this.relayFailureWindows.get(normalized) || [];
const filtered = history.filter(
(timestamp) => Number.isFinite(timestamp) && timestamp >= cutoff,
);
filtered.push(now);
this.relayFailureWindows.set(normalized, filtered);
return filtered.length;
}
markRelayUnreachable(url, ttlMs = 60000, { reason = null } = {}) {
if (!url || typeof url !== "string") {
return;
}
const normalized = url.trim();
if (!normalized) {
return;
}
const windowFailureCount = this.recordRelayFailureWindow(normalized);
const nextFailureCount = (this.relayFailureCounts.get(normalized) || 0) + 1;
this.relayFailureCounts.set(normalized, nextFailureCount);
const backoffMs = this.resolveRelayBackoffMs(nextFailureCount, ttlMs);
const retryAt = Date.now() + backoffMs;
this.relayBackoff.set(normalized, {
retryAt,
backoffMs,
failureCount: nextFailureCount,
reason,
});
this.unreachableRelays.add(normalized);
const shouldOpenCircuit =
nextFailureCount >= RELAY_CIRCUIT_BREAKER_THRESHOLD ||
windowFailureCount >= RELAY_FAILURE_WINDOW_THRESHOLD;
if (shouldOpenCircuit) {
const now = Date.now();
const openUntil = now + RELAY_CIRCUIT_BREAKER_COOLDOWN_MS;
const existing = this.relayCircuitBreakers.get(normalized);
const nextOpenUntil =
existing && Number.isFinite(existing.openUntil)
? Math.max(existing.openUntil, openUntil)
: openUntil;
const circuitReason =
reason ||
(nextFailureCount >= RELAY_CIRCUIT_BREAKER_THRESHOLD
? "consecutive-failures"
: "windowed-failures");
this.relayCircuitBreakers.set(normalized, {
openUntil: nextOpenUntil,
failureCount: nextFailureCount,
reason: circuitReason,
});
this.logRelaySummary({
key: `relay-circuit:${normalized}`,
level: "warn",
message: "Circuit breaker opened for relay.",
payload: {
relay: normalized,
openUntil: nextOpenUntil,
failureCount: nextFailureCount,
reason: circuitReason,
},
});
}
this.logRelaySummary({
key: `relay-backoff:${normalized}`,
level: "warn",
message: "Relay backoff applied.",
payload: {
relay: normalized,
backoffMs,
failureCount: nextFailureCount,
retryAt,
reason,
},
});
}
getHealthyRelays(candidates) {
const source = Array.isArray(candidates) ? candidates : this.relays;
if (!this.unreachableRelays.size) {
return source;
}
const now = Date.now();
return source.filter((url) => {
const circuit = this.relayCircuitBreakers.get(url);
if (circuit && Number.isFinite(circuit.openUntil)) {
if (circuit.openUntil > now) {
return false;
}
this.relayCircuitBreakers.delete(url);
this.relayFailureCounts.delete(url);
this.relayFailureWindows.delete(url);
this.unreachableRelays.delete(url);
this.relayBackoff.delete(url);
this.logRelaySummary({
key: `relay-circuit-reset:${url}`,
level: "info",
message: "Circuit breaker reset for relay.",
payload: { relay: url },
});
}
if (!this.unreachableRelays.has(url)) {
return true;
}
const entry = this.relayBackoff.get(url);
if (!entry) {
return false;
}
if (Number.isFinite(entry.retryAt) && entry.retryAt > now) {
return false;
}
this.relayBackoff.delete(url);
this.relayFailureCounts.delete(url);
this.relayFailureWindows.delete(url);
this.unreachableRelays.delete(url);
this.logRelaySummary({
key: `relay-backoff-expired:${url}`,
level: "info",
message: "Relay backoff expired.",
payload: { relay: url },
});
return true;
});
}
applyRelayPreferences(preferences = {}) {
const normalizedPrefs =
preferences && typeof preferences === "object" ? preferences : {};
const sanitizedAll = sanitizeRelayList(
Array.isArray(normalizedPrefs.all)
? normalizedPrefs.all
: this.relays
);
const effectiveAll =
sanitizedAll.length > 0
? sanitizedAll
: sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
const sanitizedRead = sanitizeRelayList(
Array.isArray(normalizedPrefs.read)
? normalizedPrefs.read
: effectiveAll
);
const sanitizedWrite = sanitizeRelayList(
Array.isArray(normalizedPrefs.write)
? normalizedPrefs.write
: effectiveAll
);
this.relays = effectiveAll.length ? effectiveAll : Array.from(RELAY_URLS);
this.readRelays = sanitizedRead.length ? sanitizedRead : Array.from(this.relays);
this.writeRelays = sanitizedWrite.length ? sanitizedWrite : Array.from(this.relays);
}
makeCountUnsupportedError(relayUrl) {
const normalizedUrl =
typeof relayUrl === "string" && relayUrl.trim()
? relayUrl.trim()
: "";
const error = new Error(
`[nostr] Relay ${normalizedUrl} does not support COUNT frames.`
);
error.code = "count-unsupported";
error.relay = normalizedUrl;
error.unsupported = true;
return error;
}
getRequestTimeoutMs(timeoutMs) {
const candidate = Number(timeoutMs);
if (Number.isFinite(candidate) && candidate > 0) {
return Math.floor(candidate);
}
const poolTimeout = Number(this.pool?.getTimeout);
if (Number.isFinite(poolTimeout) && poolTimeout > 0) {
return Math.floor(poolTimeout);
}
return 3400;
}
normalizeCountFilter(filter) {
if (!filter || typeof filter !== "object") {
return null;
}
const normalized = {};
const toStringArray = (value) => {
if (value === undefined || value === null) {
return [];
}
const source = Array.isArray(value) ? value : [value];
const collected = [];
for (const item of source) {
if (typeof item !== "string") {
continue;
}
const trimmed = item.trim();
if (!trimmed || collected.includes(trimmed)) {
continue;
}
collected.push(trimmed);
}
return collected;
};
if (filter.kinds !== undefined) {
const kindsSource = Array.isArray(filter.kinds)
? filter.kinds
: [filter.kinds];
const normalizedKinds = [];
const seenKinds = new Set();
for (const candidate of kindsSource) {
const parsed = Number(candidate);
if (!Number.isFinite(parsed)) {
continue;
}
const normalizedValue = Math.floor(parsed);
if (seenKinds.has(normalizedValue)) {
continue;
}
seenKinds.add(normalizedValue);
normalizedKinds.push(normalizedValue);
}
if (normalizedKinds.length) {
normalized.kinds = normalizedKinds;
}
}
const ids = toStringArray(filter.ids);
if (ids.length) {
normalized.ids = ids;
}
const authors = toStringArray(filter.authors);
if (authors.length) {
normalized.authors = authors;
}
for (const [key, value] of Object.entries(filter)) {
if (!key.startsWith("#")) {
continue;
}
const tagValues = toStringArray(value);
if (tagValues.length) {
normalized[key] = tagValues;
}
}
if (filter.since !== undefined) {
const parsedSince = Number(filter.since);
if (Number.isFinite(parsedSince)) {
normalized.since = Math.floor(parsedSince);
}
}
if (filter.until !== undefined) {
const parsedUntil = Number(filter.until);
if (Number.isFinite(parsedUntil)) {
normalized.until = Math.floor(parsedUntil);
}
}
if (filter.limit !== undefined) {
const parsedLimit = Number(filter.limit);
if (Number.isFinite(parsedLimit) && parsedLimit >= 0) {
normalized.limit = Math.floor(parsedLimit);
}
}
return Object.keys(normalized).length ? normalized : null;
}
normalizeCountFilters(filters) {
if (!filters) {
return [];
}
const list = Array.isArray(filters) ? filters : [filters];
const normalized = [];
for (const candidate of list) {
const normalizedFilter = this.normalizeCountFilter(candidate);
if (normalizedFilter) {
normalized.push(normalizedFilter);
}
}
return normalized;
}
generateCountRequestId(prefix = "count") {
this.countRequestCounter += 1;
if (this.countRequestCounter > Number.MAX_SAFE_INTEGER - 1) {
this.countRequestCounter = 1;
}
const normalizedPrefix =
typeof prefix === "string" && prefix.trim() ? prefix.trim() : "count";
const timestamp = Date.now().toString(36);
const counter = this.countRequestCounter.toString(36);
const random = Math.random().toString(36).slice(2, 8);
return `${normalizedPrefix}:${timestamp}:${counter}${random}`;
}
extractCountValue(payload) {
if (typeof payload === "number") {
const value = Math.floor(payload);
return value >= 0 ? value : 0;
}
if (payload && typeof payload === "object") {
const candidate =
typeof payload.count === "number"
? payload.count
: Number(payload.count);
if (Number.isFinite(candidate)) {
const value = Math.floor(candidate);
return value >= 0 ? value : 0;
}
}
const parsed = Number(payload);
if (Number.isFinite(parsed)) {
const value = Math.floor(parsed);
return value >= 0 ? value : 0;
}
return 0;
}
async sendRawCountFrame(relayUrl, filters, options = {}) {
if (!this.pool) {
throw new Error(
"Nostr pool not initialized. Call ensurePool() before requesting counts."
);
}
const normalizedUrl =
typeof relayUrl === "string" ? relayUrl.trim() : "";
if (!normalizedUrl) {
throw new Error("Invalid relay URL for COUNT request.");
}
if (this.countUnsupportedRelays.has(normalizedUrl)) {
throw this.makeCountUnsupportedError(normalizedUrl);
}
const normalizedFilters = this.normalizeCountFilters(filters);
if (!normalizedFilters.length) {
throw new Error("At least one filter is required for a COUNT request.");
}
const requestId =
typeof options.subId === "string" && options.subId.trim()
? options.subId.trim()
: this.generateCountRequestId();
let relay;
try {
relay = await this.pool.ensureRelay(normalizedUrl);
} catch (error) {
const connectError = new Error(
`Failed to connect to relay ${normalizedUrl}`
);
connectError.code = "relay-connect-failed";
this.markRelayUnreachable(normalizedUrl, 60000, {
reason: "connect-failed",
});
throw connectError;
}
if (!relay) {
const relayError = new Error(
`Relay ${normalizedUrl} is unavailable for COUNT requests.`
);
relayError.code = "relay-unavailable";
this.markRelayUnreachable(normalizedUrl, 60000, {
reason: "relay-unavailable",
});
throw relayError;
}
const frame = ["COUNT", requestId, ...normalizedFilters];
let countPromise;
if (
relay.openCountRequests instanceof Map &&
typeof relay.send === "function"
) {
countPromise = new Promise((resolve, reject) => {
const cleanup = () => {
if (relay.openCountRequests instanceof Map) {
relay.openCountRequests.delete(requestId);
}
};
relay.openCountRequests.set(requestId, {
resolve: (value) => {
cleanup();
resolve(value);
},
reject: (error) => {
cleanup();
reject(error);
},
});
let sendResult;
try {
sendResult = relay.send(JSON.stringify(frame));
} catch (error) {
cleanup();
reject(error);
return;
}
if (sendResult && typeof sendResult.catch === "function") {
sendResult.catch((error) => {
cleanup();
reject(error);
});
}
});
} else if (typeof relay.count === "function") {
countPromise = relay.count(normalizedFilters, { id: requestId });
} else {
this.countUnsupportedRelays.add(normalizedUrl);
throw this.makeCountUnsupportedError(normalizedUrl);
}
const timeoutMs = this.getRequestTimeoutMs(options.timeoutMs);
let rawResult;
try {
rawResult = await withRequestTimeout(
countPromise,
timeoutMs,
() => {
if (relay?.openCountRequests instanceof Map) {
relay.openCountRequests.delete(requestId);
}
},
`COUNT request timed out after ${timeoutMs}ms`
);
} catch (error) {
if (
error instanceof Error &&
error.message.includes("COUNT request timed out")
) {
error.code = "count-timeout";
}
throw error;
}
const countValue = this.extractCountValue(rawResult);
return ["COUNT", requestId, { count: countValue }];
}
async countEventsAcrossRelays(filters, options = {}) {
const normalizedFilters = this.normalizeCountFilters(filters);
if (!normalizedFilters.length) {
return { total: 0, best: null, perRelay: [], partial: false };
}
const relayList =
Array.isArray(options.relays) && options.relays.length
? options.relays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS;
const normalizedRelayList = relayList
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean);
const eligibleRelays = this.getHealthyRelays(normalizedRelayList);
const eligibleRelaySet = new Set(eligibleRelays);
const activeRelays = [];
const precomputedEntries = [];
for (const url of normalizedRelayList) {
if (this.countUnsupportedRelays.has(url)) {
const error = this.makeCountUnsupportedError(url);
precomputedEntries.push({
url,
ok: false,
error,
unsupported: true,
});
continue;
}
if (!eligibleRelaySet.has(url)) {
precomputedEntries.push({
url,
ok: false,
skipped: true,
reason: "backoff",
});
continue;
}
activeRelays.push(url);
}
const activeResults = await Promise.all(
activeRelays.map(async (url) => {
try {
const frame = await this.sendRawCountFrame(url, normalizedFilters, {
timeoutMs: options.timeoutMs,
});
const count = this.extractCountValue(frame?.[2]);
this.clearRelayBackoff(url);
return { url, ok: true, frame, count };
} catch (error) {
const isUnsupported = error?.code === "count-unsupported";
const isTimeout =
error?.code === "count-timeout" || error?.code === "timeout";
if (isUnsupported) {
this.countUnsupportedRelays.add(url);
} else {
logRelayCountFailure(url, error);
// If the count request timed out, we don't want to kill the entire relay connection
// because it might still be good for subscriptions.
// We only trip the circuit breaker for hard errors.
if (!isTimeout) {
this.markRelayUnreachable(url, 60000, {
reason: "count-error",
});
} else if (isDevMode) {
devLogger.warn(
`[nostr] Relay ${url} count timed out (ignored for circuit breaker).`
);
}
}
return {
url,
ok: false,
error,
unsupported: isUnsupported,
timedOut: isTimeout,
errorCode: error?.code || null,
};
}
})
);
const resultsByUrl = new Map();
for (const entry of [...precomputedEntries, ...activeResults]) {
if (entry && typeof entry.url === "string") {
resultsByUrl.set(entry.url, entry);
}
}
const perRelayResults = normalizedRelayList.map((url) => {
if (resultsByUrl.has(url)) {
return resultsByUrl.get(url);
}
return { url, ok: false };
});
let bestEstimate = null;
const perRelay = perRelayResults.map((entry) => {
if (!entry || !entry.ok) {
return entry;
}
const numericValue = Number(entry.count);
const normalizedCount =
Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0;
const normalizedEntry = {
...entry,
count: normalizedCount,
};
if (!Number.isFinite(numericValue) || numericValue < 0) {
normalizedEntry.rawCount = entry.count;
}
if (
!bestEstimate ||
normalizedCount > bestEstimate.count ||
(bestEstimate && normalizedCount === bestEstimate.count && !bestEstimate.frame)
) {
bestEstimate = {
relay: normalizedEntry.url,
count: normalizedCount,
frame: normalizedEntry.frame,
};
}
return normalizedEntry;
});
const total = bestEstimate ? bestEstimate.count : 0;
const partial = perRelayResults.some(
(entry) => entry?.timedOut || entry?.skipped
);
return {
total,
best: bestEstimate,
perRelay,
partial,
};
}
}
+370
View File
@@ -0,0 +1,370 @@
import { devLogger, userLogger } from "../../utils/logger.js";
import { CACHE_POLICIES } from "../cachePolicies.js";
import { NOTE_TYPES } from "../../nostrEventSchemas.js";
const EVENTS_CACHE_TTL_MS = CACHE_POLICIES[NOTE_TYPES.VIDEO_POST]?.ttl ?? (10 * 60 * 1000);
const EVENTS_CACHE_DB_NAME = "bitvid-events-cache";
const EVENTS_CACHE_DB_VERSION = 1;
const EVENTS_CACHE_IDLE_TIMEOUT_MS = 1500;
function scheduleIdleTask(callback, timeout = EVENTS_CACHE_IDLE_TIMEOUT_MS) {
if (typeof requestIdleCallback === "function") {
return requestIdleCallback(callback, { timeout });
}
return setTimeout(callback, timeout);
}
function wrapIdbRequest(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("IndexedDB request failed"));
});
}
function waitForTransaction(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error || new Error("IndexedDB transaction failed"));
tx.onabort = () => reject(tx.error || new Error("IndexedDB transaction aborted"));
});
}
/**
* Persists video events and tombstones to IndexedDB.
*
* **Schema:**
* - `events`: Store for video objects. Key: `id`.
* - `tombstones`: Store for deletion timestamps. Key: `key`.
* - `meta`: Store for versioning and timestamps. Key: `key`.
*
* **Strategy: Incremental Persistence**
* - Snapshots are saved periodically (debounced) to avoid write trashing.
* - "Fingerprints" (hashes/stringified) are tracked in memory to minimize I/O.
* - Only changed items (where fingerprint differs) are written to IDB.
* - If the TTL expires, the cache is cleared on restore to force a fresh fetch.
*/
export class EventsCacheStore {
/**
* Initializes the EventsCacheStore.
* Tracks fingerprints of persisted items to avoid redundant writes.
*/
constructor() {
/** @type {Promise<IDBDatabase>|null} Singleton promise for opening the DB */
this.dbPromise = null;
/** @type {Map<string, string>} Map of Event ID -> JSON Fingerprint */
this.persistedEventFingerprints = new Map();
/** @type {Map<string, string>} Map of Tombstone Key -> Fingerprint */
this.persistedTombstoneFingerprints = new Map();
/** @type {boolean} Whether we have loaded existing fingerprints from IDB */
this.hasLoadedFingerprints = false;
}
isSupported() {
return typeof indexedDB !== "undefined";
}
/**
* Opens (or returns existing) connection to the IndexedDB database.
* Handles schema upgrades (creating object stores).
*
* @returns {Promise<IDBDatabase|null>} The database instance or null if not supported.
*/
async getDb() {
if (!this.isSupported()) {
return null;
}
if (this.dbPromise) {
return this.dbPromise;
}
this.dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(EVENTS_CACHE_DB_NAME, EVENTS_CACHE_DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("events")) {
db.createObjectStore("events", { keyPath: "id" });
}
if (!db.objectStoreNames.contains("tombstones")) {
db.createObjectStore("tombstones", { keyPath: "key" });
}
if (!db.objectStoreNames.contains("meta")) {
db.createObjectStore("meta", { keyPath: "key" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("Failed to open events cache database"));
});
return this.dbPromise;
}
computeEventFingerprint(video) {
try {
return JSON.stringify(video);
} catch (error) {
devLogger.warn("[nostr] Failed to fingerprint cached event", error);
return String(Date.now());
}
}
computeTombstoneFingerprint(timestamp) {
return `ts:${timestamp}`;
}
/**
* Pre-loads fingerprints of all persisted items into memory.
*
* This is critical for the "Incremental Persistence" strategy. By knowing
* what is already on disk (via fingerprints), we can avoid writing unchanged records.
*
* @param {IDBDatabase} db - The database instance.
*/
async ensureFingerprintsLoaded(db) {
if (this.hasLoadedFingerprints || !db) {
return;
}
const tx = db.transaction(["events", "tombstones"], "readonly");
const eventsStore = tx.objectStore("events");
const tombstoneStore = tx.objectStore("tombstones");
const [events, tombstones] = await Promise.all([
wrapIdbRequest(eventsStore.getAll()),
wrapIdbRequest(tombstoneStore.getAll()),
waitForTransaction(tx),
]);
for (const entry of Array.isArray(events) ? events : []) {
if (entry && entry.id && entry.fingerprint) {
this.persistedEventFingerprints.set(entry.id, entry.fingerprint);
}
}
for (const entry of Array.isArray(tombstones) ? tombstones : []) {
if (entry && entry.key) {
const fingerprint = entry.fingerprint || this.computeTombstoneFingerprint(entry.timestamp);
this.persistedTombstoneFingerprints.set(entry.key, fingerprint);
}
}
this.hasLoadedFingerprints = true;
}
async readMeta(db) {
const tx = db.transaction(["meta"], "readonly");
const metaStore = tx.objectStore("meta");
const meta = await wrapIdbRequest(metaStore.get("meta"));
await waitForTransaction(tx);
return meta;
}
/**
* Restores the full state from IndexedDB.
*
* @returns {Promise<{version: number, savedAt: number, events: Map, tombstones: Map}|null>}
* The restored snapshot or null if cache is missing/expired.
*/
async restoreSnapshot() {
const db = await this.getDb();
if (!db) {
return null;
}
const meta = await this.readMeta(db);
if (!meta || meta.version !== 1 || !meta.savedAt) {
return null;
}
// Check TTL: If cache is too old, wipe it and return null to force a fresh fetch.
const now = Date.now();
if (now - meta.savedAt > EVENTS_CACHE_TTL_MS) {
const tx = db.transaction(["events", "tombstones", "meta"], "readwrite");
tx.objectStore("events").clear();
tx.objectStore("tombstones").clear();
tx.objectStore("meta").clear();
await waitForTransaction(tx);
return null;
}
const tx = db.transaction(["events", "tombstones"], "readonly");
const eventsStore = tx.objectStore("events");
const tombstoneStore = tx.objectStore("tombstones");
const [events, tombstones] = await Promise.all([
wrapIdbRequest(eventsStore.getAll()),
wrapIdbRequest(tombstoneStore.getAll()),
waitForTransaction(tx),
]);
const eventsMap = new Map();
const tombstoneMap = new Map();
for (const entry of Array.isArray(events) ? events : []) {
if (entry && entry.id && entry.video) {
eventsMap.set(entry.id, entry.video);
if (entry.fingerprint) {
this.persistedEventFingerprints.set(entry.id, entry.fingerprint);
}
}
}
for (const entry of Array.isArray(tombstones) ? tombstones : []) {
if (entry && entry.key && Number.isFinite(entry.timestamp)) {
tombstoneMap.set(entry.key, entry.timestamp);
const fingerprint = entry.fingerprint || this.computeTombstoneFingerprint(entry.timestamp);
this.persistedTombstoneFingerprints.set(entry.key, fingerprint);
}
}
this.hasLoadedFingerprints = true;
return {
version: 1,
savedAt: meta.savedAt,
events: eventsMap,
tombstones: tombstoneMap,
};
}
/**
* Persists the current state to IndexedDB.
* Only writes changes (diffs against fingerprints).
*
* @param {{events: Map, tombstones: Map, savedAt: number}} payload - The state to save.
* @param {Set<string>|null} [dirtyEventIds] - Optional set of event IDs that have changed.
* @param {Set<string>|null} [dirtyTombstoneKeys] - Optional set of tombstone keys that have changed.
* @returns {Promise<{persisted: boolean, eventWrites: number, eventDeletes: number, tombstoneWrites: number, tombstoneDeletes: number}>}
* Stats about the persistence operation.
*/
async persistSnapshot(payload, dirtyEventIds = null, dirtyTombstoneKeys = null) {
const db = await this.getDb();
if (!db) {
return { persisted: false };
}
await this.ensureFingerprintsLoaded(db);
const tx = db.transaction(["events", "tombstones", "meta"], "readwrite");
const eventsStore = tx.objectStore("events");
const tombstoneStore = tx.objectStore("tombstones");
const metaStore = tx.objectStore("meta");
const { events, tombstones, savedAt } = payload;
let eventWrites = 0;
let eventDeletes = 0;
let tombstoneWrites = 0;
let tombstoneDeletes = 0;
let skipped = 0;
// Optimization: If dirtyEventIds is provided, only check those events.
// Otherwise, iterate over all events in the payload.
const eventsIterable =
dirtyEventIds instanceof Set
? Array.from(dirtyEventIds).map((id) => [id, events.get(id)])
: events.entries();
for (const [id, video] of eventsIterable) {
if (!id || !video) {
continue;
}
if (
dirtyEventIds &&
!dirtyEventIds.has(id) &&
this.persistedEventFingerprints.has(id)
) {
skipped++;
continue;
}
const fingerprint = this.computeEventFingerprint(video);
const prevFingerprint = this.persistedEventFingerprints.get(id);
if (prevFingerprint === fingerprint) {
continue;
}
eventsStore.put({ id, video, fingerprint });
this.persistedEventFingerprints.set(id, fingerprint);
eventWrites++;
}
// Optimization: Skip deletion scan if we are processing partial updates (dirtyEventIds)
// AND the total number of events hasn't decreased. This avoids checking 'has()' on thousands of items.
const shouldScanEventDeletions =
!dirtyEventIds || events.size < this.persistedEventFingerprints.size;
if (shouldScanEventDeletions) {
for (const persistedId of Array.from(
this.persistedEventFingerprints.keys()
)) {
if (events.has(persistedId)) {
continue;
}
eventsStore.delete(persistedId);
this.persistedEventFingerprints.delete(persistedId);
eventDeletes++;
}
}
// Optimization: If dirtyTombstoneKeys is provided, only check those.
const tombstonesIterable =
dirtyTombstoneKeys instanceof Set
? Array.from(dirtyTombstoneKeys).map((key) => [key, tombstones.get(key)])
: tombstones.entries();
for (const [key, timestamp] of tombstonesIterable) {
if (!key || !Number.isFinite(timestamp)) {
continue;
}
if (
dirtyTombstoneKeys &&
!dirtyTombstoneKeys.has(key) &&
this.persistedTombstoneFingerprints.has(key)
) {
skipped++;
continue;
}
const fingerprint = this.computeTombstoneFingerprint(timestamp);
const prevFingerprint = this.persistedTombstoneFingerprints.get(key);
if (prevFingerprint === fingerprint) {
continue;
}
tombstoneStore.put({ key, timestamp, fingerprint });
this.persistedTombstoneFingerprints.set(key, fingerprint);
tombstoneWrites++;
}
const shouldScanTombstoneDeletions =
!dirtyTombstoneKeys ||
tombstones.size < this.persistedTombstoneFingerprints.size;
if (shouldScanTombstoneDeletions) {
for (const persistedKey of Array.from(
this.persistedTombstoneFingerprints.keys()
)) {
if (tombstones.has(persistedKey)) {
continue;
}
tombstoneStore.delete(persistedKey);
this.persistedTombstoneFingerprints.delete(persistedKey);
tombstoneDeletes++;
}
}
metaStore.put({ key: "meta", savedAt, version: 1 });
await waitForTransaction(tx);
return {
persisted: true,
eventWrites,
eventDeletes,
tombstoneWrites,
tombstoneDeletes,
skipped,
};
}
}
+354
View File
@@ -0,0 +1,354 @@
import { EventsCacheStore } from "./EventsCacheStore.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { isDevMode } from "../../config.js";
import { NOTE_TYPES } from "../../nostrEventSchemas.js";
import { CACHE_POLICIES } from "../cachePolicies.js";
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
const EVENTS_CACHE_TTL_MS = CACHE_POLICIES[NOTE_TYPES.VIDEO_POST]?.ttl ?? (10 * 60 * 1000);
const EVENTS_CACHE_PERSIST_DELAY_MS = 450;
const EVENTS_CACHE_IDLE_TIMEOUT_MS = 1500;
function scheduleIdleTask(callback, timeout = EVENTS_CACHE_IDLE_TIMEOUT_MS) {
if (typeof requestIdleCallback === "function") {
return requestIdleCallback(callback, { timeout });
}
return setTimeout(callback, timeout);
}
export class PersistenceManager {
constructor(client) {
this.client = client;
this.eventsCacheStore = new EventsCacheStore();
this.cachePersistTimerId = null;
this.cachePersistIdleId = null;
this.cachePersistInFlight = null;
this.cachePersistReason = null;
this.hasRestoredLocalData = false;
}
/**
* Restores application state from the best available local cache.
*
* 1. Attempts to load from `IndexedDB` (preferred).
* 2. Falls back to `localStorage` if IDB fails or is empty.
* 3. Rehydrates `allEvents`, `activeMap`, and `tombstones`.
*
* This implements the "Stale-While-Revalidate" pattern: the UI renders
* immediately with this data while network requests proceed in the background.
*
* @returns {Promise<boolean>} True if data was successfully restored.
*/
async restoreLocalData() {
if (this.hasRestoredLocalData) {
return this.client.allEvents.size > 0;
}
this.hasRestoredLocalData = true;
const restoredFromIndexedDb = await this.restoreFromIndexedDb();
if (restoredFromIndexedDb) {
return true;
}
return this.restoreFromLocalStorage();
}
async restoreFromIndexedDb() {
try {
const payload = await this.eventsCacheStore.restoreSnapshot();
if (!payload) {
return false;
}
return this.applyCachedPayload(payload, "IndexedDB");
} catch (error) {
devLogger.warn("[nostr] Failed to restore IndexedDB cache:", error);
return false;
}
}
restoreFromLocalStorage() {
if (typeof localStorage === "undefined") {
return false;
}
const now = Date.now();
const parsePayload = (raw) => {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed;
}
} catch (err) {
devLogger.warn("[nostr] Failed to parse cached events:", err);
}
return null;
};
let payload = parsePayload(localStorage.getItem(EVENTS_CACHE_STORAGE_KEY));
const applied = this.applyCachedPayload(payload, "localStorage");
if (!applied && payload) {
try {
localStorage.removeItem(EVENTS_CACHE_STORAGE_KEY);
} catch (err) {
devLogger.warn("[nostr] Failed to clear expired cache:", err);
}
}
return applied;
}
applyCachedPayload(payload, sourceLabel) {
if (!payload || payload.version !== 1) {
return false;
}
const now = Date.now();
if (
typeof payload.savedAt !== "number" ||
payload.savedAt <= 0 ||
now - payload.savedAt > EVENTS_CACHE_TTL_MS
) {
return false;
}
const events = payload.events;
if (!events || (typeof events !== "object" && !(events instanceof Map))) {
return false;
}
this.client.allEvents.clear();
this.client.rawEvents.clear();
this.client.activeMap.clear();
this.client.rootCreatedAtByRoot.clear();
this.client.tombstones.clear();
this.client.dirtyEventIds.clear();
this.client.dirtyTombstones.clear();
const tombstoneEntries =
events instanceof Map && payload.tombstones instanceof Map
? payload.tombstones.entries()
: Array.isArray(payload.tombstones)
? payload.tombstones
: [];
for (const entry of tombstoneEntries) {
const [key, value] = Array.isArray(entry) ? entry : [entry.key, entry.timestamp];
const normalizedKey = typeof key === "string" ? key.trim() : "";
const timestamp = Number.isFinite(value) ? Math.floor(value) : 0;
if (normalizedKey && timestamp > 0) {
this.client.tombstones.set(normalizedKey, timestamp);
}
}
const eventEntries =
events instanceof Map ? events.entries() : Object.entries(events);
for (const [id, video] of eventEntries) {
if (!id || !video || typeof video !== "object") {
continue;
}
this.client.applyRootCreatedAt(video);
const activeKey = this.client.getActiveKey(video);
if (video.deleted) {
this.client.recordTombstone(activeKey, video.created_at);
} else {
this.client.applyTombstoneGuard(video);
}
this.client.allEvents.set(id, video);
if (video.deleted) {
continue;
}
const existing = this.client.activeMap.get(activeKey);
if (!existing || video.created_at > existing.created_at) {
this.client.activeMap.set(activeKey, video);
}
}
if (this.client.allEvents.size > 0 && isDevMode) {
devLogger.log(
`[nostr] Restored ${this.client.allEvents.size} cached events from ${sourceLabel}`,
);
}
this.client.dirtyEventIds.clear();
this.client.dirtyTombstones.clear();
return this.client.allEvents.size > 0;
}
clearCachePersistHandles() {
if (this.cachePersistTimerId) {
clearTimeout(this.cachePersistTimerId);
this.cachePersistTimerId = null;
}
if (this.cachePersistIdleId) {
if (typeof cancelIdleCallback === "function") {
try {
cancelIdleCallback(this.cachePersistIdleId);
} catch (error) {
devLogger.warn("[nostr] Failed to cancel cache idle callback:", error);
}
} else {
clearTimeout(this.cachePersistIdleId);
}
this.cachePersistIdleId = null;
}
}
buildCachePayload() {
return {
version: 1,
savedAt: Date.now(),
events: new Map(this.client.allEvents),
tombstones: new Map(this.client.tombstones),
};
}
/**
* Schedules a persistence operation to save current state to cache.
*
* - Uses a debounce strategy (75ms) to prevent write thrashing during bursts.
* - Can be forced to run immediately via `options.immediate`.
* - Coordinates with `requestIdleCallback` to avoid blocking the main thread.
*
* @param {string} [reason="unspecified"] - Debug label for why persistence was triggered.
* @param {object} [options] - Configuration.
* @param {boolean} [options.immediate=false] - If true, bypasses debounce and persists immediately.
* @returns {Promise<boolean>|null} The persistence promise or null if debounced.
*/
saveLocalData(reason = "unspecified", options = {}) {
const { immediate = false } = options;
if (immediate) {
this.clearCachePersistHandles();
this.cachePersistInFlight = this.persistLocalData(reason).finally(() => {
this.cachePersistInFlight = null;
});
return this.cachePersistInFlight;
}
if (this.cachePersistTimerId || this.cachePersistIdleId || this.cachePersistInFlight) {
return this.cachePersistInFlight;
}
this.cachePersistReason = reason;
devLogger.log(
`[nostr] Scheduling cached events persist (${reason}) with debounce ${EVENTS_CACHE_PERSIST_DELAY_MS}ms`,
);
this.cachePersistTimerId = setTimeout(() => {
this.cachePersistTimerId = null;
this.cachePersistIdleId = scheduleIdleTask(() => {
this.cachePersistIdleId = null;
this.cachePersistInFlight = this.persistLocalData(reason).finally(() => {
this.cachePersistInFlight = null;
});
});
}, EVENTS_CACHE_PERSIST_DELAY_MS);
return this.cachePersistInFlight;
}
/**
* Executes the actual persistence logic, writing to IndexedDB (preferred) or localStorage.
*
* @param {string} reason - Debug label.
* @returns {Promise<boolean>} True if successful.
* @private
*/
async persistLocalData(reason = "unspecified") {
// Strategy:
// 1. Try to persist to IndexedDB (preferred, large capacity).
// 2. If IDB fails or returns false, fall back to LocalStorage (limited capacity).
// The `EventsCacheStore` handles differential updates (only saving changed fingerprints)
// to keep IDB writes fast.
// Capture snapshots of dirty sets to optimize persistence
const dirtyEventIdsSnapshot = new Set(this.client.dirtyEventIds);
const dirtyTombstonesSnapshot = new Set(this.client.dirtyTombstones);
// Clear the tracked items from the main sets so we don't re-process them next time.
// New items added during the async save will remain in the main sets.
for (const id of dirtyEventIdsSnapshot) {
this.client.dirtyEventIds.delete(id);
}
for (const key of dirtyTombstonesSnapshot) {
this.client.dirtyTombstones.delete(key);
}
const payload = this.buildCachePayload();
const startedAt = Date.now();
let summary = null;
let target = "localStorage";
try {
summary = await this.eventsCacheStore.persistSnapshot(
payload,
dirtyEventIdsSnapshot,
dirtyTombstonesSnapshot
);
if (summary?.persisted) {
target = "IndexedDB";
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to persist events cache to IndexedDB:",
error
);
}
if (!summary?.persisted) {
// If persistence failed, re-queue the dirty items for the next attempt.
for (const id of dirtyEventIdsSnapshot) {
this.client.dirtyEventIds.add(id);
}
for (const key of dirtyTombstonesSnapshot) {
this.client.dirtyTombstones.add(key);
}
this.persistCacheToLocalStorage(payload);
}
const durationMs = Date.now() - startedAt;
devLogger.log(
`[nostr] Cached events persisted via ${target} (reason=${reason}, duration=${durationMs}ms, events+${summary?.eventWrites ?? 0}/-${summary?.eventDeletes ?? 0}, tombstones+${summary?.tombstoneWrites ?? 0}/-${summary?.tombstoneDeletes ?? 0}, skipped=${summary?.skipped ?? 0})`,
);
return summary?.persisted;
}
persistCacheToLocalStorage(payload) {
if (typeof localStorage === "undefined") {
return;
}
const serializedEvents = {};
for (const [id, vid] of payload.events.entries()) {
serializedEvents[id] = vid;
}
const serializedPayload = {
...payload,
events: serializedEvents,
tombstones: Array.from(payload.tombstones.entries()),
};
try {
localStorage.setItem(
EVENTS_CACHE_STORAGE_KEY,
JSON.stringify(serializedPayload),
);
} catch (err) {
devLogger.warn("[nostr] Failed to persist events cache:", err);
}
}
}
+930
View File
@@ -0,0 +1,930 @@
import {
isSessionActor,
readStoredSessionActorEntry,
decryptSessionPrivateKey,
encryptSessionPrivateKey,
persistSessionActor as persistSessionActorEntry,
clearStoredSessionActor as clearStoredSessionActorEntry,
} from "../sessionActor.js";
import {
waitForNip07Extension,
requestEnablePermissions,
normalizePermissionMethod,
readStoredNip07Permissions,
writeStoredNip07Permissions,
clearStoredNip07Permissions,
runNip07WithRetry,
DEFAULT_NIP07_ENCRYPTION_METHODS,
DEFAULT_NIP07_CORE_METHODS,
DEFAULT_NIP07_PERMISSION_METHODS,
} from "../nip07Permissions.js";
import {
setActiveSigner as setActiveSignerInRegistry,
getActiveSigner as getActiveSignerFromRegistry,
clearActiveSigner as clearActiveSignerInRegistry,
logoutSigner as logoutSignerFromRegistry,
resolveActiveSigner as resolveActiveSignerFromRegistry,
} from "../../nostrClientRegistry.js";
import {
readStoredNip46Session,
writeStoredNip46Session,
clearStoredNip46Session,
Nip46RpcClient,
decryptNip46Session,
parseNip46ConnectionString,
sanitizeNip46Metadata,
normalizeNip46EncryptionAlgorithm,
resolveNip46Relays,
encodeHexToNpub,
generateNip46Secret,
} from "../nip46Client.js";
import {
summarizeHexForLog,
summarizeSecretForLog,
summarizeMetadataForLog,
summarizeUrlForLog,
} from "../nip46LoggingUtils.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { createPrivateKeyCipherClosures } from "../signerHelpers.js";
import { queueSignEvent } from "../signRequestQueue.js";
import { signEventWithPrivateKey } from "../publishHelpers.js";
import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js";
function resolveSignerCapabilities(signer) {
const fallback = {
sign: false,
nip44: false,
nip04: false,
};
if (!signer || typeof signer !== "object") {
return fallback;
}
const capabilities =
signer.capabilities && typeof signer.capabilities === "object"
? signer.capabilities
: {};
return {
sign:
(typeof capabilities.sign === "boolean" && capabilities.sign) ||
typeof signer.signEvent === "function",
nip44:
(typeof capabilities.nip44 === "boolean" && capabilities.nip44) ||
typeof signer.nip44Encrypt === "function" ||
typeof signer.nip44Decrypt === "function",
nip04:
(typeof capabilities.nip04 === "boolean" && capabilities.nip04) ||
typeof signer.nip04Encrypt === "function" ||
typeof signer.nip04Decrypt === "function",
};
}
function hydrateExtensionSignerCapabilities(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const signerType = typeof signer.type === "string" ? signer.type : "";
if (signerType !== "extension" && signerType !== "nip07") {
return;
}
const extension =
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
if (!extension) {
return;
}
if (typeof signer.signEvent !== "function" && extension.signEvent) {
if (typeof extension.signEvent === "function") {
signer.signEvent = extension.signEvent.bind(extension);
}
}
if (!signer.nip04 && extension.nip04) {
signer.nip04 = extension.nip04;
}
if (!signer.nip44 && extension.nip44) {
signer.nip44 = extension.nip44;
}
}
function attachNipMethodAliases(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const nip04 =
signer && typeof signer.nip04 === "object" && signer.nip04 !== null
? signer.nip04
: null;
if (nip04) {
const encrypt =
typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null;
const decrypt =
typeof nip04.decrypt === "function" ? nip04.decrypt.bind(nip04) : null;
if (encrypt && typeof signer.nip04Encrypt !== "function") {
signer.nip04Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt && typeof signer.nip04Decrypt !== "function") {
signer.nip04Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
const nip44 =
signer && typeof signer.nip44 === "object" && signer.nip44 !== null
? signer.nip44
: null;
if (nip44) {
const v2 =
typeof nip44.v2 === "object" && nip44.v2 !== null ? nip44.v2 : null;
const encrypt = (() => {
if (typeof signer.nip44Encrypt === "function") {
return null;
}
if (typeof v2?.encrypt === "function") {
return v2.encrypt.bind(v2);
}
if (typeof nip44.encrypt === "function") {
return nip44.encrypt.bind(nip44);
}
return null;
})();
const decrypt = (() => {
if (typeof signer.nip44Decrypt === "function") {
return null;
}
if (typeof v2?.decrypt === "function") {
return v2.decrypt.bind(v2);
}
if (typeof nip44.decrypt === "function") {
return nip44.decrypt.bind(nip44);
}
return null;
})();
if (encrypt) {
signer.nip44Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt) {
signer.nip44Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
}
function resolveActiveSigner(pubkey) {
const signer = resolveActiveSignerFromRegistry(pubkey);
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
if (signer && typeof signer === "object") {
const capsDescriptor = Object.getOwnPropertyDescriptor(
signer,
"capabilities",
);
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
}
return signer;
}
const PERMISSION_STATUS_AUTO_HIDE_MS = 12_000;
const ENCRYPTION_METHOD_PREFIXES = ["nip04.", "nip44."];
function hasEncryptionPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some((method) =>
ENCRYPTION_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix)),
);
}
function hasSigningPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some(
(method) => method === "sign_event" || method === "get_public_key",
);
}
function resolvePermissionStatusMessage(methods, context) {
const normalizedContext =
typeof context === "string" ? context.trim().toLowerCase() : "";
if (normalizedContext === "dm") {
return "Approve the extension prompt to enable encrypted direct messages.";
}
if (normalizedContext === "lists") {
return "Approve the extension prompt to access your encrypted lists.";
}
const includesEncryption = hasEncryptionPermissionMethods(methods);
const includesSigning = hasSigningPermissionMethods(methods);
if (includesEncryption && includesSigning) {
return "Approve the extension prompt to enable signing and encrypted features (DMs, subscriptions, block lists).";
}
if (includesEncryption) {
return "Approve the extension prompt to enable encrypted features like DMs and private lists.";
}
if (includesSigning) {
return "Approve the extension prompt to enable signing.";
}
return "Approve the extension prompt to continue.";
}
export class SignerManager {
constructor(client) {
this.client = client;
this.pubkey = null;
this.sessionActor = null;
this.lockedSessionActor = null;
this.nip46Client = null;
this.extensionReady = false;
this.extensionPermissionsGranted = false;
this.extensionPermissionCache = new Map();
this.sessionActorCipherClosures = null;
this.sessionActorCipherClosuresPrivateKey = null;
this.remoteSignerListeners = new Set();
this.remoteSignerStatus = {};
}
setActiveSigner(signer) {
if (!signer || typeof signer !== "object") {
return;
}
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
const capsDescriptor = Object.getOwnPropertyDescriptor(signer, "capabilities");
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
setActiveSignerInRegistry(signer);
}
getActiveSigner() {
const signer = getActiveSignerFromRegistry();
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
if (signer && typeof signer === "object") {
const capsDescriptor = Object.getOwnPropertyDescriptor(
signer,
"capabilities",
);
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
}
return signer;
}
resolveActiveSigner(pubkey) {
return resolveActiveSigner(pubkey);
}
async ensureSessionActor() {
if (this.sessionActor) {
return this.sessionActor;
}
if (this.lockedSessionActor) {
return this.lockedSessionActor;
}
const storedEntry = readStoredSessionActorEntry();
if (storedEntry) {
this.lockedSessionActor = storedEntry;
this.sessionActor = storedEntry;
return storedEntry;
}
return null;
}
clearStoredSessionActor() {
clearStoredSessionActorEntry();
this.sessionActor = null;
this.lockedSessionActor = null;
}
async ensureActiveSignerForPubkey(pubkey) {
const normalizedPubkey =
typeof pubkey === "string" && pubkey.trim()
? pubkey.trim().toLowerCase()
: "";
const existingSigner = this.resolveActiveSigner(normalizedPubkey);
if (existingSigner && typeof existingSigner.signEvent === "function") {
return existingSigner;
}
let extension =
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
if (!extension && this.extensionPermissionCache && this.extensionPermissionCache.size > 0) {
try {
await waitForNip07Extension(5000);
extension = window.nostr;
} catch (error) {
// Fall through to existing signer check
}
}
const recheckedSigner = this.resolveActiveSigner(normalizedPubkey);
if (recheckedSigner && typeof recheckedSigner.signEvent === "function") {
return recheckedSigner;
}
if (!extension) {
return existingSigner;
}
let extensionPubkey = normalizedPubkey;
if (typeof extension.getPublicKey === "function") {
try {
const retrieved = await runNip07WithRetry(
() => extension.getPublicKey(),
{ label: "extension.getPublicKey" },
);
if (typeof retrieved === "string" && retrieved.trim()) {
extensionPubkey = retrieved.trim().toLowerCase();
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to hydrate active signer from extension pubkey:",
error,
);
return existingSigner;
}
}
if (normalizedPubkey && extensionPubkey !== normalizedPubkey) {
return existingSigner;
}
const raceWinner = this.resolveActiveSigner(extensionPubkey || normalizedPubkey);
if (raceWinner && typeof raceWinner.signEvent === "function") {
return raceWinner;
}
return null;
}
async ensureExtensionPermissions(
requiredMethods = DEFAULT_NIP07_CORE_METHODS,
{ context = "general" } = {},
) {
const methods = Array.isArray(requiredMethods)
? requiredMethods
: [requiredMethods];
if (!methods.length) {
return { ok: true };
}
const cacheKey = methods.slice().sort().join(",");
if (this.extensionPermissionCache.has(cacheKey)) {
return { ok: true };
}
const cachedPermissions = readStoredNip07Permissions();
const missing = methods.filter(
(method) => !cachedPermissions.has(method),
);
if (missing.length === 0) {
this.extensionPermissionsGranted = true;
this.extensionPermissionCache.set(cacheKey, true);
return { ok: true };
}
try {
await waitForNip07Extension();
} catch (error) {
return { ok: false, error: "extension-missing" };
}
const message = resolvePermissionStatusMessage(missing, context);
try {
const response = await requestEnablePermissions(missing);
if (response?.enabled) {
writeStoredNip07Permissions(missing);
this.extensionPermissionsGranted = true;
this.extensionPermissionCache.set(cacheKey, true);
return { ok: true };
}
return { ok: false, error: "permission-denied" };
} catch (error) {
devLogger.warn("[nostr] Extension permission request failed:", error);
return { ok: false, error: "request-failed" };
}
}
async loginWithExtension(options = {}) {
let extension = null;
let attempts = 0;
const maxAttempts = 3;
if (this.extensionReady && typeof window !== "undefined") {
extension = window.nostr || null;
if (!extension) {
this.extensionReady = false;
}
}
if (!extension && typeof window !== "undefined" && window.nostr) {
extension = window.nostr;
this.extensionReady = true;
} else {
while (!extension && attempts < maxAttempts) {
try {
const timeout = attempts === 0 ? 1500 : 3000;
await waitForNip07Extension(timeout);
extension = window.nostr;
this.extensionReady = Boolean(extension);
} catch (waitError) {
devLogger.log(
`Timed out waiting for extension injection (attempt ${
attempts + 1
}/${maxAttempts}):`,
waitError,
);
}
if (extension) {
break;
}
attempts++;
if (attempts < maxAttempts) {
devLogger.log("Retrying NIP-07 detection...");
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
if (!extension) {
devLogger.log("No Nostr extension found");
throw new Error("Please install a Nostr extension (Alby, nos2x, etc.).");
}
const { allowAccountSelection = false, expectPubkey } =
typeof options === "object" && options !== null ? options : {};
const normalizedExpectedPubkey =
typeof expectPubkey === "string" && expectPubkey.trim()
? expectPubkey.trim().toLowerCase()
: null;
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension.",
);
}
const permissionResult = await this.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
{ context: "login", logMetrics: true, showStatus: false },
);
if (!permissionResult.ok) {
const denialMessage =
'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
const denialError = new Error(denialMessage);
if (permissionResult.error) {
denialError.cause = permissionResult.error;
}
throw denialError;
}
const pubkey = await extension.getPublicKey();
if (!pubkey) {
throw new Error("Extension did not return a public key.");
}
const normalized = pubkey.toLowerCase();
if (normalizedExpectedPubkey && normalized !== normalizedExpectedPubkey) {
throw new Error("Extension returned a different public key than expected.");
}
this.pubkey = normalized;
const adapter = {
type: "extension",
pubkey: normalized,
signEvent: extension.signEvent.bind(extension),
nip04: extension.nip04,
nip44: extension.nip44,
};
this.setActiveSigner(adapter);
return { pubkey: normalized, signer: adapter };
}
async connectRemoteSigner({
connectionString,
remember = true,
clientPrivateKey: providedClientPrivateKey = "",
clientPublicKey: providedClientPublicKey = "",
relays: providedRelays = [],
secret: providedSecret = "",
permissions: providedPermissions = "",
metadata: providedMetadata = {},
onAuthUrl,
onStatus,
handshakeTimeoutMs,
passphrase,
} = {}) {
const parsed = parseNip46ConnectionString(connectionString);
if (!parsed) {
const error = new Error(
"Unsupported NIP-46 URI. Provide a nostrconnect:// handshake or bunker:// pointer.",
);
error.code = "invalid-connection-string";
throw error;
}
const baseMetadata = sanitizeNip46Metadata(parsed.metadata);
const overrideMetadata = sanitizeNip46Metadata(providedMetadata);
const metadata = { ...baseMetadata, ...overrideMetadata };
devLogger.debug("[nostr] Connecting to remote signer", {
connectionType: parsed.type,
parsedRemotePubkey: summarizeHexForLog(parsed.remotePubkey || ""),
providedClientPublicKey: summarizeHexForLog(providedClientPublicKey),
providedClientPrivateKey: summarizeSecretForLog(
typeof providedClientPrivateKey === "string" ? providedClientPrivateKey : "",
),
providedSecret: summarizeSecretForLog(providedSecret),
providedPermissions: providedPermissions || null,
handshakeTimeoutMs,
parsedRelays: parsed.relays,
overrideRelayCount: Array.isArray(providedRelays) ? providedRelays.length : 0,
metadataKeys: summarizeMetadataForLog(metadata),
});
let clientPrivateKey = providedClientPrivateKey || "";
if (!clientPrivateKey) {
const tools = await ensureNostrTools();
if (tools && typeof tools.generateSecretKey === 'function') {
const secret = tools.generateSecretKey();
// Convert to hex
clientPrivateKey = Array.from(secret).map(b => b.toString(16).padStart(2, '0')).join('');
} else if (typeof window !== 'undefined' && window.crypto) {
const randomBytes = new Uint8Array(32);
window.crypto.getRandomValues(randomBytes);
clientPrivateKey = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('');
} else {
// Fallback for non-secure contexts (not ideal but functional for test/dev)
clientPrivateKey = generateNip46Secret(64);
}
}
const handleStatus = (status) => {
if (typeof onStatus !== "function") {
return;
}
try {
onStatus(status);
} catch (error) {
devLogger.warn("[nostr] Remote signer status callback threw:", error);
}
};
const handleAuthChallenge = async (url, context = {}) => {
if (typeof onAuthUrl !== "function" || !url) {
return;
}
try {
devLogger.debug("[nostr] Remote signer auth challenge surfaced", {
url: summarizeUrlForLog(url),
context,
});
const result = onAuthUrl(url, context);
if (result && typeof result.then === "function") {
await result.catch((error) => {
devLogger.warn("[nostr] Auth challenge callback promise rejected:", error);
});
}
} catch (error) {
devLogger.warn("[nostr] Auth challenge callback threw:", error);
}
};
await this.client.ensurePool();
this.nip46Client = new Nip46RpcClient({
nostrClient: this.client,
relays: resolveNip46Relays(parsed.relays, providedRelays),
clientPrivateKey: clientPrivateKey,
remotePubkey: parsed.remotePubkey,
secret: providedSecret || parsed.secret,
signEvent: signEventWithPrivateKey,
});
// Subscribe to events from the NIP46 client?
// The previous code had onStatusChange in the constructor which Nip46RpcClient doesn't seem to support directly based on the read_file output of nip46Client.js.
// However, Nip46RpcClient has no public event emitter interface shown in the file read.
// We will just use it to connect.
this.emitRemoteSignerChange({ state: "connecting" });
try {
const result = await this.nip46Client.connect({
permissions: providedPermissions,
});
if (remember) {
await writeStoredNip46Session(
{
version: 1,
clientPrivateKey: this.nip46Client.clientPrivateKey,
clientPublicKey: this.nip46Client.clientPublicKey,
remotePubkey: this.nip46Client.remotePubkey,
relays: this.nip46Client.relays,
encryption: this.nip46Client.encryptionAlgorithm,
secret: this.nip46Client.secret,
permissions: this.nip46Client.permissions,
metadata: this.nip46Client.metadata,
userPubkey: this.nip46Client.userPubkey,
lastConnectedAt: Date.now(),
},
passphrase
);
}
const userPubkey = await this.nip46Client.getUserPubkey();
this.pubkey = userPubkey;
this.setActiveSigner(this.nip46Client.getActiveSigner());
this.emitRemoteSignerChange();
return { ok: true, result, pubkey: userPubkey };
} catch(err) {
this.nip46Client = null;
this.emitRemoteSignerChange({ state: "error", error: err });
throw err;
}
}
async useStoredRemoteSigner(options = {}) {
const normalizedOptions =
options && typeof options === "object" ? options : {};
const silent = normalizedOptions.silent === true;
const forgetOnError = normalizedOptions.forgetOnError === true;
const passphrase =
typeof normalizedOptions.passphrase === "string"
? normalizedOptions.passphrase
: null;
let stored = readStoredNip46Session();
if (!stored) {
const error = new Error(
"No remote signer session is stored on this device.",
);
error.code = "no-stored-session";
throw error;
}
if (stored.encryptedSecrets) {
if (!passphrase) {
const error = new Error("Passphrase required to unlock session.");
error.code = "passphrase-required";
throw error;
}
try {
stored = await decryptNip46Session(stored, passphrase);
} catch (decryptError) {
const error = new Error("Failed to decrypt stored session.");
error.code = "decrypt-failed";
error.cause = decryptError;
throw error;
}
}
const relays = resolveNip46Relays(stored.relays, this.client.relays);
devLogger.debug("[nostr] Reconnecting to stored remote signer", {
remotePubkey: summarizeHexForLog(stored.remotePubkey || ""),
relays,
encryption: stored.encryption || "",
permissions: stored.permissions || null,
hasCredentials: Boolean(stored.clientPrivateKey && stored.secret),
});
this.emitRemoteSignerChange({
state: "connecting",
relays,
remotePubkey: stored.remotePubkey,
});
await this.client.ensurePool();
this.nip46Client = new Nip46RpcClient({
nostrClient: this.client,
relays,
clientPrivateKey: stored.clientPrivateKey,
remotePubkey: stored.remotePubkey,
secret: stored.secret,
encryption: stored.encryption,
signEvent: signEventWithPrivateKey,
});
try {
// Just verify we can ping or get pubkey
await this.nip46Client.ensureSubscription();
const pubkey = await this.nip46Client.getUserPubkey();
this.pubkey = pubkey;
this.setActiveSigner(this.nip46Client.getActiveSigner());
this.emitRemoteSignerChange();
return { ok: true, pubkey };
} catch (err) {
this.nip46Client = null;
this.emitRemoteSignerChange({ state: "error", error: err });
if (forgetOnError) {
clearStoredNip46Session();
}
throw err;
}
}
async scheduleStoredRemoteSignerRestore() {
const stored = readStoredNip46Session();
if (!stored) {
return;
}
const decryptAndConnect = async () => {
try {
await this.useStoredRemoteSigner({ silent: true });
} catch (err) {
devLogger.warn("Failed to restore remote signer", err);
}
};
decryptAndConnect();
}
async disconnectRemoteSigner({ keepStored = false } = {}) {
if (this.nip46Client) {
if (typeof this.nip46Client.destroy === 'function') {
this.nip46Client.destroy();
}
this.nip46Client = null;
}
if (!keepStored) {
clearStoredNip46Session();
}
this.emitRemoteSignerChange();
}
getStoredNip46Metadata() {
const stored = readStoredNip46Session() || {};
const userPubkey =
typeof stored.userPubkey === "string" ? stored.userPubkey : "";
return {
hasSession: Boolean(stored.remotePubkey && stored.clientPrivateKey),
remotePubkey: stored.remotePubkey || "",
metadata: stored.metadata || {},
relays: stored.relays || [],
encryption: stored.encryption || "",
userPubkey,
userNpub: encodeHexToNpub(userPubkey),
};
}
getRemoteSignerStatus() {
return { ...this.remoteSignerStatus };
}
emitRemoteSignerChange(status = {}) {
const stored = this.getStoredNip46Metadata();
const nextState =
typeof status.state === "string" && status.state.trim()
? status.state.trim()
: this.nip46Client
? "connected"
: stored.hasSession
? "stored"
: "idle";
const remotePubkey =
(typeof status.remotePubkey === "string" && status.remotePubkey.trim()) ||
this.nip46Client?.remotePubkey ||
stored.remotePubkey ||
"";
const userPubkey =
(typeof status.userPubkey === "string" && status.userPubkey.trim()) ||
this.nip46Client?.userPubkey ||
stored.userPubkey ||
"";
const metadataCandidate =
status.metadata || this.nip46Client?.metadata || stored.metadata || {};
const metadata =
metadataCandidate && typeof metadataCandidate === "object"
? { ...metadataCandidate }
: {};
const relays = Array.isArray(status.relays)
? status.relays.slice()
: (this.nip46Client?.relays || stored.relays || []).slice();
const encryption =
(typeof status.encryption === "string" && status.encryption.trim()
? normalizeNip46EncryptionAlgorithm(status.encryption)
: "") || this.nip46Client?.encryptionAlgorithm || stored.encryption || "";
const snapshot = {
state: nextState,
remotePubkey,
userPubkey,
relays,
metadata,
encryption,
error: status.error || null,
};
this.remoteSignerStatus = snapshot;
for (const listener of this.remoteSignerListeners) {
try {
listener(snapshot);
} catch (error) {
devLogger.warn("[nostr] Remote signer listener threw:", error);
}
}
return snapshot;
}
onRemoteSignerChange(listener) {
if (typeof listener !== "function") {
return () => {};
}
this.remoteSignerListeners.add(listener);
return () => {
this.remoteSignerListeners.delete(listener);
};
}
logout() {
const previousPubkey = this.pubkey;
this.pubkey = null;
logoutSignerFromRegistry(previousPubkey);
const previousSessionActor = this.sessionActor;
this.sessionActor = null;
this.sessionActorCipherClosures = null;
this.sessionActorCipherClosuresPrivateKey = null;
if (this.nip46Client) {
this.disconnectRemoteSigner({ keepStored: true });
}
const shouldClearStoredSession =
previousSessionActor &&
previousSessionActor.source === "nsec" &&
previousSessionActor.persisted !== true;
if (shouldClearStoredSession) {
this.lockedSessionActor = null;
this.clearStoredSessionActor();
}
if (
this.extensionPermissionCache &&
typeof this.extensionPermissionCache.clear === "function"
) {
this.extensionPermissionCache.clear();
}
clearStoredNip07Permissions();
this.extensionReady = false;
this.extensionPermissionsGranted = false;
this.emitRemoteSignerChange();
}
}
+12
View File
@@ -0,0 +1,12 @@
// js/nostr/utils.js
export function getActiveKey(video) {
if (video.videoRootId) {
return `ROOT:${video.videoRootId}`;
}
const dTag = video.tags?.find((t) => t[0] === "d");
if (dTag) {
return `${video.pubkey}:${dTag[1]}`;
}
return `LEGACY:${video.id}`;
}
+1 -1
View File
@@ -157,7 +157,7 @@ describe("NostrClient", () => {
assert.ok(sub);
// Wait for buffer flush (75ms debounce)
await new Promise(r => setTimeout(r, 150));
await new Promise(r => setTimeout(r, 300));
assert.ok(capturedVideo, "Video should be captured after flush");
assert.equal(capturedVideo.id, "v1");