Files
bitvid/js/subscriptionsHelpers.js
T

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,
};
}