mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
247 lines
6.6 KiB
JavaScript
247 lines
6.6 KiB
JavaScript
// js/subscriptionsHelpers.js
|
|
//
|
|
// State-free helpers extracted from subscriptions.js to keep that module under
|
|
// the file-size budget. No behavior change.
|
|
|
|
import { devLogger, userLogger } from "./utils/logger.js";
|
|
|
|
export class TinyEventEmitter {
|
|
constructor() {
|
|
this.listeners = new Map();
|
|
}
|
|
|
|
on(eventName, handler) {
|
|
if (typeof eventName !== "string" || typeof handler !== "function") {
|
|
return () => {};
|
|
}
|
|
|
|
if (!this.listeners.has(eventName)) {
|
|
this.listeners.set(eventName, new Set());
|
|
}
|
|
|
|
const handlers = this.listeners.get(eventName);
|
|
handlers.add(handler);
|
|
|
|
return () => {
|
|
handlers.delete(handler);
|
|
if (!handlers.size) {
|
|
this.listeners.delete(eventName);
|
|
}
|
|
};
|
|
}
|
|
|
|
emit(eventName, detail) {
|
|
const handlers = this.listeners.get(eventName);
|
|
if (!handlers || !handlers.size) {
|
|
return;
|
|
}
|
|
|
|
for (const handler of Array.from(handlers)) {
|
|
try {
|
|
handler(detail);
|
|
} catch (error) {
|
|
userLogger.warn(
|
|
`[SubscriptionsManager] listener for "${eventName}" threw an error`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const MAX_DECRYPT_RETRY_DELAY_MS = 60000;
|
|
|
|
// Transient decrypt failures that must RETRY rather than give up: our own
|
|
// timeout, a severed nip-07 message port ("message channel closed"), and the
|
|
// circuit breaker's fast-fail. Without this a brief channel drop during the
|
|
// login burst abandons the subscription list until a refresh (KNOWN_BUGS #0).
|
|
const TRANSIENT_DECRYPT_ERROR_CODES = new Set([
|
|
"subscriptions-decrypt-timeout",
|
|
"nip07-channel-unresponsive",
|
|
]);
|
|
const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [
|
|
"message channel closed",
|
|
"could not establish connection",
|
|
"receiving end does not exist",
|
|
"extension context invalidated",
|
|
"connection lost",
|
|
"channel is unresponsive",
|
|
];
|
|
|
|
export function isTransientDecryptError(error) {
|
|
if (!error) return false;
|
|
if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) return true;
|
|
const message =
|
|
typeof error.message === "string" ? error.message.toLowerCase() : "";
|
|
if (TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p))) {
|
|
return true;
|
|
}
|
|
const nested = Array.isArray(error.errors)
|
|
? error.errors
|
|
: Array.isArray(error.cause)
|
|
? error.cause
|
|
: null;
|
|
if (nested) {
|
|
return nested.some((entry) => isTransientDecryptError(entry?.error || entry));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function computeRetryDelay(baseDelayMs, attempt) {
|
|
const normalizedAttempt =
|
|
Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
|
const multiplier = 2 ** normalizedAttempt;
|
|
const nextDelay = Math.max(250, Math.floor(baseDelayMs * multiplier));
|
|
return Math.min(MAX_DECRYPT_RETRY_DELAY_MS, nextDelay);
|
|
}
|
|
|
|
export function normalizeHexPubkey(value) {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed.toLowerCase() : "";
|
|
}
|
|
|
|
export function normalizeEncryptionToken(value) {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]/g, "");
|
|
}
|
|
|
|
export function serializeSubscriptionTagMatrix(values) {
|
|
const tags = [];
|
|
const seen = new Set();
|
|
if (!values) {
|
|
return JSON.stringify(tags);
|
|
}
|
|
const iterable = Array.isArray(values)
|
|
? values
|
|
: values instanceof Set
|
|
? Array.from(values)
|
|
: [];
|
|
for (const candidate of iterable) {
|
|
const normalized = normalizeHexPubkey(candidate);
|
|
if (!normalized || seen.has(normalized)) {
|
|
continue;
|
|
}
|
|
seen.add(normalized);
|
|
tags.push(["p", normalized]);
|
|
}
|
|
return JSON.stringify(tags);
|
|
}
|
|
|
|
export function parseSubscriptionPlaintext(plaintext) {
|
|
if (typeof plaintext !== "string" || !plaintext) {
|
|
return [];
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(plaintext);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[SubscriptionsManager] Failed to parse subscription ciphertext as JSON; treating as empty.",
|
|
error,
|
|
);
|
|
return [];
|
|
}
|
|
|
|
if (Array.isArray(parsed)) {
|
|
const collected = [];
|
|
const seen = new Set();
|
|
for (const entry of parsed) {
|
|
if (!Array.isArray(entry) || entry.length < 2) {
|
|
continue;
|
|
}
|
|
const marker = typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : "";
|
|
if (marker !== "p") {
|
|
continue;
|
|
}
|
|
const normalized = normalizeHexPubkey(entry[1]);
|
|
if (!normalized || seen.has(normalized)) {
|
|
continue;
|
|
}
|
|
seen.add(normalized);
|
|
collected.push(normalized);
|
|
}
|
|
return collected;
|
|
}
|
|
|
|
if (parsed && typeof parsed === "object") {
|
|
const tagArray = Array.isArray(parsed.tags) ? parsed.tags : [];
|
|
if (tagArray.length) {
|
|
try {
|
|
return parseSubscriptionPlaintext(JSON.stringify(tagArray));
|
|
} catch {
|
|
// fall through to legacy handling
|
|
}
|
|
}
|
|
|
|
const legacy = Array.isArray(parsed.subPubkeys) ? parsed.subPubkeys : [];
|
|
return legacy.map((value) => normalizeHexPubkey(value)).filter(Boolean);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
export function parseCachedSubscriptionSnapshot(cached) {
|
|
if (Array.isArray(cached)) {
|
|
const normalized = cached.map((value) => normalizeHexPubkey(value)).filter(Boolean);
|
|
return {
|
|
subscribedPubkeys: normalized,
|
|
eventId: null,
|
|
createdAt: null,
|
|
eventPubkey: null,
|
|
hasSnapshot: true,
|
|
};
|
|
}
|
|
|
|
if (cached && typeof cached === "object") {
|
|
const listCandidate = Array.isArray(cached.subscribedPubkeys)
|
|
? cached.subscribedPubkeys
|
|
: Array.isArray(cached.subPubkeys)
|
|
? cached.subPubkeys
|
|
: [];
|
|
const normalized = listCandidate
|
|
.map((value) => normalizeHexPubkey(value))
|
|
.filter(Boolean);
|
|
const eventId = typeof cached.eventId === "string" ? cached.eventId : null;
|
|
const createdAtRaw = cached.createdAt;
|
|
const createdAtCandidate =
|
|
typeof createdAtRaw === "number"
|
|
? createdAtRaw
|
|
: typeof createdAtRaw === "string" && createdAtRaw.trim()
|
|
? Number(createdAtRaw)
|
|
: Number.NaN;
|
|
const createdAt = Number.isFinite(createdAtCandidate)
|
|
? Math.floor(createdAtCandidate)
|
|
: null;
|
|
const eventPubkey = normalizeHexPubkey(cached.eventPubkey);
|
|
const hasSnapshot =
|
|
Array.isArray(cached.subscribedPubkeys) ||
|
|
Array.isArray(cached.subPubkeys) ||
|
|
Boolean(eventId) ||
|
|
Number.isFinite(createdAtCandidate);
|
|
return {
|
|
subscribedPubkeys: normalized,
|
|
eventId,
|
|
createdAt,
|
|
eventPubkey,
|
|
hasSnapshot,
|
|
};
|
|
}
|
|
|
|
return {
|
|
subscribedPubkeys: [],
|
|
eventId: null,
|
|
createdAt: null,
|
|
eventPubkey: null,
|
|
hasSnapshot: false,
|
|
};
|
|
}
|