mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Refactor NostrClient into specialized managers
- Extracted persistence logic to `PersistenceManager` - Extracted connection/pool logic to `ConnectionManager` - Extracted signer/auth logic to `SignerManager` - Updated `NostrClient` to delegate to these managers - Fixed `SignerManager` implementation gaps (NIP-46 connect/restore) - Verified with `tests/nostr/client.test.mjs` Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
1 parent
1fbee4219b
commit
8b1bd5507d
7 files changed
+2776
-4224
No files matched your search
+146
-4223
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user