refactor(subscriptions): extract helpers from subscriptions (6/10)

File-size decomposition (no behavior change). Move the state-free TinyEventEmitter,
the decrypt-retry helpers (isTransientDecryptError, computeRetryDelay + tables),
the normalize helpers (normalizeHexPubkey, normalizeEncryptionToken), and the
subscription-list codec (serializeSubscriptionTagMatrix, parseSubscriptionPlaintext,
parseCachedSubscriptionSnapshot) into js/subscriptionsHelpers.js.

subscriptions.js: 2584 -> 2361 lines (below its original 2426 baseline;
re-recorded to 2361 to lock the reduction).

Build + full unit suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-19 16:22:08 -04:00
1 parent 8871c4e3a1
commit 87a29b8ff8
3 files changed
+253 -234

No files matched your search

+10 -233
View File
@@ -27,6 +27,16 @@ import { getApplication } from "./applicationContext.js";
import { VideoListView } from "./ui/views/VideoListView.js";
import { ALLOW_NSFW_CONTENT } from "./config.js";
import { devLogger, userLogger } from "./utils/logger.js";
import {
TinyEventEmitter,
isTransientDecryptError,
computeRetryDelay,
normalizeHexPubkey,
normalizeEncryptionToken,
serializeSubscriptionTagMatrix,
parseSubscriptionPlaintext,
parseCachedSubscriptionSnapshot,
} from "./subscriptionsHelpers.js";
import {
noteSignerDecryptTimeout,
noteSignerDecryptSuccess,
@@ -65,68 +75,6 @@ const DECRYPT_TIMEOUT_MS = 30000;
// should recover near-instantly. The shorter delay prevents unnecessary wait
// time during the critical login path when the first decrypt attempt fails.
const DECRYPT_RETRY_DELAY_MS = 1500;
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",
];
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;
}
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);
}
function normalizeHexPubkey(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
return trimmed ? trimmed.toLowerCase() : "";
}
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
return "";
}
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
function extractEncryptionHints(event) {
if (!event || typeof event !== "object") {
@@ -212,133 +160,6 @@ function determineDecryptionOrder(event, availableSchemes, cachedScheme = null)
return prioritized.length ? prioritized : available;
}
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);
}
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 [];
}
function parseCachedSubscriptionSnapshot(cached) {
if (Array.isArray(cached)) {
const normalized = cached.map((value) => normalizeHexPubkey(value)).filter(Boolean);
return {
subscribedPubkeys: normalized,
eventId: null,
createdAt: 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 hasSnapshot =
Array.isArray(cached.subscribedPubkeys) ||
Array.isArray(cached.subPubkeys) ||
Boolean(eventId) ||
Number.isFinite(createdAtCandidate);
return {
subscribedPubkeys: normalized,
eventId,
createdAt,
hasSnapshot,
};
}
return {
subscribedPubkeys: [],
eventId: null,
createdAt: null,
hasSnapshot: false,
};
}
const getApp = () => getApplication();
@@ -347,50 +168,6 @@ const listVideoViewEventsApi = (pointer, options) =>
const subscribeVideoViewEventsApi = (pointer, options) =>
subscribeVideoViewEvents(nostrClient, pointer, options);
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,
);
}
}
}
}
/**
* Manages the user's subscription list (kind=30000 follow set) *privately*,
* using encrypted NIP-51 tag arrays (prefer NIP-44 v2) for the content field.
+242
View File
@@ -0,0 +1,242 @@
// 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,
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 hasSnapshot =
Array.isArray(cached.subscribedPubkeys) ||
Array.isArray(cached.subPubkeys) ||
Boolean(eventId) ||
Number.isFinite(createdAtCandidate);
return {
subscribedPubkeys: normalized,
eventId,
createdAt,
hasSnapshot,
};
}
return {
subscribedPubkeys: [],
eventId: null,
createdAt: null,
hasSnapshot: false,
};
}
+1 -1
View File
@@ -36,7 +36,7 @@ const GRANDFATHERED = {
"js/ui/loginModalController.js": 2552,
"js/historyView.js": 2613,
"js/services/moderationService.js": 2512,
"js/subscriptions.js": 2426,
"js/subscriptions.js": 2361,
"js/userBlocks.js": 2428,
"js/services/nostrService.js": 2227,
"js/nostr/watchHistory.js": 2388,