mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
perf: speed up post-login list sync with shared decryption cache
Three performance bottlenecks were causing slow list loading after NIP-07 login: 1. Each list service (blocks, subscriptions, hashtag preferences) maintained its own isolated decryptionSuccessCache. When blocks decrypted with nip44_v2, subscriptions and hashtag preferences had no way to know — they each had to independently probe all schemes through the NIP-07 extension queue, tripling the number of extension calls during the critical login path. 2. List loading was gated on both relaysReadyPromise AND permissionPromise in the coordinator. Since the NIP-07 extension already grants decrypt permissions during loginWithExtension(), the redundant pre-grant was adding 2-5 seconds of unnecessary latency before any list fetching could begin. 3. The DECRYPT_RETRY_DELAY_MS was 10 seconds across all services. When a decrypt attempt failed transiently (extension still initializing), the retry waited far too long before trying again. Changes: - Create shared decryptionSchemeCache module used by all three list services - Add fast-path: when shared cache knows the correct scheme, try ONLY that scheme before falling back to parallel Promise.any() probe - Remove permissionPromise from the list-loading gate in authSessionCoordinator - Reduce DECRYPT_RETRY_DELAY_MS from 10s to 3s in all three services - Clear shared cache on logout https://claude.ai/code/session_013QrGMnxtMTsGTPqaP35jMj
This commit is contained in:
5 files changed
+187
-68
No files matched your search
@@ -9,6 +9,8 @@
|
||||
* Methods use `this` which is bound to the Application instance.
|
||||
*/
|
||||
|
||||
import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js";
|
||||
|
||||
/**
|
||||
* @param {object} deps - Injected dependencies.
|
||||
* @returns {object} Methods to be bound to the Application instance.
|
||||
@@ -218,10 +220,14 @@ export function createAuthSessionCoordinator(deps) {
|
||||
// parallel. They have no data dependencies on each other — only the feed
|
||||
// render needs all three to be settled. Loading them concurrently shaves
|
||||
// 20-30 seconds off the critical login path.
|
||||
const listStatePromise = Promise.all([
|
||||
relaysReadyPromise,
|
||||
permissionPromise,
|
||||
]).then(async () => {
|
||||
//
|
||||
// PERF: Lists no longer wait for the permission pre-grant to complete.
|
||||
// The NIP-07 extension already granted decrypt permissions during the
|
||||
// login flow (ensureExtensionPermissions is called in loginWithExtension),
|
||||
// so the signer adapter's decrypt functions work immediately. Waiting for
|
||||
// the redundant pre-grant was adding 2-5 seconds of unnecessary latency
|
||||
// before list loading could even begin.
|
||||
const listStatePromise = relaysReadyPromise.then(async () => {
|
||||
const parallelListTasks = [];
|
||||
|
||||
if (
|
||||
@@ -535,6 +541,7 @@ export function createAuthSessionCoordinator(deps) {
|
||||
|
||||
this.resetHashtagPreferencesState();
|
||||
this.resetPermissionPromptState();
|
||||
clearDecryptionSchemeCache();
|
||||
this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" });
|
||||
|
||||
await this.nwcSettingsService.onLogout({
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// js/nostr/decryptionSchemeCache.js
|
||||
|
||||
/**
|
||||
* Shared decryption scheme cache across all list services (blocks,
|
||||
* subscriptions, hashtag preferences). When one service discovers the
|
||||
* correct decryption scheme for a pubkey, the others can reuse it
|
||||
* immediately instead of independently probing the NIP-07 extension.
|
||||
*
|
||||
* This eliminates redundant extension decrypt calls during the critical
|
||||
* login path where all three services decrypt simultaneously.
|
||||
*/
|
||||
|
||||
const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/** @type {Map<string, { scheme: string, timestamp: number }>} */
|
||||
const cache = new Map();
|
||||
|
||||
/**
|
||||
* Retrieve the last successfully-used decryption scheme for a pubkey.
|
||||
* Returns null if the entry is missing or expired.
|
||||
*
|
||||
* @param {string} pubkey - Hex pubkey to look up.
|
||||
* @returns {string|null} The scheme identifier (e.g. "nip44_v2") or null.
|
||||
*/
|
||||
export function getLastSuccessfulScheme(pubkey) {
|
||||
if (typeof pubkey !== "string" || !pubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = cache.get(pubkey);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - entry.timestamp > DECRYPTION_CACHE_TTL_MS) {
|
||||
cache.delete(pubkey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.scheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful decryption scheme for a pubkey so other services
|
||||
* can skip scheme discovery.
|
||||
*
|
||||
* @param {string} pubkey - Hex pubkey.
|
||||
* @param {string} scheme - The scheme that succeeded (e.g. "nip44_v2").
|
||||
*/
|
||||
export function setLastSuccessfulScheme(pubkey, scheme) {
|
||||
if (typeof pubkey !== "string" || !pubkey) {
|
||||
return;
|
||||
}
|
||||
if (typeof scheme !== "string" || !scheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
cache.set(pubkey, { scheme, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached entries. Useful on logout.
|
||||
*/
|
||||
export function clearDecryptionSchemeCache() {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -26,16 +26,18 @@ import {
|
||||
NIP07_PRIORITY,
|
||||
} from "../nostr/nip07Permissions.js";
|
||||
import { relaySubscriptionService } from "./relaySubscriptionService.js";
|
||||
import {
|
||||
getLastSuccessfulScheme,
|
||||
setLastSuccessfulScheme,
|
||||
} from "../nostr/decryptionSchemeCache.js";
|
||||
|
||||
const LOG_PREFIX = "[HashtagPreferences]";
|
||||
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const DEFAULT_VERSION = 1;
|
||||
const DECRYPT_TIMEOUT_MS = 20000;
|
||||
const DECRYPT_RETRY_DELAY_MS = 10000;
|
||||
const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const decryptionSuccessCache = new Map();
|
||||
// PERF: Reduced from 10s to 3s for faster recovery during login.
|
||||
const DECRYPT_RETRY_DELAY_MS = 3000;
|
||||
|
||||
class TinyEventEmitter {
|
||||
constructor() {
|
||||
@@ -1076,17 +1078,32 @@ class HashtagPreferencesService {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const cachedEntry = decryptionSuccessCache.get(userPubkey);
|
||||
const cachedScheme =
|
||||
cachedEntry && Date.now() - cachedEntry.timestamp < DECRYPTION_CACHE_TTL_MS
|
||||
? cachedEntry.scheme
|
||||
: null;
|
||||
// Use the shared cross-service cache so all list services benefit from
|
||||
// the first successful decrypt during login.
|
||||
const cachedScheme = getLastSuccessfulScheme(userPubkey);
|
||||
|
||||
const order = determineDecryptionOrder(
|
||||
event,
|
||||
Array.from(decryptors.keys()),
|
||||
cachedScheme,
|
||||
);
|
||||
|
||||
// PERF: Fast path — if the shared cache already knows the correct scheme
|
||||
// (e.g. blocks or subscriptions decrypted first), try ONLY that scheme
|
||||
// before falling back to the full parallel probe. This avoids queuing
|
||||
// redundant NIP-07 extension calls.
|
||||
if (cachedScheme && decryptors.has(cachedScheme)) {
|
||||
try {
|
||||
const plaintext = await decryptors.get(cachedScheme)(ciphertext);
|
||||
if (typeof plaintext === "string") {
|
||||
setLastSuccessfulScheme(userPubkey, cachedScheme);
|
||||
return { ok: true, plaintext, scheme: cachedScheme };
|
||||
}
|
||||
} catch (_fastPathError) {
|
||||
// Cached scheme failed — fall through to parallel probe.
|
||||
}
|
||||
}
|
||||
|
||||
// PERF: Try all decryption schemes in parallel via Promise.any().
|
||||
// The first scheme to succeed wins, avoiding sequential 3-15s timeouts
|
||||
// per scheme that previously made worst-case decryption take 45s+.
|
||||
@@ -1116,10 +1133,7 @@ class HashtagPreferencesService {
|
||||
if (attempts.length) {
|
||||
try {
|
||||
const result = await Promise.any(attempts);
|
||||
decryptionSuccessCache.set(userPubkey, {
|
||||
scheme: result.scheme,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLastSuccessfulScheme(userPubkey, result.scheme);
|
||||
return { ok: true, plaintext: result.plaintext, scheme: result.scheme };
|
||||
} catch (aggregateError) {
|
||||
const attemptErrors = (aggregateError.errors || []).map((err, i) => ({
|
||||
|
||||
+29
-13
@@ -37,14 +37,19 @@ import {
|
||||
NIP07_PRIORITY,
|
||||
} from "./nostr/nip07Permissions.js";
|
||||
import { relaySubscriptionService } from "./services/relaySubscriptionService.js";
|
||||
import {
|
||||
getLastSuccessfulScheme,
|
||||
setLastSuccessfulScheme,
|
||||
} from "./nostr/decryptionSchemeCache.js";
|
||||
|
||||
const SUBSCRIPTION_SET_KIND =
|
||||
getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000;
|
||||
const DECRYPT_TIMEOUT_MS = 20000;
|
||||
const DECRYPT_RETRY_DELAY_MS = 10000;
|
||||
const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const decryptionSuccessCache = new Map();
|
||||
// PERF: Reduced from 10s to 3s — extensions that already granted permission
|
||||
// should recover quickly. The longer delay was causing unnecessary wait time
|
||||
// during the critical login path when the first decrypt attempt fails due to
|
||||
// the extension still initializing.
|
||||
const DECRYPT_RETRY_DELAY_MS = 3000;
|
||||
|
||||
function normalizeHexPubkey(value) {
|
||||
if (typeof value !== "string") {
|
||||
@@ -1097,17 +1102,31 @@ class SubscriptionsManager {
|
||||
}
|
||||
|
||||
const availableSchemes = Array.from(decryptors.keys());
|
||||
const cachedEntry = decryptionSuccessCache.get(userPubkey);
|
||||
const cachedScheme =
|
||||
cachedEntry && Date.now() - cachedEntry.timestamp < DECRYPTION_CACHE_TTL_MS
|
||||
? cachedEntry.scheme
|
||||
: null;
|
||||
// Use the shared cross-service cache so all list services benefit from
|
||||
// the first successful decrypt during login.
|
||||
const cachedScheme = getLastSuccessfulScheme(userPubkey);
|
||||
|
||||
const order = determineDecryptionOrder(
|
||||
event,
|
||||
availableSchemes,
|
||||
cachedScheme,
|
||||
);
|
||||
|
||||
// PERF: Fast path — if the shared cache already knows the correct scheme
|
||||
// (e.g. blocks decrypted first), try ONLY that scheme before falling back
|
||||
// to the full parallel probe. This avoids queuing redundant extension calls.
|
||||
if (cachedScheme && decryptors.has(cachedScheme)) {
|
||||
try {
|
||||
const plaintext = await decryptors.get(cachedScheme)(ciphertext);
|
||||
if (typeof plaintext === "string") {
|
||||
setLastSuccessfulScheme(userPubkey, cachedScheme);
|
||||
return { ok: true, plaintext, scheme: cachedScheme };
|
||||
}
|
||||
} catch (_fastPathError) {
|
||||
// Cached scheme failed — fall through to parallel probe.
|
||||
}
|
||||
}
|
||||
|
||||
// PERF: Try all decryption schemes in parallel via Promise.any().
|
||||
// The first scheme to succeed wins, avoiding sequential 3-15s timeouts
|
||||
// per scheme that previously made worst-case decryption take 45s+.
|
||||
@@ -1137,10 +1156,7 @@ class SubscriptionsManager {
|
||||
if (attempts.length) {
|
||||
try {
|
||||
const result = await Promise.any(attempts);
|
||||
decryptionSuccessCache.set(userPubkey, {
|
||||
scheme: result.scheme,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLastSuccessfulScheme(userPubkey, result.scheme);
|
||||
return { ok: true, plaintext: result.plaintext, scheme: result.scheme };
|
||||
} catch (aggregateError) {
|
||||
const attemptErrors = (aggregateError.errors || []).map((err, i) => ({
|
||||
|
||||
+54
-38
@@ -26,6 +26,10 @@ import {
|
||||
NIP07_PRIORITY,
|
||||
} from "./nostr/nip07Permissions.js";
|
||||
import { relaySubscriptionService } from "./services/relaySubscriptionService.js";
|
||||
import {
|
||||
getLastSuccessfulScheme,
|
||||
setLastSuccessfulScheme,
|
||||
} from "./nostr/decryptionSchemeCache.js";
|
||||
|
||||
class TinyEventEmitter {
|
||||
constructor() {
|
||||
@@ -81,11 +85,9 @@ const FAST_BLOCKLIST_TIMEOUT_MS = 2500;
|
||||
const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000;
|
||||
const DECRYPT_TIMEOUT_MS = 20000;
|
||||
const BACKGROUND_DECRYPT_TIMEOUT_MS = 15000;
|
||||
const DECRYPT_RETRY_DELAY_MS = 10000;
|
||||
// PERF: Reduced from 10s to 3s for faster recovery during login.
|
||||
const DECRYPT_RETRY_DELAY_MS = 3000;
|
||||
const MAX_BLOCKLIST_ENTRIES = 5000;
|
||||
const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const decryptionSuccessCache = new Map();
|
||||
|
||||
function sanitizeRelayList(candidate) {
|
||||
return Array.isArray(candidate)
|
||||
@@ -1159,11 +1161,9 @@ class UserBlockListManager {
|
||||
permissionError.signerStatus = signerStatus;
|
||||
}
|
||||
|
||||
const cachedEntry = decryptionSuccessCache.get(normalized);
|
||||
const cachedScheme =
|
||||
cachedEntry && Date.now() - cachedEntry.timestamp < DECRYPTION_CACHE_TTL_MS
|
||||
? cachedEntry.scheme
|
||||
: null;
|
||||
// Use the shared cross-service cache so all list services benefit from
|
||||
// the first successful decrypt during login.
|
||||
const cachedScheme = getLastSuccessfulScheme(normalized);
|
||||
|
||||
return {
|
||||
decryptors,
|
||||
@@ -1306,41 +1306,57 @@ class UserBlockListManager {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// PERF: Fast path — if the shared cache already knows the correct
|
||||
// scheme (e.g. another list decrypted first), try ONLY that scheme
|
||||
// before falling back to the full parallel probe. This avoids
|
||||
// queuing redundant NIP-07 extension calls.
|
||||
let decryptedText = "";
|
||||
const sharedCachedScheme = getLastSuccessfulScheme(normalized);
|
||||
if (sharedCachedScheme && decryptors.has(sharedCachedScheme)) {
|
||||
try {
|
||||
const fastResult = await decryptors.get(sharedCachedScheme)(ev.content);
|
||||
if (typeof fastResult === "string" && fastResult) {
|
||||
decryptedText = fastResult;
|
||||
setLastSuccessfulScheme(normalized, sharedCachedScheme);
|
||||
}
|
||||
} catch {
|
||||
// Cached scheme failed — fall through to parallel probe.
|
||||
}
|
||||
}
|
||||
|
||||
// PERF: Try all decryption schemes in parallel via Promise.any().
|
||||
// The first scheme to succeed wins, avoiding sequential 3-15s timeouts
|
||||
// per scheme that previously made worst-case decryption take 45s+.
|
||||
// Deduplicate schemes in the same family (nip44/nip44_v2 both call
|
||||
// the same underlying signer.nip44Decrypt) to avoid redundant calls.
|
||||
let decryptedText = "";
|
||||
try {
|
||||
const getSchemeFamily = (s) =>
|
||||
s === "nip44" || s === "nip44_v2" ? "nip44" : s;
|
||||
const seenFamilies = new Set();
|
||||
const attempts = order
|
||||
.map((scheme) => {
|
||||
const family = getSchemeFamily(scheme);
|
||||
if (seenFamilies.has(family)) return null;
|
||||
seenFamilies.add(family);
|
||||
const handler = decryptors.get(scheme);
|
||||
if (typeof handler !== "function") return null;
|
||||
return handler(ev.content).then((plaintext) => {
|
||||
if (typeof plaintext !== "string") {
|
||||
throw new Error("non-string-result");
|
||||
}
|
||||
return { plaintext, scheme };
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (attempts.length) {
|
||||
const result = await Promise.any(attempts);
|
||||
decryptedText = result.plaintext;
|
||||
decryptionSuccessCache.set(normalized, {
|
||||
scheme: result.scheme,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (!decryptedText) {
|
||||
try {
|
||||
const getSchemeFamily = (s) =>
|
||||
s === "nip44" || s === "nip44_v2" ? "nip44" : s;
|
||||
const seenFamilies = new Set();
|
||||
const attempts = order
|
||||
.map((scheme) => {
|
||||
const family = getSchemeFamily(scheme);
|
||||
if (seenFamilies.has(family)) return null;
|
||||
seenFamilies.add(family);
|
||||
const handler = decryptors.get(scheme);
|
||||
if (typeof handler !== "function") return null;
|
||||
return handler(ev.content).then((plaintext) => {
|
||||
if (typeof plaintext !== "string") {
|
||||
throw new Error("non-string-result");
|
||||
}
|
||||
return { plaintext, scheme };
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (attempts.length) {
|
||||
const result = await Promise.any(attempts);
|
||||
decryptedText = result.plaintext;
|
||||
setLastSuccessfulScheme(normalized, result.scheme);
|
||||
}
|
||||
} catch {
|
||||
// All schemes failed — decryptedText stays empty
|
||||
}
|
||||
} catch {
|
||||
// All schemes failed — decryptedText stays empty
|
||||
}
|
||||
|
||||
if (!decryptedText) {
|
||||
|
||||
Reference in new issue
Block a user