refactor(hashtags): extract pure helpers from hashtagPreferencesService (2/10)

File-size decomposition (no behavior change). Move the pure, state-free helpers
isTransientDecryptError, computeRetryDelay (+ their transient-error tables and
MAX_DECRYPT_RETRY_DELAY_MS) and normalizeHexPubkey out into a new
js/services/hashtagPreferencesHelpers.js and import them back. HEX64_REGEX is now
imported by the helper module instead of the service.

hashtagPreferencesService.js: 1541 -> 1480 lines (back under its 1484 limit).
Baseline left at 1434 (file fits its original allowance again; not bumped up).

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 11:09:34 -04:00
1 parent 76ba750708
commit e27c2fae39
2 files changed
+77 -66

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
// js/services/hashtagPreferencesHelpers.js
//
// Pure helpers extracted from hashtagPreferencesService.js to keep that service
// under the file-size budget. No module state — safe to test in isolation.
import { HEX64_REGEX } from "../utils/hex.js";
const MAX_DECRYPT_RETRY_DELAY_MS = 60000;
// Transient decrypt failures that must RETRY in the background 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 hashtag prefs until a refresh (KNOWN_BUGS #0).
const TRANSIENT_DECRYPT_ERROR_CODES = new Set([
"hashtag-preferences-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;
}
// decryptEvent wraps the per-scheme Promise.any failures in a generic
// "decrypt-failed" error and nests the real causes under .errors/.cause —
// recurse so a channel-death sub-error still triggers a retry.
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(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
return null;
}
+5 -66
View File
@@ -39,7 +39,11 @@ import {
computeIncrementalSinceWithOverlap,
selectNewestListEvent,
} from "../nostr/listEventOrdering.js";
import { HEX64_REGEX } from "../utils/hex.js";
import {
isTransientDecryptError,
computeRetryDelay,
normalizeHexPubkey,
} from "./hashtagPreferencesHelpers.js";
import { SHORT_TIMEOUT_MS, NETWORK_RETRY_DELAY_MS } from "../constants.js";
const LOG_PREFIX = "[HashtagPreferences]";
@@ -57,54 +61,6 @@ const DECRYPT_TIMEOUT_MS = 30000;
// PERF: Reduced from 3s to 1.5s — extensions that already granted permission
// should recover near-instantly. Shorter delay speeds up the login path.
const DECRYPT_RETRY_DELAY_MS = NETWORK_RETRY_DELAY_MS;
const MAX_DECRYPT_RETRY_DELAY_MS = 60000;
// Transient decrypt failures that must RETRY in the background 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 hashtag prefs until a refresh (KNOWN_BUGS #0).
const TRANSIENT_DECRYPT_ERROR_CODES = new Set([
"hashtag-preferences-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;
}
// decryptEvent wraps the per-scheme Promise.any failures in a generic
// "decrypt-failed" error and nests the real causes under .errors/.cause —
// recurse so a channel-death sub-error still triggers a retry.
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);
}
class TinyEventEmitter {
constructor() {
this.listeners = new Map();
@@ -153,23 +109,6 @@ const EVENTS = Object.freeze({
CHANGE: "change",
});
function normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
return null;
}
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
return "";